Compare commits

..

21 Commits

Author SHA1 Message Date
misha
45d072cff4 feat(insights): first isights impemetation 2026-11-09 15:35:10 +03:00
67920b2016 Merge pull request 'fix(tf-ci)' (#1134) from apoloz/eatvio_app:feat/sbp-qr-payment into main
Reviewed-on: eatvio/eatvio_app#1134
2026-11-06 10:47:12 +00:00
Anatoliy Poloz
fc28062bb5 fix(tf-ci) 2026-11-06 13:16:36 +03:00
f947182632 Merge pull request 'fix(ci): build number' (#1133) from apoloz/eatvio_app:feat/sbp-qr-payment into main
Reviewed-on: eatvio/eatvio_app#1133
2026-11-06 09:50:23 +00:00
Anatoliy Poloz
27b8128a19 fix(ci): build number 2026-11-06 12:26:36 +03:00
924dc2c16a Merge pull request 'feat(sbp_payments): QR-код оплаты СБП с другого устройства' (#1120) from apoloz/eatvio_app:feat/sbp-qr-payment into main
Reviewed-on: eatvio/eatvio_app#1120
Reviewed-by: misha <misha@noreply.localhost>
2026-11-06 08:41:34 +00:00
53ce73b41e Merge pull request 'fix(ios): derive TestFlight build number from App Store Connect' (#1105) from apoloz/eatvio_app:fix/testflight-build-number into main
Reviewed-on: eatvio/eatvio_app#1105
Reviewed-by: misha <misha@noreply.localhost>
2026-11-02 11:16:36 +00:00
Anatoliy Poloz
1829ad611d feat(sbp_payments): QR-код оплаты СБП с другого устройства
- Новый SbpQrBottomSheet: рендер checkout_url (qr.nspk.ru/{qrcId}) через qr_flutter,
  сумма, инструкция, автоопределение оплаты polling-ом статуса chosen_plan (3 сек)
  и автозакрытие с успехом
- Кнопка «Оплатить по QR-коду» в шите выбора банков — первичная, под заголовком,
  доступна сразу после получения checkout_url, не дожидаясь списка банков
  (важно для устройств без банковских приложений, iOS в РФ)
- Блок «Симуляция оплаты» (testpay.alfabank.ru/sbp-page?qrcId=...) показывается
  только когда base URL API не продуктовый: AppConfig.isProdApi/prodApiHosts
  (хост целиком — стенды-поддомены eatvio.ru продом не считаются)
- showBottomSheetWithBlur стал генериком — возвращает значение pop(result)
- showConsultationPaymentFlow: опциональная прокидка суммы в QR-шит
- Локализация ru/en, тесты (16 новых, всего 34 платёжных зелёные)
2026-11-02 14:03:56 +03:00
2b2991012d merge upstream 2026-11-02 10:27:35 +00:00
7a456c2d9d Merge pull request 'Фикс перекрытия кнопки регистрации консультанта' (#1111) from alex_chevski/eatvio_app:fix/1107-consultant-bottom-button-inset into main
Reviewed-on: eatvio/eatvio_app#1111
Reviewed-by: misha <misha@noreply.localhost>
Reviewed-by: Анатолий Полоз <t0ly.od@ya.ru>
2026-11-02 10:27:25 +00:00
36079de81c merge upstream 2026-11-02 10:00:33 +00:00
3825a974f9 Merge pull request 'открытие чата из консультации' (#1110) from alex_chevski/eatvio_app:fix/1106-consultation-chat into main
Reviewed-on: eatvio/eatvio_app#1110
Reviewed-by: misha <misha@noreply.localhost>
2026-11-02 10:00:24 +00:00
678d73932c merge upstream 2026-11-02 09:48:20 +00:00
8942e11be9 Merge pull request 'fix: android ci runner changed' (#1118) from misha/eatvio_app:android-ci-fix into main
Reviewed-on: eatvio/eatvio_app#1118
2026-11-02 09:48:08 +00:00
2668907f60 merge upstream 2026-11-02 08:14:53 +00:00
56ab9b65f9 merge upstream 2026-11-02 08:14:46 +00:00
85bc2c6d5c test(consultant): verify bottom action stays above system navigation area 2026-10-30 13:31:07 +03:00
f58892bdeb fix(consultant): keep registration button above system navigation bar 2026-10-30 13:31:05 +03:00
f14f426381 fix(consultations): open chat via consultation chat_id
Use chat_id from the consultations payload for direct navigation and keep
the chat lookup as a fallback for legacy rows without chat_id.

- validate chat id in ChatRepository and make it nullable
- guard repeated taps while opening chat
- handle nullable chat id in ProfileCubit and EmptyChatScreen

related to #1106
2026-10-29 18:09:08 +03:00
6a31880a85 fix(consultations): open chat from consultation card
The chat icon on the consultant consultation card had an empty onTap handler.
Resolve the conversation with the client via the chat API and navigate to it.

related to #1106
2026-10-29 17:27:12 +03:00
Anatoliy
f6f9764811 fix(ios): derive TestFlight build number from App Store Connect
The beta lane took the build number from pubspec (FLUTTER_BUILD_NUMBER).
Re-running CI on the same commit re-submitted an already-taken build
number and failed with 409 ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE
(run 2045: build 157 was already on TestFlight).

Now the lane queries the latest TestFlight build for the current
marketing version (falling back to the pubspec value when nothing was
uploaded yet) and uploads latest + 1, so retries can never collide.
2026-10-28 11:10:22 +03:00
87 changed files with 7739 additions and 1544 deletions

4
.gitignore vendored
View File

@@ -16,6 +16,8 @@
*.mocks.dart
android/build/
ios/XCBuildData/
docs/
specs/
env/.env
.env
android/key.properties
@@ -25,4 +27,4 @@ backend/
.fvm/
.fvmrc
/eatvio
*.code-workspace
*.code-workspace

View File

@@ -43,37 +43,95 @@ platform :ios do
end
UI.message "Using auth_key_path: #{auth_key_path}"
# Authenticate to App Store Connect early:
# needed here by latest_testflight_build_number, not only by upload_to_testflight.
app_store_connect_api_key(
key_id: "325JS9JCZH",
issuer_id: "dfa27d1d-2bb8-4d4d-847e-0b32f9b880b3",
key_filepath: auth_key_path,
duration: 1200,
in_house: false
)
# Configure Flutter for release mode
UI.message "Configuring Flutter for release mode..."
sh("cd .. && flutter build ios --config-only --release")
# Read build number from Flutter Generated.xcconfig
# Read version and build number from Flutter Generated.xcconfig.
# They are only a floor: the actual build number is derived from TestFlight below,
# so repeated CI runs on the same commit can never hit
# ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE ("bundle version must be higher").
generated_xcconfig_path = File.join("..", "Flutter", "Generated.xcconfig")
flutter_build_name = nil
flutter_build_number = nil
if File.exist?(generated_xcconfig_path)
File.foreach(generated_xcconfig_path) do |line|
matches = line.match(/FLUTTER_BUILD_NUMBER=(.*)/)
if matches
flutter_build_number = matches[1].strip
break
if (m = line.match(/FLUTTER_BUILD_NAME=(.*)/))
flutter_build_name = m[1].strip
elsif (m = line.match(/FLUTTER_BUILD_NUMBER=(.*)/))
flutter_build_number = m[1].strip
end
end
end
if flutter_build_number.nil?
UI.user_error!("Could not find FLUTTER_BUILD_NUMBER in #{generated_xcconfig_path}")
if flutter_build_name.nil? || flutter_build_number.nil?
UI.user_error!("Could not find FLUTTER_BUILD_NAME/FLUTTER_BUILD_NUMBER in #{generated_xcconfig_path}")
end
UI.message "Flutter build number: #{flutter_build_number}"
UI.message "Flutter version from pubspec: #{flutter_build_name} (#{flutter_build_number})"
# Apple closes a version train once that version is approved/released.
# Uploading into a closed train wastes a full ~8 min build and fails only
# at the upload step with altool 90186 ("closed for new build submissions")
# / 90062 ("must be higher than the previously approved version").
# Fail fast here, before building.
closed_train_states = [
Spaceship::ConnectAPI::AppStoreVersion::AppVersionState::READY_FOR_DISTRIBUTION,
Spaceship::ConnectAPI::AppStoreVersion::AppVersionState::PROCESSING_FOR_DISTRIBUTION,
Spaceship::ConnectAPI::AppStoreVersion::AppVersionState::PENDING_DEVELOPER_RELEASE,
Spaceship::ConnectAPI::AppStoreVersion::AppVersionState::PENDING_APPLE_RELEASE
].join(",")
asc_app = Spaceship::ConnectAPI::App.find("com.eatvioapp.app")
closed_train = nil
if asc_app.nil?
UI.important("App com.eatvioapp.app not found in App Store Connect, skipping closed-train guard")
else
closed_train = asc_app.get_app_store_versions(
filter: {
appVersionState: closed_train_states,
platform: Spaceship::ConnectAPI::Platform::IOS
}
).find do |v|
(Gem::Version.new(v.version_string) >= Gem::Version.new(flutter_build_name))
rescue ArgumentError
false
end
end
if closed_train
UI.user_error!(
"Version train #{closed_train.version_string} is closed for new submissions " \
"(#{closed_train.app_version_state}), pubspec version is #{flutter_build_name}. " \
"Bump the version in pubspec.yaml above #{closed_train.version_string} and re-run."
)
end
asc_latest_build_number = latest_testflight_build_number(
app_identifier: "com.eatvioapp.app",
version: flutter_build_name,
initial_build_number: flutter_build_number.to_i
)
new_build_number = [flutter_build_number.to_i, asc_latest_build_number].max + 1
UI.message "Latest TestFlight build number: #{asc_latest_build_number} -> uploading build #{new_build_number}"
match(type: "appstore", readonly: true)
# Set build number explicitly instead of incrementing
increment_build_number(
xcodeproj: "Runner.xcodeproj",
build_number: flutter_build_number
build_number: new_build_number
)
update_code_signing_settings(
@@ -93,28 +151,33 @@ platform :ios do
export_team_id: "VL68C36HYS"
)
app_store_connect_api_key(
key_id: "325JS9JCZH",
issuer_id: "dfa27d1d-2bb8-4d4d-847e-0b32f9b880b3",
key_filepath: auth_key_path,
duration: 1200,
in_house: false
)
begin
upload_to_testflight(
skip_waiting_for_build_processing: true,
skip_submission: true,
distribute_external: false,
notify_external_testers: false,
changelog: "TestFlight build #{flutter_build_number}",
changelog: "TestFlight build #{new_build_number}",
verbose: true
)
rescue => ex
UI.error "Upload to TestFlight failed: #{ex.message}"
UI.error "Full error: #{ex.backtrace.join("\n")}"
raise ex
# Xcode 26 altool prints a harmless "ERROR: ... CDUploaderStateInfoResumeFilePath"
# line even when the package was delivered fine (UPLOAD SUCCEEDED appears in the
# same output). fastlane < 2.240.0 treats every ERROR line as a failure
# (fastlane#30130), so the lane would die after a successful upload.
# Real Apple rejections (90186 closed train, 90062 version too low, ...) always
# contain UPLOAD FAILED and/or iris-code - those must still fail the lane.
if ex.message.include?("CDUploaderStateInfoResumeFilePath") &&
!ex.message.include?("UPLOAD FAILED") &&
!ex.message.include?("iris-code")
UI.important("altool delivered the package but reported a bogus resume-file ERROR " \
"(fastlane#30130). Treating upload as successful - " \
"verify the build appears in TestFlight.")
else
UI.error "Upload to TestFlight failed: #{ex.message}"
UI.error "Full error: #{ex.backtrace.join("\n")}"
raise ex
end
end
end
end

View File

@@ -33,6 +33,20 @@ class AppConfig {
late String _baseUrl;
String get baseUrl => _baseUrl;
/// Продуктовые хосты API. Сравнение по хосту целиком: поддомены
/// (например `*-hasan.eatvio.ru`) — это стенды разработки, не прод.
static const List<String> prodApiHosts = ['eatvio.ru', 'eatvio.com'];
/// Текущий base URL указывает на продуктовый API?
bool get isProdApi {
final host = Uri.tryParse(baseUrl)?.host.toLowerCase() ?? '';
if (host.isEmpty) return false;
return prodApiHosts.any((prodHost) => host == prodHost || host == 'www.$prodHost');
}
/// Работаем на стенде разработки/stage (не на продуктовом API)?
bool get isDevOrStageStand => !isProdApi;
void setBaseUrl(String baseUrls) {
_baseUrl = baseUrls;
_persistBaseUrl(baseUrls);

View File

@@ -277,7 +277,7 @@ class CubitRegistrations {
),
);
sl.registerFactory<ConsultationsCubit>(
() => ConsultationsCubit(consultationsRepository: sl()),
() => ConsultationsCubit(consultationsRepository: sl(), chatRepository: sl()),
);
sl.registerFactory<ConsultantCommentsCubit>(
() => ConsultantCommentsCubit(diaryRepository: sl()),

View File

@@ -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()));

View File

@@ -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(

View File

@@ -3,7 +3,11 @@ import 'dart:ui';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:flutter/material.dart';
Future<void> showBottomSheetWithBlur({
/// Показывает modal bottom sheet с blur-фоном.
///
/// Дженерик [T] пробрасывает значение, переданное в `Navigator.pop(T)`
/// внутри шита (по умолчанию `null`).
Future<T?> showBottomSheetWithBlur<T>({
required BuildContext context,
required Widget child,
double? height = 200,
@@ -11,7 +15,7 @@ Future<void> showBottomSheetWithBlur({
bool enableDrag = true,
bool userRootNavigator = false,
}) {
return showModalBottomSheet(
return showModalBottomSheet<T>(
context: context,
isScrollControlled: true,
barrierColor: appTheme.transparentBlack,

View File

@@ -135,9 +135,9 @@ class _EmptyChatScreenState extends State<EmptyChatScreen> {
textEditingController.clear();
});
}
final chatIdOrError = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
if (context.mounted) {
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatIdOrError});
final chatId = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
if (chatId != null && chatId.isNotEmpty && context.mounted) {
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatId});
}
}
},
@@ -189,9 +189,9 @@ class _EmptyChatScreenState extends State<EmptyChatScreen> {
if (images.isNotEmpty) {
List<File> files = images.map((image) => File(image.path)).toList();
await sl<ChatRepository>().sendMessage(files: files, slug: widget.user.slug);
final chatIdOrError = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
if (mounted) {
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatIdOrError});
final chatId = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
if (chatId != null && chatId.isNotEmpty && mounted) {
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatId});
}
}
},
@@ -205,9 +205,9 @@ class _EmptyChatScreenState extends State<EmptyChatScreen> {
if (images.isNotEmpty) {
List<File> files = images.map((image) => File(image.path)).toList();
await sl<ChatRepository>().sendMessage(files: files, slug: widget.user.slug);
final chatIdOrError = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
if (mounted) {
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatIdOrError});
final chatId = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
if (chatId != null && chatId.isNotEmpty && mounted) {
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatId});
}
}
},
@@ -243,9 +243,9 @@ class _EmptyChatScreenState extends State<EmptyChatScreen> {
if (result != null) {
List<File> files = result.paths.map((path) => File(path!)).toList();
await sl<ChatRepository>().sendMessage(files: files, slug: widget.user.slug);
final chatIdOrError = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
if (mounted) {
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatIdOrError});
final chatId = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
if (chatId != null && chatId.isNotEmpty && mounted) {
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatId});
}
}
},

View File

