Compare commits

..

37 Commits

Author SHA1 Message Date
misha
db3a32c12e feat(insights): review fixes 2026-11-11 16:05:11 +03:00
misha
5228d43f15 Merge remote-tracking branch 'upstream/main' into issue-1123 2026-11-11 10:49:33 +03:00
0511bd2cb5 Merge pull request 'Плашка статуса и диета в чате' (#1132) from alex_chevski/eatvio_app:feature/1115-consultation-status-plank into main
Reviewed-on: eatvio/eatvio_app#1132
Reviewed-by: Анатолий Полоз <t0ly.od@ya.ru>
Reviewed-by: misha <misha@noreply.localhost>
2026-11-10 19:00:59 +00:00
28ae75474a fix: localize diet notification and type plan options 2026-11-10 13:52:06 +03:00
misha
c7198c5a48 fix: exteshions removed 2026-11-10 12:41:21 +03:00
misha
8d4cabf5c6 fix: comments fixed, version changed 2026-11-10 12:35:09 +03:00
misha
8c33f033ff feat(insights): added kbju fetching for analitics 2026-11-10 12:21:53 +03:00
misha
45d072cff4 feat(insights): first isights impemetation 2026-11-09 15:35:10 +03:00
da0ac9d396 Merge branch 'feature/1115-consultation-status-plank' of ssh://10.55.5.123:222/alex_chevski/eatvio_app into feature/1115-consultation-status-plank 2026-11-06 14:28:55 +03:00
22b8ef97b4 Merge branch 'main' into feature/1115-consultation-status-plank 2026-11-06 14:27:12 +03:00
6c4572af55 fix: show personal diet name and consultant notifications 2026-11-06 14:22:55 +03:00
e1a1ce4b68 merge upstream 2026-11-06 11:13:17 +00: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
ad70d0988e Merge branch 'main' into feature/1115-consultation-status-plank
# Conflicts:
#	lib/generated/intl/messages_ru.dart
2026-11-06 13:40:05 +03:00
0241265b3c fix: align diary diet row and hide terminal cancel
- show only the personal diet name in the diary and keep the plan day chip
- hide consultation cancellation for terminal and non-cancellable states
- enable the refund form only for paid, started and in-progress plans
- cover diary rows and order info cancel visibility with tests
2026-11-06 13:33:44 +03: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
57536aba06 fix(personal-diet): open client chat after diet assignment
- navigate to the client chat after a successful attach or reattach
- reset the stack so back returns to the chat list
- keep the diet screen on missing chat id and cover the flow with tests
2026-11-05 18:28:16 +03:00
16084ee94c fix(chat): guard diet duration and keep progress plank
- show diet duration hint and plan duration in suggestion flow
- disable diet CTA on duration mismatch with grey visual state
- keep progress plank visible for personal_diet_setted
- center step labels under their circles
2026-11-03 17:40:54 +03:00
bd1155233f feat(chat): add consultation progress and personal diet status
related to #1115
2026-11-03 14:45:44 +03: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
148 changed files with 13210 additions and 2043 deletions

2
.gitignore vendored
View File

@@ -16,6 +16,8 @@
*.mocks.dart *.mocks.dart
android/build/ android/build/
ios/XCBuildData/ ios/XCBuildData/
docs/
specs/
env/.env env/.env
.env .env
android/key.properties android/key.properties

View File

@@ -44,36 +44,94 @@ platform :ios do
UI.message "Using auth_key_path: #{auth_key_path}" 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 # Configure Flutter for release mode
UI.message "Configuring Flutter for release mode..." UI.message "Configuring Flutter for release mode..."
sh("cd .. && flutter build ios --config-only --release") 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") generated_xcconfig_path = File.join("..", "Flutter", "Generated.xcconfig")
flutter_build_name = nil
flutter_build_number = nil flutter_build_number = nil
if File.exist?(generated_xcconfig_path) if File.exist?(generated_xcconfig_path)
File.foreach(generated_xcconfig_path) do |line| File.foreach(generated_xcconfig_path) do |line|
matches = line.match(/FLUTTER_BUILD_NUMBER=(.*)/) if (m = line.match(/FLUTTER_BUILD_NAME=(.*)/))
if matches flutter_build_name = m[1].strip
flutter_build_number = matches[1].strip elsif (m = line.match(/FLUTTER_BUILD_NUMBER=(.*)/))
break flutter_build_number = m[1].strip
end end
end end
end end
if flutter_build_number.nil? if flutter_build_name.nil? || flutter_build_number.nil?
UI.user_error!("Could not find FLUTTER_BUILD_NUMBER in #{generated_xcconfig_path}") UI.user_error!("Could not find FLUTTER_BUILD_NAME/FLUTTER_BUILD_NUMBER in #{generated_xcconfig_path}")
end 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) match(type: "appstore", readonly: true)
# Set build number explicitly instead of incrementing # Set build number explicitly instead of incrementing
increment_build_number( increment_build_number(
xcodeproj: "Runner.xcodeproj", xcodeproj: "Runner.xcodeproj",
build_number: flutter_build_number build_number: new_build_number
) )
update_code_signing_settings( update_code_signing_settings(
@@ -93,28 +151,33 @@ platform :ios do
export_team_id: "VL68C36HYS" 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 begin
upload_to_testflight( upload_to_testflight(
skip_waiting_for_build_processing: true, skip_waiting_for_build_processing: true,
skip_submission: true, skip_submission: true,
distribute_external: false, distribute_external: false,
notify_external_testers: false, notify_external_testers: false,
changelog: "TestFlight build #{flutter_build_number}", changelog: "TestFlight build #{new_build_number}",
verbose: true verbose: true
) )
rescue => ex rescue => ex
UI.error "Upload to TestFlight failed: #{ex.message}" # Xcode 26 altool prints a harmless "ERROR: ... CDUploaderStateInfoResumeFilePath"
UI.error "Full error: #{ex.backtrace.join("\n")}" # line even when the package was delivered fine (UPLOAD SUCCEEDED appears in the
raise ex # 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 end
end end

View File

@@ -33,6 +33,20 @@ class AppConfig {
late String _baseUrl; late String _baseUrl;
String get baseUrl => _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) { void setBaseUrl(String baseUrls) {
_baseUrl = baseUrls; _baseUrl = baseUrls;
_persistBaseUrl(baseUrls); _persistBaseUrl(baseUrls);

View File

@@ -277,7 +277,7 @@ class CubitRegistrations {
), ),
); );
sl.registerFactory<ConsultationsCubit>( sl.registerFactory<ConsultationsCubit>(
() => ConsultationsCubit(consultationsRepository: sl()), () => ConsultationsCubit(consultationsRepository: sl(), chatRepository: sl()),
); );
sl.registerFactory<ConsultantCommentsCubit>( sl.registerFactory<ConsultantCommentsCubit>(
() => ConsultantCommentsCubit(diaryRepository: sl()), () => 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/feed/repositories/feed_repository.dart';
import 'package:eatvio_app/features/glossary/repositories/glossary_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/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/presentation/repositories/blocked_users_repository.dart';
import 'package:eatvio_app/features/menu/repositories/notification_settings_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'; 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<VideoCallRepository>(() => VideoCallRepository(videoCallService: sl()));
sl.registerLazySingleton<FeedRepository>(() => FeedRepository(feedService: sl())); sl.registerLazySingleton<FeedRepository>(() => FeedRepository(feedService: sl()));
sl.registerLazySingleton<ProfileRepository>(() => ProfileRepository(profileService: sl())); sl.registerLazySingleton<ProfileRepository>(() => ProfileRepository(profileService: sl()));
sl.registerLazySingleton<InsightsRepository>(() => InsightsRepository(profileRepository: sl(), insightsService: sl()));
sl.registerLazySingleton<CalendarRepository>(() => CalendarRepository(calendarService: sl())); sl.registerLazySingleton<CalendarRepository>(() => CalendarRepository(calendarService: sl()));
sl.registerLazySingleton<SettingsRepository>(() => SettingsRepository(settingsService: sl())); sl.registerLazySingleton<SettingsRepository>(() => SettingsRepository(settingsService: sl()));
sl.registerLazySingleton<ConsultantRepository>(() => ConsultantRepository(consultantService: sl())); sl.registerLazySingleton<ConsultantRepository>(() => ConsultantRepository(consultantService: sl()));

View File

@@ -19,6 +19,7 @@ import 'package:eatvio_app/features/diary/services/diary_service.dart';
import 'package:eatvio_app/features/diets/services/diet_service.dart'; import 'package:eatvio_app/features/diets/services/diet_service.dart';
import 'package:eatvio_app/features/feed/services/feed_service.dart'; import 'package:eatvio_app/features/feed/services/feed_service.dart';
import 'package:eatvio_app/features/glossary/services/glossary_service.dart'; import 'package:eatvio_app/features/glossary/services/glossary_service.dart';
import 'package:eatvio_app/features/insights/services/insights_service.dart';
import 'package:eatvio_app/features/barcode/services/barcode_service.dart'; import 'package:eatvio_app/features/barcode/services/barcode_service.dart';
import 'package:eatvio_app/features/menu/services/blocked_users_service.dart'; import 'package:eatvio_app/features/menu/services/blocked_users_service.dart';
import 'package:eatvio_app/features/menu/services/notification_settings_service.dart'; import 'package:eatvio_app/features/menu/services/notification_settings_service.dart';
@@ -57,6 +58,7 @@ class ServiceRegistrations {
sl.registerLazySingleton<VideoCallService>(() => VideoCallService(sl())); sl.registerLazySingleton<VideoCallService>(() => VideoCallService(sl()));
sl.registerLazySingleton<FeedService>(() => FeedService(sl())); sl.registerLazySingleton<FeedService>(() => FeedService(sl()));
sl.registerLazySingleton<ProfileService>(() => ProfileService(sl())); sl.registerLazySingleton<ProfileService>(() => ProfileService(sl()));
sl.registerLazySingleton<InsightsService>(() => InsightsService(sl()));
sl.registerLazySingleton<CalendarService>(() => CalendarService(sl())); sl.registerLazySingleton<CalendarService>(() => CalendarService(sl()));
sl.registerLazySingleton<SettingsService>(() => SettingsService(sl())); sl.registerLazySingleton<SettingsService>(() => SettingsService(sl()));
sl.registerLazySingleton<ConsultantService>(() => ConsultantService(sl())); sl.registerLazySingleton<ConsultantService>(() => ConsultantService(sl()));

View File

@@ -192,7 +192,18 @@ class OtherRoutes {
return CreatePlanScreen(plan: plan); return CreatePlanScreen(plan: plan);
}, },
), ),
GoRoute(path: MyDietsScreen.path, name: MyDietsScreen.name, builder: (context, state) => const MyDietsScreen()), GoRoute(
path: MyDietsScreen.path,
name: MyDietsScreen.name,
builder: (context, state) {
final initialTabIndex = int.tryParse(
state.uri.queryParameters['initialTabIndex'] ?? '',
);
return MyDietsScreen(
initialTabIndex: initialTabIndex ?? 0,
);
},
),
GoRoute(path: MyPlansScreen.path, name: MyPlansScreen.name, builder: (context, state) => const MyPlansScreen()), GoRoute(path: MyPlansScreen.path, name: MyPlansScreen.name, builder: (context, state) => const MyPlansScreen()),
GoRoute(path: MyRecipesScreen.path, name: MyRecipesScreen.name, builder: (context, state) => const MyRecipesScreen()), GoRoute(path: MyRecipesScreen.path, name: MyRecipesScreen.name, builder: (context, state) => const MyRecipesScreen()),
@@ -354,9 +365,10 @@ class OtherRoutes {
path: UserDiaryScreen.path, path: UserDiaryScreen.path,
name: UserDiaryScreen.name, name: UserDiaryScreen.name,
builder: (context, state) { 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; final diaryId = data['diaryId'] as int;
return UserDiaryScreen(diaryId: diaryId); final ownerSlug = data['ownerSlug'] as String?;
return UserDiaryScreen(diaryId: diaryId, ownerSlug: ownerSlug);
}, },
), ),
GoRoute( GoRoute(

View File

@@ -48,6 +48,7 @@ class WsManager {
Stream<ChannelReadEvent>? _directChatStream; Stream<ChannelReadEvent>? _directChatStream;
Stream<ChannelReadEvent>? _clientTypingStream; Stream<ChannelReadEvent>? _clientTypingStream;
Stream<ChannelReadEvent>? _chosenPlanStateChangedStream;
Stream<ChannelReadEvent>? get newMessagesStream => Stream<ChannelReadEvent>? get newMessagesStream =>
_privateMessagesChannel?.bind('App\\Events\\ChatMessageUpdated'); _privateMessagesChannel?.bind('App\\Events\\ChatMessageUpdated');
@@ -74,6 +75,9 @@ class WsManager {
Stream<ChannelReadEvent>? get clientTypingStream => _clientTypingStream; Stream<ChannelReadEvent>? get clientTypingStream => _clientTypingStream;
Stream<ChannelReadEvent>? get chosenPlanStateChangedStream =>
_chosenPlanStateChangedStream;
/// Сигнал о реконнекте сокета: эмитится после повторной подписки на каналы. /// Сигнал о реконнекте сокета: эмитится после повторной подписки на каналы.
/// ///
/// Подписчики используют его, чтобы догрузить пропущенные за время обрыва /// Подписчики используют его, чтобы догрузить пропущенные за время обрыва
@@ -222,6 +226,9 @@ class WsManager {
'App\\Events\\ChatMessageUpdated', 'App\\Events\\ChatMessageUpdated',
); );
_clientTypingStream = _privateDirectChatChannel?.bind('client-typing'); _clientTypingStream = _privateDirectChatChannel?.bind('client-typing');
_chosenPlanStateChangedStream = _privateDirectChatChannel?.bind(
'chosen-plan-state-changed',
);
} }
Future<void> initPresenceDirectChatChannel(String chatId) async { Future<void> initPresenceDirectChatChannel(String chatId) async {
@@ -367,6 +374,7 @@ class WsManager {
_privateDirectChatChannel?.unsubscribe(); _privateDirectChatChannel?.unsubscribe();
_directChatStream = null; _directChatStream = null;
_clientTypingStream = null; _clientTypingStream = null;
_chosenPlanStateChangedStream = null;
} }
void unsubscribePresenceDirectChatChannel() { void unsubscribePresenceDirectChatChannel() {
@@ -401,6 +409,7 @@ class WsManager {
_privateNotificationChannel = null; _privateNotificationChannel = null;
_directChatStream = null; _directChatStream = null;
_clientTypingStream = null; _clientTypingStream = null;
_chosenPlanStateChangedStream = null;
} }
} }

View File

@@ -3,7 +3,11 @@ import 'dart:ui';
import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:flutter/material.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 BuildContext context,
required Widget child, required Widget child,
double? height = 200, double? height = 200,
@@ -11,7 +15,7 @@ Future<void> showBottomSheetWithBlur({
bool enableDrag = true, bool enableDrag = true,
bool userRootNavigator = false, bool userRootNavigator = false,
}) { }) {
return showModalBottomSheet( return showModalBottomSheet<T>(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
barrierColor: appTheme.transparentBlack, barrierColor: appTheme.transparentBlack,

View File

@@ -31,7 +31,9 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
StreamSubscription<ChannelReadEvent>? typingSubscription; StreamSubscription<ChannelReadEvent>? typingSubscription;
StreamSubscription<ChannelReadEvent>? messagesSubscription; StreamSubscription<ChannelReadEvent>? messagesSubscription;
StreamSubscription<ChannelReadEvent>? _chosenPlanStateSubscription;
StreamSubscription<void>? _reconnectSubscription; StreamSubscription<void>? _reconnectSubscription;
bool _isUpdatingChosenPlan = false;
int currentPage = 1; int currentPage = 1;
late int lastPage; late int lastPage;
bool isLoading = false; bool isLoading = false;
@@ -171,6 +173,7 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
log(e.toString()); log(e.toString());
typingSubscription?.cancel(); typingSubscription?.cancel();
messagesSubscription?.cancel(); messagesSubscription?.cancel();
_chosenPlanStateSubscription?.cancel();
_reconnectSubscription?.cancel(); _reconnectSubscription?.cancel();
_reconnectSubscription = null; _reconnectSubscription = null;
wsManager.unsubscribeDirectChatChannel(); wsManager.unsubscribeDirectChatChannel();
@@ -260,19 +263,30 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
} }
Future<void> updateChosenPlanState() async { Future<void> updateChosenPlanState() async {
if (_isUpdatingChosenPlan) return;
try { try {
_isUpdatingChosenPlan = true;
final currentState = state; final currentState = state;
if (currentState is! ChatRoomStateLoaded) { if (currentState is! ChatRoomStateLoaded) {
return; return;
} }
final chosenPlanId = currentState.chatRoom.chosenPlanId; final chosenPlanId =
currentState.chatRoom.chosenPlanId ??
currentState.chatRoom.chosenPlan?.id;
if (chosenPlanId == null) { if (chosenPlanId == null) {
return; return;
} }
final chosenPlan = await chatRepository.getChosenPlan(id: chosenPlanId); final chosenPlan = await chatRepository.getChosenPlan(id: chosenPlanId);
_emitLoaded((latest) => latest.copyWith.chatRoom(chosenPlan: chosenPlan)); if (isClosed) return;
_emitLoaded(
(latest) => latest.copyWith(
chatRoom: latest.chatRoom.copyWith(chosenPlan: chosenPlan),
),
);
} catch (e, st) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
} finally {
_isUpdatingChosenPlan = false;
} }
} }
@@ -338,8 +352,10 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
if (client == null) return; if (client == null) return;
await messagesSubscription?.cancel(); await messagesSubscription?.cancel();
await typingSubscription?.cancel(); await typingSubscription?.cancel();
await _chosenPlanStateSubscription?.cancel();
messagesSubscription = null; messagesSubscription = null;
typingSubscription = null; typingSubscription = null;
_chosenPlanStateSubscription = null;
await wsManager.initPrivateDirectChatChannel(chatId); await wsManager.initPrivateDirectChatChannel(chatId);
wsManager.subscribeDirectChatChannel(); wsManager.subscribeDirectChatChannel();
messagesSubscription = wsManager.directChatStream?.listen( messagesSubscription = wsManager.directChatStream?.listen(
@@ -361,6 +377,20 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
), ),
); );
}); });
_chosenPlanStateSubscription = wsManager
.chosenPlanStateChangedStream
?.listen(
(_) {
EasyDebounce.debounce(
'chosen-plan-state-changed',
const Duration(milliseconds: 250),
() => unawaited(updateChosenPlanState()),
);
},
onError: (Object e, StackTrace st) {
unawaited(reportFrom(e, st, this));
},
);
} }
void _onDirectChatEvent(ChannelReadEvent event) { void _onDirectChatEvent(ChannelReadEvent event) {
@@ -731,6 +761,7 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
Future<void> close() async { Future<void> close() async {
EasyDebounce.cancel('client-typing'); EasyDebounce.cancel('client-typing');
EasyDebounce.cancel('searchChatRoom'); EasyDebounce.cancel('searchChatRoom');
EasyDebounce.cancel('chosen-plan-state-changed');
if (_presenceOnlineListener != null) { if (_presenceOnlineListener != null) {
wsManager.removePresenceDirectChatListener(_presenceOnlineListener!); wsManager.removePresenceDirectChatListener(_presenceOnlineListener!);
_presenceOnlineListener = null; _presenceOnlineListener = null;
@@ -739,6 +770,7 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
_timer?.cancel(); _timer?.cancel();
await messagesSubscription?.cancel(); await messagesSubscription?.cancel();
await typingSubscription?.cancel(); await typingSubscription?.cancel();
await _chosenPlanStateSubscription?.cancel();
await _reconnectSubscription?.cancel(); await _reconnectSubscription?.cancel();
super.close(); super.close();
} }

View File

@@ -11,7 +11,6 @@ import 'package:eatvio_app/features/chat/presentation/bloc/chat_room_cubit/chat_
import 'package:eatvio_app/features/chat/presentation/bloc/chat_room_cubit/chat_room_state.dart'; import 'package:eatvio_app/features/chat/presentation/bloc/chat_room_cubit/chat_room_state.dart';
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart'; import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart'; import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart';
import 'package:eatvio_app/features/chat/presentation/widgets/about_order_button.dart';
import 'package:eatvio_app/features/chat/presentation/widgets/chat_bubble_widget.dart'; import 'package:eatvio_app/features/chat/presentation/widgets/chat_bubble_widget.dart';
import 'package:eatvio_app/features/chat/models/chat_message_model.dart'; import 'package:eatvio_app/features/chat/models/chat_message_model.dart';
import 'package:eatvio_app/features/chat/models/chat_user_model.dart'; import 'package:eatvio_app/features/chat/models/chat_user_model.dart';
@@ -24,9 +23,11 @@ import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked
import 'package:eatvio_app/core/theme/custom_text_style.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/theme/theme_helper.dart';
import 'package:eatvio_app/features/chat/presentation/widgets/chat_user_online_status_line.dart'; import 'package:eatvio_app/features/chat/presentation/widgets/chat_user_online_status_line.dart';
import 'package:eatvio_app/features/chat/presentation/utils/chat_banner_resolver.dart';
import 'package:eatvio_app/features/chat/presentation/widgets/chat_consultation_header.dart';
import 'package:eatvio_app/features/chat/presentation/widgets/notification_banner.dart'; import 'package:eatvio_app/features/chat/presentation/widgets/notification_banner.dart';
import 'package:eatvio_app/features/chat/utils/chat_counterpart_utils.dart'; import 'package:eatvio_app/features/chat/utils/chat_counterpart_utils.dart';
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart'; import 'package:eatvio_app/features/consultations/utils/consultation_progress_mapper.dart';
import 'package:eatvio_app/features/order_info/presentation/info_by_order_screen.dart'; import 'package:eatvio_app/features/order_info/presentation/info_by_order_screen.dart';
import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.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/features/user_details/presentation/user_details_screen.dart';
@@ -63,8 +64,6 @@ class _ChatScreenState extends State<ChatScreen> {
final TextEditingController textEditingController = TextEditingController(); final TextEditingController textEditingController = TextEditingController();
final ChatRoomCubit chatRoomCubit = sl<ChatRoomCubit>(); final ChatRoomCubit chatRoomCubit = sl<ChatRoomCubit>();
final List<int> unreadMessageIds = []; final List<int> unreadMessageIds = [];
final showableChatBadgeStates =
ChosenPlanStateEnum.getShowableChatBadgeStates();
late AppLifecycleListener _listener; late AppLifecycleListener _listener;
List<File> pickedFiles = []; List<File> pickedFiles = [];
@@ -188,49 +187,24 @@ class _ChatScreenState extends State<ChatScreen> {
ChosenPlanStateEnum.statePendingPayment, ChosenPlanStateEnum.statePendingPayment,
ChosenPlanStateEnum.stateInCoordination, ChosenPlanStateEnum.stateInCoordination,
].contains(chosenPlan?.chosenPlanState); ].contains(chosenPlan?.chosenPlanState);
PacketOptions? chosenOption; final chosenOption = chosenPlan?.chosenOption;
if (chosenPlan?.optionType == 'options1') { final progress = chosenPlan == null
chosenOption = chosenPlan?.plan.packet1Options; ? null
} else if (chosenPlan?.optionType == 'options2') { : mapConsultationProgress(
chosenOption = chosenPlan?.plan.packet2Options; state: chosenPlan.chosenPlanState,
} else if (chosenPlan?.optionType == 'options3') { hasPersonalDiet: chosenOption?.hasPersonalDiet ?? false,
chosenOption = chosenPlan?.plan.packet3Options; personalDietName: chosenPlan.personalDietName,
} );
final bannerType = resolveChatBanner(
final showAboutOrderButton = progress: progress,
state.chatRoom.chosenPlan != null && amIConsultant: amIConsultant,
state.chatRoom.chosenPlan!.chosenPlanState != hasCard: state.hasCard,
state: chosenPlan?.chosenPlanState,
);
final showInfoButton =
chosenPlan != null &&
chosenPlan.chosenPlanState !=
ChosenPlanStateEnum.stateCanceledByTheInitiator; ChosenPlanStateEnum.stateCanceledByTheInitiator;
BannerType? bannerType;
if (chosenPlan == null ||
chosenPlan.chosenPlanState ==
ChosenPlanStateEnum.stateCanceledByTheInitiator) {
bannerType = null;
} else if (amIConsultant &&
!state.hasCard &&
chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateInCoordination) {
bannerType = BannerType.card;
} else if (amIConsultant &&
chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateInCoordination) {
bannerType = BannerType.date;
} else if (!amIConsultant &&
(chosenPlan.chosenPlanState ==
ChosenPlanStateEnum.stateStartDateHasBeenSet ||
chosenPlan.chosenPlanState ==
ChosenPlanStateEnum.statePendingPayment)) {
bannerType = BannerType.payment;
} else if (chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateDone &&
amIConsultant) {
bannerType = BannerType.collectCharge;
} else if (chosenPlan.chosenPlanState ==
ChosenPlanStateEnum.stateLastDayFinished &&
!amIConsultant) {
bannerType = BannerType.finishPlan;
} else if (chosenOption?.hasPersonalDiet == true &&
amIConsultant &&
chosenPlan.chosenPlanState != ChosenPlanStateEnum.stateDone) {
bannerType = BannerType.setPersonalDiet;
}
return Scaffold( return Scaffold(
resizeToAvoidBottomInset: true, resizeToAvoidBottomInset: true,
@@ -340,6 +314,28 @@ class _ChatScreenState extends State<ChatScreen> {
Column( Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (progress != null)
ChatConsultationHeader(
progress: progress,
showInfoButton: showInfoButton,
onInfoTap: () {
if (amIConsultant) {
GoRouter.of(context).push(
InfoByOrderScreen.path,
extra: {
'planId': chosenPlan?.id,
'customerSlug':
state.chatRoom.users.receiver.slug,
},
);
return;
}
GoRouter.of(context).push(
InfoByOrderScreen.path,
extra: {'planId': chosenPlan?.id},
);
},
),
Expanded( Expanded(
child: Container( child: Container(
color: appTheme.whiteA700, color: appTheme.whiteA700,
@@ -384,22 +380,9 @@ class _ChatScreenState extends State<ChatScreen> {
final isPlanOrderMessage = final isPlanOrderMessage =
message.type == ChatMessageType.planOrder; message.type == ChatMessageType.planOrder;
PacketOptions? messageOption; final messageOption = isPlanOrderMessage
if (isPlanOrderMessage && ? chosenPlan?.chosenOption
chosenPlan != null) { : null;
if (chosenPlan.optionType == 'options1') {
messageOption =
chosenPlan.plan.packet1Options;
} else if (chosenPlan.optionType ==
'options2') {
messageOption =
chosenPlan.plan.packet2Options;
} else if (chosenPlan.optionType ==
'options3') {
messageOption =
chosenPlan.plan.packet3Options;
}
}
return Column( return Column(
children: [ children: [
@@ -450,21 +433,9 @@ class _ChatScreenState extends State<ChatScreen> {
final isPlanOrderMessage = final isPlanOrderMessage =
message.type == ChatMessageType.planOrder; message.type == ChatMessageType.planOrder;
PacketOptions? messageOption; final messageOption = isPlanOrderMessage
if (isPlanOrderMessage && chosenPlan != null) { ? chosenPlan?.chosenOption
if (chosenPlan.optionType == 'options1') { : null;
messageOption =
chosenPlan.plan.packet1Options;
} else if (chosenPlan.optionType ==
'options2') {
messageOption =
chosenPlan.plan.packet2Options;
} else if (chosenPlan.optionType ==
'options3') {
messageOption =
chosenPlan.plan.packet3Options;
}
}
return Column( return Column(
children: [ children: [
@@ -804,30 +775,6 @@ class _ChatScreenState extends State<ChatScreen> {
), ),
], ],
), ),
if (showAboutOrderButton)
Positioned(
top: 8,
right: 15,
left: 16,
child: AboutOrderButton(
onTap: () async {
if (amIConsultant) {
GoRouter.of(context).push(
InfoByOrderScreen.path,
extra: {
'planId': state.chatRoom.chosenPlan?.id,
'customerSlug': state.chatRoom.users.receiver.slug,
},
);
return;
}
GoRouter.of(context).push(
InfoByOrderScreen.path,
extra: {'planId': state.chatRoom.chosenPlan?.id},
);
},
),
),
], ],
), ),
), ),

View File

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

View File

@@ -0,0 +1,52 @@
import 'package:eatvio_app/features/chat/presentation/widgets/notification_banner.dart';
import 'package:eatvio_app/features/consultations/models/consultation_progress.dart';
import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart';
BannerType? resolveChatBanner({
required ConsultationProgress? progress,
required bool amIConsultant,
required bool hasCard,
required ChosenPlanStateEnum? state,
}) {
if (progress == null ||
state == null ||
progress.phase != ConsultationPhase.active) {
return null;
}
if (amIConsultant &&
!hasCard &&
state == ChosenPlanStateEnum.stateInCoordination) {
return BannerType.card;
}
if (amIConsultant && state == ChosenPlanStateEnum.stateInCoordination) {
return BannerType.date;
}
if (!amIConsultant &&
(state == ChosenPlanStateEnum.stateStartDateHasBeenSet ||
state == ChosenPlanStateEnum.statePendingPayment)) {
return BannerType.payment;
}
if (state == ChosenPlanStateEnum.stateDone && amIConsultant) {
return BannerType.collectCharge;
}
if (state == ChosenPlanStateEnum.stateLastDayFinished && !amIConsultant) {
return BannerType.finishPlan;
}
if (!amIConsultant) {
return null;
}
if (state == ChosenPlanStateEnum.statePaid &&
progress.dietStatus == PersonalDietStatus.notProposed) {
return BannerType.setPersonalDiet;
}
if ((state == ChosenPlanStateEnum.personalDietSet ||
state == ChosenPlanStateEnum.stateStarted ||
state == ChosenPlanStateEnum.stateInProgress) &&
progress.dietStatus == PersonalDietStatus.proposed) {
return BannerType.changePersonalDiet;
}
return null;
}

View File

@@ -0,0 +1,112 @@
import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/consultations/models/consultation_progress.dart';
import 'package:eatvio_app/features/consultations/presentation/widgets/consultation_progress_plank.dart';
import 'package:eatvio_app/features/consultations/presentation/widgets/personal_diet_status_chip.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
class ChatConsultationHeader extends StatelessWidget {
const ChatConsultationHeader({
super.key,
required this.progress,
required this.showInfoButton,
required this.onInfoTap,
});
final ConsultationProgress progress;
final bool showInfoButton;
final VoidCallback onInfoTap;
@override
Widget build(BuildContext context) {
final showPlank = progress.showProgressPlank;
final showChip = progress.hasChip;
if (!showPlank && !showChip && !showInfoButton) {
return const SizedBox.shrink();
}
final s = S.of(context);
return Container(
margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: appTheme.commentBg,
borderRadius: BorderRadius.circular(20),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (progress.phase == ConsultationPhase.active || showPlank)
Row(
children: [
Expanded(
child: Text(
_title(s),
style: TextStyles.textMMedium.copyWith(
color: appTheme.black,
),
),
),
if (showInfoButton)
_InfoButton(
onTap: onInfoTap,
),
],
)
else if (showInfoButton)
Align(
alignment: Alignment.centerRight,
child: _InfoButton(
onTap: onInfoTap,
),
),
if (showPlank) ...[
const SizedBox(height: 8),
ConsultationProgressPlank(progress: progress),
],
if (showChip) ...[
const SizedBox(height: 8),
PersonalDietStatusChip(progress: progress),
],
],
),
);
}
String _title(S s) {
if (progress.isCanceled) return s.orderCanceled;
if (progress.isPaymentFailed) return s.payment_failed;
if (progress.isStepCompleted(ConsultationStep.finished)) return s.done;
if (progress.showProgressPlank) return s.order_in_progress;
return s.more_about_order;
}
}
class _InfoButton extends StatelessWidget {
const _InfoButton({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
customBorder: const CircleBorder(),
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: appTheme.whiteA700,
shape: BoxShape.circle,
),
child: Icon(
Icons.info_outline_rounded,
size: 18,
color: appTheme.blueA400,
),
),
);
}
}

View File

@@ -106,7 +106,11 @@ class _NotificationBannerState extends State<NotificationBanner> {
onTap: () { onTap: () {
final planId = (context.read<ChatRoomCubit>().state as ChatRoomStateLoaded).chatRoom.chosenPlan?.id; final planId = (context.read<ChatRoomCubit>().state as ChatRoomStateLoaded).chatRoom.chosenPlan?.id;
if (planId != null) { 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)), child: Text(s.pay, style: TextStyles.textLSemibold.copyWith(color: appTheme.blueA400)),
@@ -142,12 +146,37 @@ class _NotificationBannerState extends State<NotificationBanner> {
BannerType.setPersonalDiet => [ BannerType.setPersonalDiet => [
SizedBox( SizedBox(
width: size.width * 0.6, width: size.width * 0.6,
child: Text(s.createPersonalDiet, style: TextStyles.textMMedium.copyWith(color: appTheme.black)), child: Text(s.suggestDiet, style: TextStyles.textMMedium.copyWith(color: appTheme.black)),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
GestureDetector( GestureDetector(
onTap: () { onTap: () {
GoRouter.of(context).pushNamed(MyDietsScreen.name); GoRouter.of(context).pushNamed(
MyDietsScreen.name,
queryParameters: {'initialTabIndex': '2'},
);
},
child: Text(s.goToMyDiets, style: TextStyles.textLSemibold.copyWith(color: appTheme.blueA400)),
),
const SizedBox(height: 16),
],
BannerType.changePersonalDiet => [
SizedBox(
width: size.width * 0.6,
child: Text(s.change_personal_diet, style: TextStyles.textMMedium.copyWith(color: appTheme.black)),
),
const SizedBox(height: 8),
SizedBox(
width: size.width * 0.6,
child: Text(s.personal_diet_replaces_tomorrow, style: TextStyles.textSMedium.copyWith(color: appTheme.black)),
),
const SizedBox(height: 12),
GestureDetector(
onTap: () {
GoRouter.of(context).pushNamed(
MyDietsScreen.name,
queryParameters: {'initialTabIndex': '2'},
);
}, },
child: Text(s.goToMyDiets, style: TextStyles.textLSemibold.copyWith(color: appTheme.blueA400)), child: Text(s.goToMyDiets, style: TextStyles.textLSemibold.copyWith(color: appTheme.blueA400)),
), ),
@@ -240,4 +269,4 @@ class _StarIcon extends StatelessWidget {
} }
} }
enum BannerType { date, card, payment, collectCharge, finishPlan, setPersonalDiet } enum BannerType { date, card, payment, collectCharge, finishPlan, setPersonalDiet, changePersonalDiet }

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

View File

@@ -9,6 +9,7 @@ part 'consultation_model.g.dart';
abstract class ConsultationModel with _$ConsultationModel { abstract class ConsultationModel with _$ConsultationModel {
factory ConsultationModel({ factory ConsultationModel({
required int id, required int id,
@JsonKey(name: 'chat_id') String? chatId,
@JsonKey(name: 'last_state') required ChosenPlanStateEnum chosenPlanState, @JsonKey(name: 'last_state') required ChosenPlanStateEnum chosenPlanState,
@JsonKey(name: 'plan_title') required String planTitle, @JsonKey(name: 'plan_title') required String planTitle,
@JsonKey(name: 'user_name') required String userName, @JsonKey(name: 'user_name') required String userName,
@@ -19,10 +20,11 @@ abstract class ConsultationModel with _$ConsultationModel {
@JsonKey(name: 'user_profile_photo_url') required String userProfilePhotoUrl, @JsonKey(name: 'user_profile_photo_url') required String userProfilePhotoUrl,
@JsonKey(name: 'consultation_option_price') required int consultationOptionPrice, @JsonKey(name: 'consultation_option_price') required int consultationOptionPrice,
@JsonKey(name: 'start_date', fromJson: parseDate) required DateTime startDate, @JsonKey(name: 'start_date', fromJson: parseDate) required DateTime startDate,
@JsonKey(name: 'end_date', fromJson: parseDate) required DateTime endDate, @JsonKey(name: 'end_date', fromJson: parseDateOrNull) DateTime? endDate,
@JsonKey(name: 'consultant_id') required int consultantId, @JsonKey(name: 'consultant_id') required int consultantId,
required List<DiaryModel> diaries, required List<DiaryModel> diaries,
@JsonKey(name: 'personal_diet_id') required int? personalDietId, @JsonKey(name: 'personal_diet_id') required int? personalDietId,
@JsonKey(name: 'personal_diet_name') String? personalDietName,
}) = _ConsultationModel; }) = _ConsultationModel;
factory ConsultationModel.fromJson(Map<String, dynamic> json) => _$ConsultationModelFromJson(json); factory ConsultationModel.fromJson(Map<String, dynamic> json) => _$ConsultationModelFromJson(json);
@@ -39,6 +41,11 @@ abstract class DiaryModel with _$DiaryModel {
factory DiaryModel.fromJson(Map<String, dynamic> json) => _$DiaryModelFromJson(json); factory DiaryModel.fromJson(Map<String, dynamic> json) => _$DiaryModelFromJson(json);
} }
DateTime? parseDateOrNull(dynamic date) {
if (date == null) return null;
return parseDate(date as String);
}
DateTime parseUnformattedDate(String date) { DateTime parseUnformattedDate(String date) {
final dateChunks = date.split('-').map(int.parse).toList(); final dateChunks = date.split('-').map(int.parse).toList();

View File

@@ -0,0 +1,33 @@
enum ConsultationStep { startDate, paid, inProgress, finished }
enum PersonalDietStatus { notRequired, notProposed, proposed }
enum ConsultationPhase { active, canceled, paymentFailed }
class ConsultationProgress {
const ConsultationProgress({
required this.completedSteps,
required this.showProgressPlank,
required this.dietStatus,
required this.phase,
this.personalDietName,
});
final Set<ConsultationStep> completedSteps;
final bool showProgressPlank;
final PersonalDietStatus dietStatus;
final ConsultationPhase phase;
final String? personalDietName;
bool isStepCompleted(ConsultationStep step) => completedSteps.contains(step);
bool get hasDietChip => dietStatus != PersonalDietStatus.notRequired;
bool get hasStatusChip => phase != ConsultationPhase.active;
bool get hasChip => hasDietChip || hasStatusChip;
bool get isCanceled => phase == ConsultationPhase.canceled;
bool get isPaymentFailed => phase == ConsultationPhase.paymentFailed;
}

View File

@@ -1,6 +1,7 @@
import 'dart:developer'; import 'dart:developer';
import 'package:eatvio_app/core/enums/base_state.dart'; 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/presentation/cubit/consultations_state.dart';
import 'package:eatvio_app/features/consultations/repositories/consultations_repository.dart'; import 'package:eatvio_app/features/consultations/repositories/consultations_repository.dart';
import 'package:flutter_bloc/flutter_bloc.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> { class ConsultationsCubit extends Cubit<ConsultationsState> {
final ConsultationsRepository consultationsRepository; 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 { Future<void> loadConsultations() async {
try { try {
@@ -20,4 +22,14 @@ class ConsultationsCubit extends Cubit<ConsultationsState> {
emit(state.copyWith(state: BaseState.error)); 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/theme/theme_helper.dart';
import 'package:eatvio_app/core/widgets/custom_divider.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/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/core/widgets/show_error_dialog.dart';
import 'package:eatvio_app/features/consultations/models/consultation_model.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/diary/presentation/pages/diary_screen.dart';
import 'package:eatvio_app/features/my_finance/presentation/pages/card_settings.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/plans/enums/chosen_plan_state_enum.dart';
import 'package:eatvio_app/features/user_details/presentation/user_details_screen.dart'; import 'package:eatvio_app/features/user_details/presentation/user_details_screen.dart';
import 'package:eatvio_app/generated/l10n.dart'; import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
class ConsultationCardWidget extends StatefulWidget { 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)]; final List<Color> legendColors = const [Colors.white, Color(0xFFF0C497), Color(0xFFFF9500), Color(0xFF2F80ED), Color(0xFF4CD964)];
late final List<(int, DiaryModel)> givenDaysWithDayNumber; late final List<(int, DiaryModel)> givenDaysWithDayNumber;
bool _isOpeningChat = false;
final List<List<DiaryStateEnum>> legendStates = [ final List<List<DiaryStateEnum>> legendStates = [
[DiaryStateEnum.init], [DiaryStateEnum.init],
[DiaryStateEnum.started, DiaryStateEnum.lastDayStarted], [DiaryStateEnum.started, DiaryStateEnum.lastDayStarted],
@@ -81,6 +85,30 @@ class _ConsultationCardWidgetState extends State<ConsultationCardWidget> {
widget.consultationsModel.planTitle, widget.consultationsModel.planTitle,
style: const TextStyle(height: 1, color: Color(0xFF88949F), fontSize: 14, fontWeight: FontWeight.w500), style: const TextStyle(height: 1, color: Color(0xFF88949F), fontSize: 14, fontWeight: FontWeight.w500),
), ),
if (widget.consultationsModel.personalDietId != null) ...[
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: appTheme.transparentGreen,
borderRadius: BorderRadius.circular(100),
),
child: Text(
widget.consultationsModel.personalDietName == null ||
widget.consultationsModel.personalDietName!.isEmpty
? s.personal_diet_proposed
: s.personal_diet_proposed_named(
widget.consultationsModel.personalDietName!,
),
style: TextStyles.textXSSemibold.copyWith(
color: appTheme.greenA700,
),
),
),
],
], ],
), ),
], ],
@@ -172,7 +200,7 @@ class _ConsultationCardWidgetState extends State<ConsultationCardWidget> {
); );
return; 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), borderRadius: BorderRadius.circular(100),
child: Ink( child: Ink(
@@ -255,7 +283,7 @@ class _ConsultationCardWidgetState extends State<ConsultationCardWidget> {
.where((e) => e.$2.state == DiaryStateEnum.finished || e.$2.state == DiaryStateEnum.lastDayFinished) .where((e) => e.$2.state == DiaryStateEnum.finished || e.$2.state == DiaryStateEnum.lastDayFinished)
.lastOrNull; .lastOrNull;
if (lastActiveDay != null) { 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; return;
} }
showErrorDialog( showErrorDialog(
@@ -279,7 +307,7 @@ class _ConsultationCardWidgetState extends State<ConsultationCardWidget> {
const SizedBox(width: 8), const SizedBox(width: 8),
InkWell( InkWell(
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
onTap: () {}, onTap: _isOpeningChat ? null : _openChat,
child: Ink( child: Ink(
height: 52, height: 52,
width: 52, width: 52,
@@ -308,6 +336,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) { Row _legendTitle(String text, Color color) {
return Row( return Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,

View File

@@ -0,0 +1,123 @@
import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/consultations/models/consultation_progress.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
class ConsultationProgressPlank extends StatelessWidget {
const ConsultationProgressPlank({super.key, required this.progress});
final ConsultationProgress progress;
static const _steps = [
ConsultationStep.startDate,
ConsultationStep.paid,
ConsultationStep.inProgress,
ConsultationStep.finished,
];
@override
Widget build(BuildContext context) {
if (!progress.showProgressPlank) {
return const SizedBox.shrink();
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var index = 0; index < _steps.length; index++)
Expanded(
child: Column(
children: [
Row(
children: [
Expanded(
child: Container(
height: 2,
color: index == 0
? Colors.transparent
: progress.isStepCompleted(_steps[index - 1])
? appTheme.greenA700
: appTheme.lightGray00,
),
),
_ProgressStepCircle(
key: ValueKey('consultation_step_circle_$index'),
index: index,
isCompleted: progress.isStepCompleted(_steps[index]),
),
Expanded(
child: Container(
height: 2,
color: index == _steps.length - 1
? Colors.transparent
: progress.isStepCompleted(_steps[index])
? appTheme.greenA700
: appTheme.lightGray00,
),
),
],
),
const SizedBox(height: 6),
Text(
_stepLabel(S.of(context), _steps[index]),
key: ValueKey('consultation_step_label_$index'),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyles.textFS10FW500.copyWith(
color: progress.isStepCompleted(_steps[index])
? appTheme.black
: appTheme.gray,
),
),
],
),
),
],
);
}
String _stepLabel(S s, ConsultationStep step) {
switch (step) {
case ConsultationStep.startDate:
return s.startDateHasBeenSet;
case ConsultationStep.paid:
return s.order_paid;
case ConsultationStep.inProgress:
return s.order_in_progress;
case ConsultationStep.finished:
return s.done;
}
}
}
class _ProgressStepCircle extends StatelessWidget {
const _ProgressStepCircle({
super.key,
required this.index,
required this.isCompleted,
});
final int index;
final bool isCompleted;
@override
Widget build(BuildContext context) {
return Container(
width: 24,
height: 24,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isCompleted ? appTheme.greenA700 : appTheme.lightGray00,
),
child: isCompleted
? Icon(Icons.check, size: 14, color: appTheme.whiteA700)
: Text(
'${index + 1}',
style: TextStyles.textFS10FW500.copyWith(color: appTheme.gray),
),
);
}
}

View File

@@ -0,0 +1,61 @@
import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/consultations/models/consultation_progress.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
class PersonalDietStatusChip extends StatelessWidget {
const PersonalDietStatusChip({super.key, required this.progress});
final ConsultationProgress progress;
@override
Widget build(BuildContext context) {
if (!progress.hasChip) {
return const SizedBox.shrink();
}
final s = S.of(context);
final (label, background, foreground) = _chipStyle(s);
return Align(
alignment: Alignment.centerLeft,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(100),
),
child: Text(
label,
style: TextStyles.textXSSemibold.copyWith(color: foreground),
),
),
);
}
(String, Color, Color) _chipStyle(S s) {
if (progress.isCanceled) {
return (s.orderCanceled, appTheme.lightRed, appTheme.red);
}
if (progress.isPaymentFailed) {
return (s.payment_failed, appTheme.lightRed, appTheme.red);
}
if (progress.dietStatus == PersonalDietStatus.proposed) {
final dietName = progress.personalDietName;
final label = dietName == null || dietName.isEmpty
? s.personal_diet_proposed
: s.personal_diet_proposed_named(dietName);
return (
label,
appTheme.transparentGreen,
appTheme.greenA700,
);
}
return (
s.personal_diet_not_proposed,
appTheme.gray100,
appTheme.gray,
);
}
}

View File

@@ -0,0 +1,116 @@
import 'package:eatvio_app/features/consultations/models/consultation_progress.dart';
import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart';
ConsultationProgress mapConsultationProgress({
required ChosenPlanStateEnum state,
required bool hasPersonalDiet,
String? personalDietName,
}) {
return ConsultationProgress(
completedSteps: _completedSteps(state),
showProgressPlank: _showProgressPlank(state),
dietStatus: _dietStatus(state, hasPersonalDiet),
phase: _phase(state),
personalDietName: personalDietName,
);
}
Set<ConsultationStep> _completedSteps(ChosenPlanStateEnum state) {
switch (state) {
case ChosenPlanStateEnum.stateInited:
case ChosenPlanStateEnum.stateConfirmedByConsultant:
case ChosenPlanStateEnum.stateInCoordination:
case ChosenPlanStateEnum.stateCanceledByTheInitiator:
return const {};
case ChosenPlanStateEnum.stateStartDateHasBeenSet:
case ChosenPlanStateEnum.statePendingPayment:
case ChosenPlanStateEnum.statePaymentFailed:
return const {ConsultationStep.startDate};
case ChosenPlanStateEnum.statePaid:
case ChosenPlanStateEnum.personalDietSet:
return const {ConsultationStep.startDate, ConsultationStep.paid};
case ChosenPlanStateEnum.stateStarted:
case ChosenPlanStateEnum.stateInProgress:
case ChosenPlanStateEnum.stateLastDayStarted:
case ChosenPlanStateEnum.stateLastDayFinished:
case ChosenPlanStateEnum.stateComplained:
return const {
ConsultationStep.startDate,
ConsultationStep.paid,
ConsultationStep.inProgress,
};
case ChosenPlanStateEnum.stateDone:
case ChosenPlanStateEnum.stateChargeWasCollected:
return const {
ConsultationStep.startDate,
ConsultationStep.paid,
ConsultationStep.inProgress,
ConsultationStep.finished,
};
}
}
bool _showProgressPlank(ChosenPlanStateEnum state) {
switch (state) {
case ChosenPlanStateEnum.stateInited:
case ChosenPlanStateEnum.stateConfirmedByConsultant:
case ChosenPlanStateEnum.stateChargeWasCollected:
case ChosenPlanStateEnum.stateCanceledByTheInitiator:
case ChosenPlanStateEnum.statePaymentFailed:
return false;
case ChosenPlanStateEnum.stateInCoordination:
case ChosenPlanStateEnum.stateStartDateHasBeenSet:
case ChosenPlanStateEnum.statePendingPayment:
case ChosenPlanStateEnum.statePaid:
case ChosenPlanStateEnum.personalDietSet:
case ChosenPlanStateEnum.stateStarted:
case ChosenPlanStateEnum.stateInProgress:
case ChosenPlanStateEnum.stateLastDayStarted:
case ChosenPlanStateEnum.stateLastDayFinished:
case ChosenPlanStateEnum.stateComplained:
case ChosenPlanStateEnum.stateDone:
return true;
}
}
PersonalDietStatus _dietStatus(
ChosenPlanStateEnum state,
bool hasPersonalDiet,
) {
if (!hasPersonalDiet) {
return PersonalDietStatus.notRequired;
}
switch (state) {
case ChosenPlanStateEnum.personalDietSet:
case ChosenPlanStateEnum.stateStarted:
case ChosenPlanStateEnum.stateInProgress:
case ChosenPlanStateEnum.stateLastDayStarted:
case ChosenPlanStateEnum.stateLastDayFinished:
case ChosenPlanStateEnum.stateComplained:
case ChosenPlanStateEnum.stateDone:
case ChosenPlanStateEnum.stateChargeWasCollected:
return PersonalDietStatus.proposed;
case ChosenPlanStateEnum.stateInited:
case ChosenPlanStateEnum.stateInCoordination:
case ChosenPlanStateEnum.stateConfirmedByConsultant:
case ChosenPlanStateEnum.stateStartDateHasBeenSet:
case ChosenPlanStateEnum.statePendingPayment:
case ChosenPlanStateEnum.statePaid:
return PersonalDietStatus.notProposed;
case ChosenPlanStateEnum.stateCanceledByTheInitiator:
case ChosenPlanStateEnum.statePaymentFailed:
return PersonalDietStatus.notRequired;
}
}
ConsultationPhase _phase(ChosenPlanStateEnum state) {
switch (state) {
case ChosenPlanStateEnum.stateCanceledByTheInitiator:
return ConsultationPhase.canceled;
case ChosenPlanStateEnum.statePaymentFailed:
return ConsultationPhase.paymentFailed;
default:
return ConsultationPhase.active;
}
}

View File

@@ -17,6 +17,9 @@ abstract class DiaryDayEntity with _$DiaryDayEntity {
@JsonKey(name: "chosen_plan_id") required int? chosenPlanId, @JsonKey(name: "chosen_plan_id") required int? chosenPlanId,
@JsonKey(name: "day_of_chosen_diet") required int? dayOfChosenDiet, @JsonKey(name: "day_of_chosen_diet") required int? dayOfChosenDiet,
@JsonKey(name: "chosen_diet", fromJson: parseDiet) required DiaryDietModel? diet, @JsonKey(name: "chosen_diet", fromJson: parseDiet) required DiaryDietModel? diet,
/// Slug владельца дневника; `null`, если сервер его не вернул.
String? ownerSlug,
}) = _DiaryDayEntity; }) = _DiaryDayEntity;
const DiaryDayEntity._(); const DiaryDayEntity._();

View File

@@ -48,6 +48,10 @@ abstract class DiaryDayModel with _$DiaryDayModel {
@JsonKey(name: "chosen_plan_id") required int? chosenPlanId, @JsonKey(name: "chosen_plan_id") required int? chosenPlanId,
@JsonKey(name: "day_of_chosen_diet") required int? dayOfChosenDiet, @JsonKey(name: "day_of_chosen_diet") required int? dayOfChosenDiet,
@JsonKey(name: "chosen_diet", fromJson: parseDiet) required DiaryDietModel? diet, @JsonKey(name: "chosen_diet", fromJson: parseDiet) required DiaryDietModel? diet,
/// Владелец дневника (`user` в `DiaryResource`): по `slug` консультант
/// запрашивает `/api/profile/insights/{slug}`.
@JsonKey(name: "user") DiaryOwnerModel? owner,
}) = _DiaryDayModel; }) = _DiaryDayModel;
const DiaryDayModel._(); const DiaryDayModel._();
factory DiaryDayModel.fromJson(Map<String, dynamic> json) => _$DiaryDayModelFromJson(json); factory DiaryDayModel.fromJson(Map<String, dynamic> json) => _$DiaryDayModelFromJson(json);
@@ -68,6 +72,7 @@ abstract class DiaryDayModel with _$DiaryDayModel {
wakeup: wakeup, wakeup: wakeup,
retire: retire, retire: retire,
diet: diet, diet: diet,
ownerSlug: owner?.slug,
); );
} }
@@ -81,6 +86,13 @@ DiaryDietModel? parseDiet(dynamic json) {
return DiaryDietModel.fromJson(json['diet']); return DiaryDietModel.fromJson(json['diet']);
} }
@freezed
abstract class DiaryOwnerModel with _$DiaryOwnerModel {
const factory DiaryOwnerModel({int? id, String? slug}) = _DiaryOwnerModel;
factory DiaryOwnerModel.fromJson(Map<String, dynamic> json) => _$DiaryOwnerModelFromJson(json);
}
@freezed @freezed
abstract class DiaryDietModel with _$DiaryDietModel { abstract class DiaryDietModel with _$DiaryDietModel {
const factory DiaryDietModel({required int id, required int duration, required String name}) = _DiaryDietModel; const factory DiaryDietModel({required int id, required int duration, required String name}) = _DiaryDietModel;

View File

@@ -20,7 +20,9 @@ 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/finish_day_button_widget.dart';
import 'package:eatvio_app/features/diary/presentation/widgets/meal_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/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:eatvio_app/generated/l10n.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
@@ -36,18 +38,27 @@ class DiaryScreen extends StatelessWidget {
class UserDiaryScreen extends StatelessWidget { class UserDiaryScreen extends StatelessWidget {
static const path = '/user-diary-screen'; static const path = '/user-diary-screen';
static const name = 'userDiaryScreen'; static const name = 'userDiaryScreen';
const UserDiaryScreen({super.key, required this.diaryId}); const UserDiaryScreen({super.key, required this.diaryId, this.ownerSlug});
final int diaryId; final int diaryId;
/// Slug владельца дневника (клиента) — нужен консультанту для блока «Аналитика».
final String? ownerSlug;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return DiaryScreenContent(diaryId: diaryId); return DiaryScreenContent(diaryId: diaryId, ownerSlug: ownerSlug);
} }
} }
class DiaryScreenContent extends StatefulWidget { 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 int? diaryId;
final DateTime? date; final DateTime? date;
final String? ownerSlug;
@override @override
State<DiaryScreenContent> createState() => _DiaryScreenContentState(); State<DiaryScreenContent> createState() => _DiaryScreenContentState();
} }
@@ -55,6 +66,16 @@ class DiaryScreenContent extends StatefulWidget {
class _DiaryScreenContentState extends State<DiaryScreenContent> { class _DiaryScreenContentState extends State<DiaryScreenContent> {
bool _isDiaryFinishing = false; bool _isDiaryFinishing = false;
/// Slug владельца: из `user.slug` дневника, иначе из параметра экрана.
String? _ownerSlug(DiaryStateLoaded state) {
return state.diary.ownerSlug ?? widget.ownerSlug;
}
/// Консультант видит аналитику клиента только при известном slug владельца.
bool _canShowInsights(DiaryStateLoaded state) {
return !state.isConsultantView || _ownerSlug(state) != null;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final s = S.of(context); final s = S.of(context);
@@ -110,6 +131,15 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
const SizedBox(height: 60), const SizedBox(height: 60),
const Loader(), const Loader(),
], ],
if (state is DiaryStateLoaded && _canShowInsights(state))
Padding(
padding: const EdgeInsets.fromLTRB(4, 12, 4, 12),
child: _DiaryInsights(
slug: state.isConsultantView ? _ownerSlug(state) : null,
canAdd: !state.isConsultantView,
diaryDate: state.diary.date,
),
),
if (state is DiaryStateLoaded) if (state is DiaryStateLoaded)
WakeupAndRetireWidget( WakeupAndRetireWidget(
isEnabled: isEnabled:
@@ -249,3 +279,53 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
); );
} }
} }
/// Блок «Аналитика» в дневнике. Для своего дневника перезапрашивает данные,
/// когда в том же дне меняются приёмы пищи или время подъёма/отбоя: от них
/// зависят КБЖУ-графики. Чужой дневник консультант не редактирует.
class _DiaryInsights extends StatefulWidget {
const _DiaryInsights({
required this.slug,
required this.canAdd,
required this.diaryDate,
});
final String? slug;
final bool canAdd;
/// Дата открытого дня дневника (`YYYY-MM-DD`) — стартовый день «В течение дня».
final String diaryDate;
@override
State<_DiaryInsights> createState() => _DiaryInsightsState();
}
class _DiaryInsightsState extends State<_DiaryInsights> {
int _refreshToken = 0;
bool _isOwnDiaryMutation(DiaryState previous, DiaryState current) {
if (!widget.canAdd ||
previous is! DiaryStateLoaded ||
current is! DiaryStateLoaded ||
previous.diary.id != current.diary.id) {
return false;
}
return !listEquals(previous.diary.meals, current.diary.meals) ||
previous.diary.wakeup != current.diary.wakeup ||
previous.diary.retire != current.diary.retire;
}
@override
Widget build(BuildContext context) {
return BlocListener<DiaryCubit, DiaryState>(
listenWhen: _isOwnDiaryMutation,
listener: (context, state) => setState(() => _refreshToken++),
child: InsightsCard(
slug: widget.slug,
canAdd: widget.canAdd,
refreshToken: _refreshToken,
initialIntraDate: widget.diaryDate,
),
);
}
}

View File

@@ -4,7 +4,6 @@ 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_divider.dart';
import 'package:eatvio_app/features/diary/presentation/bloc/diary_cubit.dart'; import 'package:eatvio_app/features/diary/presentation/bloc/diary_cubit.dart';
import 'package:eatvio_app/features/diary/presentation/bloc/diary_state.dart'; import 'package:eatvio_app/features/diary/presentation/bloc/diary_state.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@@ -19,9 +18,7 @@ class ChosenDietWidget extends StatelessWidget {
if (state is DiaryStateLoaded) { if (state is DiaryStateLoaded) {
if (state.diary.typeOfDiary.hasChosenDiet) { if (state.diary.typeOfDiary.hasChosenDiet) {
return ChosenDietWidgetContent( return ChosenDietWidgetContent(
currentDietDay: state.diary.dayOfChosenDiet ?? 0,
dietName: state.diary.diet?.name ?? '', dietName: state.diary.diet?.name ?? '',
dietDuration: state.diary.diet?.duration ?? 0,
dietId: state.diary.diet?.id ?? 0, dietId: state.diary.diet?.id ?? 0,
); );
} }
@@ -33,9 +30,7 @@ class ChosenDietWidget extends StatelessWidget {
const CustomDivider(), const CustomDivider(),
const SizedBox(height: 16), const SizedBox(height: 16),
ChosenDietWidgetContent( ChosenDietWidgetContent(
currentDietDay: state.diary.dayOfChosenDiet ?? 0,
dietName: state.diary.diet?.name ?? '', dietName: state.diary.diet?.name ?? '',
dietDuration: state.diary.diet?.duration ?? 0,
dietId: state.diary.diet?.id ?? 0, dietId: state.diary.diet?.id ?? 0,
), ),
], ],
@@ -50,15 +45,11 @@ class ChosenDietWidget extends StatelessWidget {
} }
class ChosenDietWidgetContent extends StatelessWidget { class ChosenDietWidgetContent extends StatelessWidget {
const ChosenDietWidgetContent({super.key, required this.currentDietDay, required this.dietName, required this.dietDuration, required this.dietId}); const ChosenDietWidgetContent({super.key, required this.dietName, required this.dietId});
final int currentDietDay;
final String dietName; final String dietName;
final int dietDuration;
final int dietId; final int dietId;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final size = MediaQuery.sizeOf(context);
final s = S.of(context);
return GestureDetector( return GestureDetector(
onTap: () { onTap: () {
GoRouter.of(context).push(RouteConstants.aboutDietScreen, extra: {'id': dietId, 'navigateToComments': false}); GoRouter.of(context).push(RouteConstants.aboutDietScreen, extra: {'id': dietId, 'navigateToComments': false});
@@ -67,19 +58,12 @@ class ChosenDietWidgetContent extends StatelessWidget {
children: [ children: [
Row( Row(
children: [ children: [
SizedBox( Expanded(
width: size.width * 0.4,
child: Text( child: Text(
dietName, dietName,
style: TextStyles.textLMedium.copyWith(color: appTheme.black, decoration: TextDecoration.underline), style: TextStyles.textLMedium.copyWith(color: appTheme.black, decoration: TextDecoration.underline),
), ),
), ),
const Spacer(),
Container(
decoration: BoxDecoration(color: appTheme.dividerColor, borderRadius: BorderRadius.circular(12)),
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12),
child: Text('${s.day} $currentDietDay из $dietDuration', style: TextStyles.textMSemibold.copyWith(color: appTheme.gray)),
),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),

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,8 @@
/// Флаги поэтапного включения аналитики.
abstract final class InsightsFeatureFlags {
/// Вкладки «КБЖУ по дням» и «В течение дня». При `true` весь блок берёт
/// данные и тексты с сервера (`GET /api/profile/insights*`, eatvio/eatvio#3017),
/// при `false` — вкладки скрыты, а вес/ИМТ/замеры собираются на клиенте из
/// `/api/profile/measure/*`.
static const bool kbjuEnabled = true;
}

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,46 @@
import 'package:eatvio_app/generated/l10n.dart';
/// Период для вкладок «Вес», «ИМТ» и «Замеры». Значение совпадает с `{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;
/// Подпись пилюли периода.
String pillLabel(S s) => switch (this) {
month => s.insights_period_1m,
threeMonths => s.insights_period_3m,
sixMonths => s.insights_period_6m,
year => s.insights_period_1y,
all => s.insights_period_all,
};
}
/// Период для вкладки «КБЖУ по дням». Значение совпадает с `daily_period` в `/api/profile/insights`.
enum DailyPeriod {
week('7'),
twoWeeks('14'),
month('30'),
threeMonths('90'),
all('all');
const DailyPeriod(this.apiValue);
final String apiValue;
/// Подпись пилюли периода.
String pillLabel(S s) => switch (this) {
week => s.insights_period_1w,
twoWeeks => s.insights_period_2w,
month => s.insights_period_1m,
threeMonths => s.insights_period_3m,
all => s.insights_period_all,
};
}

View File

@@ -0,0 +1,36 @@
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'insights_response_model.freezed.dart';
part 'insights_response_model.g.dart';
/// Ответ `GET /api/profile/insights[/{slug}]`: `{ "data": { weight, bmi, measure, daily, intra } }`.
@freezed
abstract class InsightsResponseModel with _$InsightsResponseModel {
const factory InsightsResponseModel({required InsightsPayloadModel data}) =
_InsightsResponseModel;
factory InsightsResponseModel.fromJson(Map<String, dynamic> json) =>
_$InsightsResponseModelFromJson(json);
}
/// Ответ `GET /api/profile/insights/intra` и `/insights/{slug}/intra`:
/// `{ "data": { "intra": { ... } } }`.
@freezed
abstract class InsightsIntraResponseModel with _$InsightsIntraResponseModel {
const factory InsightsIntraResponseModel({
required InsightsIntraDataModel data,
}) = _InsightsIntraResponseModel;
factory InsightsIntraResponseModel.fromJson(Map<String, dynamic> json) =>
_$InsightsIntraResponseModelFromJson(json);
}
@freezed
abstract class InsightsIntraDataModel with _$InsightsIntraDataModel {
const factory InsightsIntraDataModel({required IntraChartModel intra}) =
_InsightsIntraDataModel;
factory InsightsIntraDataModel.fromJson(Map<String, dynamic> json) =>
_$InsightsIntraDataModelFromJson(json);
}

View File

@@ -0,0 +1,91 @@
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 dataRange = paddedRange(points.map((e) => e.value), minPadding: 2);
// Ось всегда покрывает зоны нормы/избытка/ожирения, иначе при ИМТ ≈30
// зелёная полоса 18.5–25 уезжает за нижний край графика.
const zoneMin = 18.5;
const zoneMax = 40.0;
final min = dataRange.min < zoneMin ? dataRange.min : zoneMin;
final max = dataRange.max > zoneMax ? dataRange.max : zoneMax;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: insightsChartHeight,
child: SfCartesianChart(
key: const Key('insights_bmi_chart'),
margin: insightsChartMargin,
plotAreaBorderWidth: 0,
primaryXAxis: buildInsightsIndexAxis(model.labels),
primaryYAxis: buildInsightsValueAxis(
unit: s.insights_unit_bmi,
min: min,
max: 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, num>>[
SplineAreaSeries<InsightsPoint, num>(
name: s.insights_tab_bmi,
dataSource: points,
xValueMapper: (point, _) => point.index,
yValueMapper: (point, _) => point.value,
splineType: insightsSplineType,
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,135 @@
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);
// Локальный диапазон по данным (как на вебе), не ось от нуля.
// Кривую ниже точек держит insightsSplineType (monotonic).
final kcalRange = paddedRange(
calories.map((e) => e.value),
minPadding: 50,
clampZero: true,
);
final gramValues = [
...proteins.map((e) => e.value),
...fats.map((e) => e.value),
...carbs.map((e) => e.value),
];
final gramsRange = paddedRange(
gramValues,
minPadding: 5,
clampZero: true,
);
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: insightsChartMargin,
plotAreaBorderWidth: 0,
primaryXAxis: buildInsightsIndexAxis(model.labels),
primaryYAxis: NumericAxis(
title: AxisTitle(
text: s.insights_unit_kcal,
textStyle: insightsAxisLabelStyle,
),
labelStyle: insightsAxisLabelStyle,
minimum: kcalRange.min,
maximum: kcalRange.max,
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: gramsRange.min,
maximum: gramsRange.max,
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, num>>[
if (_isVisible(0))
SplineAreaSeries<InsightsPoint, num>(
name: s.insights_series_calories,
dataSource: calories,
xValueMapper: (point, _) => point.index,
yValueMapper: (point, _) => point.value,
splineType: insightsSplineType,
borderColor: InsightsColors.calories,
borderWidth: 3,
gradient: buildInsightsAreaGradient(InsightsColors.calories),
),
for (var i = 0; i < grams.length; i++)
if (_isVisible(i + 1))
SplineSeries<InsightsPoint, num>(
name: grams[i].name,
yAxisName: _gramsAxisName,
dataSource: grams[i].points,
xValueMapper: (point, _) => point.index,
yValueMapper: (point, _) => point.value,
splineType: insightsSplineType,
color: grams[i].color,
width: 2.5,
),
],
),
);
}
bool _isVisible(int index) =>
index < visibility.length ? visibility[index] : true;
}

View File

@@ -0,0 +1,180 @@
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 (как у Chart.js), подпись `dd.MM` и значение.
///
/// Индекс обязателен: Syncfusion `CategoryAxis` склеивает одинаковые строки
/// (`05.07` несколько раз → одна категория). На вебе labels идут по индексу,
/// поэтому здесь X = index, а дата рисуется через [axisLabelFormatter].
class InsightsPoint {
const InsightsPoint({
required this.index,
required this.label,
required this.value,
});
final int index;
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(index: i, label: labels[i], value: values[i]),
];
}
/// Локальный диапазон оси Y вокруг данных (не от нуля), с запасом под сплайн.
///
/// [clampZero] — не уводить минимум в минус на осях КБЖУ (ккал/граммы ≥ 0).
({double min, double max}) paddedRange(
Iterable<double> values, {
double minPadding = 1,
bool clampZero = false,
}) {
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);
var min = lowest - padding;
var max = highest + padding;
if (clampZero) {
min = math.max(0, min);
}
if (max <= min) {
max = min + minPadding;
}
return (min: min, max: max);
}
const SplineType insightsSplineType = SplineType.monotonic;
const TextStyle insightsAxisLabelStyle = TextStyle(
fontSize: 11,
color: InsightsColors.subtitle,
);
/// Ось X по индексу точки; подпись — дата из [labels].
///
/// Так даты `05.07`, `06.07`, … не схлопываются и не размножаются, когда на
/// графике несколько серий с одним и тем же набором labels (КБЖУ, замеры).
NumericAxis buildInsightsIndexAxis(List<String> labels) {
final lastIndex = math.max(labels.length - 1, 0).toDouble();
// На узком экране не подписываем каждую точку: ~6 подписей, как авто-тики Chart.js.
final interval = labels.length <= 7
? 1.0
: (labels.length / 6).ceilToDouble();
return NumericAxis(
minimum: 0,
maximum: lastIndex,
interval: interval,
labelStyle: insightsAxisLabelStyle,
majorGridLines: const MajorGridLines(width: 0),
majorTickLines: const MajorTickLines(size: 0),
axisLine: const AxisLine(color: InsightsColors.border, width: 1),
edgeLabelPlacement: EdgeLabelPlacement.shift,
axisLabelFormatter: (details) {
final index = details.value.round();
if (index < 0 || index >= labels.length) {
return ChartAxisLabel('', details.textStyle);
}
// Промежуточные дробные тики (если Syncfusion их поставит) не подписываем.
if ((details.value - index).abs() > 0.01) {
return ChartAxisLabel('', details.textStyle);
}
return ChartAxisLabel(labels[index], insightsAxisLabelStyle);
},
);
}
NumericAxis buildInsightsValueAxis({
required String unit,
required double min,
required double max,
List<PlotBand> plotBands = const [],
}) {
return NumericAxis(
minimum: min,
maximum: max,
// Доп. воздух, чтобы сплайн не врезался в край plot area.
rangePadding: ChartRangePadding.additional,
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,
);
}
/// Небольшой внутренний отступ, чтобы кривая и подписи не резались контейнером.
const EdgeInsets insightsChartMargin = EdgeInsets.fromLTRB(0, 8, 0, 4);
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,214 @@
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 &&
model.wake == null &&
model.sleep == null) {
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: insightsChartMargin,
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: insightsSplineType,
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,87 @@
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: insightsChartMargin,
plotAreaBorderWidth: 0,
primaryXAxis: buildInsightsIndexAxis(model.labels),
primaryYAxis: buildInsightsValueAxis(
unit: s.insights_unit_cm,
min: range.min,
max: range.max,
),
trackballBehavior: buildInsightsTrackball(),
series: <CartesianSeries<InsightsPoint, num>>[
for (var i = 0; i < series.length; i++)
if (_isVisible(i))
SplineSeries<InsightsPoint, num>(
name: series[i].name,
dataSource: series[i].points,
xValueMapper: (point, _) => point.index,
yValueMapper: (point, _) => point.value,
splineType: insightsSplineType,
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,54 @@
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: insightsChartMargin,
plotAreaBorderWidth: 0,
primaryXAxis: buildInsightsIndexAxis(model.labels),
primaryYAxis: buildInsightsValueAxis(
unit: s.insights_unit_kg,
min: range.min,
max: range.max,
),
trackballBehavior: buildInsightsTrackball(),
series: <CartesianSeries<InsightsPoint, num>>[
SplineAreaSeries<InsightsPoint, num>(
name: s.insights_tab_weight,
dataSource: points,
xValueMapper: (point, _) => point.index,
yValueMapper: (point, _) => point.value,
splineType: insightsSplineType,
borderColor: InsightsColors.blue,
borderWidth: 3,
gradient: buildInsightsAreaGradient(InsightsColors.blue),
markerSettings: buildInsightsMarker(InsightsColors.blue),
),
],
),
);
}
}

View File

@@ -0,0 +1,400 @@
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_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: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;
/// Полный payload и вкладка «В течение дня» координируются отдельно: смена
/// дня не должна отменять загрузку периода, и наоборот.
int _fullRequestId = 0;
int _intraRequestId = 0;
int _inflightFull = 0;
int _inflightIntra = 0;
List<InsightsPanel> get panels =>
InsightsPanel.available(kbjuEnabled: kbjuEnabled);
bool get _isBusy => _inflightFull > 0 || _inflightIntra > 0;
Future<void> load({String? intraDate}) async {
emit(const InsightsState.loading());
await _fetch(
bodyPeriod: BodyPeriod.threeMonths,
dailyPeriod: DailyPeriod.twoWeeks,
intraDate: intraDate,
isInitial: true,
);
}
/// Перезапрашивает данные, сохраняя вкладку, период, серии и раскрытие.
Future<void> refresh() async {
final current = state;
if (current is InsightsStateLoading || current is InsightsStateHidden) {
return;
}
if (current is! InsightsStateLoaded) {
return load();
}
await _fetch(
bodyPeriod: current.bodyPeriod,
dailyPeriod: current.dailyPeriod,
intraDate: current.intraDate,
isInitial: false,
);
}
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,
dailyPeriod: current.dailyPeriod,
intraDate: current.intraDate,
isInitial: false,
);
}
/// Период вкладки «КБЖУ по дням». Работает только с серверными данными.
Future<void> setDailyPeriod(DailyPeriod period) async {
final current = state;
if (!kbjuEnabled ||
current is! InsightsStateLoaded ||
current.dailyPeriod == period) {
return;
}
emit(current.copyWith(dailyPeriod: period, isRefreshing: true));
await _fetch(
bodyPeriod: current.bodyPeriod,
dailyPeriod: period,
intraDate: current.intraDate,
isInitial: false,
);
}
/// Показывает вкладку «В течение дня» за [day] (запрашивается только она).
Future<void> setIntraDate(DateTime day) {
return _loadIntra(_formatDate(day));
}
Future<void> showPreviousDay() async {
final base = _currentIntraDay();
if (base != null) {
await setIntraDate(DateTime(base.year, base.month, base.day - 1));
}
}
Future<void> showNextDay() async {
final current = state;
final base = _currentIntraDay();
if (base == null ||
current is! InsightsStateLoaded ||
!_canGoToNextDay(current, base)) {
return;
}
await setIntraDate(DateTime(base.year, base.month, base.day + 1));
}
/// База для листания: сначала оптимистично запрошенная дата, иначе ответ сервера.
DateTime? _currentIntraDay() {
final current = state;
if (!kbjuEnabled || current is! InsightsStateLoaded) {
return null;
}
final optimistic = current.intraDate;
if (optimistic != null && optimistic.isNotEmpty) {
return DateTime.tryParse(optimistic);
}
return DateTime.tryParse(current.payload.intra?.date ?? '');
}
bool _canGoToNextDay(InsightsStateLoaded current, DateTime base) {
final maxRaw =
current.payload.intra?.maxDate ?? current.payload.intra?.date ?? '';
final maxDate = DateTime.tryParse(maxRaw);
if (maxDate == null) {
return !(current.payload.intra?.isToday ?? true);
}
final baseDay = DateTime(base.year, base.month, base.day);
final maxDay = DateTime(maxDate.year, maxDate.month, maxDate.day);
return baseDay.isBefore(maxDay);
}
Future<void> _loadIntra(String date) async {
final current = state;
if (!kbjuEnabled || current is! InsightsStateLoaded) {
return;
}
final requestId = ++_intraRequestId;
_inflightIntra++;
final previousIntraDate = current.intraDate;
// Оптимистично фиксируем запрошенный день до ответа, чтобы быстрые
// нажатия «‹ ›» считали базу от него, а не от устаревшего payload.
emit(current.copyWith(intraDate: date, isRefreshing: true));
try {
final intra = await repository.fetchIntra(slug: slug, intraDate: date);
if (requestId != _intraRequestId || isClosed) {
return;
}
final latest = state;
if (latest is! InsightsStateLoaded) {
return;
}
emit(
latest.copyWith(
payload: latest.payload.copyWith(intra: intra),
intraDate: intra.isToday
? null
: (intra.date.isEmpty ? date : intra.date),
isRefreshing: _inflightFull > 0 || _inflightIntra > 1,
),
);
} on InsightsForbidden {
if (requestId == _intraRequestId && !isClosed) {
emit(const InsightsState.hidden());
}
} catch (e, st) {
await reportFrom(e, st, this);
log(e.toString(), stackTrace: st);
if (requestId == _intraRequestId && !isClosed) {
final latest = state;
if (latest is InsightsStateLoaded) {
emit(
latest.copyWith(
intraDate: previousIntraDate,
isRefreshing: _inflightFull > 0 || _inflightIntra > 1,
),
);
}
}
} finally {
_inflightIntra--;
_syncRefreshingFlag();
}
}
static String _formatDate(DateTime day) {
final month = day.month.toString().padLeft(2, '0');
final dayOfMonth = day.day.toString().padLeft(2, '0');
return '${day.year.toString().padLeft(4, '0')}-$month-$dayOfMonth';
}
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<InsightsPayloadModel> _requestPayload({
required BodyPeriod bodyPeriod,
required DailyPeriod dailyPeriod,
required String? intraDate,
}) async {
if (kbjuEnabled) {
return repository.fetchInsights(
slug: slug,
bodyPeriod: bodyPeriod,
dailyPeriod: dailyPeriod,
intraDate: intraDate,
);
}
if (!_isHeightLoaded) {
_heightCm = await repository.getHeightCm(slug: slug);
_isHeightLoaded = true;
}
return repository.getInsights(
slug: slug,
bodyPeriod: bodyPeriod,
heightCm: _heightCm,
);
}
Future<void> _fetch({
required BodyPeriod bodyPeriod,
required DailyPeriod dailyPeriod,
required String? intraDate,
required bool isInitial,
}) async {
final requestId = ++_fullRequestId;
final intraGenerationAtStart = _intraRequestId;
_inflightFull++;
final before = state;
if (before is InsightsStateLoaded && !before.isRefreshing) {
emit(before.copyWith(isRefreshing: true));
}
try {
final payload = await _requestPayload(
bodyPeriod: bodyPeriod,
dailyPeriod: dailyPeriod,
intraDate: intraDate,
);
if (requestId != _fullRequestId || isClosed) {
return;
}
final latest = state;
if (latest is InsightsStateLoaded) {
// Более свежий intra-запрос не перезаписываем полным ответом.
final keepIntra = _intraRequestId > intraGenerationAtStart;
emit(
latest.copyWith(
payload: keepIntra
? payload.copyWith(intra: latest.payload.intra)
: payload,
bodyPeriod: bodyPeriod,
dailyPeriod: dailyPeriod,
intraDate: keepIntra ? latest.intraDate : intraDate,
isRefreshing: _inflightIntra > 0 || _inflightFull > 1,
),
);
} else if (isInitial || latest is! InsightsStateError) {
emit(
InsightsState.loaded(
payload: payload,
bodyPeriod: bodyPeriod,
dailyPeriod: dailyPeriod,
intraDate: intraDate,
),
);
}
} on InsightsForbidden {
if (requestId == _fullRequestId && !isClosed) {
emit(const InsightsState.hidden());
}
} catch (e, st) {
await reportFrom(e, st, this);
log(e.toString(), stackTrace: st);
if (requestId != _fullRequestId || isClosed) {
return;
}
final latest = state;
// Ошибка перезапроса не должна стирать уже показанные данные и UI-выбор.
if (latest is InsightsStateLoaded) {
emit(
latest.copyWith(
isRefreshing: _inflightIntra > 0 || _inflightFull > 1,
),
);
} else if (isInitial) {
emit(InsightsState.error(message: e.toString()));
}
} finally {
_inflightFull--;
_syncRefreshingFlag();
}
}
void _syncRefreshingFlag() {
if (isClosed) {
return;
}
final current = state;
if (current is! InsightsStateLoaded) {
return;
}
final busy = _isBusy;
if (current.isRefreshing != busy) {
emit(current.copyWith(isRefreshing: busy));
}
}
}

View File

@@ -0,0 +1,29 @@
import 'package:eatvio_app/generated/l10n.dart';
/// Вкладки блока «Аналитика».
enum InsightsPanel {
weight,
bmi,
measures,
daily,
intra;
/// Вкладки КБЖУ доступны только при включённом флаге.
bool get isKbju => this == daily || this == intra;
/// Кнопка «+» есть только у вкладок с ручным вводом.
bool get supportsAdd => this == weight || this == measures;
/// Подпись вкладки.
String label(S s) => switch (this) {
weight => s.insights_tab_weight,
bmi => s.insights_tab_bmi,
measures => s.insights_tab_measures,
daily => s.insights_tab_daily,
intra => s.insights_tab_intra,
};
static List<InsightsPanel> available({required bool kbjuEnabled}) {
return values.where((panel) => kbjuEnabled || !panel.isKbju).toList();
}
}

View File

@@ -0,0 +1,51 @@
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(DailyPeriod.twoWeeks) DailyPeriod dailyPeriod,
/// День вкладки «В течение дня» (`YYYY-MM-DD`); `null` — сегодня.
String? intraDate,
@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,640 @@
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,
this.refreshToken = 0,
this.initialIntraDate,
});
final String? slug;
final bool canAdd;
final InsightsRepository? repository;
final bool kbjuEnabled;
/// При смене значения блок перезапрашивает данные (например, после правок
/// в дневнике), сохраняя вкладку, период и выбранный день.
final int refreshToken;
/// День вкладки «В течение дня» при первой загрузке (`YYYY-MM-DD`).
/// В дневнике совпадает с открытой датой; `null` — сегодня.
final String? initialIntraDate;
@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(intraDate: initialIntraDate),
child: _InsightsView(
refreshToken: refreshToken,
initialIntraDate: initialIntraDate,
),
),
);
}
}
class _InsightsView extends StatefulWidget {
const _InsightsView({
required this.refreshToken,
required this.initialIntraDate,
});
final int refreshToken;
final String? initialIntraDate;
@override
State<_InsightsView> createState() => _InsightsViewState();
}
class _InsightsViewState extends State<_InsightsView> {
@override
void didUpdateWidget(covariant _InsightsView oldWidget) {
super.didUpdateWidget(oldWidget);
final cubit = context.read<InsightsCubit>();
if (oldWidget.refreshToken != widget.refreshToken) {
cubit.refresh();
}
final nextDate = widget.initialIntraDate;
if (nextDate != null &&
nextDate.isNotEmpty &&
oldWidget.initialIntraDate != nextDate) {
final parsed = DateTime.tryParse(nextDate);
if (parsed != null) {
cubit.setIntraDate(parsed);
}
}
}
@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: 16, 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(
16,
0,
16,
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: 16, 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`.
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: state.dailyPeriod,
labelOf: (period) => period.pillLabel(s),
keyOf: (period) => period.apiValue,
onSelected: cubit.setDailyPeriod,
),
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 ?? 0) != 0,
),
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),
],
],
);
}
}
/// «В течение дня». Листание дней запрашивает только `/insights/intra`.
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,
onPreviousDay: cubit.showPreviousDay,
onNextDay: cubit.showNextDay,
onDatePicked: cubit.setIntraDate,
),
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,63 @@
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';
/// Горизонтально прокручиваемые вкладки (`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,79 @@
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
import 'package:flutter/material.dart';
/// Пилюли выбора периода (`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,103 @@
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/insights/services/insights_service.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();
/// Источники данных блока «Аналитика»:
/// - [fetchInsights] / [fetchIntra] — серверные `GET /api/profile/insights*`
/// (графики и тексты считает `UserInsightsService`, включая КБЖУ);
/// - [getInsights] — собирает payload на клиенте из эндпоинтов веса и замеров
/// (используется, пока выключен `InsightsFeatureFlags.kbjuEnabled`).
class InsightsRepository {
InsightsRepository({required this.profileRepository, required this.insightsService, InsightsTextBuilderFactory? textBuilderFactory})
: _textBuilderFactory = textBuilderFactory ?? (() => InsightsTextBuilder(S.current));
final ProfileRepository profileRepository;
final InsightsService insightsService;
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;
}
}
/// Полный payload с сервера. [intraDate] — `YYYY-MM-DD`, `null` — сегодня.
Future<InsightsPayloadModel> fetchInsights({String? slug, required BodyPeriod bodyPeriod, required DailyPeriod dailyPeriod, String? intraDate}) async {
try {
final response = slug == null
? await insightsService.getInsights(bodyPeriod: bodyPeriod.apiValue, dailyPeriod: dailyPeriod.apiValue, intraDate: intraDate)
: await insightsService.getInsightsBySlug(slug: slug, bodyPeriod: bodyPeriod.apiValue, dailyPeriod: dailyPeriod.apiValue, intraDate: intraDate);
return response.data;
} on DioException catch (e) {
_throwIfForbidden(e);
rethrow;
}
}
/// Только вкладка «В течение дня» за [intraDate] (`YYYY-MM-DD`).
Future<IntraChartModel> fetchIntra({String? slug, String? intraDate}) async {
try {
final response = slug == null
? await insightsService.getIntra(intraDate: intraDate)
: await insightsService.getIntraBySlug(slug: slug, intraDate: intraDate);
return response.data.intra;
} 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

@@ -0,0 +1,43 @@
import 'package:dio/dio.dart';
import 'package:eatvio_app/features/insights/models/insights_response_model.dart';
import 'package:retrofit/retrofit.dart';
part 'insights_service.g.dart';
/// Эндпоинты инсайтов (eatvio/eatvio#3017). Сервер приводит будущую
/// `intra_date` к сегодняшней, неверные значения периода дают 422, чужой
/// недоступный профиль — 403.
@RestApi()
abstract class InsightsService {
factory InsightsService(Dio dio, {String baseUrl}) = _InsightsService;
/// Свои графики целиком.
@GET('/api/profile/insights')
Future<InsightsResponseModel> getInsights({
@Query('body_period') String? bodyPeriod,
@Query('daily_period') String? dailyPeriod,
@Query('intra_date') String? intraDate,
});
/// Чужие графики целиком (клиент консультанта; остальным 403).
@GET('/api/profile/insights/{slug}')
Future<InsightsResponseModel> getInsightsBySlug({
@Path() required String slug,
@Query('body_period') String? bodyPeriod,
@Query('daily_period') String? dailyPeriod,
@Query('intra_date') String? intraDate,
});
/// Только «в течение дня» для себя.
@GET('/api/profile/insights/intra')
Future<InsightsIntraResponseModel> getIntra({
@Query('intra_date') String? intraDate,
});
/// Только «в течение дня» для другого пользователя.
@GET('/api/profile/insights/{slug}/intra')
Future<InsightsIntraResponseModel> getIntraBySlug({
@Path() required String slug,
@Query('intra_date') String? intraDate,
});
}

View File

@@ -136,6 +136,11 @@ class MyDietsCubit extends Cubit<MyDietsState> {
} }
} }
void selectInitialTab(int index) {
if (index < 0 || index > 2) return;
emit(state.copyWith(selectedStateIndex: index));
}
void changeSelectedStateIndex(int index) { void changeSelectedStateIndex(int index) {
emit(state.copyWith(selectedStateIndex: index)); emit(state.copyWith(selectedStateIndex: index));
if (state.activeDiets.isEmpty) { if (state.activeDiets.isEmpty) {

View File

@@ -19,12 +19,19 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
class MyDietsScreen extends StatelessWidget { class MyDietsScreen extends StatelessWidget {
const MyDietsScreen({super.key}); const MyDietsScreen({super.key, this.initialTabIndex = 0});
static const name = 'myDietsScreen'; static const name = 'myDietsScreen';
static const path = '/my-diets-screen'; static const path = '/my-diets-screen';
final int initialTabIndex;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider(create: (context) => sl<MyDietsCubit>()..loadAllDiets(), child: MyDietsScreenContent()); return BlocProvider(
create: (context) =>
sl<MyDietsCubit>()
..selectInitialTab(initialTabIndex)
..loadAllDiets(),
child: MyDietsScreenContent(),
);
} }
} }

View File

@@ -2174,6 +2174,56 @@ class PersonalDietIsDefinedToCustomerNotification implements BaseNotificationMod
} }
} }
class PersonalDietIsDefinedToAuthorNotification implements BaseNotificationModel {
@override
final NotificationType type;
@override
final bool isRead;
@override
final DateTime createdAt;
final int chosenPlanId;
final int dietId;
final String planName;
final String userName;
final String consultantName;
final String dietName;
final String role;
final String peerSlug;
final String peerImagePath;
PersonalDietIsDefinedToAuthorNotification({
required this.type,
required this.isRead,
required this.createdAt,
required this.chosenPlanId,
required this.dietId,
required this.planName,
required this.userName,
required this.consultantName,
required this.dietName,
required this.role,
required this.peerSlug,
required this.peerImagePath,
});
factory PersonalDietIsDefinedToAuthorNotification.fromJson(Map<String, dynamic> json, NotificationType type, {bool? isRead, String? createdAt}) {
return PersonalDietIsDefinedToAuthorNotification(
type: type,
isRead: isRead ?? false,
createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()),
chosenPlanId: json['chosen_plan_id'] ?? 0,
dietId: json['diet_id'] ?? 0,
planName: json['plan_name'] ?? '',
userName: json['user_name'] ?? '',
consultantName: json['consultant_name'] ?? '',
dietName: json['diet_name'] ?? '',
role: json['role']?.toString() ?? '',
peerSlug: json['peer_slug'] ?? '',
peerImagePath: json['peer_image_path'] ?? '',
);
}
}
// New Model Classes for Comprehensive Support // New Model Classes for Comprehensive Support
class ConsultantEducationUpdatedNotification implements BaseNotificationModel { class ConsultantEducationUpdatedNotification implements BaseNotificationModel {
@@ -2480,6 +2530,7 @@ enum NotificationType {
chosenPlanFinishedToConsultant, chosenPlanFinishedToConsultant,
stayOnTrackToCustomer, stayOnTrackToCustomer,
personalDietIsDefinedToCustomer, personalDietIsDefinedToCustomer,
personalDietIsDefinedToAuthor,
// Comments // Comments
entityCommented, entityCommented,
@@ -2602,6 +2653,7 @@ enum NotificationType {
'stay-on-track-to-customer' => NotificationType.stayOnTrackToCustomer, 'stay-on-track-to-customer' => NotificationType.stayOnTrackToCustomer,
'diary-reminder-for-users' => NotificationType.diaryReminderForUsers, 'diary-reminder-for-users' => NotificationType.diaryReminderForUsers,
'personal-diet-is-defined-to-customer' => NotificationType.personalDietIsDefinedToCustomer, 'personal-diet-is-defined-to-customer' => NotificationType.personalDietIsDefinedToCustomer,
'personal-diet-is-defined-to-author' => NotificationType.personalDietIsDefinedToAuthor,
// New additions // New additions
'consultant-education-updated' => NotificationType.consultantEducationUpdated, 'consultant-education-updated' => NotificationType.consultantEducationUpdated,

View File

@@ -277,6 +277,12 @@ class NotificationsCubit extends Cubit<NotificationsState> {
isRead: isRead, isRead: isRead,
createdAt: createdAt, createdAt: createdAt,
), ),
NotificationType.personalDietIsDefinedToAuthor => PersonalDietIsDefinedToAuthorNotification.fromJson(
notificationJson,
type,
isRead: isRead,
createdAt: createdAt,
),
NotificationType.consultantEducationUpdated => ConsultantEducationUpdatedNotification.fromJson( NotificationType.consultantEducationUpdated => ConsultantEducationUpdatedNotification.fromJson(
notificationJson, notificationJson,
type, type,

View File

@@ -122,6 +122,9 @@ class NotificationWidget extends StatelessWidget {
PersonalDietIsDefinedToCustomerNotification() => _PersonalDietIsDefinedToCustomerWidget( PersonalDietIsDefinedToCustomerNotification() => _PersonalDietIsDefinedToCustomerWidget(
notification: notification as PersonalDietIsDefinedToCustomerNotification, notification: notification as PersonalDietIsDefinedToCustomerNotification,
), ),
PersonalDietIsDefinedToAuthorNotification() => _PersonalDietIsDefinedToAuthorWidget(
notification: notification as PersonalDietIsDefinedToAuthorNotification,
),
// New Widgets // New Widgets
ConsultantEducationUpdatedNotification() => _ConsultantEducationUpdatedWidget(notification: notification as ConsultantEducationUpdatedNotification), ConsultantEducationUpdatedNotification() => _ConsultantEducationUpdatedWidget(notification: notification as ConsultantEducationUpdatedNotification),
ComplaintRegisteredNotification() => _ComplaintRegisteredWidget(notification: notification as ComplaintRegisteredNotification), ComplaintRegisteredNotification() => _ComplaintRegisteredWidget(notification: notification as ComplaintRegisteredNotification),
@@ -1273,6 +1276,30 @@ class _PersonalDietIsDefinedToCustomerWidget extends StatelessWidget {
} }
} }
class _PersonalDietIsDefinedToAuthorWidget extends StatelessWidget {
const _PersonalDietIsDefinedToAuthorWidget({required this.notification});
final PersonalDietIsDefinedToAuthorNotification notification;
@override
Widget build(BuildContext context) {
final s = S.of(context);
final userName = notification.userName.isNotEmpty ? notification.userName : s.default_user_name;
final notificationsText = notification.dietName.isEmpty
? s.personal_diet_defined_to_author(userName)
: s.personal_diet_defined_to_author_named(userName, notification.dietName);
return _BaseNotificationWidget(
isRead: notification.isRead,
notificationsText: notificationsText,
createdAt: notification.createdAt,
onTap: notification.peerSlug.isEmpty
? null
: () {
context.push(RouteConstants.profileScreen, extra: notification.peerSlug);
},
);
}
}
class _ConsultantEducationUpdatedWidget extends StatelessWidget { class _ConsultantEducationUpdatedWidget extends StatelessWidget {
const _ConsultantEducationUpdatedWidget({required this.notification}); const _ConsultantEducationUpdatedWidget({required this.notification});
final ConsultantEducationUpdatedNotification notification; final ConsultantEducationUpdatedNotification notification;

View File

@@ -413,6 +413,12 @@ class NotificationsService {
isRead: readDate != null, isRead: readDate != null,
createdAt: createdAt, createdAt: createdAt,
), ),
NotificationType.personalDietIsDefinedToAuthor => PersonalDietIsDefinedToAuthorNotification.fromJson(
notificationJson['data'],
type,
isRead: readDate != null,
createdAt: createdAt,
),
NotificationType.consultantEducationUpdated => ConsultantEducationUpdatedNotification.fromJson( NotificationType.consultantEducationUpdated => ConsultantEducationUpdatedNotification.fromJson(
notificationJson['data'], notificationJson['data'],
type, type,

View File

@@ -6,7 +6,6 @@ import 'package:eatvio_app/features/cancel_plan_bottom_sheet/cancel_plan_bottom_
import 'package:eatvio_app/core/widgets/custom_outlined_button.dart'; import 'package:eatvio_app/core/widgets/custom_outlined_button.dart';
import 'package:eatvio_app/core/widgets/loader.dart'; import 'package:eatvio_app/core/widgets/loader.dart';
import 'package:eatvio_app/features/diets/presentation/widgets/author_card.dart'; import 'package:eatvio_app/features/diets/presentation/widgets/author_card.dart';
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
import 'package:eatvio_app/features/order_info/cubit/order_info_cubit.dart'; import 'package:eatvio_app/features/order_info/cubit/order_info_cubit.dart';
import 'package:eatvio_app/features/order_info/cubit/order_info_state.dart'; import 'package:eatvio_app/features/order_info/cubit/order_info_state.dart';
import 'package:eatvio_app/features/order_info/presentation/widgets/collect_charge_banner.dart'; import 'package:eatvio_app/features/order_info/presentation/widgets/collect_charge_banner.dart';
@@ -106,14 +105,7 @@ class _InfoByOrderScreenContentState extends State<InfoByOrderScreenContent> {
), ),
); );
} }
PacketOptions? chosenOption; final chosenOption = plan.chosenOption;
if (plan.optionType == 'options1') {
chosenOption = plan.plan.packet1Options;
} else if (plan.optionType == 'options2') {
chosenOption = plan.plan.packet2Options;
} else {
chosenOption = plan.plan.packet3Options;
}
PlanStatusValues planStatus; PlanStatusValues planStatus;
if (widget.successfulPayment == true) { if (widget.successfulPayment == true) {
@@ -164,7 +156,7 @@ class _InfoByOrderScreenContentState extends State<InfoByOrderScreenContent> {
const SizedBox(height: 8), const SizedBox(height: 8),
PlanState(state: plan.chosenPlanState, hasPersonalDiet: chosenOption?.hasPersonalDiet ?? false), PlanState(state: plan.chosenPlanState, hasPersonalDiet: chosenOption?.hasPersonalDiet ?? false),
const SizedBox(height: 20), const SizedBox(height: 20),
if (widget.successfulPayment == null) if (widget.successfulPayment == null && plan.chosenPlanState.canShowCancelAction)
CustomOutlinedButton( CustomOutlinedButton(
text: s.cancelConsultation, text: s.cancelConsultation,
height: 52, height: 52,
@@ -173,7 +165,7 @@ class _InfoByOrderScreenContentState extends State<InfoByOrderScreenContent> {
onPressed: () async { onPressed: () async {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
builder: (context) => CancelPlanBottomSheet(enableForm: plan.chosenPlanState.isStateAfterPaidState(), planId: plan.id), builder: (context) => CancelPlanBottomSheet(enableForm: plan.chosenPlanState.isCancelAfterPaidState, planId: plan.id),
); );
}, },
), ),

View File

@@ -14,6 +14,7 @@ import 'package:flutter/material.dart';
Future<void> showConsultationPaymentFlow( Future<void> showConsultationPaymentFlow(
BuildContext context, { BuildContext context, {
required int planId, required int planId,
double? amount,
Future<bool?> Function(BuildContext)? showGate, Future<bool?> Function(BuildContext)? showGate,
Future<PromoCodeResult?> Function( Future<PromoCodeResult?> Function(
BuildContext, { BuildContext, {
@@ -21,7 +22,7 @@ Future<void> showConsultationPaymentFlow(
Future<bool> Function()? onRemove, Future<bool> Function()? onRemove,
PromoCodeModel? initialPromo, PromoCodeModel? initialPromo,
})? showForm, })? showForm,
Future<void> Function(BuildContext, {required int planId})? showSbp, Future<void> Function(BuildContext, {required int planId, double? amount})? showSbp,
Future<PromoValidateResponse> Function({ Future<PromoValidateResponse> Function({
required int planId, required int planId,
required String promoCode, required String promoCode,
@@ -65,7 +66,7 @@ Future<void> showConsultationPaymentFlow(
} }
if (!context.mounted) return; if (!context.mounted) return;
await sbp(context, planId: planId); await sbp(context, planId: planId, amount: amount);
return; return;
} }
@@ -92,7 +93,7 @@ Future<void> showConsultationPaymentFlow(
} }
if (!context.mounted) return; if (!context.mounted) return;
await sbp(context, planId: planId); await sbp(context, planId: planId, amount: amount);
} }
Future<bool?> _showGate(BuildContext context) { Future<bool?> _showGate(BuildContext context) {
@@ -113,8 +114,8 @@ Future<PromoCodeResult?> _showForm(
); );
} }
Future<void> _showSbp(BuildContext context, {required int planId}) { Future<void> _showSbp(BuildContext context, {required int planId, double? amount}) {
return showSbpBanksBottomSheet(context, planId: planId); return showSbpBanksBottomSheet(context, planId: planId, amount: amount);
} }
Future<PromoValidateResponse> _defaultValidatePromo({ 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/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_cubit.dart';
import 'package:eatvio_app/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_state.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:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:sbp/models/c2bmembers_model.dart'; import 'package:sbp/models/c2bmembers_model.dart';
/// Показывает bottom sheet выбора банка СБП для оплаты консультации. /// Показывает 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; final height = MediaQuery.sizeOf(context).height * 0.55;
return showBottomSheetWithBlur( return showBottomSheetWithBlur(
context: context, context: context,
height: height, 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] /// При открытии вызывает [ConsultationPaymentCubit.startPayment] с [planId]
/// и показывает [Loader], пока загружаются checkout_url и список банков. /// и показывает [Loader], пока загружаются checkout_url и список банков.
class SbpBanksBottomSheet extends StatefulWidget { 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; final int planId;
/// Сумма к оплате в рублях — передаётся в QR-шит для отображения.
final double? amount;
final ConsultationPaymentCubit? cubit; final ConsultationPaymentCubit? cubit;
@override @override
@@ -77,6 +86,32 @@ class _SbpBanksBottomSheetState extends State<SbpBanksBottomSheet> {
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black), style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
textAlign: TextAlign.center, 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), const SizedBox(height: 16),
Expanded( Expanded(
child: BlocBuilder<ConsultationPaymentCubit, ConsultationPaymentState>( 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 { Future<void> _onBankTap(C2bmemberModel bank) async {
final opened = await _cubit.openBank(bank: bank); final opened = await _cubit.openBank(bank: bank);
if (opened && mounted) { 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,38 +9,54 @@ import 'package:eatvio_app/core/error_reporting/report_from.dart';
class PersonalDietSuggestionCubit extends Cubit<PersonalDietSuggestionState> { class PersonalDietSuggestionCubit extends Cubit<PersonalDietSuggestionState> {
final DietRepository dietRepository; final DietRepository dietRepository;
final PersonalDietSuggestionRepository personalDietSuggestionService; final PersonalDietSuggestionRepository personalDietSuggestionService;
late final int dietId; late int dietId;
PersonalDietSuggestionCubit({required this.dietRepository, required this.personalDietSuggestionService}) : super(PersonalDietSuggestionState()); PersonalDietSuggestionCubit({required this.dietRepository, required this.personalDietSuggestionService}) : super(PersonalDietSuggestionState());
Future<void> setDietName(int dietId) async { Future<void> setDietName(int dietId) async {
try { try {
emit(state.copyWith(isLoading: true, error: null)); if (!isClosed) {
emit(state.copyWith(isLoading: true, error: null));
}
final diet = await dietRepository.getDiet(dietId); final diet = await dietRepository.getDiet(dietId);
emit(state.copyWith(isLoading: false, dietName: diet.name)); if (!isClosed) {
emit(state.copyWith(
isLoading: false,
dietName: diet.name,
dietDuration: diet.duration,
));
}
} catch (e, st) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
log(e.toString(), stackTrace: st); log(e.toString(), stackTrace: st);
emit(state.copyWith(isLoading: false, error: e.toString())); if (!isClosed) {
emit(state.copyWith(isLoading: false, error: e.toString()));
}
} }
} }
Future<void> getConsultationWithPersonalDiets(int dietId) async { Future<void> getConsultationWithPersonalDiets(int dietId) async {
try { try {
this.dietId = dietId; this.dietId = dietId;
emit(state.copyWith(isLoading: true, error: null)); if (!isClosed) {
setDietName(dietId); emit(state.copyWith(isLoading: true, error: null));
}
await setDietName(dietId);
final consultations = await personalDietSuggestionService.getConsultationWithPersonalDiets(); final consultations = await personalDietSuggestionService.getConsultationWithPersonalDiets();
final consultationWithDiet = consultations.where((consultation) => consultation.personalDietId != null).toList(); final consultationWithDiet = consultations.where((consultation) => consultation.personalDietId != null).toList();
final consultationWithoutDiet = consultations.where((consultation) => consultation.personalDietId == null).toList(); final consultationWithoutDiet = consultations.where((consultation) => consultation.personalDietId == null).toList();
emit(state.copyWith( if (!isClosed) {
isLoading: false, emit(state.copyWith(
consultationsWithDiet: consultationWithDiet, isLoading: false,
consultationsWithoutDiet: consultationWithoutDiet, consultationsWithDiet: consultationWithDiet,
)); consultationsWithoutDiet: consultationWithoutDiet,
));
}
} catch (e, st) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
log(e.toString(), stackTrace: st); log(e.toString(), stackTrace: st);
emit(state.copyWith(isLoading: false, error: e.toString())); if (!isClosed) {
emit(state.copyWith(isLoading: false, error: e.toString()));
}
} }
} }
@@ -49,6 +65,7 @@ class PersonalDietSuggestionCubit extends Cubit<PersonalDietSuggestionState> {
}) async { }) async {
try { try {
await personalDietSuggestionService.attachPersonalDiet(consultationId: consultationId, dietId: dietId); await personalDietSuggestionService.attachPersonalDiet(consultationId: consultationId, dietId: dietId);
await getConsultationWithPersonalDiets(dietId);
return true; return true;
} catch (e, st) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
@@ -62,6 +79,7 @@ class PersonalDietSuggestionCubit extends Cubit<PersonalDietSuggestionState> {
}) async { }) async {
try { try {
await personalDietSuggestionService.reattachPersonalDiet(consultationId: consultationId, dietId: dietId); await personalDietSuggestionService.reattachPersonalDiet(consultationId: consultationId, dietId: dietId);
await getConsultationWithPersonalDiets(dietId);
return true; return true;
} catch (e, st) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);

View File

@@ -9,8 +9,8 @@ abstract class PersonalDietSuggestionState with _$PersonalDietSuggestionState {
@Default([]) List<ConsultationModel> consultationsWithDiet, @Default([]) List<ConsultationModel> consultationsWithDiet,
@Default([]) List<ConsultationModel> consultationsWithoutDiet, @Default([]) List<ConsultationModel> consultationsWithoutDiet,
@Default('') String dietName, @Default('') String dietName,
@Default(0) int dietDuration,
@Default(false) bool isLoading, @Default(false) bool isLoading,
@Default(null) String? error, @Default(null) String? error,
}) = _PersonalDietSuggestionState; }) = _PersonalDietSuggestionState;
} }

View File

@@ -4,6 +4,8 @@ import 'package:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/core/locator_service.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/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/utils/localization_utils.dart';
import 'package:eatvio_app/core/utils/toasts.dart';
import 'package:eatvio_app/core/widgets/bottom_sheet_handler_bar.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/core/widgets/bottom_sheet_with_blur.dart';
import 'package:eatvio_app/core/widgets/custom_outlined_button.dart'; import 'package:eatvio_app/core/widgets/custom_outlined_button.dart';
@@ -11,6 +13,7 @@ import 'package:eatvio_app/features/consultations/models/consultation_model.dart
import 'package:eatvio_app/features/personal_diet_suggestion/cubit/personal_diet_suggestion_cubit.dart'; import 'package:eatvio_app/features/personal_diet_suggestion/cubit/personal_diet_suggestion_cubit.dart';
import 'package:eatvio_app/generated/l10n.dart'; import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@@ -19,9 +22,15 @@ class ConsultationWidget extends StatelessWidget {
super.key, super.key,
required this.consultationModel, required this.consultationModel,
required this.dietName, required this.dietName,
this.dietDuration = 0,
}); });
final ConsultationModel consultationModel; final ConsultationModel consultationModel;
final String dietName; final String dietName;
final int dietDuration;
bool get _isDurationMismatch =>
dietDuration > 0 && consultationModel.duration != dietDuration;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final s = S.of(context); final s = S.of(context);
@@ -64,7 +73,20 @@ class ConsultationWidget extends StatelessWidget {
), ),
), ),
Text( Text(
'Старт: ${consultationModel.userName}', s.plan_duration_label(
consultationModel.duration,
getDayForm(consultationModel.duration, s),
),
style: TextStyles.textMMedium.copyWith(
color: const Color(0xFF88949F),
),
),
Text(
s.start_date_label(
DateFormat('dd.MM.yyyy').format(
consultationModel.startDate,
),
),
style: TextStyles.textMMedium.copyWith( style: TextStyles.textMMedium.copyWith(
color: const Color(0xFF88949F), color: const Color(0xFF88949F),
), ),
@@ -103,15 +125,47 @@ class ConsultationWidget extends StatelessWidget {
color: const Color(0xFF88949F), color: const Color(0xFF88949F),
), ),
), ),
Text(
s.plan_duration_label(
consultationModel.duration,
getDayForm(consultationModel.duration, s),
),
style: TextStyles.textMMedium.copyWith(
color: const Color(0xFF88949F),
),
),
], ],
), ),
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
if (_isDurationMismatch)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.error_outline, size: 18, color: appTheme.red),
const SizedBox(width: 8),
Expanded(
child: Text(
s.personal_diet_duration_mismatch(
dietDuration,
getDayForm(dietDuration, s),
consultationModel.duration,
getDayForm(consultationModel.duration, s),
),
style: TextStyles.textSMedium.copyWith(color: appTheme.red),
),
),
],
),
),
if (consultationModel.personalDietId != null) if (consultationModel.personalDietId != null)
CustomOutlinedButton( CustomOutlinedButton(
text: s.changeDiet, text: s.changeDiet,
height: 52, height: 52,
isDisabled: _isDurationMismatch,
buttonTextStyle: TextStyles.textMSemibold.copyWith( buttonTextStyle: TextStyles.textMSemibold.copyWith(
color: appTheme.gray, color: appTheme.gray,
), ),
@@ -128,35 +182,8 @@ class ConsultationWidget extends StatelessWidget {
userName: consultationModel.userName, userName: consultationModel.userName,
dietName: dietName, dietName: dietName,
onTap: () async { onTap: () async {
final result = await context
.read<PersonalDietSuggestionCubit>()
.attachDietToConsultation(
consultationId: consultationModel.id,
);
AppRouter.navigatorKey.currentContext?.pop(); AppRouter.navigatorKey.currentContext?.pop();
if (result) { await _attachDiet(context, reattach: true);
if (!context.mounted) return;
showBottomSheetWithBlur(
userRootNavigator: true,
context: context,
height: null,
child: SuggestionSuccessBottomSheet(
userName: consultationModel.userName,
dietName: dietName,
),
);
return;
}
if (!context.mounted) return;
showBottomSheetWithBlur(
userRootNavigator: true,
context: context,
height: null,
child: SuggestionFailureBottomSheet(
userName: consultationModel.userName,
dietName: dietName,
),
);
}, },
), ),
); );
@@ -166,12 +193,17 @@ class ConsultationWidget extends StatelessWidget {
CustomOutlinedButton( CustomOutlinedButton(
text: s.suggestDiet, text: s.suggestDiet,
height: 52, height: 52,
isDisabled: _isDurationMismatch,
buttonTextStyle: TextStyles.textMSemibold.copyWith( buttonTextStyle: TextStyles.textMSemibold.copyWith(
color: appTheme.whiteA700, color: _isDurationMismatch ? appTheme.gray : appTheme.whiteA700,
), ),
buttonStyle: ButtonStyle( buttonStyle: ButtonStyle(
side: const WidgetStatePropertyAll(BorderSide.none), side: const WidgetStatePropertyAll(BorderSide.none),
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400), backgroundColor: WidgetStatePropertyAll(
_isDurationMismatch
? appTheme.dividerColor
: appTheme.blueA400,
),
), ),
onPressed: () async { onPressed: () async {
showBottomSheetWithBlur( showBottomSheetWithBlur(
@@ -183,34 +215,7 @@ class ConsultationWidget extends StatelessWidget {
dietName: dietName, dietName: dietName,
onTap: () async { onTap: () async {
AppRouter.navigatorKey.currentContext?.pop(); AppRouter.navigatorKey.currentContext?.pop();
final result = await context await _attachDiet(context, reattach: false);
.read<PersonalDietSuggestionCubit>()
.attachDietToConsultation(
consultationId: consultationModel.id,
);
if (result) {
if (!context.mounted) return;
showBottomSheetWithBlur(
userRootNavigator: true,
context: context,
height: null,
child: SuggestionSuccessBottomSheet(
userName: consultationModel.userName,
dietName: dietName,
),
);
return;
}
if (!context.mounted) return;
showBottomSheetWithBlur(
userRootNavigator: true,
context: context,
height: null,
child: SuggestionFailureBottomSheet(
userName: consultationModel.userName,
dietName: dietName,
),
);
}, },
), ),
); );
@@ -220,6 +225,41 @@ class ConsultationWidget extends StatelessWidget {
), ),
); );
} }
Future<void> _attachDiet(
BuildContext context, {
required bool reattach,
}) async {
final router = GoRouter.of(context);
final chatId = consultationModel.chatId;
final noChatMessage = S.of(context).msg_something_went_wrong;
final cubit = context.read<PersonalDietSuggestionCubit>();
final result = reattach
? await cubit.reattachDietToConsultation(
consultationId: consultationModel.id,
)
: await cubit.attachDietToConsultation(
consultationId: consultationModel.id,
);
if (!result) {
if (!context.mounted) return;
showBottomSheetWithBlur(
userRootNavigator: true,
context: context,
height: null,
child: SuggestionFailureBottomSheet(
userName: consultationModel.userName,
dietName: dietName,
),
);
return;
}
if (chatId == null || chatId.isEmpty) {
showErrorToast(noChatMessage);
return;
}
router.goNamed('chatScreen', queryParameters: {'uuid': chatId});
}
} }
class SuggestDietBottomSheet extends StatefulWidget { class SuggestDietBottomSheet extends StatefulWidget {
@@ -278,65 +318,6 @@ class _SuggestDietBottomSheet extends State<SuggestDietBottomSheet> {
} }
} }
class SuggestionSuccessBottomSheet extends StatefulWidget {
const SuggestionSuccessBottomSheet({
super.key,
required this.userName,
required this.dietName,
});
final String userName;
final String dietName;
@override
State<SuggestionSuccessBottomSheet> createState() =>
_SuggestionSuccessBottomSheet();
}
class _SuggestionSuccessBottomSheet
extends State<SuggestionSuccessBottomSheet> {
@override
Widget build(BuildContext context) {
final s = S.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8),
const BottomSheetHandlerBar(),
const SizedBox(height: 20),
Text(
'Предложение диеты',
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
),
const SizedBox(height: 16),
Text(
'«${widget.dietName}» предложена пользователю ${widget.userName}',
style: TextStyles.textSMedium.copyWith(color: appTheme.black),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
CustomOutlinedButton(
text: s.ok,
height: 52,
buttonTextStyle: TextStyles.textMSemibold.copyWith(
color: appTheme.whiteA700,
),
buttonStyle: ButtonStyle(
side: const WidgetStatePropertyAll(BorderSide.none),
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
),
onPressed: () async {
AppRouter.navigatorKey.currentContext?.pop();
},
),
const SizedBox(height: 40),
],
),
);
}
}
class SuggestionFailureBottomSheet extends StatefulWidget { class SuggestionFailureBottomSheet extends StatefulWidget {
const SuggestionFailureBottomSheet({ const SuggestionFailureBottomSheet({
super.key, super.key,

View File

@@ -1,6 +1,7 @@
import 'package:eatvio_app/core/locator_service.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/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/utils/localization_utils.dart';
import 'package:eatvio_app/core/widgets/loader.dart'; import 'package:eatvio_app/core/widgets/loader.dart';
import 'package:eatvio_app/features/personal_diet_suggestion/cubit/personal_diet_suggestion_cubit.dart'; import 'package:eatvio_app/features/personal_diet_suggestion/cubit/personal_diet_suggestion_cubit.dart';
import 'package:eatvio_app/features/personal_diet_suggestion/cubit/personal_diet_suggestion_state.dart'; import 'package:eatvio_app/features/personal_diet_suggestion/cubit/personal_diet_suggestion_state.dart';
@@ -17,7 +18,9 @@ class PersonalDietSuggestionScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider( return BlocProvider(
create: (context) => sl<PersonalDietSuggestionCubit>()..getConsultationWithPersonalDiets(dietId), create: (context) =>
sl<PersonalDietSuggestionCubit>()
..getConsultationWithPersonalDiets(dietId),
child: const PersonalDietSuggestionScreenContent(), child: const PersonalDietSuggestionScreenContent(),
); );
} }
@@ -28,7 +31,10 @@ class PersonalDietSuggestionScreenContent extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final s = S.of(context); final s = S.of(context);
return BlocBuilder<PersonalDietSuggestionCubit, PersonalDietSuggestionState>( return BlocBuilder<
PersonalDietSuggestionCubit,
PersonalDietSuggestionState
>(
builder: (context, state) { builder: (context, state) {
return Scaffold( return Scaffold(
backgroundColor: appTheme.gray100, backgroundColor: appTheme.gray100,
@@ -36,45 +42,97 @@ class PersonalDietSuggestionScreenContent extends StatelessWidget {
centerTitle: true, centerTitle: true,
title: Text( title: Text(
'${s.dietSuggestion}\n«${state.dietName}»', '${s.dietSuggestion}\n«${state.dietName}»',
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black), style: TextStyles.headingXLSemibold.copyWith(
color: appTheme.black,
),
), ),
), ),
body: state.isLoading body: state.isLoading
? const Center(child: Loader()) ? const Center(child: Loader())
: (state.consultationsWithDiet.isEmpty && state.consultationsWithoutDiet.isEmpty) : SingleChildScrollView(
? Center( child: Column(
child: Padding( crossAxisAlignment: CrossAxisAlignment.stretch,
padding: const EdgeInsets.all(32.0), children: [
child: Text( if (state.dietDuration > 0)
'Нет активных консультаций с персональной диетой', Padding(
style: TextStyles.textMSemibold.copyWith(color: appTheme.gray), padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
textAlign: TextAlign.center, child: _PersonalDietDurationHint(
), duration: state.dietDuration,
),
)
: SingleChildScrollView(
child: Column(
children: [
...List.generate(state.consultationsWithDiet.length, (index) {
return ConsultationWidget(
consultationModel: state.consultationsWithDiet[index],
dietName: state.dietName,
);
}),
const SizedBox(
height: 12,
), ),
...List.generate(state.consultationsWithoutDiet.length, (index) { ),
if (state.consultationsWithDiet.isEmpty &&
state.consultationsWithoutDiet.isEmpty)
Padding(
padding: const EdgeInsets.all(32),
child: Text(
s.no_active_personal_diet_consultations,
style: TextStyles.textMSemibold.copyWith(
color: appTheme.gray,
),
textAlign: TextAlign.center,
),
)
else ...[
...List.generate(state.consultationsWithDiet.length, (
index,
) {
return ConsultationWidget(
consultationModel:
state.consultationsWithDiet[index],
dietName: state.dietName,
dietDuration: state.dietDuration,
);
}),
const SizedBox(height: 12),
...List.generate(
state.consultationsWithoutDiet.length,
(index) {
return ConsultationWidget( return ConsultationWidget(
consultationModel: state.consultationsWithoutDiet[index], consultationModel:
state.consultationsWithoutDiet[index],
dietName: state.dietName, dietName: state.dietName,
dietDuration: state.dietDuration,
); );
}) },
], ),
), ],
), ],
),
),
); );
}, },
); );
} }
} }
class _PersonalDietDurationHint extends StatelessWidget {
const _PersonalDietDurationHint({required this.duration});
final int duration;
@override
Widget build(BuildContext context) {
final s = S.of(context);
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: appTheme.gray50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: appTheme.dividerColor),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.info_outline, size: 20, color: appTheme.blueA400),
const SizedBox(width: 8),
Expanded(
child: Text(
s.personal_diet_duration_hint(duration, getDayForm(duration, s)),
style: TextStyles.textSMedium.copyWith(color: appTheme.gray),
),
),
],
),
);
}
}

