forked from eatvio/eatvio_app
Compare commits
37 Commits
android-ci
...
issue-1123
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db3a32c12e | ||
|
|
5228d43f15 | ||
| 0511bd2cb5 | |||
| 28ae75474a | |||
|
|
c7198c5a48 | ||
|
|
8d4cabf5c6 | ||
|
|
8c33f033ff | ||
|
|
45d072cff4 | ||
| da0ac9d396 | |||
| 22b8ef97b4 | |||
| 6c4572af55 | |||
| e1a1ce4b68 | |||
| 67920b2016 | |||
| ad70d0988e | |||
| 0241265b3c | |||
|
|
fc28062bb5 | ||
| f947182632 | |||
|
|
27b8128a19 | ||
| 924dc2c16a | |||
| 57536aba06 | |||
| 16084ee94c | |||
| bd1155233f | |||
| 53ce73b41e | |||
|
|
1829ad611d | ||
| 2b2991012d | |||
| 7a456c2d9d | |||
| 36079de81c | |||
| 3825a974f9 | |||
| 678d73932c | |||
| 8942e11be9 | |||
| 2668907f60 | |||
| 56ab9b65f9 | |||
| 85bc2c6d5c | |||
| f58892bdeb | |||
| f14f426381 | |||
| 6a31880a85 | |||
|
|
f6f9764811 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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()),
|
||||||
|
|||||||
@@ -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()));
|
||||||
|
|||||||
@@ -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()));
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
33
lib/features/consultations/models/consultation_progress.dart
Normal file
33
lib/features/consultations/models/consultation_progress.dart
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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._();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
222
lib/features/insights/domain/insights_text_builder.dart
Normal file
222
lib/features/insights/domain/insights_text_builder.dart
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/models/insights_period.dart';
|
||||||
|
import 'package:eatvio_app/features/profile/models/circumference_model.dart';
|
||||||
|
import 'package:eatvio_app/features/profile/models/weight_model.dart';
|
||||||
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
|
|
||||||
|
/// Собирает [InsightsPayloadModel] из сырых точек мобильного API.
|
||||||
|
///
|
||||||
|
/// Зеркало веб-`UserInsightsService`: порог изменения [deltaEpsilon], минимум две
|
||||||
|
/// точки для дельты, те же категории ИМТ и формат подписей.
|
||||||
|
class InsightsTextBuilder {
|
||||||
|
InsightsTextBuilder(this.s);
|
||||||
|
|
||||||
|
final S s;
|
||||||
|
|
||||||
|
static const double deltaEpsilon = 0.05;
|
||||||
|
static const String _emptyHeadline = '—';
|
||||||
|
|
||||||
|
InsightsPayloadModel build({
|
||||||
|
required List<WeightModel> weights,
|
||||||
|
required List<CircumferenceModel> circumferences,
|
||||||
|
required BodyPeriod period,
|
||||||
|
int? heightCm,
|
||||||
|
}) {
|
||||||
|
final sortedWeights = [...weights]
|
||||||
|
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
||||||
|
final sortedCircumferences = [...circumferences]
|
||||||
|
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
||||||
|
|
||||||
|
return InsightsPayloadModel(
|
||||||
|
weight: buildWeight(sortedWeights, period),
|
||||||
|
bmi: buildBmi(sortedWeights, period, heightCm),
|
||||||
|
measure: buildMeasure(sortedCircumferences, period),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
WeightChartModel buildWeight(List<WeightModel> weights, BodyPeriod period) {
|
||||||
|
final values = weights.map((e) => e.weight).toList();
|
||||||
|
final last = values.isEmpty ? null : values.last;
|
||||||
|
final delta = values.length < 2 ? null : _round(values.last - values.first);
|
||||||
|
|
||||||
|
return WeightChartModel(
|
||||||
|
labels: weights.map((e) => formatLabel(e.createdAt)).toList(),
|
||||||
|
values: values,
|
||||||
|
last: last,
|
||||||
|
delta: delta,
|
||||||
|
headline: last == null ? _emptyHeadline : last.toStringAsFixed(1),
|
||||||
|
unit: s.insights_unit_kg,
|
||||||
|
deltaText: _weightDeltaText(values, period),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
BmiChartModel buildBmi(
|
||||||
|
List<WeightModel> weights,
|
||||||
|
BodyPeriod period,
|
||||||
|
int? heightCm,
|
||||||
|
) {
|
||||||
|
final hasHeight = heightCm != null && heightCm > 0;
|
||||||
|
if (!hasHeight) {
|
||||||
|
return BmiChartModel(
|
||||||
|
headline: _emptyHeadline,
|
||||||
|
deltaText: s.insights_need_height,
|
||||||
|
hint: s.insights_need_height,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final points = weights.where((e) => e.bmi != null).toList();
|
||||||
|
final values = points.map((e) => e.bmi!).toList();
|
||||||
|
final last = values.isEmpty ? null : values.last;
|
||||||
|
final delta = values.length < 2 ? null : _round(values.last - values.first);
|
||||||
|
final category = last == null ? null : bmiCategory(last);
|
||||||
|
|
||||||
|
return BmiChartModel(
|
||||||
|
labels: points.map((e) => formatLabel(e.createdAt)).toList(),
|
||||||
|
values: values,
|
||||||
|
last: last,
|
||||||
|
delta: delta,
|
||||||
|
headline: last == null ? _emptyHeadline : last.toStringAsFixed(1),
|
||||||
|
category: category,
|
||||||
|
deltaText: _bmiDeltaText(values, period, category),
|
||||||
|
hint: s.insights_bmi_hint(heightCm.toString()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
MeasureChartModel buildMeasure(
|
||||||
|
List<CircumferenceModel> circumferences,
|
||||||
|
BodyPeriod period,
|
||||||
|
) {
|
||||||
|
final waist = circumferences.map((e) => e.waistCircumference).toList();
|
||||||
|
final chest = circumferences.map((e) => e.chestCircumference).toList();
|
||||||
|
final hips = circumferences.map((e) => e.hipCircumference).toList();
|
||||||
|
|
||||||
|
return MeasureChartModel(
|
||||||
|
labels: circumferences.map((e) => formatLabel(e.createdAt)).toList(),
|
||||||
|
waist: waist,
|
||||||
|
chest: chest,
|
||||||
|
hips: hips,
|
||||||
|
headline: waist.isEmpty ? _emptyHeadline : waist.last.round().toString(),
|
||||||
|
deltaText: _measureDeltaText(
|
||||||
|
waist: waist,
|
||||||
|
chest: chest,
|
||||||
|
hips: hips,
|
||||||
|
period: period,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Категория по таблице ВОЗ из постановки #2120.
|
||||||
|
String bmiCategory(double bmi) {
|
||||||
|
if (bmi < 16) {
|
||||||
|
return s.insights_bmi_severe_underweight;
|
||||||
|
}
|
||||||
|
if (bmi < 18.5) {
|
||||||
|
return s.insights_bmi_underweight;
|
||||||
|
}
|
||||||
|
if (bmi < 25) {
|
||||||
|
return s.insights_bmi_normal;
|
||||||
|
}
|
||||||
|
if (bmi < 30) {
|
||||||
|
return s.insights_bmi_overweight;
|
||||||
|
}
|
||||||
|
if (bmi < 35) {
|
||||||
|
return s.insights_bmi_obesity_1;
|
||||||
|
}
|
||||||
|
if (bmi < 40) {
|
||||||
|
return s.insights_bmi_obesity_2;
|
||||||
|
}
|
||||||
|
return s.insights_bmi_obesity_3;
|
||||||
|
}
|
||||||
|
|
||||||
|
String periodLabel(BodyPeriod period) {
|
||||||
|
switch (period) {
|
||||||
|
case BodyPeriod.month:
|
||||||
|
return s.insights_period_label_1;
|
||||||
|
case BodyPeriod.threeMonths:
|
||||||
|
return s.insights_period_label_3;
|
||||||
|
case BodyPeriod.sixMonths:
|
||||||
|
return s.insights_period_label_6;
|
||||||
|
case BodyPeriod.year:
|
||||||
|
return s.insights_period_label_12;
|
||||||
|
case BodyPeriod.all:
|
||||||
|
return s.insights_period_label_all;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String formatLabel(DateTime date) {
|
||||||
|
final local = date.toLocal();
|
||||||
|
final day = local.day.toString().padLeft(2, '0');
|
||||||
|
final month = local.month.toString().padLeft(2, '0');
|
||||||
|
return '$day.$month';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Одна цифра после запятой без хвостового нуля: `3`, `3.5`.
|
||||||
|
String formatNumber(double value) {
|
||||||
|
final text = value.toStringAsFixed(1);
|
||||||
|
return text.endsWith('.0') ? text.substring(0, text.length - 2) : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _weightDeltaText(List<double> values, BodyPeriod period) {
|
||||||
|
if (values.length < 2) {
|
||||||
|
return s.insights_not_enough;
|
||||||
|
}
|
||||||
|
final diff = values.last - values.first;
|
||||||
|
final periodText = periodLabel(period);
|
||||||
|
if (diff.abs() < deltaEpsilon) {
|
||||||
|
return s.insights_weight_same(periodText);
|
||||||
|
}
|
||||||
|
final value = formatNumber(diff.abs());
|
||||||
|
return diff < 0
|
||||||
|
? s.insights_weight_lost(value, periodText)
|
||||||
|
: s.insights_weight_gained(value, periodText);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _bmiDeltaText(
|
||||||
|
List<double> values,
|
||||||
|
BodyPeriod period,
|
||||||
|
String? category,
|
||||||
|
) {
|
||||||
|
if (values.length < 2 || category == null) {
|
||||||
|
return s.insights_not_enough;
|
||||||
|
}
|
||||||
|
final diff = values.last - values.first;
|
||||||
|
final periodText = periodLabel(period);
|
||||||
|
if (diff.abs() < deltaEpsilon) {
|
||||||
|
return s.insights_bmi_same(periodText, category);
|
||||||
|
}
|
||||||
|
final value = formatNumber(diff.abs());
|
||||||
|
return diff < 0
|
||||||
|
? s.insights_bmi_lower(value, periodText, category)
|
||||||
|
: s.insights_bmi_higher(value, periodText, category);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _measureDeltaText({
|
||||||
|
required List<double> waist,
|
||||||
|
required List<double> chest,
|
||||||
|
required List<double> hips,
|
||||||
|
required BodyPeriod period,
|
||||||
|
}) {
|
||||||
|
if (waist.length < 2) {
|
||||||
|
return s.insights_not_enough;
|
||||||
|
}
|
||||||
|
final parts = [
|
||||||
|
_measurePart(s.insights_series_waist, waist),
|
||||||
|
_measurePart(s.insights_series_chest, chest),
|
||||||
|
_measurePart(s.insights_series_hips, hips),
|
||||||
|
];
|
||||||
|
return s.insights_measures_summary(parts.join(', '), periodLabel(period));
|
||||||
|
}
|
||||||
|
|
||||||
|
String _measurePart(String name, List<double> values) {
|
||||||
|
final diff = values.last - values.first;
|
||||||
|
if (diff.abs() < deltaEpsilon) {
|
||||||
|
return s.insights_measure_same(name);
|
||||||
|
}
|
||||||
|
final value = formatNumber(diff.abs());
|
||||||
|
return diff < 0
|
||||||
|
? s.insights_measure_less(name, value)
|
||||||
|
: s.insights_measure_more(name, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
double _round(double value) => (value * 10).round() / 10;
|
||||||
|
}
|
||||||
8
lib/features/insights/insights_feature_flags.dart
Normal file
8
lib/features/insights/insights_feature_flags.dart
Normal 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;
|
||||||
|
}
|
||||||
132
lib/features/insights/models/insights_payload_model.dart
Normal file
132
lib/features/insights/models/insights_payload_model.dart
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
|
part 'insights_payload_model.freezed.dart';
|
||||||
|
part 'insights_payload_model.g.dart';
|
||||||
|
|
||||||
|
/// Структура повторяет веб-`UserInsightsService::chartPayload()`, чтобы во второй
|
||||||
|
/// итерации заменить только источник данных.
|
||||||
|
@freezed
|
||||||
|
abstract class InsightsPayloadModel with _$InsightsPayloadModel {
|
||||||
|
const factory InsightsPayloadModel({
|
||||||
|
required WeightChartModel weight,
|
||||||
|
required BmiChartModel bmi,
|
||||||
|
required MeasureChartModel measure,
|
||||||
|
DailyChartModel? daily,
|
||||||
|
IntraChartModel? intra,
|
||||||
|
}) = _InsightsPayloadModel;
|
||||||
|
|
||||||
|
factory InsightsPayloadModel.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$InsightsPayloadModelFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class WeightChartModel with _$WeightChartModel {
|
||||||
|
const factory WeightChartModel({
|
||||||
|
@Default([]) List<String> labels,
|
||||||
|
@Default([]) List<double> values,
|
||||||
|
double? last,
|
||||||
|
double? delta,
|
||||||
|
@Default('') String headline,
|
||||||
|
@Default('') String unit,
|
||||||
|
@Default('') String deltaText,
|
||||||
|
}) = _WeightChartModel;
|
||||||
|
|
||||||
|
factory WeightChartModel.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$WeightChartModelFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class BmiChartModel with _$BmiChartModel {
|
||||||
|
const factory BmiChartModel({
|
||||||
|
@Default([]) List<String> labels,
|
||||||
|
@Default([]) List<double> values,
|
||||||
|
double? last,
|
||||||
|
double? delta,
|
||||||
|
@Default('') String headline,
|
||||||
|
String? category,
|
||||||
|
@Default('') String deltaText,
|
||||||
|
@Default('') String hint,
|
||||||
|
}) = _BmiChartModel;
|
||||||
|
|
||||||
|
factory BmiChartModel.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$BmiChartModelFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class MeasureChartModel with _$MeasureChartModel {
|
||||||
|
const factory MeasureChartModel({
|
||||||
|
@Default([]) List<String> labels,
|
||||||
|
@Default([]) List<double> waist,
|
||||||
|
@Default([]) List<double> chest,
|
||||||
|
@Default([]) List<double> hips,
|
||||||
|
@Default('') String headline,
|
||||||
|
@Default('') String deltaText,
|
||||||
|
}) = _MeasureChartModel;
|
||||||
|
|
||||||
|
factory MeasureChartModel.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$MeasureChartModelFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class DailyChartModel with _$DailyChartModel {
|
||||||
|
const factory DailyChartModel({
|
||||||
|
@Default([]) List<String> labels,
|
||||||
|
@Default([]) List<double> kcal,
|
||||||
|
@Default([]) List<double> proteins,
|
||||||
|
@Default([]) List<double> fats,
|
||||||
|
@Default([]) List<double> carbs,
|
||||||
|
double? average,
|
||||||
|
double? percent,
|
||||||
|
@Default('') String headline,
|
||||||
|
@Default('') String deltaText,
|
||||||
|
}) = _DailyChartModel;
|
||||||
|
|
||||||
|
factory DailyChartModel.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$DailyChartModelFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class IntraChartModel with _$IntraChartModel {
|
||||||
|
const factory IntraChartModel({
|
||||||
|
@Default([]) List<IntraBarModel> bars,
|
||||||
|
@Default([]) List<ScalePointModel> satiety,
|
||||||
|
@Default([]) List<ScalePointModel> mood,
|
||||||
|
double? wake,
|
||||||
|
double? sleep,
|
||||||
|
String? wakeLabel,
|
||||||
|
String? sleepLabel,
|
||||||
|
String? wakeText,
|
||||||
|
String? sleepText,
|
||||||
|
@Default('') String date,
|
||||||
|
@Default('') String maxDate,
|
||||||
|
@Default('') String dateLabel,
|
||||||
|
@Default(false) bool isToday,
|
||||||
|
}) = _IntraChartModel;
|
||||||
|
|
||||||
|
factory IntraChartModel.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$IntraChartModelFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Приём пищи: время `x` (часы 0–24) и калории из белков/жиров/углеводов.
|
||||||
|
@freezed
|
||||||
|
abstract class IntraBarModel with _$IntraBarModel {
|
||||||
|
const factory IntraBarModel({
|
||||||
|
required double x,
|
||||||
|
@Default(0) double p,
|
||||||
|
@Default(0) double f,
|
||||||
|
@Default(0) double c,
|
||||||
|
}) = _IntraBarModel;
|
||||||
|
|
||||||
|
factory IntraBarModel.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$IntraBarModelFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Точка шкалы 0–5 (сытость/настроение) в момент времени `x`.
|
||||||
|
@freezed
|
||||||
|
abstract class ScalePointModel with _$ScalePointModel {
|
||||||
|
const factory ScalePointModel({required double x, required double y}) =
|
||||||
|
_ScalePointModel;
|
||||||
|
|
||||||
|
factory ScalePointModel.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$ScalePointModelFromJson(json);
|
||||||
|
}
|
||||||
46
lib/features/insights/models/insights_period.dart
Normal file
46
lib/features/insights/models/insights_period.dart
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
36
lib/features/insights/models/insights_response_model.dart
Normal file
36
lib/features/insights/models/insights_response_model.dart
Normal 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);
|
||||||
|
}
|
||||||
91
lib/features/insights/presentation/charts/bmi_chart.dart
Normal file
91
lib/features/insights/presentation/charts/bmi_chart.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
135
lib/features/insights/presentation/charts/daily_kbju_chart.dart
Normal file
135
lib/features/insights/presentation/charts/daily_kbju_chart.dart
Normal 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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
214
lib/features/insights/presentation/charts/intra_day_chart.dart
Normal file
214
lib/features/insights/presentation/charts/intra_day_chart.dart
Normal 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],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
87
lib/features/insights/presentation/charts/measure_chart.dart
Normal file
87
lib/features/insights/presentation/charts/measure_chart.dart
Normal 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;
|
||||||
|
}
|
||||||
54
lib/features/insights/presentation/charts/weight_chart.dart
Normal file
54
lib/features/insights/presentation/charts/weight_chart.dart
Normal 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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
400
lib/features/insights/presentation/cubit/insights_cubit.dart
Normal file
400
lib/features/insights/presentation/cubit/insights_cubit.dart
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
29
lib/features/insights/presentation/cubit/insights_panel.dart
Normal file
29
lib/features/insights/presentation/cubit/insights_panel.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
51
lib/features/insights/presentation/cubit/insights_state.dart
Normal file
51
lib/features/insights/presentation/cubit/insights_state.dart
Normal 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||||
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
|
import 'package:flutter/cupertino.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
const int _minValue = 20;
|
||||||
|
const int _maxValue = 199;
|
||||||
|
|
||||||
|
/// Значения замеров в сантиметрах.
|
||||||
|
typedef CircumferenceValues = ({int chest, int waist, int hip});
|
||||||
|
|
||||||
|
/// Выбор замеров груди, талии и бёдер. [onSave] вызывается после закрытия шторки.
|
||||||
|
Future<void> showAddCircumferenceBottomSheet(
|
||||||
|
BuildContext context, {
|
||||||
|
CircumferenceValues? initial,
|
||||||
|
required Future<void> Function(CircumferenceValues values) onSave,
|
||||||
|
}) {
|
||||||
|
return showModalBottomSheet<void>(
|
||||||
|
useRootNavigator: true,
|
||||||
|
context: context,
|
||||||
|
builder: (_) =>
|
||||||
|
AddCircumferenceBottomSheet(initial: initial, onSave: onSave),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class AddCircumferenceBottomSheet extends StatefulWidget {
|
||||||
|
const AddCircumferenceBottomSheet({
|
||||||
|
super.key,
|
||||||
|
this.initial,
|
||||||
|
required this.onSave,
|
||||||
|
});
|
||||||
|
|
||||||
|
final CircumferenceValues? initial;
|
||||||
|
final Future<void> Function(CircumferenceValues values) onSave;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AddCircumferenceBottomSheet> createState() =>
|
||||||
|
_AddCircumferenceBottomSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddCircumferenceBottomSheetState
|
||||||
|
extends State<AddCircumferenceBottomSheet> {
|
||||||
|
late final FixedExtentScrollController _chestController;
|
||||||
|
late final FixedExtentScrollController _waistController;
|
||||||
|
late final FixedExtentScrollController _hipController;
|
||||||
|
late int _chest;
|
||||||
|
late int _waist;
|
||||||
|
late int _hip;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_chest = _clamp(widget.initial?.chest);
|
||||||
|
_waist = _clamp(widget.initial?.waist);
|
||||||
|
_hip = _clamp(widget.initial?.hip);
|
||||||
|
_chestController = FixedExtentScrollController(
|
||||||
|
initialItem: _chest - _minValue,
|
||||||
|
);
|
||||||
|
_waistController = FixedExtentScrollController(
|
||||||
|
initialItem: _waist - _minValue,
|
||||||
|
);
|
||||||
|
_hipController = FixedExtentScrollController(initialItem: _hip - _minValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_chestController.dispose();
|
||||||
|
_waistController.dispose();
|
||||||
|
_hipController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
int _clamp(int? value) => (value ?? _minValue).clamp(_minValue, _maxValue);
|
||||||
|
|
||||||
|
void _save() {
|
||||||
|
final values = (chest: _chest, waist: _waist, hip: _hip);
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
widget.onSave(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final s = S.of(context);
|
||||||
|
return SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
for (final title in [
|
||||||
|
s.insights_series_chest,
|
||||||
|
s.insights_series_waist,
|
||||||
|
s.insights_series_hips,
|
||||||
|
])
|
||||||
|
Expanded(
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
height: 200,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_picker(
|
||||||
|
const Key('add_circumference_chest_picker'),
|
||||||
|
_chestController,
|
||||||
|
(value) => _chest = value,
|
||||||
|
),
|
||||||
|
_picker(
|
||||||
|
const Key('add_circumference_waist_picker'),
|
||||||
|
_waistController,
|
||||||
|
(value) => _waist = value,
|
||||||
|
),
|
||||||
|
_picker(
|
||||||
|
const Key('add_circumference_hip_picker'),
|
||||||
|
_hipController,
|
||||||
|
(value) => _hip = value,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: InkWell(
|
||||||
|
key: const Key('add_sheet_save'),
|
||||||
|
onTap: _save,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: Ink(
|
||||||
|
width: MediaQuery.of(context).size.width - 32,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: appTheme.blueA400,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
s.save,
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _picker(
|
||||||
|
Key key,
|
||||||
|
FixedExtentScrollController controller,
|
||||||
|
ValueChanged<int> onChanged,
|
||||||
|
) {
|
||||||
|
return Expanded(
|
||||||
|
child: CupertinoPicker(
|
||||||
|
key: key,
|
||||||
|
magnification: 1.22,
|
||||||
|
squeeze: 1.2,
|
||||||
|
useMagnifier: true,
|
||||||
|
scrollController: controller,
|
||||||
|
itemExtent: 24,
|
||||||
|
onSelectedItemChanged: (index) => onChanged(index + _minValue),
|
||||||
|
children: [for (int i = _minValue; i <= _maxValue; i++) Text('$i')],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||||
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
|
import 'package:flutter/cupertino.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
const int _minKilo = 20;
|
||||||
|
const int _maxKilo = 299;
|
||||||
|
|
||||||
|
/// Выбор веса (кг + десятые). [onSave] вызывается после закрытия шторки.
|
||||||
|
Future<void> showAddWeightBottomSheet(
|
||||||
|
BuildContext context, {
|
||||||
|
double? initialWeight,
|
||||||
|
required Future<void> Function(double weight) onSave,
|
||||||
|
}) {
|
||||||
|
return showModalBottomSheet<void>(
|
||||||
|
useRootNavigator: true,
|
||||||
|
context: context,
|
||||||
|
builder: (_) =>
|
||||||
|
AddWeightBottomSheet(initialWeight: initialWeight, onSave: onSave),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class AddWeightBottomSheet extends StatefulWidget {
|
||||||
|
const AddWeightBottomSheet({
|
||||||
|
super.key,
|
||||||
|
this.initialWeight,
|
||||||
|
required this.onSave,
|
||||||
|
});
|
||||||
|
|
||||||
|
final double? initialWeight;
|
||||||
|
final Future<void> Function(double weight) onSave;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AddWeightBottomSheet> createState() => _AddWeightBottomSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddWeightBottomSheetState extends State<AddWeightBottomSheet> {
|
||||||
|
late final FixedExtentScrollController _kiloController;
|
||||||
|
late final FixedExtentScrollController _gramsController;
|
||||||
|
late int _kilo;
|
||||||
|
late int _grams;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final initial = widget.initialWeight;
|
||||||
|
final tenths = initial == null
|
||||||
|
? 0
|
||||||
|
: (initial * 10).round().clamp(_minKilo * 10, _maxKilo * 10 + 9);
|
||||||
|
_kilo = initial == null ? _minKilo : tenths ~/ 10;
|
||||||
|
_grams = initial == null ? 0 : tenths % 10;
|
||||||
|
_kiloController = FixedExtentScrollController(
|
||||||
|
initialItem: _kilo - _minKilo,
|
||||||
|
);
|
||||||
|
_gramsController = FixedExtentScrollController(initialItem: _grams);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_kiloController.dispose();
|
||||||
|
_gramsController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _save() {
|
||||||
|
final weight = double.parse('$_kilo.$_grams');
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
widget.onSave(weight);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
height: 200,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: CupertinoPicker(
|
||||||
|
key: const Key('add_weight_kilo_picker'),
|
||||||
|
magnification: 1.22,
|
||||||
|
squeeze: 1.2,
|
||||||
|
useMagnifier: true,
|
||||||
|
scrollController: _kiloController,
|
||||||
|
itemExtent: 24,
|
||||||
|
onSelectedItemChanged: (value) => _kilo = value + _minKilo,
|
||||||
|
children: [
|
||||||
|
for (int i = _minKilo; i <= _maxKilo; i++) Text('$i'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: CupertinoPicker(
|
||||||
|
key: const Key('add_weight_grams_picker'),
|
||||||
|
magnification: 1.22,
|
||||||
|
squeeze: 1.2,
|
||||||
|
useMagnifier: true,
|
||||||
|
scrollController: _gramsController,
|
||||||
|
itemExtent: 24,
|
||||||
|
onSelectedItemChanged: (value) => _grams = value,
|
||||||
|
children: [for (int i = 0; i < 10; i++) Text('$i')],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_SaveButton(onTap: _save),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SaveButton extends StatelessWidget {
|
||||||
|
const _SaveButton({required this.onTap});
|
||||||
|
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: InkWell(
|
||||||
|
key: const Key('add_sheet_save'),
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: Ink(
|
||||||
|
width: MediaQuery.of(context).size.width - 32,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: appTheme.blueA400,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
S.of(context).save,
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
640
lib/features/insights/presentation/widgets/insights_card.dart
Normal file
640
lib/features/insights/presentation/widgets/insights_card.dart
Normal 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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import 'package:flutter/painting.dart';
|
||||||
|
|
||||||
|
/// Токены из `latest-tmpls/src/scss/pages/diary.scss` (`&__analytics*`).
|
||||||
|
abstract final class InsightsColors {
|
||||||
|
static const Color blue = Color(0xFF2F80ED);
|
||||||
|
static const Color accentBackground = Color(0xFFF6FAFF);
|
||||||
|
static const Color title = Color(0xFF323F4B);
|
||||||
|
static const Color subtitle = Color(0xFF7B8794);
|
||||||
|
static const Color border = Color(0xFFE6E6E6);
|
||||||
|
static const Color positive = Color(0xFF019A3E);
|
||||||
|
|
||||||
|
static const Color calories = Color(0xFF323F4B);
|
||||||
|
static const Color proteins = Color(0xFF2F80ED);
|
||||||
|
static const Color fats = Color(0xFFF2994A);
|
||||||
|
static const Color carbs = Color(0xFF0D6EFD);
|
||||||
|
static const Color satiety = Color(0xFF019A3E);
|
||||||
|
static const Color mood = Color(0xFFEB555C);
|
||||||
|
|
||||||
|
static const Color waist = Color(0xFF2F80ED);
|
||||||
|
static const Color chest = Color(0xFF9B51E0);
|
||||||
|
static const Color hips = Color(0xFFF2994A);
|
||||||
|
|
||||||
|
static const Color wake = Color(0xFFF2994A);
|
||||||
|
static const Color sleepLine = Color(0xFF7B8794);
|
||||||
|
static const Color sleepShade = Color(0x1A7B8794);
|
||||||
|
|
||||||
|
static const Color bmiNormalZone = Color(0x14019A3E);
|
||||||
|
static const Color bmiOverweightZone = Color(0x1AF2994A);
|
||||||
|
static const Color bmiObesityZone = Color(0x14EB555C);
|
||||||
|
|
||||||
|
static const Color cardShadow = Color(0x26000000);
|
||||||
|
static const Color fabShadow = Color(0x592F80ED);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Высота области графика.
|
||||||
|
const double insightsChartHeight = 270;
|
||||||
42
lib/features/insights/presentation/widgets/insights_fab.dart
Normal file
42
lib/features/insights/presentation/widgets/insights_fab.dart
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Круглая кнопка «+» (52px) справа-снизу карточки.
|
||||||
|
class InsightsFab extends StatelessWidget {
|
||||||
|
const InsightsFab({super.key, required this.onPressed, this.tooltip});
|
||||||
|
|
||||||
|
final VoidCallback onPressed;
|
||||||
|
final String? tooltip;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Tooltip(
|
||||||
|
message: tooltip ?? '',
|
||||||
|
child: Container(
|
||||||
|
width: 52,
|
||||||
|
height: 52,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: InsightsColors.blue,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: InsightsColors.fabShadow,
|
||||||
|
blurRadius: 20,
|
||||||
|
offset: Offset(0, 8),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
shape: const CircleBorder(),
|
||||||
|
child: InkWell(
|
||||||
|
key: const Key('insights_fab'),
|
||||||
|
customBorder: const CircleBorder(),
|
||||||
|
onTap: onPressed,
|
||||||
|
child: const Icon(Icons.add, color: Colors.white, size: 28),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Крупное число + единица + строка с дельтой.
|
||||||
|
class InsightsHead extends StatelessWidget {
|
||||||
|
const InsightsHead({
|
||||||
|
super.key,
|
||||||
|
required this.headline,
|
||||||
|
required this.unit,
|
||||||
|
required this.deltaText,
|
||||||
|
this.isDeltaHighlighted = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String headline;
|
||||||
|
final String unit;
|
||||||
|
final String deltaText;
|
||||||
|
|
||||||
|
/// Зелёная дельта при наличии изменения, иначе нейтральный серый.
|
||||||
|
final bool isDeltaHighlighted;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||||
|
textBaseline: TextBaseline.alphabetic,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
headline,
|
||||||
|
key: const Key('insights_headline'),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 34,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: InsightsColors.title,
|
||||||
|
height: 1.1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (unit.isNotEmpty) ...[
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
unit,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: InsightsColors.subtitle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (deltaText.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
deltaText,
|
||||||
|
key: const Key('insights_delta'),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13.5,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: isDeltaHighlighted
|
||||||
|
? InsightsColors.positive
|
||||||
|
: InsightsColors.subtitle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
130
lib/features/insights/presentation/widgets/intra_date_nav.dart
Normal file
130
lib/features/insights/presentation/widgets/intra_date_nav.dart
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||||
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Навигация по дням для вкладки «В течение дня»: `‹ дата ›` и выбор даты.
|
||||||
|
///
|
||||||
|
/// Колбэки `null` — навигация неактивна (данные других дней ещё не подключены).
|
||||||
|
class IntraDateNav extends StatelessWidget {
|
||||||
|
const IntraDateNav({
|
||||||
|
super.key,
|
||||||
|
required this.model,
|
||||||
|
this.onPreviousDay,
|
||||||
|
this.onNextDay,
|
||||||
|
this.onDatePicked,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IntraChartModel model;
|
||||||
|
final VoidCallback? onPreviousDay;
|
||||||
|
final VoidCallback? onNextDay;
|
||||||
|
final ValueChanged<DateTime>? onDatePicked;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final s = S.of(context);
|
||||||
|
final canGoNext = !model.isToday && onNextDay != null;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: InsightsColors.accentBackground,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_NavButton(
|
||||||
|
key: const Key('insights_intra_prev'),
|
||||||
|
icon: Icons.chevron_left_rounded,
|
||||||
|
tooltip: s.insights_prev_day,
|
||||||
|
onPressed: onPreviousDay,
|
||||||
|
),
|
||||||
|
InkWell(
|
||||||
|
key: const Key('insights_intra_pick'),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
onTap: onDatePicked == null ? null : () => _pickDate(context),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(minWidth: 72),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.calendar_today_rounded,
|
||||||
|
size: 14,
|
||||||
|
color: InsightsColors.blue,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
model.dateLabel,
|
||||||
|
key: const Key('insights_intra_date_label'),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13.5,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: InsightsColors.title,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_NavButton(
|
||||||
|
key: const Key('insights_intra_next'),
|
||||||
|
icon: Icons.chevron_right_rounded,
|
||||||
|
tooltip: s.insights_next_day,
|
||||||
|
onPressed: canGoNext ? onNextDay : null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickDate(BuildContext context) async {
|
||||||
|
final lastDate = DateTime.tryParse(model.maxDate) ?? DateTime.now();
|
||||||
|
final current = DateTime.tryParse(model.date) ?? lastDate;
|
||||||
|
final picked = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: current.isAfter(lastDate) ? lastDate : current,
|
||||||
|
firstDate: DateTime(2000),
|
||||||
|
lastDate: lastDate,
|
||||||
|
helpText: S.of(context).insights_pick_date,
|
||||||
|
);
|
||||||
|
if (picked != null) {
|
||||||
|
onDatePicked?.call(picked);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NavButton extends StatelessWidget {
|
||||||
|
const _NavButton({
|
||||||
|
super.key,
|
||||||
|
required this.icon,
|
||||||
|
required this.tooltip,
|
||||||
|
required this.onPressed,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final String tooltip;
|
||||||
|
final VoidCallback? onPressed;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Opacity(
|
||||||
|
opacity: onPressed == null ? 0.35 : 1,
|
||||||
|
child: SizedBox(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
child: IconButton(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
tooltip: tooltip,
|
||||||
|
onPressed: onPressed,
|
||||||
|
icon: Icon(icon, color: InsightsColors.blue),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
96
lib/features/insights/presentation/widgets/intra_legend.dart
Normal file
96
lib/features/insights/presentation/widgets/intra_legend.dart
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
|
||||||
|
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||||
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Легенда под графиком «В течение дня»: подъём, отход ко сну, затемнение сна.
|
||||||
|
class IntraLegend extends StatelessWidget {
|
||||||
|
const IntraLegend({super.key, required this.model});
|
||||||
|
|
||||||
|
final IntraChartModel model;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final s = S.of(context);
|
||||||
|
final wakeText =
|
||||||
|
model.wakeText ??
|
||||||
|
(model.wakeLabel == null ? null : s.insights_wake_at(model.wakeLabel!));
|
||||||
|
final sleepText =
|
||||||
|
model.sleepText ??
|
||||||
|
(model.sleepLabel == null
|
||||||
|
? null
|
||||||
|
: s.insights_sleep_at(model.sleepLabel!));
|
||||||
|
|
||||||
|
return Wrap(
|
||||||
|
spacing: 16,
|
||||||
|
runSpacing: 6,
|
||||||
|
children: [
|
||||||
|
if (wakeText != null)
|
||||||
|
_LegendItem(
|
||||||
|
key: const Key('insights_legend_wake'),
|
||||||
|
color: InsightsColors.wake,
|
||||||
|
text: wakeText,
|
||||||
|
isDashed: true,
|
||||||
|
),
|
||||||
|
if (sleepText != null)
|
||||||
|
_LegendItem(
|
||||||
|
key: const Key('insights_legend_sleep'),
|
||||||
|
color: InsightsColors.sleepLine,
|
||||||
|
text: sleepText,
|
||||||
|
isDashed: true,
|
||||||
|
),
|
||||||
|
_LegendItem(
|
||||||
|
key: const Key('insights_legend_shade'),
|
||||||
|
color: InsightsColors.sleepShade,
|
||||||
|
text: s.insights_sleep_shade,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LegendItem extends StatelessWidget {
|
||||||
|
const _LegendItem({
|
||||||
|
super.key,
|
||||||
|
required this.color,
|
||||||
|
required this.text,
|
||||||
|
this.isDashed = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Color color;
|
||||||
|
final String text;
|
||||||
|
final bool isDashed;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
isDashed
|
||||||
|
? Container(
|
||||||
|
width: 18,
|
||||||
|
height: 0,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border(top: BorderSide(color: color, width: 2)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
|
width: 18,
|
||||||
|
height: 10,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12.5,
|
||||||
|
color: InsightsColors.subtitle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
79
lib/features/insights/presentation/widgets/period_pills.dart
Normal file
79
lib/features/insights/presentation/widgets/period_pills.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
76
lib/features/insights/presentation/widgets/series_chips.dart
Normal file
76
lib/features/insights/presentation/widgets/series_chips.dart
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class SeriesChipData {
|
||||||
|
const SeriesChipData({
|
||||||
|
required this.label,
|
||||||
|
required this.color,
|
||||||
|
required this.isVisible,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final Color color;
|
||||||
|
final bool isVisible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Чипы-переключатели серий под графиком.
|
||||||
|
class SeriesChips extends StatelessWidget {
|
||||||
|
const SeriesChips({super.key, required this.chips, required this.onToggle});
|
||||||
|
|
||||||
|
final List<SeriesChipData> chips;
|
||||||
|
final ValueChanged<int> onToggle;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
for (var i = 0; i < chips.length; i++)
|
||||||
|
Opacity(
|
||||||
|
opacity: chips[i].isVisible ? 1 : 0.45,
|
||||||
|
child: InkWell(
|
||||||
|
key: Key('insights_series_$i'),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
onTap: () => onToggle(i),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 14,
|
||||||
|
vertical: 7,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
border: Border.all(color: InsightsColors.border, width: 1.5),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: chips[i].color,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
chips[i].label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: InsightsColors.title,
|
||||||
|
decoration: chips[i].isVisible
|
||||||
|
? TextDecoration.none
|
||||||
|
: TextDecoration.lineThrough,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
103
lib/features/insights/repositories/insights_repository.dart
Normal file
103
lib/features/insights/repositories/insights_repository.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
43
lib/features/insights/services/insights_service.dart
Normal file
43
lib/features/insights/services/insights_service.dart
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
29
lib/features/plans/enums/plan_option_type_enum.dart
Normal file
29
lib/features/plans/enums/plan_option_type_enum.dart
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,389 +0,0 @@
|
|||||||
import 'package:eatvio_app/features/profile/models/circumference_model.dart';
|
|
||||||
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
|
|
||||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
|
||||||
import 'package:flutter/cupertino.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
|
||||||
|
|
||||||
class CircumferenceChartWidget extends StatefulWidget {
|
|
||||||
final List<CircumferenceModel> circumferencePoints;
|
|
||||||
final ProfileCubit? profileCubit;
|
|
||||||
|
|
||||||
const CircumferenceChartWidget({
|
|
||||||
super.key,
|
|
||||||
required this.circumferencePoints,
|
|
||||||
this.profileCubit,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<CircumferenceChartWidget> createState() => _CircumferenceChartWidgetState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _CircumferenceChartWidgetState extends State<CircumferenceChartWidget> {
|
|
||||||
late FixedExtentScrollController chestController;
|
|
||||||
late FixedExtentScrollController waistController;
|
|
||||||
late FixedExtentScrollController hipController;
|
|
||||||
late int chest;
|
|
||||||
late int waist;
|
|
||||||
late int hip;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Column(
|
|
||||||
children: [
|
|
||||||
if (widget.circumferencePoints.isNotEmpty)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'${widget.circumferencePoints.last.chestCircumference}',
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.black,
|
|
||||||
fontSize: 40,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (widget.circumferencePoints.length == 1)
|
|
||||||
Container(
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 12),
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
color: Colors.black12,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (widget.circumferencePoints.length > 1)
|
|
||||||
widget.circumferencePoints.last.chestCircumference > widget.circumferencePoints[widget.circumferencePoints.length - 2].chestCircumference
|
|
||||||
? const Icon(
|
|
||||||
Icons.arrow_drop_up,
|
|
||||||
size: 40,
|
|
||||||
color: Colors.grey,
|
|
||||||
)
|
|
||||||
: const Icon(
|
|
||||||
Icons.arrow_drop_down,
|
|
||||||
size: 40,
|
|
||||||
color: Colors.grey,
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
'${widget.circumferencePoints.last.waistCircumference}',
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.black,
|
|
||||||
fontSize: 40,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (widget.circumferencePoints.length == 1)
|
|
||||||
Container(
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 12),
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
color: Colors.black12,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (widget.circumferencePoints.length > 1)
|
|
||||||
widget.circumferencePoints.last.waistCircumference > widget.circumferencePoints[widget.circumferencePoints.length - 2].waistCircumference
|
|
||||||
? const Icon(
|
|
||||||
Icons.arrow_drop_up,
|
|
||||||
size: 40,
|
|
||||||
color: Colors.grey,
|
|
||||||
)
|
|
||||||
: const Icon(
|
|
||||||
Icons.arrow_drop_down,
|
|
||||||
size: 40,
|
|
||||||
color: Colors.grey,
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
'${widget.circumferencePoints.last.hipCircumference}',
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.black,
|
|
||||||
fontSize: 40,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (widget.circumferencePoints.length == 1)
|
|
||||||
Container(
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 12),
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
color: Colors.black12,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (widget.circumferencePoints.length > 1)
|
|
||||||
widget.circumferencePoints.last.hipCircumference > widget.circumferencePoints[widget.circumferencePoints.length - 2].hipCircumference
|
|
||||||
? const Icon(
|
|
||||||
Icons.arrow_drop_up,
|
|
||||||
size: 40,
|
|
||||||
color: Colors.grey,
|
|
||||||
)
|
|
||||||
: const Icon(
|
|
||||||
Icons.arrow_drop_down,
|
|
||||||
size: 40,
|
|
||||||
color: Colors.grey,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Stack(
|
|
||||||
alignment: Alignment.center,
|
|
||||||
children: [
|
|
||||||
SfCartesianChart(
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
|
||||||
enableAxisAnimation: true,
|
|
||||||
plotAreaBorderWidth: 0,
|
|
||||||
trackballBehavior: TrackballBehavior(
|
|
||||||
enable: true,
|
|
||||||
activationMode: ActivationMode.singleTap,
|
|
||||||
tooltipDisplayMode: TrackballDisplayMode.groupAllPoints,
|
|
||||||
tooltipSettings: const InteractiveTooltip(),
|
|
||||||
lineType: TrackballLineType.vertical,
|
|
||||||
),
|
|
||||||
primaryXAxis: DateTimeCategoryAxis(
|
|
||||||
tickPosition: TickPosition.inside,
|
|
||||||
majorTickLines: const MajorTickLines(
|
|
||||||
size: 0,
|
|
||||||
),
|
|
||||||
edgeLabelPlacement: EdgeLabelPlacement.shift,
|
|
||||||
dateFormat: DateFormat.MMMd('ru'),
|
|
||||||
labelStyle: const TextStyle(
|
|
||||||
color: Colors.grey,
|
|
||||||
),
|
|
||||||
majorGridLines: MajorGridLines(dashArray: const [1], color: Colors.grey.withValues(alpha: 0.3)),
|
|
||||||
axisLine: AxisLine(
|
|
||||||
color: Colors.grey.withValues(alpha: 0.3),
|
|
||||||
width: 0.4,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
primaryYAxis: const NumericAxis(
|
|
||||||
isVisible: false,
|
|
||||||
labelFormat: '{value} см',
|
|
||||||
axisLine: AxisLine(
|
|
||||||
width: 1,
|
|
||||||
),
|
|
||||||
majorTickLines: MajorTickLines(size: 0),
|
|
||||||
),
|
|
||||||
series: _getSplineSeries(),
|
|
||||||
tooltipBehavior: TooltipBehavior(enable: true),
|
|
||||||
),
|
|
||||||
if (widget.profileCubit != null)
|
|
||||||
Positioned(
|
|
||||||
right: 16,
|
|
||||||
bottom: 16,
|
|
||||||
child: FloatingActionButton.small(
|
|
||||||
heroTag: 'addCircumference',
|
|
||||||
onPressed: () {
|
|
||||||
if (widget.circumferencePoints.isNotEmpty) {
|
|
||||||
chest = widget.circumferencePoints.last.chestCircumference;
|
|
||||||
waist = widget.circumferencePoints.last.waistCircumference;
|
|
||||||
hip = widget.circumferencePoints.last.hipCircumference;
|
|
||||||
chestController = FixedExtentScrollController(initialItem: chest - 20);
|
|
||||||
waistController = FixedExtentScrollController(initialItem: waist - 20);
|
|
||||||
hipController = FixedExtentScrollController(initialItem: hip - 20);
|
|
||||||
} else {
|
|
||||||
chestController = FixedExtentScrollController();
|
|
||||||
waistController = FixedExtentScrollController();
|
|
||||||
hipController = FixedExtentScrollController();
|
|
||||||
}
|
|
||||||
showModalBottomSheet(
|
|
||||||
useRootNavigator: true,
|
|
||||||
context: context,
|
|
||||||
builder: (context) {
|
|
||||||
return SafeArea(
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: CupertinoPicker(
|
|
||||||
magnification: 1.22,
|
|
||||||
squeeze: 1.2,
|
|
||||||
useMagnifier: true,
|
|
||||||
scrollController: chestController,
|
|
||||||
itemExtent: 24,
|
|
||||||
onSelectedItemChanged: (int value) {
|
|
||||||
chest = value;
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
for (int i = 20; i < 200; i++) Text('$i'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: CupertinoPicker(
|
|
||||||
magnification: 1.22,
|
|
||||||
squeeze: 1.2,
|
|
||||||
useMagnifier: true,
|
|
||||||
scrollController: waistController,
|
|
||||||
itemExtent: 24,
|
|
||||||
onSelectedItemChanged: (int value) {
|
|
||||||
waist = value;
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
for (int i = 20; i < 200; i++) Text('$i'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: CupertinoPicker(
|
|
||||||
magnification: 1.22,
|
|
||||||
squeeze: 1.2,
|
|
||||||
useMagnifier: true,
|
|
||||||
scrollController: hipController,
|
|
||||||
itemExtent: 24,
|
|
||||||
onSelectedItemChanged: (int value) {
|
|
||||||
hip = value;
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
for (int i = 20; i < 200; i++) Text('$i'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
InkWell(
|
|
||||||
onTap: () {
|
|
||||||
widget.profileCubit?.addCircumference(
|
|
||||||
chestCircumference: chest + 20,
|
|
||||||
waistCircumference: waist + 20,
|
|
||||||
hipCircumference: hip + 20,
|
|
||||||
);
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
},
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
child: Ink(
|
|
||||||
width: MediaQuery.of(context).size.width - 32,
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: appTheme.blueA400,
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
),
|
|
||||||
child: const Center(
|
|
||||||
child: Text(
|
|
||||||
'Сохранить',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
backgroundColor: appTheme.blueA400,
|
|
||||||
shape: const CircleBorder(),
|
|
||||||
child: const Icon(
|
|
||||||
Icons.add,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (widget.circumferencePoints.isEmpty)
|
|
||||||
const Padding(
|
|
||||||
padding: EdgeInsets.symmetric(horizontal: 48),
|
|
||||||
child: Text(
|
|
||||||
'Добавьте Ваши замеры',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<CartesianSeries<CircumferenceModel, DateTime>> _getSplineSeries() {
|
|
||||||
const Color seriesColor1 = Colors.red;
|
|
||||||
final Color seriesColor2 = appTheme.blueA400;
|
|
||||||
const Color seriesColor3 = Colors.green;
|
|
||||||
|
|
||||||
return <CartesianSeries<CircumferenceModel, DateTime>>[
|
|
||||||
SplineAreaSeries<CircumferenceModel, DateTime>(
|
|
||||||
markerSettings: const MarkerSettings(
|
|
||||||
isVisible: true,
|
|
||||||
color: seriesColor1,
|
|
||||||
height: 8,
|
|
||||||
width: 8,
|
|
||||||
),
|
|
||||||
dataLabelSettings: const DataLabelSettings(
|
|
||||||
labelAlignment: ChartDataLabelAlignment.top,
|
|
||||||
isVisible: true,
|
|
||||||
),
|
|
||||||
dataSource: widget.circumferencePoints,
|
|
||||||
borderColor: seriesColor1,
|
|
||||||
gradient: LinearGradient(
|
|
||||||
colors: [
|
|
||||||
seriesColor1.withValues(alpha: 0.14),
|
|
||||||
Colors.transparent,
|
|
||||||
],
|
|
||||||
begin: Alignment.topCenter,
|
|
||||||
end: const Alignment(0, 0.6),
|
|
||||||
),
|
|
||||||
name: 'Грудь',
|
|
||||||
xValueMapper: (CircumferenceModel value, int index) => value.createdAt,
|
|
||||||
yValueMapper: (CircumferenceModel value, int index) => value.chestCircumference,
|
|
||||||
),
|
|
||||||
SplineAreaSeries<CircumferenceModel, DateTime>(
|
|
||||||
markerSettings: MarkerSettings(
|
|
||||||
isVisible: true,
|
|
||||||
color: seriesColor2,
|
|
||||||
height: 8,
|
|
||||||
width: 8,
|
|
||||||
),
|
|
||||||
dataLabelSettings: const DataLabelSettings(
|
|
||||||
labelAlignment: ChartDataLabelAlignment.top,
|
|
||||||
isVisible: true,
|
|
||||||
),
|
|
||||||
dataSource: widget.circumferencePoints,
|
|
||||||
borderColor: seriesColor2,
|
|
||||||
gradient: LinearGradient(
|
|
||||||
colors: [
|
|
||||||
seriesColor2.withValues(alpha: 0.14),
|
|
||||||
Colors.transparent,
|
|
||||||
],
|
|
||||||
begin: Alignment.topCenter,
|
|
||||||
end: const Alignment(0, 0.6),
|
|
||||||
),
|
|
||||||
name: 'Талия',
|
|
||||||
xValueMapper: (CircumferenceModel value, int index) => value.createdAt,
|
|
||||||
yValueMapper: (CircumferenceModel value, int index) => value.waistCircumference,
|
|
||||||
),
|
|
||||||
SplineAreaSeries<CircumferenceModel, DateTime>(
|
|
||||||
markerSettings: const MarkerSettings(
|
|
||||||
isVisible: true,
|
|
||||||
color: seriesColor3,
|
|
||||||
height: 8,
|
|
||||||
width: 8,
|
|
||||||
),
|
|
||||||
dataLabelSettings: const DataLabelSettings(
|
|
||||||
labelAlignment: ChartDataLabelAlignment.top,
|
|
||||||
isVisible: true,
|
|
||||||
),
|
|
||||||
dataSource: widget.circumferencePoints,
|
|
||||||
borderColor: seriesColor3,
|
|
||||||
gradient: LinearGradient(
|
|
||||||
colors: [
|
|
||||||
seriesColor3.withValues(alpha: 0.14),
|
|
||||||
Colors.transparent,
|
|
||||||
],
|
|
||||||
begin: Alignment.topCenter,
|
|
||||||
end: const Alignment(0, 0.6),
|
|
||||||
),
|
|
||||||
name: 'Бёдра',
|
|
||||||
xValueMapper: (CircumferenceModel value, int index) => value.createdAt,
|
|
||||||
yValueMapper: (CircumferenceModel value, int index) => value.hipCircumference,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
import 'package:eatvio_app/features/profile/models/weight_model.dart';
|
|
||||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
|
|
||||||
class ImtChartWidget extends StatelessWidget {
|
|
||||||
final List<WeightModel> weightPoints;
|
|
||||||
|
|
||||||
const ImtChartWidget({
|
|
||||||
super.key,
|
|
||||||
required this.weightPoints,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
if (weightPoints.isNotEmpty)
|
|
||||||
Text(
|
|
||||||
weightPoints.last.bmi.toStringAsFixed(2),
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.black,
|
|
||||||
fontSize: 40,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (weightPoints.length > 1)
|
|
||||||
weightPoints.last.bmi > weightPoints[weightPoints.length - 2].bmi
|
|
||||||
? const Icon(
|
|
||||||
Icons.arrow_drop_up,
|
|
||||||
size: 40,
|
|
||||||
color: Colors.grey,
|
|
||||||
)
|
|
||||||
: const Icon(
|
|
||||||
Icons.arrow_drop_down,
|
|
||||||
size: 40,
|
|
||||||
color: Colors.grey,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Stack(
|
|
||||||
alignment: Alignment.center,
|
|
||||||
children: [
|
|
||||||
SfCartesianChart(
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
|
||||||
enableAxisAnimation: true,
|
|
||||||
plotAreaBorderWidth: 0,
|
|
||||||
trackballBehavior: TrackballBehavior(
|
|
||||||
enable: true,
|
|
||||||
activationMode: ActivationMode.singleTap,
|
|
||||||
tooltipDisplayMode: TrackballDisplayMode.groupAllPoints,
|
|
||||||
tooltipSettings: const InteractiveTooltip(),
|
|
||||||
lineType: TrackballLineType.vertical,
|
|
||||||
),
|
|
||||||
primaryXAxis: DateTimeCategoryAxis(
|
|
||||||
tickPosition: TickPosition.inside,
|
|
||||||
majorTickLines: const MajorTickLines(
|
|
||||||
size: 0,
|
|
||||||
),
|
|
||||||
edgeLabelPlacement: EdgeLabelPlacement.shift,
|
|
||||||
dateFormat: DateFormat.MMMd('ru'),
|
|
||||||
labelStyle: const TextStyle(
|
|
||||||
color: Colors.grey,
|
|
||||||
),
|
|
||||||
majorGridLines: MajorGridLines(dashArray: const [1], color: Colors.grey.withValues(alpha: 0.3)),
|
|
||||||
axisLine: AxisLine(
|
|
||||||
color: Colors.grey.withValues(alpha: 0.3),
|
|
||||||
width: 0.4,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
primaryYAxis: const NumericAxis(
|
|
||||||
isVisible: false,
|
|
||||||
),
|
|
||||||
series: _getSplineSeries(),
|
|
||||||
tooltipBehavior: TooltipBehavior(enable: true),
|
|
||||||
),
|
|
||||||
if (weightPoints.isEmpty)
|
|
||||||
const Padding(
|
|
||||||
padding: EdgeInsets.symmetric(horizontal: 32),
|
|
||||||
child: Text(
|
|
||||||
'Индекс Массы Тела рассчитывается при указанных весе и росте',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<CartesianSeries<WeightModel, DateTime>> _getSplineSeries() {
|
|
||||||
return <CartesianSeries<WeightModel, DateTime>>[
|
|
||||||
SplineAreaSeries<WeightModel, DateTime>(
|
|
||||||
markerSettings: MarkerSettings(
|
|
||||||
isVisible: true,
|
|
||||||
color: appTheme.blueA400,
|
|
||||||
height: 8,
|
|
||||||
width: 8,
|
|
||||||
),
|
|
||||||
dataLabelSettings: const DataLabelSettings(
|
|
||||||
labelAlignment: ChartDataLabelAlignment.top,
|
|
||||||
isVisible: true,
|
|
||||||
),
|
|
||||||
dataSource: weightPoints,
|
|
||||||
borderColor: appTheme.blueA400,
|
|
||||||
gradient: LinearGradient(
|
|
||||||
colors: [
|
|
||||||
appTheme.blueA400.withValues(alpha: 0.14),
|
|
||||||
Colors.transparent,
|
|
||||||
],
|
|
||||||
begin: Alignment.topCenter,
|
|
||||||
end: const Alignment(0, 0.6),
|
|
||||||
),
|
|
||||||
name: 'ИМТ',
|
|
||||||
xValueMapper: (WeightModel value, int index) => value.createdAt,
|
|
||||||
yValueMapper: (WeightModel value, int index) => double.parse(value.bmi.toStringAsFixed(2)),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,250 +0,0 @@
|
|||||||
import 'package:eatvio_app/features/profile/models/weight_model.dart';
|
|
||||||
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
|
|
||||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
|
||||||
import 'package:flutter/cupertino.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
|
||||||
|
|
||||||
class WeightChartWidget extends StatefulWidget {
|
|
||||||
final List<WeightModel> weightPoints;
|
|
||||||
final ProfileCubit? profileCubit;
|
|
||||||
|
|
||||||
const WeightChartWidget({
|
|
||||||
super.key,
|
|
||||||
required this.weightPoints,
|
|
||||||
this.profileCubit,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<WeightChartWidget> createState() => _WeightChartWidgetState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _WeightChartWidgetState extends State<WeightChartWidget> {
|
|
||||||
late FixedExtentScrollController weightKiloController;
|
|
||||||
late FixedExtentScrollController weightGramsController;
|
|
||||||
int weightKilo = 20;
|
|
||||||
int weightGrams = 0;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
if (widget.weightPoints.isNotEmpty)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'${widget.weightPoints.last.weight.toStringAsFixed(1)} кг',
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.black,
|
|
||||||
fontSize: 40,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (widget.weightPoints.length == 1)
|
|
||||||
Container(
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 12),
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
color: Colors.black12,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (widget.weightPoints.length > 1)
|
|
||||||
widget.weightPoints.last.weight > widget.weightPoints[widget.weightPoints.length - 2].weight
|
|
||||||
? Icon(
|
|
||||||
Icons.arrow_drop_up,
|
|
||||||
size: 40,
|
|
||||||
color: Colors.grey.withValues(alpha: 0.4),
|
|
||||||
)
|
|
||||||
: Icon(
|
|
||||||
Icons.arrow_drop_down,
|
|
||||||
size: 40,
|
|
||||||
color: Colors.grey.withValues(alpha: 0.4),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Stack(
|
|
||||||
alignment: Alignment.center,
|
|
||||||
children: [
|
|
||||||
SfCartesianChart(
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
|
||||||
enableAxisAnimation: true,
|
|
||||||
plotAreaBorderWidth: 0,
|
|
||||||
trackballBehavior: TrackballBehavior(
|
|
||||||
enable: true,
|
|
||||||
activationMode: ActivationMode.singleTap,
|
|
||||||
tooltipDisplayMode: TrackballDisplayMode.groupAllPoints,
|
|
||||||
tooltipSettings: const InteractiveTooltip(),
|
|
||||||
lineType: TrackballLineType.vertical,
|
|
||||||
),
|
|
||||||
primaryXAxis: DateTimeCategoryAxis(
|
|
||||||
tickPosition: TickPosition.inside,
|
|
||||||
majorTickLines: const MajorTickLines(
|
|
||||||
size: 0,
|
|
||||||
),
|
|
||||||
edgeLabelPlacement: EdgeLabelPlacement.shift,
|
|
||||||
dateFormat: DateFormat.MMMd('ru'),
|
|
||||||
labelStyle: const TextStyle(
|
|
||||||
color: Colors.grey,
|
|
||||||
),
|
|
||||||
majorGridLines: MajorGridLines(dashArray: const [1], color: Colors.grey.withValues(alpha: 0.3)),
|
|
||||||
axisLine: AxisLine(
|
|
||||||
color: Colors.grey.withValues(alpha: 0.3),
|
|
||||||
width: 0.4,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
primaryYAxis: const NumericAxis(
|
|
||||||
isVisible: false,
|
|
||||||
labelFormat: '{value} кг',
|
|
||||||
majorGridLines: MajorGridLines(dashArray: [1], color: Colors.grey),
|
|
||||||
axisLine: AxisLine(
|
|
||||||
width: 1,
|
|
||||||
),
|
|
||||||
majorTickLines: MajorTickLines(size: 0),
|
|
||||||
),
|
|
||||||
series: _getSplineSeries(),
|
|
||||||
tooltipBehavior: TooltipBehavior(enable: true),
|
|
||||||
),
|
|
||||||
if (widget.profileCubit != null)
|
|
||||||
Positioned(
|
|
||||||
right: 16,
|
|
||||||
bottom: 16,
|
|
||||||
child: FloatingActionButton.small(
|
|
||||||
heroTag: 'addWeight',
|
|
||||||
onPressed: () {
|
|
||||||
if (widget.weightPoints.isNotEmpty) {
|
|
||||||
try {
|
|
||||||
weightKilo = int.parse(widget.weightPoints.last.weight.toString().split('.')[0]);
|
|
||||||
weightGrams = int.parse(widget.weightPoints.last.weight.toString().split('.')[1][0]);
|
|
||||||
} catch (e) {
|
|
||||||
weightGrams = 0;
|
|
||||||
}
|
|
||||||
weightKiloController = FixedExtentScrollController(initialItem: weightKilo - 20);
|
|
||||||
weightGramsController = FixedExtentScrollController(initialItem: weightGrams);
|
|
||||||
} else {
|
|
||||||
weightKiloController = FixedExtentScrollController();
|
|
||||||
weightGramsController = FixedExtentScrollController();
|
|
||||||
}
|
|
||||||
showModalBottomSheet(
|
|
||||||
useRootNavigator: true,
|
|
||||||
context: context,
|
|
||||||
builder: (context) {
|
|
||||||
return SafeArea(
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: CupertinoPicker(
|
|
||||||
magnification: 1.22,
|
|
||||||
squeeze: 1.2,
|
|
||||||
useMagnifier: true,
|
|
||||||
scrollController: weightKiloController,
|
|
||||||
itemExtent: 24,
|
|
||||||
onSelectedItemChanged: (int value) {
|
|
||||||
weightKilo = value + 20;
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
for (int i = 20; i < 300; i++) Text('$i'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: CupertinoPicker(
|
|
||||||
magnification: 1.22,
|
|
||||||
squeeze: 1.2,
|
|
||||||
useMagnifier: true,
|
|
||||||
scrollController: weightGramsController,
|
|
||||||
itemExtent: 24,
|
|
||||||
onSelectedItemChanged: (int value) {
|
|
||||||
weightGrams = value;
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
for (int i = 0; i < 10; i++) Text('$i'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
InkWell(
|
|
||||||
onTap: () {
|
|
||||||
widget.profileCubit?.addWeight(weight: double.parse('$weightKilo.$weightGrams'));
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
},
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
child: Ink(
|
|
||||||
width: MediaQuery.of(context).size.width - 32,
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: appTheme.blueA400,
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
),
|
|
||||||
child: const Center(
|
|
||||||
child: Text(
|
|
||||||
'Сохранить',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
backgroundColor: appTheme.blueA400,
|
|
||||||
shape: const CircleBorder(),
|
|
||||||
child: const Icon(
|
|
||||||
Icons.add,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (widget.weightPoints.isEmpty) const Text('Добавьте Ваш вес'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<CartesianSeries<WeightModel, DateTime>> _getSplineSeries() {
|
|
||||||
return <CartesianSeries<WeightModel, DateTime>>[
|
|
||||||
SplineAreaSeries<WeightModel, DateTime>(
|
|
||||||
markerSettings: MarkerSettings(
|
|
||||||
isVisible: true,
|
|
||||||
color: appTheme.blueA400,
|
|
||||||
height: 8,
|
|
||||||
width: 8,
|
|
||||||
),
|
|
||||||
dataLabelSettings: const DataLabelSettings(
|
|
||||||
labelAlignment: ChartDataLabelAlignment.top,
|
|
||||||
isVisible: true,
|
|
||||||
),
|
|
||||||
dataSource: widget.weightPoints,
|
|
||||||
borderColor: appTheme.blueA400,
|
|
||||||
gradient: LinearGradient(
|
|
||||||
colors: [
|
|
||||||
appTheme.blueA400.withValues(alpha: 0.14),
|
|
||||||
Colors.transparent,
|
|
||||||
],
|
|
||||||
begin: Alignment.topCenter,
|
|
||||||
end: const Alignment(0, 0.6),
|
|
||||||
),
|
|
||||||
name: 'Вес',
|
|
||||||
xValueMapper: (WeightModel value, int index) => value.createdAt,
|
|
||||||
yValueMapper: (WeightModel value, int index) => value.weight,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,23 +14,10 @@ class UserDetailsCubit extends Cubit<UserDetailsState> {
|
|||||||
try {
|
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
"Вы уверены, что хотите отменить заказ?",
|
"Вы уверены, что хотите отменить заказ?",
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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(
|
||||||
"Расскажите в свободной форме о том как просыпаетесь, когда едите, когда ложитесь спать:",
|
"Расскажите в свободной форме о том как просыпаетесь, когда едите, когда ложитесь спать:",
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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": "Нет активных консультаций с персональной диетой"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user