@@ -106,7 +106,11 @@ class _NotificationBannerState extends State<NotificationBanner> {
onTap: () {
final planId = (context.read<ChatRoomCubit>().state as ChatRoomStateLoaded).chatRoom.chosenPlan?.id;
if (planId != null) {
showConsultationPaymentFlow(context, planId: planId);
showConsultationPaymentFlow(
context,
planId: planId,
amount: widget.planPrice,
);
}
},
child: Text(s.pay, style: TextStyles.textLSemibold.copyWith(color: appTheme.blueA400)),

View File

@@ -137,11 +137,12 @@ class ChatRepository {
}
Future<String> getChatByUserSlug(String slug) async {
Future<String?> getChatByUserSlug(String slug) async {
final response = await chatService.getChatByUserSlug(slug: slug);
if (response.response.statusCode == 404) {
return '404';
final data = response.data;
if (data is Map && data['id'] is String && (data['id'] as String).isNotEmpty) {
return data['id'] as String;
}
return response.data['id'];
return null;
}
}

View File

@@ -14,6 +14,8 @@ class BecomeConsultantScreen extends StatefulWidget {
class _BecomeConsultantScreenState extends State<BecomeConsultantScreen> {
@override
Widget build(BuildContext context) {
final bottomInset = MediaQuery.paddingOf(context).bottom;
return Scaffold(
appBar: AppBar(
title: const Text('Стать консультантом'),
@@ -24,14 +26,18 @@ class _BecomeConsultantScreenState extends State<BecomeConsultantScreen> {
icon: const Icon(Icons.close),
),
),
body: const Padding(
padding: EdgeInsets.all(16),
child: Text(
body: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: EdgeInsets.fromLTRB(16, 16, 16, 16 + 52 + 8 + bottomInset),
child: const Text(
'Чтобы стать консультантом необходимо пройти короткую регистрацию. Подготовьте свои дипломы и/или сертификаты в формате jpg, они Вам пригодятся для проверки и подтверждения. Также необходимо будет указать ссылки на Ваши соц. сети и указать тип лица (физ. лицо или юр. лицо). На данный момент доступна регистрация в качестве физ. лица.\n\nВнутри Итвио разница между обычным пользователем и консультантом заключается в дополнительных возможностях последнего создавать публичные диеты, рецепты и планы питания; участвовать в ежемесячных рейтингах.',
style: TextStyle(fontSize: 16),
),
),
bottomSheet: _startRegistrationButton(),
bottomSheet: Padding(
padding: EdgeInsets.fromLTRB(16, 0, 16, 8 + bottomInset),
child: _startRegistrationButton(),
),
);
}
@@ -44,7 +50,7 @@ class _BecomeConsultantScreenState extends State<BecomeConsultantScreen> {
borderRadius: BorderRadius.circular(20),
child: Ink(
height: 52,
width: MediaQuery.of(context).size.width - 32,
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: appTheme.blueA400,

View File

@@ -9,6 +9,7 @@ part 'consultation_model.g.dart';
abstract class ConsultationModel with _$ConsultationModel {
factory ConsultationModel({
required int id,
@JsonKey(name: 'chat_id') String? chatId,
@JsonKey(name: 'last_state') required ChosenPlanStateEnum chosenPlanState,
@JsonKey(name: 'plan_title') required String planTitle,
@JsonKey(name: 'user_name') required String userName,

View File

@@ -1,6 +1,7 @@
import 'dart:developer';
import 'package:eatvio_app/core/enums/base_state.dart';
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
import 'package:eatvio_app/features/consultations/presentation/cubit/consultations_state.dart';
import 'package:eatvio_app/features/consultations/repositories/consultations_repository.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -8,7 +9,8 @@ import 'package:eatvio_app/core/error_reporting/report_from.dart';
class ConsultationsCubit extends Cubit<ConsultationsState> {
final ConsultationsRepository consultationsRepository;
ConsultationsCubit({required this.consultationsRepository}) : super(ConsultationsState());
final ChatRepository chatRepository;
ConsultationsCubit({required this.consultationsRepository, required this.chatRepository}) : super(ConsultationsState());
Future<void> loadConsultations() async {
try {
@@ -20,4 +22,14 @@ class ConsultationsCubit extends Cubit<ConsultationsState> {
emit(state.copyWith(state: BaseState.error));
}
}
Future<String?> getChatIdByUserSlug(String slug) async {
try {
return await chatRepository.getChatByUserSlug(slug);
} catch (e, st) {
await reportFrom(e, st, this);
log(e.toString(), stackTrace: st);
return null;
}
}
}

View File

@@ -3,14 +3,17 @@ import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/widgets/custom_divider.dart';
import 'package:eatvio_app/core/widgets/custom_outlined_button.dart';
import 'package:eatvio_app/core/utils/toasts.dart';
import 'package:eatvio_app/core/widgets/show_error_dialog.dart';
import 'package:eatvio_app/features/consultations/models/consultation_model.dart';
import 'package:eatvio_app/features/consultations/presentation/cubit/consultations_cubit.dart';
import 'package:eatvio_app/features/diary/presentation/pages/diary_screen.dart';
import 'package:eatvio_app/features/my_finance/presentation/pages/card_settings.dart';
import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart';
import 'package:eatvio_app/features/user_details/presentation/user_details_screen.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
class ConsultationCardWidget extends StatefulWidget {
@@ -34,6 +37,7 @@ class _ConsultationCardWidgetState extends State<ConsultationCardWidget> {
final List<Color> legendColors = const [Colors.white, Color(0xFFF0C497), Color(0xFFFF9500), Color(0xFF2F80ED), Color(0xFF4CD964)];
late final List<(int, DiaryModel)> givenDaysWithDayNumber;
bool _isOpeningChat = false;
final List<List<DiaryStateEnum>> legendStates = [
[DiaryStateEnum.init],
[DiaryStateEnum.started, DiaryStateEnum.lastDayStarted],
@@ -172,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(
@@ -255,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(
@@ -279,7 +283,7 @@ class _ConsultationCardWidgetState extends State<ConsultationCardWidget> {
const SizedBox(width: 8),
InkWell(
borderRadius: BorderRadius.circular(20),
onTap: () {},
onTap: _isOpeningChat ? null : _openChat,
child: Ink(
height: 52,
width: 52,
@@ -308,6 +312,22 @@ class _ConsultationCardWidgetState extends State<ConsultationCardWidget> {
);
}
Future<void> _openChat() async {
if (_isOpeningChat) return;
setState(() => _isOpeningChat = true);
var chatId = widget.consultationsModel.chatId;
if (chatId == null || chatId.isEmpty) {
chatId = await context.read<ConsultationsCubit>().getChatIdByUserSlug(widget.consultationsModel.userSlug);
}
if (!mounted) return;
setState(() => _isOpeningChat = false);
if (chatId == null || chatId.isEmpty) {
showErrorToast(S.of(context).msg_something_went_wrong);
return;
}
GoRouter.of(context).pushNamed('chatScreen', extra: {'uuid': chatId});
}
Row _legendTitle(String text, Color color) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,

View File

@@ -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:

View 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;
}

View File

@@ -0,0 +1,6 @@
/// Флаги поэтапного включения аналитики.
abstract final class InsightsFeatureFlags {
/// Вкладки «КБЖУ по дням» и «В течение дня» требуют бэкенд-эндпоинта
/// `GET /api/profile/insights` и включаются во второй итерации.
static const bool kbjuEnabled = false;
}

View 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);
}

View 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;
}

View 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,
),
),
],
],
);
}
}

View 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;
}

View File

@@ -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,
),
),
),
),
);
}
}

View 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],
),
];
}
}

View 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;
}

View 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),
),
],
),
);
}
}

View 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),
);
}
}
}

View 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();
}
}

View 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;
}

View File

@@ -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')],
),
);
}
}

View File

@@ -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),
),
),
),
),
);
}
}

View 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),
),
],
);
}
}

View File

@@ -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;

View 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),
),
),
),
);
}
}

View File

@@ -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,
),
),
],
],
);
}
}

View File

@@ -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,
),
),
),
),
],
),
);
}
}

View 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),
),
),
);
}
}

View 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,
),
),
],
);
}
}

View 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,
),
),
),
),
);
}
}

View 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,
),
),
],
),
),
),
),
],
);
}
}

View 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();
}
}
}

View File

@@ -14,6 +14,7 @@ import 'package:flutter/material.dart';
Future<void> showConsultationPaymentFlow(
BuildContext context, {
required int planId,
double? amount,
Future<bool?> Function(BuildContext)? showGate,
Future<PromoCodeResult?> Function(
BuildContext, {
@@ -21,7 +22,7 @@ Future<void> showConsultationPaymentFlow(
Future<bool> Function()? onRemove,
PromoCodeModel? initialPromo,
})? showForm,
Future<void> Function(BuildContext, {required int planId})? showSbp,
Future<void> Function(BuildContext, {required int planId, double? amount})? showSbp,
Future<PromoValidateResponse> Function({
required int planId,
required String promoCode,
@@ -65,7 +66,7 @@ Future<void> showConsultationPaymentFlow(
}
if (!context.mounted) return;
await sbp(context, planId: planId);
await sbp(context, planId: planId, amount: amount);
return;
}
@@ -92,7 +93,7 @@ Future<void> showConsultationPaymentFlow(
}
if (!context.mounted) return;
await sbp(context, planId: planId);
await sbp(context, planId: planId, amount: amount);
}
Future<bool?> _showGate(BuildContext context) {
@@ -113,8 +114,8 @@ Future<PromoCodeResult?> _showForm(
);
}
Future<void> _showSbp(BuildContext context, {required int planId}) {
return showSbpBanksBottomSheet(context, planId: planId);
Future<void> _showSbp(BuildContext context, {required int planId, double? amount}) {
return showSbpBanksBottomSheet(context, planId: planId, amount: amount);
}
Future<PromoValidateResponse> _defaultValidatePromo({

View File

@@ -8,18 +8,23 @@ import 'package:eatvio_app/core/widgets/custom_outlined_button.dart';
import 'package:eatvio_app/core/widgets/loader.dart';
import 'package:eatvio_app/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_cubit.dart';
import 'package:eatvio_app/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_state.dart';
import 'package:eatvio_app/features/payment/presentation/widgets/sbp_qr_bottom_sheet.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:sbp/models/c2bmembers_model.dart';
/// Показывает bottom sheet выбора банка СБП для оплаты консультации.
Future<void> showSbpBanksBottomSheet(BuildContext context, {required int planId}) {
Future<void> showSbpBanksBottomSheet(
BuildContext context, {
required int planId,
double? amount,
}) {
final height = MediaQuery.sizeOf(context).height * 0.55;
return showBottomSheetWithBlur(
context: context,
height: height,
child: SbpBanksBottomSheet(planId: planId),
child: SbpBanksBottomSheet(planId: planId, amount: amount),
);
}
@@ -28,9 +33,13 @@ Future<void> showSbpBanksBottomSheet(BuildContext context, {required int planId}
/// При открытии вызывает [ConsultationPaymentCubit.startPayment] с [planId]
/// и показывает [Loader], пока загружаются checkout_url и список банков.
class SbpBanksBottomSheet extends StatefulWidget {
const SbpBanksBottomSheet({super.key, required this.planId, this.cubit});
const SbpBanksBottomSheet({super.key, required this.planId, this.amount, this.cubit});
final int planId;
/// Сумма к оплате в рублях — передаётся в QR-шит для отображения.
final double? amount;
final ConsultationPaymentCubit? cubit;
@override
@@ -77,6 +86,32 @@ class _SbpBanksBottomSheetState extends State<SbpBanksBottomSheet> {
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
textAlign: TextAlign.center,
),
// QR-кнопка доступна сразу после получения checkout_url,
// не дожидаясь списка банков: на устройствах без банковских
// приложений (iOS в РФ) это единственный путь оплаты.
BlocBuilder<ConsultationPaymentCubit, ConsultationPaymentState>(
buildWhen: (previous, current) =>
previous.checkoutUrl != current.checkoutUrl,
builder: (context, state) {
final checkoutUrl = state.checkoutUrl;
if (checkoutUrl == null || checkoutUrl.isEmpty) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(top: 16),
child: CustomOutlinedButton(
text: s.pay_by_qr_code,
height: 52,
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
buttonStyle: ButtonStyle(
side: const WidgetStatePropertyAll(BorderSide.none),
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
),
onPressed: _onShowQrTap,
),
);
},
),
const SizedBox(height: 16),
Expanded(
child: BlocBuilder<ConsultationPaymentCubit, ConsultationPaymentState>(
@@ -122,6 +157,23 @@ class _SbpBanksBottomSheetState extends State<SbpBanksBottomSheet> {
);
}
/// Открывает QR-шит поверх шита банков; при подтверждённой оплате
/// закрывает и родительский шит.
Future<void> _onShowQrTap() async {
final paid = await showSbpQrBottomSheet(
context,
planId: widget.planId,
cubit: _cubit,
amount: widget.amount,
);
if (paid == true && mounted) {
final navigator = Navigator.of(context);
if (navigator.canPop()) {
navigator.pop();
}
}
}
Future<void> _onBankTap(C2bmemberModel bank) async {
final opened = await _cubit.openBank(bank: bank);
if (opened && mounted) {

View File

@@ -0,0 +1,316 @@
import 'dart:async';
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/widgets/bottom_sheet_handler_bar.dart';
import 'package:eatvio_app/core/widgets/bottom_sheet_with_blur.dart';
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
import 'package:eatvio_app/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_cubit.dart';
import 'package:eatvio_app/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_state.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:url_launcher/url_launcher.dart';
/// Тестовый симулятор оплаты СБП (только для стендов разработки/stage).
const String _testPayUrlBase = 'https://testpay.alfabank.ru/sbp-page?qrcId=';
const String _nspkUrlPrefix = 'https://qr.nspk.ru/';
const Duration _pollInterval = Duration(seconds: 3);
const Duration _autoCloseDelay = Duration(seconds: 2);
/// Показывает bottom sheet с QR-кодом СБП для оплаты консультации
/// с другого устройства.
///
/// Возвращает `true`, если оплата подтверждена polling-ом статуса плана.
Future<bool?> showSbpQrBottomSheet(
BuildContext context, {
required int planId,
required ConsultationPaymentCubit cubit,
double? amount,
}) {
return showBottomSheetWithBlur<bool>(
context: context,
height: null,
child: SbpQrBottomSheet(
planId: planId,
cubit: cubit,
amount: amount,
),
);
}
/// QR-код оплаты СБП: рендерит [ConsultationPaymentState.checkoutUrl]
/// (payload вида `https://qr.nspk.ru/{qrcId}`), ожидает оплату через
/// polling статуса chosen_plan и показывает успех.
class SbpQrBottomSheet extends StatefulWidget {
const SbpQrBottomSheet({
super.key,
required this.planId,
required this.cubit,
this.amount,
this.chatRepository,
this.pollInterval = _pollInterval,
});
final int planId;
final ConsultationPaymentCubit cubit;
/// Сумма к оплате в рублях (показывается над QR, если передана).
final double? amount;
/// Переопределяется в тестах; по умолчанию берётся из DI.
final ChatRepository? chatRepository;
final Duration pollInterval;
@override
State<SbpQrBottomSheet> createState() => _SbpQrBottomSheetState();
}
class _SbpQrBottomSheetState extends State<SbpQrBottomSheet> {
late final ChatRepository _chatRepository;
late final Timer _pollTimer;
bool _paid = false;
@override
void initState() {
super.initState();
_chatRepository = widget.chatRepository ?? sl<ChatRepository>();
_pollTimer = Timer.periodic(widget.pollInterval, (_) => _pollStatus());
_pollStatus();
}
@override
void dispose() {
_pollTimer.cancel();
super.dispose();
}
Future<void> _pollStatus() async {
if (_paid || !mounted) return;
try {
final plan = await _chatRepository.getChosenPlan(id: widget.planId);
if (plan.chosenPlanState.isStateAfterPaidState()) {
_onPaid();
}
} catch (_) {
// Сетевые сбои polling-а игнорируем — следующий тик повторит проверку.
}
}
void _onPaid() {
if (_paid || !mounted) return;
setState(() => _paid = true);
Future.delayed(_autoCloseDelay, () {
if (!mounted) return;
Navigator.of(context).pop(true);
});
}
@override
Widget build(BuildContext context) {
final s = S.of(context);
return BlocProvider<ConsultationPaymentCubit>.value(
value: widget.cubit,
child: BlocBuilder<ConsultationPaymentCubit, ConsultationPaymentState>(
builder: (context, state) {
final checkoutUrl = state.checkoutUrl;
if (checkoutUrl == null || checkoutUrl.isEmpty) {
return const SizedBox.shrink();
}
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 8),
const BottomSheetHandlerBar(),
const SizedBox(height: 20),
Text(
_paid ? s.sbp_qr_paid_title : s.sbp_qr_title,
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
if (_paid)
_PaidContent(description: s.sbp_qr_paid_description)
else ...[
if (widget.amount != null) ...[
Text(
s.sbp_qr_amount_label,
style: TextStyles.textMMedium.copyWith(color: appTheme.gray600),
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
Text(
'${widget.amount!.toStringAsFixed(0)} ₽',
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
],
_QrCodeCard(checkoutUrl: checkoutUrl),
const SizedBox(height: 16),
_HowToPay(
step1: s.sbp_qr_step1,
step2: s.sbp_qr_step2,
step3: s.sbp_qr_step3,
),
const SizedBox(height: 16),
_TestPayButton(checkoutUrl: checkoutUrl),
const SizedBox(height: 16),
Text(
s.sbp_qr_waiting_payment,
style: TextStyles.textMMedium.copyWith(color: appTheme.gray600),
textAlign: TextAlign.center,
),
],
const SizedBox(height: 24),
],
),
);
},
),
);
}
}
class _QrCodeCard extends StatelessWidget {
const _QrCodeCard({required this.checkoutUrl});
final String checkoutUrl;
@override
Widget build(BuildContext context) {
return Center(
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: appTheme.blueGray300),
),
child: QrImageView(
key: const ValueKey('sbp_qr_code'),
data: checkoutUrl,
size: 200,
backgroundColor: Colors.white,
),
),
);
}
}
class _HowToPay extends StatelessWidget {
const _HowToPay({required this.step1, required this.step2, required this.step3});
final String step1;
final String step2;
final String step3;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: appTheme.blueA400.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(step1, style: TextStyles.textSMedium.copyWith(color: appTheme.black)),
const SizedBox(height: 4),
Text(step2, style: TextStyles.textSMedium.copyWith(color: appTheme.black)),
const SizedBox(height: 4),
Text(step3, style: TextStyles.textSMedium.copyWith(color: appTheme.black)),
],
),
);
}
}
/// Кнопка открытия тестового симулятора оплаты Альфа-Банка.
///
/// Видна только когда QR выдан непродовым бэкендом: тестовый qrcId от
/// продового в приложении не отличить (формат одинаковый), различает их
/// именно то, какой бэкенд выдал checkout_url — на стендах разработки/stage
/// Альфа подключена в sandbox и выдаёт QR тестового терминала. Поэтому
/// критерий — текущий base URL API не продуктовый (см. [AppConfig.isProdApi]).
class _TestPayButton extends StatelessWidget {
const _TestPayButton({required this.checkoutUrl});
final String checkoutUrl;
bool get _visible {
if (sl<AppConfig>().isProdApi) return false;
return checkoutUrl.startsWith(_nspkUrlPrefix);
}
@override
Widget build(BuildContext context) {
if (!_visible) return const SizedBox.shrink();
final s = S.of(context);
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFFFF3E0),
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
Text(
s.sbp_qr_test_hint,
style: TextStyles.textXSSemibold.copyWith(color: appTheme.gray600),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
OutlinedButton(
onPressed: () => _openTestPayment(),
child: Text(s.sbp_qr_open_test_payment),
),
],
),
);
}
Future<void> _openTestPayment() async {
final qrcId = checkoutUrl.replaceFirst(_nspkUrlPrefix, '');
final uri = Uri.parse('$_testPayUrlBase$qrcId');
try {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} catch (_) {
// Не смогли открыть браузер — пользователь оплатит по QR.
}
}
}
class _PaidContent extends StatelessWidget {
const _PaidContent({required this.description});
final String description;
@override
Widget build(BuildContext context) {
return Column(
children: [
const SizedBox(height: 8),
Container(
width: 64,
height: 64,
decoration: const BoxDecoration(color: Color(0xFFE8F5E9), shape: BoxShape.circle),
child: const Icon(Icons.check_rounded, color: Color(0xFF4CAF50), size: 40),
),
const SizedBox(height: 12),
Text(
description,
style: TextStyles.textMMedium.copyWith(color: appTheme.gray600),
textAlign: TextAlign.center,
),
],
);
}
}

View File

@@ -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;

View File

@@ -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);

View File