View File

@@ -11,13 +11,23 @@ class PersonalDietSuggestionRepository {
} }
Future<void> attachPersonalDiet({required int consultationId, required int dietId}) async { Future<void> attachPersonalDiet({
return personalDietSuggestionService.attachPersonalDiet(consultationId: consultationId, dietId: dietId); required int consultationId,
required int dietId,
}) async {
await personalDietSuggestionService.attachPersonalDiet(
consultationId,
{'diet_id': dietId},
);
} }
Future<void> reattachPersonalDiet({required int consultationId, required int dietId}) async { Future<void> reattachPersonalDiet({
return await personalDietSuggestionService.reattachPersonalDiet(consultationId: consultationId, dietId: dietId); required int consultationId,
required int dietId,
}) async {
await personalDietSuggestionService.reattachPersonalDiet(
consultationId,
{'diet_id': dietId},
);
} }
} }

View File

@@ -1,36 +1,40 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:eatvio_app/features/consultations/models/consultation_model.dart'; import 'package:eatvio_app/features/consultations/models/consultation_model.dart';
import 'package:retrofit/retrofit.dart';
class PersonalDietSuggestionService { part 'personal_diet_suggestion_service.g.dart';
final Dio dio;
PersonalDietSuggestionService(this.dio);
Future<ConsultationPaginationModel> getConsultationWithPersonalDiets() async { @RestApi()
try { abstract class PersonalDietSuggestionService {
final response = await dio.get('/api/consultations', queryParameters: { factory PersonalDietSuggestionService(Dio dio, {String? baseUrl}) =
'filters[plan][has_personal_diet][\$personal_diet]': '1', _PersonalDietSuggestionService;
'filters[last_state][\$notIn][]': ['done', 'charge_was_collected', 'complained', 'last_day_finished', 'canceled_by_the_initiator', 'inited'],
'sort[]': 'start_date:desc',
});
return ConsultationPaginationModel.fromJson(response.data);
} catch (e) {
rethrow;
}
}
Future<void> attachPersonalDiet({required int consultationId, required int dietId}) async { @GET('/api/consultations')
try { Future<ConsultationPaginationModel> getConsultationWithPersonalDiets({
await dio.post('/api/chosen_plans/$consultationId/attach_diet', data: {'diet_id': dietId}); @Query('filters[plan][has_personal_diet][\$personal_diet]')
} catch (e) { String hasPersonalDiet = '1',
rethrow; @Query('filters[last_state][\$notIn][]')
} List<String> notIn = const [
} 'done',
'charge_was_collected',
'complained',
'last_day_finished',
'last_day_started',
'canceled_by_the_initiator',
'inited',
],
@Query('sort[]') List<String> sort = const ['start_date:desc'],
});
Future<void> reattachPersonalDiet({required int consultationId, required int dietId}) async { @POST('/api/chosen_plans/{id}/attach_diet')
try { Future<void> attachPersonalDiet(
await dio.post('/api/chosen_plans/$consultationId/re_attach_diet', data: {'diet_id': dietId}); @Path('id') int consultationId,
} catch (e) { @Body() Map<String, dynamic> body,
rethrow; );
}
} @POST('/api/chosen_plans/{id}/re_attach_diet')
Future<void> reattachPersonalDiet(
@Path('id') int consultationId,
@Body() Map<String, dynamic> body,
);
} }