@@ -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()));
@@ -145,13 +137,13 @@ class ProfileCubit extends Cubit<ProfileState> {
Future<String?> getChatRoomIdBySlug({required String slug}) async {
try {
final chatIdOrError = await chatRepository.getChatByUserSlug(slug);
final userProfile = await profileRepository.getProfileBySlug(slug);
if (chatIdOrError == '404') {
final chatId = await chatRepository.getChatByUserSlug(slug);
if (chatId == null || chatId.isEmpty) {
final userProfile = await profileRepository.getProfileBySlug(slug);
AppRouter.navigatorKey.currentContext?.pushNamed('emptyChatScreen', extra: userProfile);
} else {
AppRouter.navigatorKey.currentContext?.pushNamed('chatScreen', extra: {'uuid': chatIdOrError});
return chatIdOrError;
AppRouter.navigatorKey.currentContext?.pushNamed('chatScreen', extra: {'uuid': chatId});
return chatId;
}
} catch (e, st) {
await reportFrom(e, st, this);

View File

@@ -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;

View File

@@ -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;
});
},
);
}
}

View File

@@ -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,
),
];
}
}

View File

@@ -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)),
),
];
}
}

View File

@@ -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,
),
];
}
}

View File

@@ -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));
}
}
}

View File

@@ -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;
}

View File

@@ -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;
});
},
);
}
}

View File

@@ -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(
"Количество приглашенных",
@@ -658,6 +808,9 @@ class MessageLookup extends MessageLookupByLibrary {
"В пароле должно быть минимум 6 символов",
),
"pay": MessageLookupByLibrary.simpleMessage("Оплатить"),
"pay_by_qr_code": MessageLookupByLibrary.simpleMessage(
"Оплатить по QR-коду",
),
"payment_failed": MessageLookupByLibrary.simpleMessage("Оплата не прошла"),
"pcs": MessageLookupByLibrary.simpleMessage("шт"),
"pending_payment": MessageLookupByLibrary.simpleMessage("Ожидается оплата"),
@@ -681,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(
@@ -728,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",
),
@@ -777,6 +930,34 @@ class MessageLookup extends MessageLookupByLibrary {
"sbp_payment_error": MessageLookupByLibrary.simpleMessage(
"Не удалось начать оплату. Попробуйте позже",
),
"sbp_qr_amount_label": MessageLookupByLibrary.simpleMessage(
"Сумма к оплате",
),
"sbp_qr_open_test_payment": MessageLookupByLibrary.simpleMessage(
"Открыть тестовую оплату",
),
"sbp_qr_paid_description": MessageLookupByLibrary.simpleMessage(
"Консультация оплачена. Можно вернуться в чат.",
),
"sbp_qr_paid_title": MessageLookupByLibrary.simpleMessage(
"Оплата прошла успешно!",
),
"sbp_qr_step1": MessageLookupByLibrary.simpleMessage(
"Откройте приложение банка на телефоне",
),
"sbp_qr_step2": MessageLookupByLibrary.simpleMessage(
"Выберите «Оплата по QR-коду»",
),
"sbp_qr_step3": MessageLookupByLibrary.simpleMessage(
"Наведите камеру на код",
),
"sbp_qr_test_hint": MessageLookupByLibrary.simpleMessage(
"Симуляция оплаты на тестовом стенде Альфа-Банка",
),
"sbp_qr_title": MessageLookupByLibrary.simpleMessage("Оплата по QR-коду"),
"sbp_qr_waiting_payment": MessageLookupByLibrary.simpleMessage(
"Ожидание оплаты…",
),
"send": MessageLookupByLibrary.simpleMessage("Отправить"),
"send_complain": MessageLookupByLibrary.simpleMessage("Отправить жалобу"),
"services": MessageLookupByLibrary.simpleMessage("Оказание услуг"),
@@ -826,7 +1007,7 @@ class MessageLookup extends MessageLookupByLibrary {
"step_by_step_photo_recipe": MessageLookupByLibrary.simpleMessage(
"Пошаговый фоторецепт",
),
"step_widget": m4,
"step_widget": m17,
"successfully_published": MessageLookupByLibrary.simpleMessage(
"успешно опубликован",
),
@@ -851,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(
@@ -886,7 +1067,7 @@ class MessageLookup extends MessageLookupByLibrary {
"Верхние показатели давления:",
),
"upper": MessageLookupByLibrary.simpleMessage("Верхнее"),
"userCanceldThePlanSubscription": m7,
"userCanceldThePlanSubscription": m20,
"userConfirmedPlanCompletion": MessageLookupByLibrary.simpleMessage(
"Пользователь подтвердил прохождения плана. В ближайшее время Вы получите",
),
@@ -894,8 +1075,8 @@ class MessageLookup extends MessageLookupByLibrary {
MessageLookupByLibrary.simpleMessage(
"Пользователь подтвердил прохождения плана.",
),
"userPaidThePlan": m8,
"userSubscribedToPlan": m9,
"userPaidThePlan": m21,
"userSubscribedToPlan": m22,
"user_authorization": MessageLookupByLibrary.simpleMessage(
"Аутентификация",
),
@@ -1025,7 +1206,7 @@ class MessageLookup extends MessageLookupByLibrary {
"youCanComplain": MessageLookupByLibrary.simpleMessage(
"Если вас что-то не устроило, вы можете подать жалобу",
),
"youCanceledSubscriptionToPlan": m10,
"youCanceledSubscriptionToPlan": m23,
"youShureYouWantToCancelOrder": MessageLookupByLibrary.simpleMessage(
"Вы уверены, что хотите отменить заказ?",
),

View File

@@ -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("Фамилия"),
@@ -297,6 +445,9 @@ class MessageLookup extends MessageLookupByLibrary {
"password_short": MessageLookupByLibrary.simpleMessage(
"В пароле должно быть минимум 6 символов",
),
"pay_by_qr_code": MessageLookupByLibrary.simpleMessage(
"Оплатить по QR-коду",
),
"pcs": MessageLookupByLibrary.simpleMessage("шт"),
"personal": MessageLookupByLibrary.simpleMessage("Персональные"),
"personal_diet_state": MessageLookupByLibrary.simpleMessage("Персональная"),
@@ -335,7 +486,7 @@ class MessageLookup extends MessageLookupByLibrary {
"Промокод применён!",
),
"promo_code": MessageLookupByLibrary.simpleMessage("Промокод"),
"promo_discount_hint": m3,
"promo_discount_hint": m16,
"promo_invalid": MessageLookupByLibrary.simpleMessage(
"Промокод не найден или истёк",
),
@@ -354,6 +505,34 @@ class MessageLookup extends MessageLookupByLibrary {
"sbp_payment_error": MessageLookupByLibrary.simpleMessage(
"Не удалось начать оплату. Попробуйте позже",
),
"sbp_qr_amount_label": MessageLookupByLibrary.simpleMessage(
"Сумма к оплате",
),
"sbp_qr_open_test_payment": MessageLookupByLibrary.simpleMessage(
"Открыть тестовую оплату",
),
"sbp_qr_paid_description": MessageLookupByLibrary.simpleMessage(
"Консультация оплачена. Можно вернуться в чат.",
),
"sbp_qr_paid_title": MessageLookupByLibrary.simpleMessage(
"Оплата прошла успешно!",
),
"sbp_qr_step1": MessageLookupByLibrary.simpleMessage(
"Откройте приложение банка на телефоне",
),
"sbp_qr_step2": MessageLookupByLibrary.simpleMessage(
"Выберите «Оплата по QR-коду»",
),
"sbp_qr_step3": MessageLookupByLibrary.simpleMessage(
"Наведите камеру на код",
),
"sbp_qr_test_hint": MessageLookupByLibrary.simpleMessage(
"Симуляция оплаты на тестовом стенде Альфа-Банка",
),
"sbp_qr_title": MessageLookupByLibrary.simpleMessage("Оплата по QR-коду"),
"sbp_qr_waiting_payment": MessageLookupByLibrary.simpleMessage(
"Ожидание оплаты…",
),
"services": MessageLookupByLibrary.simpleMessage("Оказание услуг"),
"settings": MessageLookupByLibrary.simpleMessage("Настройки"),
"share": MessageLookupByLibrary.simpleMessage("Поделиться"),

View File

@@ -2689,6 +2689,116 @@ class S {
);
}
/// `Оплатить по QR-коду`
String get pay_by_qr_code {
return Intl.message(
'Оплатить по QR-коду',
name: 'pay_by_qr_code',
desc: '',
args: [],
);
}
/// `Оплата по QR-коду`
String get sbp_qr_title {
return Intl.message(
'Оплата по QR-коду',
name: 'sbp_qr_title',
desc: '',
args: [],
);
}
/// `Сумма к оплате`
String get sbp_qr_amount_label {
return Intl.message(
'Сумма к оплате',
name: 'sbp_qr_amount_label',
desc: '',
args: [],
);
}
/// `Откройте приложение банка на телефоне`
String get sbp_qr_step1 {
return Intl.message(
'Откройте приложение банка на телефоне',
name: 'sbp_qr_step1',
desc: '',
args: [],
);
}
/// `Выберите «Оплата по QR-коду»`
String get sbp_qr_step2 {
return Intl.message(
'Выберите «Оплата по QR-коду»',
name: 'sbp_qr_step2',
desc: '',
args: [],
);
}
/// `Наведите камеру на код`
String get sbp_qr_step3 {
return Intl.message(
'Наведите камеру на код',
name: 'sbp_qr_step3',
desc: '',
args: [],
);
}
/// `Ожидание оплаты…`
String get sbp_qr_waiting_payment {
return Intl.message(
'Ожидание оплаты…',
name: 'sbp_qr_waiting_payment',
desc: '',
args: [],
);
}
/// `Оплата прошла успешно!`
String get sbp_qr_paid_title {
return Intl.message(
'Оплата прошла успешно!',
name: 'sbp_qr_paid_title',
desc: '',
args: [],
);
}
/// `Консультация оплачена. Можно вернуться в чат.`
String get sbp_qr_paid_description {
return Intl.message(
'Консультация оплачена. Можно вернуться в чат.',
name: 'sbp_qr_paid_description',
desc: '',
args: [],
);
}
/// `Открыть тестовую оплату`
String get sbp_qr_open_test_payment {
return Intl.message(
'Открыть тестовую оплату',
name: 'sbp_qr_open_test_payment',
desc: '',
args: [],
);
}
/// `Симуляция оплаты на тестовом стенде Альфа-Банка`
String get sbp_qr_test_hint {
return Intl.message(
'Симуляция оплаты на тестовом стенде Альфа-Банка',
name: 'sbp_qr_test_hint',
desc: '',
args: [],
);
}
/// `Подробнее о заказе`
String get more_about_order {
return Intl.message(
@@ -4443,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> {

View File

@@ -330,6 +330,17 @@
"choose_bank_for_payment": "Выберите банк",
"no_installed_banks": "Нет установленных банков",
"sbp_payment_error": "Не удалось начать оплату. Попробуйте позже",
"pay_by_qr_code": "Оплатить по QR-коду",
"sbp_qr_title": "Оплата по QR-коду",
"sbp_qr_amount_label": "Сумма к оплате",
"sbp_qr_step1": "Откройте приложение банка на телефоне",
"sbp_qr_step2": "Выберите «Оплата по QR-коду»",
"sbp_qr_step3": "Наведите камеру на код",
"sbp_qr_waiting_payment": "Ожидание оплаты…",
"sbp_qr_paid_title": "Оплата прошла успешно!",
"sbp_qr_paid_description": "Консультация оплачена. Можно вернуться в чат.",
"sbp_qr_open_test_payment": "Открыть тестовую оплату",
"sbp_qr_test_hint": "Симуляция оплаты на тестовом стенде Альфа-Банка",
"more_about_order": "Подробнее о заказе",
"cancel_order": "Отменить заказ",
"refuse_order":"Отказаться от заказа",
@@ -526,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"
}

View File

@@ -239,6 +239,17 @@
"choose_bank_for_payment": "Выберите банк",
"no_installed_banks": "Нет установленных банков",
"sbp_payment_error": "Не удалось начать оплату. Попробуйте позже",
"pay_by_qr_code": "Оплатить по QR-коду",
"sbp_qr_title": "Оплата по QR-коду",
"sbp_qr_amount_label": "Сумма к оплате",
"sbp_qr_step1": "Откройте приложение банка на телефоне",
"sbp_qr_step2": "Выберите «Оплата по QR-коду»",
"sbp_qr_step3": "Наведите камеру на код",
"sbp_qr_waiting_payment": "Ожидание оплаты…",
"sbp_qr_paid_title": "Оплата прошла успешно!",
"sbp_qr_paid_description": "Консультация оплачена. Можно вернуться в чат.",
"sbp_qr_open_test_payment": "Открыть тестовую оплату",
"sbp_qr_test_hint": "Симуляция оплаты на тестовом стенде Альфа-Банка",
"yes": "Да",
"apply": "Применить",
"have_promo_code": "У вас есть промокод?",
@@ -271,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": "Не удалось сохранить запись"
}

View File

@@ -1,7 +1,7 @@
name: eatvio_app
description: A new Flutter project.
version: 1.3.69+158
publish_to: 'none'
version: 1.3.71+160
publish_to: "none"
environment:
sdk: ^3.10.4
@@ -92,6 +92,7 @@ dependencies:
floating: ^6.0.0
wakelock_plus: ^1.5.2
sbp: 0.1.0
qr_flutter: ^4.1.0
# html 0.15.7 removed top-level query_selector.matches used by flutter_html 3.0.0.
dependency_overrides:

View File

@@ -526,11 +526,11 @@ void main() {
},
);
test('getChatByUserSlug - возвращает 404 при отсутствии чата', () async {
test('getChatByUserSlug - возвращает null, если в ответе нет id', () async {
const slug = 'user-slug';
final response = HttpResponse(
{},
Response(requestOptions: RequestOptions(path: ''), statusCode: 404),
Response(requestOptions: RequestOptions(path: ''), statusCode: 200),
);
when(
@@ -539,7 +539,7 @@ void main() {
final result = await chatRepository.getChatByUserSlug(slug);
expect(result, '404');
expect(result, isNull);
verify(mockChatService.getChatByUserSlug(slug: slug)).called(1);
});

View File

@@ -16,5 +16,29 @@ void main() {
expect(find.byType(BecomeConsultantScreen), findsOneWidget);
expect(find.text('Стать консультантом'), findsOneWidget);
});
testWidgets('keeps the bottom action above the system navigation area', (tester) async {
const bottomInset = 34.0;
await tester.pumpWidget(
MaterialApp(
theme: ThemeHelper().themeData(),
builder: (context, child) => MediaQuery(
data: MediaQuery.of(context).copyWith(padding: const EdgeInsets.only(bottom: bottomInset)),
child: child!,
),
home: const BecomeConsultantScreen(),
),
);
await tester.pumpAndSettle();
final buttonRect = tester.getRect(
find.ancestor(of: find.text('Начать регистрацию'), matching: find.byType(Ink)).first,
);
final viewHeight = tester.view.physicalSize.height / tester.view.devicePixelRatio;
expect(buttonRect.bottom, lessThanOrEqualTo(viewHeight - bottomInset));
expect(tester.takeException(), isNull);
});
});
}

View File

@@ -156,6 +156,55 @@ void main() {
});
group('ConsultationModel', () {
test('fromJson parses chat_id', () {
final json = {
'id': 1,
'chat_id': 'direct-chat-uuid',
'last_state': 'started',
'plan_title': 'Test Plan',
'user_name': 'User 1',
'user_slug': 'user-1',
'consultant_payment': 1000.0,
'duration': 30,
'current_diary_day': 5,
'user_profile_photo_url': 'photo.jpg',
'consultation_option_price': 2000,
'start_date': '2024-01-01T00:00:00Z',
'end_date': '2024-01-31T00:00:00Z',
'consultant_id': 1,
'diaries': [],
'personal_diet_id': null,
};
final model = ConsultationModel.fromJson(json);
expect(model.chatId, 'direct-chat-uuid');
});
test('fromJson allows missing chat_id', () {
final json = {
'id': 1,
'last_state': 'started',
'plan_title': 'Test Plan',
'user_name': 'User 1',
'user_slug': 'user-1',
'consultant_payment': 1000.0,
'duration': 30,
'current_diary_day': 5,
'user_profile_photo_url': 'photo.jpg',
'consultation_option_price': 2000,
'start_date': '2024-01-01T00:00:00Z',
'end_date': '2024-01-31T00:00:00Z',
'consultant_id': 1,
'diaries': [],
'personal_diet_id': null,
};
final model = ConsultationModel.fromJson(json);
expect(model.chatId, isNull);
});
test('toJson serializes ConsultationModel correctly', () {
final model = ConsultationModel(
id: 1,

View File

@@ -1,4 +1,5 @@
import 'package:eatvio_app/core/enums/base_state.dart';
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
import 'package:eatvio_app/features/consultations/models/consultation_model.dart';
import 'package:eatvio_app/features/consultations/presentation/cubit/consultations_cubit.dart';
import 'package:eatvio_app/features/consultations/presentation/cubit/consultations_state.dart';
@@ -8,14 +9,21 @@ import 'package:mocktail/mocktail.dart';
class MockConsultationsRepository extends Mock implements ConsultationsRepository {}
class MockChatRepository extends Mock implements ChatRepository {}
void main() {
group('ConsultationsCubit', () {
late ConsultationsCubit cubit;
late MockConsultationsRepository mockRepository;
late MockChatRepository mockChatRepository;
setUp(() {
mockRepository = MockConsultationsRepository();
cubit = ConsultationsCubit(consultationsRepository: mockRepository);
mockChatRepository = MockChatRepository();
cubit = ConsultationsCubit(
consultationsRepository: mockRepository,
chatRepository: mockChatRepository,
);
});
tearDown(() {
@@ -47,5 +55,33 @@ void main() {
expect(cubit.state.state, BaseState.error);
});
test('getChatIdByUserSlug - возвращает id чата', () async {
when(() => mockChatRepository.getChatByUserSlug('john-doe'))
.thenAnswer((_) async => 'chat-uuid');
final chatId = await cubit.getChatIdByUserSlug('john-doe');
expect(chatId, 'chat-uuid');
verify(() => mockChatRepository.getChatByUserSlug('john-doe')).called(1);
});
test('getChatIdByUserSlug - возвращает null при ошибке', () async {
when(() => mockChatRepository.getChatByUserSlug(any()))
.thenThrow(Exception('Error'));
final chatId = await cubit.getChatIdByUserSlug('john-doe');
expect(chatId, isNull);
});
test('getChatIdByUserSlug - возвращает null когда чата нет', () async {
when(() => mockChatRepository.getChatByUserSlug(any()))
.thenAnswer((_) async => null);
final chatId = await cubit.getChatIdByUserSlug('john-doe');
expect(chatId, isNull);
});
});
}

View File

@@ -1,18 +1,26 @@
import 'dart:async';
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/app_initializer.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/consultations/models/consultation_model.dart';
import 'package:eatvio_app/features/consultations/presentation/cubit/consultations_cubit.dart';
import 'package:eatvio_app/features/consultations/presentation/widgets/consultation_card_widget.dart';
import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:mocktail/mocktail.dart';
import 'package:network_image_mock/network_image_mock.dart';
class MockConsultationsCubit extends Mock implements ConsultationsCubit {}
void main() {
group('ConsultationCardWidget smoke', () {
setUpAll(() async {
@@ -28,6 +36,20 @@ void main() {
AppConfig(buildType: AppBuildType.tests)
..setBaseUrl('http://localhost'),
);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel('PonnamKarthik/fluttertoast'),
(MethodCall methodCall) async => null,
);
});
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel('PonnamKarthik/fluttertoast'),
null,
);
});
final mockConsultation = ConsultationModel(
@@ -64,7 +86,8 @@ void main() {
personalDietId: null,
);
Widget wrap(Widget child) {
Widget wrap(Widget child, {ConsultationsCubit? cubit}) {
final consultationsCubit = cubit ?? MockConsultationsCubit();
return MaterialApp.router(
theme: ThemeHelper().themeData(),
localizationsDelegates: const [
@@ -77,7 +100,20 @@ void main() {
routerConfig: GoRouter(
initialLocation: '/',
routes: [
GoRoute(path: '/', builder: (context, state) => child),
GoRoute(
path: '/',
builder: (context, state) => BlocProvider<ConsultationsCubit>.value(
value: consultationsCubit,
child: child,
),
),
GoRoute(
path: '/chat-screen',
name: 'chatScreen',
builder: (context, state) => Scaffold(
body: Text('chat:${(state.extra as Map)['uuid']}'),
),
),
GoRoute(
path: '/profile-screen',
name: 'profileScreen',
@@ -163,5 +199,118 @@ void main() {
});
},
);
testWidgets('opens chat with consultation user when chat icon tapped', (
tester,
) async {
final cubit = MockConsultationsCubit();
when(() => cubit.stream).thenAnswer((_) => const Stream.empty());
when(() => cubit.getChatIdByUserSlug('john-doe'))
.thenAnswer((_) async => 'chat-uuid');
await mockNetworkImagesFor(() async {
await tester.pumpWidget(
wrap(
ConsultationCardWidget(consultationsModel: mockConsultation),
cubit: cubit,
),
);
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.chat_outlined));
await tester.pumpAndSettle();
expect(find.text('chat:chat-uuid'), findsOneWidget);
verify(() => cubit.getChatIdByUserSlug('john-doe')).called(1);
});
});
testWidgets(
'opens chat directly with chat id without requesting it again',
(tester) async {
final cubit = MockConsultationsCubit();
when(() => cubit.stream).thenAnswer((_) => const Stream.empty());
final consultationWithChatId = mockConsultation.copyWith(
chatId: 'direct-chat-uuid',
);
await mockNetworkImagesFor(() async {
await tester.pumpWidget(
wrap(
ConsultationCardWidget(
consultationsModel: consultationWithChatId,
),
cubit: cubit,
),
);
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.chat_outlined));
await tester.pumpAndSettle();
expect(find.text('chat:direct-chat-uuid'), findsOneWidget);
verifyNever(() => cubit.getChatIdByUserSlug(any()));
});
},
);
testWidgets('shows error and stays on card when chat id is null', (
tester,
) async {
final cubit = MockConsultationsCubit();
when(() => cubit.stream).thenAnswer((_) => const Stream.empty());
when(() => cubit.getChatIdByUserSlug('john-doe'))
.thenAnswer((_) async => null);
await mockNetworkImagesFor(() async {
await tester.pumpWidget(
wrap(
ConsultationCardWidget(consultationsModel: mockConsultation),
cubit: cubit,
),
);
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.chat_outlined));
await tester.pumpAndSettle();
await tester.pump(const Duration(seconds: 3));
expect(find.textContaining('chat:'), findsNothing);
expect(
find.byType(ConsultationCardWidget),
findsOneWidget,
);
verify(() => cubit.getChatIdByUserSlug('john-doe')).called(1);
});
});
testWidgets('does not request chat id twice while first is pending', (
tester,
) async {
final cubit = MockConsultationsCubit();
when(() => cubit.stream).thenAnswer((_) => const Stream.empty());
final completer = Completer<String?>();
when(() => cubit.getChatIdByUserSlug('john-doe'))
.thenAnswer((_) => completer.future);
await mockNetworkImagesFor(() async {
await tester.pumpWidget(
wrap(
ConsultationCardWidget(consultationsModel: mockConsultation),
cubit: cubit,
),
);
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.chat_outlined));
await tester.tap(find.byIcon(Icons.chat_outlined));
completer.complete('chat-uuid');
await tester.pumpAndSettle();
expect(find.text('chat:chat-uuid'), findsOneWidget);
verify(() => cubit.getChatIdByUserSlug('john-doe')).called(1);
});
});
});
}

View File

@@ -15,6 +15,8 @@ import 'package:eatvio_app/features/diary/models/diary_day_model.dart';
import 'package:eatvio_app/features/diary/models/nutrition_model.dart';
import 'package:eatvio_app/core/widgets/data_error_widget.dart';
import 'package:eatvio_app/features/diary/presentation/widgets/meal_widget.dart';
import 'package:eatvio_app/features/insights/presentation/widgets/insights_card.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_dotenv/flutter_dotenv.dart';
@@ -23,6 +25,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import '../../../../fixtures/doubles/insights_doubles.dart';
import 'diary_screen_smoke_test.mocks.dart';
@GenerateNiceMocks([
@@ -44,6 +47,7 @@ void main() {
AppConfig(buildType: AppBuildType.tests)
..setBaseUrl('http://localhost'),
);
sl.registerLazySingleton<InsightsRepository>(() => FakeInsightsRepository());
});
Widget wrap(Widget child) {
@@ -383,6 +387,76 @@ void main() {
expect(find.byType(Text), findsWidgets);
});
Future<void> pumpDiaryWithInsights(
WidgetTester tester, {
required bool isConsultantView,
String? ownerSlug,
required FakeInsightsRepository insightsRepository,
}) async {
final diaryCubit = MockDiaryCubit();
final state = loadedState(hasChosenPlan: false).copyWith(isConsultantView: isConsultantView);
when(diaryCubit.state).thenReturn(state);
when(diaryCubit.stream).thenAnswer((_) => const Stream<DiaryState>.empty());
when(diaryCubit.close()).thenAnswer((_) async => Future<void>.value());
when(
diaryCubit.fetchDiaryV2(needLoading: anyNamed('needLoading'), diaryId: anyNamed('diaryId')),
).thenAnswer((_) async => true);
final commentsCubit = MockConsultantCommentsCubit();
when(commentsCubit.state).thenReturn(ConsultantCommentsState(commentsModel: ConsultantCommentModel(comment: '')));
when(commentsCubit.stream).thenAnswer((_) => const Stream<ConsultantCommentsState>.empty());
when(commentsCubit.close()).thenAnswer((_) async => Future<void>.value());
sl.registerFactory<DiaryCubit>(() => diaryCubit);
sl.registerFactory<ConsultantCommentsCubit>(() => commentsCubit);
sl.unregister<InsightsRepository>();
sl.registerLazySingleton<InsightsRepository>(() => insightsRepository);
await tester.pumpWidget(
wrap(isConsultantView ? UserDiaryScreen(diaryId: 1, ownerSlug: ownerSlug) : DiaryScreen(date: DateTime(2025, 1, 1))),
);
await tester.pump(const Duration(milliseconds: 200));
}
testWidgets('own diary shows insights card for own data with add button enabled', (tester) async {
final insightsRepository = FakeInsightsRepository();
await pumpDiaryWithInsights(tester, isConsultantView: false, insightsRepository: insightsRepository);
expect(find.byType(InsightsCard), findsOneWidget);
expect(tester.widget<InsightsCard>(find.byType(InsightsCard)).slug, isNull);
expect(tester.widget<InsightsCard>(find.byType(InsightsCard)).canAdd, isTrue);
expect(insightsRepository.insightsRequests.single.slug, isNull);
});
testWidgets('consultant view shows insights card of the client without add button', (tester) async {
final insightsRepository = FakeInsightsRepository();
await pumpDiaryWithInsights(tester, isConsultantView: true, ownerSlug: 'client', insightsRepository: insightsRepository);
final card = tester.widget<InsightsCard>(find.byType(InsightsCard));
expect(card.slug, 'client');
expect(card.canAdd, isFalse);
expect(insightsRepository.insightsRequests.single.slug, 'client');
});
testWidgets('consultant view without owner slug hides insights card', (tester) async {
final insightsRepository = FakeInsightsRepository();
await pumpDiaryWithInsights(tester, isConsultantView: true, insightsRepository: insightsRepository);
expect(find.byType(InsightsCard), findsNothing);
expect(insightsRepository.insightsRequests, isEmpty);
});
testWidgets('consultant view hides insights card on 403', (tester) async {
final insightsRepository = FakeInsightsRepository(error: const InsightsForbidden());
await pumpDiaryWithInsights(tester, isConsultantView: true, ownerSlug: 'client', insightsRepository: insightsRepository);
expect(find.byKey(const Key('insights_card')), findsNothing);
});
testWidgets('shows add button for a finished day inside the edit window', (
tester,
) async {

View File

@@ -0,0 +1,285 @@
import 'package:eatvio_app/features/insights/domain/insights_text_builder.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';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
WeightModel _weight(double weight, {double? bmi, int day = 1}) {
final date = DateTime(2026, 11, day, 12);
return WeightModel(
id: day,
userId: 1,
weight: weight,
createdAt: date,
updatedAt: date,
bmi: bmi,
);
}
CircumferenceModel _circumference({
double waist = 80,
double chest = 95,
double hips = 100,
int day = 1,
}) {
final date = DateTime(2026, 11, day, 12);
return CircumferenceModel(
id: day,
userId: 1,
chestCircumference: chest,
waistCircumference: waist,
hipCircumference: hips,
createdAt: date,
updatedAt: date,
);
}
void main() {
late InsightsTextBuilder builder;
setUpAll(() async {
final s = await S.load(const Locale('ru'));
builder = InsightsTextBuilder(s);
});
group('форматирование', () {
test('formatNumber: один знак без хвостового нуля', () {
expect(builder.formatNumber(3), '3');
expect(builder.formatNumber(3.5), '3.5');
expect(builder.formatNumber(3.04), '3');
expect(builder.formatNumber(3.26), '3.3');
});
test('formatLabel: dd.MM', () {
expect(builder.formatLabel(DateTime(2026, 11, 9, 12)), '09.11');
});
test('periodLabel: все периоды', () {
expect(builder.periodLabel(BodyPeriod.month), 'месяц');
expect(builder.periodLabel(BodyPeriod.threeMonths), '3 месяца');
expect(builder.periodLabel(BodyPeriod.sixMonths), '6 месяцев');
expect(builder.periodLabel(BodyPeriod.year), 'год');
expect(builder.periodLabel(BodyPeriod.all), 'всё время');
});
});
group('вес', () {
test('снижение', () {
final model = builder.buildWeight([
_weight(74.5),
_weight(73.2, day: 9),
], BodyPeriod.month);
expect(model.deltaText, 'Снизили на 1.3 кг за месяц');
expect(model.delta, -1.3);
expect(model.last, 73.2);
expect(model.headline, '73.2');
expect(model.unit, 'кг');
expect(model.labels, ['01.11', '09.11']);
expect(model.values, [74.5, 73.2]);
});
test('набор', () {
final model = builder.buildWeight([
_weight(70),
_weight(72, day: 3),
], BodyPeriod.threeMonths);
expect(model.deltaText, 'Набрали 2 кг за 3 месяца');
expect(model.delta, 2);
});
test('порог 0.05: меньше — не изменился', () {
final model = builder.buildWeight([
_weight(70),
_weight(70.04, day: 3),
], BodyPeriod.year);
expect(model.deltaText, 'Вес не изменился за год');
});
test('порог 0.05: ровно 0.25 — уже изменение', () {
final model = builder.buildWeight([
_weight(70),
_weight(70.25, day: 3),
], BodyPeriod.all);
expect(model.deltaText, 'Набрали 0.3 кг за всё время');
});
test('одна точка: недостаточно данных, но headline есть', () {
final model = builder.buildWeight([_weight(72)], BodyPeriod.month);
expect(model.deltaText, 'Недостаточно данных');
expect(model.delta, isNull);
expect(model.headline, '72.0');
});
test('нет точек', () {
final model = builder.buildWeight([], BodyPeriod.month);
expect(model.deltaText, 'Недостаточно данных');
expect(model.last, isNull);
expect(model.headline, '—');
expect(model.values, isEmpty);
});
});
group('категории ИМТ (границы)', () {
final cases = <double, String>{
15.9: 'выраженный дефицит массы',
16.0: 'недостаточная масса',
18.4: 'недостаточная масса',
18.5: 'норма',
24.9: 'норма',
25.0: 'избыточная масса',
29.9: 'избыточная масса',
30.0: 'ожирение I ст.',
34.9: 'ожирение I ст.',
35.0: 'ожирение II ст.',
39.9: 'ожирение II ст.',
40.0: 'ожирение III ст.',
55.0: 'ожирение III ст.',
};
cases.forEach((bmi, expected) {
test('$bmi → $expected', () {
expect(builder.bmiCategory(bmi), expected);
});
});
});
group('ИМТ', () {
test('без роста: пусто и подсказка', () {
final model = builder.buildBmi(
[_weight(70, bmi: 22)],
BodyPeriod.month,
null,
);
expect(model.values, isEmpty);
expect(model.headline, '—');
expect(model.hint, 'Укажите рост, чтобы посчитать ИМТ.');
expect(model.deltaText, 'Укажите рост, чтобы посчитать ИМТ.');
expect(model.category, isNull);
});
test('нулевой рост считается отсутствующим', () {
final model = builder.buildBmi(
[_weight(70, bmi: 22)],
BodyPeriod.month,
0,
);
expect(model.values, isEmpty);
});
test('ИМТ ниже', () {
final model = builder.buildBmi(
[_weight(80, bmi: 26.5), _weight(75, bmi: 24.2, day: 5)],
BodyPeriod.threeMonths,
176,
);
expect(model.deltaText, 'ИМТ ниже на 2.3 за 3 месяца · норма');
expect(model.category, 'норма');
expect(model.headline, '24.2');
expect(model.last, 24.2);
expect(model.delta, -2.3);
expect(model.hint, contains('Считаем при росте 176 см.'));
});
test('ИМТ выше', () {
final model = builder.buildBmi(
[_weight(70, bmi: 24), _weight(80, bmi: 27, day: 5)],
BodyPeriod.month,
170,
);
expect(model.deltaText, 'ИМТ выше на 3 за месяц · избыточная масса');
});
test('ИМТ не изменился', () {
final model = builder.buildBmi(
[_weight(70, bmi: 22), _weight(70, bmi: 22.01, day: 5)],
BodyPeriod.year,
170,
);
expect(model.deltaText, 'ИМТ не изменился за год · норма');
});
test('точки с bmi = null пропускаются', () {
final model = builder.buildBmi(
[_weight(70, bmi: null), _weight(71, bmi: 22, day: 3)],
BodyPeriod.month,
170,
);
expect(model.values, [22]);
expect(model.labels, ['03.11']);
expect(model.deltaText, 'Недостаточно данных');
});
test('рост есть, точек нет', () {
final model = builder.buildBmi([], BodyPeriod.month, 170);
expect(model.values, isEmpty);
expect(model.deltaText, 'Недостаточно данных');
expect(model.hint, contains('170'));
});
});
group('замеры', () {
test('по каждой части: меньше / больше / без изменений', () {
final model = builder.buildMeasure([
_circumference(waist: 82, chest: 96, hips: 100),
_circumference(waist: 80.5, chest: 98, hips: 100, day: 9),
], BodyPeriod.month);
expect(
model.deltaText,
'Талия меньше на 1.5 см, Грудь больше на 2 см, Бёдра без изменений за месяц',
);
expect(model.headline, '81');
expect(model.waist, [82, 80.5]);
expect(model.labels, ['01.11', '09.11']);
});
test('одна точка — недостаточно данных', () {
final model = builder.buildMeasure([_circumference()], BodyPeriod.month);
expect(model.deltaText, 'Недостаточно данных');
expect(model.headline, '80');
});
test('нет точек', () {
final model = builder.buildMeasure([], BodyPeriod.month);
expect(model.headline, '—');
expect(model.deltaText, 'Недостаточно данных');
});
});
group('build', () {
test('сортирует по дате, daily/intra = null', () {
final payload = builder.build(
weights: [_weight(73, bmi: 23, day: 9), _weight(75, bmi: 24, day: 1)],
circumferences: [
_circumference(waist: 79, day: 9),
_circumference(waist: 81),
],
period: BodyPeriod.month,
heightCm: 175,
);
expect(payload.weight.values, [75, 73]);
expect(payload.bmi.values, [24, 23]);
expect(payload.measure.waist, [81, 79]);
expect(payload.daily, isNull);
expect(payload.intra, isNull);
});
});
}

View File

@@ -0,0 +1,76 @@
import 'dart:convert';
import 'dart:io';
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
import 'package:flutter_test/flutter_test.dart';
Map<String, dynamic> _fixture(String name) {
return jsonDecode(File('test/fixtures/insights/$name').readAsStringSync())
as Map<String, dynamic>;
}
void main() {
group('InsightsPayloadModel', () {
test('парсит полный payload (weight/bmi/measure/daily/intra)', () {
final model = InsightsPayloadModel.fromJson(
_fixture('insights_payload.json'),
);
expect(model.weight.values, [74.5, 73.9, 73.2]);
expect(model.weight.last, 73.2);
expect(model.weight.delta, -1.3);
expect(model.weight.unit, 'кг');
expect(model.measure.waist, [82.0, 80.5]);
expect(model.measure.chest, [96.0, 96.0]);
expect(model.daily, isNotNull);
expect(model.daily!.kcal, [1850.0, 2010.0, 1930.0]);
expect(model.daily!.average, 1930);
expect(model.intra, isNotNull);
expect(model.intra!.bars, hasLength(2));
expect(model.intra!.bars.first.x, 8.5);
expect(model.intra!.bars.first.c, 240);
expect(model.intra!.satiety.last.y, 4);
expect(model.intra!.wake, 7.5);
expect(model.intra!.isToday, isTrue);
});
test('bmi.values пустой и last/delta/category = null', () {
final model = InsightsPayloadModel.fromJson(
_fixture('insights_payload.json'),
);
expect(model.bmi.values, isEmpty);
expect(model.bmi.last, isNull);
expect(model.bmi.delta, isNull);
expect(model.bmi.category, isNull);
expect(model.bmi.deltaText, contains('рост'));
});
test('intra без wake/sleep и null в average/percent', () {
final model = InsightsPayloadModel.fromJson(
_fixture('insights_payload_minimal.json'),
);
expect(model.intra!.wake, isNull);
expect(model.intra!.sleep, isNull);
expect(model.intra!.bars, isEmpty);
expect(model.daily!.average, isNull);
expect(model.daily!.percent, isNull);
expect(model.weight.last, isNull);
});
test('daily и intra необязательны', () {
final json = _fixture('insights_payload_minimal.json')
..remove('daily')
..remove('intra');
final model = InsightsPayloadModel.fromJson(json);
expect(model.daily, isNull);
expect(model.intra, isNull);
});
});
}

View File

@@ -0,0 +1,430 @@
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_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/repositories/insights_repository.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
class _MockInsightsRepository extends Mock implements InsightsRepository {}
const _payload = InsightsPayloadModel(
weight: WeightChartModel(values: [70, 71], headline: '71.0'),
bmi: BmiChartModel(),
measure: MeasureChartModel(),
);
const _payload2 = InsightsPayloadModel(
weight: WeightChartModel(values: [72], headline: '72.0'),
bmi: BmiChartModel(),
measure: MeasureChartModel(),
);
void main() {
late _MockInsightsRepository repository;
setUpAll(() {
registerFallbackValue(BodyPeriod.month);
});
setUp(() {
repository = _MockInsightsRepository();
when(
() => repository.getHeightCm(slug: any(named: 'slug')),
).thenAnswer((_) async => 176);
when(
() => repository.getInsights(
slug: any(named: 'slug'),
bodyPeriod: any(named: 'bodyPeriod'),
heightCm: any(named: 'heightCm'),
),
).thenAnswer((_) async => _payload);
});
InsightsCubit buildCubit({
String? slug,
bool canAdd = false,
bool kbjuEnabled = false,
}) {
return InsightsCubit(
repository: repository,
slug: slug,
canAdd: canAdd,
kbjuEnabled: kbjuEnabled,
);
}
InsightsStateLoaded loaded(InsightsCubit cubit) =>
cubit.state as InsightsStateLoaded;
test('начальное состояние — initial', () {
expect(buildCubit().state, isA<InsightsStateInitial>());
});
group('load', () {
test('дефолты: период 3М, вкладка «Вес», блок свёрнут', () async {
final cubit = buildCubit();
final states = <InsightsState>[];
cubit.stream.listen(states.add);
await cubit.load();
await Future<void>.delayed(Duration.zero);
expect(states.first, isA<InsightsStateLoading>());
final state = loaded(cubit);
expect(state.bodyPeriod, BodyPeriod.threeMonths);
expect(state.panel, InsightsPanel.weight);
expect(state.isExpanded, isFalse);
expect(state.isRefreshing, isFalse);
expect(state.showSleep, isTrue);
expect(state.payload, _payload);
verify(
() => repository.getInsights(
slug: null,
bodyPeriod: BodyPeriod.threeMonths,
heightCm: 176,
),
).called(1);
await cubit.close();
});
test('пробрасывает slug владельца', () async {
final cubit = buildCubit(slug: 'client');
await cubit.load();
verify(() => repository.getHeightCm(slug: 'client')).called(1);
verify(
() => repository.getInsights(
slug: 'client',
bodyPeriod: BodyPeriod.threeMonths,
heightCm: 176,
),
).called(1);
await cubit.close();
});
test('403 → hidden', () async {
when(
() => repository.getHeightCm(slug: any(named: 'slug')),
).thenThrow(const InsightsForbidden());
final cubit = buildCubit(slug: 'client');
await cubit.load();
expect(cubit.state, isA<InsightsStateHidden>());
await cubit.close();
});
test('403 на запросе точек → hidden', () async {
when(
() => repository.getInsights(
slug: any(named: 'slug'),
bodyPeriod: any(named: 'bodyPeriod'),
heightCm: any(named: 'heightCm'),
),
).thenThrow(const InsightsForbidden());
final cubit = buildCubit(slug: 'client');
await cubit.load();
expect(cubit.state, isA<InsightsStateHidden>());
await cubit.close();
});
test('другая ошибка → error', () async {
when(
() => repository.getInsights(
slug: any(named: 'slug'),
bodyPeriod: any(named: 'bodyPeriod'),
heightCm: any(named: 'heightCm'),
),
).thenThrow(Exception('boom'));
final cubit = buildCubit();
await cubit.load();
expect(cubit.state, isA<InsightsStateError>());
await cubit.close();
});
});
group('setBodyPeriod', () {
test(
'смена периода → перезапрос; рост не запрашивается повторно',
() async {
final cubit = buildCubit();
await cubit.load();
when(
() => repository.getInsights(
slug: null,
bodyPeriod: BodyPeriod.year,
heightCm: 176,
),
).thenAnswer((_) async => _payload2);
await cubit.setBodyPeriod(BodyPeriod.year);
expect(loaded(cubit).bodyPeriod, BodyPeriod.year);
expect(loaded(cubit).payload, _payload2);
expect(loaded(cubit).isRefreshing, isFalse);
verify(() => repository.getHeightCm(slug: null)).called(1);
await cubit.close();
},
);
test('тот же период — без запроса', () async {
final cubit = buildCubit();
await cubit.load();
clearInteractions(repository);
await cubit.setBodyPeriod(BodyPeriod.threeMonths);
verifyNever(
() => repository.getInsights(
slug: any(named: 'slug'),
bodyPeriod: any(named: 'bodyPeriod'),
heightCm: any(named: 'heightCm'),
),
);
await cubit.close();
});
test('ошибка перезапроса возвращает прежний период и данные', () async {
final cubit = buildCubit();
await cubit.load();
when(
() => repository.getInsights(
slug: null,
bodyPeriod: BodyPeriod.all,
heightCm: 176,
),
).thenThrow(Exception('boom'));
await cubit.setBodyPeriod(BodyPeriod.all);
expect(loaded(cubit).bodyPeriod, BodyPeriod.threeMonths);
expect(loaded(cubit).payload, _payload);
expect(loaded(cubit).isRefreshing, isFalse);
await cubit.close();
});
test('403 при перезапросе → hidden', () async {
final cubit = buildCubit(slug: 'c');
await cubit.load();
when(
() => repository.getInsights(
slug: 'c',
bodyPeriod: BodyPeriod.month,
heightCm: 176,
),
).thenThrow(const InsightsForbidden());
await cubit.setBodyPeriod(BodyPeriod.month);
expect(cubit.state, isA<InsightsStateHidden>());
await cubit.close();
});
});
group('панели и серии', () {
test('toggleExpanded раскрывает и сворачивает', () async {
final cubit = buildCubit();
await cubit.load();
cubit.toggleExpanded();
expect(loaded(cubit).isExpanded, isTrue);
cubit.toggleExpanded();
expect(loaded(cubit).isExpanded, isFalse);
await cubit.close();
});
test('setPanel переключает вкладку', () async {
final cubit = buildCubit();
await cubit.load();
cubit.setPanel(InsightsPanel.measures);
expect(loaded(cubit).panel, InsightsPanel.measures);
await cubit.close();
});
test('вкладки КБЖУ недоступны при выключенном флаге', () async {
final cubit = buildCubit();
await cubit.load();
cubit.setPanel(InsightsPanel.daily);
expect(loaded(cubit).panel, InsightsPanel.weight);
expect(cubit.panels, [
InsightsPanel.weight,
InsightsPanel.bmi,
InsightsPanel.measures,
]);
await cubit.close();
});
test('вкладки КБЖУ доступны при включённом флаге', () async {
final cubit = buildCubit(kbjuEnabled: true);
await cubit.load();
cubit.setPanel(InsightsPanel.intra);
expect(loaded(cubit).panel, InsightsPanel.intra);
expect(cubit.panels, InsightsPanel.values);
await cubit.close();
});
test('toggleSeries переключает только выбранную серию', () async {
final cubit = buildCubit();
await cubit.load();
cubit.toggleSeries(InsightsPanel.measures, 1);
expect(loaded(cubit).seriesVisibility.measure, [true, false, true]);
cubit.toggleSeries(InsightsPanel.measures, 1);
expect(loaded(cubit).seriesVisibility.measure, [true, true, true]);
cubit.toggleSeries(InsightsPanel.daily, 0);
expect(loaded(cubit).seriesVisibility.daily, [false, true, true, true]);
expect(loaded(cubit).seriesVisibility.measure, [true, true, true]);
await cubit.close();
});
test(
'toggleSeries игнорирует неверный индекс и вкладку без серий',
() async {
final cubit = buildCubit();
await cubit.load();
final before = loaded(cubit).seriesVisibility;
cubit.toggleSeries(InsightsPanel.measures, 5);
cubit.toggleSeries(InsightsPanel.weight, 0);
expect(loaded(cubit).seriesVisibility, before);
await cubit.close();
},
);
test('toggleSleep', () async {
final cubit = buildCubit();
await cubit.load();
cubit.toggleSleep();
expect(loaded(cubit).showSleep, isFalse);
await cubit.close();
});
});
group('refresh и добавление записей', () {
test('refresh сохраняет вкладку, период, серии и раскрытие', () async {
final cubit = buildCubit();
await cubit.load();
cubit.toggleExpanded();
cubit.setPanel(InsightsPanel.measures);
cubit.toggleSeries(InsightsPanel.measures, 0);
when(
() => repository.getInsights(
slug: null,
bodyPeriod: BodyPeriod.threeMonths,
heightCm: 176,
),
).thenAnswer((_) async => _payload2);
await cubit.refresh();
final state = loaded(cubit);
expect(state.payload, _payload2);
expect(state.panel, InsightsPanel.measures);
expect(state.isExpanded, isTrue);
expect(state.seriesVisibility.measure, [false, true, true]);
expect(state.isRefreshing, isFalse);
await cubit.close();
});
test('addWeight сохраняет и обновляет данные', () async {
when(
() => repository.addWeight(weight: any(named: 'weight')),
).thenAnswer((_) async {});
final cubit = buildCubit(canAdd: true);
await cubit.load();
when(
() => repository.getInsights(
slug: null,
bodyPeriod: BodyPeriod.threeMonths,
heightCm: 176,
),
).thenAnswer((_) async => _payload2);
final isSaved = await cubit.addWeight(weight: 72);
expect(isSaved, isTrue);
verify(() => repository.addWeight(weight: 72)).called(1);
expect(loaded(cubit).payload, _payload2);
await cubit.close();
});
test('addCircumference сохраняет и обновляет данные', () async {
when(
() => repository.addCircumference(
chestCircumference: any(named: 'chestCircumference'),
waistCircumference: any(named: 'waistCircumference'),
hipCircumference: any(named: 'hipCircumference'),
),
).thenAnswer((_) async {});
final cubit = buildCubit(canAdd: true);
await cubit.load();
when(
() => repository.getInsights(
slug: null,
bodyPeriod: BodyPeriod.threeMonths,
heightCm: 176,
),
).thenAnswer((_) async => _payload2);
final isSaved = await cubit.addCircumference(
chestCircumference: 95,
waistCircumference: 80,
hipCircumference: 100,
);
expect(isSaved, isTrue);
verify(
() => repository.addCircumference(
chestCircumference: 95,
waistCircumference: 80,
hipCircumference: 100,
),
).called(1);
expect(loaded(cubit).payload, _payload2);
await cubit.close();
});
test(
'ошибка сохранения не перезагружает данные и возвращает false',
() async {
when(
() => repository.addWeight(weight: any(named: 'weight')),
).thenThrow(Exception('boom'));
final cubit = buildCubit(canAdd: true);
await cubit.load();
clearInteractions(repository);
expect(await cubit.addWeight(weight: 72), isFalse);
verifyNever(
() => repository.getInsights(
slug: any(named: 'slug'),
bodyPeriod: any(named: 'bodyPeriod'),
heightCm: any(named: 'heightCm'),
),
);
expect(loaded(cubit).payload, _payload);
await cubit.close();
},
);
});
}

View File

@@ -0,0 +1,382 @@
import 'package:eatvio_app/core/theme/theme_helper.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/measure_chart.dart';
import 'package:eatvio_app/features/insights/presentation/charts/weight_chart.dart';
import 'package:eatvio_app/features/insights/presentation/widgets/insights_card.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_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../../../fixtures/doubles/insights_doubles.dart';
void main() {
Widget wrap(Widget child) {
return MaterialApp(
theme: ThemeHelper().themeData(),
localizationsDelegates: const [
S.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('ru')],
home: Scaffold(body: SingleChildScrollView(child: child)),
);
}
Future<void> pumpCard(
WidgetTester tester,
FakeInsightsRepository repository, {
String? slug,
bool canAdd = false,
bool kbjuEnabled = false,
bool expand = true,
}) async {
tester.view.physicalSize = const Size(900, 2400);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(
wrap(
InsightsCard(
slug: slug,
canAdd: canAdd,
repository: repository,
kbjuEnabled: kbjuEnabled,
),
),
);
await tester.pumpAndSettle();
if (expand &&
find.byKey(const Key('insights_toggle')).evaluate().isNotEmpty) {
await tester.tap(find.byKey(const Key('insights_toggle')));
await tester.pumpAndSettle();
}
}
group('свёрнутое состояние', () {
testWidgets('по умолчанию свёрнут: виден заголовок, графиков нет', (
tester,
) async {
await pumpCard(tester, FakeInsightsRepository(), expand: false);
expect(find.text('Аналитика'), findsOneWidget);
expect(find.text('нажмите, чтобы посмотреть прогресс'), findsOneWidget);
expect(find.byType(WeightChart), findsNothing);
expect(find.byKey(const Key('insights_tab_weight')), findsNothing);
});
testWidgets('тап по заголовку раскрывает и сворачивает блок', (
tester,
) async {
await pumpCard(tester, FakeInsightsRepository(), expand: false);
await tester.tap(find.byKey(const Key('insights_toggle')));
await tester.pumpAndSettle();
expect(find.byType(WeightChart), findsOneWidget);
expect(find.text('вес, замеры и питание'), findsOneWidget);
await tester.tap(find.byKey(const Key('insights_toggle')));
await tester.pumpAndSettle();
expect(find.byType(WeightChart), findsNothing);
});
});
group('вкладки', () {
testWidgets(
'показаны Вес, ИМТ, Замеры; вкладки КБЖУ скрыты при выключенном флаге',
(tester) async {
await pumpCard(tester, FakeInsightsRepository());
expect(find.byKey(const Key('insights_tab_weight')), findsOneWidget);
expect(find.byKey(const Key('insights_tab_bmi')), findsOneWidget);
expect(find.byKey(const Key('insights_tab_measures')), findsOneWidget);
expect(find.byKey(const Key('insights_tab_daily')), findsNothing);
expect(find.byKey(const Key('insights_tab_intra')), findsNothing);
},
);
testWidgets('вкладки переключают панели', (tester) async {
await pumpCard(tester, FakeInsightsRepository());
expect(find.byType(WeightChart), findsOneWidget);
expect(find.byKey(const Key('insights_headline')), findsOneWidget);
await tester.tap(find.byKey(const Key('insights_tab_measures')));
await tester.pumpAndSettle();
expect(find.byType(MeasureChart), findsOneWidget);
expect(find.byType(WeightChart), findsNothing);
await tester.tap(find.byKey(const Key('insights_tab_bmi')));
await tester.pumpAndSettle();
expect(find.byType(BmiChart), findsOneWidget);
expect(find.byType(MeasureChart), findsNothing);
await tester.tap(find.byKey(const Key('insights_tab_weight')));
await tester.pumpAndSettle();
expect(find.byType(WeightChart), findsOneWidget);
});
testWidgets('вкладки КБЖУ видны при включённом флаге', (tester) async {
await pumpCard(tester, FakeInsightsRepository(), kbjuEnabled: true);
expect(find.byKey(const Key('insights_tab_daily')), findsOneWidget);
expect(find.byKey(const Key('insights_tab_intra')), findsOneWidget);
});
});
group('вес', () {
testWidgets('показывает крупное число и текст дельты из payload', (
tester,
) async {
await pumpCard(tester, FakeInsightsRepository());
expect(find.text('73.2'), findsOneWidget);
expect(find.text('кг'), findsWidgets);
expect(find.text('Снизили на 1.3 кг за период'), findsOneWidget);
});
});
group('пилюли периодов', () {
testWidgets(
'по умолчанию запрошены 3 месяца; смена периода перезапрашивает данные',
(tester) async {
final repository = FakeInsightsRepository();
await pumpCard(tester, repository);
expect(
repository.insightsRequests.single.period,
BodyPeriod.threeMonths,
);
await tester.tap(find.byKey(const Key('insights_period_12')));
await tester.pumpAndSettle();
expect(repository.insightsRequests.map((e) => e.period), [
BodyPeriod.threeMonths,
BodyPeriod.year,
]);
await tester.tap(find.byKey(const Key('insights_period_all')));
await tester.pumpAndSettle();
expect(repository.insightsRequests.last.period, BodyPeriod.all);
},
);
testWidgets('повторный тап по активной пилюле не делает запрос', (
tester,
) async {
final repository = FakeInsightsRepository();
await pumpCard(tester, repository);
await tester.tap(find.byKey(const Key('insights_period_3')));
await tester.pumpAndSettle();
expect(repository.insightsRequests, hasLength(1));
});
testWidgets('slug пробрасывается в запросы', (tester) async {
final repository = FakeInsightsRepository();
await pumpCard(tester, repository, slug: 'client');
await tester.tap(find.byKey(const Key('insights_period_1')));
await tester.pumpAndSettle();
expect(repository.insightsRequests.map((e) => e.slug), [
'client',
'client',
]);
});
});
group('кнопка «+»', () {
testWidgets('нет при canAdd = false', (tester) async {
await pumpCard(tester, FakeInsightsRepository());
expect(find.byKey(const Key('insights_fab')), findsNothing);
});
testWidgets('есть на вкладках «Вес» и «Замеры», нет на «ИМТ»', (
tester,
) async {
await pumpCard(tester, FakeInsightsRepository(), canAdd: true);
expect(find.byKey(const Key('insights_fab')), findsOneWidget);
await tester.tap(find.byKey(const Key('insights_tab_bmi')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('insights_fab')), findsNothing);
await tester.tap(find.byKey(const Key('insights_tab_measures')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('insights_fab')), findsOneWidget);
});
testWidgets('нет в свёрнутом состоянии', (tester) async {
await pumpCard(
tester,
FakeInsightsRepository(),
canAdd: true,
expand: false,
);
expect(find.byKey(const Key('insights_fab')), findsNothing);
});
testWidgets(
'сохранение веса отправляет значение и перезапрашивает данные',
(tester) async {
final repository = FakeInsightsRepository();
await pumpCard(tester, repository, canAdd: true);
await tester.tap(find.byKey(const Key('insights_fab')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('add_weight_kilo_picker')), findsOneWidget);
await tester.tap(find.byKey(const Key('add_sheet_save')));
await tester.pumpAndSettle();
// Начальное значение шторки — последний вес из payload (73.2).
expect(repository.addedWeights, [73.2]);
expect(repository.insightsRequests, hasLength(2));
},
);
testWidgets(
'сохранение замеров отправляет значения и перезапрашивает данные',
(tester) async {
final repository = FakeInsightsRepository();
await pumpCard(tester, repository, canAdd: true);
await tester.tap(find.byKey(const Key('insights_tab_measures')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('insights_fab')));
await tester.pumpAndSettle();
expect(
find.byKey(const Key('add_circumference_waist_picker')),
findsOneWidget,
);
await tester.tap(find.byKey(const Key('add_sheet_save')));
await tester.pumpAndSettle();
// Последние значения из payload: грудь 96, талия 80.5 → 81 (округление), бёдра 100.
expect(repository.addedCircumferences, [
(chest: 96, waist: 81, hip: 100),
]);
expect(repository.insightsRequests, hasLength(2));
},
);
});
group('серии замеров', () {
testWidgets('чипы переключают видимость серии', (tester) async {
await pumpCard(tester, FakeInsightsRepository());
await tester.tap(find.byKey(const Key('insights_tab_measures')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('insights_series_0')), findsOneWidget);
expect(find.byKey(const Key('insights_series_1')), findsOneWidget);
expect(find.byKey(const Key('insights_series_2')), findsOneWidget);
expect(
tester
.widget<Opacity>(
find
.ancestor(
of: find.byKey(const Key('insights_series_1')),
matching: find.byType(Opacity),
)
.first,
)
.opacity,
1,
);
await tester.tap(find.byKey(const Key('insights_series_1')));
await tester.pumpAndSettle();
expect(
tester
.widget<Opacity>(
find
.ancestor(
of: find.byKey(const Key('insights_series_1')),
matching: find.byType(Opacity),
)
.first,
)
.opacity,
0.45,
);
});
});
group('ИМТ', () {
testWidgets('без значений показывает только deltaText', (tester) async {
await pumpCard(tester, FakeInsightsRepository());
await tester.tap(find.byKey(const Key('insights_tab_bmi')));
await tester.pumpAndSettle();
expect(find.text('Укажите рост, чтобы посчитать ИМТ.'), findsOneWidget);
expect(find.byKey(const Key('insights_bmi_chart')), findsNothing);
expect(find.byKey(const Key('insights_headline')), findsNothing);
});
testWidgets('со значениями рисует график и подсказку', (tester) async {
final payload = loadInsightsPayloadFixture().copyWith.bmi(
labels: ['01.11', '09.11'],
values: [26.1, 24.2],
last: 24.2,
delta: -1.9,
headline: '24.2',
category: 'норма',
deltaText: 'ИМТ ниже на 1.9 за 3 месяца · норма',
hint: 'Считаем при росте 176 см.',
);
await pumpCard(tester, FakeInsightsRepository(payload: payload));
await tester.tap(find.byKey(const Key('insights_tab_bmi')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('insights_bmi_chart')), findsOneWidget);
expect(find.byKey(const Key('insights_bmi_hint')), findsOneWidget);
expect(find.text('ИМТ ниже на 1.9 за 3 месяца · норма'), findsOneWidget);
});
});
group('скрытие и ошибки', () {
testWidgets('403 → блок скрыт целиком', (tester) async {
await pumpCard(
tester,
FakeInsightsRepository(error: const InsightsForbidden()),
slug: 'client',
expand: false,
);
expect(find.byKey(const Key('insights_card')), findsNothing);
expect(find.text('Аналитика'), findsNothing);
});
testWidgets(
'ошибка загрузки показывает кнопку «Повторить», повтор грузит данные',
(tester) async {
final repository = FakeInsightsRepository(error: Exception('boom'));
await pumpCard(tester, repository, expand: false);
expect(find.text('Не удалось загрузить аналитику'), findsOneWidget);
repository.error = null;
await tester.tap(find.byKey(const Key('insights_retry')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('insights_toggle')), findsOneWidget);
expect(find.text('Не удалось загрузить аналитику'), findsNothing);
},
);
});
}

View File

@@ -0,0 +1,367 @@
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
import 'package:eatvio_app/features/insights/presentation/charts/daily_kbju_chart.dart';
import 'package:eatvio_app/features/insights/presentation/charts/intra_day_chart.dart';
import 'package:eatvio_app/features/insights/presentation/widgets/insights_card.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/generated/l10n.dart';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:syncfusion_flutter_charts/charts.dart';
import '../../../../fixtures/doubles/insights_doubles.dart';
void main() {
Widget wrap(Widget child) {
return MaterialApp(
theme: ThemeHelper().themeData(),
localizationsDelegates: const [
S.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('ru')],
home: Scaffold(body: SingleChildScrollView(child: child)),
);
}
Future<void> pumpKbjuCard(
WidgetTester tester,
FakeInsightsRepository repository,
String tabKey,
) async {
tester.view.physicalSize = const Size(900, 2600);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(
wrap(InsightsCard(repository: repository, kbjuEnabled: true)),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('insights_toggle')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(Key(tabKey)));
await tester.pumpAndSettle();
}
group('КБЖУ по дням', () {
testWidgets('рисует график, шапку со средним и 4 чипа серий', (
tester,
) async {
await pumpKbjuCard(
tester,
FakeInsightsRepository(),
'insights_tab_daily',
);
expect(find.byType(DailyKbjuChart), findsOneWidget);
expect(find.text('1930'), findsOneWidget);
expect(find.text('ккал в среднем'), findsOneWidget);
expect(find.text('98% от нормы'), findsOneWidget);
for (var i = 0; i < 4; i++) {
expect(find.byKey(Key('insights_series_$i')), findsOneWidget);
}
expect(find.byKey(const Key('insights_series_4')), findsNothing);
});
testWidgets('чипы скрывают серии', (tester) async {
await pumpKbjuCard(
tester,
FakeInsightsRepository(),
'insights_tab_daily',
);
SfCartesianChart chart() => tester.widget<SfCartesianChart>(
find.byKey(const Key('insights_daily_chart')),
);
expect(chart().series, hasLength(4));
await tester.tap(find.byKey(const Key('insights_series_1')));
await tester.pumpAndSettle();
expect(chart().series, hasLength(3));
});
testWidgets('без daily показывает заглушку', (tester) async {
final payload = loadInsightsPayloadFixture().copyWith(daily: null);
await pumpKbjuCard(
tester,
FakeInsightsRepository(payload: payload),
'insights_tab_daily',
);
expect(find.byType(DailyKbjuChart), findsNothing);
expect(find.text('Недостаточно данных'), findsOneWidget);
});
testWidgets('пустой daily показывает заглушку вместо графика', (
tester,
) async {
await pumpKbjuCard(
tester,
FakeInsightsRepository(
payload: loadInsightsPayloadFixture('insights_payload_minimal.json'),
),
'insights_tab_daily',
);
expect(find.byKey(const Key('insights_daily_chart')), findsNothing);
expect(find.text('Недостаточно данных'), findsWidgets);
});
});
group('В течение дня', () {
testWidgets('рисует график, навигацию по дням, легенду и подсказку', (
tester,
) async {
await pumpKbjuCard(
tester,
FakeInsightsRepository(),
'insights_tab_intra',
);
expect(find.byType(IntraDayChart), findsOneWidget);
expect(find.byType(IntraDateNav), findsOneWidget);
expect(find.text('Сегодня'), findsOneWidget);
expect(find.text('Подъём в 07:30'), findsOneWidget);
expect(find.text('Отход ко сну в 23:00'), findsOneWidget);
expect(find.text('Затемнение — время сна'), findsOneWidget);
expect(find.byKey(const Key('insights_intra_hint')), findsOneWidget);
for (var i = 0; i < 6; i++) {
expect(find.byKey(Key('insights_series_$i')), findsOneWidget);
}
});
testWidgets(
'кнопка «следующий день» отключена при isToday, «предыдущий» без колбэка тоже',
(tester) async {
await pumpKbjuCard(
tester,
FakeInsightsRepository(),
'insights_tab_intra',
);
IconButton buttonIn(String key) => tester.widget<IconButton>(
find.descendant(
of: find.byKey(Key(key)),
matching: find.byType(IconButton),
),
);
expect(buttonIn('insights_intra_next').onPressed, isNull);
expect(buttonIn('insights_intra_prev').onPressed, isNull);
},
);
testWidgets('чип «Сон и подъём» убирает затемнение и легенду не трогает', (
tester,
) async {
await pumpKbjuCard(
tester,
FakeInsightsRepository(),
'insights_tab_intra',
);
NumericAxis xAxis() =>
tester
.widget<SfCartesianChart>(
find.byKey(const Key('insights_intra_chart')),
)
.primaryXAxis
as NumericAxis;
// wake + sleep: две полосы затемнения и две линии.
expect(xAxis().plotBands, hasLength(4));
await tester.tap(find.byKey(const Key('insights_series_5')));
await tester.pumpAndSettle();
expect(xAxis().plotBands, isEmpty);
expect(find.text('Затемнение — время сна'), findsOneWidget);
});
testWidgets('чипы сытости и настроения скрывают линии', (tester) async {
await pumpKbjuCard(
tester,
FakeInsightsRepository(),
'insights_tab_intra',
);
int seriesCount() => tester
.widget<SfCartesianChart>(
find.byKey(const Key('insights_intra_chart')),
)
.series
.length;
// 3 столбика + сытость + настроение.
expect(seriesCount(), 5);
await tester.tap(find.byKey(const Key('insights_series_3')));
await tester.pumpAndSettle();
expect(seriesCount(), 4);
await tester.tap(find.byKey(const Key('insights_series_4')));
await tester.pumpAndSettle();
expect(seriesCount(), 3);
});
testWidgets('пустые линии не создают серий (без экстраполяции)', (
tester,
) async {
final intra = loadInsightsPayloadFixture().intra!.copyWith(
satiety: const [],
mood: const [],
);
final payload = loadInsightsPayloadFixture().copyWith(intra: intra);
await pumpKbjuCard(
tester,
FakeInsightsRepository(payload: payload),
'insights_tab_intra',
);
final chart = tester.widget<SfCartesianChart>(
find.byKey(const Key('insights_intra_chart')),
);
expect(chart.series, hasLength(3));
});
testWidgets('без wake/sleep затемнения нет', (tester) async {
const intra = IntraChartModel(
bars: [IntraBarModel(x: 12, p: 100, f: 50, c: 200)],
date: '2026-11-09',
maxDate: '2026-11-09',
dateLabel: 'Сегодня',
isToday: true,
);
final payload = loadInsightsPayloadFixture().copyWith(intra: intra);
await pumpKbjuCard(
tester,
FakeInsightsRepository(payload: payload),
'insights_tab_intra',
);
final xAxis =
tester
.widget<SfCartesianChart>(
find.byKey(const Key('insights_intra_chart')),
)
.primaryXAxis
as NumericAxis;
expect(xAxis.plotBands, isEmpty);
expect(find.byKey(const Key('insights_legend_wake')), findsNothing);
expect(find.byKey(const Key('insights_legend_sleep')), findsNothing);
expect(find.byType(IntraLegend), findsOneWidget);
});
testWidgets('пустой день показывает заглушку', (tester) async {
await pumpKbjuCard(
tester,
FakeInsightsRepository(
payload: loadInsightsPayloadFixture('insights_payload_minimal.json'),
),
'insights_tab_intra',
);
expect(find.byKey(const Key('insights_intra_chart')), findsNothing);
expect(find.text('Недостаточно данных'), findsOneWidget);
});
});
group('IntraDateNav', () {
const model = IntraChartModel(
date: '2026-11-08',
maxDate: '2026-11-09',
dateLabel: '8 ноября',
isToday: false,
);
testWidgets('вызывает колбэки предыдущего и следующего дня', (
tester,
) async {
var prev = 0;
var next = 0;
await tester.pumpWidget(
wrap(
IntraDateNav(
model: model,
onPreviousDay: () => prev++,
onNextDay: () => next++,
onDatePicked: (_) {},
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('insights_intra_prev')));
await tester.tap(find.byKey(const Key('insights_intra_next')));
expect(prev, 1);
expect(next, 1);
expect(find.text('8 ноября'), findsOneWidget);
});
testWidgets('«следующий день» отключён, когда выбран сегодняшний день', (
tester,
) async {
var next = 0;
await tester.pumpWidget(
wrap(
IntraDateNav(
model: model.copyWith(isToday: true, dateLabel: 'Сегодня'),
onPreviousDay: () {},
onNextDay: () => next++,
),
),
);
await tester.pumpAndSettle();
await tester.tap(
find.byKey(const Key('insights_intra_next')),
warnIfMissed: false,
);
expect(next, 0);
});
testWidgets(
'выбор даты: календарь ограничен maxDate и возвращает выбранный день',
(tester) async {
DateTime? picked;
await tester.pumpWidget(
wrap(
IntraDateNav(
model: model,
onPreviousDay: () {},
onNextDay: () {},
onDatePicked: (date) => picked = date,
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('insights_intra_pick')));
await tester.pumpAndSettle();
final dialog = tester.widget<CalendarDatePicker>(
find.byType(CalendarDatePicker),
);
expect(dialog.lastDate, DateTime(2026, 11, 9));
expect(dialog.initialDate, DateTime(2026, 11, 8));
await tester.tap(find.text('5'));
await tester.tap(
find.text('OK').evaluate().isNotEmpty
? find.text('OK')
: find.text('ОК'),
);
await tester.pumpAndSettle();
expect(picked, DateTime(2026, 11, 5));
},
);
});
}

View File

@@ -0,0 +1,214 @@
import 'package:dio/dio.dart';
import 'package:eatvio_app/features/insights/domain/insights_text_builder.dart';
import 'package:eatvio_app/features/insights/models/insights_period.dart';
import 'package:eatvio_app/features/insights/repositories/insights_repository.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:eatvio_app/features/profile/repositories/profile_repository.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
class _MockProfileRepository extends Mock implements ProfileRepository {}
ProfileDetailsModel _details({int height = 176}) => ProfileDetailsModel(
firstName: 'A',
lastName: 'B',
birthdate: '1990-01-01',
gender: 'male',
country: 'RU',
city: 'Moscow',
lang: 'ru',
weight: 70,
height: height,
chestCircumference: 90,
waistCircumference: 80,
hipCircumference: 95,
bloodPressureUp: 120,
bloodPressureDown: 80,
hasHypo: false,
hasHyper: false,
aboutAlergy: '',
aboutTakingPils: '',
aboutSportHobbies: '',
favoriteFoods: '',
unfavoriteFoods: '',
additionalInfo: '',
isMoodFillable: false,
isBedtimeFillable: false,
activityCoefficient: 1.2,
);
DioException _dio(int status) => DioException(
requestOptions: RequestOptions(path: '/x'),
response: Response(
requestOptions: RequestOptions(path: '/x'),
statusCode: status,
),
);
void main() {
late _MockProfileRepository profileRepository;
late InsightsRepository repository;
final date1 = DateTime(2026, 11, 1, 12);
final date2 = DateTime(2026, 11, 9, 12);
final weights = [
WeightModel(
id: 1,
userId: 1,
weight: 75,
createdAt: date1,
updatedAt: date1,
bmi: 24,
),
WeightModel(
id: 2,
userId: 1,
weight: 73.5,
createdAt: date2,
updatedAt: date2,
bmi: 23.5,
),
];
final circumferences = [
CircumferenceModel(
id: 1,
userId: 1,
chestCircumference: 95,
waistCircumference: 82,
hipCircumference: 100,
createdAt: date1,
updatedAt: date1,
),
];
setUp(() async {
final s = await S.load(const Locale('ru'));
profileRepository = _MockProfileRepository();
repository = InsightsRepository(
profileRepository: profileRepository,
textBuilderFactory: () => InsightsTextBuilder(s),
);
});
group('getInsights', () {
test('собирает payload из веса и замеров; daily/intra == null', () async {
when(
() => profileRepository.getWeight(period: '3', slug: null),
).thenAnswer((_) async => weights);
when(
() => profileRepository.getCircumferences(period: '3', slug: null),
).thenAnswer((_) async => circumferences);
final payload = await repository.getInsights(
bodyPeriod: BodyPeriod.threeMonths,
heightCm: 176,
);
expect(payload.weight.values, [75, 73.5]);
expect(payload.weight.deltaText, 'Снизили на 1.5 кг за 3 месяца');
expect(payload.bmi.values, [24, 23.5]);
expect(payload.measure.waist, [82]);
expect(payload.daily, isNull);
expect(payload.intra, isNull);
});
test('пробрасывает slug и значение периода', () async {
when(
() => profileRepository.getWeight(period: 'all', slug: 'client'),
).thenAnswer((_) async => weights);
when(
() =>
profileRepository.getCircumferences(period: 'all', slug: 'client'),
).thenAnswer((_) async => circumferences);
await repository.getInsights(slug: 'client', bodyPeriod: BodyPeriod.all);
verify(
() => profileRepository.getWeight(period: 'all', slug: 'client'),
).called(1);
verify(
() =>
profileRepository.getCircumferences(period: 'all', slug: 'client'),
).called(1);
});
test('403 → InsightsForbidden', () async {
when(
() => profileRepository.getWeight(period: '3', slug: 'client'),
).thenThrow(_dio(403));
when(
() => profileRepository.getCircumferences(period: '3', slug: 'client'),
).thenAnswer((_) async => circumferences);
expect(
repository.getInsights(
slug: 'client',
bodyPeriod: BodyPeriod.threeMonths,
),
throwsA(isA<InsightsForbidden>()),
);
});
test('прочие ошибки пробрасываются как есть', () async {
when(
() => profileRepository.getWeight(period: '3', slug: null),
).thenThrow(_dio(500));
when(
() => profileRepository.getCircumferences(period: '3', slug: null),
).thenAnswer((_) async => circumferences);
expect(
repository.getInsights(bodyPeriod: BodyPeriod.threeMonths),
throwsA(isA<DioException>()),
);
});
});
group('getHeightCm', () {
test('свой профиль', () async {
when(
() => profileRepository.getMyProfileDetails(),
).thenAnswer((_) async => _details());
expect(await repository.getHeightCm(), 176);
});
test('профиль клиента по slug', () async {
when(
() => profileRepository.getProfileDetailsBySlug('client'),
).thenAnswer((_) async => _details(height: 168));
expect(await repository.getHeightCm(slug: 'client'), 168);
});
test('нулевой рост → null', () async {
when(
() => profileRepository.getMyProfileDetails(),
).thenAnswer((_) async => _details(height: 0));
expect(await repository.getHeightCm(), isNull);
});
test('ошибка не 403 → null', () async {
when(() => profileRepository.getMyProfileDetails()).thenThrow(_dio(500));
expect(await repository.getHeightCm(), isNull);
});
test('403 → InsightsForbidden', () async {
when(
() => profileRepository.getProfileDetailsBySlug('client'),
).thenThrow(_dio(403));
expect(
repository.getHeightCm(slug: 'client'),
throwsA(isA<InsightsForbidden>()),
);
});
});
}

View File

@@ -73,7 +73,7 @@ void main() {
return formResult;
}
Future<void> mockSbp(BuildContext context, {required int planId}) async {
Future<void> mockSbp(BuildContext context, {required int planId, double? amount}) async {
sbpCalled = true;
sbpPlanId = planId;
}

View File

@@ -1,18 +1,26 @@
import 'dart:async';
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/app_initializer.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/widgets/loader.dart';
import 'package:eatvio_app/features/chat/models/payment_model.dart';
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
import 'package:eatvio_app/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_cubit.dart';
import 'package:eatvio_app/features/payment/presentation/widgets/sbp_banks_bottom_sheet.dart';
import 'package:eatvio_app/features/payment/presentation/widgets/sbp_qr_bottom_sheet.dart';
import 'package:eatvio_app/features/payment/repositories/payment_repository.dart';
import 'package:eatvio_app/features/payment/repositories/sbp_repository.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:get_it/get_it.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:sbp/models/c2bmembers_model.dart';
import '../../../../fixtures/fixture_reader.dart';
@@ -21,9 +29,21 @@ import 'sbp_banks_bottom_sheet_test.mocks.dart';
@GenerateNiceMocks([
MockSpec<PaymentRepository>(),
MockSpec<SbpRepository>(),
MockSpec<ChatRepository>(),
])
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
// AppConfig в конструкторе читает EnvConfig (dotenv) — инициализируем.
setUpAll(() async {
await dotenv.load(
fileName: 'test.env',
isOptional: true,
mergeWith: const {
'BASE_URL': 'https://eatvio.ru',
'DEV_BASE_URL': 'https://dev2.eatvio.com',
},
);
});
group('SbpBanksBottomSheet', () {
late MockPaymentRepository mockPaymentRepository;
@@ -183,5 +203,66 @@ void main() {
expect(find.byType(Loader), findsNothing);
expect(find.text('Нет установленных банков'), findsOneWidget);
});
testWidgets('QR-кнопка доступна до загрузки списка банков', (tester) async {
const planId = 12;
final banksCompleter = Completer<List<C2bmemberModel>>();
when(
mockPaymentRepository.startPaymentProcess(planId: planId),
).thenAnswer((_) async => alfaPayment);
when(mockSbpRepository.getInstalledBanks()).thenAnswer((_) => banksCompleter.future);
sl.registerLazySingleton<AppConfig>(
() => AppConfig(buildType: AppBuildType.tests),
);
sl.registerLazySingleton<ChatRepository>(() => MockChatRepository());
await tester.pumpWidget(createTestWidget(planId: planId));
await tester.pump();
await tester.pump();
// Банки ещё грузятся (Loader), а QR-кнопка уже доступна.
expect(find.byType(Loader), findsOneWidget);
expect(find.text('Оплатить по QR-коду'), findsOneWidget);
banksCompleter.complete([installedBank]);
await tester.pumpAndSettle();
expect(find.byType(Loader), findsNothing);
expect(find.text('Сбербанк'), findsOneWidget);
expect(find.text('Оплатить по QR-коду'), findsOneWidget);
await GetIt.I.reset();
});
testWidgets('после старта оплаты показывает кнопку QR и открывает QR-шит', (tester) async {
const planId = 12;
when(
mockPaymentRepository.startPaymentProcess(planId: planId),
).thenAnswer((_) async => alfaPayment);
when(mockSbpRepository.getInstalledBanks()).thenAnswer((_) async => [installedBank]);
sl.registerLazySingleton<AppConfig>(
() => AppConfig(buildType: AppBuildType.tests),
);
sl.registerLazySingleton<ChatRepository>(() => MockChatRepository());
await tester.pumpWidget(createTestWidget(planId: planId));
await tester.pumpAndSettle();
expect(find.text('Оплатить по QR-коду'), findsOneWidget);
await tester.tap(find.text('Оплатить по QR-коду'));
await tester.pump();
await tester.pump();
expect(find.byType(SbpQrBottomSheet), findsOneWidget);
expect(find.byType(QrImageView), findsOneWidget);
// Закрываем QR-шит (останавливает polling-таймер).
tester.state<NavigatorState>(find.byType(Navigator).first).pop();
await tester.pump();
await tester.pump();
await GetIt.I.reset();
});
});
}

View File

@@ -0,0 +1,256 @@
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/app_initializer.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
import 'package:eatvio_app/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_cubit.dart';
import 'package:eatvio_app/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_state.dart';
import 'package:eatvio_app/features/payment/presentation/widgets/sbp_qr_bottom_sheet.dart';
import 'package:eatvio_app/features/payment/repositories/payment_repository.dart';
import 'package:eatvio_app/features/payment/repositories/sbp_repository.dart';
import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart';
import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:get_it/get_it.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'sbp_qr_bottom_sheet_test.mocks.dart';
@GenerateNiceMocks([
MockSpec<PaymentRepository>(),
MockSpec<SbpRepository>(),
MockSpec<ChatRepository>(),
MockSpec<ChosenPlanModel>(),
])
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const planId = 12;
const checkoutUrl = 'https://qr.nspk.ru/AD10106TB0BVFTBG9DQ96NKJEIVVTKIO';
group('SbpQrBottomSheet', () {
late MockPaymentRepository mockPaymentRepository;
late MockSbpRepository mockSbpRepository;
late MockChatRepository mockChatRepository;
late MockChosenPlanModel mockPlan;
late ConsultationPaymentCubit cubit;
late Future<bool?>? sheetResult;
setUp(() async {
await GetIt.I.reset();
// AppConfig в конструкторе читает EnvConfig (dotenv) — инициализируем.
await dotenv.load(
fileName: 'test.env',
isOptional: true,
mergeWith: const {
'BASE_URL': 'https://eatvio.ru',
'DEV_BASE_URL': 'https://dev2.eatvio.com',
},
);
sl.registerLazySingleton<AppConfig>(
() => AppConfig(buildType: AppBuildType.tests),
);
sl.registerLazySingleton<ChatRepository>(() => mockChatRepository);
mockPaymentRepository = MockPaymentRepository();
mockSbpRepository = MockSbpRepository();
mockChatRepository = MockChatRepository();
mockPlan = MockChosenPlanModel();
cubit = ConsultationPaymentCubit(
paymentRepository: mockPaymentRepository,
sbpRepository: mockSbpRepository,
);
cubit.emit(
ConsultationPaymentState(
isLoading: false,
checkoutUrl: checkoutUrl,
),
);
sheetResult = null;
});
tearDown(() async {
await GetIt.I.reset();
await cubit.close();
});
Future<void> pumpApp(WidgetTester tester, {double? amount}) {
return tester.pumpWidget(
MaterialApp(
theme: ThemeHelper().themeData(),
localizationsDelegates: const [
S.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('ru')],
home: Scaffold(
body: Builder(
builder: (context) => Center(
child: ElevatedButton(
onPressed: () => sheetResult = showSbpQrBottomSheet(
context,
planId: planId,
cubit: cubit,
amount: amount,
),
child: const Text('open'),
),
),
),
),
),
);
}
Future<void> openSheet(WidgetTester tester, {double? amount}) async {
await pumpApp(tester, amount: amount);
await tester.tap(find.text('open'));
await tester.pump();
await tester.pump();
}
/// Закрывает шит, чтобы остановить polling-таймер в конце теста.
Future<void> closeSheet(WidgetTester tester) async {
final navigator = tester.state<NavigatorState>(
find.byType(Navigator).first,
);
navigator.pop();
await tester.pump();
await tester.pump();
}
void stubPlanState(ChosenPlanStateEnum state) {
when(mockChatRepository.getChosenPlan(id: planId))
.thenAnswer((_) async => mockPlan);
when(mockPlan.chosenPlanState).thenReturn(state);
}
/// Перерегистрирует DI с AppConfig, смотрящим на конкретный base URL.
Future<void> useApiBaseUrl(String baseUrl) async {
await GetIt.I.reset();
sl.registerLazySingleton<AppConfig>(
() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl(baseUrl),
);
sl.registerLazySingleton<ChatRepository>(() => mockChatRepository);
}
testWidgets('рендерит QR-код из checkout_url и инструкцию', (tester) async {
stubPlanState(ChosenPlanStateEnum.statePendingPayment);
await openSheet(tester);
expect(find.byKey(const ValueKey('sbp_qr_code')), findsOneWidget);
expect(find.byType(QrImageView), findsOneWidget);
expect(find.text('Оплата по QR-коду'), findsOneWidget);
expect(find.text('Ожидание оплаты…'), findsOneWidget);
await closeSheet(tester);
});
testWidgets('показывает сумму, если передана', (tester) async {
stubPlanState(ChosenPlanStateEnum.statePendingPayment);
await openSheet(tester, amount: 1200);
expect(find.text('Сумма к оплате'), findsOneWidget);
expect(find.text('1200 ₽'), findsOneWidget);
await closeSheet(tester);
});
testWidgets('не показывает сумму, если не передана', (tester) async {
stubPlanState(ChosenPlanStateEnum.statePendingPayment);
await openSheet(tester);
expect(find.text('Сумма к оплате'), findsNothing);
await closeSheet(tester);
});
testWidgets('показывает тестовую кнопку на stage-стенде (dev2)', (tester) async {
stubPlanState(ChosenPlanStateEnum.statePendingPayment);
await useApiBaseUrl('https://dev2.eatvio.com');
await openSheet(tester);
expect(find.text('Открыть тестовую оплату'), findsOneWidget);
await closeSheet(tester);
});
testWidgets('показывает тестовую кнопку на стенде-поддомене прод-хоста', (tester) async {
stubPlanState(ChosenPlanStateEnum.statePendingPayment);
// Поддомен eatvio.ru — стенд (например, hasan), а не прод.
await useApiBaseUrl('https://ubooquiequ7ohz9ias3aik4a-hasan.eatvio.ru');
await openSheet(tester);
expect(find.text('Открыть тестовую оплату'), findsOneWidget);
await closeSheet(tester);
});
testWidgets('скрывает тестовую кнопку на продуктовом API', (tester) async {
stubPlanState(ChosenPlanStateEnum.statePendingPayment);
await useApiBaseUrl('https://eatvio.ru');
await openSheet(tester);
expect(find.text('Открыть тестовую оплату'), findsNothing);
// Сам QR при этом работает.
expect(find.byType(QrImageView), findsOneWidget);
await closeSheet(tester);
});
testWidgets('скрывает тестовую кнопку на продуктовом API (eatvio.com)', (tester) async {
stubPlanState(ChosenPlanStateEnum.statePendingPayment);
await useApiBaseUrl('https://eatvio.com');
await openSheet(tester);
expect(find.text('Открыть тестовую оплату'), findsNothing);
await closeSheet(tester);
});
testWidgets('при оплате (statePaid) показывает успех и закрывает шит', (tester) async {
stubPlanState(ChosenPlanStateEnum.statePaid);
await openSheet(tester);
// Первый poll выполняется сразу в initState.
await tester.pump();
expect(find.text('Оплата прошла успешно!'), findsOneWidget);
// Автозакрытие через 2 секунды.
await tester.pump(const Duration(seconds: 2));
await tester.pump();
expect(find.byType(SbpQrBottomSheet), findsNothing);
expect(await sheetResult, isTrue);
});
testWidgets('до оплаты продолжает ждать', (tester) async {
stubPlanState(ChosenPlanStateEnum.statePendingPayment);
await openSheet(tester);
await tester.pump(const Duration(seconds: 3));
await tester.pump();
expect(find.text('Оплата прошла успешно!'), findsNothing);
expect(find.text('Ожидание оплаты…'), findsOneWidget);
await closeSheet(tester);
});
});
}

View File

@@ -1,3 +1,4 @@
import 'package:eatvio_app/features/profile/models/circumference_model.dart';
import 'package:eatvio_app/features/profile/models/weight_model.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -42,6 +43,51 @@ void main() {
expect(json['created_at'], isA<String>());
expect(json['updated_at'], isA<String>());
});
Map<String, dynamic> json({Object? weight = 72.3, Object? bmi = 22.4}) => {
'id': 1,
'user_id': 2,
'weight': weight,
'created_at': '2026-11-01T10:00:00.000000Z',
'updated_at': '2026-11-01T10:00:00.000000Z',
'bmi': bmi,
};
test('парсит дробный weight', () {
final model = WeightModel.fromJson(json());
expect(model.weight, 72.3);
expect(model.bmi, 22.4);
});
test('парсит целый weight как double', () {
final model = WeightModel.fromJson(json(weight: 72));
expect(model.weight, 72.0);
});
test('допускает bmi: null (рост не указан)', () {
final model = WeightModel.fromJson(json(bmi: null));
expect(model.bmi, isNull);
});
});
group('CircumferenceModel', () {
test('парсит целые и дробные значения (прореженные средние)', () {
final model = CircumferenceModel.fromJson({
'id': 1,
'user_id': 2,
'chest_circumference': 95,
'waist_circumference': 80.5,
'hip_circumference': 100,
'created_at': '2026-11-01T10:00:00.000000Z',
'updated_at': '2026-11-01T10:00:00.000000Z',
});
expect(model.chestCircumference, 95.0);
expect(model.waistCircumference, 80.5);
expect(model.hipCircumference, 100.0);
});
});
}

View File

@@ -4,9 +4,8 @@ import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart';
import 'package:eatvio_app/features/profile/models/circumference_model.dart';
import 'package:eatvio_app/features/insights/repositories/insights_repository.dart';
import 'package:eatvio_app/features/profile/models/profile_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' as profile_plans;
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
import 'package:eatvio_app/features/profile/presentation/pages/profile_screen.dart';
@@ -45,6 +44,7 @@ void main() {
plansRepository = MockPlansRepository();
sl.registerLazySingleton<ProfileRepository>(() => profileRepository);
sl.registerLazySingleton<InsightsRepository>(() => InsightsRepository(profileRepository: profileRepository));
sl.registerLazySingleton<ChatRepository>(() => chatRepository);
sl.registerLazySingleton<ConsultantRepository>(() => consultantRepository);
sl.registerLazySingleton<PlansRepository>(() => plansRepository);
@@ -117,8 +117,6 @@ void main() {
when(profileRepository.getMyProfile()).thenAnswer((_) async => profile);
when(profileRepository.getMyProfileDetails()).thenAnswer((_) async => profileDetails);
when(profileRepository.getBlogPostsByAuthorSlug(slug: anyNamed('slug'))).thenAnswer((_) async => []);
when(profileRepository.getWeight(period: anyNamed('period'))).thenAnswer((_) async => <WeightModel>[]);
when(profileRepository.getCircumferences(period: anyNamed('period'))).thenAnswer((_) async => <CircumferenceModel>[]);
await mockNetworkImagesFor(() async {
await tester.pumpWidget(wrap(const ProfileScreen()));
@@ -190,8 +188,6 @@ void main() {
when(profileRepository.getMyProfileDetails()).thenAnswer((_) async => profileDetails);
when(profileRepository.getProfileDetailsBySlug(any)).thenAnswer((_) async => profileDetails);
when(profileRepository.getBlogPostsByAuthorSlug(slug: anyNamed('slug'))).thenAnswer((_) async => []);
when(profileRepository.getWeight(period: anyNamed('period'))).thenAnswer((_) async => <WeightModel>[]);
when(profileRepository.getCircumferences(period: anyNamed('period'))).thenAnswer((_) async => <CircumferenceModel>[]);
await mockNetworkImagesFor(() async {
await tester.pumpWidget(wrap(const ProfileScreen(slug: 'test-user')));

View File

@@ -1,47 +0,0 @@
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/profile/models/circumference_model.dart';
import 'package:eatvio_app/features/profile/presentation/widgets/circumference_chart_widget.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:intl/date_symbol_data_local.dart';
void main() {
group('CircumferenceChartWidget smoke', () {
setUpAll(() async {
await initializeDateFormatting('ru', null);
});
Widget wrap(Widget child) {
return MaterialApp(
theme: ThemeHelper().themeData(),
home: Scaffold(body: child),
);
}
testWidgets('renders circumference chart widget with empty points', (tester) async {
await tester.pumpWidget(wrap(const CircumferenceChartWidget(circumferencePoints: [])));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(find.byType(CircumferenceChartWidget), findsOneWidget);
});
testWidgets('renders circumference chart widget with points', (tester) async {
final points = [
CircumferenceModel(
id: 1,
userId: 1,
chestCircumference: 90,
waistCircumference: 80,
hipCircumference: 95,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
];
await tester.pumpWidget(wrap(CircumferenceChartWidget(circumferencePoints: points)));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(find.byType(CircumferenceChartWidget), findsOneWidget);
});
});
}

View File

@@ -1,132 +0,0 @@
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/app_initializer.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/profile/models/weight_model.dart';
import 'package:eatvio_app/features/profile/presentation/widgets/imt_chart_widget.dart';
import 'package:flutter/material.dart';
import 'package:flutter_portal/flutter_portal.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:intl/date_symbol_data_local.dart';
void main() {
group('ImtChartWidget', () {
setUp(() async {
await sl.reset();
await initializeDateFormatting('ru', null);
dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost');
sl.registerLazySingleton<AppConfig>(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost'));
});
Widget createTestWidget(Widget child) {
return MaterialApp(
theme: ThemeHelper().themeData(),
home: Portal(
child: Scaffold(body: SingleChildScrollView(child: child)),
),
);
}
testWidgets('should display chart with empty weight points', (WidgetTester tester) async {
// Arrange
const widget = ImtChartWidget(weightPoints: []);
// Act
await tester.pumpWidget(createTestWidget(widget));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
// Assert
expect(find.byType(ImtChartWidget), findsOneWidget);
});
testWidgets('should display chart with single weight point', (WidgetTester tester) async {
// Arrange
final weightPoints = [
WeightModel(
id: 1,
userId: 1,
weight: 70,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
bmi: 23.5,
),
];
final widget = ImtChartWidget(weightPoints: weightPoints);
// Act
await tester.pumpWidget(createTestWidget(widget));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
// Assert
expect(find.byType(ImtChartWidget), findsOneWidget);
});
testWidgets('should display chart with multiple weight points and show increase arrow', (WidgetTester tester) async {
// Arrange
final now = DateTime.now();
final weightPoints = [
WeightModel(
id: 1,
userId: 1,
weight: 70,
createdAt: now.subtract(const Duration(days: 7)),
updatedAt: now.subtract(const Duration(days: 7)),
bmi: 23.0,
),
WeightModel(
id: 2,
userId: 1,
weight: 72,
createdAt: now,
updatedAt: now,
bmi: 24.0,
),
];
final widget = ImtChartWidget(weightPoints: weightPoints);
// Act
await tester.pumpWidget(createTestWidget(widget));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
// Assert
expect(find.byType(ImtChartWidget), findsOneWidget);
});
testWidgets('should display chart with multiple weight points and show decrease arrow', (WidgetTester tester) async {
// Arrange
final now = DateTime.now();
final weightPoints = [
WeightModel(
id: 1,
userId: 1,
weight: 72,
createdAt: now.subtract(const Duration(days: 7)),
updatedAt: now.subtract(const Duration(days: 7)),
bmi: 24.0,
),
WeightModel(
id: 2,
userId: 1,
weight: 70,
createdAt: now,
updatedAt: now,
bmi: 23.0,
),
];
final widget = ImtChartWidget(weightPoints: weightPoints);
// Act
await tester.pumpWidget(createTestWidget(widget));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
// Assert
expect(find.byType(ImtChartWidget), findsOneWidget);
});
});
}

View File

@@ -1,64 +0,0 @@
import 'package:eatvio_app/core/theme/theme_helper.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/weight_chart_widget.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:intl/intl.dart';
void main() {
group('Profile chart widgets smoke', () {
setUpAll(() async {
Intl.defaultLocale = 'ru';
await initializeDateFormatting('ru');
});
Widget wrap(Widget child) {
return MaterialApp(
theme: ThemeHelper().themeData(),
home: Scaffold(body: SingleChildScrollView(child: child)),
);
}
testWidgets('WeightChartWidget renders empty state', (tester) async {
await tester.pumpWidget(wrap(const WeightChartWidget(weightPoints: [])));
await tester.pumpAndSettle(const Duration(milliseconds: 200));
expect(find.text('Добавьте Ваш вес'), findsOneWidget);
});
testWidgets('WeightChartWidget renders with one point', (tester) async {
final points = [WeightModel(id: 1, userId: 1, weight: 70, createdAt: DateTime(2025, 1, 1), updatedAt: DateTime(2025, 1, 1), bmi: 22.0)];
await tester.pumpWidget(wrap(WeightChartWidget(weightPoints: points)));
await tester.pumpAndSettle(const Duration(milliseconds: 200));
expect(find.textContaining('кг'), findsWidgets);
});
testWidgets('CircumferenceChartWidget renders empty state', (tester) async {
await tester.pumpWidget(wrap(const CircumferenceChartWidget(circumferencePoints: [])));
await tester.pumpAndSettle(const Duration(milliseconds: 200));
expect(find.text('Добавьте Ваши замеры'), findsOneWidget);
});
testWidgets('CircumferenceChartWidget renders with one point', (tester) async {
final points = [
CircumferenceModel(
id: 1,
userId: 1,
chestCircumference: 100,
waistCircumference: 80,
hipCircumference: 90,
createdAt: DateTime(2025, 1, 1),
updatedAt: DateTime(2025, 1, 1),
),
];
await tester.pumpWidget(wrap(CircumferenceChartWidget(circumferencePoints: points)));
await tester.pumpAndSettle(const Duration(milliseconds: 200));
expect(find.text('100'), findsOneWidget);
expect(find.text('80'), findsOneWidget);
expect(find.text('90'), findsOneWidget);
});
});
}

View File

@@ -1,37 +0,0 @@
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/profile/models/weight_model.dart';
import 'package:eatvio_app/features/profile/presentation/widgets/weight_chart_widget.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:intl/date_symbol_data_local.dart';
void main() {
group('WeightChartWidget smoke', () {
setUpAll(() async {
await initializeDateFormatting('ru', null);
});
Widget wrap(Widget child) {
return MaterialApp(
theme: ThemeHelper().themeData(),
home: Scaffold(body: child),
);
}
testWidgets('renders weight chart widget with empty points', (tester) async {
await tester.pumpWidget(wrap(const WeightChartWidget(weightPoints: [])));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(find.byType(WeightChartWidget), findsOneWidget);
});
testWidgets('renders weight chart widget with points', (tester) async {
final points = [WeightModel(id: 1, userId: 1, weight: 70, bmi: 22.5, createdAt: DateTime.now(), updatedAt: DateTime.now())];
await tester.pumpWidget(wrap(WeightChartWidget(weightPoints: points)));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(find.byType(WeightChartWidget), findsOneWidget);
});
});
}

View File

@@ -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:eatvio_app/features/profile/repositories/profile_repository.dart';
import 'package:eatvio_app/features/user_details/presentation/cubit/user_details_cubit.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -28,8 +26,6 @@ void main() {
expect(cubit.state.isLoading, isFalse);
expect(cubit.state.error, isNull);
expect(cubit.state.profileDetails, isNull);
expect(cubit.state.weightPoints, isNull);
expect(cubit.state.circumferencePoints, isNull);
});
test('getUserDetails - успешная загрузка деталей пользователя', () async {
@@ -62,37 +58,8 @@ void main() {
activityCoefficient: 1.5,
);
final weightPoints = [
WeightModel(
id: 1,
userId: 1,
weight: 70,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
bmi: 22.9,
),
];
final circumferencePoints = [
CircumferenceModel(
id: 1,
userId: 1,
chestCircumference: 100,
waistCircumference: 80,
hipCircumference: 90,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
];
when(mockProfileRepository.getProfileDetailsBySlug(slug))
.thenAnswer((_) async => profileDetails);
when(mockProfileRepository.getWeight(period: '1', slug: slug))
.thenAnswer((_) async => weightPoints);
when(mockProfileRepository.getCircumferences(period: '1', slug: slug))
.thenAnswer((_) async => circumferencePoints);
cubit.getUserDetails(slug);
@@ -119,75 +86,6 @@ void main() {
expect(cubit.state.error, equals(error.toString()));
expect(cubit.state.isLoading, isFalse);
});
test('getChartData - успешная загрузка данных графика', () async {
const slug = 'test-user';
const period = '1';
final weightPoints = [
WeightModel(
id: 1,
userId: 1,
weight: 70,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
bmi: 22.9,
),
WeightModel(
id: 2,
userId: 1,
weight: 71,
createdAt: DateTime.now().add(const Duration(days: 1)),
updatedAt: DateTime.now().add(const Duration(days: 1)),
bmi: 23.2,
),
];
final circumferencePoints = [
CircumferenceModel(
id: 1,
userId: 1,
chestCircumference: 100,
waistCircumference: 80,
hipCircumference: 90,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
];
when(mockProfileRepository.getWeight(period: period, slug: slug))
.thenAnswer((_) async => weightPoints);
when(mockProfileRepository.getCircumferences(period: period, slug: slug))
.thenAnswer((_) async => circumferencePoints);
cubit.getChartData(period: period, slug: slug);
// Ждем завершения асинхронных операций
await Future.delayed(const Duration(milliseconds: 100));
expect(cubit.state.weightPoints, equals(weightPoints));
expect(cubit.state.circumferencePoints, equals(circumferencePoints));
verify(mockProfileRepository.getWeight(period: period, slug: slug)).called(1);
verify(mockProfileRepository.getCircumferences(period: period, slug: slug)).called(1);
});
test('getChartData - обработка ошибки', () async {
const slug = 'test-user';
const period = '1';
final error = Exception('Ошибка загрузки');
when(mockProfileRepository.getWeight(period: period, slug: slug))
.thenThrow(error);
cubit.getChartData(period: period, slug: slug);
// Ждем завершения асинхронных операций
await Future.delayed(const Duration(milliseconds: 100));
expect(cubit.state.error, equals(error.toString()));
expect(cubit.state.isLoading, isFalse);
});
});
}

View File

@@ -2,6 +2,7 @@ import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/app_initializer.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/insights/repositories/insights_repository.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';
@@ -97,10 +98,11 @@ void main() {
final cubit = UserDetailsCubit(profileRepository: profileRepository);
when(profileRepository.getProfileDetailsBySlug('test-slug')).thenAnswer((_) async => mockProfileDetails);
when(profileRepository.getWeight(period: '1', slug: 'test-slug')).thenAnswer((_) async => mockWeightPoints);
when(profileRepository.getCircumferences(period: '1', slug: 'test-slug')).thenAnswer((_) async => mockCircumferencePoints);
when(profileRepository.getWeight(period: '3', slug: 'test-slug')).thenAnswer((_) async => mockWeightPoints);
when(profileRepository.getCircumferences(period: '3', slug: 'test-slug')).thenAnswer((_) async => mockCircumferencePoints);
sl.registerLazySingleton<ProfileRepository>(() => profileRepository);
sl.registerLazySingleton<InsightsRepository>(() => InsightsRepository(profileRepository: profileRepository));
sl.registerLazySingleton<UserDetailsCubit>(() => cubit);
await tester.pumpWidget(wrap(const UserDetailsScreen(slug: 'test-slug')));
@@ -117,10 +119,11 @@ void main() {
final cubit = UserDetailsCubit(profileRepository: profileRepository);
when(profileRepository.getProfileDetailsBySlug('test-slug')).thenAnswer((_) async => mockProfileDetails);
when(profileRepository.getWeight(period: '1', slug: 'test-slug')).thenAnswer((_) async => mockWeightPoints);
when(profileRepository.getCircumferences(period: '1', slug: 'test-slug')).thenAnswer((_) async => mockCircumferencePoints);
when(profileRepository.getWeight(period: '3', slug: 'test-slug')).thenAnswer((_) async => mockWeightPoints);
when(profileRepository.getCircumferences(period: '3', slug: 'test-slug')).thenAnswer((_) async => mockCircumferencePoints);
sl.registerLazySingleton<ProfileRepository>(() => profileRepository);
sl.registerLazySingleton<InsightsRepository>(() => InsightsRepository(profileRepository: profileRepository));
sl.registerLazySingleton<UserDetailsCubit>(() => cubit);
await tester.pumpWidget(wrap(const UserDetailsScreen(slug: 'test-slug')));
@@ -136,6 +139,7 @@ void main() {
when(profileRepository.getProfileDetailsBySlug('test-slug')).thenThrow(Exception('Error loading profile'));
sl.registerLazySingleton<ProfileRepository>(() => profileRepository);
sl.registerLazySingleton<InsightsRepository>(() => InsightsRepository(profileRepository: profileRepository));
sl.registerLazySingleton<UserDetailsCubit>(() => cubit);
await tester.pumpWidget(wrap(const UserDetailsScreen(slug: 'test-slug')));

View File

@@ -0,0 +1,54 @@
import 'dart:convert';
import 'dart:io';
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/repositories/insights_repository.dart';
import 'package:flutter_test/flutter_test.dart';
InsightsPayloadModel loadInsightsPayloadFixture([String name = 'insights_payload.json']) {
final json = jsonDecode(File('test/fixtures/insights/$name').readAsStringSync()) as Map<String, dynamic>;
return InsightsPayloadModel.fromJson(json);
}
/// Подмена [InsightsRepository] для виджет-тестов: без сети, с журналом вызовов.
class FakeInsightsRepository extends Fake implements InsightsRepository {
FakeInsightsRepository({InsightsPayloadModel? payload, this.error, this.heightCm = 176}) : payload = payload ?? loadInsightsPayloadFixture();
InsightsPayloadModel payload;
/// Если задан — выбрасывается из [getInsights] (например, [InsightsForbidden]).
Object? error;
int? heightCm;
final List<({String? slug, BodyPeriod period})> insightsRequests = [];
final List<double> addedWeights = [];
final List<({int chest, int waist, int hip})> addedCircumferences = [];
@override
Future<int?> getHeightCm({String? slug}) async => heightCm;
@override
Future<InsightsPayloadModel> getInsights({String? slug, required BodyPeriod bodyPeriod, int? heightCm}) async {
insightsRequests.add((slug: slug, period: bodyPeriod));
final failure = error;
if (failure != null) {
throw failure;
}
return payload;
}
@override
Future<void> addWeight({required double weight}) async {
addedWeights.add(weight);
}
@override
Future<void> addCircumference({
required int chestCircumference,
required int waistCircumference,
required int hipCircumference,
}) async {
addedCircumferences.add((chest: chestCircumference, waist: waistCircumference, hip: hipCircumference));
}
}

View File

@@ -69,7 +69,7 @@ class FixtureChatRepository extends ChatRepository {
: super(chatService: _NoopChatService());
@override
Future<String> getChatByUserSlug(String slug) async {
Future<String?> getChatByUserSlug(String slug) async {
return 'chat-uuid-123';
}
}

View File

@@ -0,0 +1,58 @@
{
"weight": {
"labels": ["01.11", "05.11", "09.11"],
"values": [74.5, 73.9, 73.2],
"last": 73.2,
"delta": -1.3,
"headline": "73.2",
"unit": "кг",
"deltaText": "Снизили на 1.3 кг за период"
},
"bmi": {
"labels": [],
"values": [],
"last": null,
"delta": null,
"headline": "—",
"category": null,
"deltaText": "Укажите рост, чтобы посчитать ИМТ.",
"hint": ""
},
"measure": {
"labels": ["01.11", "09.11"],
"waist": [82, 80.5],
"chest": [96, 96],
"hips": [101, 100],
"headline": "81",
"deltaText": "Талия меньше на 1.5 см за период"
},
"daily": {
"labels": ["03.11", "04.11", "05.11"],
"kcal": [1850, 2010, 1930],
"proteins": [92, 101, 96],
"fats": [60, 66, 63],
"carbs": [210, 222, 215],
"average": 1930,
"percent": 98,
"headline": "1930",
"deltaText": "98% от нормы"
},
"intra": {
"bars": [
{"x": 8.5, "p": 120, "f": 90, "c": 240},
{"x": 13, "p": 160, "f": 130, "c": 300}
],
"satiety": [{"x": 8.5, "y": 3}, {"x": 13, "y": 4}],
"mood": [{"x": 8.5, "y": 4}],
"wake": 7.5,
"sleep": 23,
"wakeLabel": "07:30",
"sleepLabel": "23:00",
"wakeText": "Подъём в 07:30",
"sleepText": "Отход ко сну в 23:00",
"date": "2026-11-09",
"maxDate": "2026-11-09",
"dateLabel": "Сегодня",
"isToday": true
}
}

View File

@@ -0,0 +1,7 @@
{
"weight": {"labels": [], "values": [], "last": null, "delta": null, "headline": "—", "unit": "кг", "deltaText": "Недостаточно данных"},
"bmi": {"labels": [], "values": [], "headline": "—", "deltaText": "Укажите рост, чтобы посчитать ИМТ.", "hint": "Укажите рост, чтобы посчитать ИМТ."},
"measure": {"labels": [], "waist": [], "chest": [], "hips": [], "headline": "—", "deltaText": "Недостаточно данных"},
"daily": {"labels": [], "kcal": [], "proteins": [], "fats": [], "carbs": [], "average": null, "percent": null, "headline": "—", "deltaText": ""},
"intra": {"bars": [], "satiety": [], "mood": [], "wake": null, "sleep": null, "date": "2026-11-09", "maxDate": "2026-11-09", "dateLabel": "Сегодня", "isToday": true}
}