View File

@@ -62,6 +62,32 @@ enum ChosenPlanStateEnum {
} }
} }
bool get canShowCancelAction {
switch (this) {
case ChosenPlanStateEnum.stateInited:
case ChosenPlanStateEnum.stateInCoordination:
case ChosenPlanStateEnum.stateStartDateHasBeenSet:
case ChosenPlanStateEnum.statePendingPayment:
case ChosenPlanStateEnum.statePaid:
case ChosenPlanStateEnum.stateStarted:
case ChosenPlanStateEnum.stateInProgress:
return true;
default:
return false;
}
}
bool get isCancelAfterPaidState {
switch (this) {
case ChosenPlanStateEnum.statePaid:
case ChosenPlanStateEnum.stateStarted:
case ChosenPlanStateEnum.stateInProgress:
return true;
default:
return false;
}
}
static List<ChosenPlanStateEnum> getActiveStates() { static List<ChosenPlanStateEnum> getActiveStates() {
return [ return [
ChosenPlanStateEnum.statePaid, ChosenPlanStateEnum.statePaid,

View File

@@ -0,0 +1,29 @@
import 'package:freezed_annotation/freezed_annotation.dart';
enum PlanOptionType {
@JsonValue('options1')
options1('options1'),
@JsonValue('options2')
options2('options2'),
@JsonValue('options3')
options3('options3');
const PlanOptionType(this.apiValue);
final String apiValue;
static PlanOptionType? tryParse(String? raw) {
for (final option in values) {
if (option.apiValue == raw) {
return option;
}
}
return null;
}
static PlanOptionType fromIndex(int index) => switch (index) {
1 => PlanOptionType.options2,
2 => PlanOptionType.options3,
_ => PlanOptionType.options1,
};
}

View File

@@ -1,5 +1,6 @@
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart'; import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart'; import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart';
import 'package:eatvio_app/features/plans/enums/plan_option_type_enum.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
part 'chosen_plan_model.freezed.dart'; part 'chosen_plan_model.freezed.dart';
@@ -7,6 +8,7 @@ part 'chosen_plan_model.g.dart';
@freezed @freezed
abstract class ChosenPlanModel with _$ChosenPlanModel { abstract class ChosenPlanModel with _$ChosenPlanModel {
const ChosenPlanModel._();
factory ChosenPlanModel({ factory ChosenPlanModel({
@JsonKey() required int id, @JsonKey() required int id,
@@ -15,10 +17,24 @@ abstract class ChosenPlanModel with _$ChosenPlanModel {
@JsonKey(name: 'last_state') required ChosenPlanStateEnum chosenPlanState, @JsonKey(name: 'last_state') required ChosenPlanStateEnum chosenPlanState,
@JsonKey(name: 'chat_id') required String chatId, @JsonKey(name: 'chat_id') required String chatId,
@JsonKey(name: 'consultant_id') required int consultantId, @JsonKey(name: 'consultant_id') required int consultantId,
@JsonKey(name: 'personal_diet_name') String? personalDietName,
@JsonKey(name: 'plan') required PlanModel plan, @JsonKey(name: 'plan') required PlanModel plan,
}) = _ChosenPlanModel; }) = _ChosenPlanModel;
factory ChosenPlanModel.fromJson(Map<String, dynamic> json) => _$ChosenPlanModelFromJson(json); factory ChosenPlanModel.fromJson(Map<String, dynamic> json) => _$ChosenPlanModelFromJson(json);
PacketOptions? get chosenOption {
switch (PlanOptionType.tryParse(optionType)) {
case PlanOptionType.options1:
return plan.packet1Options;
case PlanOptionType.options2:
return plan.packet2Options;
case PlanOptionType.options3:
return plan.packet3Options;
case null:
return null;
}
}
} }
ChosenPlanModel? parseChosenPlan(dynamic json) { ChosenPlanModel? parseChosenPlan(dynamic json) {

View File

@@ -4,6 +4,7 @@ import 'package:dio/dio.dart';
import 'package:easy_debounce/easy_debounce.dart'; import 'package:easy_debounce/easy_debounce.dart';
import 'package:eatvio_app/core/utils/toasts.dart'; import 'package:eatvio_app/core/utils/toasts.dart';
import 'package:eatvio_app/features/diets/repositories/diet_repository.dart'; import 'package:eatvio_app/features/diets/repositories/diet_repository.dart';
import 'package:eatvio_app/features/plans/enums/plan_option_type_enum.dart';
import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart'; import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart';
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_state.dart'; import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_state.dart';
import 'package:eatvio_app/features/plans/repositories/plan_repository.dart'; import 'package:eatvio_app/features/plans/repositories/plan_repository.dart';
@@ -133,7 +134,7 @@ class AboutPlanCubit extends Cubit<AboutPlanState> {
final slug = state.planModel?.slug; final slug = state.planModel?.slug;
final optionIndex = state.chosenOptionIndex; final optionIndex = state.chosenOptionIndex;
if (slug != null) { if (slug != null) {
final optionName = _getOptionNameFromIndex(optionIndex); final optionName = PlanOptionType.fromIndex(optionIndex).apiValue;
final body = {'options_type': optionName}; final body = {'options_type': optionName};
return (await planRepository.chosePlan(slug, body), null); return (await planRepository.chosePlan(slug, body), null);
} }
@@ -181,10 +182,4 @@ class AboutPlanCubit extends Cubit<AboutPlanState> {
return false; return false;
} }
} }
String _getOptionNameFromIndex(int index) => switch (index) {
1 => 'options2',
2 => 'options3',
_ => 'options1',
};
} }

View File

@@ -9,9 +9,9 @@ abstract class CircumferenceModel with _$CircumferenceModel {
const factory CircumferenceModel({ const factory CircumferenceModel({
@JsonKey(name: "id") required int id, @JsonKey(name: "id") required int id,
@JsonKey(name: "user_id") required int userId, @JsonKey(name: "user_id") required int userId,
@JsonKey(name: "chest_circumference") required int chestCircumference, @JsonKey(name: "chest_circumference") required double chestCircumference,
@JsonKey(name: "waist_circumference") required int waistCircumference, @JsonKey(name: "waist_circumference") required double waistCircumference,
@JsonKey(name: "hip_circumference") required int hipCircumference, @JsonKey(name: "hip_circumference") required double hipCircumference,
@JsonKey(name: "created_at") required DateTime createdAt, @JsonKey(name: "created_at") required DateTime createdAt,
@JsonKey(name: "updated_at") required DateTime updatedAt, @JsonKey(name: "updated_at") required DateTime updatedAt,
}) = _CircumferenceModel; }) = _CircumferenceModel;

View File

@@ -9,10 +9,10 @@ abstract class WeightModel with _$WeightModel {
const factory WeightModel({ const factory WeightModel({
@JsonKey(name: "id") required int id, @JsonKey(name: "id") required int id,
@JsonKey(name: "user_id") required int userId, @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: "created_at") required DateTime createdAt,
@JsonKey(name: "updated_at") required DateTime updatedAt, @JsonKey(name: "updated_at") required DateTime updatedAt,
@JsonKey(name: "bmi") required double bmi, @JsonKey(name: "bmi") double? bmi,
}) = _WeightModel; }) = _WeightModel;
factory WeightModel.fromJson(Map<String, dynamic> json) => _$WeightModelFromJson(json); factory WeightModel.fromJson(Map<String, dynamic> json) => _$WeightModelFromJson(json);

View File

@@ -17,7 +17,6 @@ class ProfileCubit extends Cubit<ProfileState> {
final ProfileRepository profileRepository; final ProfileRepository profileRepository;
final ChatRepository chatRepository; final ChatRepository chatRepository;
final ConsultantRepository consultantRepository; final ConsultantRepository consultantRepository;
String _currentPeriod = '1';
/// Bottom-nav / own profile instance, used to refresh posts after create/update/delete. /// Bottom-nav / own profile instance, used to refresh posts after create/update/delete.
static ProfileCubit? myProfileCubit; 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; myProfileCubit = this;
emit(const ProfileState.loading()); emit(const ProfileState.loading());
_currentPeriod = period;
try { try {
final profile = await profileRepository.getMyProfile(); final profile = await profileRepository.getMyProfile();
List<ConsultantCertPhotoModel> certPhotos = await consultantRepository.getConsultantCertPhotos(); List<ConsultantCertPhotoModel> certPhotos = await consultantRepository.getConsultantCertPhotos();
@@ -69,8 +67,6 @@ class ProfileCubit extends Cubit<ProfileState> {
isMyProfile: true, isMyProfile: true,
profileDetails: await profileRepository.getMyProfileDetails(), profileDetails: await profileRepository.getMyProfileDetails(),
blogPosts: await profileRepository.getBlogPostsByAuthorSlug(slug: profile.slug), 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, consultantInfo: profile.roles.contains('consultant') ? await consultantRepository.getConsultantRequestStatus(profile.slug) : null,
certPhotos: certPhotos, certPhotos: certPhotos,
), ),
@@ -110,8 +106,6 @@ class ProfileCubit extends Cubit<ProfileState> {
profile: (state as ProfileStateLoaded).profile, profile: (state as ProfileStateLoaded).profile,
profileDetails: (state as ProfileStateLoaded).profileDetails, profileDetails: (state as ProfileStateLoaded).profileDetails,
blogPosts: feedBlogPostList, blogPosts: feedBlogPostList,
weightPoints: (state as ProfileStateLoaded).weightPoints,
circumferencePoints: (state as ProfileStateLoaded).circumferencePoints,
consultantInfo: (state as ProfileStateLoaded).consultantInfo, consultantInfo: (state as ProfileStateLoaded).consultantInfo,
isMyProfile: (state as ProfileStateLoaded).isMyProfile, isMyProfile: (state as ProfileStateLoaded).isMyProfile,
certPhotos: (state as ProfileStateLoaded).certPhotos, certPhotos: (state as ProfileStateLoaded).certPhotos,
@@ -126,7 +120,6 @@ class ProfileCubit extends Cubit<ProfileState> {
waistCircumference: waistCircumference, waistCircumference: waistCircumference,
hipCircumference: hipCircumference, hipCircumference: hipCircumference,
); );
await getMyProfile(period: _currentPeriod);
} catch (e, st) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
emit(ProfileState.error(errorMessage: e.toString())); emit(ProfileState.error(errorMessage: e.toString()));
@@ -136,7 +129,6 @@ class ProfileCubit extends Cubit<ProfileState> {
Future<void> addWeight({required double weight}) async { Future<void> addWeight({required double weight}) async {
try { try {
await profileRepository.addWeight(weight: weight); await profileRepository.addWeight(weight: weight);
await getMyProfile(period: _currentPeriod);
} catch (e, st) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
emit(ProfileState.error(errorMessage: e.toString())); emit(ProfileState.error(errorMessage: e.toString()));
@@ -145,13 +137,13 @@ class ProfileCubit extends Cubit<ProfileState> {
Future<String?> getChatRoomIdBySlug({required String slug}) async { Future<String?> getChatRoomIdBySlug({required String slug}) async {
try { try {
final chatIdOrError = await chatRepository.getChatByUserSlug(slug); final chatId = await chatRepository.getChatByUserSlug(slug);
final userProfile = await profileRepository.getProfileBySlug(slug); if (chatId == null || chatId.isEmpty) {
if (chatIdOrError == '404') { final userProfile = await profileRepository.getProfileBySlug(slug);
AppRouter.navigatorKey.currentContext?.pushNamed('emptyChatScreen', extra: userProfile); AppRouter.navigatorKey.currentContext?.pushNamed('emptyChatScreen', extra: userProfile);
} else { } else {
AppRouter.navigatorKey.currentContext?.pushNamed('chatScreen', extra: {'uuid': chatIdOrError}); AppRouter.navigatorKey.currentContext?.pushNamed('chatScreen', extra: {'uuid': chatId});
return chatIdOrError; return chatId;
} }
} catch (e, st) { } catch (e, st) {
await reportFrom(e, st, this); 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/consultant_cert_photo_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_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/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/profile_model.dart';
import 'package:eatvio_app/features/profile/models/weight_model.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
part 'profile_state.freezed.dart'; part 'profile_state.freezed.dart';
@@ -20,8 +18,6 @@ abstract class ProfileState with _$ProfileState {
required List<FeedBlogPostModel> blogPosts, required List<FeedBlogPostModel> blogPosts,
required bool isMyProfile, required bool isMyProfile,
required List<ConsultantCertPhotoModel> certPhotos, required List<ConsultantCertPhotoModel> certPhotos,
List<WeightModel>? weightPoints,
List<CircumferenceModel>? circumferencePoints,
ConsultationRequestModel? consultantInfo, ConsultationRequestModel? consultantInfo,
}) = ProfileStateLoaded; }) = 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/chat/presentation/pages/photo_view_screen.dart';
import 'package:eatvio_app/features/complaining/presentation/complaining_bottom_sheet.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/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/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_cubit.dart';
import 'package:eatvio_app/features/profile/presentation/bloc/plans_cubit/plans_state.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_cubit.dart';
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_state.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/bloc/sub_cubit/sub_cubit.dart';
import 'package:eatvio_app/features/profile/presentation/pages/subs_screen.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/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/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/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/locator_service.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/generated/l10n.dart'; import 'package:eatvio_app/generated/l10n.dart';
@@ -60,21 +56,8 @@ class ProfileScreen extends StatefulWidget {
class _ProfileScreenState extends State<ProfileScreen> { class _ProfileScreenState extends State<ProfileScreen> {
late final ProfileCubit profileCubit = sl<ProfileCubit>(); late final ProfileCubit profileCubit = sl<ProfileCubit>();
final PlansCubit plansCubit = sl<PlansCubit>(); 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(); final TextEditingController controller = TextEditingController();
List<WeightModel> weightPoints = [];
List<CircumferenceModel> circumferencePoints = [];
int _selectedChipIndex = 0;
int _selectedIntervalChipIndex = 0;
late bool canSub; late bool canSub;
bool isFirstFetch = true; bool isFirstFetch = true;
int _currentBullet = 0; int _currentBullet = 0;
@@ -96,10 +79,6 @@ class _ProfileScreenState extends State<ProfileScreen> {
bloc: profileCubit, bloc: profileCubit,
builder: (BuildContext context, ProfileState state) { builder: (BuildContext context, ProfileState state) {
if (state is ProfileStateLoaded) { if (state is ProfileStateLoaded) {
if (state.circumferencePoints != null) {
circumferencePoints = state.circumferencePoints!;
weightPoints = state.weightPoints!;
}
if (isFirstFetch) { if (isFirstFetch) {
canSub = !state.profile.isEntitySubscribedByMe; canSub = !state.profile.isEntitySubscribedByMe;
if (state.profile.roles.contains('consultant') && !hidePlans) { if (state.profile.roles.contains('consultant') && !hidePlans) {
@@ -281,6 +260,14 @@ class _ProfileScreenState extends State<ProfileScreen> {
padding: const EdgeInsets.only(bottom: 16), padding: const EdgeInsets.only(bottom: 16),
child: _badges(state), child: _badges(state),
), ),
// Доступ решает сервер (UserPolicy::viewInsights): консультант видит
// клиента, пока консультация не завершена; всем остальным 403 и блок скрыт.
if (!state.profile.isBlockedByMe &&
!state.profile.isBlockedByAuthor)
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: InsightsCard(slug: state.profile.slug),
),
if (state.profile.isBlockedByAuthor) if (state.profile.isBlockedByAuthor)
Column( Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@@ -442,10 +429,11 @@ class _ProfileScreenState extends State<ProfileScreen> {
child: _badges(state), child: _badges(state),
), ),
if (state.profile.roles.contains('consultant')) _socialLinks(state), if (state.profile.roles.contains('consultant')) _socialLinks(state),
Padding( if (state.isMyProfile)
padding: const EdgeInsets.only(bottom: 16), const Padding(
child: _charts(), padding: EdgeInsets.only(bottom: 16),
), child: InsightsCard(canAdd: true),
),
if (state.profile.roles.contains('consultant')) _education(state), if (state.profile.roles.contains('consultant')) _education(state),
if (state.profile.roles.contains('consultant')) if (state.profile.roles.contains('consultant'))
_diplomasAndCertificates(state), _diplomasAndCertificates(state),
@@ -1722,114 +1710,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 { try {
final profileDetails = await profileRepository.getProfileDetailsBySlug(slug); final profileDetails = await profileRepository.getProfileDetailsBySlug(slug);
emit(state.copyWith(profileDetails: profileDetails, isLoading: false)); emit(state.copyWith(profileDetails: profileDetails, isLoading: false));
getChartData(slug: slug);
} catch (e, st) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
log(e.toString()); log(e.toString());
emit(state.copyWith(error: e.toString(), isLoading: false)); 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/profile_model.dart';
import 'package:eatvio_app/features/profile/models/weight_model.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
part 'user_details_state.freezed.dart'; part 'user_details_state.freezed.dart';
@@ -11,7 +9,5 @@ abstract class UserDetailsState with _$UserDetailsState {
@Default(false) bool isLoading, @Default(false) bool isLoading,
String? error, String? error,
ProfileDetailsModel? profileDetails, ProfileDetailsModel? profileDetails,
List<WeightModel>? weightPoints,
List<CircumferenceModel>? circumferencePoints,
}) = _UserDetailsState; }) = _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/data_error_widget.dart';
import 'package:eatvio_app/core/widgets/loader.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/diets/presentation/widgets/more_options.dart';
import 'package:eatvio_app/features/profile/models/circumference_model.dart'; import 'package:eatvio_app/features/insights/presentation/widgets/insights_card.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/user_details/presentation/cubit/user_details_cubit.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/cubit/user_details_state.dart';
import 'package:eatvio_app/features/user_details/presentation/widgets/expandable_bouble.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> { 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; bool isMoreOptionsVisible = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -59,8 +48,6 @@ class _UserDetailsContentState extends State<UserDetailsContent> {
builder: (context, state) { builder: (context, state) {
final profileDetails = state.profileDetails; final profileDetails = state.profileDetails;
if (profileDetails != null && state.isLoading == false) { if (profileDetails != null && state.isLoading == false) {
weightPoints = state.weightPoints ?? [];
circumferencePoints = state.circumferencePoints ?? [];
return Scaffold( return Scaffold(
backgroundColor: appTheme.gray100, backgroundColor: appTheme.gray100,
appBar: AppBar( appBar: AppBar(
@@ -104,7 +91,7 @@ class _UserDetailsContentState extends State<UserDetailsContent> {
spacing: 12, spacing: 12,
children: [ children: [
const SizedBox.shrink(), const SizedBox.shrink(),
_charts(), InsightsCard(slug: widget.slug),
ExpandableBouble( ExpandableBouble(
initiallyExpanded: true, initiallyExpanded: true,
title: s.general_info, 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,80 @@ class MessageLookup extends MessageLookupByLibrary {
static String m0(date) => "Консультант прокомментировал день ${date}"; 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(userName) =>
"You have proposed a personal diet to ${userName}";
static String m15(userName, dietName) =>
"You have proposed a personal diet to ${userName}, selected diet: «${dietName}»";
static String m16(duration, dayWord) =>
"The diet lasts ${duration} ${dayWord}. It can only be offered for a plan with the same duration.";
static String m17(dietDuration, dietDayWord, planDuration, planDayWord) =>
"Not suitable: diet lasts ${dietDuration} ${dietDayWord}, plan lasts ${planDuration} ${planDayWord}.";
static String m18(name) => "Diet «${name}» proposed";
static String m19(planName) => "План «${planName}» начался";
static String m20(planName, name) =>
"План «${planName}» с пользователем ${name} начался"; "План «${planName}» с пользователем ${name} начался";
static String m3(percent, amount) => static String m21(duration, dayWord) => "Plan for ${duration} ${dayWord}";
static String m22(percent, amount) =>
"Discount ${percent}% — you save ${amount} ₽"; "Discount ${percent}% — you save ${amount} ₽";
static String m4(step) => "шаг ${step} из 2"; static String m23(date) => "Start: ${date}";
static String m5(planName, name) => static String m24(step) => "шаг ${step} из 2";
static String m25(planName, name) =>
"Завтра начинается план «${planName}» с пользователем ${name}"; "Завтра начинается план «${planName}» с пользователем ${name}";
static String m6(planName) => static String m26(planName) =>
"Завтра начнется прохождение плана «${planName}»"; "Завтра начнется прохождение плана «${planName}»";
static String m7(name, planName) => static String m27(name, planName) =>
"Пользователь ${name} отменил подписку на план «${planName}»"; "Пользователь ${name} отменил подписку на план «${planName}»";
static String m8(name, planName) => static String m28(name, planName) =>
"Пользователь ${name} оплатил план «${planName}»"; "Пользователь ${name} оплатил план «${planName}»";
static String m9(name, planName) => static String m29(name, planName) =>
"Пользователь ${name} подписался на план «${planName}»"; "Пользователь ${name} подписался на план «${planName}»";
static String m10(planName) => "Вы отменили подписку на план «${planName}»"; static String m30(planName) => "Вы отменили подписку на план «${planName}»";
final messages = _notInlinedMessages(_notInlinedMessages); final messages = _notInlinedMessages(_notInlinedMessages);
static Map<String, Function> _notInlinedMessages(_) => <String, Function>{ static Map<String, Function> _notInlinedMessages(_) => <String, Function>{
@@ -170,6 +218,9 @@ class MessageLookup extends MessageLookupByLibrary {
), ),
"carbohydrates": MessageLookupByLibrary.simpleMessage("Углеводы"), "carbohydrates": MessageLookupByLibrary.simpleMessage("Углеводы"),
"changeDiet": MessageLookupByLibrary.simpleMessage("Заменить диету"), "changeDiet": MessageLookupByLibrary.simpleMessage("Заменить диету"),
"change_personal_diet": MessageLookupByLibrary.simpleMessage(
"Change personal diet",
),
"chargeApproved": MessageLookupByLibrary.simpleMessage("Выплата одобрена"), "chargeApproved": MessageLookupByLibrary.simpleMessage("Выплата одобрена"),
"chargeCollectionFailed": MessageLookupByLibrary.simpleMessage( "chargeCollectionFailed": MessageLookupByLibrary.simpleMessage(
"Не удалось оформить выплату", "Не удалось оформить выплату",
@@ -317,6 +368,7 @@ class MessageLookup extends MessageLookupByLibrary {
), ),
"days": MessageLookupByLibrary.simpleMessage("дней"), "days": MessageLookupByLibrary.simpleMessage("дней"),
"deception": MessageLookupByLibrary.simpleMessage("Обман"), "deception": MessageLookupByLibrary.simpleMessage("Обман"),
"default_user_name": MessageLookupByLibrary.simpleMessage("User"),
"delete": MessageLookupByLibrary.simpleMessage("Удалить"), "delete": MessageLookupByLibrary.simpleMessage("Удалить"),
"deleteComment": MessageLookupByLibrary.simpleMessage( "deleteComment": MessageLookupByLibrary.simpleMessage(
"Удалить комментарий", "Удалить комментарий",
@@ -503,6 +555,126 @@ class MessageLookup extends MessageLookupByLibrary {
"initial_measurements": MessageLookupByLibrary.simpleMessage( "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("Пригласить"), "invite": MessageLookupByLibrary.simpleMessage("Пригласить"),
"invited_people_count": MessageLookupByLibrary.simpleMessage( "invited_people_count": MessageLookupByLibrary.simpleMessage(
"Количество приглашенных", "Количество приглашенных",
@@ -604,6 +776,10 @@ class MessageLookup extends MessageLookupByLibrary {
"nicotine": MessageLookupByLibrary.simpleMessage("Никотин"), "nicotine": MessageLookupByLibrary.simpleMessage("Никотин"),
"no": MessageLookupByLibrary.simpleMessage("Нет"), "no": MessageLookupByLibrary.simpleMessage("Нет"),
"no_account": MessageLookupByLibrary.simpleMessage("У меня нет аккаунта"), "no_account": MessageLookupByLibrary.simpleMessage("У меня нет аккаунта"),
"no_active_personal_diet_consultations":
MessageLookupByLibrary.simpleMessage(
"No active consultations with a personal diet",
),
"no_default_cards": MessageLookupByLibrary.simpleMessage( "no_default_cards": MessageLookupByLibrary.simpleMessage(
"У вас нет привязанных карт. Что бы начать работу, привяжите карту", "У вас нет привязанных карт. Что бы начать работу, привяжите карту",
), ),
@@ -635,6 +811,9 @@ class MessageLookup extends MessageLookupByLibrary {
"order": MessageLookupByLibrary.simpleMessage("Порядок"), "order": MessageLookupByLibrary.simpleMessage("Порядок"),
"orderCanceled": MessageLookupByLibrary.simpleMessage("Заказ отменен"), "orderCanceled": MessageLookupByLibrary.simpleMessage("Заказ отменен"),
"order_created": MessageLookupByLibrary.simpleMessage("Заказ создан"), "order_created": MessageLookupByLibrary.simpleMessage("Заказ создан"),
"order_in_progress": MessageLookupByLibrary.simpleMessage(
"Order in progress",
),
"order_paid": MessageLookupByLibrary.simpleMessage("Заказ оплачен"), "order_paid": MessageLookupByLibrary.simpleMessage("Заказ оплачен"),
"order_price": MessageLookupByLibrary.simpleMessage("Сумма заказа"), "order_price": MessageLookupByLibrary.simpleMessage("Сумма заказа"),
"order_status": MessageLookupByLibrary.simpleMessage("Статус заказа"), "order_status": MessageLookupByLibrary.simpleMessage("Статус заказа"),
@@ -658,6 +837,9 @@ class MessageLookup extends MessageLookupByLibrary {
"В пароле должно быть минимум 6 символов", "В пароле должно быть минимум 6 символов",
), ),
"pay": MessageLookupByLibrary.simpleMessage("Оплатить"), "pay": MessageLookupByLibrary.simpleMessage("Оплатить"),
"pay_by_qr_code": MessageLookupByLibrary.simpleMessage(
"Оплатить по QR-коду",
),
"payment_failed": MessageLookupByLibrary.simpleMessage("Оплата не прошла"), "payment_failed": MessageLookupByLibrary.simpleMessage("Оплата не прошла"),
"pcs": MessageLookupByLibrary.simpleMessage("шт"), "pcs": MessageLookupByLibrary.simpleMessage("шт"),
"pending_payment": MessageLookupByLibrary.simpleMessage("Ожидается оплата"), "pending_payment": MessageLookupByLibrary.simpleMessage("Ожидается оплата"),
@@ -665,6 +847,20 @@ class MessageLookup extends MessageLookupByLibrary {
"personalDietCreating": MessageLookupByLibrary.simpleMessage( "personalDietCreating": MessageLookupByLibrary.simpleMessage(
"Составление индивидуальной диеты", "Составление индивидуальной диеты",
), ),
"personal_diet_defined_to_author": m14,
"personal_diet_defined_to_author_named": m15,
"personal_diet_duration_hint": m16,
"personal_diet_duration_mismatch": m17,
"personal_diet_not_proposed": MessageLookupByLibrary.simpleMessage(
"Personal diet not proposed",
),
"personal_diet_proposed": MessageLookupByLibrary.simpleMessage(
"Personal diet proposed",
),
"personal_diet_proposed_named": m18,
"personal_diet_replaces_tomorrow": MessageLookupByLibrary.simpleMessage(
"The change takes effect tomorrow",
),
"personal_diet_set": MessageLookupByLibrary.simpleMessage( "personal_diet_set": MessageLookupByLibrary.simpleMessage(
"Персональная диета установлена", "Персональная диета установлена",
), ),
@@ -681,17 +877,18 @@ class MessageLookup extends MessageLookupByLibrary {
"Физическая форма", "Физическая форма",
), ),
"place_of_living": MessageLookupByLibrary.simpleMessage("Место проживания"), "place_of_living": MessageLookupByLibrary.simpleMessage("Место проживания"),
"planStarted": m1, "planStarted": m19,
"planWillBeConfirmedAfterThreeDays": MessageLookupByLibrary.simpleMessage( "planWillBeConfirmedAfterThreeDays": MessageLookupByLibrary.simpleMessage(
"Если вы не подтвердите, то план через три дня подтвердится автоматически", "Если вы не подтвердите, то план через три дня подтвердится автоматически",
), ),
"planWithUserStarted": m2, "planWithUserStarted": m20,
"plan_author": MessageLookupByLibrary.simpleMessage("Автор плана"), "plan_author": MessageLookupByLibrary.simpleMessage("Автор плана"),
"plan_desc": MessageLookupByLibrary.simpleMessage("Описание плана"), "plan_desc": MessageLookupByLibrary.simpleMessage("Описание плана"),
"plan_desc_example": MessageLookupByLibrary.simpleMessage( "plan_desc_example": MessageLookupByLibrary.simpleMessage(
"Большое подробное, привлекательное описание плана", "Большое подробное, привлекательное описание плана",
), ),
"plan_duration": MessageLookupByLibrary.simpleMessage("Срок плана"), "plan_duration": MessageLookupByLibrary.simpleMessage("Срок плана"),
"plan_duration_label": m21,
"plan_label_desc": MessageLookupByLibrary.simpleMessage( "plan_label_desc": MessageLookupByLibrary.simpleMessage(
"План для поддержания формы", "План для поддержания формы",
), ),
@@ -728,7 +925,7 @@ class MessageLookup extends MessageLookupByLibrary {
"Promo code applied!", "Promo code applied!",
), ),
"promo_code": MessageLookupByLibrary.simpleMessage("Promo code"), "promo_code": MessageLookupByLibrary.simpleMessage("Promo code"),
"promo_discount_hint": m3, "promo_discount_hint": m22,
"promo_invalid": MessageLookupByLibrary.simpleMessage( "promo_invalid": MessageLookupByLibrary.simpleMessage(
"Promo code not found or expired", "Promo code not found or expired",
), ),
@@ -777,6 +974,34 @@ class MessageLookup extends MessageLookupByLibrary {
"sbp_payment_error": MessageLookupByLibrary.simpleMessage( "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": MessageLookupByLibrary.simpleMessage("Отправить"),
"send_complain": MessageLookupByLibrary.simpleMessage("Отправить жалобу"), "send_complain": MessageLookupByLibrary.simpleMessage("Отправить жалобу"),
"services": MessageLookupByLibrary.simpleMessage("Оказание услуг"), "services": MessageLookupByLibrary.simpleMessage("Оказание услуг"),
@@ -821,12 +1046,13 @@ class MessageLookup extends MessageLookupByLibrary {
"Выбрана дата старта", "Выбрана дата старта",
), ),
"startDiet": MessageLookupByLibrary.simpleMessage("Начать диету"), "startDiet": MessageLookupByLibrary.simpleMessage("Начать диету"),
"start_date_label": m23,
"statistic": MessageLookupByLibrary.simpleMessage("Статистика"), "statistic": MessageLookupByLibrary.simpleMessage("Статистика"),
"step": MessageLookupByLibrary.simpleMessage("Шаг"), "step": MessageLookupByLibrary.simpleMessage("Шаг"),
"step_by_step_photo_recipe": MessageLookupByLibrary.simpleMessage( "step_by_step_photo_recipe": MessageLookupByLibrary.simpleMessage(
"Пошаговый фоторецепт", "Пошаговый фоторецепт",
), ),
"step_widget": m4, "step_widget": m24,
"successfully_published": MessageLookupByLibrary.simpleMessage( "successfully_published": MessageLookupByLibrary.simpleMessage(
"успешно опубликован", "успешно опубликован",
), ),
@@ -851,8 +1077,8 @@ class MessageLookup extends MessageLookupByLibrary {
"toOffer": MessageLookupByLibrary.simpleMessage("Предложить"), "toOffer": MessageLookupByLibrary.simpleMessage("Предложить"),
"to_draft": MessageLookupByLibrary.simpleMessage("В черновик"), "to_draft": MessageLookupByLibrary.simpleMessage("В черновик"),
"to_order": MessageLookupByLibrary.simpleMessage("Заказать"), "to_order": MessageLookupByLibrary.simpleMessage("Заказать"),
"tommorowPlanWithUserWillStart": m5, "tommorowPlanWithUserWillStart": m25,
"tomorrowYourPlanWillStart": m6, "tomorrowYourPlanWillStart": m26,
"try_again": MessageLookupByLibrary.simpleMessage("Попробовать снова"), "try_again": MessageLookupByLibrary.simpleMessage("Попробовать снова"),
"try_again_2": MessageLookupByLibrary.simpleMessage("Попробуйте ещё раз"), "try_again_2": MessageLookupByLibrary.simpleMessage("Попробуйте ещё раз"),
"typeAmoutOfReturn": MessageLookupByLibrary.simpleMessage( "typeAmoutOfReturn": MessageLookupByLibrary.simpleMessage(
@@ -886,7 +1112,7 @@ class MessageLookup extends MessageLookupByLibrary {
"Верхние показатели давления:", "Верхние показатели давления:",
), ),
"upper": MessageLookupByLibrary.simpleMessage("Верхнее"), "upper": MessageLookupByLibrary.simpleMessage("Верхнее"),
"userCanceldThePlanSubscription": m7, "userCanceldThePlanSubscription": m27,
"userConfirmedPlanCompletion": MessageLookupByLibrary.simpleMessage( "userConfirmedPlanCompletion": MessageLookupByLibrary.simpleMessage(
"Пользователь подтвердил прохождения плана. В ближайшее время Вы получите", "Пользователь подтвердил прохождения плана. В ближайшее время Вы получите",
), ),
@@ -894,8 +1120,8 @@ class MessageLookup extends MessageLookupByLibrary {
MessageLookupByLibrary.simpleMessage( MessageLookupByLibrary.simpleMessage(
"Пользователь подтвердил прохождения плана.", "Пользователь подтвердил прохождения плана.",
), ),
"userPaidThePlan": m8, "userPaidThePlan": m28,
"userSubscribedToPlan": m9, "userSubscribedToPlan": m29,
"user_authorization": MessageLookupByLibrary.simpleMessage( "user_authorization": MessageLookupByLibrary.simpleMessage(
"Аутентификация", "Аутентификация",
), ),
@@ -1025,7 +1251,7 @@ class MessageLookup extends MessageLookupByLibrary {
"youCanComplain": MessageLookupByLibrary.simpleMessage( "youCanComplain": MessageLookupByLibrary.simpleMessage(
"Если вас что-то не устроило, вы можете подать жалобу", "Если вас что-то не устроило, вы можете подать жалобу",
), ),
"youCanceledSubscriptionToPlan": m10, "youCanceledSubscriptionToPlan": m30,
"youShureYouWantToCancelOrder": MessageLookupByLibrary.simpleMessage( "youShureYouWantToCancelOrder": MessageLookupByLibrary.simpleMessage(
"Вы уверены, что хотите отменить заказ?", "Вы уверены, что хотите отменить заказ?",
), ),

View File

@@ -20,9 +20,57 @@ typedef String MessageIfAbsent(String messageStr, List<dynamic> args);
class MessageLookup extends MessageLookupByLibrary { class MessageLookup extends MessageLookupByLibrary {
String get localeName => 'ru'; 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 m14(userName) =>
"Вы предложили персональную диету пользователю ${userName}";
static String m15(userName, dietName) =>
"Вы предложили персональную диету пользователю ${userName}, выбранная диета: «${dietName}»";
static String m16(duration, dayWord) =>
"Диета рассчитана на ${duration} ${dayWord}. Предложить её можно только для плана с такой же длительностью.";
static String m17(dietDuration, dietDayWord, planDuration, planDayWord) =>
"Не подходит: диета на ${dietDuration} ${dietDayWord}, план на ${planDuration} ${planDayWord}.";
static String m18(name) => "Диета «${name}» предложена";
static String m21(duration, dayWord) => "План на ${duration} ${dayWord}";
static String m22(percent, amount) =>
"Скидка ${percent}% — вы экономите ${amount} ₽"; "Скидка ${percent}% — вы экономите ${amount} ₽";
static String m23(date) => "Старт: ${date}";
final messages = _notInlinedMessages(_notInlinedMessages); final messages = _notInlinedMessages(_notInlinedMessages);
static Map<String, Function> _notInlinedMessages(_) => <String, Function>{ static Map<String, Function> _notInlinedMessages(_) => <String, Function>{
"Moscow": MessageLookupByLibrary.simpleMessage("Москва"), "Moscow": MessageLookupByLibrary.simpleMessage("Москва"),
@@ -66,6 +114,10 @@ class MessageLookup extends MessageLookupByLibrary {
"Если вы любите мучные продукты, но хотите придерживаться диеты, вам подходит диета с добавленим мучных блюд на основе цельнозерновых вариантов. В них больше сложных углеводов и клетчатки, что способствует длительному чувству сытости и поддерживает здоровье пищеварительной системы.", "Если вы любите мучные продукты, но хотите придерживаться диеты, вам подходит диета с добавленим мучных блюд на основе цельнозерновых вариантов. В них больше сложных углеводов и клетчатки, что способствует длительному чувству сытости и поддерживает здоровье пищеварительной системы.",
), ),
"carbohydrates": MessageLookupByLibrary.simpleMessage("Углеводы"), "carbohydrates": MessageLookupByLibrary.simpleMessage("Углеводы"),
"changeDiet": MessageLookupByLibrary.simpleMessage("Заменить диету"),
"change_personal_diet": MessageLookupByLibrary.simpleMessage(
"Заменить диету",
),
"chat": MessageLookupByLibrary.simpleMessage("Чат"), "chat": MessageLookupByLibrary.simpleMessage("Чат"),
"chest": MessageLookupByLibrary.simpleMessage("Грудь"), "chest": MessageLookupByLibrary.simpleMessage("Грудь"),
"chest_circumference": MessageLookupByLibrary.simpleMessage("Обхват груди"), "chest_circumference": MessageLookupByLibrary.simpleMessage("Обхват груди"),
@@ -78,6 +130,10 @@ class MessageLookup extends MessageLookupByLibrary {
"choose_plan_duration": MessageLookupByLibrary.simpleMessage( "choose_plan_duration": MessageLookupByLibrary.simpleMessage(
"Выберите срок плана", "Выберите срок плана",
), ),
"choose_plan_start_date_with_personal_diet":
MessageLookupByLibrary.simpleMessage(
"Выберите дату старта плана питания с учетом составления персональной диеты",
),
"choose_start_diet_date": MessageLookupByLibrary.simpleMessage( "choose_start_diet_date": MessageLookupByLibrary.simpleMessage(
"Выберите дату старты диеты. Она отобразиться у Вас в дневнике", "Выберите дату старты диеты. Она отобразиться у Вас в дневнике",
), ),
@@ -110,6 +166,7 @@ class MessageLookup extends MessageLookupByLibrary {
"date_of_birth": MessageLookupByLibrary.simpleMessage("Дата рождения"), "date_of_birth": MessageLookupByLibrary.simpleMessage("Дата рождения"),
"day": MessageLookupByLibrary.simpleMessage("День"), "day": MessageLookupByLibrary.simpleMessage("День"),
"days": MessageLookupByLibrary.simpleMessage("дней"), "days": MessageLookupByLibrary.simpleMessage("дней"),
"default_user_name": MessageLookupByLibrary.simpleMessage("Пользователь"),
"deleted": MessageLookupByLibrary.simpleMessage("Удалена"), "deleted": MessageLookupByLibrary.simpleMessage("Удалена"),
"diary": MessageLookupByLibrary.simpleMessage("Дневник"), "diary": MessageLookupByLibrary.simpleMessage("Дневник"),
"diet": MessageLookupByLibrary.simpleMessage("Диета"), "diet": MessageLookupByLibrary.simpleMessage("Диета"),
@@ -126,6 +183,7 @@ class MessageLookup extends MessageLookupByLibrary {
"do_you_take_pills": MessageLookupByLibrary.simpleMessage( "do_you_take_pills": MessageLookupByLibrary.simpleMessage(
"Принимаете ли вы лекарства или бады?", "Принимаете ли вы лекарства или бады?",
), ),
"done": MessageLookupByLibrary.simpleMessage("Завершен"),
"down_pressure": MessageLookupByLibrary.simpleMessage( "down_pressure": MessageLookupByLibrary.simpleMessage(
"Нижние показатели давления:", "Нижние показатели давления:",
), ),
@@ -192,6 +250,9 @@ class MessageLookup extends MessageLookupByLibrary {
"from_a_to_z": MessageLookupByLibrary.simpleMessage("Название: А-Я"), "from_a_to_z": MessageLookupByLibrary.simpleMessage("Название: А-Я"),
"from_z_to_a": MessageLookupByLibrary.simpleMessage("Название: Я-А"), "from_z_to_a": MessageLookupByLibrary.simpleMessage("Название: Я-А"),
"gallery": MessageLookupByLibrary.simpleMessage("Галерея"), "gallery": MessageLookupByLibrary.simpleMessage("Галерея"),
"goToMyDiets": MessageLookupByLibrary.simpleMessage(
"Перейти к моим диетам",
),
"gr": MessageLookupByLibrary.simpleMessage("гр"), "gr": MessageLookupByLibrary.simpleMessage("гр"),
"grams": MessageLookupByLibrary.simpleMessage("гр"), "grams": MessageLookupByLibrary.simpleMessage("гр"),
"have_promo_code": MessageLookupByLibrary.simpleMessage( "have_promo_code": MessageLookupByLibrary.simpleMessage(
@@ -212,6 +273,124 @@ class MessageLookup extends MessageLookupByLibrary {
"initial_measurements": MessageLookupByLibrary.simpleMessage( "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("Название"), "label": MessageLookupByLibrary.simpleMessage("Название"),
"language": MessageLookupByLibrary.simpleMessage("Язык"), "language": MessageLookupByLibrary.simpleMessage("Язык"),
"last_name": MessageLookupByLibrary.simpleMessage("Фамилия"), "last_name": MessageLookupByLibrary.simpleMessage("Фамилия"),
@@ -262,6 +441,10 @@ class MessageLookup extends MessageLookupByLibrary {
), ),
"no": MessageLookupByLibrary.simpleMessage("Нет"), "no": MessageLookupByLibrary.simpleMessage("Нет"),
"no_account": MessageLookupByLibrary.simpleMessage("У меня нет аккаунта"), "no_account": MessageLookupByLibrary.simpleMessage("У меня нет аккаунта"),
"no_active_personal_diet_consultations":
MessageLookupByLibrary.simpleMessage(
"Нет активных консультаций с персональной диетой",
),
"no_installed_banks": MessageLookupByLibrary.simpleMessage( "no_installed_banks": MessageLookupByLibrary.simpleMessage(
"Нет установленных банков", "Нет установленных банков",
), ),
@@ -284,6 +467,9 @@ class MessageLookup extends MessageLookupByLibrary {
), ),
"option_title": MessageLookupByLibrary.simpleMessage("Заголовок опции"), "option_title": MessageLookupByLibrary.simpleMessage("Заголовок опции"),
"order": MessageLookupByLibrary.simpleMessage("Порядок"), "order": MessageLookupByLibrary.simpleMessage("Порядок"),
"orderCanceled": MessageLookupByLibrary.simpleMessage("Заказ отменен"),
"order_in_progress": MessageLookupByLibrary.simpleMessage("Заказ в работе"),
"order_paid": MessageLookupByLibrary.simpleMessage("Заказ оплачен"),
"password": MessageLookupByLibrary.simpleMessage("Пароль"), "password": MessageLookupByLibrary.simpleMessage("Пароль"),
"password_confirmation": MessageLookupByLibrary.simpleMessage( "password_confirmation": MessageLookupByLibrary.simpleMessage(
"Подтвердите пароль", "Подтвердите пароль",
@@ -297,8 +483,29 @@ class MessageLookup extends MessageLookupByLibrary {
"password_short": MessageLookupByLibrary.simpleMessage( "password_short": MessageLookupByLibrary.simpleMessage(
"В пароле должно быть минимум 6 символов", "В пароле должно быть минимум 6 символов",
), ),
"pay_by_qr_code": MessageLookupByLibrary.simpleMessage(
"Оплатить по QR-коду",
),
"payment_failed": MessageLookupByLibrary.simpleMessage("Оплата не прошла"),
"pcs": MessageLookupByLibrary.simpleMessage("шт"), "pcs": MessageLookupByLibrary.simpleMessage("шт"),
"personal": MessageLookupByLibrary.simpleMessage("Персональные"), "personal": MessageLookupByLibrary.simpleMessage("Персональные"),
"personal_diet_defined_to_author": m14,
"personal_diet_defined_to_author_named": m15,
"personal_diet_duration_hint": m16,
"personal_diet_duration_mismatch": m17,
"personal_diet_not_proposed": MessageLookupByLibrary.simpleMessage(
"Диета не предложена",
),
"personal_diet_proposed": MessageLookupByLibrary.simpleMessage(
"Диета предложена",
),
"personal_diet_proposed_named": m18,
"personal_diet_replaces_tomorrow": MessageLookupByLibrary.simpleMessage(
"Замена действует со следующего дня",
),
"personal_diet_set": MessageLookupByLibrary.simpleMessage(
"Персональная диета установлена",
),
"personal_diet_state": MessageLookupByLibrary.simpleMessage("Персональная"), "personal_diet_state": MessageLookupByLibrary.simpleMessage("Персональная"),
"photo": MessageLookupByLibrary.simpleMessage("Фото"), "photo": MessageLookupByLibrary.simpleMessage("Фото"),
"place_of_living": MessageLookupByLibrary.simpleMessage("Место проживания"), "place_of_living": MessageLookupByLibrary.simpleMessage("Место проживания"),
@@ -308,6 +515,7 @@ class MessageLookup extends MessageLookupByLibrary {
"Большое подробное, привлекательное описание плана", "Большое подробное, привлекательное описание плана",
), ),
"plan_duration": MessageLookupByLibrary.simpleMessage("Срок плана"), "plan_duration": MessageLookupByLibrary.simpleMessage("Срок плана"),
"plan_duration_label": m21,
"plan_label_desc": MessageLookupByLibrary.simpleMessage( "plan_label_desc": MessageLookupByLibrary.simpleMessage(
"План для поддержания формы", "План для поддержания формы",
), ),
@@ -335,7 +543,7 @@ class MessageLookup extends MessageLookupByLibrary {
"Промокод применён!", "Промокод применён!",
), ),
"promo_code": MessageLookupByLibrary.simpleMessage("Промокод"), "promo_code": MessageLookupByLibrary.simpleMessage("Промокод"),
"promo_discount_hint": m3, "promo_discount_hint": m22,
"promo_invalid": MessageLookupByLibrary.simpleMessage( "promo_invalid": MessageLookupByLibrary.simpleMessage(
"Промокод не найден или истёк", "Промокод не найден или истёк",
), ),
@@ -354,6 +562,34 @@ class MessageLookup extends MessageLookupByLibrary {
"sbp_payment_error": MessageLookupByLibrary.simpleMessage( "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("Оказание услуг"), "services": MessageLookupByLibrary.simpleMessage("Оказание услуг"),
"settings": MessageLookupByLibrary.simpleMessage("Настройки"), "settings": MessageLookupByLibrary.simpleMessage("Настройки"),
"share": MessageLookupByLibrary.simpleMessage("Поделиться"), "share": MessageLookupByLibrary.simpleMessage("Поделиться"),
@@ -364,7 +600,12 @@ class MessageLookup extends MessageLookupByLibrary {
"План питания для похудения должен быть разработан с учётом индивидуальных особенностей организма, возраста, пола, уровня физической активности и состояния здоровья. Общие принципы: \n● Снижение калорийности; \n● Сбалансированный рацион; \n● Разнообразие продуктов; \n● Питьевой режим; \n● Ограничение вредных продуктов;", "План питания для похудения должен быть разработан с учётом индивидуальных особенностей организма, возраста, пола, уровня физической активности и состояния здоровья. Общие принципы: \n● Снижение калорийности; \n● Сбалансированный рацион; \n● Разнообразие продуктов; \n● Питьевой режим; \n● Ограничение вредных продуктов;",
), ),
"standard": MessageLookupByLibrary.simpleMessage("Стандарт"), "standard": MessageLookupByLibrary.simpleMessage("Стандарт"),
"startDateHasBeenSet": MessageLookupByLibrary.simpleMessage(
"Выбрана дата старта",
),
"startDiet": MessageLookupByLibrary.simpleMessage("Начать диету"), "startDiet": MessageLookupByLibrary.simpleMessage("Начать диету"),
"start_date_label": m23,
"suggestDiet": MessageLookupByLibrary.simpleMessage("Предложить диету"),
"tell_your_schedule": MessageLookupByLibrary.simpleMessage( "tell_your_schedule": 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 { String get more_about_order {
return Intl.message( return Intl.message(
@@ -4443,6 +4553,804 @@ class S {
args: [], 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: [],
);
}
/// `Order in progress`
String get order_in_progress {
return Intl.message(
'Order in progress',
name: 'order_in_progress',
desc: '',
args: [],
);
}
/// `Personal diet proposed`
String get personal_diet_proposed {
return Intl.message(
'Personal diet proposed',
name: 'personal_diet_proposed',
desc: '',
args: [],
);
}
/// `Diet «{name}» proposed`
String personal_diet_proposed_named(String name) {
return Intl.message(
'Diet «$name» proposed',
name: 'personal_diet_proposed_named',
desc: '',
args: [name],
);
}
/// `User`
String get default_user_name {
return Intl.message('User', name: 'default_user_name', desc: '', args: []);
}
/// `You have proposed a personal diet to {userName}`
String personal_diet_defined_to_author(String userName) {
return Intl.message(
'You have proposed a personal diet to $userName',
name: 'personal_diet_defined_to_author',
desc: '',
args: [userName],
);
}
/// `You have proposed a personal diet to {userName}, selected diet: «{dietName}»`
String personal_diet_defined_to_author_named(
String userName,
String dietName,
) {
return Intl.message(
'You have proposed a personal diet to $userName, selected diet: «$dietName»',
name: 'personal_diet_defined_to_author_named',
desc: '',
args: [userName, dietName],
);
}
/// `Personal diet not proposed`
String get personal_diet_not_proposed {
return Intl.message(
'Personal diet not proposed',
name: 'personal_diet_not_proposed',
desc: '',
args: [],
);
}
/// `Change personal diet`
String get change_personal_diet {
return Intl.message(
'Change personal diet',
name: 'change_personal_diet',
desc: '',
args: [],
);
}
/// `The change takes effect tomorrow`
String get personal_diet_replaces_tomorrow {
return Intl.message(
'The change takes effect tomorrow',
name: 'personal_diet_replaces_tomorrow',
desc: '',
args: [],
);
}
/// `Start: {date}`
String start_date_label(String date) {
return Intl.message(
'Start: $date',
name: 'start_date_label',
desc: '',
args: [date],
);
}
/// `The diet lasts {duration} {dayWord}. It can only be offered for a plan with the same duration.`
String personal_diet_duration_hint(int duration, String dayWord) {
return Intl.message(
'The diet lasts $duration $dayWord. It can only be offered for a plan with the same duration.',
name: 'personal_diet_duration_hint',
desc: '',
args: [duration, dayWord],
);
}
/// `Not suitable: diet lasts {dietDuration} {dietDayWord}, plan lasts {planDuration} {planDayWord}.`
String personal_diet_duration_mismatch(
int dietDuration,
String dietDayWord,
int planDuration,
String planDayWord,
) {
return Intl.message(
'Not suitable: diet lasts $dietDuration $dietDayWord, plan lasts $planDuration $planDayWord.',
name: 'personal_diet_duration_mismatch',
desc: '',
args: [dietDuration, dietDayWord, planDuration, planDayWord],
);
}
/// `Plan for {duration} {dayWord}`
String plan_duration_label(int duration, String dayWord) {
return Intl.message(
'Plan for $duration $dayWord',
name: 'plan_duration_label',
desc: '',
args: [duration, dayWord],
);
}
/// `No active consultations with a personal diet`
String get no_active_personal_diet_consultations {
return Intl.message(
'No active consultations with a personal diet',
name: 'no_active_personal_diet_consultations',
desc: '',
args: [],
);
}
} }
class AppLocalizationDelegate extends LocalizationsDelegate<S> { class AppLocalizationDelegate extends LocalizationsDelegate<S> {

View File

@@ -330,15 +330,26 @@
"choose_bank_for_payment": "Выберите банк", "choose_bank_for_payment": "Выберите банк",
"no_installed_banks": "Нет установленных банков", "no_installed_banks": "Нет установленных банков",
"sbp_payment_error": "Не удалось начать оплату. Попробуйте позже", "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": "Подробнее о заказе", "more_about_order": "Подробнее о заказе",
"cancel_order": "Отменить заказ", "cancel_order": "Отменить заказ",
"refuse_order":"Отказаться от заказа", "refuse_order": "Отказаться от заказа",
"user_info": "Анкета пользователя", "user_info": "Анкета пользователя",
"pending_payment": "Ожидается оплата", "pending_payment": "Ожидается оплата",
"my_cards": "Мои карты", "my_cards": "Мои карты",
"new_card": "Добавить карту", "new_card": "Добавить карту",
"no_default_cards": "У вас нет привязанных карт. Что бы начать работу, привяжите карту", "no_default_cards": "У вас нет привязанных карт. Что бы начать работу, привяжите карту",
"chose_plan_start_date_2":"Выберите дату старты плана. Она отобразиться в дневнике пользователя", "chose_plan_start_date_2": "Выберите дату старты плана. Она отобразиться в дневнике пользователя",
"plan_seller": "Продавец плана", "plan_seller": "Продавец плана",
"payment_failed": "Оплата не прошла", "payment_failed": "Оплата не прошла",
"order_paid": "Заказ оплачен", "order_paid": "Заказ оплачен",
@@ -409,7 +420,7 @@
"allowAcessToGaleryDescription": "Предоставление доступа позволит вам добавлять фото к записям", "allowAcessToGaleryDescription": "Предоставление доступа позволит вам добавлять фото к записям",
"allowAccessToCamera": "Разрешите доступ к камере", "allowAccessToCamera": "Разрешите доступ к камере",
"allowAccessToCameraDescription": "Предоставление доступа позволит вам делать фотографии и добавлять к записям", "allowAccessToCameraDescription": "Предоставление доступа позволит вам делать фотографии и добавлять к записям",
"allowAccess": "Разрешить доступ" , "allowAccess": "Разрешить доступ",
"copy": "Скопировать", "copy": "Скопировать",
"reply": "Ответить", "reply": "Ответить",
"deleteComment": "Удалить комментарий", "deleteComment": "Удалить комментарий",
@@ -509,8 +520,12 @@
"promo_discount_hint": "Discount {percent}% — you save {amount} ₽", "promo_discount_hint": "Discount {percent}% — you save {amount} ₽",
"@promo_discount_hint": { "@promo_discount_hint": {
"placeholders": { "placeholders": {
"percent": {"type": "int"}, "percent": {
"amount": {"type": "String"} "type": "int"
},
"amount": {
"type": "String"
}
} }
}, },
"promo_remove_failed": "Could not remove the promo code. Try again later", "promo_remove_failed": "Could not remove the promo code. Try again later",
@@ -526,6 +541,279 @@
"video_call_close": "Close", "video_call_close": "Close",
"video_call_notification_in_progress": "Call in progress", "video_call_notification_in_progress": "Call in progress",
"video_call_notification_return": "Return to call", "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",
"order_in_progress": "Order in progress",
"personal_diet_proposed": "Personal diet proposed",
"personal_diet_proposed_named": "Diet «{name}» proposed",
"@personal_diet_proposed_named": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"default_user_name": "User",
"personal_diet_defined_to_author": "You have proposed a personal diet to {userName}",
"@personal_diet_defined_to_author": {
"placeholders": {
"userName": {
"type": "String"
}
}
},
"personal_diet_defined_to_author_named": "You have proposed a personal diet to {userName}, selected diet: «{dietName}»",
"@personal_diet_defined_to_author_named": {
"placeholders": {
"userName": {
"type": "String"
},
"dietName": {
"type": "String"
}
}
},
"personal_diet_not_proposed": "Personal diet not proposed",
"change_personal_diet": "Change personal diet",
"personal_diet_replaces_tomorrow": "The change takes effect tomorrow",
"start_date_label": "Start: {date}",
"@start_date_label": {
"placeholders": {
"date": {
"type": "String"
}
}
},
"personal_diet_duration_hint": "The diet lasts {duration} {dayWord}. It can only be offered for a plan with the same duration.",
"@personal_diet_duration_hint": {
"placeholders": {
"duration": {
"type": "int"
},
"dayWord": {
"type": "String"
}
}
},
"personal_diet_duration_mismatch": "Not suitable: diet lasts {dietDuration} {dietDayWord}, plan lasts {planDuration} {planDayWord}.",
"@personal_diet_duration_mismatch": {
"placeholders": {
"dietDuration": {
"type": "int"
},
"dietDayWord": {
"type": "String"
},
"planDuration": {
"type": "int"
},
"planDayWord": {
"type": "String"
}
}
},
"plan_duration_label": "Plan for {duration} {dayWord}",
"@plan_duration_label": {
"placeholders": {
"duration": {
"type": "int"
},
"dayWord": {
"type": "String"
}
}
},
"no_active_personal_diet_consultations": "No active consultations with a personal diet"
} }

View File

@@ -239,6 +239,17 @@
"choose_bank_for_payment": "Выберите банк", "choose_bank_for_payment": "Выберите банк",
"no_installed_banks": "Нет установленных банков", "no_installed_banks": "Нет установленных банков",
"sbp_payment_error": "Не удалось начать оплату. Попробуйте позже", "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": "Да", "yes": "Да",
"apply": "Применить", "apply": "Применить",
"have_promo_code": "У вас есть промокод?", "have_promo_code": "У вас есть промокод?",
@@ -252,8 +263,12 @@
"promo_discount_hint": "Скидка {percent}% — вы экономите {amount} ₽", "promo_discount_hint": "Скидка {percent}% — вы экономите {amount} ₽",
"@promo_discount_hint": { "@promo_discount_hint": {
"placeholders": { "placeholders": {
"percent": {"type": "int"}, "percent": {
"amount": {"type": "String"} "type": "int"
},
"amount": {
"type": "String"
}
} }
}, },
"promo_remove_failed": "Не удалось убрать промокод. Попробуйте позже", "promo_remove_failed": "Не удалось убрать промокод. Попробуйте позже",
@@ -271,5 +286,289 @@
"video_call_notification_in_progress": "Звонок идёт", "video_call_notification_in_progress": "Звонок идёт",
"video_call_notification_return": "Вернуться к звонку", "video_call_notification_return": "Вернуться к звонку",
"video_call_notification_end": "Завершить звонок", "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": "Не удалось сохранить запись",
"startDateHasBeenSet": "Выбрана дата старта",
"order_paid": "Заказ оплачен",
"done": "Завершен",
"orderCanceled": "Заказ отменен",
"payment_failed": "Оплата не прошла",
"personal_diet_set": "Персональная диета установлена",
"suggestDiet": "Предложить диету",
"changeDiet": "Заменить диету",
"goToMyDiets": "Перейти к моим диетам",
"choose_plan_start_date_with_personal_diet": "Выберите дату старта плана питания с учетом составления персональной диеты",
"order_in_progress": "Заказ в работе",
"personal_diet_proposed": "Диета предложена",
"personal_diet_proposed_named": "Диета «{name}» предложена",
"@personal_diet_proposed_named": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"default_user_name": "Пользователь",
"personal_diet_defined_to_author": "Вы предложили персональную диету пользователю {userName}",
"@personal_diet_defined_to_author": {
"placeholders": {
"userName": {
"type": "String"
}
}
},
"personal_diet_defined_to_author_named": "Вы предложили персональную диету пользователю {userName}, выбранная диета: «{dietName}»",
"@personal_diet_defined_to_author_named": {
"placeholders": {
"userName": {
"type": "String"
},
"dietName": {
"type": "String"
}
}
},
"personal_diet_not_proposed": "Диета не предложена",
"change_personal_diet": "Заменить диету",
"personal_diet_replaces_tomorrow": "Замена действует со следующего дня",
"start_date_label": "Старт: {date}",
"@start_date_label": {
"placeholders": {
"date": {
"type": "String"
}
}
},
"personal_diet_duration_hint": "Диета рассчитана на {duration} {dayWord}. Предложить её можно только для плана с такой же длительностью.",
"@personal_diet_duration_hint": {
"placeholders": {
"duration": {
"type": "int"
},
"dayWord": {
"type": "String"
}
}
},
"personal_diet_duration_mismatch": "Не подходит: диета на {dietDuration} {dietDayWord}, план на {planDuration} {planDayWord}.",
"@personal_diet_duration_mismatch": {
"placeholders": {
"dietDuration": {
"type": "int"
},
"dietDayWord": {
"type": "String"
},
"planDuration": {
"type": "int"
},
"planDayWord": {
"type": "String"
}
}
},
"plan_duration_label": "План на {duration} {dayWord}",
"@plan_duration_label": {
"placeholders": {
"duration": {
"type": "int"
},
"dayWord": {
"type": "String"
}
}
},
"no_active_personal_diet_consultations": "Нет активных консультаций с персональной диетой"
} }

View File

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

View File

@@ -78,6 +78,66 @@ void main() {
meta: const ChatMetaModel(lastPage: 1), meta: const ChatMetaModel(lastPage: 1),
); );
const planForChosenPlanUpdates = PlanModel(
id: 1,
slug: 'test-plan',
name: 'Test Plan',
image: 'https://example.com/image.jpg',
duration: 7,
state: 'active',
type: PlanType(
fit: true,
slim: false,
behavior: false,
wellness: false,
),
details: 'Test details',
packetsCount: 3,
author: Author(
authorSlug: 'author',
authorName: 'Author Name',
authorProfileIcon: 'https://example.com/icon.jpg',
authorIsMe: false,
),
totalLikes: 10,
totalViews: 100,
totalComments: 5,
isFavorite: false,
minPriceOfOptions: 1000,
maxPriceOfOptions: 2000,
wasLikedByMe: false,
);
ChosenPlanModel buildChosenPlan(
int id,
ChosenPlanStateEnum chosenPlanState,
) {
return ChosenPlanModel(
id: id,
optionType: 'options1',
startDate: DateTime.now(),
chosenPlanState: chosenPlanState,
chatId: 'chat-id',
consultantId: 1,
plan: planForChosenPlanUpdates,
);
}
void stubChatChannel({
required Stream<ChannelReadEvent> chosenPlanStream,
}) {
when(mockWsManager.client).thenReturn(MockPusherChannelsClient());
when(
mockWsManager.directChatStream,
).thenAnswer((_) => const Stream<ChannelReadEvent>.empty());
when(
mockWsManager.clientTypingStream,
).thenAnswer((_) => const Stream<ChannelReadEvent>.empty());
when(
mockWsManager.chosenPlanStateChangedStream,
).thenAnswer((_) => chosenPlanStream);
}
setUp(() { setUp(() {
mockChatRepository = MockChatRepository(); mockChatRepository = MockChatRepository();
mockCardsRepository = MockCardsRepository(); mockCardsRepository = MockCardsRepository();
@@ -721,6 +781,160 @@ void main() {
}, },
); );
test(
'chosenPlanStateChangedStream - перезапрашивает план и обновляет state',
() async {
const uuid = 'room-uuid';
const planId = 321;
final chosenPlan = buildChosenPlan(
planId,
ChosenPlanStateEnum.stateStarted,
);
final updatedPlan = chosenPlan.copyWith(
chosenPlanState: ChosenPlanStateEnum.statePaid,
);
final controller = StreamController<ChannelReadEvent>.broadcast();
when(
mockChatRepository.getChatRoom(uuid),
).thenAnswer((_) async => testChatRoom.copyWith(chosenPlanId: planId));
when(
mockChatRepository.getChatMessages(uuid, 1),
).thenAnswer((_) async => testChatMessages);
when(
mockChatRepository.getChosenPlan(id: planId),
).thenAnswer((_) async => chosenPlan);
stubChatChannel(chosenPlanStream: controller.stream);
await chatRoomCubit.initChat(uuid);
expect(chatRoomCubit.state, isA<ChatRoomStateLoaded>());
when(
mockChatRepository.getChosenPlan(id: planId),
).thenAnswer((_) async => updatedPlan);
clearInteractions(mockChatRepository);
controller.add(_FakeChannelReadEvent());
await Future<void>.delayed(const Duration(milliseconds: 350));
verify(mockChatRepository.getChosenPlan(id: planId)).called(1);
final state = chatRoomCubit.state as ChatRoomStateLoaded;
expect(
state.chatRoom.chosenPlan?.chosenPlanState,
ChosenPlanStateEnum.statePaid,
);
await controller.close();
},
);
test(
'chosenPlanStateChangedStream - использует chosenPlan.id если chosenPlanId null',
() async {
const orderId = 'alfa_c29_k15138_t1712345678_r1234';
const conversationUuid = 'fbd81429-8019-499a-80e8-e3933cc4d872';
const planId = 654;
final chosenPlan = buildChosenPlan(
planId,
ChosenPlanStateEnum.stateStarted,
);
final updatedPlan = chosenPlan.copyWith(
chosenPlanState: ChosenPlanStateEnum.statePaid,
);
final controller = StreamController<ChannelReadEvent>.broadcast();
when(
mockChatRepository.resolveOrderIdToConversation(orderId: orderId),
).thenAnswer(
(_) async => OrderIdToConversationModel(
chosenPlanId: planId,
conversationUuid: conversationUuid,
),
);
when(
mockChatRepository.getChatRoom(conversationUuid),
).thenAnswer((_) async => testChatRoom);
when(
mockChatRepository.getChatMessages(conversationUuid, 1),
).thenAnswer((_) async => testChatMessages);
when(
mockChatRepository.getChosenPlan(id: planId),
).thenAnswer((_) async => chosenPlan);
stubChatChannel(chosenPlanStream: controller.stream);
await chatRoomCubit.initChat(null, orderId: orderId);
final loaded = chatRoomCubit.state as ChatRoomStateLoaded;
expect(loaded.chatRoom.chosenPlanId, isNull);
expect(loaded.chatRoom.chosenPlan?.id, planId);
when(
mockChatRepository.getChosenPlan(id: planId),
).thenAnswer((_) async => updatedPlan);
clearInteractions(mockChatRepository);
controller.add(_FakeChannelReadEvent());
await Future<void>.delayed(const Duration(milliseconds: 350));
verify(mockChatRepository.getChosenPlan(id: planId)).called(1);
final state = chatRoomCubit.state as ChatRoomStateLoaded;
expect(
state.chatRoom.chosenPlan?.chosenPlanState,
ChosenPlanStateEnum.statePaid,
);
await controller.close();
},
);
test(
'chosenPlanStateChangedStream - дебаунсит повторные события',
() async {
const uuid = 'room-uuid';
const planId = 987;
final chosenPlan = buildChosenPlan(
planId,
ChosenPlanStateEnum.stateStarted,
);
final updatedPlan = chosenPlan.copyWith(
chosenPlanState: ChosenPlanStateEnum.statePaid,
);
final controller = StreamController<ChannelReadEvent>.broadcast();
when(
mockChatRepository.getChatRoom(uuid),
).thenAnswer((_) async => testChatRoom.copyWith(chosenPlanId: planId));
when(
mockChatRepository.getChatMessages(uuid, 1),
).thenAnswer((_) async => testChatMessages);
when(
mockChatRepository.getChosenPlan(id: planId),
).thenAnswer((_) async => chosenPlan);
stubChatChannel(chosenPlanStream: controller.stream);
await chatRoomCubit.initChat(uuid);
expect(chatRoomCubit.state, isA<ChatRoomStateLoaded>());
when(
mockChatRepository.getChosenPlan(id: planId),
).thenAnswer((_) async => updatedPlan);
clearInteractions(mockChatRepository);
controller
..add(_FakeChannelReadEvent())
..add(_FakeChannelReadEvent());
await Future<void>.delayed(const Duration(milliseconds: 350));
verify(mockChatRepository.getChosenPlan(id: planId)).called(1);
final state = chatRoomCubit.state as ChatRoomStateLoaded;
expect(
state.chatRoom.chosenPlan?.chosenPlanState,
ChosenPlanStateEnum.statePaid,
);
await controller.close();
},
);
test('choseStartPlanDate - устанавливает дату начала плана', () async { test('choseStartPlanDate - устанавливает дату начала плана', () async {
const uuid = 'room-uuid'; const uuid = 'room-uuid';
const planId = 123; const planId = 123;
@@ -2578,3 +2792,8 @@ void main() {
); );
}); });
} }
class _FakeChannelReadEvent extends Fake implements ChannelReadEvent {
@override
dynamic get data => null;
}

View File

@@ -0,0 +1,232 @@
import 'package:eatvio_app/features/chat/presentation/utils/chat_banner_resolver.dart';
import 'package:eatvio_app/features/chat/presentation/widgets/notification_banner.dart';
import 'package:eatvio_app/features/consultations/models/consultation_progress.dart';
import 'package:eatvio_app/features/consultations/utils/consultation_progress_mapper.dart';
import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart';
import 'package:flutter_test/flutter_test.dart';
ConsultationProgress _progress(
ChosenPlanStateEnum state, {
bool hasPersonalDiet = false,
}) {
return mapConsultationProgress(
state: state,
hasPersonalDiet: hasPersonalDiet,
);
}
void main() {
group('resolveChatBanner — карта консультанта', () {
test('in_coordination без карты просит добавить карту', () {
final banner = resolveChatBanner(
progress: _progress(ChosenPlanStateEnum.stateInCoordination),
amIConsultant: true,
hasCard: false,
state: ChosenPlanStateEnum.stateInCoordination,
);
expect(banner, BannerType.card);
});
test('in_coordination с картой просит выбрать дату', () {
final banner = resolveChatBanner(
progress: _progress(ChosenPlanStateEnum.stateInCoordination),
amIConsultant: true,
hasCard: true,
state: ChosenPlanStateEnum.stateInCoordination,
);
expect(banner, BannerType.date);
});
test('paid + notProposed + диета просит предложить диету', () {
final banner = resolveChatBanner(
progress: _progress(
ChosenPlanStateEnum.statePaid,
hasPersonalDiet: true,
),
amIConsultant: true,
hasCard: true,
state: ChosenPlanStateEnum.statePaid,
);
expect(banner, BannerType.setPersonalDiet);
});
test(
'personal_diet_setted/started/in_progress + proposed просят сменить диету',
() {
const states = [
ChosenPlanStateEnum.personalDietSet,
ChosenPlanStateEnum.stateStarted,
ChosenPlanStateEnum.stateInProgress,
];
for (final state in states) {
final banner = resolveChatBanner(
progress: _progress(state, hasPersonalDiet: true),
amIConsultant: true,
hasCard: true,
state: state,
);
expect(
banner,
BannerType.changePersonalDiet,
reason: 'state=${state.name}',
);
}
},
);
test('last_day_finished не просит менять диету (нет баннера)', () {
final banner = resolveChatBanner(
progress: _progress(
ChosenPlanStateEnum.stateLastDayFinished,
hasPersonalDiet: true,
),
amIConsultant: true,
hasCard: true,
state: ChosenPlanStateEnum.stateLastDayFinished,
);
expect(banner, isNot(BannerType.changePersonalDiet));
expect(banner, isNull);
});
test('done не просит менять диету (collectCharge)', () {
final banner = resolveChatBanner(
progress: _progress(
ChosenPlanStateEnum.stateDone,
hasPersonalDiet: true,
),
amIConsultant: true,
hasCard: true,
state: ChosenPlanStateEnum.stateDone,
);
expect(banner, isNot(BannerType.changePersonalDiet));
expect(banner, BannerType.collectCharge);
});
});
group('resolveChatBanner — карта клиента', () {
test('start_date_has_been_set просит оплатить', () {
final banner = resolveChatBanner(
progress: _progress(ChosenPlanStateEnum.stateStartDateHasBeenSet),
amIConsultant: false,
hasCard: true,
state: ChosenPlanStateEnum.stateStartDateHasBeenSet,
);
expect(banner, BannerType.payment);
});
test('pending_payment просит оплатить', () {
final banner = resolveChatBanner(
progress: _progress(ChosenPlanStateEnum.statePendingPayment),
amIConsultant: false,
hasCard: true,
state: ChosenPlanStateEnum.statePendingPayment,
);
expect(banner, BannerType.payment);
});
test('paid + notProposed не показывает diet-баннер клиенту', () {
final banner = resolveChatBanner(
progress: _progress(
ChosenPlanStateEnum.statePaid,
hasPersonalDiet: true,
),
amIConsultant: false,
hasCard: true,
state: ChosenPlanStateEnum.statePaid,
);
expect(banner, isNull);
});
test(
'personal_diet_setted + proposed не показывает diet-баннер клиенту',
() {
final banner = resolveChatBanner(
progress: _progress(
ChosenPlanStateEnum.personalDietSet,
hasPersonalDiet: true,
),
amIConsultant: false,
hasCard: true,
state: ChosenPlanStateEnum.personalDietSet,
);
expect(banner, isNull);
},
);
});
group('resolveChatBanner — терминальные фазы', () {
test('canceled не показывает баннер', () {
expect(
resolveChatBanner(
progress: _progress(ChosenPlanStateEnum.stateCanceledByTheInitiator),
amIConsultant: true,
hasCard: true,
state: ChosenPlanStateEnum.stateCanceledByTheInitiator,
),
isNull,
);
expect(
resolveChatBanner(
progress: _progress(ChosenPlanStateEnum.stateCanceledByTheInitiator),
amIConsultant: false,
hasCard: true,
state: ChosenPlanStateEnum.stateCanceledByTheInitiator,
),
isNull,
);
});
test('payment_failed не показывает баннер', () {
expect(
resolveChatBanner(
progress: _progress(ChosenPlanStateEnum.statePaymentFailed),
amIConsultant: true,
hasCard: true,
state: ChosenPlanStateEnum.statePaymentFailed,
),
isNull,
);
expect(
resolveChatBanner(
progress: _progress(ChosenPlanStateEnum.statePaymentFailed),
amIConsultant: false,
hasCard: true,
state: ChosenPlanStateEnum.statePaymentFailed,
),
isNull,
);
});
test('нет progress или state — нет баннера', () {
expect(
resolveChatBanner(
progress: null,
amIConsultant: true,
hasCard: true,
state: ChosenPlanStateEnum.statePaid,
),
isNull,
);
expect(
resolveChatBanner(
progress: _progress(ChosenPlanStateEnum.statePaid),
amIConsultant: true,
hasCard: true,
state: null,
),
isNull,
);
});
});
}

View File

@@ -0,0 +1,86 @@
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/chat/presentation/widgets/chat_consultation_header.dart';
import 'package:eatvio_app/features/consultations/presentation/widgets/consultation_progress_plank.dart';
import 'package:eatvio_app/features/consultations/models/consultation_progress.dart';
import 'package:eatvio_app/features/consultations/presentation/widgets/personal_diet_status_chip.dart';
import 'package:eatvio_app/features/consultations/utils/consultation_progress_mapper.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_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
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: Center(child: child)),
);
}
void main() {
group('ChatConsultationHeader', () {
testWidgets('вызывает onInfoTap по тапу на info', (tester) async {
var tapped = 0;
final progress = mapConsultationProgress(
state: ChosenPlanStateEnum.stateInited,
hasPersonalDiet: false,
);
await tester.pumpWidget(
_wrap(
ChatConsultationHeader(
progress: progress,
showInfoButton: true,
onInfoTap: () => tapped++,
),
),
);
await tester.tap(find.byIcon(Icons.info_outline_rounded));
await tester.pump();
expect(tapped, 1);
});
testWidgets(
'personal_diet_setted показывает плашку и чип вместе с баннером замены',
(tester) async {
final progress = mapConsultationProgress(
state: ChosenPlanStateEnum.personalDietSet,
hasPersonalDiet: true,
personalDietName: 'Индивидуальный план питания',
);
expect(progress.showProgressPlank, isTrue);
expect(progress.dietStatus, PersonalDietStatus.proposed);
await tester.pumpWidget(
_wrap(
ChatConsultationHeader(
progress: progress,
showInfoButton: false,
onInfoTap: () {},
),
),
);
final s = S.of(tester.element(find.byType(ChatConsultationHeader)));
expect(find.text(s.order_in_progress), findsNWidgets(2));
expect(find.byType(PersonalDietStatusChip), findsOneWidget);
expect(
find.text(s.personal_diet_proposed_named('Индивидуальный план питания')),
findsOneWidget,
);
expect(find.byType(ConsultationProgressPlank), findsOneWidget);
expect(find.byIcon(Icons.check), findsNWidgets(2));
},
);
});
}

View File

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

View File

@@ -16,5 +16,29 @@ void main() {
expect(find.byType(BecomeConsultantScreen), findsOneWidget); expect(find.byType(BecomeConsultantScreen), findsOneWidget);
expect(find.text('Стать консультантом'), 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,57 @@ void main() {
}); });
group('ConsultationModel', () { 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,
'personal_diet_name': 'Индивидуальный план питания',
};
final model = ConsultationModel.fromJson(json);
expect(model.chatId, 'direct-chat-uuid');
expect(model.personalDietName, 'Индивидуальный план питания');
});
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', () { test('toJson serializes ConsultationModel correctly', () {
final model = ConsultationModel( final model = ConsultationModel(
id: 1, id: 1,

View File

@@ -1,4 +1,5 @@
import 'package:eatvio_app/core/enums/base_state.dart'; 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/models/consultation_model.dart';
import 'package:eatvio_app/features/consultations/presentation/cubit/consultations_cubit.dart'; import 'package:eatvio_app/features/consultations/presentation/cubit/consultations_cubit.dart';
import 'package:eatvio_app/features/consultations/presentation/cubit/consultations_state.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 MockConsultationsRepository extends Mock implements ConsultationsRepository {}
class MockChatRepository extends Mock implements ChatRepository {}
void main() { void main() {
group('ConsultationsCubit', () { group('ConsultationsCubit', () {
late ConsultationsCubit cubit; late ConsultationsCubit cubit;
late MockConsultationsRepository mockRepository; late MockConsultationsRepository mockRepository;
late MockChatRepository mockChatRepository;
setUp(() { setUp(() {
mockRepository = MockConsultationsRepository(); mockRepository = MockConsultationsRepository();
cubit = ConsultationsCubit(consultationsRepository: mockRepository); mockChatRepository = MockChatRepository();
cubit = ConsultationsCubit(
consultationsRepository: mockRepository,
chatRepository: mockChatRepository,
);
}); });
tearDown(() { tearDown(() {
@@ -47,5 +55,33 @@ void main() {
expect(cubit.state.state, BaseState.error); 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_config.dart';
import 'package:eatvio_app/core/app_initializer.dart'; import 'package:eatvio_app/core/app_initializer.dart';
import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/theme_helper.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/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/consultations/presentation/widgets/consultation_card_widget.dart';
import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart'; import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart';
import 'package:eatvio_app/generated/l10n.dart'; import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.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_dotenv/flutter_dotenv.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:mocktail/mocktail.dart';
import 'package:network_image_mock/network_image_mock.dart'; import 'package:network_image_mock/network_image_mock.dart';
class MockConsultationsCubit extends Mock implements ConsultationsCubit {}
void main() { void main() {
group('ConsultationCardWidget smoke', () { group('ConsultationCardWidget smoke', () {
setUpAll(() async { setUpAll(() async {
@@ -28,6 +36,20 @@ void main() {
AppConfig(buildType: AppBuildType.tests) AppConfig(buildType: AppBuildType.tests)
..setBaseUrl('http://localhost'), ..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( final mockConsultation = ConsultationModel(
@@ -64,7 +86,8 @@ void main() {
personalDietId: null, personalDietId: null,
); );
Widget wrap(Widget child) { Widget wrap(Widget child, {ConsultationsCubit? cubit}) {
final consultationsCubit = cubit ?? MockConsultationsCubit();
return MaterialApp.router( return MaterialApp.router(
theme: ThemeHelper().themeData(), theme: ThemeHelper().themeData(),
localizationsDelegates: const [ localizationsDelegates: const [
@@ -77,7 +100,20 @@ void main() {
routerConfig: GoRouter( routerConfig: GoRouter(
initialLocation: '/', initialLocation: '/',
routes: [ 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( GoRoute(
path: '/profile-screen', path: '/profile-screen',
name: 'profileScreen', name: 'profileScreen',
@@ -117,6 +153,29 @@ void main() {
}); });
}); });
testWidgets('shows personal diet name in status chip', (tester) async {
final consultation = mockConsultation.copyWith(
personalDietId: 1,
personalDietName: 'Индивидуальный план питания',
);
await mockNetworkImagesFor(() async {
await tester.pumpWidget(
wrap(ConsultationCardWidget(consultationsModel: consultation)),
);
await tester.pumpAndSettle();
final s = S.of(tester.element(find.byType(ConsultationCardWidget)));
expect(
find.text(
s.personal_diet_proposed_named('Индивидуальный план питания'),
),
findsOneWidget,
);
});
});
testWidgets('renders diary days', (tester) async { testWidgets('renders diary days', (tester) async {
await mockNetworkImagesFor(() async { await mockNetworkImagesFor(() async {
await tester.pumpWidget( await tester.pumpWidget(
@@ -163,5 +222,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);
});
});
}); });
} }

Some files were not shown because too many files have changed in this diff Show More