forked from eatvio/eatvio_app
Compare commits
47 Commits
issue-1089
...
issue-1123
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45d072cff4 | ||
| 67920b2016 | |||
|
|
fc28062bb5 | ||
| f947182632 | |||
|
|
27b8128a19 | ||
| 924dc2c16a | |||
| 53ce73b41e | |||
|
|
1829ad611d | ||
| 2b2991012d | |||
| 7a456c2d9d | |||
| 36079de81c | |||
| 3825a974f9 | |||
| 678d73932c | |||
| 8942e11be9 | |||
|
|
55e1633d1c | ||
| 2668907f60 | |||
| 56ab9b65f9 | |||
| 5b2ac4f228 | |||
| 51aff6c175 | |||
| cba2689406 | |||
| 5f0b7e026b | |||
| 85bc2c6d5c | |||
| f58892bdeb | |||
| f14f426381 | |||
| 6a31880a85 | |||
| 3a41a85f9d | |||
| 194b8d5b16 | |||
|
|
f6f9764811 | ||
| e2d287eb9e | |||
| cf5c42ff4b | |||
| 28c62bf775 | |||
| 67e6526552 | |||
| dedb55ecca | |||
| 6b4c88068e | |||
| 04dab02a6d | |||
| c45612decb | |||
| db293bf13f | |||
| 05eaa3caa9 | |||
| fe54037cef | |||
| e6601e2fe6 | |||
| c70c9beb20 | |||
| d2fc087699 | |||
| 18925fd95e | |||
| 37a787c8d0 | |||
| fb68f02ca4 | |||
| 4b72258b1f | |||
| 3b8d1bf651 |
@@ -14,7 +14,7 @@ env:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: [ubuntu-latest]
|
||||
runs-on: [debian-12]
|
||||
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@v3
|
||||
@@ -155,10 +155,10 @@ jobs:
|
||||
|
||||
- name: Get dependencies
|
||||
run: |
|
||||
flutter clean
|
||||
flutter pub cache repair
|
||||
flutter pub get
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
flutter clean
|
||||
flutter pub cache repair
|
||||
flutter pub get
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
|
||||
- name: Native Analyze
|
||||
run: flutter analyze
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -16,6 +16,8 @@
|
||||
*.mocks.dart
|
||||
android/build/
|
||||
ios/XCBuildData/
|
||||
docs/
|
||||
specs/
|
||||
env/.env
|
||||
.env
|
||||
android/key.properties
|
||||
@@ -25,4 +27,4 @@ backend/
|
||||
.fvm/
|
||||
.fvmrc
|
||||
/eatvio
|
||||
*.code-workspace
|
||||
*.code-workspace
|
||||
|
||||
@@ -43,37 +43,95 @@ platform :ios do
|
||||
end
|
||||
|
||||
UI.message "Using auth_key_path: #{auth_key_path}"
|
||||
|
||||
|
||||
# Authenticate to App Store Connect early:
|
||||
# needed here by latest_testflight_build_number, not only by upload_to_testflight.
|
||||
app_store_connect_api_key(
|
||||
key_id: "325JS9JCZH",
|
||||
issuer_id: "dfa27d1d-2bb8-4d4d-847e-0b32f9b880b3",
|
||||
key_filepath: auth_key_path,
|
||||
duration: 1200,
|
||||
in_house: false
|
||||
)
|
||||
|
||||
# Configure Flutter for release mode
|
||||
UI.message "Configuring Flutter for release mode..."
|
||||
sh("cd .. && flutter build ios --config-only --release")
|
||||
|
||||
# Read build number from Flutter Generated.xcconfig
|
||||
# Read version and build number from Flutter Generated.xcconfig.
|
||||
# They are only a floor: the actual build number is derived from TestFlight below,
|
||||
# so repeated CI runs on the same commit can never hit
|
||||
# ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE ("bundle version must be higher").
|
||||
generated_xcconfig_path = File.join("..", "Flutter", "Generated.xcconfig")
|
||||
flutter_build_name = nil
|
||||
flutter_build_number = nil
|
||||
|
||||
|
||||
if File.exist?(generated_xcconfig_path)
|
||||
File.foreach(generated_xcconfig_path) do |line|
|
||||
matches = line.match(/FLUTTER_BUILD_NUMBER=(.*)/)
|
||||
if matches
|
||||
flutter_build_number = matches[1].strip
|
||||
break
|
||||
if (m = line.match(/FLUTTER_BUILD_NAME=(.*)/))
|
||||
flutter_build_name = m[1].strip
|
||||
elsif (m = line.match(/FLUTTER_BUILD_NUMBER=(.*)/))
|
||||
flutter_build_number = m[1].strip
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if flutter_build_number.nil?
|
||||
UI.user_error!("Could not find FLUTTER_BUILD_NUMBER in #{generated_xcconfig_path}")
|
||||
|
||||
if flutter_build_name.nil? || flutter_build_number.nil?
|
||||
UI.user_error!("Could not find FLUTTER_BUILD_NAME/FLUTTER_BUILD_NUMBER in #{generated_xcconfig_path}")
|
||||
end
|
||||
|
||||
UI.message "Flutter build number: #{flutter_build_number}"
|
||||
|
||||
|
||||
UI.message "Flutter version from pubspec: #{flutter_build_name} (#{flutter_build_number})"
|
||||
|
||||
# Apple closes a version train once that version is approved/released.
|
||||
# Uploading into a closed train wastes a full ~8 min build and fails only
|
||||
# at the upload step with altool 90186 ("closed for new build submissions")
|
||||
# / 90062 ("must be higher than the previously approved version").
|
||||
# Fail fast here, before building.
|
||||
closed_train_states = [
|
||||
Spaceship::ConnectAPI::AppStoreVersion::AppVersionState::READY_FOR_DISTRIBUTION,
|
||||
Spaceship::ConnectAPI::AppStoreVersion::AppVersionState::PROCESSING_FOR_DISTRIBUTION,
|
||||
Spaceship::ConnectAPI::AppStoreVersion::AppVersionState::PENDING_DEVELOPER_RELEASE,
|
||||
Spaceship::ConnectAPI::AppStoreVersion::AppVersionState::PENDING_APPLE_RELEASE
|
||||
].join(",")
|
||||
asc_app = Spaceship::ConnectAPI::App.find("com.eatvioapp.app")
|
||||
closed_train = nil
|
||||
if asc_app.nil?
|
||||
UI.important("App com.eatvioapp.app not found in App Store Connect, skipping closed-train guard")
|
||||
else
|
||||
closed_train = asc_app.get_app_store_versions(
|
||||
filter: {
|
||||
appVersionState: closed_train_states,
|
||||
platform: Spaceship::ConnectAPI::Platform::IOS
|
||||
}
|
||||
).find do |v|
|
||||
(Gem::Version.new(v.version_string) >= Gem::Version.new(flutter_build_name))
|
||||
rescue ArgumentError
|
||||
false
|
||||
end
|
||||
end
|
||||
if closed_train
|
||||
UI.user_error!(
|
||||
"Version train #{closed_train.version_string} is closed for new submissions " \
|
||||
"(#{closed_train.app_version_state}), pubspec version is #{flutter_build_name}. " \
|
||||
"Bump the version in pubspec.yaml above #{closed_train.version_string} and re-run."
|
||||
)
|
||||
end
|
||||
|
||||
asc_latest_build_number = latest_testflight_build_number(
|
||||
app_identifier: "com.eatvioapp.app",
|
||||
version: flutter_build_name,
|
||||
initial_build_number: flutter_build_number.to_i
|
||||
)
|
||||
|
||||
new_build_number = [flutter_build_number.to_i, asc_latest_build_number].max + 1
|
||||
UI.message "Latest TestFlight build number: #{asc_latest_build_number} -> uploading build #{new_build_number}"
|
||||
|
||||
match(type: "appstore", readonly: true)
|
||||
|
||||
|
||||
# Set build number explicitly instead of incrementing
|
||||
increment_build_number(
|
||||
xcodeproj: "Runner.xcodeproj",
|
||||
build_number: flutter_build_number
|
||||
build_number: new_build_number
|
||||
)
|
||||
|
||||
update_code_signing_settings(
|
||||
@@ -93,28 +151,33 @@ platform :ios do
|
||||
export_team_id: "VL68C36HYS"
|
||||
)
|
||||
|
||||
app_store_connect_api_key(
|
||||
key_id: "325JS9JCZH",
|
||||
issuer_id: "dfa27d1d-2bb8-4d4d-847e-0b32f9b880b3",
|
||||
key_filepath: auth_key_path,
|
||||
duration: 1200,
|
||||
in_house: false
|
||||
)
|
||||
|
||||
|
||||
begin
|
||||
upload_to_testflight(
|
||||
skip_waiting_for_build_processing: true,
|
||||
skip_submission: true,
|
||||
distribute_external: false,
|
||||
notify_external_testers: false,
|
||||
changelog: "TestFlight build #{flutter_build_number}",
|
||||
changelog: "TestFlight build #{new_build_number}",
|
||||
verbose: true
|
||||
)
|
||||
rescue => ex
|
||||
UI.error "Upload to TestFlight failed: #{ex.message}"
|
||||
UI.error "Full error: #{ex.backtrace.join("\n")}"
|
||||
raise ex
|
||||
# Xcode 26 altool prints a harmless "ERROR: ... CDUploaderStateInfoResumeFilePath"
|
||||
# line even when the package was delivered fine (UPLOAD SUCCEEDED appears in the
|
||||
# same output). fastlane < 2.240.0 treats every ERROR line as a failure
|
||||
# (fastlane#30130), so the lane would die after a successful upload.
|
||||
# Real Apple rejections (90186 closed train, 90062 version too low, ...) always
|
||||
# contain UPLOAD FAILED and/or iris-code - those must still fail the lane.
|
||||
if ex.message.include?("CDUploaderStateInfoResumeFilePath") &&
|
||||
!ex.message.include?("UPLOAD FAILED") &&
|
||||
!ex.message.include?("iris-code")
|
||||
UI.important("altool delivered the package but reported a bogus resume-file ERROR " \
|
||||
"(fastlane#30130). Treating upload as successful - " \
|
||||
"verify the build appears in TestFlight.")
|
||||
else
|
||||
UI.error "Upload to TestFlight failed: #{ex.message}"
|
||||
UI.error "Full error: #{ex.backtrace.join("\n")}"
|
||||
raise ex
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -33,6 +33,20 @@ class AppConfig {
|
||||
late String _baseUrl;
|
||||
String get baseUrl => _baseUrl;
|
||||
|
||||
/// Продуктовые хосты API. Сравнение по хосту целиком: поддомены
|
||||
/// (например `*-hasan.eatvio.ru`) — это стенды разработки, не прод.
|
||||
static const List<String> prodApiHosts = ['eatvio.ru', 'eatvio.com'];
|
||||
|
||||
/// Текущий base URL указывает на продуктовый API?
|
||||
bool get isProdApi {
|
||||
final host = Uri.tryParse(baseUrl)?.host.toLowerCase() ?? '';
|
||||
if (host.isEmpty) return false;
|
||||
return prodApiHosts.any((prodHost) => host == prodHost || host == 'www.$prodHost');
|
||||
}
|
||||
|
||||
/// Работаем на стенде разработки/stage (не на продуктовом API)?
|
||||
bool get isDevOrStageStand => !isProdApi;
|
||||
|
||||
void setBaseUrl(String baseUrls) {
|
||||
_baseUrl = baseUrls;
|
||||
_persistBaseUrl(baseUrls);
|
||||
|
||||
@@ -97,6 +97,7 @@ class CubitRegistrations {
|
||||
systemPipCoordinator: sl(),
|
||||
),
|
||||
);
|
||||
sl.registerLazySingleton<FeedCubit>(() => FeedCubit(feedRepository: sl()));
|
||||
}
|
||||
|
||||
/// Регистрация Cubit'ов как фабрик
|
||||
@@ -108,10 +109,15 @@ class CubitRegistrations {
|
||||
firebaseApi: sl(),
|
||||
prefs: sl(),
|
||||
profileRepository: sl(),
|
||||
sessionLifecycleService: sl(),
|
||||
),
|
||||
);
|
||||
sl.registerFactory<RegistrationCubit>(
|
||||
() => RegistrationCubit(registrationRepository: sl(), prefs: sl()),
|
||||
() => RegistrationCubit(
|
||||
registrationRepository: sl(),
|
||||
prefs: sl(),
|
||||
sessionLifecycleService: sl(),
|
||||
),
|
||||
);
|
||||
sl.registerFactory<ResetPasswordCubit>(
|
||||
() => ResetPasswordCubit(resetPasswordRepository: sl()),
|
||||
@@ -120,14 +126,21 @@ class CubitRegistrations {
|
||||
() => VerifyEmailCubit(profileRepository: sl(), prefs: sl()),
|
||||
);
|
||||
sl.registerFactory<OAuthCubit>(
|
||||
() => OAuthCubit(authRepository: sl(), firebaseApi: sl(), prefs: sl()),
|
||||
() => OAuthCubit(
|
||||
authRepository: sl(),
|
||||
firebaseApi: sl(),
|
||||
prefs: sl(),
|
||||
sessionLifecycleService: sl(),
|
||||
),
|
||||
);
|
||||
sl.registerFactory<RegistrationCubitV2>(
|
||||
() =>
|
||||
RegistrationCubitV2(registrationRepository: sl(), firebaseApi: sl()),
|
||||
() => RegistrationCubitV2(
|
||||
registrationRepository: sl(),
|
||||
firebaseApi: sl(),
|
||||
sessionLifecycleService: sl(),
|
||||
),
|
||||
);
|
||||
// Feed related cubits
|
||||
sl.registerFactory<FeedCubit>(() => FeedCubit(feedRepository: sl()));
|
||||
sl.registerFactory<RecipesCubit>(
|
||||
() => RecipesCubit(
|
||||
feedRepository: sl(),
|
||||
@@ -242,6 +255,7 @@ class CubitRegistrations {
|
||||
settingsRepository: sl(),
|
||||
consultantRepository: sl(),
|
||||
consultationsRepository: sl(),
|
||||
sessionLifecycleService: sl(),
|
||||
),
|
||||
);
|
||||
sl.registerFactory<RegisterConsultantCubit>(
|
||||
@@ -263,7 +277,7 @@ class CubitRegistrations {
|
||||
),
|
||||
);
|
||||
sl.registerFactory<ConsultationsCubit>(
|
||||
() => ConsultationsCubit(consultationsRepository: sl()),
|
||||
() => ConsultationsCubit(consultationsRepository: sl(), chatRepository: sl()),
|
||||
);
|
||||
sl.registerFactory<ConsultantCommentsCubit>(
|
||||
() => ConsultantCommentsCubit(diaryRepository: sl()),
|
||||
@@ -297,7 +311,7 @@ class CubitRegistrations {
|
||||
sl.registerFactory<UserDetailsCubit>(
|
||||
() => UserDetailsCubit(profileRepository: sl()),
|
||||
);
|
||||
sl.registerFactory<MenuCubit>(() => MenuCubit(sl()));
|
||||
sl.registerFactory<MenuCubit>(() => MenuCubit(sl(), sl()));
|
||||
sl.registerFactory<MarketingCubit>(() => MarketingCubit(sl()));
|
||||
sl.registerFactory<PersonalDietSuggestionCubit>(
|
||||
() => PersonalDietSuggestionCubit(
|
||||
|
||||
@@ -18,6 +18,7 @@ import 'package:eatvio_app/features/diets/repositories/diet_repository.dart';
|
||||
import 'package:eatvio_app/features/feed/repositories/feed_repository.dart';
|
||||
import 'package:eatvio_app/features/glossary/repositories/glossary_repository.dart';
|
||||
import 'package:eatvio_app/features/barcode/repositories/barcode_repository.dart';
|
||||
import 'package:eatvio_app/features/insights/repositories/insights_repository.dart';
|
||||
import 'package:eatvio_app/features/menu/presentation/repositories/blocked_users_repository.dart';
|
||||
import 'package:eatvio_app/features/menu/repositories/notification_settings_repository.dart';
|
||||
import 'package:eatvio_app/features/my_diets/repositories/my_diets_repository.dart';
|
||||
@@ -55,6 +56,7 @@ class RepositoryRegistrations {
|
||||
sl.registerLazySingleton<VideoCallRepository>(() => VideoCallRepository(videoCallService: sl()));
|
||||
sl.registerLazySingleton<FeedRepository>(() => FeedRepository(feedService: sl()));
|
||||
sl.registerLazySingleton<ProfileRepository>(() => ProfileRepository(profileService: sl()));
|
||||
sl.registerLazySingleton<InsightsRepository>(() => InsightsRepository(profileRepository: sl()));
|
||||
sl.registerLazySingleton<CalendarRepository>(() => CalendarRepository(calendarService: sl()));
|
||||
sl.registerLazySingleton<SettingsRepository>(() => SettingsRepository(settingsService: sl()));
|
||||
sl.registerLazySingleton<ConsultantRepository>(() => ConsultantRepository(consultantService: sl()));
|
||||
|
||||
15
lib/core/di/session_registrations.dart
Normal file
15
lib/core/di/session_registrations.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
|
||||
import 'package:eatvio_app/core/websockets/ws_manager.dart';
|
||||
|
||||
class SessionRegistrations {
|
||||
static void registerAll() {
|
||||
sl.registerLazySingleton<SessionLifecycleService>(
|
||||
() => SessionLifecycleService(
|
||||
wsManager: sl<WsManager>(),
|
||||
errorReporter: sl<ErrorReporter>(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/app_router.dart';
|
||||
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
|
||||
import 'package:eatvio_app/core/token_storage.dart';
|
||||
import 'package:eatvio_app/core/websockets/ws_manager.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class TokenInterceptor extends Interceptor {
|
||||
@override
|
||||
@@ -14,6 +12,7 @@ class TokenInterceptor extends Interceptor {
|
||||
final token = await TokenStorage.getTokenFromStorage();
|
||||
if (token != null && token.isNotEmpty) {
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
options.extra['authToken'] = token;
|
||||
}
|
||||
|
||||
super.onRequest(options, handler);
|
||||
@@ -25,9 +24,18 @@ class TokenInterceptor extends Interceptor {
|
||||
ErrorInterceptorHandler handler,
|
||||
) async {
|
||||
if (err.response?.statusCode == 401) {
|
||||
await sl<WsManager>().clear();
|
||||
await TokenStorage.removeTokenInStorage();
|
||||
AppRouter.navigatorKey.currentContext?.go('/auth-screen');
|
||||
final requestCarriedAuth = err.requestOptions.headers.containsKey(
|
||||
'Authorization',
|
||||
);
|
||||
final requestToken = err.requestOptions.extra['authToken'];
|
||||
final currentToken = await TokenStorage.getTokenFromStorage();
|
||||
if (requestCarriedAuth &&
|
||||
requestToken != null &&
|
||||
requestToken == currentToken) {
|
||||
await sl<SessionLifecycleService>().endSession(
|
||||
reason: SessionEndReason.unauthorized,
|
||||
);
|
||||
}
|
||||
}
|
||||
super.onError(err, handler);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:eatvio_app/core/di/cubit_registrations.dart';
|
||||
import 'package:eatvio_app/core/di/external_registrations.dart';
|
||||
import 'package:eatvio_app/core/di/repository_registrations.dart';
|
||||
import 'package:eatvio_app/core/di/service_registrations.dart';
|
||||
import 'package:eatvio_app/core/di/session_registrations.dart';
|
||||
import 'package:eatvio_app/core/prefs.dart';
|
||||
import 'package:eatvio_app/core/websockets/ws_manager.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
@@ -38,4 +39,6 @@ Future<void> init({required AppBuildType buildType}) async {
|
||||
sl.registerLazySingleton<WsManager>(
|
||||
() => WsManager(wsService: sl(), errorReporter: sl()),
|
||||
);
|
||||
|
||||
SessionRegistrations.registerAll();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'package:eatvio_app/core/routing/route_constants.dart';
|
||||
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
|
||||
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_state.dart';
|
||||
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/widgets/bottom_nav_scope.dart';
|
||||
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/pages/chats_screen.dart';
|
||||
@@ -24,14 +23,10 @@ class MainNavigationRoutes {
|
||||
return [
|
||||
ShellRoute(
|
||||
builder: (context, state, child) {
|
||||
// Инициализация ProfileAvatarCubit при первом использовании ShellRoute
|
||||
final profileAvatarCubit = context.read<ProfileAvatarCubit>();
|
||||
if (profileAvatarCubit.state is ProfileAvatarStateLoading) {
|
||||
// Вызываем getMyProfile() только если состояние loading (первый запуск)
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
profileAvatarCubit.getMyProfile();
|
||||
});
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
profileAvatarCubit.ensureFreshForCurrentSession();
|
||||
});
|
||||
|
||||
return _MainShellWithNavScope(currentLocation: state.matchedLocation, child: child);
|
||||
},
|
||||
|
||||
@@ -354,9 +354,10 @@ class OtherRoutes {
|
||||
path: UserDiaryScreen.path,
|
||||
name: UserDiaryScreen.name,
|
||||
builder: (context, state) {
|
||||
final data = state.extra as Map<String, int>;
|
||||
final data = state.extra as Map<String, Object?>;
|
||||
final diaryId = data['diaryId'] as int;
|
||||
return UserDiaryScreen(diaryId: diaryId);
|
||||
final ownerSlug = data['ownerSlug'] as String?;
|
||||
return UserDiaryScreen(diaryId: diaryId, ownerSlug: ownerSlug);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
|
||||
122
lib/core/session/session_lifecycle_service.dart
Normal file
122
lib/core/session/session_lifecycle_service.dart
Normal file
@@ -0,0 +1,122 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:appmetrica_plugin/appmetrica_plugin.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/app_router.dart';
|
||||
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
|
||||
import 'package:eatvio_app/core/firebase_api.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/prefs.dart';
|
||||
import 'package:eatvio_app/core/prefs_keys.dart';
|
||||
import 'package:eatvio_app/core/routing/route_constants.dart';
|
||||
import 'package:eatvio_app/core/token_storage.dart';
|
||||
import 'package:eatvio_app/core/websockets/ws_manager.dart';
|
||||
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/new_messages_cubit/new_messages_cubit.dart';
|
||||
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
||||
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
enum SessionEndReason { logout, unauthorized, accountDeleted }
|
||||
|
||||
class SessionLifecycleService {
|
||||
SessionLifecycleService({
|
||||
required WsManager wsManager,
|
||||
required ErrorReporter errorReporter,
|
||||
}) : _wsManager = wsManager,
|
||||
_errorReporter = errorReporter;
|
||||
|
||||
final WsManager _wsManager;
|
||||
final ErrorReporter _errorReporter;
|
||||
|
||||
int? _authenticatedFingerprint;
|
||||
bool _endingSession = false;
|
||||
|
||||
Future<void> endSession({required SessionEndReason reason}) async {
|
||||
if (_endingSession) return;
|
||||
_endingSession = true;
|
||||
try {
|
||||
await _run(() => _wsManager.clear());
|
||||
await _run(TokenStorage.removeTokenInStorage);
|
||||
await _run(SocketIdStorage.removeSocketIdInStorage);
|
||||
await _run(() => sl<ProfileAvatarCubit>().reset());
|
||||
await _run(() => sl<ChatsCubit>().reset());
|
||||
await _run(() => sl<NotificationsCubit>().reset());
|
||||
await _run(() => sl<NewMessagesCubit>().reset());
|
||||
await _run(() => sl<FeedCubit>().reset());
|
||||
await _run(() async => ProfileCubit.clearMyProfileReference());
|
||||
await _run(() => sl<VideoCallSessionCubit>().endCall());
|
||||
await _run(() async => AppMetrica.setUserProfileID(null));
|
||||
await _run(
|
||||
() => sl<Prefs>().remove(PrefsKeys.isEmailVerificationSkipped),
|
||||
);
|
||||
await _run(() => sl<Prefs>().remove(PrefsKeys.registrationDate));
|
||||
_authenticatedFingerprint = null;
|
||||
await _run(() async {
|
||||
final context = AppRouter.navigatorKey.currentContext;
|
||||
if (context == null) return;
|
||||
final path =
|
||||
AppRouter.appRouter.routeInformationProvider.value.uri.path;
|
||||
if (path != RouteConstants.authScreen) {
|
||||
context.go(RouteConstants.authScreen);
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
_endingSession = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> onAuthenticated() async {
|
||||
String? token;
|
||||
try {
|
||||
token = await TokenStorage.getTokenFromStorage();
|
||||
} catch (e, st) {
|
||||
await _report(e, st);
|
||||
return;
|
||||
}
|
||||
if (token == null || token.isEmpty) return;
|
||||
final fingerprint = token.hashCode;
|
||||
if (_authenticatedFingerprint == fingerprint) return;
|
||||
_authenticatedFingerprint = fingerprint;
|
||||
|
||||
await _run(() async {
|
||||
await _wsManager.clear();
|
||||
await _wsManager.initialize();
|
||||
});
|
||||
await _run(() => sl<ProfileAvatarCubit>().getMyProfile());
|
||||
await _run(() => sl<ChatsCubit>().getChatRoomsWithLoading());
|
||||
await _run(() => sl<NotificationsCubit>().getNotifications());
|
||||
await _run(() => sl<FirebaseApi>().registerPushToken());
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
try {
|
||||
await _run(() => sl<FirebaseApi>().unregisterPushTokens());
|
||||
await _run(
|
||||
() => sl<Dio>().post('${sl<AppConfig>().baseUrl}/api/profile/logout'),
|
||||
);
|
||||
} finally {
|
||||
await endSession(reason: SessionEndReason.logout);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _run(FutureOr<void> Function() action) async {
|
||||
try {
|
||||
await action();
|
||||
} catch (e, st) {
|
||||
await _report(e, st);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _report(Object error, StackTrace stackTrace) {
|
||||
return _errorReporter.report(
|
||||
error,
|
||||
stackTrace,
|
||||
origin: 'SessionLifecycleService',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,11 @@ import 'dart:ui';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
Future<void> showBottomSheetWithBlur({
|
||||
/// Показывает modal bottom sheet с blur-фоном.
|
||||
///
|
||||
/// Дженерик [T] пробрасывает значение, переданное в `Navigator.pop(T)`
|
||||
/// внутри шита (по умолчанию `null`).
|
||||
Future<T?> showBottomSheetWithBlur<T>({
|
||||
required BuildContext context,
|
||||
required Widget child,
|
||||
double? height = 200,
|
||||
@@ -11,7 +15,7 @@ Future<void> showBottomSheetWithBlur({
|
||||
bool enableDrag = true,
|
||||
bool userRootNavigator = false,
|
||||
}) {
|
||||
return showModalBottomSheet(
|
||||
return showModalBottomSheet<T>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
barrierColor: appTheme.transparentBlack,
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:developer';
|
||||
import 'package:eatvio_app/core/firebase_api.dart';
|
||||
import 'package:eatvio_app/core/prefs.dart';
|
||||
import 'package:eatvio_app/core/prefs_keys.dart';
|
||||
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
|
||||
import 'package:eatvio_app/core/token_storage.dart';
|
||||
import 'package:eatvio_app/core/utils/toasts.dart';
|
||||
import 'package:eatvio_app/features/auth/presentation/bloc/auth_cubit/auth_state.dart';
|
||||
@@ -17,15 +18,26 @@ class AuthCubit extends Cubit<AuthState> {
|
||||
final ProfileRepository profileRepository;
|
||||
final FirebaseApi firebaseApi;
|
||||
final Prefs prefs;
|
||||
final SessionLifecycleService sessionLifecycleService;
|
||||
|
||||
AuthCubit({required this.authRepository, required this.prefs, required this.profileRepository, required this.firebaseApi}) : super(const AuthState());
|
||||
AuthCubit({
|
||||
required this.authRepository,
|
||||
required this.prefs,
|
||||
required this.profileRepository,
|
||||
required this.firebaseApi,
|
||||
required this.sessionLifecycleService,
|
||||
}) : super(const AuthState());
|
||||
|
||||
Future<bool> getTokenByCredentials(String email, String password) async {
|
||||
emit(const AuthState.loading());
|
||||
try {
|
||||
final token = (await authRepository.getTokenByCredentials(email, password)).token;
|
||||
final token = (await authRepository.getTokenByCredentials(
|
||||
email,
|
||||
password,
|
||||
)).token;
|
||||
emit(AuthState.loaded(token: token));
|
||||
await TokenStorage.saveTokenInStorage(token);
|
||||
await sessionLifecycleService.onAuthenticated();
|
||||
return true;
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
@@ -48,7 +60,8 @@ class AuthCubit extends Cubit<AuthState> {
|
||||
|
||||
Future<void> registerAnonymousPushToken() async {
|
||||
try {
|
||||
final hasAnonymousTokenBeenSent = prefs.getBool(PrefsKeys.hasAnonymousTokenBeenSent) ?? false;
|
||||
final hasAnonymousTokenBeenSent =
|
||||
prefs.getBool(PrefsKeys.hasAnonymousTokenBeenSent) ?? false;
|
||||
if (!hasAnonymousTokenBeenSent) {
|
||||
await firebaseApi.registerAnonymousPushToken();
|
||||
prefs.setBool(PrefsKeys.hasAnonymousTokenBeenSent, true);
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/firebase_api.dart';
|
||||
import 'package:eatvio_app/core/mixins/registration_date_cache_mixin.dart';
|
||||
import 'package:eatvio_app/core/prefs.dart';
|
||||
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
|
||||
import 'package:eatvio_app/core/token_storage.dart';
|
||||
import 'package:eatvio_app/features/auth/models/oauth_verification_request_response.dart';
|
||||
import 'package:eatvio_app/features/auth/presentation/bloc/oauth_cubit/oauth_state.dart';
|
||||
@@ -13,10 +14,12 @@ import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||
class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
|
||||
final AuthRepository authRepository;
|
||||
final FirebaseApi firebaseApi;
|
||||
final SessionLifecycleService sessionLifecycleService;
|
||||
|
||||
OAuthCubit({
|
||||
required this.authRepository,
|
||||
required this.firebaseApi,
|
||||
required this.sessionLifecycleService,
|
||||
required Prefs prefs,
|
||||
}) : super(const OAuthState()) {
|
||||
this.prefs = prefs;
|
||||
@@ -40,6 +43,7 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
|
||||
|
||||
log('OAuthCubit: Got token model: ${tokenModel.token}');
|
||||
await TokenStorage.saveTokenInStorage(tokenModel.token);
|
||||
await sessionLifecycleService.onAuthenticated();
|
||||
emit(OAuthState.loaded(token: tokenModel.token));
|
||||
} on DioException catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
@@ -122,7 +126,7 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
|
||||
clientId: clientId,
|
||||
);
|
||||
await TokenStorage.saveTokenInStorage(token);
|
||||
await firebaseApi.registerPushToken();
|
||||
await sessionLifecycleService.onAuthenticated();
|
||||
cacheRegistrationDate(DateTime.now());
|
||||
emit(OAuthState.loaded(token: token));
|
||||
} on DioException catch (e, st) {
|
||||
@@ -241,7 +245,7 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
|
||||
'OAuthCubit: OAuth verification confirmed successfully, token received',
|
||||
);
|
||||
await TokenStorage.saveTokenInStorage(response.token);
|
||||
await firebaseApi.registerPushToken();
|
||||
await sessionLifecycleService.onAuthenticated();
|
||||
emit(OAuthState.loaded(token: response.token));
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
|
||||
@@ -4,12 +4,10 @@ import 'package:eatvio_app/app_keys.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/app_router.dart';
|
||||
import 'package:eatvio_app/core/const.dart';
|
||||
import 'package:eatvio_app/core/firebase_api.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/core/utils/image_constant.dart';
|
||||
import 'package:eatvio_app/core/utils/toasts.dart';
|
||||
import 'package:eatvio_app/core/websockets/ws_manager.dart';
|
||||
import 'package:eatvio_app/core/widgets/custom_divider.dart';
|
||||
import 'package:eatvio_app/core/widgets/custom_text_form_field.dart';
|
||||
import 'package:eatvio_app/features/auth/presentation/bloc/auth_cubit/auth_cubit.dart';
|
||||
@@ -18,8 +16,6 @@ import 'package:eatvio_app/features/auth/presentation/bloc/oauth_cubit/oauth_cub
|
||||
import 'package:eatvio_app/features/auth/presentation/bloc/oauth_cubit/oauth_state.dart';
|
||||
import 'package:eatvio_app/features/auth/entities/auth_result_entity.dart';
|
||||
import 'package:eatvio_app/core/di/cubit_registrations.dart';
|
||||
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
|
||||
import 'package:eatvio_app/features/dev_tools/presentation/dev_tools_screen.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
@@ -68,23 +64,8 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _reconnectWebSocket(BuildContext context) async {
|
||||
try {
|
||||
await sl<WsManager>().clear();
|
||||
await sl<WsManager>().initialize();
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
if (context.mounted) {
|
||||
try {
|
||||
await sl<ProfileAvatarCubit>().getMyProfile();
|
||||
await sl<ChatsCubit>().getChatRoomsWithLoading();
|
||||
await sl<FirebaseApi>().registerPushToken();
|
||||
showSuccessToast('Успешный вход');
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
Future<void> _reconnectWebSocket() async {
|
||||
showSuccessToast('Успешный вход');
|
||||
AppRouter.navigatorKey.currentContext?.go('/feed-screen', extra: {'needToInitializeWebSocket': false});
|
||||
}
|
||||
|
||||
@@ -202,18 +183,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
}
|
||||
|
||||
Future<void> _reconnectAfterAuth() async {
|
||||
final context = AppRouter.navigatorKey.currentContext;
|
||||
if (context == null) return;
|
||||
try {
|
||||
await sl<WsManager>().clear();
|
||||
await sl<WsManager>().initialize();
|
||||
await sl<ProfileAvatarCubit>().getMyProfile();
|
||||
await sl<ChatsCubit>().getChatRoomsWithLoading();
|
||||
await sl<FirebaseApi>().registerPushToken();
|
||||
showSuccessToast('Успешный вход');
|
||||
} catch (e) {
|
||||
log(e.toString());
|
||||
}
|
||||
showSuccessToast('Успешный вход');
|
||||
AppRouter.navigatorKey.currentContext?.go('/feed-screen', extra: {'needToInitializeWebSocket': false});
|
||||
}
|
||||
|
||||
@@ -395,7 +365,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
final isAuthenticated = await authCubit.getTokenByCredentials(emailController.text, passwordController.text);
|
||||
if (isAuthenticated) {
|
||||
if (context.mounted) {
|
||||
await _reconnectWebSocket(context);
|
||||
await _reconnectWebSocket();
|
||||
}
|
||||
}
|
||||
setState(() {
|
||||
|
||||
@@ -9,11 +9,7 @@ import 'package:eatvio_app/features/auth/presentation/bloc/oauth_cubit/oauth_sta
|
||||
import 'package:eatvio_app/features/auth/entities/auth_result_entity.dart';
|
||||
import 'package:eatvio_app/core/app_router.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/websockets/ws_manager.dart';
|
||||
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
|
||||
import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart';
|
||||
import 'package:eatvio_app/core/firebase_api.dart';
|
||||
import 'package:eatvio_app/core/utils/toasts.dart';
|
||||
import 'package:eatvio_app/core/utils/validation_utils.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
@@ -62,23 +58,8 @@ class _RegisterViaSocialScreenState extends State<RegisterViaSocialScreen> {
|
||||
_initializeFieldsFromAuthResult();
|
||||
}
|
||||
|
||||
Future<void> _reconnectWebSocket(BuildContext context) async {
|
||||
try {
|
||||
await sl<WsManager>().clear();
|
||||
await sl<WsManager>().initialize();
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
if (context.mounted) {
|
||||
try {
|
||||
await sl<ProfileAvatarCubit>().getMyProfile();
|
||||
await sl<ChatsCubit>().getChatRoomsWithLoading();
|
||||
await sl<FirebaseApi>().registerPushToken();
|
||||
showSuccessToast('Успешная регистрация');
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
Future<void> _reconnectWebSocket() async {
|
||||
showSuccessToast('Успешная регистрация');
|
||||
AppRouter.appRouter.go('/feed-screen', extra: {'needToInitializeWebSocket': false});
|
||||
}
|
||||
|
||||
@@ -451,7 +432,7 @@ class _RegisterViaSocialScreenState extends State<RegisterViaSocialScreen> {
|
||||
Builder(
|
||||
builder: (context) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_reconnectWebSocket(context);
|
||||
_reconnectWebSocket();
|
||||
});
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
@@ -795,7 +776,7 @@ class _RegisterViaSocialScreenState extends State<RegisterViaSocialScreen> {
|
||||
|
||||
// Перезагружаем веб-сокет и переходим на главный экран
|
||||
if (widgetContext.mounted) {
|
||||
_reconnectWebSocket(widgetContext);
|
||||
_reconnectWebSocket();
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
log('OAuth Verification Confirm Error: $e', error: e, stackTrace: stackTrace);
|
||||
|
||||
@@ -3,13 +3,10 @@ import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/app_router.dart';
|
||||
import 'package:eatvio_app/core/firebase_api.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
|
||||
import 'package:eatvio_app/core/token_storage.dart';
|
||||
import 'package:eatvio_app/core/utils/toasts.dart';
|
||||
import 'package:eatvio_app/core/websockets/ws_manager.dart';
|
||||
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:qr_code_scanner_plus/qr_code_scanner_plus.dart';
|
||||
@@ -39,7 +36,10 @@ class _ScanQRCodeScreenState extends State<ScanQRCodeScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Сканирование QR-кода'), centerTitle: true),
|
||||
appBar: AppBar(
|
||||
title: const Text('Сканирование QR-кода'),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: Column(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
@@ -60,30 +60,33 @@ class _ScanQRCodeScreenState extends State<ScanQRCodeScreen> {
|
||||
try {
|
||||
final scannedUrl = scanData.code;
|
||||
final uri = Uri.parse(scannedUrl ?? '');
|
||||
final scannedBaseUrl = (uri.hasAuthority) ? '${uri.scheme}://${uri.host}' : '';
|
||||
final scannedBaseUrl = (uri.hasAuthority)
|
||||
? '${uri.scheme}://${uri.host}'
|
||||
: '';
|
||||
sl<AppConfig>().setBaseUrl(scannedBaseUrl);
|
||||
sl<Dio>().options.baseUrl = scannedBaseUrl;
|
||||
final response = await Dio().getUri(uri, options: Options(headers: {'accept': 'application/json', 'content-type': 'application/json'}));
|
||||
final response = await Dio().getUri(
|
||||
uri,
|
||||
options: Options(
|
||||
headers: {
|
||||
'accept': 'application/json',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
),
|
||||
);
|
||||
final token = response.data['token'];
|
||||
await TokenStorage.saveTokenInStorage(token);
|
||||
try {
|
||||
await sl<WsManager>().clear();
|
||||
await sl<WsManager>().initialize();
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
try {
|
||||
await sl<ProfileAvatarCubit>().getMyProfile();
|
||||
await sl<ChatsCubit>().getChatRoomsWithLoading();
|
||||
await sl<FirebaseApi>().registerPushToken();
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
AppRouter.navigatorKey.currentContext?.go('/feed-screen', extra: {'needToInitializeWebSocket': false});
|
||||
await sl<SessionLifecycleService>().onAuthenticated();
|
||||
AppRouter.navigatorKey.currentContext?.go(
|
||||
'/feed-screen',
|
||||
extra: {'needToInitializeWebSocket': false},
|
||||
);
|
||||
showSuccessToast('Добро пожаловать!');
|
||||
} catch (e) {
|
||||
await controller.resumeCamera();
|
||||
showErrorToast('Что-то пошло не так. Попробуйте обновить страницу с QR-кодом или войдите с помощью логина и пароля.');
|
||||
showErrorToast(
|
||||
'Что-то пошло не так. Попробуйте обновить страницу с QR-кодом или войдите с помощью логина и пароля.',
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,13 @@ class NewMessagesCubit extends Cubit<NewMessagesState> {
|
||||
|
||||
NewMessagesCubit() : super(const NewMessagesState.empty());
|
||||
|
||||
void reset() {
|
||||
_messagesCount = 0;
|
||||
if (!isClosed) {
|
||||
emit(const NewMessagesState.empty());
|
||||
}
|
||||
}
|
||||
|
||||
void incrementCounter() {
|
||||
_messagesCount++;
|
||||
emit(NewMessagesState.loaded(messagesCount: _messagesCount));
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:appmetrica_plugin/appmetrica_plugin.dart';
|
||||
import 'package:eatvio_app/core/app_router.dart';
|
||||
import 'package:eatvio_app/core/token_storage.dart';
|
||||
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_state.dart';
|
||||
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
class ProfileAvatarCubit extends Cubit<ProfileAvatarState> {
|
||||
final ProfileRepository profileRepository;
|
||||
|
||||
ProfileAvatarCubit({required this.profileRepository}) : super(const ProfileAvatarState.loading());
|
||||
ProfileAvatarCubit({required this.profileRepository})
|
||||
: super(const ProfileAvatarState.loading());
|
||||
|
||||
int _requestId = 0;
|
||||
int? _sessionFingerprint;
|
||||
bool _ensuring = false;
|
||||
|
||||
int _calculateAge(String birthdate) {
|
||||
try {
|
||||
@@ -25,7 +29,8 @@ class ProfileAvatarCubit extends Cubit<ProfileAvatarState> {
|
||||
final now = DateTime.now();
|
||||
int age = now.year - birthYear;
|
||||
|
||||
if (now.month < birthMonth || (now.month == birthMonth && now.day < birthDay)) {
|
||||
if (now.month < birthMonth ||
|
||||
(now.month == birthMonth && now.day < birthDay)) {
|
||||
age--;
|
||||
}
|
||||
|
||||
@@ -38,30 +43,82 @@ class ProfileAvatarCubit extends Cubit<ProfileAvatarState> {
|
||||
return 0;
|
||||
}
|
||||
|
||||
void reset() {
|
||||
_requestId++;
|
||||
_sessionFingerprint = null;
|
||||
unawaited(AppMetrica.setUserProfileID(null).catchError((Object _) {}));
|
||||
if (!isClosed) emit(const ProfileAvatarState.loading());
|
||||
}
|
||||
|
||||
Future<void> ensureFreshForCurrentSession() async {
|
||||
final token = await TokenStorage.getTokenFromStorage();
|
||||
if (token == null || token.isEmpty) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
|
||||
final fingerprint = token.hashCode;
|
||||
if (_sessionFingerprint == fingerprint &&
|
||||
state is ProfileAvatarStateLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_ensuring) {
|
||||
return;
|
||||
}
|
||||
|
||||
_ensuring = true;
|
||||
try {
|
||||
await getMyProfile();
|
||||
} finally {
|
||||
_ensuring = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> getMyProfile() async {
|
||||
final requestId = ++_requestId;
|
||||
final token = await TokenStorage.getTokenFromStorage();
|
||||
final fingerprint = token?.hashCode;
|
||||
if (isClosed || requestId != _requestId) return;
|
||||
|
||||
emit(const ProfileAvatarState.loading());
|
||||
try {
|
||||
final profile = await profileRepository.getMyProfile();
|
||||
if (isClosed || requestId != _requestId) return;
|
||||
|
||||
final profileDetails = await profileRepository.getMyProfileDetails();
|
||||
if (isClosed || requestId != _requestId) return;
|
||||
|
||||
_sessionFingerprint = fingerprint;
|
||||
emit(ProfileAvatarState.loaded(profile: profile));
|
||||
|
||||
try {
|
||||
final notificationStatus = await Permission.notification.status;
|
||||
if (isClosed || requestId != _requestId) return;
|
||||
final isNotificationEnabled = notificationStatus.isGranted;
|
||||
AppMetricaUserProfile userProfile = AppMetricaUserProfile([
|
||||
AppMetricaNameAttribute.withValue(profile.name),
|
||||
AppMetricaGenderAttribute.withValue(profileDetails.gender == 'male' ? AppMetricaGender.male : AppMetricaGender.female),
|
||||
AppMetricaBirthDateAttribute.withAge(_calculateAge(profileDetails.birthdate)),
|
||||
AppMetricaNotificationEnabledAttribute.withValue(isNotificationEnabled),
|
||||
AppMetricaGenderAttribute.withValue(
|
||||
profileDetails.gender == 'male'
|
||||
? AppMetricaGender.male
|
||||
: AppMetricaGender.female,
|
||||
),
|
||||
AppMetricaBirthDateAttribute.withAge(
|
||||
_calculateAge(profileDetails.birthdate),
|
||||
),
|
||||
AppMetricaNotificationEnabledAttribute.withValue(
|
||||
isNotificationEnabled,
|
||||
),
|
||||
]);
|
||||
AppMetrica.setUserProfileID(profile.slug);
|
||||
AppMetrica.reportUserProfile(userProfile);
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
// Игнорируем ошибки плагинов, профиль уже загружен
|
||||
}
|
||||
} catch (e, st) {
|
||||
if (isClosed || requestId != _requestId) return;
|
||||
await reportFrom(e, st, this);
|
||||
AppRouter.navigatorKey.currentContext?.go('/auth-screen');
|
||||
if (isClosed || requestId != _requestId) return;
|
||||
emit(ProfileAvatarState.error(errorMessage: e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,8 @@ class BottomNavigationBarWidget extends StatefulWidget {
|
||||
class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
int currentBottomNavBarIndex = 1;
|
||||
List<String>? pages;
|
||||
int? _pagesProfileId;
|
||||
bool? _pagesIsConsultant;
|
||||
int tapsCount = 0;
|
||||
|
||||
List<String> _getPages(bool isConsultant) {
|
||||
@@ -179,7 +181,16 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
final isConsultant = profileModel.roles.contains('consultant');
|
||||
|
||||
// Инициализируем pages на основе profileModel
|
||||
pages ??= _getPages(isConsultant);
|
||||
if (pages == null ||
|
||||
_pagesProfileId != profileModel.id ||
|
||||
_pagesIsConsultant != isConsultant) {
|
||||
_pagesProfileId = profileModel.id;
|
||||
_pagesIsConsultant = isConsultant;
|
||||
pages = _getPages(isConsultant);
|
||||
if (currentBottomNavBarIndex >= pages!.length) {
|
||||
currentBottomNavBarIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Expose tab switching to descendants (e.g. notification widget)
|
||||
final controller = BottomNavScope.maybeOf(context);
|
||||
|
||||
@@ -57,6 +57,17 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
int page = 1;
|
||||
late int lastPage;
|
||||
|
||||
/// Clears all user-scoped state. WS subscriptions stay intact so that
|
||||
/// `getChatRoomsWithLoading` can re-load the new user's chats.
|
||||
void reset() {
|
||||
_query = null;
|
||||
page = 1;
|
||||
lastPage = 1;
|
||||
if (!isClosed) {
|
||||
emit(ChatsState());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> connectToWebSocket() async {
|
||||
try {
|
||||
await newIncomingMessagesSubscription?.cancel();
|
||||
|
||||
@@ -135,9 +135,9 @@ class _EmptyChatScreenState extends State<EmptyChatScreen> {
|
||||
textEditingController.clear();
|
||||
});
|
||||
}
|
||||
final chatIdOrError = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
|
||||
if (context.mounted) {
|
||||
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatIdOrError});
|
||||
final chatId = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
|
||||
if (chatId != null && chatId.isNotEmpty && context.mounted) {
|
||||
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatId});
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -189,9 +189,9 @@ class _EmptyChatScreenState extends State<EmptyChatScreen> {
|
||||
if (images.isNotEmpty) {
|
||||
List<File> files = images.map((image) => File(image.path)).toList();
|
||||
await sl<ChatRepository>().sendMessage(files: files, slug: widget.user.slug);
|
||||
final chatIdOrError = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
|
||||
if (mounted) {
|
||||
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatIdOrError});
|
||||
final chatId = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
|
||||
if (chatId != null && chatId.isNotEmpty && mounted) {
|
||||
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatId});
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -205,9 +205,9 @@ class _EmptyChatScreenState extends State<EmptyChatScreen> {
|
||||
if (images.isNotEmpty) {
|
||||
List<File> files = images.map((image) => File(image.path)).toList();
|
||||
await sl<ChatRepository>().sendMessage(files: files, slug: widget.user.slug);
|
||||
final chatIdOrError = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
|
||||
if (mounted) {
|
||||
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatIdOrError});
|
||||
final chatId = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
|
||||
if (chatId != null && chatId.isNotEmpty && mounted) {
|
||||
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatId});
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -243,9 +243,9 @@ class _EmptyChatScreenState extends State<EmptyChatScreen> {
|
||||
if (result != null) {
|
||||
List<File> files = result.paths.map((path) => File(path!)).toList();
|
||||
await sl<ChatRepository>().sendMessage(files: files, slug: widget.user.slug);
|
||||
final chatIdOrError = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
|
||||
if (mounted) {
|
||||
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatIdOrError});
|
||||
final chatId = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
|
||||
if (chatId != null && chatId.isNotEmpty && mounted) {
|
||||
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatId});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -106,7 +106,11 @@ class _NotificationBannerState extends State<NotificationBanner> {
|
||||
onTap: () {
|
||||
final planId = (context.read<ChatRoomCubit>().state as ChatRoomStateLoaded).chatRoom.chosenPlan?.id;
|
||||
if (planId != null) {
|
||||
showConsultationPaymentFlow(context, planId: planId);
|
||||
showConsultationPaymentFlow(
|
||||
context,
|
||||
planId: planId,
|
||||
amount: widget.planPrice,
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Text(s.pay, style: TextStyles.textLSemibold.copyWith(color: appTheme.blueA400)),
|
||||
|
||||
@@ -137,11 +137,12 @@ class ChatRepository {
|
||||
|
||||
}
|
||||
|
||||
Future<String> getChatByUserSlug(String slug) async {
|
||||
Future<String?> getChatByUserSlug(String slug) async {
|
||||
final response = await chatService.getChatByUserSlug(slug: slug);
|
||||
if (response.response.statusCode == 404) {
|
||||
return '404';
|
||||
final data = response.data;
|
||||
if (data is Map && data['id'] is String && (data['id'] as String).isNotEmpty) {
|
||||
return data['id'] as String;
|
||||
}
|
||||
return response.data['id'];
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ class BecomeConsultantScreen extends StatefulWidget {
|
||||
class _BecomeConsultantScreenState extends State<BecomeConsultantScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bottomInset = MediaQuery.paddingOf(context).bottom;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Стать консультантом'),
|
||||
@@ -24,14 +26,18 @@ class _BecomeConsultantScreenState extends State<BecomeConsultantScreen> {
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
),
|
||||
body: const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Text(
|
||||
body: SingleChildScrollView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
padding: EdgeInsets.fromLTRB(16, 16, 16, 16 + 52 + 8 + bottomInset),
|
||||
child: const Text(
|
||||
'Чтобы стать консультантом необходимо пройти короткую регистрацию. Подготовьте свои дипломы и/или сертификаты в формате jpg, они Вам пригодятся для проверки и подтверждения. Также необходимо будет указать ссылки на Ваши соц. сети и указать тип лица (физ. лицо или юр. лицо). На данный момент доступна регистрация в качестве физ. лица.\n\nВнутри Итвио разница между обычным пользователем и консультантом заключается в дополнительных возможностях последнего создавать публичные диеты, рецепты и планы питания; участвовать в ежемесячных рейтингах.',
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
bottomSheet: _startRegistrationButton(),
|
||||
bottomSheet: Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 0, 16, 8 + bottomInset),
|
||||
child: _startRegistrationButton(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,7 +50,7 @@ class _BecomeConsultantScreenState extends State<BecomeConsultantScreen> {
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Ink(
|
||||
height: 52,
|
||||
width: MediaQuery.of(context).size.width - 32,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
color: appTheme.blueA400,
|
||||
|
||||
@@ -9,6 +9,7 @@ part 'consultation_model.g.dart';
|
||||
abstract class ConsultationModel with _$ConsultationModel {
|
||||
factory ConsultationModel({
|
||||
required int id,
|
||||
@JsonKey(name: 'chat_id') String? chatId,
|
||||
@JsonKey(name: 'last_state') required ChosenPlanStateEnum chosenPlanState,
|
||||
@JsonKey(name: 'plan_title') required String planTitle,
|
||||
@JsonKey(name: 'user_name') required String userName,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:eatvio_app/core/enums/base_state.dart';
|
||||
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
|
||||
import 'package:eatvio_app/features/consultations/presentation/cubit/consultations_state.dart';
|
||||
import 'package:eatvio_app/features/consultations/repositories/consultations_repository.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -8,7 +9,8 @@ import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||
|
||||
class ConsultationsCubit extends Cubit<ConsultationsState> {
|
||||
final ConsultationsRepository consultationsRepository;
|
||||
ConsultationsCubit({required this.consultationsRepository}) : super(ConsultationsState());
|
||||
final ChatRepository chatRepository;
|
||||
ConsultationsCubit({required this.consultationsRepository, required this.chatRepository}) : super(ConsultationsState());
|
||||
|
||||
Future<void> loadConsultations() async {
|
||||
try {
|
||||
@@ -20,4 +22,14 @@ class ConsultationsCubit extends Cubit<ConsultationsState> {
|
||||
emit(state.copyWith(state: BaseState.error));
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> getChatIdByUserSlug(String slug) async {
|
||||
try {
|
||||
return await chatRepository.getChatByUserSlug(slug);
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString(), stackTrace: st);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,17 @@ import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/core/widgets/custom_divider.dart';
|
||||
import 'package:eatvio_app/core/widgets/custom_outlined_button.dart';
|
||||
import 'package:eatvio_app/core/utils/toasts.dart';
|
||||
import 'package:eatvio_app/core/widgets/show_error_dialog.dart';
|
||||
import 'package:eatvio_app/features/consultations/models/consultation_model.dart';
|
||||
import 'package:eatvio_app/features/consultations/presentation/cubit/consultations_cubit.dart';
|
||||
import 'package:eatvio_app/features/diary/presentation/pages/diary_screen.dart';
|
||||
import 'package:eatvio_app/features/my_finance/presentation/pages/card_settings.dart';
|
||||
import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart';
|
||||
import 'package:eatvio_app/features/user_details/presentation/user_details_screen.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class ConsultationCardWidget extends StatefulWidget {
|
||||
@@ -34,6 +37,7 @@ class _ConsultationCardWidgetState extends State<ConsultationCardWidget> {
|
||||
final List<Color> legendColors = const [Colors.white, Color(0xFFF0C497), Color(0xFFFF9500), Color(0xFF2F80ED), Color(0xFF4CD964)];
|
||||
|
||||
late final List<(int, DiaryModel)> givenDaysWithDayNumber;
|
||||
bool _isOpeningChat = false;
|
||||
final List<List<DiaryStateEnum>> legendStates = [
|
||||
[DiaryStateEnum.init],
|
||||
[DiaryStateEnum.started, DiaryStateEnum.lastDayStarted],
|
||||
@@ -172,7 +176,7 @@ class _ConsultationCardWidgetState extends State<ConsultationCardWidget> {
|
||||
);
|
||||
return;
|
||||
}
|
||||
GoRouter.of(context).push(UserDiaryScreen.path, extra: {'diaryId': day.$2.id,});
|
||||
GoRouter.of(context).push(UserDiaryScreen.path, extra: {'diaryId': day.$2.id, 'ownerSlug': widget.consultationsModel.userSlug});
|
||||
},
|
||||
borderRadius: BorderRadius.circular(100),
|
||||
child: Ink(
|
||||
@@ -255,7 +259,7 @@ class _ConsultationCardWidgetState extends State<ConsultationCardWidget> {
|
||||
.where((e) => e.$2.state == DiaryStateEnum.finished || e.$2.state == DiaryStateEnum.lastDayFinished)
|
||||
.lastOrNull;
|
||||
if (lastActiveDay != null) {
|
||||
GoRouter.of(context).push(UserDiaryScreen.path, extra: {'diaryId': lastActiveDay.$2.id,});
|
||||
GoRouter.of(context).push(UserDiaryScreen.path, extra: {'diaryId': lastActiveDay.$2.id, 'ownerSlug': widget.consultationsModel.userSlug});
|
||||
return;
|
||||
}
|
||||
showErrorDialog(
|
||||
@@ -279,7 +283,7 @@ class _ConsultationCardWidgetState extends State<ConsultationCardWidget> {
|
||||
const SizedBox(width: 8),
|
||||
InkWell(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: () {},
|
||||
onTap: _isOpeningChat ? null : _openChat,
|
||||
child: Ink(
|
||||
height: 52,
|
||||
width: 52,
|
||||
@@ -308,6 +312,22 @@ class _ConsultationCardWidgetState extends State<ConsultationCardWidget> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openChat() async {
|
||||
if (_isOpeningChat) return;
|
||||
setState(() => _isOpeningChat = true);
|
||||
var chatId = widget.consultationsModel.chatId;
|
||||
if (chatId == null || chatId.isEmpty) {
|
||||
chatId = await context.read<ConsultationsCubit>().getChatIdByUserSlug(widget.consultationsModel.userSlug);
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() => _isOpeningChat = false);
|
||||
if (chatId == null || chatId.isEmpty) {
|
||||
showErrorToast(S.of(context).msg_something_went_wrong);
|
||||
return;
|
||||
}
|
||||
GoRouter.of(context).pushNamed('chatScreen', extra: {'uuid': chatId});
|
||||
}
|
||||
|
||||
Row _legendTitle(String text, Color color) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
||||
@@ -20,6 +20,7 @@ import 'package:eatvio_app/features/diary/presentation/widgets/day_comment_widge
|
||||
import 'package:eatvio_app/features/diary/presentation/widgets/finish_day_button_widget.dart';
|
||||
import 'package:eatvio_app/features/diary/presentation/widgets/meal_widget.dart';
|
||||
import 'package:eatvio_app/features/diary/presentation/widgets/wakeup_and_retire_widget.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_card.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -36,18 +37,22 @@ class DiaryScreen extends StatelessWidget {
|
||||
class UserDiaryScreen extends StatelessWidget {
|
||||
static const path = '/user-diary-screen';
|
||||
static const name = 'userDiaryScreen';
|
||||
const UserDiaryScreen({super.key, required this.diaryId});
|
||||
const UserDiaryScreen({super.key, required this.diaryId, this.ownerSlug});
|
||||
final int diaryId;
|
||||
|
||||
/// Slug владельца дневника (клиента) — нужен консультанту для блока «Аналитика».
|
||||
final String? ownerSlug;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DiaryScreenContent(diaryId: diaryId);
|
||||
return DiaryScreenContent(diaryId: diaryId, ownerSlug: ownerSlug);
|
||||
}
|
||||
}
|
||||
|
||||
class DiaryScreenContent extends StatefulWidget {
|
||||
const DiaryScreenContent({super.key, this.diaryId, this.date});
|
||||
const DiaryScreenContent({super.key, this.diaryId, this.date, this.ownerSlug});
|
||||
final int? diaryId;
|
||||
final DateTime? date;
|
||||
final String? ownerSlug;
|
||||
@override
|
||||
State<DiaryScreenContent> createState() => _DiaryScreenContentState();
|
||||
}
|
||||
@@ -55,6 +60,11 @@ class DiaryScreenContent extends StatefulWidget {
|
||||
class _DiaryScreenContentState extends State<DiaryScreenContent> {
|
||||
bool _isDiaryFinishing = false;
|
||||
|
||||
/// Консультант видит аналитику клиента только при известном slug владельца.
|
||||
bool _canShowInsights(DiaryStateLoaded state) {
|
||||
return !state.isConsultantView || widget.ownerSlug != null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
@@ -110,6 +120,14 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
|
||||
const SizedBox(height: 60),
|
||||
const Loader(),
|
||||
],
|
||||
if (state is DiaryStateLoaded && _canShowInsights(state))
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: InsightsCard(
|
||||
slug: state.isConsultantView ? widget.ownerSlug : null,
|
||||
canAdd: !state.isConsultantView,
|
||||
),
|
||||
),
|
||||
if (state is DiaryStateLoaded)
|
||||
WakeupAndRetireWidget(
|
||||
isEnabled:
|
||||
|
||||
@@ -12,9 +12,19 @@ class FeedCubit extends Cubit<FeedState> {
|
||||
bool _hasMore = true;
|
||||
bool isLoading = false;
|
||||
String? _query;
|
||||
int _requestId = 0;
|
||||
|
||||
FeedCubit({required this.feedRepository}) : super(const FeedState.loading());
|
||||
|
||||
void reset() {
|
||||
_requestId++;
|
||||
_nextCursor = null;
|
||||
_hasMore = true;
|
||||
isLoading = false;
|
||||
_query = null;
|
||||
if (!isClosed) emit(const FeedState.loading());
|
||||
}
|
||||
|
||||
void updateFeedBlogPostList(FeedBlogPostModel? feedBlogPostModel, bool wasLikedByMe) {
|
||||
if (feedBlogPostModel == null || isClosed || state is! FeedStateLoaded) return;
|
||||
final List<FeedBlogPostModel> feedBlogPostList = [...(state as FeedStateLoaded).feedBlogPostList];
|
||||
@@ -39,17 +49,18 @@ class FeedCubit extends Cubit<FeedState> {
|
||||
}
|
||||
|
||||
Future<void> loadFeedBlogPostList({String? query, int? currentUserId}) async {
|
||||
final requestId = ++_requestId;
|
||||
try {
|
||||
_query = query;
|
||||
_nextCursor = null;
|
||||
_hasMore = true;
|
||||
final feed = await feedRepository.getFeed(search: query, currentUserId: currentUserId);
|
||||
if (isClosed || requestId != _requestId) return;
|
||||
_nextCursor = feed.nextCursor;
|
||||
_hasMore = _nextCursor != null;
|
||||
if (!isClosed) {
|
||||
emit(FeedState.loaded(feedBlogPostList: feed.posts, isLoading: false));
|
||||
}
|
||||
emit(FeedState.loaded(feedBlogPostList: feed.posts, isLoading: false));
|
||||
} catch (e, st) {
|
||||
if (isClosed || requestId != _requestId) return;
|
||||
await reportFrom(e, st, this);
|
||||
if (!isClosed) {
|
||||
emit(FeedState.error(errorMessage: e.toString()));
|
||||
@@ -62,14 +73,16 @@ class FeedCubit extends Cubit<FeedState> {
|
||||
return;
|
||||
}
|
||||
|
||||
final requestId = _requestId;
|
||||
isLoading = true;
|
||||
emit((state as FeedStateLoaded).copyWith(isLoading: true));
|
||||
|
||||
try {
|
||||
final feed = await feedRepository.getFeed(search: _query, cursor: _nextCursor, currentUserId: currentUserId);
|
||||
if (isClosed || requestId != _requestId) return;
|
||||
_nextCursor = feed.nextCursor;
|
||||
_hasMore = _nextCursor != null;
|
||||
if (!isClosed && state is FeedStateLoaded) {
|
||||
if (state is FeedStateLoaded) {
|
||||
emit(
|
||||
FeedState.loaded(
|
||||
feedBlogPostList: [...(state as FeedStateLoaded).feedBlogPostList, ...feed.posts],
|
||||
@@ -78,14 +91,15 @@ class FeedCubit extends Cubit<FeedState> {
|
||||
);
|
||||
}
|
||||
} catch (e, st) {
|
||||
if (isClosed || requestId != _requestId) return;
|
||||
await reportFrom(e, st, this);
|
||||
if (!isClosed && state is FeedStateLoaded) {
|
||||
if (state is FeedStateLoaded) {
|
||||
emit((state as FeedStateLoaded).copyWith(isLoading: false));
|
||||
} else if (!isClosed) {
|
||||
} else {
|
||||
emit(FeedState.error(errorMessage: e.toString()));
|
||||
}
|
||||
} finally {
|
||||
isLoading = false;
|
||||
if (requestId == _requestId) isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
222
lib/features/insights/domain/insights_text_builder.dart
Normal file
222
lib/features/insights/domain/insights_text_builder.dart
Normal file
@@ -0,0 +1,222 @@
|
||||
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
|
||||
import 'package:eatvio_app/features/insights/models/insights_period.dart';
|
||||
import 'package:eatvio_app/features/profile/models/circumference_model.dart';
|
||||
import 'package:eatvio_app/features/profile/models/weight_model.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
|
||||
/// Собирает [InsightsPayloadModel] из сырых точек мобильного API.
|
||||
///
|
||||
/// Зеркало веб-`UserInsightsService`: порог изменения [deltaEpsilon], минимум две
|
||||
/// точки для дельты, те же категории ИМТ и формат подписей.
|
||||
class InsightsTextBuilder {
|
||||
InsightsTextBuilder(this.s);
|
||||
|
||||
final S s;
|
||||
|
||||
static const double deltaEpsilon = 0.05;
|
||||
static const String _emptyHeadline = '—';
|
||||
|
||||
InsightsPayloadModel build({
|
||||
required List<WeightModel> weights,
|
||||
required List<CircumferenceModel> circumferences,
|
||||
required BodyPeriod period,
|
||||
int? heightCm,
|
||||
}) {
|
||||
final sortedWeights = [...weights]
|
||||
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
||||
final sortedCircumferences = [...circumferences]
|
||||
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
||||
|
||||
return InsightsPayloadModel(
|
||||
weight: buildWeight(sortedWeights, period),
|
||||
bmi: buildBmi(sortedWeights, period, heightCm),
|
||||
measure: buildMeasure(sortedCircumferences, period),
|
||||
);
|
||||
}
|
||||
|
||||
WeightChartModel buildWeight(List<WeightModel> weights, BodyPeriod period) {
|
||||
final values = weights.map((e) => e.weight).toList();
|
||||
final last = values.isEmpty ? null : values.last;
|
||||
final delta = values.length < 2 ? null : _round(values.last - values.first);
|
||||
|
||||
return WeightChartModel(
|
||||
labels: weights.map((e) => formatLabel(e.createdAt)).toList(),
|
||||
values: values,
|
||||
last: last,
|
||||
delta: delta,
|
||||
headline: last == null ? _emptyHeadline : last.toStringAsFixed(1),
|
||||
unit: s.insights_unit_kg,
|
||||
deltaText: _weightDeltaText(values, period),
|
||||
);
|
||||
}
|
||||
|
||||
BmiChartModel buildBmi(
|
||||
List<WeightModel> weights,
|
||||
BodyPeriod period,
|
||||
int? heightCm,
|
||||
) {
|
||||
final hasHeight = heightCm != null && heightCm > 0;
|
||||
if (!hasHeight) {
|
||||
return BmiChartModel(
|
||||
headline: _emptyHeadline,
|
||||
deltaText: s.insights_need_height,
|
||||
hint: s.insights_need_height,
|
||||
);
|
||||
}
|
||||
|
||||
final points = weights.where((e) => e.bmi != null).toList();
|
||||
final values = points.map((e) => e.bmi!).toList();
|
||||
final last = values.isEmpty ? null : values.last;
|
||||
final delta = values.length < 2 ? null : _round(values.last - values.first);
|
||||
final category = last == null ? null : bmiCategory(last);
|
||||
|
||||
return BmiChartModel(
|
||||
labels: points.map((e) => formatLabel(e.createdAt)).toList(),
|
||||
values: values,
|
||||
last: last,
|
||||
delta: delta,
|
||||
headline: last == null ? _emptyHeadline : last.toStringAsFixed(1),
|
||||
category: category,
|
||||
deltaText: _bmiDeltaText(values, period, category),
|
||||
hint: s.insights_bmi_hint(heightCm.toString()),
|
||||
);
|
||||
}
|
||||
|
||||
MeasureChartModel buildMeasure(
|
||||
List<CircumferenceModel> circumferences,
|
||||
BodyPeriod period,
|
||||
) {
|
||||
final waist = circumferences.map((e) => e.waistCircumference).toList();
|
||||
final chest = circumferences.map((e) => e.chestCircumference).toList();
|
||||
final hips = circumferences.map((e) => e.hipCircumference).toList();
|
||||
|
||||
return MeasureChartModel(
|
||||
labels: circumferences.map((e) => formatLabel(e.createdAt)).toList(),
|
||||
waist: waist,
|
||||
chest: chest,
|
||||
hips: hips,
|
||||
headline: waist.isEmpty ? _emptyHeadline : waist.last.round().toString(),
|
||||
deltaText: _measureDeltaText(
|
||||
waist: waist,
|
||||
chest: chest,
|
||||
hips: hips,
|
||||
period: period,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Категория по таблице ВОЗ из постановки #2120.
|
||||
String bmiCategory(double bmi) {
|
||||
if (bmi < 16) {
|
||||
return s.insights_bmi_severe_underweight;
|
||||
}
|
||||
if (bmi < 18.5) {
|
||||
return s.insights_bmi_underweight;
|
||||
}
|
||||
if (bmi < 25) {
|
||||
return s.insights_bmi_normal;
|
||||
}
|
||||
if (bmi < 30) {
|
||||
return s.insights_bmi_overweight;
|
||||
}
|
||||
if (bmi < 35) {
|
||||
return s.insights_bmi_obesity_1;
|
||||
}
|
||||
if (bmi < 40) {
|
||||
return s.insights_bmi_obesity_2;
|
||||
}
|
||||
return s.insights_bmi_obesity_3;
|
||||
}
|
||||
|
||||
String periodLabel(BodyPeriod period) {
|
||||
switch (period) {
|
||||
case BodyPeriod.month:
|
||||
return s.insights_period_label_1;
|
||||
case BodyPeriod.threeMonths:
|
||||
return s.insights_period_label_3;
|
||||
case BodyPeriod.sixMonths:
|
||||
return s.insights_period_label_6;
|
||||
case BodyPeriod.year:
|
||||
return s.insights_period_label_12;
|
||||
case BodyPeriod.all:
|
||||
return s.insights_period_label_all;
|
||||
}
|
||||
}
|
||||
|
||||
String formatLabel(DateTime date) {
|
||||
final local = date.toLocal();
|
||||
final day = local.day.toString().padLeft(2, '0');
|
||||
final month = local.month.toString().padLeft(2, '0');
|
||||
return '$day.$month';
|
||||
}
|
||||
|
||||
/// Одна цифра после запятой без хвостового нуля: `3`, `3.5`.
|
||||
String formatNumber(double value) {
|
||||
final text = value.toStringAsFixed(1);
|
||||
return text.endsWith('.0') ? text.substring(0, text.length - 2) : text;
|
||||
}
|
||||
|
||||
String _weightDeltaText(List<double> values, BodyPeriod period) {
|
||||
if (values.length < 2) {
|
||||
return s.insights_not_enough;
|
||||
}
|
||||
final diff = values.last - values.first;
|
||||
final periodText = periodLabel(period);
|
||||
if (diff.abs() < deltaEpsilon) {
|
||||
return s.insights_weight_same(periodText);
|
||||
}
|
||||
final value = formatNumber(diff.abs());
|
||||
return diff < 0
|
||||
? s.insights_weight_lost(value, periodText)
|
||||
: s.insights_weight_gained(value, periodText);
|
||||
}
|
||||
|
||||
String _bmiDeltaText(
|
||||
List<double> values,
|
||||
BodyPeriod period,
|
||||
String? category,
|
||||
) {
|
||||
if (values.length < 2 || category == null) {
|
||||
return s.insights_not_enough;
|
||||
}
|
||||
final diff = values.last - values.first;
|
||||
final periodText = periodLabel(period);
|
||||
if (diff.abs() < deltaEpsilon) {
|
||||
return s.insights_bmi_same(periodText, category);
|
||||
}
|
||||
final value = formatNumber(diff.abs());
|
||||
return diff < 0
|
||||
? s.insights_bmi_lower(value, periodText, category)
|
||||
: s.insights_bmi_higher(value, periodText, category);
|
||||
}
|
||||
|
||||
String _measureDeltaText({
|
||||
required List<double> waist,
|
||||
required List<double> chest,
|
||||
required List<double> hips,
|
||||
required BodyPeriod period,
|
||||
}) {
|
||||
if (waist.length < 2) {
|
||||
return s.insights_not_enough;
|
||||
}
|
||||
final parts = [
|
||||
_measurePart(s.insights_series_waist, waist),
|
||||
_measurePart(s.insights_series_chest, chest),
|
||||
_measurePart(s.insights_series_hips, hips),
|
||||
];
|
||||
return s.insights_measures_summary(parts.join(', '), periodLabel(period));
|
||||
}
|
||||
|
||||
String _measurePart(String name, List<double> values) {
|
||||
final diff = values.last - values.first;
|
||||
if (diff.abs() < deltaEpsilon) {
|
||||
return s.insights_measure_same(name);
|
||||
}
|
||||
final value = formatNumber(diff.abs());
|
||||
return diff < 0
|
||||
? s.insights_measure_less(name, value)
|
||||
: s.insights_measure_more(name, value);
|
||||
}
|
||||
|
||||
double _round(double value) => (value * 10).round() / 10;
|
||||
}
|
||||
6
lib/features/insights/insights_feature_flags.dart
Normal file
6
lib/features/insights/insights_feature_flags.dart
Normal file
@@ -0,0 +1,6 @@
|
||||
/// Флаги поэтапного включения аналитики.
|
||||
abstract final class InsightsFeatureFlags {
|
||||
/// Вкладки «КБЖУ по дням» и «В течение дня» требуют бэкенд-эндпоинта
|
||||
/// `GET /api/profile/insights` и включаются во второй итерации.
|
||||
static const bool kbjuEnabled = false;
|
||||
}
|
||||
132
lib/features/insights/models/insights_payload_model.dart
Normal file
132
lib/features/insights/models/insights_payload_model.dart
Normal file
@@ -0,0 +1,132 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'insights_payload_model.freezed.dart';
|
||||
part 'insights_payload_model.g.dart';
|
||||
|
||||
/// Структура повторяет веб-`UserInsightsService::chartPayload()`, чтобы во второй
|
||||
/// итерации заменить только источник данных.
|
||||
@freezed
|
||||
abstract class InsightsPayloadModel with _$InsightsPayloadModel {
|
||||
const factory InsightsPayloadModel({
|
||||
required WeightChartModel weight,
|
||||
required BmiChartModel bmi,
|
||||
required MeasureChartModel measure,
|
||||
DailyChartModel? daily,
|
||||
IntraChartModel? intra,
|
||||
}) = _InsightsPayloadModel;
|
||||
|
||||
factory InsightsPayloadModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$InsightsPayloadModelFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class WeightChartModel with _$WeightChartModel {
|
||||
const factory WeightChartModel({
|
||||
@Default([]) List<String> labels,
|
||||
@Default([]) List<double> values,
|
||||
double? last,
|
||||
double? delta,
|
||||
@Default('') String headline,
|
||||
@Default('') String unit,
|
||||
@Default('') String deltaText,
|
||||
}) = _WeightChartModel;
|
||||
|
||||
factory WeightChartModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$WeightChartModelFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class BmiChartModel with _$BmiChartModel {
|
||||
const factory BmiChartModel({
|
||||
@Default([]) List<String> labels,
|
||||
@Default([]) List<double> values,
|
||||
double? last,
|
||||
double? delta,
|
||||
@Default('') String headline,
|
||||
String? category,
|
||||
@Default('') String deltaText,
|
||||
@Default('') String hint,
|
||||
}) = _BmiChartModel;
|
||||
|
||||
factory BmiChartModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$BmiChartModelFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class MeasureChartModel with _$MeasureChartModel {
|
||||
const factory MeasureChartModel({
|
||||
@Default([]) List<String> labels,
|
||||
@Default([]) List<double> waist,
|
||||
@Default([]) List<double> chest,
|
||||
@Default([]) List<double> hips,
|
||||
@Default('') String headline,
|
||||
@Default('') String deltaText,
|
||||
}) = _MeasureChartModel;
|
||||
|
||||
factory MeasureChartModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$MeasureChartModelFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class DailyChartModel with _$DailyChartModel {
|
||||
const factory DailyChartModel({
|
||||
@Default([]) List<String> labels,
|
||||
@Default([]) List<double> kcal,
|
||||
@Default([]) List<double> proteins,
|
||||
@Default([]) List<double> fats,
|
||||
@Default([]) List<double> carbs,
|
||||
double? average,
|
||||
double? percent,
|
||||
@Default('') String headline,
|
||||
@Default('') String deltaText,
|
||||
}) = _DailyChartModel;
|
||||
|
||||
factory DailyChartModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$DailyChartModelFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class IntraChartModel with _$IntraChartModel {
|
||||
const factory IntraChartModel({
|
||||
@Default([]) List<IntraBarModel> bars,
|
||||
@Default([]) List<ScalePointModel> satiety,
|
||||
@Default([]) List<ScalePointModel> mood,
|
||||
double? wake,
|
||||
double? sleep,
|
||||
String? wakeLabel,
|
||||
String? sleepLabel,
|
||||
String? wakeText,
|
||||
String? sleepText,
|
||||
@Default('') String date,
|
||||
@Default('') String maxDate,
|
||||
@Default('') String dateLabel,
|
||||
@Default(false) bool isToday,
|
||||
}) = _IntraChartModel;
|
||||
|
||||
factory IntraChartModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$IntraChartModelFromJson(json);
|
||||
}
|
||||
|
||||
/// Приём пищи: время `x` (часы 0–24) и калории из белков/жиров/углеводов.
|
||||
@freezed
|
||||
abstract class IntraBarModel with _$IntraBarModel {
|
||||
const factory IntraBarModel({
|
||||
required double x,
|
||||
@Default(0) double p,
|
||||
@Default(0) double f,
|
||||
@Default(0) double c,
|
||||
}) = _IntraBarModel;
|
||||
|
||||
factory IntraBarModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$IntraBarModelFromJson(json);
|
||||
}
|
||||
|
||||
/// Точка шкалы 0–5 (сытость/настроение) в момент времени `x`.
|
||||
@freezed
|
||||
abstract class ScalePointModel with _$ScalePointModel {
|
||||
const factory ScalePointModel({required double x, required double y}) =
|
||||
_ScalePointModel;
|
||||
|
||||
factory ScalePointModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$ScalePointModelFromJson(json);
|
||||
}
|
||||
26
lib/features/insights/models/insights_period.dart
Normal file
26
lib/features/insights/models/insights_period.dart
Normal file
@@ -0,0 +1,26 @@
|
||||
/// Период для вкладок «Вес», «ИМТ» и «Замеры». Значение совпадает с `{period}` в
|
||||
/// `/api/profile/measure/weight/{period}`.
|
||||
enum BodyPeriod {
|
||||
month('1'),
|
||||
threeMonths('3'),
|
||||
sixMonths('6'),
|
||||
year('12'),
|
||||
all('all');
|
||||
|
||||
const BodyPeriod(this.apiValue);
|
||||
|
||||
final String apiValue;
|
||||
}
|
||||
|
||||
/// Период для вкладки «КБЖУ по дням» (используется со второй итерации).
|
||||
enum DailyPeriod {
|
||||
week('7'),
|
||||
twoWeeks('14'),
|
||||
month('30'),
|
||||
threeMonths('90'),
|
||||
all('all');
|
||||
|
||||
const DailyPeriod(this.apiValue);
|
||||
|
||||
final String apiValue;
|
||||
}
|
||||
84
lib/features/insights/presentation/charts/bmi_chart.dart
Normal file
84
lib/features/insights/presentation/charts/bmi_chart.dart
Normal file
@@ -0,0 +1,84 @@
|
||||
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/charts/insights_chart_common.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||
|
||||
/// График ИМТ с цветными зонами: норма 18.5–25, избыточная масса 25–30, ожирение 30–40.
|
||||
class BmiChart extends StatelessWidget {
|
||||
const BmiChart({super.key, required this.model});
|
||||
|
||||
final BmiChartModel model;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final points = buildPoints(model.labels, model.values);
|
||||
if (points.isEmpty) {
|
||||
return InsightsChartPlaceholder(text: model.deltaText);
|
||||
}
|
||||
final s = S.of(context);
|
||||
final range = paddedRange(points.map((e) => e.value), minPadding: 2);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: insightsChartHeight,
|
||||
child: SfCartesianChart(
|
||||
key: const Key('insights_bmi_chart'),
|
||||
margin: EdgeInsets.zero,
|
||||
plotAreaBorderWidth: 0,
|
||||
primaryXAxis: buildInsightsCategoryAxis(),
|
||||
primaryYAxis: buildInsightsValueAxis(
|
||||
unit: s.insights_unit_bmi,
|
||||
min: range.min,
|
||||
max: range.max,
|
||||
plotBands: const [
|
||||
PlotBand(
|
||||
start: 18.5,
|
||||
end: 25,
|
||||
color: InsightsColors.bmiNormalZone,
|
||||
),
|
||||
PlotBand(
|
||||
start: 25,
|
||||
end: 30,
|
||||
color: InsightsColors.bmiOverweightZone,
|
||||
),
|
||||
PlotBand(
|
||||
start: 30,
|
||||
end: 40,
|
||||
color: InsightsColors.bmiObesityZone,
|
||||
),
|
||||
],
|
||||
),
|
||||
trackballBehavior: buildInsightsTrackball(),
|
||||
series: <CartesianSeries<InsightsPoint, String>>[
|
||||
SplineAreaSeries<InsightsPoint, String>(
|
||||
name: s.insights_tab_bmi,
|
||||
dataSource: points,
|
||||
xValueMapper: (point, _) => point.label,
|
||||
yValueMapper: (point, _) => point.value,
|
||||
borderColor: InsightsColors.blue,
|
||||
borderWidth: 3,
|
||||
gradient: buildInsightsAreaGradient(InsightsColors.blue),
|
||||
markerSettings: buildInsightsMarker(InsightsColors.blue),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (model.hint.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
model.hint,
|
||||
key: const Key('insights_bmi_hint'),
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: InsightsColors.subtitle,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
113
lib/features/insights/presentation/charts/daily_kbju_chart.dart
Normal file
113
lib/features/insights/presentation/charts/daily_kbju_chart.dart
Normal file
@@ -0,0 +1,113 @@
|
||||
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/charts/insights_chart_common.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||
|
||||
/// КБЖУ по дням: калории — площадь на левой оси («ккал»), Б/Ж/У — линии на
|
||||
/// правой оси («граммы»). [visibility] — Калории, Белки, Жиры, Углеводы.
|
||||
class DailyKbjuChart extends StatelessWidget {
|
||||
const DailyKbjuChart({
|
||||
super.key,
|
||||
required this.model,
|
||||
required this.visibility,
|
||||
});
|
||||
|
||||
final DailyChartModel model;
|
||||
final List<bool> visibility;
|
||||
|
||||
static const String _gramsAxisName = 'grams';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
final calories = buildPoints(model.labels, model.kcal);
|
||||
if (calories.isEmpty) {
|
||||
return InsightsChartPlaceholder(text: s.insights_not_enough);
|
||||
}
|
||||
final proteins = buildPoints(model.labels, model.proteins);
|
||||
final fats = buildPoints(model.labels, model.fats);
|
||||
final carbs = buildPoints(model.labels, model.carbs);
|
||||
|
||||
final grams = [
|
||||
(
|
||||
name: s.insights_series_proteins,
|
||||
color: InsightsColors.proteins,
|
||||
points: proteins,
|
||||
),
|
||||
(name: s.insights_series_fats, color: InsightsColors.fats, points: fats),
|
||||
(
|
||||
name: s.insights_series_carbs,
|
||||
color: InsightsColors.carbs,
|
||||
points: carbs,
|
||||
),
|
||||
];
|
||||
|
||||
return SizedBox(
|
||||
height: insightsChartHeight,
|
||||
child: SfCartesianChart(
|
||||
key: const Key('insights_daily_chart'),
|
||||
margin: EdgeInsets.zero,
|
||||
plotAreaBorderWidth: 0,
|
||||
primaryXAxis: buildInsightsCategoryAxis(),
|
||||
primaryYAxis: NumericAxis(
|
||||
title: AxisTitle(
|
||||
text: s.insights_unit_kcal,
|
||||
textStyle: insightsAxisLabelStyle,
|
||||
),
|
||||
labelStyle: insightsAxisLabelStyle,
|
||||
minimum: 0,
|
||||
majorTickLines: const MajorTickLines(size: 0),
|
||||
axisLine: const AxisLine(width: 0),
|
||||
majorGridLines: MajorGridLines(
|
||||
color: InsightsColors.border.withValues(alpha: 0.8),
|
||||
dashArray: const [4, 4],
|
||||
),
|
||||
),
|
||||
axes: <ChartAxis>[
|
||||
NumericAxis(
|
||||
name: _gramsAxisName,
|
||||
opposedPosition: true,
|
||||
minimum: 0,
|
||||
title: AxisTitle(
|
||||
text: s.insights_unit_grams,
|
||||
textStyle: insightsAxisLabelStyle,
|
||||
),
|
||||
labelStyle: insightsAxisLabelStyle,
|
||||
majorTickLines: const MajorTickLines(size: 0),
|
||||
axisLine: const AxisLine(width: 0),
|
||||
majorGridLines: const MajorGridLines(width: 0),
|
||||
),
|
||||
],
|
||||
trackballBehavior: buildInsightsTrackball(),
|
||||
series: <CartesianSeries<InsightsPoint, String>>[
|
||||
if (_isVisible(0))
|
||||
SplineAreaSeries<InsightsPoint, String>(
|
||||
name: s.insights_series_calories,
|
||||
dataSource: calories,
|
||||
xValueMapper: (point, _) => point.label,
|
||||
yValueMapper: (point, _) => point.value,
|
||||
borderColor: InsightsColors.calories,
|
||||
borderWidth: 3,
|
||||
gradient: buildInsightsAreaGradient(InsightsColors.calories),
|
||||
),
|
||||
for (var i = 0; i < grams.length; i++)
|
||||
if (_isVisible(i + 1))
|
||||
SplineSeries<InsightsPoint, String>(
|
||||
name: grams[i].name,
|
||||
yAxisName: _gramsAxisName,
|
||||
dataSource: grams[i].points,
|
||||
xValueMapper: (point, _) => point.label,
|
||||
yValueMapper: (point, _) => point.value,
|
||||
color: grams[i].color,
|
||||
width: 2.5,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool _isVisible(int index) =>
|
||||
index < visibility.length ? visibility[index] : true;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||
|
||||
/// Точка графика: подпись оси X (`dd.MM`) и значение.
|
||||
class InsightsPoint {
|
||||
const InsightsPoint(this.label, this.value);
|
||||
|
||||
final String label;
|
||||
final double value;
|
||||
}
|
||||
|
||||
/// Склеивает `labels[]` и `values[]` (по минимальной длине).
|
||||
List<InsightsPoint> buildPoints(List<String> labels, List<double> values) {
|
||||
final count = math.min(labels.length, values.length);
|
||||
return [for (var i = 0; i < count; i++) InsightsPoint(labels[i], values[i])];
|
||||
}
|
||||
|
||||
/// Границы оси Y с отступом, чтобы линия не прижималась к краям (и ось не начиналась с нуля).
|
||||
({double min, double max}) paddedRange(
|
||||
Iterable<double> values, {
|
||||
double minPadding = 1,
|
||||
}) {
|
||||
if (values.isEmpty) {
|
||||
return (min: 0, max: 1);
|
||||
}
|
||||
final lowest = values.reduce(math.min);
|
||||
final highest = values.reduce(math.max);
|
||||
final padding = math.max((highest - lowest) * 0.2, minPadding);
|
||||
return (
|
||||
min: (lowest - padding).floorToDouble(),
|
||||
max: (highest + padding).ceilToDouble(),
|
||||
);
|
||||
}
|
||||
|
||||
const TextStyle insightsAxisLabelStyle = TextStyle(
|
||||
fontSize: 11,
|
||||
color: InsightsColors.subtitle,
|
||||
);
|
||||
|
||||
CategoryAxis buildInsightsCategoryAxis() {
|
||||
return const CategoryAxis(
|
||||
arrangeByIndex: true,
|
||||
labelStyle: insightsAxisLabelStyle,
|
||||
majorGridLines: MajorGridLines(width: 0),
|
||||
majorTickLines: MajorTickLines(size: 0),
|
||||
axisLine: AxisLine(color: InsightsColors.border, width: 1),
|
||||
edgeLabelPlacement: EdgeLabelPlacement.shift,
|
||||
labelIntersectAction: AxisLabelIntersectAction.hide,
|
||||
);
|
||||
}
|
||||
|
||||
NumericAxis buildInsightsValueAxis({
|
||||
required String unit,
|
||||
required double min,
|
||||
required double max,
|
||||
List<PlotBand> plotBands = const [],
|
||||
}) {
|
||||
return NumericAxis(
|
||||
minimum: min,
|
||||
maximum: max,
|
||||
labelFormat: '{value} $unit',
|
||||
labelStyle: insightsAxisLabelStyle,
|
||||
majorTickLines: const MajorTickLines(size: 0),
|
||||
axisLine: const AxisLine(width: 0),
|
||||
majorGridLines: MajorGridLines(
|
||||
color: InsightsColors.border.withValues(alpha: 0.8),
|
||||
dashArray: const [4, 4],
|
||||
),
|
||||
plotBands: plotBands,
|
||||
);
|
||||
}
|
||||
|
||||
TrackballBehavior buildInsightsTrackball() {
|
||||
return TrackballBehavior(
|
||||
enable: true,
|
||||
activationMode: ActivationMode.singleTap,
|
||||
tooltipDisplayMode: TrackballDisplayMode.groupAllPoints,
|
||||
lineType: TrackballLineType.vertical,
|
||||
tooltipSettings: const InteractiveTooltip(),
|
||||
);
|
||||
}
|
||||
|
||||
MarkerSettings buildInsightsMarker(Color color) {
|
||||
return MarkerSettings(
|
||||
isVisible: true,
|
||||
width: 4,
|
||||
height: 4,
|
||||
color: Colors.white,
|
||||
borderColor: color,
|
||||
borderWidth: 2,
|
||||
);
|
||||
}
|
||||
|
||||
LinearGradient buildInsightsAreaGradient(Color color) {
|
||||
return LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [color.withValues(alpha: 0.18), color.withValues(alpha: 0)],
|
||||
);
|
||||
}
|
||||
|
||||
/// Пустое состояние графика (нет данных).
|
||||
class InsightsChartPlaceholder extends StatelessWidget {
|
||||
const InsightsChartPlaceholder({super.key, required this.text});
|
||||
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: insightsChartHeight,
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Text(
|
||||
text,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: InsightsColors.subtitle,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
210
lib/features/insights/presentation/charts/intra_day_chart.dart
Normal file
210
lib/features/insights/presentation/charts/intra_day_chart.dart
Normal file
@@ -0,0 +1,210 @@
|
||||
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/charts/insights_chart_common.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||
|
||||
/// График «В течение дня»: столбики Б/Ж/У (ккал) по приёмам пищи на оси 0–24 ч,
|
||||
/// линии сытости и настроения (шкала 0–5) и затемнение времени сна.
|
||||
///
|
||||
/// [visibility] — Белки, Жиры, Углеводы, Сытость, Настроение.
|
||||
class IntraDayChart extends StatelessWidget {
|
||||
const IntraDayChart({
|
||||
super.key,
|
||||
required this.model,
|
||||
required this.visibility,
|
||||
required this.showSleep,
|
||||
});
|
||||
|
||||
final IntraChartModel model;
|
||||
final List<bool> visibility;
|
||||
final bool showSleep;
|
||||
|
||||
static const String _scaleAxisName = 'scale';
|
||||
static const double _barWidthPx = 26;
|
||||
static const double _axesReservedWidthPx = 80;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
if (model.bars.isEmpty && model.satiety.isEmpty && model.mood.isEmpty) {
|
||||
return InsightsChartPlaceholder(text: s.insights_not_enough);
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
height: insightsChartHeight,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final plotWidth = (constraints.maxWidth - _axesReservedWidthPx).clamp(
|
||||
120.0,
|
||||
double.infinity,
|
||||
);
|
||||
final barFraction = (_barWidthPx / (plotWidth / 24)).clamp(0.1, 1.0);
|
||||
return SfCartesianChart(
|
||||
key: const Key('insights_intra_chart'),
|
||||
margin: EdgeInsets.zero,
|
||||
plotAreaBorderWidth: 0,
|
||||
primaryXAxis: NumericAxis(
|
||||
minimum: 0,
|
||||
maximum: 24,
|
||||
interval: 2,
|
||||
labelStyle: insightsAxisLabelStyle,
|
||||
majorGridLines: const MajorGridLines(width: 0),
|
||||
majorTickLines: const MajorTickLines(size: 0),
|
||||
axisLine: const AxisLine(color: InsightsColors.border, width: 1),
|
||||
axisLabelFormatter: (details) => ChartAxisLabel(
|
||||
'${details.value.toInt().toString().padLeft(2, '0')}:00',
|
||||
details.textStyle,
|
||||
),
|
||||
plotBands: _sleepBands(),
|
||||
),
|
||||
primaryYAxis: NumericAxis(
|
||||
minimum: 0,
|
||||
title: AxisTitle(
|
||||
text: s.insights_unit_kcal_per_meal,
|
||||
textStyle: insightsAxisLabelStyle,
|
||||
),
|
||||
labelStyle: insightsAxisLabelStyle,
|
||||
majorTickLines: const MajorTickLines(size: 0),
|
||||
axisLine: const AxisLine(width: 0),
|
||||
majorGridLines: MajorGridLines(
|
||||
color: InsightsColors.border.withValues(alpha: 0.8),
|
||||
dashArray: const [4, 4],
|
||||
),
|
||||
),
|
||||
axes: const <ChartAxis>[
|
||||
NumericAxis(
|
||||
name: _scaleAxisName,
|
||||
opposedPosition: true,
|
||||
minimum: 0,
|
||||
maximum: 5,
|
||||
interval: 1,
|
||||
labelStyle: insightsAxisLabelStyle,
|
||||
majorTickLines: MajorTickLines(size: 0),
|
||||
axisLine: AxisLine(width: 0),
|
||||
majorGridLines: MajorGridLines(width: 0),
|
||||
),
|
||||
],
|
||||
trackballBehavior: buildInsightsTrackball(),
|
||||
series: <CartesianSeries<dynamic, double>>[
|
||||
if (_isVisible(0))
|
||||
_barSeries(
|
||||
s.insights_series_proteins,
|
||||
InsightsColors.proteins,
|
||||
(bar) => bar.p,
|
||||
barFraction,
|
||||
),
|
||||
if (_isVisible(1))
|
||||
_barSeries(
|
||||
s.insights_series_fats,
|
||||
InsightsColors.fats,
|
||||
(bar) => bar.f,
|
||||
barFraction,
|
||||
),
|
||||
if (_isVisible(2))
|
||||
_barSeries(
|
||||
s.insights_series_carbs,
|
||||
InsightsColors.carbs,
|
||||
(bar) => bar.c,
|
||||
barFraction,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(6),
|
||||
),
|
||||
),
|
||||
if (_isVisible(3) && model.satiety.isNotEmpty)
|
||||
_scaleSeries(
|
||||
s.insights_series_satiety,
|
||||
InsightsColors.satiety,
|
||||
model.satiety,
|
||||
),
|
||||
if (_isVisible(4) && model.mood.isNotEmpty)
|
||||
_scaleSeries(
|
||||
s.insights_series_mood,
|
||||
InsightsColors.mood,
|
||||
model.mood,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool _isVisible(int index) =>
|
||||
index < visibility.length ? visibility[index] : true;
|
||||
|
||||
CartesianSeries<IntraBarModel, double> _barSeries(
|
||||
String name,
|
||||
Color color,
|
||||
double Function(IntraBarModel bar) valueOf,
|
||||
double widthFraction, {
|
||||
BorderRadius borderRadius = BorderRadius.zero,
|
||||
}) {
|
||||
return StackedColumnSeries<IntraBarModel, double>(
|
||||
name: name,
|
||||
dataSource: model.bars,
|
||||
xValueMapper: (bar, _) => bar.x,
|
||||
yValueMapper: (bar, _) => valueOf(bar),
|
||||
color: color,
|
||||
width: widthFraction,
|
||||
spacing: 0,
|
||||
borderRadius: borderRadius,
|
||||
);
|
||||
}
|
||||
|
||||
CartesianSeries<ScalePointModel, double> _scaleSeries(
|
||||
String name,
|
||||
Color color,
|
||||
List<ScalePointModel> points,
|
||||
) {
|
||||
return SplineSeries<ScalePointModel, double>(
|
||||
name: name,
|
||||
yAxisName: _scaleAxisName,
|
||||
dataSource: points,
|
||||
xValueMapper: (point, _) => point.x,
|
||||
yValueMapper: (point, _) => point.y,
|
||||
splineType: SplineType.monotonic,
|
||||
color: color,
|
||||
width: 2.5,
|
||||
markerSettings: MarkerSettings(
|
||||
isVisible: true,
|
||||
width: 3,
|
||||
height: 3,
|
||||
color: color,
|
||||
borderColor: color,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Затемнение `[0, wake]` и `[sleep, 24]` плюс пунктиры подъёма и отхода ко сну.
|
||||
List<PlotBand> _sleepBands() {
|
||||
if (!showSleep) {
|
||||
return const [];
|
||||
}
|
||||
final wake = model.wake;
|
||||
final sleep = model.sleep;
|
||||
return [
|
||||
if (wake != null && wake > 0)
|
||||
PlotBand(start: 0, end: wake, color: InsightsColors.sleepShade),
|
||||
if (sleep != null && sleep < 24 && (wake == null || sleep > wake))
|
||||
PlotBand(start: sleep, end: 24, color: InsightsColors.sleepShade),
|
||||
if (wake != null)
|
||||
PlotBand(
|
||||
start: wake,
|
||||
end: wake,
|
||||
borderColor: InsightsColors.wake,
|
||||
borderWidth: 1.5,
|
||||
dashArray: const [5, 4],
|
||||
),
|
||||
if (sleep != null)
|
||||
PlotBand(
|
||||
start: sleep,
|
||||
end: sleep,
|
||||
borderColor: InsightsColors.sleepLine,
|
||||
borderWidth: 1.5,
|
||||
dashArray: const [5, 4],
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
86
lib/features/insights/presentation/charts/measure_chart.dart
Normal file
86
lib/features/insights/presentation/charts/measure_chart.dart
Normal file
@@ -0,0 +1,86 @@
|
||||
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/charts/insights_chart_common.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||
|
||||
/// Три линии: талия, грудь, бёдра. [visibility] — видимость серий в этом порядке.
|
||||
class MeasureChart extends StatelessWidget {
|
||||
const MeasureChart({
|
||||
super.key,
|
||||
required this.model,
|
||||
required this.visibility,
|
||||
required this.emptyText,
|
||||
});
|
||||
|
||||
final MeasureChartModel model;
|
||||
final List<bool> visibility;
|
||||
final String emptyText;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final waist = buildPoints(model.labels, model.waist);
|
||||
final chest = buildPoints(model.labels, model.chest);
|
||||
final hips = buildPoints(model.labels, model.hips);
|
||||
if (waist.isEmpty && chest.isEmpty && hips.isEmpty) {
|
||||
return InsightsChartPlaceholder(text: emptyText);
|
||||
}
|
||||
final s = S.of(context);
|
||||
final series = [
|
||||
(
|
||||
name: s.insights_series_waist,
|
||||
color: InsightsColors.waist,
|
||||
points: waist,
|
||||
),
|
||||
(
|
||||
name: s.insights_series_chest,
|
||||
color: InsightsColors.chest,
|
||||
points: chest,
|
||||
),
|
||||
(name: s.insights_series_hips, color: InsightsColors.hips, points: hips),
|
||||
];
|
||||
final visibleValues = <double>[
|
||||
for (var i = 0; i < series.length; i++)
|
||||
if (_isVisible(i)) ...series[i].points.map((e) => e.value),
|
||||
];
|
||||
final range = paddedRange(
|
||||
visibleValues.isEmpty
|
||||
? series.expand((e) => e.points).map((e) => e.value)
|
||||
: visibleValues,
|
||||
minPadding: 2,
|
||||
);
|
||||
|
||||
return SizedBox(
|
||||
height: insightsChartHeight,
|
||||
child: SfCartesianChart(
|
||||
key: const Key('insights_measure_chart'),
|
||||
margin: EdgeInsets.zero,
|
||||
plotAreaBorderWidth: 0,
|
||||
primaryXAxis: buildInsightsCategoryAxis(),
|
||||
primaryYAxis: buildInsightsValueAxis(
|
||||
unit: s.insights_unit_cm,
|
||||
min: range.min,
|
||||
max: range.max,
|
||||
),
|
||||
trackballBehavior: buildInsightsTrackball(),
|
||||
series: <CartesianSeries<InsightsPoint, String>>[
|
||||
for (var i = 0; i < series.length; i++)
|
||||
if (_isVisible(i))
|
||||
SplineSeries<InsightsPoint, String>(
|
||||
name: series[i].name,
|
||||
dataSource: series[i].points,
|
||||
xValueMapper: (point, _) => point.label,
|
||||
yValueMapper: (point, _) => point.value,
|
||||
color: series[i].color,
|
||||
width: 3,
|
||||
markerSettings: buildInsightsMarker(series[i].color),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool _isVisible(int index) =>
|
||||
index < visibility.length ? visibility[index] : true;
|
||||
}
|
||||
53
lib/features/insights/presentation/charts/weight_chart.dart
Normal file
53
lib/features/insights/presentation/charts/weight_chart.dart
Normal file
@@ -0,0 +1,53 @@
|
||||
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/charts/insights_chart_common.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||
|
||||
class WeightChart extends StatelessWidget {
|
||||
const WeightChart({super.key, required this.model, required this.emptyText});
|
||||
|
||||
final WeightChartModel model;
|
||||
|
||||
/// Текст при отсутствии точек («Добавьте Ваш вес»).
|
||||
final String emptyText;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final points = buildPoints(model.labels, model.values);
|
||||
if (points.isEmpty) {
|
||||
return InsightsChartPlaceholder(text: emptyText);
|
||||
}
|
||||
final s = S.of(context);
|
||||
final range = paddedRange(points.map((e) => e.value));
|
||||
|
||||
return SizedBox(
|
||||
height: insightsChartHeight,
|
||||
child: SfCartesianChart(
|
||||
key: const Key('insights_weight_chart'),
|
||||
margin: EdgeInsets.zero,
|
||||
plotAreaBorderWidth: 0,
|
||||
primaryXAxis: buildInsightsCategoryAxis(),
|
||||
primaryYAxis: buildInsightsValueAxis(
|
||||
unit: s.insights_unit_kg,
|
||||
min: range.min,
|
||||
max: range.max,
|
||||
),
|
||||
trackballBehavior: buildInsightsTrackball(),
|
||||
series: <CartesianSeries<InsightsPoint, String>>[
|
||||
SplineAreaSeries<InsightsPoint, String>(
|
||||
name: s.insights_tab_weight,
|
||||
dataSource: points,
|
||||
xValueMapper: (point, _) => point.label,
|
||||
yValueMapper: (point, _) => point.value,
|
||||
borderColor: InsightsColors.blue,
|
||||
borderWidth: 3,
|
||||
gradient: buildInsightsAreaGradient(InsightsColors.blue),
|
||||
markerSettings: buildInsightsMarker(InsightsColors.blue),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
184
lib/features/insights/presentation/cubit/insights_cubit.dart
Normal file
184
lib/features/insights/presentation/cubit/insights_cubit.dart
Normal file
@@ -0,0 +1,184 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||
import 'package:eatvio_app/features/insights/insights_feature_flags.dart';
|
||||
import 'package:eatvio_app/features/insights/models/insights_period.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/cubit/insights_panel.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/cubit/insights_state.dart';
|
||||
import 'package:eatvio_app/features/insights/repositories/insights_repository.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class InsightsCubit extends Cubit<InsightsState> {
|
||||
InsightsCubit({
|
||||
required this.repository,
|
||||
this.slug,
|
||||
this.canAdd = false,
|
||||
this.kbjuEnabled = InsightsFeatureFlags.kbjuEnabled,
|
||||
}) : super(const InsightsState.initial());
|
||||
|
||||
final InsightsRepository repository;
|
||||
|
||||
/// `null` — собственные данные, иначе slug владельца (консультант → клиент).
|
||||
final String? slug;
|
||||
|
||||
/// Показывать ли кнопку «+» (только владелец).
|
||||
final bool canAdd;
|
||||
|
||||
final bool kbjuEnabled;
|
||||
|
||||
bool _isHeightLoaded = false;
|
||||
int? _heightCm;
|
||||
int _requestId = 0;
|
||||
|
||||
List<InsightsPanel> get panels =>
|
||||
InsightsPanel.available(kbjuEnabled: kbjuEnabled);
|
||||
|
||||
Future<void> load() async {
|
||||
emit(const InsightsState.loading());
|
||||
await _fetch(bodyPeriod: BodyPeriod.threeMonths, previous: null);
|
||||
}
|
||||
|
||||
/// Перезапрашивает данные, сохраняя вкладку, период, серии и раскрытие.
|
||||
Future<void> refresh() async {
|
||||
final current = state;
|
||||
if (current is! InsightsStateLoaded) {
|
||||
return load();
|
||||
}
|
||||
emit(current.copyWith(isRefreshing: true));
|
||||
await _fetch(bodyPeriod: current.bodyPeriod, previous: current);
|
||||
}
|
||||
|
||||
Future<void> setBodyPeriod(BodyPeriod period) async {
|
||||
final current = state;
|
||||
if (current is! InsightsStateLoaded || current.bodyPeriod == period) {
|
||||
return;
|
||||
}
|
||||
emit(current.copyWith(bodyPeriod: period, isRefreshing: true));
|
||||
await _fetch(bodyPeriod: period, previous: current);
|
||||
}
|
||||
|
||||
void toggleExpanded() {
|
||||
final current = state;
|
||||
if (current is InsightsStateLoaded) {
|
||||
emit(current.copyWith(isExpanded: !current.isExpanded));
|
||||
}
|
||||
}
|
||||
|
||||
void setPanel(InsightsPanel panel) {
|
||||
final current = state;
|
||||
if (current is! InsightsStateLoaded || !panels.contains(panel)) {
|
||||
return;
|
||||
}
|
||||
emit(current.copyWith(panel: panel));
|
||||
}
|
||||
|
||||
/// Включает/выключает серию [index] на вкладке [panel].
|
||||
void toggleSeries(InsightsPanel panel, int index) {
|
||||
final current = state;
|
||||
if (current is! InsightsStateLoaded) {
|
||||
return;
|
||||
}
|
||||
final visibility = current.seriesVisibility;
|
||||
final source = switch (panel) {
|
||||
InsightsPanel.measures => visibility.measure,
|
||||
InsightsPanel.daily => visibility.daily,
|
||||
InsightsPanel.intra => visibility.intra,
|
||||
_ => null,
|
||||
};
|
||||
if (source == null || index < 0 || index >= source.length) {
|
||||
return;
|
||||
}
|
||||
final updated = [...source]..[index] = !source[index];
|
||||
final nextVisibility = switch (panel) {
|
||||
InsightsPanel.measures => visibility.copyWith(measure: updated),
|
||||
InsightsPanel.daily => visibility.copyWith(daily: updated),
|
||||
_ => visibility.copyWith(intra: updated),
|
||||
};
|
||||
emit(current.copyWith(seriesVisibility: nextVisibility));
|
||||
}
|
||||
|
||||
void toggleSleep() {
|
||||
final current = state;
|
||||
if (current is InsightsStateLoaded) {
|
||||
emit(current.copyWith(showSleep: !current.showSleep));
|
||||
}
|
||||
}
|
||||
|
||||
/// Сохраняет вес и обновляет данные. Возвращает `false`, если сохранить не удалось.
|
||||
Future<bool> addWeight({required double weight}) {
|
||||
return _add(() => repository.addWeight(weight: weight));
|
||||
}
|
||||
|
||||
/// Сохраняет замеры и обновляет данные. Возвращает `false`, если сохранить не удалось.
|
||||
Future<bool> addCircumference({
|
||||
required int chestCircumference,
|
||||
required int waistCircumference,
|
||||
required int hipCircumference,
|
||||
}) {
|
||||
return _add(
|
||||
() => repository.addCircumference(
|
||||
chestCircumference: chestCircumference,
|
||||
waistCircumference: waistCircumference,
|
||||
hipCircumference: hipCircumference,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _add(Future<void> Function() action) async {
|
||||
try {
|
||||
await action();
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString(), stackTrace: st);
|
||||
return false;
|
||||
}
|
||||
await refresh();
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _fetch({
|
||||
required BodyPeriod bodyPeriod,
|
||||
required InsightsStateLoaded? previous,
|
||||
}) async {
|
||||
final requestId = ++_requestId;
|
||||
try {
|
||||
if (!_isHeightLoaded) {
|
||||
_heightCm = await repository.getHeightCm(slug: slug);
|
||||
_isHeightLoaded = true;
|
||||
}
|
||||
final payload = await repository.getInsights(
|
||||
slug: slug,
|
||||
bodyPeriod: bodyPeriod,
|
||||
heightCm: _heightCm,
|
||||
);
|
||||
if (requestId != _requestId || isClosed) {
|
||||
return;
|
||||
}
|
||||
emit(
|
||||
previous == null
|
||||
? InsightsState.loaded(payload: payload, bodyPeriod: bodyPeriod)
|
||||
: previous.copyWith(
|
||||
payload: payload,
|
||||
bodyPeriod: bodyPeriod,
|
||||
isRefreshing: false,
|
||||
),
|
||||
);
|
||||
} on InsightsForbidden {
|
||||
if (requestId == _requestId && !isClosed) {
|
||||
emit(const InsightsState.hidden());
|
||||
}
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString(), stackTrace: st);
|
||||
if (requestId != _requestId || isClosed) {
|
||||
return;
|
||||
}
|
||||
// Ошибка перезапроса не должна стирать уже показанные данные.
|
||||
emit(
|
||||
previous == null
|
||||
? InsightsState.error(message: e.toString())
|
||||
: previous.copyWith(isRefreshing: false),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
18
lib/features/insights/presentation/cubit/insights_panel.dart
Normal file
18
lib/features/insights/presentation/cubit/insights_panel.dart
Normal file
@@ -0,0 +1,18 @@
|
||||
/// Вкладки блока «Аналитика».
|
||||
enum InsightsPanel {
|
||||
weight,
|
||||
bmi,
|
||||
measures,
|
||||
daily,
|
||||
intra;
|
||||
|
||||
/// Вкладки КБЖУ доступны только при включённом флаге.
|
||||
bool get isKbju => this == daily || this == intra;
|
||||
|
||||
/// Кнопка «+» есть только у вкладок с ручным вводом.
|
||||
bool get supportsAdd => this == weight || this == measures;
|
||||
|
||||
static List<InsightsPanel> available({required bool kbjuEnabled}) {
|
||||
return values.where((panel) => kbjuEnabled || !panel.isKbju).toList();
|
||||
}
|
||||
}
|
||||
47
lib/features/insights/presentation/cubit/insights_state.dart
Normal file
47
lib/features/insights/presentation/cubit/insights_state.dart
Normal file
@@ -0,0 +1,47 @@
|
||||
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
|
||||
import 'package:eatvio_app/features/insights/models/insights_period.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/cubit/insights_panel.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'insights_state.freezed.dart';
|
||||
|
||||
/// Видимость серий (чипы под графиком): `true` — серия показана.
|
||||
@freezed
|
||||
abstract class InsightsSeriesVisibility with _$InsightsSeriesVisibility {
|
||||
const factory InsightsSeriesVisibility({
|
||||
/// Талия, Грудь, Бёдра.
|
||||
@Default([true, true, true]) List<bool> measure,
|
||||
|
||||
/// Калории, Белки, Жиры, Углеводы.
|
||||
@Default([true, true, true, true]) List<bool> daily,
|
||||
|
||||
/// Белки, Жиры, Углеводы, Сытость, Настроение.
|
||||
@Default([true, true, true, true, true]) List<bool> intra,
|
||||
}) = _InsightsSeriesVisibility;
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class InsightsState with _$InsightsState {
|
||||
const factory InsightsState.initial() = InsightsStateInitial;
|
||||
|
||||
const factory InsightsState.loading() = InsightsStateLoading;
|
||||
|
||||
/// 403: данные недоступны, блок не показывается.
|
||||
const factory InsightsState.hidden() = InsightsStateHidden;
|
||||
|
||||
const factory InsightsState.error({required String message}) =
|
||||
InsightsStateError;
|
||||
|
||||
const factory InsightsState.loaded({
|
||||
required InsightsPayloadModel payload,
|
||||
@Default(BodyPeriod.threeMonths) BodyPeriod bodyPeriod,
|
||||
@Default(InsightsPanel.weight) InsightsPanel panel,
|
||||
@Default(InsightsSeriesVisibility())
|
||||
InsightsSeriesVisibility seriesVisibility,
|
||||
@Default(true) bool showSleep,
|
||||
@Default(false) bool isExpanded,
|
||||
|
||||
/// Идёт перезапрос (смена периода / обновление) — старые данные остаются на экране.
|
||||
@Default(false) bool isRefreshing,
|
||||
}) = InsightsStateLoaded;
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
const int _minValue = 20;
|
||||
const int _maxValue = 199;
|
||||
|
||||
/// Значения замеров в сантиметрах.
|
||||
typedef CircumferenceValues = ({int chest, int waist, int hip});
|
||||
|
||||
/// Выбор замеров груди, талии и бёдер. [onSave] вызывается после закрытия шторки.
|
||||
Future<void> showAddCircumferenceBottomSheet(
|
||||
BuildContext context, {
|
||||
CircumferenceValues? initial,
|
||||
required Future<void> Function(CircumferenceValues values) onSave,
|
||||
}) {
|
||||
return showModalBottomSheet<void>(
|
||||
useRootNavigator: true,
|
||||
context: context,
|
||||
builder: (_) =>
|
||||
AddCircumferenceBottomSheet(initial: initial, onSave: onSave),
|
||||
);
|
||||
}
|
||||
|
||||
class AddCircumferenceBottomSheet extends StatefulWidget {
|
||||
const AddCircumferenceBottomSheet({
|
||||
super.key,
|
||||
this.initial,
|
||||
required this.onSave,
|
||||
});
|
||||
|
||||
final CircumferenceValues? initial;
|
||||
final Future<void> Function(CircumferenceValues values) onSave;
|
||||
|
||||
@override
|
||||
State<AddCircumferenceBottomSheet> createState() =>
|
||||
_AddCircumferenceBottomSheetState();
|
||||
}
|
||||
|
||||
class _AddCircumferenceBottomSheetState
|
||||
extends State<AddCircumferenceBottomSheet> {
|
||||
late final FixedExtentScrollController _chestController;
|
||||
late final FixedExtentScrollController _waistController;
|
||||
late final FixedExtentScrollController _hipController;
|
||||
late int _chest;
|
||||
late int _waist;
|
||||
late int _hip;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_chest = _clamp(widget.initial?.chest);
|
||||
_waist = _clamp(widget.initial?.waist);
|
||||
_hip = _clamp(widget.initial?.hip);
|
||||
_chestController = FixedExtentScrollController(
|
||||
initialItem: _chest - _minValue,
|
||||
);
|
||||
_waistController = FixedExtentScrollController(
|
||||
initialItem: _waist - _minValue,
|
||||
);
|
||||
_hipController = FixedExtentScrollController(initialItem: _hip - _minValue);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_chestController.dispose();
|
||||
_waistController.dispose();
|
||||
_hipController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
int _clamp(int? value) => (value ?? _minValue).clamp(_minValue, _maxValue);
|
||||
|
||||
void _save() {
|
||||
final values = (chest: _chest, waist: _waist, hip: _hip);
|
||||
Navigator.of(context).pop();
|
||||
widget.onSave(values);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
for (final title in [
|
||||
s.insights_series_chest,
|
||||
s.insights_series_waist,
|
||||
s.insights_series_hips,
|
||||
])
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 200,
|
||||
child: Row(
|
||||
children: [
|
||||
_picker(
|
||||
const Key('add_circumference_chest_picker'),
|
||||
_chestController,
|
||||
(value) => _chest = value,
|
||||
),
|
||||
_picker(
|
||||
const Key('add_circumference_waist_picker'),
|
||||
_waistController,
|
||||
(value) => _waist = value,
|
||||
),
|
||||
_picker(
|
||||
const Key('add_circumference_hip_picker'),
|
||||
_hipController,
|
||||
(value) => _hip = value,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: InkWell(
|
||||
key: const Key('add_sheet_save'),
|
||||
onTap: _save,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Ink(
|
||||
width: MediaQuery.of(context).size.width - 32,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: appTheme.blueA400,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
s.save,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _picker(
|
||||
Key key,
|
||||
FixedExtentScrollController controller,
|
||||
ValueChanged<int> onChanged,
|
||||
) {
|
||||
return Expanded(
|
||||
child: CupertinoPicker(
|
||||
key: key,
|
||||
magnification: 1.22,
|
||||
squeeze: 1.2,
|
||||
useMagnifier: true,
|
||||
scrollController: controller,
|
||||
itemExtent: 24,
|
||||
onSelectedItemChanged: (index) => onChanged(index + _minValue),
|
||||
children: [for (int i = _minValue; i <= _maxValue; i++) Text('$i')],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
const int _minKilo = 20;
|
||||
const int _maxKilo = 299;
|
||||
|
||||
/// Выбор веса (кг + десятые). [onSave] вызывается после закрытия шторки.
|
||||
Future<void> showAddWeightBottomSheet(
|
||||
BuildContext context, {
|
||||
double? initialWeight,
|
||||
required Future<void> Function(double weight) onSave,
|
||||
}) {
|
||||
return showModalBottomSheet<void>(
|
||||
useRootNavigator: true,
|
||||
context: context,
|
||||
builder: (_) =>
|
||||
AddWeightBottomSheet(initialWeight: initialWeight, onSave: onSave),
|
||||
);
|
||||
}
|
||||
|
||||
class AddWeightBottomSheet extends StatefulWidget {
|
||||
const AddWeightBottomSheet({
|
||||
super.key,
|
||||
this.initialWeight,
|
||||
required this.onSave,
|
||||
});
|
||||
|
||||
final double? initialWeight;
|
||||
final Future<void> Function(double weight) onSave;
|
||||
|
||||
@override
|
||||
State<AddWeightBottomSheet> createState() => _AddWeightBottomSheetState();
|
||||
}
|
||||
|
||||
class _AddWeightBottomSheetState extends State<AddWeightBottomSheet> {
|
||||
late final FixedExtentScrollController _kiloController;
|
||||
late final FixedExtentScrollController _gramsController;
|
||||
late int _kilo;
|
||||
late int _grams;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final initial = widget.initialWeight;
|
||||
final tenths = initial == null
|
||||
? 0
|
||||
: (initial * 10).round().clamp(_minKilo * 10, _maxKilo * 10 + 9);
|
||||
_kilo = initial == null ? _minKilo : tenths ~/ 10;
|
||||
_grams = initial == null ? 0 : tenths % 10;
|
||||
_kiloController = FixedExtentScrollController(
|
||||
initialItem: _kilo - _minKilo,
|
||||
);
|
||||
_gramsController = FixedExtentScrollController(initialItem: _grams);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_kiloController.dispose();
|
||||
_gramsController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _save() {
|
||||
final weight = double.parse('$_kilo.$_grams');
|
||||
Navigator.of(context).pop();
|
||||
widget.onSave(weight);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 200,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: CupertinoPicker(
|
||||
key: const Key('add_weight_kilo_picker'),
|
||||
magnification: 1.22,
|
||||
squeeze: 1.2,
|
||||
useMagnifier: true,
|
||||
scrollController: _kiloController,
|
||||
itemExtent: 24,
|
||||
onSelectedItemChanged: (value) => _kilo = value + _minKilo,
|
||||
children: [
|
||||
for (int i = _minKilo; i <= _maxKilo; i++) Text('$i'),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: CupertinoPicker(
|
||||
key: const Key('add_weight_grams_picker'),
|
||||
magnification: 1.22,
|
||||
squeeze: 1.2,
|
||||
useMagnifier: true,
|
||||
scrollController: _gramsController,
|
||||
itemExtent: 24,
|
||||
onSelectedItemChanged: (value) => _grams = value,
|
||||
children: [for (int i = 0; i < 10; i++) Text('$i')],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_SaveButton(onTap: _save),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SaveButton extends StatelessWidget {
|
||||
const _SaveButton({required this.onTap});
|
||||
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: InkWell(
|
||||
key: const Key('add_sheet_save'),
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Ink(
|
||||
width: MediaQuery.of(context).size.width - 32,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: appTheme.blueA400,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
S.of(context).save,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
593
lib/features/insights/presentation/widgets/insights_card.dart
Normal file
593
lib/features/insights/presentation/widgets/insights_card.dart
Normal file
@@ -0,0 +1,593 @@
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/utils/toasts.dart';
|
||||
import 'package:eatvio_app/features/insights/insights_feature_flags.dart';
|
||||
import 'package:eatvio_app/features/insights/models/insights_period.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/charts/bmi_chart.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/charts/daily_kbju_chart.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/charts/insights_chart_common.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/charts/intra_day_chart.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/charts/measure_chart.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/charts/weight_chart.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/cubit/insights_cubit.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/cubit/insights_panel.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/cubit/insights_state.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/add_circumference_bottom_sheet.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/add_weight_bottom_sheet.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_fab.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_head.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_tabs.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/intra_date_nav.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/intra_legend.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/period_pills.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/series_chips.dart';
|
||||
import 'package:eatvio_app/features/insights/repositories/insights_repository.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
/// Сворачиваемый блок «Аналитика» (вес, ИМТ, замеры).
|
||||
///
|
||||
/// [slug] — владелец данных (`null` — текущий пользователь). При 403 блок
|
||||
/// скрывается целиком. [canAdd] включает кнопку «+» на вкладках «Вес» и «Замеры».
|
||||
class InsightsCard extends StatelessWidget {
|
||||
const InsightsCard({
|
||||
super.key,
|
||||
this.slug,
|
||||
this.canAdd = false,
|
||||
this.repository,
|
||||
this.kbjuEnabled = InsightsFeatureFlags.kbjuEnabled,
|
||||
});
|
||||
|
||||
final String? slug;
|
||||
final bool canAdd;
|
||||
final InsightsRepository? repository;
|
||||
final bool kbjuEnabled;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return KeyedSubtree(
|
||||
key: ValueKey('insights_card_$slug'),
|
||||
child: BlocProvider<InsightsCubit>(
|
||||
create: (_) => InsightsCubit(
|
||||
repository: repository ?? sl<InsightsRepository>(),
|
||||
slug: slug,
|
||||
canAdd: canAdd,
|
||||
kbjuEnabled: kbjuEnabled,
|
||||
)..load(),
|
||||
child: const _InsightsView(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InsightsView extends StatelessWidget {
|
||||
const _InsightsView();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<InsightsCubit, InsightsState>(
|
||||
builder: (context, state) {
|
||||
return switch (state) {
|
||||
InsightsStateLoaded() => _CardShell(
|
||||
child: _LoadedContent(state: state),
|
||||
),
|
||||
InsightsStateError() => const _CardShell(child: _ErrorContent()),
|
||||
_ => const SizedBox.shrink(),
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CardShell extends StatelessWidget {
|
||||
const _CardShell({required this.child});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
key: const Key('insights_card'),
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: InsightsColors.cardShadow,
|
||||
blurRadius: 4,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ErrorContent extends StatelessWidget {
|
||||
const _ErrorContent();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
s.insights_load_error,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: InsightsColors.subtitle,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
key: const Key('insights_retry'),
|
||||
onPressed: () => context.read<InsightsCubit>().load(),
|
||||
child: Text(s.insights_retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LoadedContent extends StatelessWidget {
|
||||
const _LoadedContent({required this.state});
|
||||
|
||||
final InsightsStateLoaded state;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cubit = context.read<InsightsCubit>();
|
||||
final showFab = cubit.canAdd && state.panel.supportsAdd;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_Toggle(isExpanded: state.isExpanded, onTap: cubit.toggleExpanded),
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
alignment: Alignment.topCenter,
|
||||
child: state.isExpanded
|
||||
? Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
20,
|
||||
0,
|
||||
20,
|
||||
showFab ? 24 + 44 : 24,
|
||||
),
|
||||
child: _Body(state: state),
|
||||
)
|
||||
: const SizedBox(width: double.infinity),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (showFab && state.isExpanded)
|
||||
Positioned(
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
child: InsightsFab(
|
||||
tooltip: S.of(context).insights_add_record,
|
||||
onPressed: () => _onAddPressed(context, cubit, state),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _onAddPressed(
|
||||
BuildContext context,
|
||||
InsightsCubit cubit,
|
||||
InsightsStateLoaded state,
|
||||
) {
|
||||
final s = S.of(context);
|
||||
if (state.panel == InsightsPanel.weight) {
|
||||
showAddWeightBottomSheet(
|
||||
context,
|
||||
initialWeight: state.payload.weight.last,
|
||||
onSave: (weight) async {
|
||||
if (!await cubit.addWeight(weight: weight)) {
|
||||
showErrorToast(s.insights_save_error);
|
||||
}
|
||||
},
|
||||
);
|
||||
} else if (state.panel == InsightsPanel.measures) {
|
||||
final measure = state.payload.measure;
|
||||
showAddCircumferenceBottomSheet(
|
||||
context,
|
||||
initial:
|
||||
measure.waist.isEmpty ||
|
||||
measure.chest.isEmpty ||
|
||||
measure.hips.isEmpty
|
||||
? null
|
||||
: (
|
||||
chest: measure.chest.last.round(),
|
||||
waist: measure.waist.last.round(),
|
||||
hip: measure.hips.last.round(),
|
||||
),
|
||||
onSave: (values) async {
|
||||
final isSaved = await cubit.addCircumference(
|
||||
chestCircumference: values.chest,
|
||||
waistCircumference: values.waist,
|
||||
hipCircumference: values.hip,
|
||||
);
|
||||
if (!isSaved) {
|
||||
showErrorToast(s.insights_save_error);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _Toggle extends StatelessWidget {
|
||||
const _Toggle({required this.isExpanded, required this.onTap});
|
||||
|
||||
final bool isExpanded;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
return InkWell(
|
||||
key: const Key('insights_toggle'),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 34,
|
||||
height: 34,
|
||||
decoration: BoxDecoration(
|
||||
color: InsightsColors.accentBackground,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.show_chart_rounded,
|
||||
size: 20,
|
||||
color: InsightsColors.blue,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
s.insights_title,
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: InsightsColors.title,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
key: const Key('insights_subtitle'),
|
||||
isExpanded
|
||||
? s.insights_subtitle_open
|
||||
: s.insights_subtitle_closed,
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: InsightsColors.subtitle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
AnimatedRotation(
|
||||
turns: isExpanded ? 0 : 0.5,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: const Icon(
|
||||
Icons.keyboard_arrow_up_rounded,
|
||||
size: 26,
|
||||
color: InsightsColors.blue,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Body extends StatelessWidget {
|
||||
const _Body({required this.state});
|
||||
|
||||
final InsightsStateLoaded state;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cubit = context.read<InsightsCubit>();
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
InsightsTabs(
|
||||
panels: cubit.panels,
|
||||
selected: state.panel,
|
||||
onSelected: cubit.setPanel,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
opacity: state.isRefreshing ? 0.5 : 1,
|
||||
child: _PanelContent(state: state, canAdd: cubit.canAdd),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PanelContent extends StatelessWidget {
|
||||
const _PanelContent({required this.state, required this.canAdd});
|
||||
|
||||
final InsightsStateLoaded state;
|
||||
final bool canAdd;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cubit = context.read<InsightsCubit>();
|
||||
final s = S.of(context);
|
||||
final payload = state.payload;
|
||||
|
||||
final Widget content;
|
||||
switch (state.panel) {
|
||||
case InsightsPanel.weight:
|
||||
content = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
InsightsHead(
|
||||
headline: payload.weight.headline,
|
||||
unit: payload.weight.unit,
|
||||
deltaText: payload.weight.deltaText,
|
||||
isDeltaHighlighted: payload.weight.delta != null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
WeightChart(
|
||||
model: payload.weight,
|
||||
emptyText: canAdd
|
||||
? s.insights_add_weight_empty
|
||||
: s.insights_not_enough,
|
||||
),
|
||||
],
|
||||
);
|
||||
case InsightsPanel.bmi:
|
||||
content = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (payload.bmi.values.isNotEmpty) ...[
|
||||
InsightsHead(
|
||||
headline: payload.bmi.headline,
|
||||
unit: s.insights_unit_bmi,
|
||||
deltaText: payload.bmi.deltaText,
|
||||
isDeltaHighlighted: payload.bmi.delta != null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
BmiChart(model: payload.bmi),
|
||||
],
|
||||
);
|
||||
case InsightsPanel.measures:
|
||||
final visibility = state.seriesVisibility.measure;
|
||||
content = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
InsightsHead(
|
||||
headline: payload.measure.headline,
|
||||
unit: s.insights_unit_cm_waist,
|
||||
deltaText: payload.measure.deltaText,
|
||||
isDeltaHighlighted: payload.measure.waist.length > 1,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SeriesChips(
|
||||
chips: [
|
||||
SeriesChipData(
|
||||
label: s.insights_series_waist,
|
||||
color: InsightsColors.waist,
|
||||
isVisible: visibility[0],
|
||||
),
|
||||
SeriesChipData(
|
||||
label: s.insights_series_chest,
|
||||
color: InsightsColors.chest,
|
||||
isVisible: visibility[1],
|
||||
),
|
||||
SeriesChipData(
|
||||
label: s.insights_series_hips,
|
||||
color: InsightsColors.hips,
|
||||
isVisible: visibility[2],
|
||||
),
|
||||
],
|
||||
onToggle: (index) =>
|
||||
cubit.toggleSeries(InsightsPanel.measures, index),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
MeasureChart(
|
||||
model: payload.measure,
|
||||
visibility: visibility,
|
||||
emptyText: canAdd
|
||||
? s.insights_add_measures_empty
|
||||
: s.insights_not_enough,
|
||||
),
|
||||
],
|
||||
);
|
||||
case InsightsPanel.daily:
|
||||
return _DailyPanel(state: state);
|
||||
case InsightsPanel.intra:
|
||||
return _IntraPanel(state: state);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
PeriodPills<BodyPeriod>(
|
||||
periods: BodyPeriod.values,
|
||||
selected: state.bodyPeriod,
|
||||
labelOf: (period) => period.pillLabel(s),
|
||||
keyOf: (period) => period.apiValue,
|
||||
onSelected: cubit.setBodyPeriod,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
content,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// «КБЖУ по дням». Период пока фиксирован: перезапрос появится вместе с
|
||||
/// эндпоинтом `GET /api/profile/insights` (итерация 2).
|
||||
class _DailyPanel extends StatelessWidget {
|
||||
const _DailyPanel({required this.state});
|
||||
|
||||
final InsightsStateLoaded state;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
final cubit = context.read<InsightsCubit>();
|
||||
final daily = state.payload.daily;
|
||||
final visibility = state.seriesVisibility.daily;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
PeriodPills<DailyPeriod>(
|
||||
periods: DailyPeriod.values,
|
||||
selected: DailyPeriod.twoWeeks,
|
||||
labelOf: (period) => period.pillLabel(s),
|
||||
keyOf: (period) => period.apiValue,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (daily == null)
|
||||
InsightsChartPlaceholder(text: s.insights_not_enough)
|
||||
else ...[
|
||||
InsightsHead(
|
||||
headline: daily.headline,
|
||||
unit: s.insights_unit_kcal_avg,
|
||||
deltaText: daily.deltaText,
|
||||
isDeltaHighlighted: daily.percent != null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SeriesChips(
|
||||
chips: [
|
||||
SeriesChipData(
|
||||
label: s.insights_series_calories,
|
||||
color: InsightsColors.calories,
|
||||
isVisible: visibility[0],
|
||||
),
|
||||
SeriesChipData(
|
||||
label: s.insights_series_proteins,
|
||||
color: InsightsColors.proteins,
|
||||
isVisible: visibility[1],
|
||||
),
|
||||
SeriesChipData(
|
||||
label: s.insights_series_fats,
|
||||
color: InsightsColors.fats,
|
||||
isVisible: visibility[2],
|
||||
),
|
||||
SeriesChipData(
|
||||
label: s.insights_series_carbs,
|
||||
color: InsightsColors.carbs,
|
||||
isVisible: visibility[3],
|
||||
),
|
||||
],
|
||||
onToggle: (index) => cubit.toggleSeries(InsightsPanel.daily, index),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DailyKbjuChart(model: daily, visibility: visibility),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// «В течение дня». Навигация по датам неактивна до итерации 2.
|
||||
class _IntraPanel extends StatelessWidget {
|
||||
const _IntraPanel({required this.state});
|
||||
|
||||
final InsightsStateLoaded state;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
final cubit = context.read<InsightsCubit>();
|
||||
final intra = state.payload.intra;
|
||||
final visibility = state.seriesVisibility.intra;
|
||||
|
||||
if (intra == null) {
|
||||
return InsightsChartPlaceholder(text: s.insights_not_enough);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
IntraDateNav(model: intra),
|
||||
const SizedBox(height: 16),
|
||||
SeriesChips(
|
||||
chips: [
|
||||
SeriesChipData(
|
||||
label: s.insights_series_proteins,
|
||||
color: InsightsColors.proteins,
|
||||
isVisible: visibility[0],
|
||||
),
|
||||
SeriesChipData(
|
||||
label: s.insights_series_fats,
|
||||
color: InsightsColors.fats,
|
||||
isVisible: visibility[1],
|
||||
),
|
||||
SeriesChipData(
|
||||
label: s.insights_series_carbs,
|
||||
color: InsightsColors.carbs,
|
||||
isVisible: visibility[2],
|
||||
),
|
||||
SeriesChipData(
|
||||
label: s.insights_series_satiety,
|
||||
color: InsightsColors.satiety,
|
||||
isVisible: visibility[3],
|
||||
),
|
||||
SeriesChipData(
|
||||
label: s.insights_series_mood,
|
||||
color: InsightsColors.mood,
|
||||
isVisible: visibility[4],
|
||||
),
|
||||
SeriesChipData(
|
||||
label: s.insights_series_sleep,
|
||||
color: InsightsColors.sleepLine,
|
||||
isVisible: state.showSleep,
|
||||
),
|
||||
],
|
||||
onToggle: (index) {
|
||||
if (index == 5) {
|
||||
cubit.toggleSleep();
|
||||
} else {
|
||||
cubit.toggleSeries(InsightsPanel.intra, index);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
IntraDayChart(
|
||||
model: intra,
|
||||
visibility: visibility,
|
||||
showSleep: state.showSleep,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
IntraLegend(model: intra),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
s.insights_intra_hint,
|
||||
key: const Key('insights_intra_hint'),
|
||||
style: const TextStyle(fontSize: 13, color: InsightsColors.subtitle),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'package:flutter/painting.dart';
|
||||
|
||||
/// Токены из `latest-tmpls/src/scss/pages/diary.scss` (`&__analytics*`).
|
||||
abstract final class InsightsColors {
|
||||
static const Color blue = Color(0xFF2F80ED);
|
||||
static const Color accentBackground = Color(0xFFF6FAFF);
|
||||
static const Color title = Color(0xFF323F4B);
|
||||
static const Color subtitle = Color(0xFF7B8794);
|
||||
static const Color border = Color(0xFFE6E6E6);
|
||||
static const Color positive = Color(0xFF019A3E);
|
||||
|
||||
static const Color calories = Color(0xFF323F4B);
|
||||
static const Color proteins = Color(0xFF2F80ED);
|
||||
static const Color fats = Color(0xFFF2994A);
|
||||
static const Color carbs = Color(0xFF0D6EFD);
|
||||
static const Color satiety = Color(0xFF019A3E);
|
||||
static const Color mood = Color(0xFFEB555C);
|
||||
|
||||
static const Color waist = Color(0xFF2F80ED);
|
||||
static const Color chest = Color(0xFF9B51E0);
|
||||
static const Color hips = Color(0xFFF2994A);
|
||||
|
||||
static const Color wake = Color(0xFFF2994A);
|
||||
static const Color sleepLine = Color(0xFF7B8794);
|
||||
static const Color sleepShade = Color(0x1A7B8794);
|
||||
|
||||
static const Color bmiNormalZone = Color(0x14019A3E);
|
||||
static const Color bmiOverweightZone = Color(0x1AF2994A);
|
||||
static const Color bmiObesityZone = Color(0x14EB555C);
|
||||
|
||||
static const Color cardShadow = Color(0x26000000);
|
||||
static const Color fabShadow = Color(0x592F80ED);
|
||||
}
|
||||
|
||||
/// Высота области графика.
|
||||
const double insightsChartHeight = 270;
|
||||
42
lib/features/insights/presentation/widgets/insights_fab.dart
Normal file
42
lib/features/insights/presentation/widgets/insights_fab.dart
Normal file
@@ -0,0 +1,42 @@
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Круглая кнопка «+» (52px) справа-снизу карточки.
|
||||
class InsightsFab extends StatelessWidget {
|
||||
const InsightsFab({super.key, required this.onPressed, this.tooltip});
|
||||
|
||||
final VoidCallback onPressed;
|
||||
final String? tooltip;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Tooltip(
|
||||
message: tooltip ?? '',
|
||||
child: Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
decoration: const BoxDecoration(
|
||||
color: InsightsColors.blue,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: InsightsColors.fabShadow,
|
||||
blurRadius: 20,
|
||||
offset: Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
shape: const CircleBorder(),
|
||||
child: InkWell(
|
||||
key: const Key('insights_fab'),
|
||||
customBorder: const CircleBorder(),
|
||||
onTap: onPressed,
|
||||
child: const Icon(Icons.add, color: Colors.white, size: 28),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Крупное число + единица + строка с дельтой.
|
||||
class InsightsHead extends StatelessWidget {
|
||||
const InsightsHead({
|
||||
super.key,
|
||||
required this.headline,
|
||||
required this.unit,
|
||||
required this.deltaText,
|
||||
this.isDeltaHighlighted = true,
|
||||
});
|
||||
|
||||
final String headline;
|
||||
final String unit;
|
||||
final String deltaText;
|
||||
|
||||
/// Зелёная дельта при наличии изменения, иначе нейтральный серый.
|
||||
final bool isDeltaHighlighted;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(
|
||||
headline,
|
||||
key: const Key('insights_headline'),
|
||||
style: const TextStyle(
|
||||
fontSize: 34,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: InsightsColors.title,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
if (unit.isNotEmpty) ...[
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
unit,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: InsightsColors.subtitle,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (deltaText.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
deltaText,
|
||||
key: const Key('insights_delta'),
|
||||
style: TextStyle(
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: isDeltaHighlighted
|
||||
? InsightsColors.positive
|
||||
: InsightsColors.subtitle,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'package:eatvio_app/features/insights/presentation/cubit/insights_panel.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
extension InsightsPanelLabel on InsightsPanel {
|
||||
String label(S s) {
|
||||
switch (this) {
|
||||
case InsightsPanel.weight:
|
||||
return s.insights_tab_weight;
|
||||
case InsightsPanel.bmi:
|
||||
return s.insights_tab_bmi;
|
||||
case InsightsPanel.measures:
|
||||
return s.insights_tab_measures;
|
||||
case InsightsPanel.daily:
|
||||
return s.insights_tab_daily;
|
||||
case InsightsPanel.intra:
|
||||
return s.insights_tab_intra;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Горизонтально прокручиваемые вкладки (`l-tab`).
|
||||
class InsightsTabs extends StatelessWidget {
|
||||
const InsightsTabs({
|
||||
super.key,
|
||||
required this.panels,
|
||||
required this.selected,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
final List<InsightsPanel> panels;
|
||||
final InsightsPanel selected;
|
||||
final ValueChanged<InsightsPanel> onSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
for (final panel in panels)
|
||||
InkWell(
|
||||
key: Key('insights_tab_${panel.name}'),
|
||||
onTap: () => onSelected(panel),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: panel == selected
|
||||
? InsightsColors.blue
|
||||
: Colors.transparent,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
panel.label(s),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: panel == selected
|
||||
? FontWeight.w700
|
||||
: FontWeight.w600,
|
||||
color: panel == selected
|
||||
? InsightsColors.blue
|
||||
: InsightsColors.subtitle,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
130
lib/features/insights/presentation/widgets/intra_date_nav.dart
Normal file
130
lib/features/insights/presentation/widgets/intra_date_nav.dart
Normal file
@@ -0,0 +1,130 @@
|
||||
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Навигация по дням для вкладки «В течение дня»: `‹ дата ›` и выбор даты.
|
||||
///
|
||||
/// Колбэки `null` — навигация неактивна (данные других дней ещё не подключены).
|
||||
class IntraDateNav extends StatelessWidget {
|
||||
const IntraDateNav({
|
||||
super.key,
|
||||
required this.model,
|
||||
this.onPreviousDay,
|
||||
this.onNextDay,
|
||||
this.onDatePicked,
|
||||
});
|
||||
|
||||
final IntraChartModel model;
|
||||
final VoidCallback? onPreviousDay;
|
||||
final VoidCallback? onNextDay;
|
||||
final ValueChanged<DateTime>? onDatePicked;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
final canGoNext = !model.isToday && onNextDay != null;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: InsightsColors.accentBackground,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_NavButton(
|
||||
key: const Key('insights_intra_prev'),
|
||||
icon: Icons.chevron_left_rounded,
|
||||
tooltip: s.insights_prev_day,
|
||||
onPressed: onPreviousDay,
|
||||
),
|
||||
InkWell(
|
||||
key: const Key('insights_intra_pick'),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
onTap: onDatePicked == null ? null : () => _pickDate(context),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minWidth: 72),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.calendar_today_rounded,
|
||||
size: 14,
|
||||
color: InsightsColors.blue,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
model.dateLabel,
|
||||
key: const Key('insights_intra_date_label'),
|
||||
style: const TextStyle(
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: InsightsColors.title,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_NavButton(
|
||||
key: const Key('insights_intra_next'),
|
||||
icon: Icons.chevron_right_rounded,
|
||||
tooltip: s.insights_next_day,
|
||||
onPressed: canGoNext ? onNextDay : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickDate(BuildContext context) async {
|
||||
final lastDate = DateTime.tryParse(model.maxDate) ?? DateTime.now();
|
||||
final current = DateTime.tryParse(model.date) ?? lastDate;
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: current.isAfter(lastDate) ? lastDate : current,
|
||||
firstDate: DateTime(2000),
|
||||
lastDate: lastDate,
|
||||
helpText: S.of(context).insights_pick_date,
|
||||
);
|
||||
if (picked != null) {
|
||||
onDatePicked?.call(picked);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _NavButton extends StatelessWidget {
|
||||
const _NavButton({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.tooltip,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String tooltip;
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Opacity(
|
||||
opacity: onPressed == null ? 0.35 : 1,
|
||||
child: SizedBox(
|
||||
width: 36,
|
||||
height: 36,
|
||||
child: IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
tooltip: tooltip,
|
||||
onPressed: onPressed,
|
||||
icon: Icon(icon, color: InsightsColors.blue),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
96
lib/features/insights/presentation/widgets/intra_legend.dart
Normal file
96
lib/features/insights/presentation/widgets/intra_legend.dart
Normal file
@@ -0,0 +1,96 @@
|
||||
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Легенда под графиком «В течение дня»: подъём, отход ко сну, затемнение сна.
|
||||
class IntraLegend extends StatelessWidget {
|
||||
const IntraLegend({super.key, required this.model});
|
||||
|
||||
final IntraChartModel model;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
final wakeText =
|
||||
model.wakeText ??
|
||||
(model.wakeLabel == null ? null : s.insights_wake_at(model.wakeLabel!));
|
||||
final sleepText =
|
||||
model.sleepText ??
|
||||
(model.sleepLabel == null
|
||||
? null
|
||||
: s.insights_sleep_at(model.sleepLabel!));
|
||||
|
||||
return Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
if (wakeText != null)
|
||||
_LegendItem(
|
||||
key: const Key('insights_legend_wake'),
|
||||
color: InsightsColors.wake,
|
||||
text: wakeText,
|
||||
isDashed: true,
|
||||
),
|
||||
if (sleepText != null)
|
||||
_LegendItem(
|
||||
key: const Key('insights_legend_sleep'),
|
||||
color: InsightsColors.sleepLine,
|
||||
text: sleepText,
|
||||
isDashed: true,
|
||||
),
|
||||
_LegendItem(
|
||||
key: const Key('insights_legend_shade'),
|
||||
color: InsightsColors.sleepShade,
|
||||
text: s.insights_sleep_shade,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LegendItem extends StatelessWidget {
|
||||
const _LegendItem({
|
||||
super.key,
|
||||
required this.color,
|
||||
required this.text,
|
||||
this.isDashed = false,
|
||||
});
|
||||
|
||||
final Color color;
|
||||
final String text;
|
||||
final bool isDashed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
isDashed
|
||||
? Container(
|
||||
width: 18,
|
||||
height: 0,
|
||||
decoration: BoxDecoration(
|
||||
border: Border(top: BorderSide(color: color, width: 2)),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
width: 18,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5,
|
||||
color: InsightsColors.subtitle,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
115
lib/features/insights/presentation/widgets/period_pills.dart
Normal file
115
lib/features/insights/presentation/widgets/period_pills.dart
Normal file
@@ -0,0 +1,115 @@
|
||||
import 'package:eatvio_app/features/insights/models/insights_period.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
extension BodyPeriodLabel on BodyPeriod {
|
||||
String pillLabel(S s) {
|
||||
switch (this) {
|
||||
case BodyPeriod.month:
|
||||
return s.insights_period_1m;
|
||||
case BodyPeriod.threeMonths:
|
||||
return s.insights_period_3m;
|
||||
case BodyPeriod.sixMonths:
|
||||
return s.insights_period_6m;
|
||||
case BodyPeriod.year:
|
||||
return s.insights_period_1y;
|
||||
case BodyPeriod.all:
|
||||
return s.insights_period_all;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension DailyPeriodLabel on DailyPeriod {
|
||||
String pillLabel(S s) {
|
||||
switch (this) {
|
||||
case DailyPeriod.week:
|
||||
return s.insights_period_1w;
|
||||
case DailyPeriod.twoWeeks:
|
||||
return s.insights_period_2w;
|
||||
case DailyPeriod.month:
|
||||
return s.insights_period_1m;
|
||||
case DailyPeriod.threeMonths:
|
||||
return s.insights_period_3m;
|
||||
case DailyPeriod.all:
|
||||
return s.insights_period_all;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Пилюли выбора периода (`1М 3М 6М 1Г Всё время`).
|
||||
class PeriodPills<T> extends StatelessWidget {
|
||||
const PeriodPills({
|
||||
super.key,
|
||||
required this.periods,
|
||||
required this.selected,
|
||||
required this.labelOf,
|
||||
required this.keyOf,
|
||||
this.onSelected,
|
||||
});
|
||||
|
||||
final List<T> periods;
|
||||
final T selected;
|
||||
final String Function(T period) labelOf;
|
||||
final String Function(T period) keyOf;
|
||||
|
||||
/// `null` — пилюли неинтерактивны (данные для периода ещё не подключены).
|
||||
final ValueChanged<T>? onSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
for (final period in periods)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 6),
|
||||
child: _Pill(
|
||||
key: Key('insights_period_${keyOf(period)}'),
|
||||
label: labelOf(period),
|
||||
isActive: period == selected,
|
||||
onTap: onSelected == null ? null : () => onSelected!(period),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Pill extends StatelessWidget {
|
||||
const _Pill({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.isActive,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final bool isActive;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: isActive ? InsightsColors.blue : InsightsColors.accentBackground,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 7),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: isActive ? Colors.white : InsightsColors.subtitle,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
76
lib/features/insights/presentation/widgets/series_chips.dart
Normal file
76
lib/features/insights/presentation/widgets/series_chips.dart
Normal file
@@ -0,0 +1,76 @@
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class SeriesChipData {
|
||||
const SeriesChipData({
|
||||
required this.label,
|
||||
required this.color,
|
||||
required this.isVisible,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final Color color;
|
||||
final bool isVisible;
|
||||
}
|
||||
|
||||
/// Чипы-переключатели серий под графиком.
|
||||
class SeriesChips extends StatelessWidget {
|
||||
const SeriesChips({super.key, required this.chips, required this.onToggle});
|
||||
|
||||
final List<SeriesChipData> chips;
|
||||
final ValueChanged<int> onToggle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (var i = 0; i < chips.length; i++)
|
||||
Opacity(
|
||||
opacity: chips[i].isVisible ? 1 : 0.45,
|
||||
child: InkWell(
|
||||
key: Key('insights_series_$i'),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
onTap: () => onToggle(i),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 7,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: InsightsColors.border, width: 1.5),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: chips[i].color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
chips[i].label,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: InsightsColors.title,
|
||||
decoration: chips[i].isVisible
|
||||
? TextDecoration.none
|
||||
: TextDecoration.lineThrough,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
94
lib/features/insights/repositories/insights_repository.dart
Normal file
94
lib/features/insights/repositories/insights_repository.dart
Normal file
@@ -0,0 +1,94 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/features/insights/domain/insights_text_builder.dart';
|
||||
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
|
||||
import 'package:eatvio_app/features/insights/models/insights_period.dart';
|
||||
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
|
||||
/// Данные аналитики недоступны текущему пользователю (HTTP 403): консультация
|
||||
/// не активна либо это чужой профиль без доступа.
|
||||
class InsightsForbidden implements Exception {
|
||||
const InsightsForbidden();
|
||||
|
||||
@override
|
||||
String toString() => 'InsightsForbidden';
|
||||
}
|
||||
|
||||
typedef InsightsTextBuilderFactory = InsightsTextBuilder Function();
|
||||
|
||||
/// Итерация 1: собирает [InsightsPayloadModel] из существующих эндпоинтов
|
||||
/// профиля. Во второй итерации источник заменяется одним запросом
|
||||
/// `GET /api/profile/insights`.
|
||||
class InsightsRepository {
|
||||
InsightsRepository({
|
||||
required this.profileRepository,
|
||||
InsightsTextBuilderFactory? textBuilderFactory,
|
||||
}) : _textBuilderFactory =
|
||||
textBuilderFactory ?? (() => InsightsTextBuilder(S.current));
|
||||
|
||||
final ProfileRepository profileRepository;
|
||||
final InsightsTextBuilderFactory _textBuilderFactory;
|
||||
|
||||
/// Рост в сантиметрах для расчёта подсказки ИМТ; `null`, если не указан или
|
||||
/// профиль не удалось загрузить (кроме 403 — он пробрасывается как [InsightsForbidden]).
|
||||
Future<int?> getHeightCm({String? slug}) async {
|
||||
try {
|
||||
final details = slug == null
|
||||
? await profileRepository.getMyProfileDetails()
|
||||
: await profileRepository.getProfileDetailsBySlug(slug);
|
||||
return details.height > 0 ? details.height : null;
|
||||
} on DioException catch (e) {
|
||||
_throwIfForbidden(e);
|
||||
return null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<InsightsPayloadModel> getInsights({
|
||||
String? slug,
|
||||
required BodyPeriod bodyPeriod,
|
||||
int? heightCm,
|
||||
}) async {
|
||||
try {
|
||||
final (weights, circumferences) = await (
|
||||
profileRepository.getWeight(period: bodyPeriod.apiValue, slug: slug),
|
||||
profileRepository.getCircumferences(
|
||||
period: bodyPeriod.apiValue,
|
||||
slug: slug,
|
||||
),
|
||||
).wait;
|
||||
return _textBuilderFactory().build(
|
||||
weights: weights,
|
||||
circumferences: circumferences,
|
||||
period: bodyPeriod,
|
||||
heightCm: heightCm,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
_throwIfForbidden(e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> addWeight({required double weight}) {
|
||||
return profileRepository.addWeight(weight: weight);
|
||||
}
|
||||
|
||||
Future<void> addCircumference({
|
||||
required int chestCircumference,
|
||||
required int waistCircumference,
|
||||
required int hipCircumference,
|
||||
}) {
|
||||
return profileRepository.addCircumference(
|
||||
chestCircumference: chestCircumference,
|
||||
waistCircumference: waistCircumference,
|
||||
hipCircumference: hipCircumference,
|
||||
);
|
||||
}
|
||||
|
||||
void _throwIfForbidden(DioException e) {
|
||||
if (e.response?.statusCode == 403) {
|
||||
throw const InsightsForbidden();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
|
||||
import 'package:eatvio_app/features/menu/presentation/bloc/menu_cubit/menu_state.dart';
|
||||
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -7,7 +8,11 @@ import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||
|
||||
class MenuCubit extends Cubit<MenuState> {
|
||||
final ProfileRepository profileRepository;
|
||||
MenuCubit(this.profileRepository) : super(const MenuState.initial());
|
||||
final SessionLifecycleService sessionLifecycleService;
|
||||
MenuCubit(this.profileRepository, this.sessionLifecycleService)
|
||||
: super(const MenuState.initial());
|
||||
|
||||
Future<void> logout() => sessionLifecycleService.logout();
|
||||
|
||||
Future<void> getMyProfile() async {
|
||||
emit(const MenuState.loading());
|
||||
@@ -23,7 +28,10 @@ class MenuCubit extends Cubit<MenuState> {
|
||||
|
||||
Future<String?> makePassKey() async {
|
||||
try {
|
||||
final passKey = await profileRepository.getPassKey(routeName: 'product_nutritional_value.index', routeParams: {});
|
||||
final passKey = await profileRepository.getPassKey(
|
||||
routeName: 'product_nutritional_value.index',
|
||||
routeParams: {},
|
||||
);
|
||||
return passKey;
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
|
||||
import 'package:eatvio_app/core/const.dart';
|
||||
import 'package:eatvio_app/core/firebase_api.dart';
|
||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||
import 'package:eatvio_app/core/token_storage.dart';
|
||||
import 'package:eatvio_app/core/utils/image_constant.dart';
|
||||
import 'package:eatvio_app/core/utils/date_time_utils.dart';
|
||||
import 'package:eatvio_app/core/widgets/data_error_widget.dart';
|
||||
import 'package:eatvio_app/core/widgets/loader.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/core/websockets/ws_manager.dart';
|
||||
import 'package:eatvio_app/features/consultant_rating/presentation/consultant_rating_screen.dart';
|
||||
import 'package:eatvio_app/features/finances_and_consultations_archive/presentation/consultant_finances_screen.dart';
|
||||
import 'package:eatvio_app/features/finances_and_consultations_archive/presentation/user_purchases_screen.dart';
|
||||
import 'package:eatvio_app/features/menu/presentation/bloc/menu_cubit/menu_cubit.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
|
||||
import 'package:eatvio_app/features/menu/presentation/bloc/menu_cubit/menu_state.dart';
|
||||
import 'package:eatvio_app/features/my_diets/presentation/my_diets_screen.dart';
|
||||
import 'package:eatvio_app/features/my_plans/presentation/my_plans_screen.dart';
|
||||
@@ -155,6 +148,7 @@ class MenuScreen extends StatelessWidget {
|
||||
|
||||
Future<Null> Function() _onTapLogout(BuildContext context) {
|
||||
return () async {
|
||||
final menuCubit = context.read<MenuCubit>();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => CupertinoAlertDialog(
|
||||
@@ -162,25 +156,8 @@ class MenuScreen extends StatelessWidget {
|
||||
actions: [
|
||||
CupertinoDialogAction(
|
||||
onPressed: () async {
|
||||
try {
|
||||
// Logout revokes the Sanctum token. Unregister first, or
|
||||
// FCM / VoIP tokens stay on the server and calls still ring.
|
||||
await sl<FirebaseApi>().unregisterPushTokens();
|
||||
} catch (e, s) {
|
||||
await sl<ErrorReporter>().report(e, s, origin: 'MenuScreen');
|
||||
}
|
||||
try {
|
||||
await sl<Dio>().post('${sl<AppConfig>().baseUrl}/api/profile/logout');
|
||||
} catch (e, s) {
|
||||
await sl<ErrorReporter>().report(e, s, origin: 'MenuScreen');
|
||||
} finally {
|
||||
await sl<VideoCallSessionCubit>().endCall();
|
||||
await sl<WsManager>().clear();
|
||||
await TokenStorage.removeTokenInStorage();
|
||||
if (context.mounted) {
|
||||
GoRouter.of(context).go('/auth-screen', extra: true);
|
||||
}
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
await menuCubit.logout();
|
||||
},
|
||||
isDestructiveAction: true,
|
||||
child: const Text("Выйти"),
|
||||
|
||||
@@ -16,6 +16,11 @@ class NotificationsCubit extends Cubit<NotificationsState> {
|
||||
StreamSubscription? _newNotificationsSubscription;
|
||||
NotificationsCubit({required this.wsManager, required this.notificationsRepository}) : super(const NotificationsState.empty());
|
||||
|
||||
void reset() {
|
||||
_notifications.clear();
|
||||
if (!isClosed) emit(const NotificationsState.empty());
|
||||
}
|
||||
|
||||
Future<void> connectToWebSocket() async {
|
||||
try {
|
||||
await _newNotificationsSubscription?.cancel();
|
||||
|
||||
@@ -14,6 +14,7 @@ import 'package:flutter/material.dart';
|
||||
Future<void> showConsultationPaymentFlow(
|
||||
BuildContext context, {
|
||||
required int planId,
|
||||
double? amount,
|
||||
Future<bool?> Function(BuildContext)? showGate,
|
||||
Future<PromoCodeResult?> Function(
|
||||
BuildContext, {
|
||||
@@ -21,7 +22,7 @@ Future<void> showConsultationPaymentFlow(
|
||||
Future<bool> Function()? onRemove,
|
||||
PromoCodeModel? initialPromo,
|
||||
})? showForm,
|
||||
Future<void> Function(BuildContext, {required int planId})? showSbp,
|
||||
Future<void> Function(BuildContext, {required int planId, double? amount})? showSbp,
|
||||
Future<PromoValidateResponse> Function({
|
||||
required int planId,
|
||||
required String promoCode,
|
||||
@@ -65,7 +66,7 @@ Future<void> showConsultationPaymentFlow(
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
await sbp(context, planId: planId);
|
||||
await sbp(context, planId: planId, amount: amount);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -92,7 +93,7 @@ Future<void> showConsultationPaymentFlow(
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
await sbp(context, planId: planId);
|
||||
await sbp(context, planId: planId, amount: amount);
|
||||
}
|
||||
|
||||
Future<bool?> _showGate(BuildContext context) {
|
||||
@@ -113,8 +114,8 @@ Future<PromoCodeResult?> _showForm(
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showSbp(BuildContext context, {required int planId}) {
|
||||
return showSbpBanksBottomSheet(context, planId: planId);
|
||||
Future<void> _showSbp(BuildContext context, {required int planId, double? amount}) {
|
||||
return showSbpBanksBottomSheet(context, planId: planId, amount: amount);
|
||||
}
|
||||
|
||||
Future<PromoValidateResponse> _defaultValidatePromo({
|
||||
|
||||
@@ -8,18 +8,23 @@ import 'package:eatvio_app/core/widgets/custom_outlined_button.dart';
|
||||
import 'package:eatvio_app/core/widgets/loader.dart';
|
||||
import 'package:eatvio_app/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_cubit.dart';
|
||||
import 'package:eatvio_app/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_state.dart';
|
||||
import 'package:eatvio_app/features/payment/presentation/widgets/sbp_qr_bottom_sheet.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:sbp/models/c2bmembers_model.dart';
|
||||
|
||||
/// Показывает bottom sheet выбора банка СБП для оплаты консультации.
|
||||
Future<void> showSbpBanksBottomSheet(BuildContext context, {required int planId}) {
|
||||
Future<void> showSbpBanksBottomSheet(
|
||||
BuildContext context, {
|
||||
required int planId,
|
||||
double? amount,
|
||||
}) {
|
||||
final height = MediaQuery.sizeOf(context).height * 0.55;
|
||||
return showBottomSheetWithBlur(
|
||||
context: context,
|
||||
height: height,
|
||||
child: SbpBanksBottomSheet(planId: planId),
|
||||
child: SbpBanksBottomSheet(planId: planId, amount: amount),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,9 +33,13 @@ Future<void> showSbpBanksBottomSheet(BuildContext context, {required int planId}
|
||||
/// При открытии вызывает [ConsultationPaymentCubit.startPayment] с [planId]
|
||||
/// и показывает [Loader], пока загружаются checkout_url и список банков.
|
||||
class SbpBanksBottomSheet extends StatefulWidget {
|
||||
const SbpBanksBottomSheet({super.key, required this.planId, this.cubit});
|
||||
const SbpBanksBottomSheet({super.key, required this.planId, this.amount, this.cubit});
|
||||
|
||||
final int planId;
|
||||
|
||||
/// Сумма к оплате в рублях — передаётся в QR-шит для отображения.
|
||||
final double? amount;
|
||||
|
||||
final ConsultationPaymentCubit? cubit;
|
||||
|
||||
@override
|
||||
@@ -77,6 +86,32 @@ class _SbpBanksBottomSheetState extends State<SbpBanksBottomSheet> {
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
// QR-кнопка доступна сразу после получения checkout_url,
|
||||
// не дожидаясь списка банков: на устройствах без банковских
|
||||
// приложений (iOS в РФ) это единственный путь оплаты.
|
||||
BlocBuilder<ConsultationPaymentCubit, ConsultationPaymentState>(
|
||||
buildWhen: (previous, current) =>
|
||||
previous.checkoutUrl != current.checkoutUrl,
|
||||
builder: (context, state) {
|
||||
final checkoutUrl = state.checkoutUrl;
|
||||
if (checkoutUrl == null || checkoutUrl.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
child: CustomOutlinedButton(
|
||||
text: s.pay_by_qr_code,
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
||||
),
|
||||
onPressed: _onShowQrTap,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: BlocBuilder<ConsultationPaymentCubit, ConsultationPaymentState>(
|
||||
@@ -122,6 +157,23 @@ class _SbpBanksBottomSheetState extends State<SbpBanksBottomSheet> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Открывает QR-шит поверх шита банков; при подтверждённой оплате
|
||||
/// закрывает и родительский шит.
|
||||
Future<void> _onShowQrTap() async {
|
||||
final paid = await showSbpQrBottomSheet(
|
||||
context,
|
||||
planId: widget.planId,
|
||||
cubit: _cubit,
|
||||
amount: widget.amount,
|
||||
);
|
||||
if (paid == true && mounted) {
|
||||
final navigator = Navigator.of(context);
|
||||
if (navigator.canPop()) {
|
||||
navigator.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onBankTap(C2bmemberModel bank) async {
|
||||
final opened = await _cubit.openBank(bank: bank);
|
||||
if (opened && mounted) {
|
||||
|
||||
@@ -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,9 +9,9 @@ abstract class CircumferenceModel with _$CircumferenceModel {
|
||||
const factory CircumferenceModel({
|
||||
@JsonKey(name: "id") required int id,
|
||||
@JsonKey(name: "user_id") required int userId,
|
||||
@JsonKey(name: "chest_circumference") required int chestCircumference,
|
||||
@JsonKey(name: "waist_circumference") required int waistCircumference,
|
||||
@JsonKey(name: "hip_circumference") required int hipCircumference,
|
||||
@JsonKey(name: "chest_circumference") required double chestCircumference,
|
||||
@JsonKey(name: "waist_circumference") required double waistCircumference,
|
||||
@JsonKey(name: "hip_circumference") required double hipCircumference,
|
||||
@JsonKey(name: "created_at") required DateTime createdAt,
|
||||
@JsonKey(name: "updated_at") required DateTime updatedAt,
|
||||
}) = _CircumferenceModel;
|
||||
|
||||
@@ -9,10 +9,10 @@ abstract class WeightModel with _$WeightModel {
|
||||
const factory WeightModel({
|
||||
@JsonKey(name: "id") required int id,
|
||||
@JsonKey(name: "user_id") required int userId,
|
||||
@JsonKey(name: "weight") required int weight,
|
||||
@JsonKey(name: "weight") required double weight,
|
||||
@JsonKey(name: "created_at") required DateTime createdAt,
|
||||
@JsonKey(name: "updated_at") required DateTime updatedAt,
|
||||
@JsonKey(name: "bmi") required double bmi,
|
||||
@JsonKey(name: "bmi") double? bmi,
|
||||
}) = _WeightModel;
|
||||
|
||||
factory WeightModel.fromJson(Map<String, dynamic> json) => _$WeightModelFromJson(json);
|
||||
|
||||
@@ -17,11 +17,14 @@ class ProfileCubit extends Cubit<ProfileState> {
|
||||
final ProfileRepository profileRepository;
|
||||
final ChatRepository chatRepository;
|
||||
final ConsultantRepository consultantRepository;
|
||||
String _currentPeriod = '1';
|
||||
|
||||
/// Bottom-nav / own profile instance, used to refresh posts after create/update/delete.
|
||||
static ProfileCubit? myProfileCubit;
|
||||
|
||||
static void clearMyProfileReference() {
|
||||
myProfileCubit = null;
|
||||
}
|
||||
|
||||
ProfileCubit({required this.profileRepository, required this.chatRepository, required this.consultantRepository}) : super(const ProfileState.loading());
|
||||
|
||||
Future<void> getProfileBySlug(String slug) async {
|
||||
@@ -51,10 +54,9 @@ class ProfileCubit extends Cubit<ProfileState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> getMyProfile({String period = '1'}) async {
|
||||
Future<void> getMyProfile() async {
|
||||
myProfileCubit = this;
|
||||
emit(const ProfileState.loading());
|
||||
_currentPeriod = period;
|
||||
try {
|
||||
final profile = await profileRepository.getMyProfile();
|
||||
List<ConsultantCertPhotoModel> certPhotos = await consultantRepository.getConsultantCertPhotos();
|
||||
@@ -65,8 +67,6 @@ class ProfileCubit extends Cubit<ProfileState> {
|
||||
isMyProfile: true,
|
||||
profileDetails: await profileRepository.getMyProfileDetails(),
|
||||
blogPosts: await profileRepository.getBlogPostsByAuthorSlug(slug: profile.slug),
|
||||
weightPoints: await profileRepository.getWeight(period: period),
|
||||
circumferencePoints: await profileRepository.getCircumferences(period: period),
|
||||
consultantInfo: profile.roles.contains('consultant') ? await consultantRepository.getConsultantRequestStatus(profile.slug) : null,
|
||||
certPhotos: certPhotos,
|
||||
),
|
||||
@@ -106,8 +106,6 @@ class ProfileCubit extends Cubit<ProfileState> {
|
||||
profile: (state as ProfileStateLoaded).profile,
|
||||
profileDetails: (state as ProfileStateLoaded).profileDetails,
|
||||
blogPosts: feedBlogPostList,
|
||||
weightPoints: (state as ProfileStateLoaded).weightPoints,
|
||||
circumferencePoints: (state as ProfileStateLoaded).circumferencePoints,
|
||||
consultantInfo: (state as ProfileStateLoaded).consultantInfo,
|
||||
isMyProfile: (state as ProfileStateLoaded).isMyProfile,
|
||||
certPhotos: (state as ProfileStateLoaded).certPhotos,
|
||||
@@ -122,7 +120,6 @@ class ProfileCubit extends Cubit<ProfileState> {
|
||||
waistCircumference: waistCircumference,
|
||||
hipCircumference: hipCircumference,
|
||||
);
|
||||
await getMyProfile(period: _currentPeriod);
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
emit(ProfileState.error(errorMessage: e.toString()));
|
||||
@@ -132,7 +129,6 @@ class ProfileCubit extends Cubit<ProfileState> {
|
||||
Future<void> addWeight({required double weight}) async {
|
||||
try {
|
||||
await profileRepository.addWeight(weight: weight);
|
||||
await getMyProfile(period: _currentPeriod);
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
emit(ProfileState.error(errorMessage: e.toString()));
|
||||
@@ -141,13 +137,13 @@ class ProfileCubit extends Cubit<ProfileState> {
|
||||
|
||||
Future<String?> getChatRoomIdBySlug({required String slug}) async {
|
||||
try {
|
||||
final chatIdOrError = await chatRepository.getChatByUserSlug(slug);
|
||||
final userProfile = await profileRepository.getProfileBySlug(slug);
|
||||
if (chatIdOrError == '404') {
|
||||
final chatId = await chatRepository.getChatByUserSlug(slug);
|
||||
if (chatId == null || chatId.isEmpty) {
|
||||
final userProfile = await profileRepository.getProfileBySlug(slug);
|
||||
AppRouter.navigatorKey.currentContext?.pushNamed('emptyChatScreen', extra: userProfile);
|
||||
} else {
|
||||
AppRouter.navigatorKey.currentContext?.pushNamed('chatScreen', extra: {'uuid': chatIdOrError});
|
||||
return chatIdOrError;
|
||||
AppRouter.navigatorKey.currentContext?.pushNamed('chatScreen', extra: {'uuid': chatId});
|
||||
return chatId;
|
||||
}
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
||||
import 'package:eatvio_app/features/profile/models/circumference_model.dart';
|
||||
import 'package:eatvio_app/features/profile/models/profile_model.dart';
|
||||
import 'package:eatvio_app/features/profile/models/weight_model.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'profile_state.freezed.dart';
|
||||
@@ -20,8 +18,6 @@ abstract class ProfileState with _$ProfileState {
|
||||
required List<FeedBlogPostModel> blogPosts,
|
||||
required bool isMyProfile,
|
||||
required List<ConsultantCertPhotoModel> certPhotos,
|
||||
List<WeightModel>? weightPoints,
|
||||
List<CircumferenceModel>? circumferencePoints,
|
||||
ConsultationRequestModel? consultantInfo,
|
||||
}) = ProfileStateLoaded;
|
||||
|
||||
|
||||
@@ -18,21 +18,17 @@ import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/prof
|
||||
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart';
|
||||
import 'package:eatvio_app/features/complaining/presentation/complaining_bottom_sheet.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/widgets/plan_widget.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_card.dart';
|
||||
import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_cubit.dart';
|
||||
import 'package:eatvio_app/features/profile/models/circumference_model.dart';
|
||||
import 'package:eatvio_app/features/profile/models/weight_model.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/plans_cubit/plans_cubit.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/plans_cubit/plans_state.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_state.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/sub_cubit/sub_cubit.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/pages/subs_screen.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/widgets/circumference_chart_widget.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/widgets/expandable_page_view.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/widgets/imt_chart_widget.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/widgets/profile_blog_post_widget.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/widgets/toggle_cert_photo_bottom_sheet.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/widgets/weight_chart_widget.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
@@ -60,21 +56,8 @@ class ProfileScreen extends StatefulWidget {
|
||||
class _ProfileScreenState extends State<ProfileScreen> {
|
||||
late final ProfileCubit profileCubit = sl<ProfileCubit>();
|
||||
final PlansCubit plansCubit = sl<PlansCubit>();
|
||||
final List<String> chipsLabelList = ['Вес', 'Замеры', 'ИМТ'];
|
||||
final List<String> chipsIntervalLabelList = [
|
||||
'1М',
|
||||
'3М',
|
||||
'6М',
|
||||
'1Г',
|
||||
'ВСЁ ВРЕМЯ',
|
||||
];
|
||||
final List<String> chipsIntervalList = ['1', '3', '6', '12', 'all'];
|
||||
final TextEditingController controller = TextEditingController();
|
||||
|
||||
List<WeightModel> weightPoints = [];
|
||||
List<CircumferenceModel> circumferencePoints = [];
|
||||
int _selectedChipIndex = 0;
|
||||
int _selectedIntervalChipIndex = 0;
|
||||
late bool canSub;
|
||||
bool isFirstFetch = true;
|
||||
int _currentBullet = 0;
|
||||
@@ -96,10 +79,6 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
||||
bloc: profileCubit,
|
||||
builder: (BuildContext context, ProfileState state) {
|
||||
if (state is ProfileStateLoaded) {
|
||||
if (state.circumferencePoints != null) {
|
||||
circumferencePoints = state.circumferencePoints!;
|
||||
weightPoints = state.weightPoints!;
|
||||
}
|
||||
if (isFirstFetch) {
|
||||
canSub = !state.profile.isEntitySubscribedByMe;
|
||||
if (state.profile.roles.contains('consultant') && !hidePlans) {
|
||||
@@ -442,10 +421,11 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
||||
child: _badges(state),
|
||||
),
|
||||
if (state.profile.roles.contains('consultant')) _socialLinks(state),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: _charts(),
|
||||
),
|
||||
if (state.isMyProfile)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(bottom: 16),
|
||||
child: InsightsCard(canAdd: true),
|
||||
),
|
||||
if (state.profile.roles.contains('consultant')) _education(state),
|
||||
if (state.profile.roles.contains('consultant'))
|
||||
_diplomasAndCertificates(state),
|
||||
@@ -1722,114 +1702,4 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Material _charts() {
|
||||
return Material(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
_choiceChipWidget(0),
|
||||
const SizedBox(width: 8),
|
||||
_choiceChipWidget(1),
|
||||
const SizedBox(width: 8),
|
||||
_choiceChipWidget(2),
|
||||
],
|
||||
),
|
||||
),
|
||||
Builder(
|
||||
builder: (context) {
|
||||
switch (_selectedChipIndex) {
|
||||
case 0:
|
||||
return WeightChartWidget(
|
||||
weightPoints: weightPoints,
|
||||
profileCubit: profileCubit,
|
||||
);
|
||||
case 1:
|
||||
return CircumferenceChartWidget(
|
||||
circumferencePoints: circumferencePoints,
|
||||
profileCubit: profileCubit,
|
||||
);
|
||||
case 2:
|
||||
return ImtChartWidget(weightPoints: weightPoints);
|
||||
default:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
const SizedBox(width: 16),
|
||||
_choiceIntervalChipWidget(0),
|
||||
const SizedBox(width: 8),
|
||||
_choiceIntervalChipWidget(1),
|
||||
const SizedBox(width: 8),
|
||||
_choiceIntervalChipWidget(2),
|
||||
const SizedBox(width: 8),
|
||||
_choiceIntervalChipWidget(3),
|
||||
const SizedBox(width: 8),
|
||||
_choiceIntervalChipWidget(4),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
ChoiceChip _choiceIntervalChipWidget(int index) {
|
||||
return ChoiceChip(
|
||||
selectedColor: Colors.grey.withValues(alpha: 0.1),
|
||||
pressElevation: 0,
|
||||
showCheckmark: false,
|
||||
side: BorderSide.none,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
label: Text(chipsIntervalLabelList[index]),
|
||||
labelStyle: TextStyle(
|
||||
color: appTheme.gray600,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 12,
|
||||
),
|
||||
selected: _selectedIntervalChipIndex == index,
|
||||
onSelected: (bool selected) {
|
||||
profileCubit.getMyProfile(period: chipsIntervalList[index]);
|
||||
setState(() {
|
||||
_selectedIntervalChipIndex = index;
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
ChoiceChip _choiceChipWidget(int index) {
|
||||
return ChoiceChip(
|
||||
selectedColor: appTheme.blueA400,
|
||||
pressElevation: 0,
|
||||
showCheckmark: false,
|
||||
side: BorderSide.none,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
label: Text(chipsLabelList[index]),
|
||||
labelStyle: TextStyle(
|
||||
color: index == _selectedChipIndex ? Colors.white : appTheme.gray600,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 12,
|
||||
),
|
||||
selected: _selectedChipIndex == index,
|
||||
backgroundColor: appTheme.gray100,
|
||||
onSelected: (bool selected) {
|
||||
setState(() {
|
||||
_selectedChipIndex = index;
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,389 +0,0 @@
|
||||
import 'package:eatvio_app/features/profile/models/circumference_model.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||
|
||||
class CircumferenceChartWidget extends StatefulWidget {
|
||||
final List<CircumferenceModel> circumferencePoints;
|
||||
final ProfileCubit? profileCubit;
|
||||
|
||||
const CircumferenceChartWidget({
|
||||
super.key,
|
||||
required this.circumferencePoints,
|
||||
this.profileCubit,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CircumferenceChartWidget> createState() => _CircumferenceChartWidgetState();
|
||||
}
|
||||
|
||||
class _CircumferenceChartWidgetState extends State<CircumferenceChartWidget> {
|
||||
late FixedExtentScrollController chestController;
|
||||
late FixedExtentScrollController waistController;
|
||||
late FixedExtentScrollController hipController;
|
||||
late int chest;
|
||||
late int waist;
|
||||
late int hip;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
if (widget.circumferencePoints.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'${widget.circumferencePoints.last.chestCircumference}',
|
||||
style: const TextStyle(
|
||||
color: Colors.black,
|
||||
fontSize: 40,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (widget.circumferencePoints.length == 1)
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12),
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.black12,
|
||||
),
|
||||
),
|
||||
if (widget.circumferencePoints.length > 1)
|
||||
widget.circumferencePoints.last.chestCircumference > widget.circumferencePoints[widget.circumferencePoints.length - 2].chestCircumference
|
||||
? const Icon(
|
||||
Icons.arrow_drop_up,
|
||||
size: 40,
|
||||
color: Colors.grey,
|
||||
)
|
||||
: const Icon(
|
||||
Icons.arrow_drop_down,
|
||||
size: 40,
|
||||
color: Colors.grey,
|
||||
),
|
||||
Text(
|
||||
'${widget.circumferencePoints.last.waistCircumference}',
|
||||
style: const TextStyle(
|
||||
color: Colors.black,
|
||||
fontSize: 40,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (widget.circumferencePoints.length == 1)
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12),
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.black12,
|
||||
),
|
||||
),
|
||||
if (widget.circumferencePoints.length > 1)
|
||||
widget.circumferencePoints.last.waistCircumference > widget.circumferencePoints[widget.circumferencePoints.length - 2].waistCircumference
|
||||
? const Icon(
|
||||
Icons.arrow_drop_up,
|
||||
size: 40,
|
||||
color: Colors.grey,
|
||||
)
|
||||
: const Icon(
|
||||
Icons.arrow_drop_down,
|
||||
size: 40,
|
||||
color: Colors.grey,
|
||||
),
|
||||
Text(
|
||||
'${widget.circumferencePoints.last.hipCircumference}',
|
||||
style: const TextStyle(
|
||||
color: Colors.black,
|
||||
fontSize: 40,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (widget.circumferencePoints.length == 1)
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12),
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.black12,
|
||||
),
|
||||
),
|
||||
if (widget.circumferencePoints.length > 1)
|
||||
widget.circumferencePoints.last.hipCircumference > widget.circumferencePoints[widget.circumferencePoints.length - 2].hipCircumference
|
||||
? const Icon(
|
||||
Icons.arrow_drop_up,
|
||||
size: 40,
|
||||
color: Colors.grey,
|
||||
)
|
||||
: const Icon(
|
||||
Icons.arrow_drop_down,
|
||||
size: 40,
|
||||
color: Colors.grey,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
SfCartesianChart(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||||
enableAxisAnimation: true,
|
||||
plotAreaBorderWidth: 0,
|
||||
trackballBehavior: TrackballBehavior(
|
||||
enable: true,
|
||||
activationMode: ActivationMode.singleTap,
|
||||
tooltipDisplayMode: TrackballDisplayMode.groupAllPoints,
|
||||
tooltipSettings: const InteractiveTooltip(),
|
||||
lineType: TrackballLineType.vertical,
|
||||
),
|
||||
primaryXAxis: DateTimeCategoryAxis(
|
||||
tickPosition: TickPosition.inside,
|
||||
majorTickLines: const MajorTickLines(
|
||||
size: 0,
|
||||
),
|
||||
edgeLabelPlacement: EdgeLabelPlacement.shift,
|
||||
dateFormat: DateFormat.MMMd('ru'),
|
||||
labelStyle: const TextStyle(
|
||||
color: Colors.grey,
|
||||
),
|
||||
majorGridLines: MajorGridLines(dashArray: const [1], color: Colors.grey.withValues(alpha: 0.3)),
|
||||
axisLine: AxisLine(
|
||||
color: Colors.grey.withValues(alpha: 0.3),
|
||||
width: 0.4,
|
||||
),
|
||||
),
|
||||
primaryYAxis: const NumericAxis(
|
||||
isVisible: false,
|
||||
labelFormat: '{value} см',
|
||||
axisLine: AxisLine(
|
||||
width: 1,
|
||||
),
|
||||
majorTickLines: MajorTickLines(size: 0),
|
||||
),
|
||||
series: _getSplineSeries(),
|
||||
tooltipBehavior: TooltipBehavior(enable: true),
|
||||
),
|
||||
if (widget.profileCubit != null)
|
||||
Positioned(
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
child: FloatingActionButton.small(
|
||||
heroTag: 'addCircumference',
|
||||
onPressed: () {
|
||||
if (widget.circumferencePoints.isNotEmpty) {
|
||||
chest = widget.circumferencePoints.last.chestCircumference;
|
||||
waist = widget.circumferencePoints.last.waistCircumference;
|
||||
hip = widget.circumferencePoints.last.hipCircumference;
|
||||
chestController = FixedExtentScrollController(initialItem: chest - 20);
|
||||
waistController = FixedExtentScrollController(initialItem: waist - 20);
|
||||
hipController = FixedExtentScrollController(initialItem: hip - 20);
|
||||
} else {
|
||||
chestController = FixedExtentScrollController();
|
||||
waistController = FixedExtentScrollController();
|
||||
hipController = FixedExtentScrollController();
|
||||
}
|
||||
showModalBottomSheet(
|
||||
useRootNavigator: true,
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: CupertinoPicker(
|
||||
magnification: 1.22,
|
||||
squeeze: 1.2,
|
||||
useMagnifier: true,
|
||||
scrollController: chestController,
|
||||
itemExtent: 24,
|
||||
onSelectedItemChanged: (int value) {
|
||||
chest = value;
|
||||
},
|
||||
children: [
|
||||
for (int i = 20; i < 200; i++) Text('$i'),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: CupertinoPicker(
|
||||
magnification: 1.22,
|
||||
squeeze: 1.2,
|
||||
useMagnifier: true,
|
||||
scrollController: waistController,
|
||||
itemExtent: 24,
|
||||
onSelectedItemChanged: (int value) {
|
||||
waist = value;
|
||||
},
|
||||
children: [
|
||||
for (int i = 20; i < 200; i++) Text('$i'),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: CupertinoPicker(
|
||||
magnification: 1.22,
|
||||
squeeze: 1.2,
|
||||
useMagnifier: true,
|
||||
scrollController: hipController,
|
||||
itemExtent: 24,
|
||||
onSelectedItemChanged: (int value) {
|
||||
hip = value;
|
||||
},
|
||||
children: [
|
||||
for (int i = 20; i < 200; i++) Text('$i'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
widget.profileCubit?.addCircumference(
|
||||
chestCircumference: chest + 20,
|
||||
waistCircumference: waist + 20,
|
||||
hipCircumference: hip + 20,
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Ink(
|
||||
width: MediaQuery.of(context).size.width - 32,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: appTheme.blueA400,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'Сохранить',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
backgroundColor: appTheme.blueA400,
|
||||
shape: const CircleBorder(),
|
||||
child: const Icon(
|
||||
Icons.add,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.circumferencePoints.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 48),
|
||||
child: Text(
|
||||
'Добавьте Ваши замеры',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<CartesianSeries<CircumferenceModel, DateTime>> _getSplineSeries() {
|
||||
const Color seriesColor1 = Colors.red;
|
||||
final Color seriesColor2 = appTheme.blueA400;
|
||||
const Color seriesColor3 = Colors.green;
|
||||
|
||||
return <CartesianSeries<CircumferenceModel, DateTime>>[
|
||||
SplineAreaSeries<CircumferenceModel, DateTime>(
|
||||
markerSettings: const MarkerSettings(
|
||||
isVisible: true,
|
||||
color: seriesColor1,
|
||||
height: 8,
|
||||
width: 8,
|
||||
),
|
||||
dataLabelSettings: const DataLabelSettings(
|
||||
labelAlignment: ChartDataLabelAlignment.top,
|
||||
isVisible: true,
|
||||
),
|
||||
dataSource: widget.circumferencePoints,
|
||||
borderColor: seriesColor1,
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
seriesColor1.withValues(alpha: 0.14),
|
||||
Colors.transparent,
|
||||
],
|
||||
begin: Alignment.topCenter,
|
||||
end: const Alignment(0, 0.6),
|
||||
),
|
||||
name: 'Грудь',
|
||||
xValueMapper: (CircumferenceModel value, int index) => value.createdAt,
|
||||
yValueMapper: (CircumferenceModel value, int index) => value.chestCircumference,
|
||||
),
|
||||
SplineAreaSeries<CircumferenceModel, DateTime>(
|
||||
markerSettings: MarkerSettings(
|
||||
isVisible: true,
|
||||
color: seriesColor2,
|
||||
height: 8,
|
||||
width: 8,
|
||||
),
|
||||
dataLabelSettings: const DataLabelSettings(
|
||||
labelAlignment: ChartDataLabelAlignment.top,
|
||||
isVisible: true,
|
||||
),
|
||||
dataSource: widget.circumferencePoints,
|
||||
borderColor: seriesColor2,
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
seriesColor2.withValues(alpha: 0.14),
|
||||
Colors.transparent,
|
||||
],
|
||||
begin: Alignment.topCenter,
|
||||
end: const Alignment(0, 0.6),
|
||||
),
|
||||
name: 'Талия',
|
||||
xValueMapper: (CircumferenceModel value, int index) => value.createdAt,
|
||||
yValueMapper: (CircumferenceModel value, int index) => value.waistCircumference,
|
||||
),
|
||||
SplineAreaSeries<CircumferenceModel, DateTime>(
|
||||
markerSettings: const MarkerSettings(
|
||||
isVisible: true,
|
||||
color: seriesColor3,
|
||||
height: 8,
|
||||
width: 8,
|
||||
),
|
||||
dataLabelSettings: const DataLabelSettings(
|
||||
labelAlignment: ChartDataLabelAlignment.top,
|
||||
isVisible: true,
|
||||
),
|
||||
dataSource: widget.circumferencePoints,
|
||||
borderColor: seriesColor3,
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
seriesColor3.withValues(alpha: 0.14),
|
||||
Colors.transparent,
|
||||
],
|
||||
begin: Alignment.topCenter,
|
||||
end: const Alignment(0, 0.6),
|
||||
),
|
||||
name: 'Бёдра',
|
||||
xValueMapper: (CircumferenceModel value, int index) => value.createdAt,
|
||||
yValueMapper: (CircumferenceModel value, int index) => value.hipCircumference,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import 'package:eatvio_app/features/profile/models/weight_model.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class ImtChartWidget extends StatelessWidget {
|
||||
final List<WeightModel> weightPoints;
|
||||
|
||||
const ImtChartWidget({
|
||||
super.key,
|
||||
required this.weightPoints,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
if (weightPoints.isNotEmpty)
|
||||
Text(
|
||||
weightPoints.last.bmi.toStringAsFixed(2),
|
||||
style: const TextStyle(
|
||||
color: Colors.black,
|
||||
fontSize: 40,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (weightPoints.length > 1)
|
||||
weightPoints.last.bmi > weightPoints[weightPoints.length - 2].bmi
|
||||
? const Icon(
|
||||
Icons.arrow_drop_up,
|
||||
size: 40,
|
||||
color: Colors.grey,
|
||||
)
|
||||
: const Icon(
|
||||
Icons.arrow_drop_down,
|
||||
size: 40,
|
||||
color: Colors.grey,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
SfCartesianChart(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||||
enableAxisAnimation: true,
|
||||
plotAreaBorderWidth: 0,
|
||||
trackballBehavior: TrackballBehavior(
|
||||
enable: true,
|
||||
activationMode: ActivationMode.singleTap,
|
||||
tooltipDisplayMode: TrackballDisplayMode.groupAllPoints,
|
||||
tooltipSettings: const InteractiveTooltip(),
|
||||
lineType: TrackballLineType.vertical,
|
||||
),
|
||||
primaryXAxis: DateTimeCategoryAxis(
|
||||
tickPosition: TickPosition.inside,
|
||||
majorTickLines: const MajorTickLines(
|
||||
size: 0,
|
||||
),
|
||||
edgeLabelPlacement: EdgeLabelPlacement.shift,
|
||||
dateFormat: DateFormat.MMMd('ru'),
|
||||
labelStyle: const TextStyle(
|
||||
color: Colors.grey,
|
||||
),
|
||||
majorGridLines: MajorGridLines(dashArray: const [1], color: Colors.grey.withValues(alpha: 0.3)),
|
||||
axisLine: AxisLine(
|
||||
color: Colors.grey.withValues(alpha: 0.3),
|
||||
width: 0.4,
|
||||
),
|
||||
),
|
||||
primaryYAxis: const NumericAxis(
|
||||
isVisible: false,
|
||||
),
|
||||
series: _getSplineSeries(),
|
||||
tooltipBehavior: TooltipBehavior(enable: true),
|
||||
),
|
||||
if (weightPoints.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Text(
|
||||
'Индекс Массы Тела рассчитывается при указанных весе и росте',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<CartesianSeries<WeightModel, DateTime>> _getSplineSeries() {
|
||||
return <CartesianSeries<WeightModel, DateTime>>[
|
||||
SplineAreaSeries<WeightModel, DateTime>(
|
||||
markerSettings: MarkerSettings(
|
||||
isVisible: true,
|
||||
color: appTheme.blueA400,
|
||||
height: 8,
|
||||
width: 8,
|
||||
),
|
||||
dataLabelSettings: const DataLabelSettings(
|
||||
labelAlignment: ChartDataLabelAlignment.top,
|
||||
isVisible: true,
|
||||
),
|
||||
dataSource: weightPoints,
|
||||
borderColor: appTheme.blueA400,
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
appTheme.blueA400.withValues(alpha: 0.14),
|
||||
Colors.transparent,
|
||||
],
|
||||
begin: Alignment.topCenter,
|
||||
end: const Alignment(0, 0.6),
|
||||
),
|
||||
name: 'ИМТ',
|
||||
xValueMapper: (WeightModel value, int index) => value.createdAt,
|
||||
yValueMapper: (WeightModel value, int index) => double.parse(value.bmi.toStringAsFixed(2)),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
import 'package:eatvio_app/features/profile/models/weight_model.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||
|
||||
class WeightChartWidget extends StatefulWidget {
|
||||
final List<WeightModel> weightPoints;
|
||||
final ProfileCubit? profileCubit;
|
||||
|
||||
const WeightChartWidget({
|
||||
super.key,
|
||||
required this.weightPoints,
|
||||
this.profileCubit,
|
||||
});
|
||||
|
||||
@override
|
||||
State<WeightChartWidget> createState() => _WeightChartWidgetState();
|
||||
}
|
||||
|
||||
class _WeightChartWidgetState extends State<WeightChartWidget> {
|
||||
late FixedExtentScrollController weightKiloController;
|
||||
late FixedExtentScrollController weightGramsController;
|
||||
int weightKilo = 20;
|
||||
int weightGrams = 0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (widget.weightPoints.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'${widget.weightPoints.last.weight.toStringAsFixed(1)} кг',
|
||||
style: const TextStyle(
|
||||
color: Colors.black,
|
||||
fontSize: 40,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (widget.weightPoints.length == 1)
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12),
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.black12,
|
||||
),
|
||||
),
|
||||
if (widget.weightPoints.length > 1)
|
||||
widget.weightPoints.last.weight > widget.weightPoints[widget.weightPoints.length - 2].weight
|
||||
? Icon(
|
||||
Icons.arrow_drop_up,
|
||||
size: 40,
|
||||
color: Colors.grey.withValues(alpha: 0.4),
|
||||
)
|
||||
: Icon(
|
||||
Icons.arrow_drop_down,
|
||||
size: 40,
|
||||
color: Colors.grey.withValues(alpha: 0.4),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
SfCartesianChart(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||||
enableAxisAnimation: true,
|
||||
plotAreaBorderWidth: 0,
|
||||
trackballBehavior: TrackballBehavior(
|
||||
enable: true,
|
||||
activationMode: ActivationMode.singleTap,
|
||||
tooltipDisplayMode: TrackballDisplayMode.groupAllPoints,
|
||||
tooltipSettings: const InteractiveTooltip(),
|
||||
lineType: TrackballLineType.vertical,
|
||||
),
|
||||
primaryXAxis: DateTimeCategoryAxis(
|
||||
tickPosition: TickPosition.inside,
|
||||
majorTickLines: const MajorTickLines(
|
||||
size: 0,
|
||||
),
|
||||
edgeLabelPlacement: EdgeLabelPlacement.shift,
|
||||
dateFormat: DateFormat.MMMd('ru'),
|
||||
labelStyle: const TextStyle(
|
||||
color: Colors.grey,
|
||||
),
|
||||
majorGridLines: MajorGridLines(dashArray: const [1], color: Colors.grey.withValues(alpha: 0.3)),
|
||||
axisLine: AxisLine(
|
||||
color: Colors.grey.withValues(alpha: 0.3),
|
||||
width: 0.4,
|
||||
),
|
||||
),
|
||||
primaryYAxis: const NumericAxis(
|
||||
isVisible: false,
|
||||
labelFormat: '{value} кг',
|
||||
majorGridLines: MajorGridLines(dashArray: [1], color: Colors.grey),
|
||||
axisLine: AxisLine(
|
||||
width: 1,
|
||||
),
|
||||
majorTickLines: MajorTickLines(size: 0),
|
||||
),
|
||||
series: _getSplineSeries(),
|
||||
tooltipBehavior: TooltipBehavior(enable: true),
|
||||
),
|
||||
if (widget.profileCubit != null)
|
||||
Positioned(
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
child: FloatingActionButton.small(
|
||||
heroTag: 'addWeight',
|
||||
onPressed: () {
|
||||
if (widget.weightPoints.isNotEmpty) {
|
||||
try {
|
||||
weightKilo = int.parse(widget.weightPoints.last.weight.toString().split('.')[0]);
|
||||
weightGrams = int.parse(widget.weightPoints.last.weight.toString().split('.')[1][0]);
|
||||
} catch (e) {
|
||||
weightGrams = 0;
|
||||
}
|
||||
weightKiloController = FixedExtentScrollController(initialItem: weightKilo - 20);
|
||||
weightGramsController = FixedExtentScrollController(initialItem: weightGrams);
|
||||
} else {
|
||||
weightKiloController = FixedExtentScrollController();
|
||||
weightGramsController = FixedExtentScrollController();
|
||||
}
|
||||
showModalBottomSheet(
|
||||
useRootNavigator: true,
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: CupertinoPicker(
|
||||
magnification: 1.22,
|
||||
squeeze: 1.2,
|
||||
useMagnifier: true,
|
||||
scrollController: weightKiloController,
|
||||
itemExtent: 24,
|
||||
onSelectedItemChanged: (int value) {
|
||||
weightKilo = value + 20;
|
||||
},
|
||||
children: [
|
||||
for (int i = 20; i < 300; i++) Text('$i'),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: CupertinoPicker(
|
||||
magnification: 1.22,
|
||||
squeeze: 1.2,
|
||||
useMagnifier: true,
|
||||
scrollController: weightGramsController,
|
||||
itemExtent: 24,
|
||||
onSelectedItemChanged: (int value) {
|
||||
weightGrams = value;
|
||||
},
|
||||
children: [
|
||||
for (int i = 0; i < 10; i++) Text('$i'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
widget.profileCubit?.addWeight(weight: double.parse('$weightKilo.$weightGrams'));
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Ink(
|
||||
width: MediaQuery.of(context).size.width - 32,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: appTheme.blueA400,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'Сохранить',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
backgroundColor: appTheme.blueA400,
|
||||
shape: const CircleBorder(),
|
||||
child: const Icon(
|
||||
Icons.add,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.weightPoints.isEmpty) const Text('Добавьте Ваш вес'),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<CartesianSeries<WeightModel, DateTime>> _getSplineSeries() {
|
||||
return <CartesianSeries<WeightModel, DateTime>>[
|
||||
SplineAreaSeries<WeightModel, DateTime>(
|
||||
markerSettings: MarkerSettings(
|
||||
isVisible: true,
|
||||
color: appTheme.blueA400,
|
||||
height: 8,
|
||||
width: 8,
|
||||
),
|
||||
dataLabelSettings: const DataLabelSettings(
|
||||
labelAlignment: ChartDataLabelAlignment.top,
|
||||
isVisible: true,
|
||||
),
|
||||
dataSource: widget.weightPoints,
|
||||
borderColor: appTheme.blueA400,
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
appTheme.blueA400.withValues(alpha: 0.14),
|
||||
Colors.transparent,
|
||||
],
|
||||
begin: Alignment.topCenter,
|
||||
end: const Alignment(0, 0.6),
|
||||
),
|
||||
name: 'Вес',
|
||||
xValueMapper: (WeightModel value, int index) => value.createdAt,
|
||||
yValueMapper: (WeightModel value, int index) => value.weight,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:eatvio_app/core/app_router.dart';
|
||||
import 'package:eatvio_app/core/firebase_api.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/mixins/registration_date_cache_mixin.dart';
|
||||
import 'package:eatvio_app/core/prefs.dart';
|
||||
|
||||
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
|
||||
import 'package:eatvio_app/core/token_storage.dart';
|
||||
import 'package:eatvio_app/features/registration/models/user_registration_model.dart';
|
||||
import 'package:eatvio_app/features/registration/presentation/bloc/registration_cubit/registration_state.dart';
|
||||
@@ -18,7 +16,8 @@ import 'package:go_router/go_router.dart';
|
||||
|
||||
class RegistrationCubit extends Cubit<RegistrationState> with RegistrationDateCacheMixin {
|
||||
final RegistrationRepository registrationRepository;
|
||||
RegistrationCubit({required this.registrationRepository, required Prefs prefs})
|
||||
final SessionLifecycleService sessionLifecycleService;
|
||||
RegistrationCubit({required this.registrationRepository, required this.sessionLifecycleService, required Prefs prefs})
|
||||
: super(
|
||||
const RegistrationState(
|
||||
sheet: UserRegistrationSheet(
|
||||
@@ -62,7 +61,7 @@ class RegistrationCubit extends Cubit<RegistrationState> with RegistrationDateCa
|
||||
state.sheet.copyWith(phone: state.sheet.phone.replaceAll(' ', '').replaceAll('(', '').replaceAll(')', ''), responseBody: '{}'),
|
||||
);
|
||||
await TokenStorage.saveTokenInStorage(token);
|
||||
await sl<FirebaseApi>().registerPushToken();
|
||||
await sessionLifecycleService.onAuthenticated();
|
||||
RegistrationAnalyticsService.reportRegistrationComplete();
|
||||
cacheRegistrationDate(DateTime.now());
|
||||
AppRouter.navigatorKey.currentContext?.replaceNamed('verifyEmailScreen');
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:developer';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/firebase_api.dart';
|
||||
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
|
||||
import 'package:eatvio_app/core/token_storage.dart';
|
||||
import 'package:eatvio_app/core/utils/date_time_utils.dart';
|
||||
import 'package:eatvio_app/core/utils/validation_utils.dart';
|
||||
@@ -15,9 +16,11 @@ import 'package:gpassword/gpassword.dart';
|
||||
class RegistrationCubitV2 extends Cubit<RegistrationStateV2> {
|
||||
final RegistrationRepositoryV2 registrationRepository;
|
||||
final FirebaseApi firebaseApi;
|
||||
final SessionLifecycleService sessionLifecycleService;
|
||||
RegistrationCubitV2({
|
||||
required this.registrationRepository,
|
||||
required this.firebaseApi,
|
||||
required this.sessionLifecycleService,
|
||||
}) : super(const RegistrationStateV2());
|
||||
|
||||
void setFirstName(String firstName) {
|
||||
@@ -204,7 +207,7 @@ class RegistrationCubitV2 extends Cubit<RegistrationStateV2> {
|
||||
);
|
||||
if (token != null) {
|
||||
await TokenStorage.saveTokenInStorage(token);
|
||||
await firebaseApi.registerPushToken();
|
||||
await sessionLifecycleService.onAuthenticated();
|
||||
emit(state.copyWith(registrationError: null));
|
||||
return (true, false);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart';
|
||||
import 'package:eatvio_app/features/consultations/repositories/consultations_repository.dart';
|
||||
@@ -17,12 +19,14 @@ class SettingsCubit extends Cubit<SettingsState> {
|
||||
final SettingsRepository settingsRepository;
|
||||
final ConsultantRepository consultantRepository;
|
||||
final ConsultationsRepository consultationsRepository;
|
||||
final SessionLifecycleService sessionLifecycleService;
|
||||
|
||||
SettingsCubit({
|
||||
required this.profileRepository,
|
||||
required this.settingsRepository,
|
||||
required this.profileRepository,
|
||||
required this.settingsRepository,
|
||||
required this.consultantRepository,
|
||||
required this.consultationsRepository,
|
||||
required this.sessionLifecycleService,
|
||||
}) : super(const SettingsState.loading());
|
||||
|
||||
Future<void> init() async {
|
||||
@@ -30,10 +34,12 @@ class SettingsCubit extends Cubit<SettingsState> {
|
||||
late final ConsultationRequestModel? consultantInfo;
|
||||
late final bool hasActiveConsultation;
|
||||
late final OAuthAccountsDataModel? oauthAccounts;
|
||||
|
||||
|
||||
try {
|
||||
if (profile.phoneConfirmationState.contains('confirmed')) {
|
||||
consultantInfo = await consultantRepository.getMyRequestStatus(profile.slug);
|
||||
consultantInfo = await consultantRepository.getMyRequestStatus(
|
||||
profile.slug,
|
||||
);
|
||||
} else {
|
||||
consultantInfo = null;
|
||||
}
|
||||
@@ -41,14 +47,14 @@ class SettingsCubit extends Cubit<SettingsState> {
|
||||
await reportFrom(e, st, this);
|
||||
consultantInfo = null;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
hasActiveConsultation = await _checkActiveConsultation();
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
hasActiveConsultation = false;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
final oauthResponse = await settingsRepository.getOAuthAccounts();
|
||||
oauthAccounts = oauthResponse.data;
|
||||
@@ -56,7 +62,7 @@ class SettingsCubit extends Cubit<SettingsState> {
|
||||
await reportFrom(e, st, this);
|
||||
oauthAccounts = null;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
emit(
|
||||
SettingsState.loaded(
|
||||
@@ -74,9 +80,13 @@ class SettingsCubit extends Cubit<SettingsState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateProfileDetails({required ProfileDetailsModel profileDetailsModel}) async {
|
||||
Future<void> updateProfileDetails({
|
||||
required ProfileDetailsModel profileDetailsModel,
|
||||
}) async {
|
||||
try {
|
||||
await settingsRepository.updateProfileDetails(profileDetailsModel: profileDetailsModel);
|
||||
await settingsRepository.updateProfileDetails(
|
||||
profileDetailsModel: profileDetailsModel,
|
||||
);
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
} finally {
|
||||
@@ -84,9 +94,13 @@ class SettingsCubit extends Cubit<SettingsState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateProfileSettings({required ProfileSettingsModel profileSettingsModel}) async {
|
||||
Future<void> updateProfileSettings({
|
||||
required ProfileSettingsModel profileSettingsModel,
|
||||
}) async {
|
||||
try {
|
||||
await settingsRepository.updateProfileSettings(profileSettingsModel: profileSettingsModel);
|
||||
await settingsRepository.updateProfileSettings(
|
||||
profileSettingsModel: profileSettingsModel,
|
||||
);
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
} finally {
|
||||
@@ -97,9 +111,15 @@ class SettingsCubit extends Cubit<SettingsState> {
|
||||
Future<bool> deleteAccount() async {
|
||||
try {
|
||||
await profileRepository.deleteAccount();
|
||||
await sessionLifecycleService.endSession(
|
||||
reason: SessionEndReason.accountDeleted,
|
||||
);
|
||||
return true;
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
if (e is DioException && e.response?.statusCode == 401) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -135,11 +155,14 @@ class SettingsCubit extends Cubit<SettingsState> {
|
||||
|
||||
Future<bool> _checkActiveConsultation() async {
|
||||
try {
|
||||
final userPurchases = await consultationsRepository.getUserPurchases(null, null);
|
||||
final userPurchases = await consultationsRepository.getUserPurchases(
|
||||
null,
|
||||
null,
|
||||
);
|
||||
final activeStates = ChosenPlanStateEnum.getActiveStates();
|
||||
|
||||
return userPurchases.any((purchase) =>
|
||||
activeStates.contains(purchase.chosenPlanState)
|
||||
|
||||
return userPurchases.any(
|
||||
(purchase) => activeStates.contains(purchase.chosenPlanState),
|
||||
);
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
|
||||
@@ -698,7 +698,6 @@ class _DeleteAccountBottomSheet extends State<DeleteAccountBottomSheet> {
|
||||
onPressed: () async {
|
||||
final result = await widget.cubit.deleteAccount();
|
||||
if (result == true) {
|
||||
AppRouter.navigatorKey.currentContext?.go('/auth-screen');
|
||||
return;
|
||||
}
|
||||
AppRouter.navigatorKey.currentContext?.pop();
|
||||
|
||||
@@ -14,23 +14,10 @@ class UserDetailsCubit extends Cubit<UserDetailsState> {
|
||||
try {
|
||||
final profileDetails = await profileRepository.getProfileDetailsBySlug(slug);
|
||||
emit(state.copyWith(profileDetails: profileDetails, isLoading: false));
|
||||
getChartData(slug: slug);
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString());
|
||||
emit(state.copyWith(error: e.toString(), isLoading: false));
|
||||
}
|
||||
}
|
||||
|
||||
void getChartData({String period = '1', required String slug}) async {
|
||||
try {
|
||||
final weightPoints = await profileRepository.getWeight(period: period, slug: slug);
|
||||
final circumferencePoints = await profileRepository.getCircumferences(period: period, slug: slug);
|
||||
emit(state.copyWith(weightPoints: weightPoints, circumferencePoints: circumferencePoints));
|
||||
} catch (e, stackTrace) {
|
||||
await reportFrom(e, stackTrace, this);
|
||||
log(e.toString(), stackTrace: stackTrace);
|
||||
emit(state.copyWith(error: e.toString(), isLoading: false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import 'package:eatvio_app/features/profile/models/circumference_model.dart';
|
||||
import 'package:eatvio_app/features/profile/models/profile_model.dart';
|
||||
import 'package:eatvio_app/features/profile/models/weight_model.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'user_details_state.freezed.dart';
|
||||
@@ -11,7 +9,5 @@ abstract class UserDetailsState with _$UserDetailsState {
|
||||
@Default(false) bool isLoading,
|
||||
String? error,
|
||||
ProfileDetailsModel? profileDetails,
|
||||
List<WeightModel>? weightPoints,
|
||||
List<CircumferenceModel>? circumferencePoints,
|
||||
}) = _UserDetailsState;
|
||||
}
|
||||
@@ -8,11 +8,7 @@ import 'package:eatvio_app/core/widgets/custom_text_form_field.dart';
|
||||
import 'package:eatvio_app/core/widgets/data_error_widget.dart';
|
||||
import 'package:eatvio_app/core/widgets/loader.dart';
|
||||
import 'package:eatvio_app/features/diets/presentation/widgets/more_options.dart';
|
||||
import 'package:eatvio_app/features/profile/models/circumference_model.dart';
|
||||
import 'package:eatvio_app/features/profile/models/weight_model.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/widgets/circumference_chart_widget.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/widgets/imt_chart_widget.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/widgets/weight_chart_widget.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_card.dart';
|
||||
import 'package:eatvio_app/features/user_details/presentation/cubit/user_details_cubit.dart';
|
||||
import 'package:eatvio_app/features/user_details/presentation/cubit/user_details_state.dart';
|
||||
import 'package:eatvio_app/features/user_details/presentation/widgets/expandable_bouble.dart';
|
||||
@@ -44,13 +40,6 @@ class UserDetailsContent extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _UserDetailsContentState extends State<UserDetailsContent> {
|
||||
final List<String> chipsLabelList = ['Вес', 'Замеры', 'ИМТ'];
|
||||
final List<String> chipsIntervalLabelList = ['1М', '3М', '6М', '1Г', 'ВСЁ ВРЕМЯ'];
|
||||
final List<String> chipsIntervalList = ['1', '3', '6', '12', 'all'];
|
||||
|
||||
List<WeightModel> weightPoints = [];
|
||||
List<CircumferenceModel> circumferencePoints = []; int _selectedChipIndex = 0;
|
||||
int _selectedIntervalChipIndex = 0;
|
||||
bool isMoreOptionsVisible = false;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -59,8 +48,6 @@ class _UserDetailsContentState extends State<UserDetailsContent> {
|
||||
builder: (context, state) {
|
||||
final profileDetails = state.profileDetails;
|
||||
if (profileDetails != null && state.isLoading == false) {
|
||||
weightPoints = state.weightPoints ?? [];
|
||||
circumferencePoints = state.circumferencePoints ?? [];
|
||||
return Scaffold(
|
||||
backgroundColor: appTheme.gray100,
|
||||
appBar: AppBar(
|
||||
@@ -104,7 +91,7 @@ class _UserDetailsContentState extends State<UserDetailsContent> {
|
||||
spacing: 12,
|
||||
children: [
|
||||
const SizedBox.shrink(),
|
||||
_charts(),
|
||||
InsightsCard(slug: widget.slug),
|
||||
ExpandableBouble(
|
||||
initiallyExpanded: true,
|
||||
title: s.general_info,
|
||||
@@ -309,92 +296,4 @@ class _UserDetailsContentState extends State<UserDetailsContent> {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Material _charts() {
|
||||
return Material(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(children: [_choiceChipWidget(0), const SizedBox(width: 8), _choiceChipWidget(1), const SizedBox(width: 8), _choiceChipWidget(2)]),
|
||||
),
|
||||
Builder(
|
||||
builder: (context) {
|
||||
switch (_selectedChipIndex) {
|
||||
case 0:
|
||||
return WeightChartWidget(weightPoints: weightPoints);
|
||||
case 1:
|
||||
return CircumferenceChartWidget(circumferencePoints: circumferencePoints);
|
||||
case 2:
|
||||
return ImtChartWidget(weightPoints: weightPoints);
|
||||
default:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
const SizedBox(width: 16),
|
||||
_choiceIntervalChipWidget(0),
|
||||
const SizedBox(width: 8),
|
||||
_choiceIntervalChipWidget(1),
|
||||
const SizedBox(width: 8),
|
||||
_choiceIntervalChipWidget(2),
|
||||
const SizedBox(width: 8),
|
||||
_choiceIntervalChipWidget(3),
|
||||
const SizedBox(width: 8),
|
||||
_choiceIntervalChipWidget(4),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
ChoiceChip _choiceIntervalChipWidget(int index) {
|
||||
return ChoiceChip(
|
||||
selectedColor: Colors.grey.withValues(alpha: 0.1),
|
||||
pressElevation: 0,
|
||||
showCheckmark: false,
|
||||
side: BorderSide.none,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
label: Text(chipsIntervalLabelList[index]),
|
||||
labelStyle: TextStyle(color: appTheme.gray600, fontWeight: FontWeight.w500, fontSize: 12),
|
||||
selected: _selectedIntervalChipIndex == index,
|
||||
onSelected: (bool selected) {
|
||||
context.read<UserDetailsCubit>().getChartData(period: chipsIntervalList[index], slug: widget.slug);
|
||||
setState(() {
|
||||
_selectedIntervalChipIndex = index;
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
ChoiceChip _choiceChipWidget(int index) {
|
||||
return ChoiceChip(
|
||||
selectedColor: appTheme.blueA400,
|
||||
pressElevation: 0,
|
||||
showCheckmark: false,
|
||||
side: BorderSide.none,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
label: Text(chipsLabelList[index]),
|
||||
labelStyle: TextStyle(color: index == _selectedChipIndex ? Colors.white : appTheme.gray600, fontWeight: FontWeight.w500, fontSize: 12),
|
||||
selected: _selectedChipIndex == index,
|
||||
backgroundColor: appTheme.gray100,
|
||||
onSelected: (bool selected) {
|
||||
setState(() {
|
||||
_selectedChipIndex = index;
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,32 +22,62 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
|
||||
static String m0(date) => "Консультант прокомментировал день ${date}";
|
||||
|
||||
static String m1(planName) => "План «${planName}» начался";
|
||||
static String m1(value, period, category) =>
|
||||
"BMI higher by ${value} over ${period} · ${category}";
|
||||
|
||||
static String m2(planName, name) =>
|
||||
static String m2(height) =>
|
||||
"Green zone is normal (18.5–25), yellow is overweight, pink is obesity. Calculated for a height of ${height} cm.";
|
||||
|
||||
static String m3(value, period, category) =>
|
||||
"BMI lower by ${value} over ${period} · ${category}";
|
||||
|
||||
static String m4(period, category) =>
|
||||
"BMI unchanged over ${period} · ${category}";
|
||||
|
||||
static String m5(name, value) => "${name} down ${value} cm";
|
||||
|
||||
static String m6(name, value) => "${name} up ${value} cm";
|
||||
|
||||
static String m7(name) => "${name} unchanged";
|
||||
|
||||
static String m8(parts, period) => "${parts} over ${period}";
|
||||
|
||||
static String m9(time) => "Bedtime at ${time}";
|
||||
|
||||
static String m10(time) => "Wake-up at ${time}";
|
||||
|
||||
static String m11(value, period) => "Gained ${value} kg over ${period}";
|
||||
|
||||
static String m12(value, period) => "Lost ${value} kg over ${period}";
|
||||
|
||||
static String m13(period) => "Weight unchanged over ${period}";
|
||||
|
||||
static String m14(planName) => "План «${planName}» начался";
|
||||
|
||||
static String m15(planName, name) =>
|
||||
"План «${planName}» с пользователем ${name} начался";
|
||||
|
||||
static String m3(percent, amount) =>
|
||||
static String m16(percent, amount) =>
|
||||
"Discount ${percent}% — you save ${amount} ₽";
|
||||
|
||||
static String m4(step) => "шаг ${step} из 2";
|
||||
static String m17(step) => "шаг ${step} из 2";
|
||||
|
||||
static String m5(planName, name) =>
|
||||
static String m18(planName, name) =>
|
||||
"Завтра начинается план «${planName}» с пользователем ${name}";
|
||||
|
||||
static String m6(planName) =>
|
||||
static String m19(planName) =>
|
||||
"Завтра начнется прохождение плана «${planName}»";
|
||||
|
||||
static String m7(name, planName) =>
|
||||
static String m20(name, planName) =>
|
||||
"Пользователь ${name} отменил подписку на план «${planName}»";
|
||||
|
||||
static String m8(name, planName) =>
|
||||
static String m21(name, planName) =>
|
||||
"Пользователь ${name} оплатил план «${planName}»";
|
||||
|
||||
static String m9(name, planName) =>
|
||||
static String m22(name, planName) =>
|
||||
"Пользователь ${name} подписался на план «${planName}»";
|
||||
|
||||
static String m10(planName) => "Вы отменили подписку на план «${planName}»";
|
||||
static String m23(planName) => "Вы отменили подписку на план «${planName}»";
|
||||
|
||||
final messages = _notInlinedMessages(_notInlinedMessages);
|
||||
static Map<String, Function> _notInlinedMessages(_) => <String, Function>{
|
||||
@@ -503,6 +533,126 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"initial_measurements": MessageLookupByLibrary.simpleMessage(
|
||||
"Первичные замеры",
|
||||
),
|
||||
"insights_add_measures_empty": MessageLookupByLibrary.simpleMessage(
|
||||
"Add your measurements",
|
||||
),
|
||||
"insights_add_record": MessageLookupByLibrary.simpleMessage("Add entry"),
|
||||
"insights_add_weight_empty": MessageLookupByLibrary.simpleMessage(
|
||||
"Add your weight",
|
||||
),
|
||||
"insights_bmi_higher": m1,
|
||||
"insights_bmi_hint": m2,
|
||||
"insights_bmi_lower": m3,
|
||||
"insights_bmi_normal": MessageLookupByLibrary.simpleMessage("normal"),
|
||||
"insights_bmi_obesity_1": MessageLookupByLibrary.simpleMessage(
|
||||
"obesity class I",
|
||||
),
|
||||
"insights_bmi_obesity_2": MessageLookupByLibrary.simpleMessage(
|
||||
"obesity class II",
|
||||
),
|
||||
"insights_bmi_obesity_3": MessageLookupByLibrary.simpleMessage(
|
||||
"obesity class III",
|
||||
),
|
||||
"insights_bmi_overweight": MessageLookupByLibrary.simpleMessage(
|
||||
"overweight",
|
||||
),
|
||||
"insights_bmi_same": m4,
|
||||
"insights_bmi_severe_underweight": MessageLookupByLibrary.simpleMessage(
|
||||
"severe underweight",
|
||||
),
|
||||
"insights_bmi_underweight": MessageLookupByLibrary.simpleMessage(
|
||||
"underweight",
|
||||
),
|
||||
"insights_intra_hint": MessageLookupByLibrary.simpleMessage(
|
||||
"Bars show what each meal consists of: protein, fat and carbs in kcal. Lines show satiety and mood on a 0–5 scale.",
|
||||
),
|
||||
"insights_load_error": MessageLookupByLibrary.simpleMessage(
|
||||
"Failed to load analytics",
|
||||
),
|
||||
"insights_measure_less": m5,
|
||||
"insights_measure_more": m6,
|
||||
"insights_measure_same": m7,
|
||||
"insights_measures_summary": m8,
|
||||
"insights_need_height": MessageLookupByLibrary.simpleMessage(
|
||||
"Set your height to calculate BMI.",
|
||||
),
|
||||
"insights_next_day": MessageLookupByLibrary.simpleMessage("Next day"),
|
||||
"insights_not_enough": MessageLookupByLibrary.simpleMessage(
|
||||
"Not enough data",
|
||||
),
|
||||
"insights_period_1m": MessageLookupByLibrary.simpleMessage("1M"),
|
||||
"insights_period_1w": MessageLookupByLibrary.simpleMessage("1W"),
|
||||
"insights_period_1y": MessageLookupByLibrary.simpleMessage("1Y"),
|
||||
"insights_period_2w": MessageLookupByLibrary.simpleMessage("2W"),
|
||||
"insights_period_3m": MessageLookupByLibrary.simpleMessage("3M"),
|
||||
"insights_period_6m": MessageLookupByLibrary.simpleMessage("6M"),
|
||||
"insights_period_all": MessageLookupByLibrary.simpleMessage("All time"),
|
||||
"insights_period_label_1": MessageLookupByLibrary.simpleMessage("month"),
|
||||
"insights_period_label_12": MessageLookupByLibrary.simpleMessage("year"),
|
||||
"insights_period_label_3": MessageLookupByLibrary.simpleMessage("3 months"),
|
||||
"insights_period_label_6": MessageLookupByLibrary.simpleMessage("6 months"),
|
||||
"insights_period_label_all": MessageLookupByLibrary.simpleMessage(
|
||||
"all time",
|
||||
),
|
||||
"insights_pick_date": MessageLookupByLibrary.simpleMessage("Pick a date"),
|
||||
"insights_prev_day": MessageLookupByLibrary.simpleMessage("Previous day"),
|
||||
"insights_retry": MessageLookupByLibrary.simpleMessage("Retry"),
|
||||
"insights_save_error": MessageLookupByLibrary.simpleMessage(
|
||||
"Failed to save the entry",
|
||||
),
|
||||
"insights_series_calories": MessageLookupByLibrary.simpleMessage(
|
||||
"Calories",
|
||||
),
|
||||
"insights_series_carbs": MessageLookupByLibrary.simpleMessage("Carbs"),
|
||||
"insights_series_chest": MessageLookupByLibrary.simpleMessage("Chest"),
|
||||
"insights_series_fats": MessageLookupByLibrary.simpleMessage("Fat"),
|
||||
"insights_series_hips": MessageLookupByLibrary.simpleMessage("Hips"),
|
||||
"insights_series_mood": MessageLookupByLibrary.simpleMessage("Mood"),
|
||||
"insights_series_proteins": MessageLookupByLibrary.simpleMessage("Protein"),
|
||||
"insights_series_satiety": MessageLookupByLibrary.simpleMessage("Satiety"),
|
||||
"insights_series_sleep": MessageLookupByLibrary.simpleMessage(
|
||||
"Sleep and wake-up",
|
||||
),
|
||||
"insights_series_waist": MessageLookupByLibrary.simpleMessage("Waist"),
|
||||
"insights_sleep_at": m9,
|
||||
"insights_sleep_shade": MessageLookupByLibrary.simpleMessage(
|
||||
"Shading — sleep time",
|
||||
),
|
||||
"insights_subtitle_closed": MessageLookupByLibrary.simpleMessage(
|
||||
"tap to see your progress",
|
||||
),
|
||||
"insights_subtitle_open": MessageLookupByLibrary.simpleMessage(
|
||||
"weight, measurements and nutrition",
|
||||
),
|
||||
"insights_tab_bmi": MessageLookupByLibrary.simpleMessage("BMI"),
|
||||
"insights_tab_daily": MessageLookupByLibrary.simpleMessage(
|
||||
"Nutrition by day",
|
||||
),
|
||||
"insights_tab_intra": MessageLookupByLibrary.simpleMessage(
|
||||
"Throughout the day",
|
||||
),
|
||||
"insights_tab_measures": MessageLookupByLibrary.simpleMessage(
|
||||
"Measurements",
|
||||
),
|
||||
"insights_tab_weight": MessageLookupByLibrary.simpleMessage("Weight"),
|
||||
"insights_title": MessageLookupByLibrary.simpleMessage("Analytics"),
|
||||
"insights_today": MessageLookupByLibrary.simpleMessage("Today"),
|
||||
"insights_unit_bmi": MessageLookupByLibrary.simpleMessage("BMI"),
|
||||
"insights_unit_cm": MessageLookupByLibrary.simpleMessage("cm"),
|
||||
"insights_unit_cm_waist": MessageLookupByLibrary.simpleMessage("cm waist"),
|
||||
"insights_unit_grams": MessageLookupByLibrary.simpleMessage("grams"),
|
||||
"insights_unit_kcal": MessageLookupByLibrary.simpleMessage("kcal"),
|
||||
"insights_unit_kcal_avg": MessageLookupByLibrary.simpleMessage(
|
||||
"kcal on average",
|
||||
),
|
||||
"insights_unit_kcal_per_meal": MessageLookupByLibrary.simpleMessage(
|
||||
"kcal per meal",
|
||||
),
|
||||
"insights_unit_kg": MessageLookupByLibrary.simpleMessage("kg"),
|
||||
"insights_wake_at": m10,
|
||||
"insights_weight_gained": m11,
|
||||
"insights_weight_lost": m12,
|
||||
"insights_weight_same": m13,
|
||||
"invite": MessageLookupByLibrary.simpleMessage("Пригласить"),
|
||||
"invited_people_count": MessageLookupByLibrary.simpleMessage(
|
||||
"Количество приглашенных",
|
||||
@@ -658,6 +808,9 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"В пароле должно быть минимум 6 символов",
|
||||
),
|
||||
"pay": MessageLookupByLibrary.simpleMessage("Оплатить"),
|
||||
"pay_by_qr_code": MessageLookupByLibrary.simpleMessage(
|
||||
"Оплатить по QR-коду",
|
||||
),
|
||||
"payment_failed": MessageLookupByLibrary.simpleMessage("Оплата не прошла"),
|
||||
"pcs": MessageLookupByLibrary.simpleMessage("шт"),
|
||||
"pending_payment": MessageLookupByLibrary.simpleMessage("Ожидается оплата"),
|
||||
@@ -681,11 +834,11 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"Физическая форма",
|
||||
),
|
||||
"place_of_living": MessageLookupByLibrary.simpleMessage("Место проживания"),
|
||||
"planStarted": m1,
|
||||
"planStarted": m14,
|
||||
"planWillBeConfirmedAfterThreeDays": MessageLookupByLibrary.simpleMessage(
|
||||
"Если вы не подтвердите, то план через три дня подтвердится автоматически",
|
||||
),
|
||||
"planWithUserStarted": m2,
|
||||
"planWithUserStarted": m15,
|
||||
"plan_author": MessageLookupByLibrary.simpleMessage("Автор плана"),
|
||||
"plan_desc": MessageLookupByLibrary.simpleMessage("Описание плана"),
|
||||
"plan_desc_example": MessageLookupByLibrary.simpleMessage(
|
||||
@@ -728,7 +881,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"Promo code applied!",
|
||||
),
|
||||
"promo_code": MessageLookupByLibrary.simpleMessage("Promo code"),
|
||||
"promo_discount_hint": m3,
|
||||
"promo_discount_hint": m16,
|
||||
"promo_invalid": MessageLookupByLibrary.simpleMessage(
|
||||
"Promo code not found or expired",
|
||||
),
|
||||
@@ -777,6 +930,34 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"sbp_payment_error": MessageLookupByLibrary.simpleMessage(
|
||||
"Не удалось начать оплату. Попробуйте позже",
|
||||
),
|
||||
"sbp_qr_amount_label": MessageLookupByLibrary.simpleMessage(
|
||||
"Сумма к оплате",
|
||||
),
|
||||
"sbp_qr_open_test_payment": MessageLookupByLibrary.simpleMessage(
|
||||
"Открыть тестовую оплату",
|
||||
),
|
||||
"sbp_qr_paid_description": MessageLookupByLibrary.simpleMessage(
|
||||
"Консультация оплачена. Можно вернуться в чат.",
|
||||
),
|
||||
"sbp_qr_paid_title": MessageLookupByLibrary.simpleMessage(
|
||||
"Оплата прошла успешно!",
|
||||
),
|
||||
"sbp_qr_step1": MessageLookupByLibrary.simpleMessage(
|
||||
"Откройте приложение банка на телефоне",
|
||||
),
|
||||
"sbp_qr_step2": MessageLookupByLibrary.simpleMessage(
|
||||
"Выберите «Оплата по QR-коду»",
|
||||
),
|
||||
"sbp_qr_step3": MessageLookupByLibrary.simpleMessage(
|
||||
"Наведите камеру на код",
|
||||
),
|
||||
"sbp_qr_test_hint": MessageLookupByLibrary.simpleMessage(
|
||||
"Симуляция оплаты на тестовом стенде Альфа-Банка",
|
||||
),
|
||||
"sbp_qr_title": MessageLookupByLibrary.simpleMessage("Оплата по QR-коду"),
|
||||
"sbp_qr_waiting_payment": MessageLookupByLibrary.simpleMessage(
|
||||
"Ожидание оплаты…",
|
||||
),
|
||||
"send": MessageLookupByLibrary.simpleMessage("Отправить"),
|
||||
"send_complain": MessageLookupByLibrary.simpleMessage("Отправить жалобу"),
|
||||
"services": MessageLookupByLibrary.simpleMessage("Оказание услуг"),
|
||||
@@ -826,7 +1007,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"step_by_step_photo_recipe": MessageLookupByLibrary.simpleMessage(
|
||||
"Пошаговый фоторецепт",
|
||||
),
|
||||
"step_widget": m4,
|
||||
"step_widget": m17,
|
||||
"successfully_published": MessageLookupByLibrary.simpleMessage(
|
||||
"успешно опубликован",
|
||||
),
|
||||
@@ -851,8 +1032,8 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"toOffer": MessageLookupByLibrary.simpleMessage("Предложить"),
|
||||
"to_draft": MessageLookupByLibrary.simpleMessage("В черновик"),
|
||||
"to_order": MessageLookupByLibrary.simpleMessage("Заказать"),
|
||||
"tommorowPlanWithUserWillStart": m5,
|
||||
"tomorrowYourPlanWillStart": m6,
|
||||
"tommorowPlanWithUserWillStart": m18,
|
||||
"tomorrowYourPlanWillStart": m19,
|
||||
"try_again": MessageLookupByLibrary.simpleMessage("Попробовать снова"),
|
||||
"try_again_2": MessageLookupByLibrary.simpleMessage("Попробуйте ещё раз"),
|
||||
"typeAmoutOfReturn": MessageLookupByLibrary.simpleMessage(
|
||||
@@ -886,7 +1067,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"Верхние показатели давления:",
|
||||
),
|
||||
"upper": MessageLookupByLibrary.simpleMessage("Верхнее"),
|
||||
"userCanceldThePlanSubscription": m7,
|
||||
"userCanceldThePlanSubscription": m20,
|
||||
"userConfirmedPlanCompletion": MessageLookupByLibrary.simpleMessage(
|
||||
"Пользователь подтвердил прохождения плана. В ближайшее время Вы получите",
|
||||
),
|
||||
@@ -894,8 +1075,8 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
MessageLookupByLibrary.simpleMessage(
|
||||
"Пользователь подтвердил прохождения плана.",
|
||||
),
|
||||
"userPaidThePlan": m8,
|
||||
"userSubscribedToPlan": m9,
|
||||
"userPaidThePlan": m21,
|
||||
"userSubscribedToPlan": m22,
|
||||
"user_authorization": MessageLookupByLibrary.simpleMessage(
|
||||
"Аутентификация",
|
||||
),
|
||||
@@ -1025,7 +1206,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"youCanComplain": MessageLookupByLibrary.simpleMessage(
|
||||
"Если вас что-то не устроило, вы можете подать жалобу",
|
||||
),
|
||||
"youCanceledSubscriptionToPlan": m10,
|
||||
"youCanceledSubscriptionToPlan": m23,
|
||||
"youShureYouWantToCancelOrder": MessageLookupByLibrary.simpleMessage(
|
||||
"Вы уверены, что хотите отменить заказ?",
|
||||
),
|
||||
|
||||
@@ -20,7 +20,37 @@ typedef String MessageIfAbsent(String messageStr, List<dynamic> args);
|
||||
class MessageLookup extends MessageLookupByLibrary {
|
||||
String get localeName => 'ru';
|
||||
|
||||
static String m3(percent, amount) =>
|
||||
static String m1(value, period, category) =>
|
||||
"ИМТ выше на ${value} за ${period} · ${category}";
|
||||
|
||||
static String m2(height) =>
|
||||
"Зелёная зона — норма (18.5–25), жёлтая — избыточный вес, розовая — ожирение. Считаем при росте ${height} см.";
|
||||
|
||||
static String m3(value, period, category) =>
|
||||
"ИМТ ниже на ${value} за ${period} · ${category}";
|
||||
|
||||
static String m4(period, category) =>
|
||||
"ИМТ не изменился за ${period} · ${category}";
|
||||
|
||||
static String m5(name, value) => "${name} меньше на ${value} см";
|
||||
|
||||
static String m6(name, value) => "${name} больше на ${value} см";
|
||||
|
||||
static String m7(name) => "${name} без изменений";
|
||||
|
||||
static String m8(parts, period) => "${parts} за ${period}";
|
||||
|
||||
static String m9(time) => "Отход ко сну в ${time}";
|
||||
|
||||
static String m10(time) => "Подъём в ${time}";
|
||||
|
||||
static String m11(value, period) => "Набрали ${value} кг за ${period}";
|
||||
|
||||
static String m12(value, period) => "Снизили на ${value} кг за ${period}";
|
||||
|
||||
static String m13(period) => "Вес не изменился за ${period}";
|
||||
|
||||
static String m16(percent, amount) =>
|
||||
"Скидка ${percent}% — вы экономите ${amount} ₽";
|
||||
|
||||
final messages = _notInlinedMessages(_notInlinedMessages);
|
||||
@@ -212,6 +242,124 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"initial_measurements": MessageLookupByLibrary.simpleMessage(
|
||||
"Первичные замеры",
|
||||
),
|
||||
"insights_add_measures_empty": MessageLookupByLibrary.simpleMessage(
|
||||
"Добавьте Ваши замеры",
|
||||
),
|
||||
"insights_add_record": MessageLookupByLibrary.simpleMessage(
|
||||
"Добавить запись",
|
||||
),
|
||||
"insights_add_weight_empty": MessageLookupByLibrary.simpleMessage(
|
||||
"Добавьте Ваш вес",
|
||||
),
|
||||
"insights_bmi_higher": m1,
|
||||
"insights_bmi_hint": m2,
|
||||
"insights_bmi_lower": m3,
|
||||
"insights_bmi_normal": MessageLookupByLibrary.simpleMessage("норма"),
|
||||
"insights_bmi_obesity_1": MessageLookupByLibrary.simpleMessage(
|
||||
"ожирение I ст.",
|
||||
),
|
||||
"insights_bmi_obesity_2": MessageLookupByLibrary.simpleMessage(
|
||||
"ожирение II ст.",
|
||||
),
|
||||
"insights_bmi_obesity_3": MessageLookupByLibrary.simpleMessage(
|
||||
"ожирение III ст.",
|
||||
),
|
||||
"insights_bmi_overweight": MessageLookupByLibrary.simpleMessage(
|
||||
"избыточная масса",
|
||||
),
|
||||
"insights_bmi_same": m4,
|
||||
"insights_bmi_severe_underweight": MessageLookupByLibrary.simpleMessage(
|
||||
"выраженный дефицит массы",
|
||||
),
|
||||
"insights_bmi_underweight": MessageLookupByLibrary.simpleMessage(
|
||||
"недостаточная масса",
|
||||
),
|
||||
"insights_intra_hint": MessageLookupByLibrary.simpleMessage(
|
||||
"Столбики показывают, из чего состоят приёмы пищи: белки, жиры и углеводы в ккал. Линии — сытость и настроение по шкале от 0 до 5.",
|
||||
),
|
||||
"insights_load_error": MessageLookupByLibrary.simpleMessage(
|
||||
"Не удалось загрузить аналитику",
|
||||
),
|
||||
"insights_measure_less": m5,
|
||||
"insights_measure_more": m6,
|
||||
"insights_measure_same": m7,
|
||||
"insights_measures_summary": m8,
|
||||
"insights_need_height": MessageLookupByLibrary.simpleMessage(
|
||||
"Укажите рост, чтобы посчитать ИМТ.",
|
||||
),
|
||||
"insights_next_day": MessageLookupByLibrary.simpleMessage("Следующий день"),
|
||||
"insights_not_enough": MessageLookupByLibrary.simpleMessage(
|
||||
"Недостаточно данных",
|
||||
),
|
||||
"insights_period_1m": MessageLookupByLibrary.simpleMessage("1М"),
|
||||
"insights_period_1w": MessageLookupByLibrary.simpleMessage("1Н"),
|
||||
"insights_period_1y": MessageLookupByLibrary.simpleMessage("1Г"),
|
||||
"insights_period_2w": MessageLookupByLibrary.simpleMessage("2Н"),
|
||||
"insights_period_3m": MessageLookupByLibrary.simpleMessage("3М"),
|
||||
"insights_period_6m": MessageLookupByLibrary.simpleMessage("6М"),
|
||||
"insights_period_all": MessageLookupByLibrary.simpleMessage("Всё время"),
|
||||
"insights_period_label_1": MessageLookupByLibrary.simpleMessage("месяц"),
|
||||
"insights_period_label_12": MessageLookupByLibrary.simpleMessage("год"),
|
||||
"insights_period_label_3": MessageLookupByLibrary.simpleMessage("3 месяца"),
|
||||
"insights_period_label_6": MessageLookupByLibrary.simpleMessage(
|
||||
"6 месяцев",
|
||||
),
|
||||
"insights_period_label_all": MessageLookupByLibrary.simpleMessage(
|
||||
"всё время",
|
||||
),
|
||||
"insights_pick_date": MessageLookupByLibrary.simpleMessage("Выбрать дату"),
|
||||
"insights_prev_day": MessageLookupByLibrary.simpleMessage(
|
||||
"Предыдущий день",
|
||||
),
|
||||
"insights_retry": MessageLookupByLibrary.simpleMessage("Повторить"),
|
||||
"insights_save_error": MessageLookupByLibrary.simpleMessage(
|
||||
"Не удалось сохранить запись",
|
||||
),
|
||||
"insights_series_calories": MessageLookupByLibrary.simpleMessage("Калории"),
|
||||
"insights_series_carbs": MessageLookupByLibrary.simpleMessage("Углеводы"),
|
||||
"insights_series_chest": MessageLookupByLibrary.simpleMessage("Грудь"),
|
||||
"insights_series_fats": MessageLookupByLibrary.simpleMessage("Жиры"),
|
||||
"insights_series_hips": MessageLookupByLibrary.simpleMessage("Бёдра"),
|
||||
"insights_series_mood": MessageLookupByLibrary.simpleMessage("Настроение"),
|
||||
"insights_series_proteins": MessageLookupByLibrary.simpleMessage("Белки"),
|
||||
"insights_series_satiety": MessageLookupByLibrary.simpleMessage("Сытость"),
|
||||
"insights_series_sleep": MessageLookupByLibrary.simpleMessage(
|
||||
"Сон и подъём",
|
||||
),
|
||||
"insights_series_waist": MessageLookupByLibrary.simpleMessage("Талия"),
|
||||
"insights_sleep_at": m9,
|
||||
"insights_sleep_shade": MessageLookupByLibrary.simpleMessage(
|
||||
"Затемнение — время сна",
|
||||
),
|
||||
"insights_subtitle_closed": MessageLookupByLibrary.simpleMessage(
|
||||
"нажмите, чтобы посмотреть прогресс",
|
||||
),
|
||||
"insights_subtitle_open": MessageLookupByLibrary.simpleMessage(
|
||||
"вес, замеры и питание",
|
||||
),
|
||||
"insights_tab_bmi": MessageLookupByLibrary.simpleMessage("ИМТ"),
|
||||
"insights_tab_daily": MessageLookupByLibrary.simpleMessage("КБЖУ по дням"),
|
||||
"insights_tab_intra": MessageLookupByLibrary.simpleMessage("В течение дня"),
|
||||
"insights_tab_measures": MessageLookupByLibrary.simpleMessage("Замеры"),
|
||||
"insights_tab_weight": MessageLookupByLibrary.simpleMessage("Вес"),
|
||||
"insights_title": MessageLookupByLibrary.simpleMessage("Аналитика"),
|
||||
"insights_today": MessageLookupByLibrary.simpleMessage("Сегодня"),
|
||||
"insights_unit_bmi": MessageLookupByLibrary.simpleMessage("ИМТ"),
|
||||
"insights_unit_cm": MessageLookupByLibrary.simpleMessage("см"),
|
||||
"insights_unit_cm_waist": MessageLookupByLibrary.simpleMessage("см талия"),
|
||||
"insights_unit_grams": MessageLookupByLibrary.simpleMessage("граммы"),
|
||||
"insights_unit_kcal": MessageLookupByLibrary.simpleMessage("ккал"),
|
||||
"insights_unit_kcal_avg": MessageLookupByLibrary.simpleMessage(
|
||||
"ккал в среднем",
|
||||
),
|
||||
"insights_unit_kcal_per_meal": MessageLookupByLibrary.simpleMessage(
|
||||
"ккал за приём",
|
||||
),
|
||||
"insights_unit_kg": MessageLookupByLibrary.simpleMessage("кг"),
|
||||
"insights_wake_at": m10,
|
||||
"insights_weight_gained": m11,
|
||||
"insights_weight_lost": m12,
|
||||
"insights_weight_same": m13,
|
||||
"label": MessageLookupByLibrary.simpleMessage("Название"),
|
||||
"language": MessageLookupByLibrary.simpleMessage("Язык"),
|
||||
"last_name": MessageLookupByLibrary.simpleMessage("Фамилия"),
|
||||
@@ -297,6 +445,9 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"password_short": MessageLookupByLibrary.simpleMessage(
|
||||
"В пароле должно быть минимум 6 символов",
|
||||
),
|
||||
"pay_by_qr_code": MessageLookupByLibrary.simpleMessage(
|
||||
"Оплатить по QR-коду",
|
||||
),
|
||||
"pcs": MessageLookupByLibrary.simpleMessage("шт"),
|
||||
"personal": MessageLookupByLibrary.simpleMessage("Персональные"),
|
||||
"personal_diet_state": MessageLookupByLibrary.simpleMessage("Персональная"),
|
||||
@@ -335,7 +486,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"Промокод применён!",
|
||||
),
|
||||
"promo_code": MessageLookupByLibrary.simpleMessage("Промокод"),
|
||||
"promo_discount_hint": m3,
|
||||
"promo_discount_hint": m16,
|
||||
"promo_invalid": MessageLookupByLibrary.simpleMessage(
|
||||
"Промокод не найден или истёк",
|
||||
),
|
||||
@@ -354,6 +505,34 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"sbp_payment_error": MessageLookupByLibrary.simpleMessage(
|
||||
"Не удалось начать оплату. Попробуйте позже",
|
||||
),
|
||||
"sbp_qr_amount_label": MessageLookupByLibrary.simpleMessage(
|
||||
"Сумма к оплате",
|
||||
),
|
||||
"sbp_qr_open_test_payment": MessageLookupByLibrary.simpleMessage(
|
||||
"Открыть тестовую оплату",
|
||||
),
|
||||
"sbp_qr_paid_description": MessageLookupByLibrary.simpleMessage(
|
||||
"Консультация оплачена. Можно вернуться в чат.",
|
||||
),
|
||||
"sbp_qr_paid_title": MessageLookupByLibrary.simpleMessage(
|
||||
"Оплата прошла успешно!",
|
||||
),
|
||||
"sbp_qr_step1": MessageLookupByLibrary.simpleMessage(
|
||||
"Откройте приложение банка на телефоне",
|
||||
),
|
||||
"sbp_qr_step2": MessageLookupByLibrary.simpleMessage(
|
||||
"Выберите «Оплата по QR-коду»",
|
||||
),
|
||||
"sbp_qr_step3": MessageLookupByLibrary.simpleMessage(
|
||||
"Наведите камеру на код",
|
||||
),
|
||||
"sbp_qr_test_hint": MessageLookupByLibrary.simpleMessage(
|
||||
"Симуляция оплаты на тестовом стенде Альфа-Банка",
|
||||
),
|
||||
"sbp_qr_title": MessageLookupByLibrary.simpleMessage("Оплата по QR-коду"),
|
||||
"sbp_qr_waiting_payment": MessageLookupByLibrary.simpleMessage(
|
||||
"Ожидание оплаты…",
|
||||
),
|
||||
"services": MessageLookupByLibrary.simpleMessage("Оказание услуг"),
|
||||
"settings": MessageLookupByLibrary.simpleMessage("Настройки"),
|
||||
"share": MessageLookupByLibrary.simpleMessage("Поделиться"),
|
||||
|
||||
@@ -2689,6 +2689,116 @@ class S {
|
||||
);
|
||||
}
|
||||
|
||||
/// `Оплатить по QR-коду`
|
||||
String get pay_by_qr_code {
|
||||
return Intl.message(
|
||||
'Оплатить по QR-коду',
|
||||
name: 'pay_by_qr_code',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Оплата по QR-коду`
|
||||
String get sbp_qr_title {
|
||||
return Intl.message(
|
||||
'Оплата по QR-коду',
|
||||
name: 'sbp_qr_title',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Сумма к оплате`
|
||||
String get sbp_qr_amount_label {
|
||||
return Intl.message(
|
||||
'Сумма к оплате',
|
||||
name: 'sbp_qr_amount_label',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Откройте приложение банка на телефоне`
|
||||
String get sbp_qr_step1 {
|
||||
return Intl.message(
|
||||
'Откройте приложение банка на телефоне',
|
||||
name: 'sbp_qr_step1',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Выберите «Оплата по QR-коду»`
|
||||
String get sbp_qr_step2 {
|
||||
return Intl.message(
|
||||
'Выберите «Оплата по QR-коду»',
|
||||
name: 'sbp_qr_step2',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Наведите камеру на код`
|
||||
String get sbp_qr_step3 {
|
||||
return Intl.message(
|
||||
'Наведите камеру на код',
|
||||
name: 'sbp_qr_step3',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Ожидание оплаты…`
|
||||
String get sbp_qr_waiting_payment {
|
||||
return Intl.message(
|
||||
'Ожидание оплаты…',
|
||||
name: 'sbp_qr_waiting_payment',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Оплата прошла успешно!`
|
||||
String get sbp_qr_paid_title {
|
||||
return Intl.message(
|
||||
'Оплата прошла успешно!',
|
||||
name: 'sbp_qr_paid_title',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Консультация оплачена. Можно вернуться в чат.`
|
||||
String get sbp_qr_paid_description {
|
||||
return Intl.message(
|
||||
'Консультация оплачена. Можно вернуться в чат.',
|
||||
name: 'sbp_qr_paid_description',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Открыть тестовую оплату`
|
||||
String get sbp_qr_open_test_payment {
|
||||
return Intl.message(
|
||||
'Открыть тестовую оплату',
|
||||
name: 'sbp_qr_open_test_payment',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Симуляция оплаты на тестовом стенде Альфа-Банка`
|
||||
String get sbp_qr_test_hint {
|
||||
return Intl.message(
|
||||
'Симуляция оплаты на тестовом стенде Альфа-Банка',
|
||||
name: 'sbp_qr_test_hint',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Подробнее о заказе`
|
||||
String get more_about_order {
|
||||
return Intl.message(
|
||||
@@ -4443,6 +4553,661 @@ class S {
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Analytics`
|
||||
String get insights_title {
|
||||
return Intl.message(
|
||||
'Analytics',
|
||||
name: 'insights_title',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `tap to see your progress`
|
||||
String get insights_subtitle_closed {
|
||||
return Intl.message(
|
||||
'tap to see your progress',
|
||||
name: 'insights_subtitle_closed',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `weight, measurements and nutrition`
|
||||
String get insights_subtitle_open {
|
||||
return Intl.message(
|
||||
'weight, measurements and nutrition',
|
||||
name: 'insights_subtitle_open',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Weight`
|
||||
String get insights_tab_weight {
|
||||
return Intl.message(
|
||||
'Weight',
|
||||
name: 'insights_tab_weight',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `BMI`
|
||||
String get insights_tab_bmi {
|
||||
return Intl.message('BMI', name: 'insights_tab_bmi', desc: '', args: []);
|
||||
}
|
||||
|
||||
/// `Measurements`
|
||||
String get insights_tab_measures {
|
||||
return Intl.message(
|
||||
'Measurements',
|
||||
name: 'insights_tab_measures',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Nutrition by day`
|
||||
String get insights_tab_daily {
|
||||
return Intl.message(
|
||||
'Nutrition by day',
|
||||
name: 'insights_tab_daily',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Throughout the day`
|
||||
String get insights_tab_intra {
|
||||
return Intl.message(
|
||||
'Throughout the day',
|
||||
name: 'insights_tab_intra',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `1M`
|
||||
String get insights_period_1m {
|
||||
return Intl.message('1M', name: 'insights_period_1m', desc: '', args: []);
|
||||
}
|
||||
|
||||
/// `3M`
|
||||
String get insights_period_3m {
|
||||
return Intl.message('3M', name: 'insights_period_3m', desc: '', args: []);
|
||||
}
|
||||
|
||||
/// `6M`
|
||||
String get insights_period_6m {
|
||||
return Intl.message('6M', name: 'insights_period_6m', desc: '', args: []);
|
||||
}
|
||||
|
||||
/// `1Y`
|
||||
String get insights_period_1y {
|
||||
return Intl.message('1Y', name: 'insights_period_1y', desc: '', args: []);
|
||||
}
|
||||
|
||||
/// `All time`
|
||||
String get insights_period_all {
|
||||
return Intl.message(
|
||||
'All time',
|
||||
name: 'insights_period_all',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `1W`
|
||||
String get insights_period_1w {
|
||||
return Intl.message('1W', name: 'insights_period_1w', desc: '', args: []);
|
||||
}
|
||||
|
||||
/// `2W`
|
||||
String get insights_period_2w {
|
||||
return Intl.message('2W', name: 'insights_period_2w', desc: '', args: []);
|
||||
}
|
||||
|
||||
/// `Waist`
|
||||
String get insights_series_waist {
|
||||
return Intl.message(
|
||||
'Waist',
|
||||
name: 'insights_series_waist',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Chest`
|
||||
String get insights_series_chest {
|
||||
return Intl.message(
|
||||
'Chest',
|
||||
name: 'insights_series_chest',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Hips`
|
||||
String get insights_series_hips {
|
||||
return Intl.message(
|
||||
'Hips',
|
||||
name: 'insights_series_hips',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Calories`
|
||||
String get insights_series_calories {
|
||||
return Intl.message(
|
||||
'Calories',
|
||||
name: 'insights_series_calories',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Protein`
|
||||
String get insights_series_proteins {
|
||||
return Intl.message(
|
||||
'Protein',
|
||||
name: 'insights_series_proteins',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Fat`
|
||||
String get insights_series_fats {
|
||||
return Intl.message(
|
||||
'Fat',
|
||||
name: 'insights_series_fats',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Carbs`
|
||||
String get insights_series_carbs {
|
||||
return Intl.message(
|
||||
'Carbs',
|
||||
name: 'insights_series_carbs',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Satiety`
|
||||
String get insights_series_satiety {
|
||||
return Intl.message(
|
||||
'Satiety',
|
||||
name: 'insights_series_satiety',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Mood`
|
||||
String get insights_series_mood {
|
||||
return Intl.message(
|
||||
'Mood',
|
||||
name: 'insights_series_mood',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Sleep and wake-up`
|
||||
String get insights_series_sleep {
|
||||
return Intl.message(
|
||||
'Sleep and wake-up',
|
||||
name: 'insights_series_sleep',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `kg`
|
||||
String get insights_unit_kg {
|
||||
return Intl.message('kg', name: 'insights_unit_kg', desc: '', args: []);
|
||||
}
|
||||
|
||||
/// `BMI`
|
||||
String get insights_unit_bmi {
|
||||
return Intl.message('BMI', name: 'insights_unit_bmi', desc: '', args: []);
|
||||
}
|
||||
|
||||
/// `cm`
|
||||
String get insights_unit_cm {
|
||||
return Intl.message('cm', name: 'insights_unit_cm', desc: '', args: []);
|
||||
}
|
||||
|
||||
/// `cm waist`
|
||||
String get insights_unit_cm_waist {
|
||||
return Intl.message(
|
||||
'cm waist',
|
||||
name: 'insights_unit_cm_waist',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `kcal`
|
||||
String get insights_unit_kcal {
|
||||
return Intl.message('kcal', name: 'insights_unit_kcal', desc: '', args: []);
|
||||
}
|
||||
|
||||
/// `kcal on average`
|
||||
String get insights_unit_kcal_avg {
|
||||
return Intl.message(
|
||||
'kcal on average',
|
||||
name: 'insights_unit_kcal_avg',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `grams`
|
||||
String get insights_unit_grams {
|
||||
return Intl.message(
|
||||
'grams',
|
||||
name: 'insights_unit_grams',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `kcal per meal`
|
||||
String get insights_unit_kcal_per_meal {
|
||||
return Intl.message(
|
||||
'kcal per meal',
|
||||
name: 'insights_unit_kcal_per_meal',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `month`
|
||||
String get insights_period_label_1 {
|
||||
return Intl.message(
|
||||
'month',
|
||||
name: 'insights_period_label_1',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `3 months`
|
||||
String get insights_period_label_3 {
|
||||
return Intl.message(
|
||||
'3 months',
|
||||
name: 'insights_period_label_3',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `6 months`
|
||||
String get insights_period_label_6 {
|
||||
return Intl.message(
|
||||
'6 months',
|
||||
name: 'insights_period_label_6',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `year`
|
||||
String get insights_period_label_12 {
|
||||
return Intl.message(
|
||||
'year',
|
||||
name: 'insights_period_label_12',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `all time`
|
||||
String get insights_period_label_all {
|
||||
return Intl.message(
|
||||
'all time',
|
||||
name: 'insights_period_label_all',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Not enough data`
|
||||
String get insights_not_enough {
|
||||
return Intl.message(
|
||||
'Not enough data',
|
||||
name: 'insights_not_enough',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Set your height to calculate BMI.`
|
||||
String get insights_need_height {
|
||||
return Intl.message(
|
||||
'Set your height to calculate BMI.',
|
||||
name: 'insights_need_height',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Lost {value} kg over {period}`
|
||||
String insights_weight_lost(String value, String period) {
|
||||
return Intl.message(
|
||||
'Lost $value kg over $period',
|
||||
name: 'insights_weight_lost',
|
||||
desc: '',
|
||||
args: [value, period],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Gained {value} kg over {period}`
|
||||
String insights_weight_gained(String value, String period) {
|
||||
return Intl.message(
|
||||
'Gained $value kg over $period',
|
||||
name: 'insights_weight_gained',
|
||||
desc: '',
|
||||
args: [value, period],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Weight unchanged over {period}`
|
||||
String insights_weight_same(String period) {
|
||||
return Intl.message(
|
||||
'Weight unchanged over $period',
|
||||
name: 'insights_weight_same',
|
||||
desc: '',
|
||||
args: [period],
|
||||
);
|
||||
}
|
||||
|
||||
/// `BMI lower by {value} over {period} · {category}`
|
||||
String insights_bmi_lower(String value, String period, String category) {
|
||||
return Intl.message(
|
||||
'BMI lower by $value over $period · $category',
|
||||
name: 'insights_bmi_lower',
|
||||
desc: '',
|
||||
args: [value, period, category],
|
||||
);
|
||||
}
|
||||
|
||||
/// `BMI higher by {value} over {period} · {category}`
|
||||
String insights_bmi_higher(String value, String period, String category) {
|
||||
return Intl.message(
|
||||
'BMI higher by $value over $period · $category',
|
||||
name: 'insights_bmi_higher',
|
||||
desc: '',
|
||||
args: [value, period, category],
|
||||
);
|
||||
}
|
||||
|
||||
/// `BMI unchanged over {period} · {category}`
|
||||
String insights_bmi_same(String period, String category) {
|
||||
return Intl.message(
|
||||
'BMI unchanged over $period · $category',
|
||||
name: 'insights_bmi_same',
|
||||
desc: '',
|
||||
args: [period, category],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Green zone is normal (18.5–25), yellow is overweight, pink is obesity. Calculated for a height of {height} cm.`
|
||||
String insights_bmi_hint(String height) {
|
||||
return Intl.message(
|
||||
'Green zone is normal (18.5–25), yellow is overweight, pink is obesity. Calculated for a height of $height cm.',
|
||||
name: 'insights_bmi_hint',
|
||||
desc: '',
|
||||
args: [height],
|
||||
);
|
||||
}
|
||||
|
||||
/// `severe underweight`
|
||||
String get insights_bmi_severe_underweight {
|
||||
return Intl.message(
|
||||
'severe underweight',
|
||||
name: 'insights_bmi_severe_underweight',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `underweight`
|
||||
String get insights_bmi_underweight {
|
||||
return Intl.message(
|
||||
'underweight',
|
||||
name: 'insights_bmi_underweight',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `normal`
|
||||
String get insights_bmi_normal {
|
||||
return Intl.message(
|
||||
'normal',
|
||||
name: 'insights_bmi_normal',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `overweight`
|
||||
String get insights_bmi_overweight {
|
||||
return Intl.message(
|
||||
'overweight',
|
||||
name: 'insights_bmi_overweight',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `obesity class I`
|
||||
String get insights_bmi_obesity_1 {
|
||||
return Intl.message(
|
||||
'obesity class I',
|
||||
name: 'insights_bmi_obesity_1',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `obesity class II`
|
||||
String get insights_bmi_obesity_2 {
|
||||
return Intl.message(
|
||||
'obesity class II',
|
||||
name: 'insights_bmi_obesity_2',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `obesity class III`
|
||||
String get insights_bmi_obesity_3 {
|
||||
return Intl.message(
|
||||
'obesity class III',
|
||||
name: 'insights_bmi_obesity_3',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `{name} down {value} cm`
|
||||
String insights_measure_less(String name, String value) {
|
||||
return Intl.message(
|
||||
'$name down $value cm',
|
||||
name: 'insights_measure_less',
|
||||
desc: '',
|
||||
args: [name, value],
|
||||
);
|
||||
}
|
||||
|
||||
/// `{name} up {value} cm`
|
||||
String insights_measure_more(String name, String value) {
|
||||
return Intl.message(
|
||||
'$name up $value cm',
|
||||
name: 'insights_measure_more',
|
||||
desc: '',
|
||||
args: [name, value],
|
||||
);
|
||||
}
|
||||
|
||||
/// `{name} unchanged`
|
||||
String insights_measure_same(String name) {
|
||||
return Intl.message(
|
||||
'$name unchanged',
|
||||
name: 'insights_measure_same',
|
||||
desc: '',
|
||||
args: [name],
|
||||
);
|
||||
}
|
||||
|
||||
/// `{parts} over {period}`
|
||||
String insights_measures_summary(String parts, String period) {
|
||||
return Intl.message(
|
||||
'$parts over $period',
|
||||
name: 'insights_measures_summary',
|
||||
desc: '',
|
||||
args: [parts, period],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Add entry`
|
||||
String get insights_add_record {
|
||||
return Intl.message(
|
||||
'Add entry',
|
||||
name: 'insights_add_record',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Add your weight`
|
||||
String get insights_add_weight_empty {
|
||||
return Intl.message(
|
||||
'Add your weight',
|
||||
name: 'insights_add_weight_empty',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Add your measurements`
|
||||
String get insights_add_measures_empty {
|
||||
return Intl.message(
|
||||
'Add your measurements',
|
||||
name: 'insights_add_measures_empty',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Failed to load analytics`
|
||||
String get insights_load_error {
|
||||
return Intl.message(
|
||||
'Failed to load analytics',
|
||||
name: 'insights_load_error',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Retry`
|
||||
String get insights_retry {
|
||||
return Intl.message('Retry', name: 'insights_retry', desc: '', args: []);
|
||||
}
|
||||
|
||||
/// `Bars show what each meal consists of: protein, fat and carbs in kcal. Lines show satiety and mood on a 0–5 scale.`
|
||||
String get insights_intra_hint {
|
||||
return Intl.message(
|
||||
'Bars show what each meal consists of: protein, fat and carbs in kcal. Lines show satiety and mood on a 0–5 scale.',
|
||||
name: 'insights_intra_hint',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Shading — sleep time`
|
||||
String get insights_sleep_shade {
|
||||
return Intl.message(
|
||||
'Shading — sleep time',
|
||||
name: 'insights_sleep_shade',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Wake-up at {time}`
|
||||
String insights_wake_at(String time) {
|
||||
return Intl.message(
|
||||
'Wake-up at $time',
|
||||
name: 'insights_wake_at',
|
||||
desc: '',
|
||||
args: [time],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Bedtime at {time}`
|
||||
String insights_sleep_at(String time) {
|
||||
return Intl.message(
|
||||
'Bedtime at $time',
|
||||
name: 'insights_sleep_at',
|
||||
desc: '',
|
||||
args: [time],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Previous day`
|
||||
String get insights_prev_day {
|
||||
return Intl.message(
|
||||
'Previous day',
|
||||
name: 'insights_prev_day',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Next day`
|
||||
String get insights_next_day {
|
||||
return Intl.message(
|
||||
'Next day',
|
||||
name: 'insights_next_day',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Pick a date`
|
||||
String get insights_pick_date {
|
||||
return Intl.message(
|
||||
'Pick a date',
|
||||
name: 'insights_pick_date',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Today`
|
||||
String get insights_today {
|
||||
return Intl.message('Today', name: 'insights_today', desc: '', args: []);
|
||||
}
|
||||
|
||||
/// `Failed to save the entry`
|
||||
String get insights_save_error {
|
||||
return Intl.message(
|
||||
'Failed to save the entry',
|
||||
name: 'insights_save_error',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AppLocalizationDelegate extends LocalizationsDelegate<S> {
|
||||
|
||||
@@ -330,6 +330,17 @@
|
||||
"choose_bank_for_payment": "Выберите банк",
|
||||
"no_installed_banks": "Нет установленных банков",
|
||||
"sbp_payment_error": "Не удалось начать оплату. Попробуйте позже",
|
||||
"pay_by_qr_code": "Оплатить по QR-коду",
|
||||
"sbp_qr_title": "Оплата по QR-коду",
|
||||
"sbp_qr_amount_label": "Сумма к оплате",
|
||||
"sbp_qr_step1": "Откройте приложение банка на телефоне",
|
||||
"sbp_qr_step2": "Выберите «Оплата по QR-коду»",
|
||||
"sbp_qr_step3": "Наведите камеру на код",
|
||||
"sbp_qr_waiting_payment": "Ожидание оплаты…",
|
||||
"sbp_qr_paid_title": "Оплата прошла успешно!",
|
||||
"sbp_qr_paid_description": "Консультация оплачена. Можно вернуться в чат.",
|
||||
"sbp_qr_open_test_payment": "Открыть тестовую оплату",
|
||||
"sbp_qr_test_hint": "Симуляция оплаты на тестовом стенде Альфа-Банка",
|
||||
"more_about_order": "Подробнее о заказе",
|
||||
"cancel_order": "Отменить заказ",
|
||||
"refuse_order":"Отказаться от заказа",
|
||||
@@ -526,6 +537,198 @@
|
||||
"video_call_close": "Close",
|
||||
"video_call_notification_in_progress": "Call in progress",
|
||||
"video_call_notification_return": "Return to call",
|
||||
"video_call_notification_end": "End call"
|
||||
"video_call_notification_end": "End call",
|
||||
"insights_title": "Analytics",
|
||||
"insights_subtitle_closed": "tap to see your progress",
|
||||
"insights_subtitle_open": "weight, measurements and nutrition",
|
||||
"insights_tab_weight": "Weight",
|
||||
"insights_tab_bmi": "BMI",
|
||||
"insights_tab_measures": "Measurements",
|
||||
"insights_tab_daily": "Nutrition by day",
|
||||
"insights_tab_intra": "Throughout the day",
|
||||
"insights_period_1m": "1M",
|
||||
"insights_period_3m": "3M",
|
||||
"insights_period_6m": "6M",
|
||||
"insights_period_1y": "1Y",
|
||||
"insights_period_all": "All time",
|
||||
"insights_period_1w": "1W",
|
||||
"insights_period_2w": "2W",
|
||||
"insights_series_waist": "Waist",
|
||||
"insights_series_chest": "Chest",
|
||||
"insights_series_hips": "Hips",
|
||||
"insights_series_calories": "Calories",
|
||||
"insights_series_proteins": "Protein",
|
||||
"insights_series_fats": "Fat",
|
||||
"insights_series_carbs": "Carbs",
|
||||
"insights_series_satiety": "Satiety",
|
||||
"insights_series_mood": "Mood",
|
||||
"insights_series_sleep": "Sleep and wake-up",
|
||||
"insights_unit_kg": "kg",
|
||||
"insights_unit_bmi": "BMI",
|
||||
"insights_unit_cm": "cm",
|
||||
"insights_unit_cm_waist": "cm waist",
|
||||
"insights_unit_kcal": "kcal",
|
||||
"insights_unit_kcal_avg": "kcal on average",
|
||||
"insights_unit_grams": "grams",
|
||||
"insights_unit_kcal_per_meal": "kcal per meal",
|
||||
"insights_period_label_1": "month",
|
||||
"insights_period_label_3": "3 months",
|
||||
"insights_period_label_6": "6 months",
|
||||
"insights_period_label_12": "year",
|
||||
"insights_period_label_all": "all time",
|
||||
"insights_not_enough": "Not enough data",
|
||||
"insights_need_height": "Set your height to calculate BMI.",
|
||||
"insights_weight_lost": "Lost {value} kg over {period}",
|
||||
"@insights_weight_lost": {
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String"
|
||||
},
|
||||
"period": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"insights_weight_gained": "Gained {value} kg over {period}",
|
||||
"@insights_weight_gained": {
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String"
|
||||
},
|
||||
"period": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"insights_weight_same": "Weight unchanged over {period}",
|
||||
"@insights_weight_same": {
|
||||
"placeholders": {
|
||||
"period": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"insights_bmi_lower": "BMI lower by {value} over {period} · {category}",
|
||||
"@insights_bmi_lower": {
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String"
|
||||
},
|
||||
"period": {
|
||||
"type": "String"
|
||||
},
|
||||
"category": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"insights_bmi_higher": "BMI higher by {value} over {period} · {category}",
|
||||
"@insights_bmi_higher": {
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String"
|
||||
},
|
||||
"period": {
|
||||
"type": "String"
|
||||
},
|
||||
"category": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"insights_bmi_same": "BMI unchanged over {period} · {category}",
|
||||
"@insights_bmi_same": {
|
||||
"placeholders": {
|
||||
"period": {
|
||||
"type": "String"
|
||||
},
|
||||
"category": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"insights_bmi_hint": "Green zone is normal (18.5–25), yellow is overweight, pink is obesity. Calculated for a height of {height} cm.",
|
||||
"@insights_bmi_hint": {
|
||||
"placeholders": {
|
||||
"height": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"insights_bmi_severe_underweight": "severe underweight",
|
||||
"insights_bmi_underweight": "underweight",
|
||||
"insights_bmi_normal": "normal",
|
||||
"insights_bmi_overweight": "overweight",
|
||||
"insights_bmi_obesity_1": "obesity class I",
|
||||
"insights_bmi_obesity_2": "obesity class II",
|
||||
"insights_bmi_obesity_3": "obesity class III",
|
||||
"insights_measure_less": "{name} down {value} cm",
|
||||
"@insights_measure_less": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
},
|
||||
"value": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"insights_measure_more": "{name} up {value} cm",
|
||||
"@insights_measure_more": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
},
|
||||
"value": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"insights_measure_same": "{name} unchanged",
|
||||
"@insights_measure_same": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"insights_measures_summary": "{parts} over {period}",
|
||||
"@insights_measures_summary": {
|
||||
"placeholders": {
|
||||
"parts": {
|
||||
"type": "String"
|
||||
},
|
||||
"period": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"insights_add_record": "Add entry",
|
||||
"insights_add_weight_empty": "Add your weight",
|
||||
"insights_add_measures_empty": "Add your measurements",
|
||||
"insights_load_error": "Failed to load analytics",
|
||||
"insights_retry": "Retry",
|
||||
"insights_intra_hint": "Bars show what each meal consists of: protein, fat and carbs in kcal. Lines show satiety and mood on a 0–5 scale.",
|
||||
"insights_sleep_shade": "Shading — sleep time",
|
||||
"insights_wake_at": "Wake-up at {time}",
|
||||
"@insights_wake_at": {
|
||||
"placeholders": {
|
||||
"time": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"insights_sleep_at": "Bedtime at {time}",
|
||||
"@insights_sleep_at": {
|
||||
"placeholders": {
|
||||
"time": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"insights_prev_day": "Previous day",
|
||||
"insights_next_day": "Next day",
|
||||
"insights_pick_date": "Pick a date",
|
||||
"insights_today": "Today",
|
||||
"insights_save_error": "Failed to save the entry"
|
||||
}
|
||||
|
||||
|
||||
@@ -239,6 +239,17 @@
|
||||
"choose_bank_for_payment": "Выберите банк",
|
||||
"no_installed_banks": "Нет установленных банков",
|
||||
"sbp_payment_error": "Не удалось начать оплату. Попробуйте позже",
|
||||
"pay_by_qr_code": "Оплатить по QR-коду",
|
||||
"sbp_qr_title": "Оплата по QR-коду",
|
||||
"sbp_qr_amount_label": "Сумма к оплате",
|
||||
"sbp_qr_step1": "Откройте приложение банка на телефоне",
|
||||
"sbp_qr_step2": "Выберите «Оплата по QR-коду»",
|
||||
"sbp_qr_step3": "Наведите камеру на код",
|
||||
"sbp_qr_waiting_payment": "Ожидание оплаты…",
|
||||
"sbp_qr_paid_title": "Оплата прошла успешно!",
|
||||
"sbp_qr_paid_description": "Консультация оплачена. Можно вернуться в чат.",
|
||||
"sbp_qr_open_test_payment": "Открыть тестовую оплату",
|
||||
"sbp_qr_test_hint": "Симуляция оплаты на тестовом стенде Альфа-Банка",
|
||||
"yes": "Да",
|
||||
"apply": "Применить",
|
||||
"have_promo_code": "У вас есть промокод?",
|
||||
@@ -271,5 +282,198 @@
|
||||
"video_call_notification_in_progress": "Звонок идёт",
|
||||
"video_call_notification_return": "Вернуться к звонку",
|
||||
"video_call_notification_end": "Завершить звонок",
|
||||
"blog_post_image_min_size_error": "Минимальный размер изображения — 500×500 px"
|
||||
"blog_post_image_min_size_error": "Минимальный размер изображения — 500×500 px",
|
||||
"insights_title": "Аналитика",
|
||||
"insights_subtitle_closed": "нажмите, чтобы посмотреть прогресс",
|
||||
"insights_subtitle_open": "вес, замеры и питание",
|
||||
"insights_tab_weight": "Вес",
|
||||
"insights_tab_bmi": "ИМТ",
|
||||
"insights_tab_measures": "Замеры",
|
||||
"insights_tab_daily": "КБЖУ по дням",
|
||||
"insights_tab_intra": "В течение дня",
|
||||
"insights_period_1m": "1М",
|
||||
"insights_period_3m": "3М",
|
||||
"insights_period_6m": "6М",
|
||||
"insights_period_1y": "1Г",
|
||||
"insights_period_all": "Всё время",
|
||||
"insights_period_1w": "1Н",
|
||||
"insights_period_2w": "2Н",
|
||||
"insights_series_waist": "Талия",
|
||||
"insights_series_chest": "Грудь",
|
||||
"insights_series_hips": "Бёдра",
|
||||
"insights_series_calories": "Калории",
|
||||
"insights_series_proteins": "Белки",
|
||||
"insights_series_fats": "Жиры",
|
||||
"insights_series_carbs": "Углеводы",
|
||||
"insights_series_satiety": "Сытость",
|
||||
"insights_series_mood": "Настроение",
|
||||
"insights_series_sleep": "Сон и подъём",
|
||||
"insights_unit_kg": "кг",
|
||||
"insights_unit_bmi": "ИМТ",
|
||||
"insights_unit_cm": "см",
|
||||
"insights_unit_cm_waist": "см талия",
|
||||
"insights_unit_kcal": "ккал",
|
||||
"insights_unit_kcal_avg": "ккал в среднем",
|
||||
"insights_unit_grams": "граммы",
|
||||
"insights_unit_kcal_per_meal": "ккал за приём",
|
||||
"insights_period_label_1": "месяц",
|
||||
"insights_period_label_3": "3 месяца",
|
||||
"insights_period_label_6": "6 месяцев",
|
||||
"insights_period_label_12": "год",
|
||||
"insights_period_label_all": "всё время",
|
||||
"insights_not_enough": "Недостаточно данных",
|
||||
"insights_need_height": "Укажите рост, чтобы посчитать ИМТ.",
|
||||
"insights_weight_lost": "Снизили на {value} кг за {period}",
|
||||
"@insights_weight_lost": {
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String"
|
||||
},
|
||||
"period": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"insights_weight_gained": "Набрали {value} кг за {period}",
|
||||
"@insights_weight_gained": {
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String"
|
||||
},
|
||||
"period": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"insights_weight_same": "Вес не изменился за {period}",
|
||||
"@insights_weight_same": {
|
||||
"placeholders": {
|
||||
"period": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"insights_bmi_lower": "ИМТ ниже на {value} за {period} · {category}",
|
||||
"@insights_bmi_lower": {
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String"
|
||||
},
|
||||
"period": {
|
||||
"type": "String"
|
||||
},
|
||||
"category": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"insights_bmi_higher": "ИМТ выше на {value} за {period} · {category}",
|
||||
"@insights_bmi_higher": {
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String"
|
||||
},
|
||||
"period": {
|
||||
"type": "String"
|
||||
},
|
||||
"category": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"insights_bmi_same": "ИМТ не изменился за {period} · {category}",
|
||||
"@insights_bmi_same": {
|
||||
"placeholders": {
|
||||
"period": {
|
||||
"type": "String"
|
||||
},
|
||||
"category": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"insights_bmi_hint": "Зелёная зона — норма (18.5–25), жёлтая — избыточный вес, розовая — ожирение. Считаем при росте {height} см.",
|
||||
"@insights_bmi_hint": {
|
||||
"placeholders": {
|
||||
"height": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"insights_bmi_severe_underweight": "выраженный дефицит массы",
|
||||
"insights_bmi_underweight": "недостаточная масса",
|
||||
"insights_bmi_normal": "норма",
|
||||
"insights_bmi_overweight": "избыточная масса",
|
||||
"insights_bmi_obesity_1": "ожирение I ст.",
|
||||
"insights_bmi_obesity_2": "ожирение II ст.",
|
||||
"insights_bmi_obesity_3": "ожирение III ст.",
|
||||
"insights_measure_less": "{name} меньше на {value} см",
|
||||
"@insights_measure_less": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
},
|
||||
"value": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"insights_measure_more": "{name} больше на {value} см",
|
||||
"@insights_measure_more": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
},
|
||||
"value": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"insights_measure_same": "{name} без изменений",
|
||||
"@insights_measure_same": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"insights_measures_summary": "{parts} за {period}",
|
||||
"@insights_measures_summary": {
|
||||
"placeholders": {
|
||||
"parts": {
|
||||
"type": "String"
|
||||
},
|
||||
"period": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"insights_add_record": "Добавить запись",
|
||||
"insights_add_weight_empty": "Добавьте Ваш вес",
|
||||
"insights_add_measures_empty": "Добавьте Ваши замеры",
|
||||
"insights_load_error": "Не удалось загрузить аналитику",
|
||||
"insights_retry": "Повторить",
|
||||
"insights_intra_hint": "Столбики показывают, из чего состоят приёмы пищи: белки, жиры и углеводы в ккал. Линии — сытость и настроение по шкале от 0 до 5.",
|
||||
"insights_sleep_shade": "Затемнение — время сна",
|
||||
"insights_wake_at": "Подъём в {time}",
|
||||
"@insights_wake_at": {
|
||||
"placeholders": {
|
||||
"time": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"insights_sleep_at": "Отход ко сну в {time}",
|
||||
"@insights_sleep_at": {
|
||||
"placeholders": {
|
||||
"time": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"insights_prev_day": "Предыдущий день",
|
||||
"insights_next_day": "Следующий день",
|
||||
"insights_pick_date": "Выбрать дату",
|
||||
"insights_today": "Сегодня",
|
||||
"insights_save_error": "Не удалось сохранить запись"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: eatvio_app
|
||||
description: A new Flutter project.
|
||||
version: 1.3.69+158
|
||||
publish_to: 'none'
|
||||
version: 1.3.71+160
|
||||
publish_to: "none"
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.4
|
||||
@@ -92,6 +92,7 @@ dependencies:
|
||||
floating: ^6.0.0
|
||||
wakelock_plus: ^1.5.2
|
||||
sbp: 0.1.0
|
||||
qr_flutter: ^4.1.0
|
||||
|
||||
# html 0.15.7 removed top-level query_selector.matches used by flutter_html 3.0.0.
|
||||
dependency_overrides:
|
||||
|
||||
118
test/core/interceptors/token_interceptor_session_test.dart
Normal file
118
test/core/interceptors/token_interceptor_session_test.dart
Normal file
@@ -0,0 +1,118 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/interceptors/token_interceptor.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
|
||||
import 'package:eatvio_app/core/token_storage.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../fixtures/doubles/session_doubles.dart';
|
||||
|
||||
class _RecordingErrorInterceptorHandler extends ErrorInterceptorHandler {
|
||||
int nextCalls = 0;
|
||||
DioException? nextError;
|
||||
|
||||
@override
|
||||
void next(DioException error) {
|
||||
nextCalls++;
|
||||
nextError = error;
|
||||
}
|
||||
|
||||
@override
|
||||
void resolve(Response<dynamic> response) {
|
||||
throw UnimplementedError('resolve should not be called');
|
||||
}
|
||||
|
||||
@override
|
||||
void reject(
|
||||
DioException error, [
|
||||
bool callFollowingErrorInterceptor = false,
|
||||
]) {
|
||||
throw UnimplementedError('reject should not be called');
|
||||
}
|
||||
}
|
||||
|
||||
DioException _unauthorized(RequestOptions options) {
|
||||
return DioException(
|
||||
requestOptions: options,
|
||||
response: Response<dynamic>(requestOptions: options, statusCode: 401),
|
||||
type: DioExceptionType.badResponse,
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
late FakeSessionLifecycleService sessionLifecycleService;
|
||||
late TokenInterceptor interceptor;
|
||||
|
||||
setUp(() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
TokenStorage.clearCache();
|
||||
await sl.reset();
|
||||
|
||||
sessionLifecycleService = FakeSessionLifecycleService();
|
||||
sl.registerSingleton<SessionLifecycleService>(sessionLifecycleService);
|
||||
interceptor = TokenInterceptor();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await sl.reset();
|
||||
TokenStorage.clearCache();
|
||||
});
|
||||
|
||||
group('TokenInterceptor.onError 401', () {
|
||||
test('request without Authorization does not end the session', () async {
|
||||
await TokenStorage.saveTokenInStorage('current-token');
|
||||
|
||||
final options = RequestOptions(path: '/api/profile/index');
|
||||
final error = _unauthorized(options);
|
||||
final handler = _RecordingErrorInterceptorHandler();
|
||||
|
||||
await interceptor.onError(error, handler);
|
||||
|
||||
expect(handler.nextCalls, 1);
|
||||
expect(handler.nextError, same(error));
|
||||
expect(sessionLifecycleService.endSessionCalls, 0);
|
||||
});
|
||||
|
||||
test('stale request token does not end the new session', () async {
|
||||
await TokenStorage.saveTokenInStorage('new-token');
|
||||
|
||||
final options = RequestOptions(path: '/api/profile/index');
|
||||
options.headers['Authorization'] = 'Bearer old-token';
|
||||
options.extra['authToken'] = 'old-token';
|
||||
|
||||
final error = _unauthorized(options);
|
||||
final handler = _RecordingErrorInterceptorHandler();
|
||||
|
||||
await interceptor.onError(error, handler);
|
||||
|
||||
expect(handler.nextCalls, 1);
|
||||
expect(sessionLifecycleService.endSessionCalls, 0);
|
||||
});
|
||||
|
||||
test(
|
||||
'current request token ends the session once as unauthorized',
|
||||
() async {
|
||||
await TokenStorage.saveTokenInStorage('current-token');
|
||||
|
||||
final options = RequestOptions(path: '/api/profile/index');
|
||||
options.headers['Authorization'] = 'Bearer current-token';
|
||||
options.extra['authToken'] = 'current-token';
|
||||
|
||||
final error = _unauthorized(options);
|
||||
final handler = _RecordingErrorInterceptorHandler();
|
||||
|
||||
await interceptor.onError(error, handler);
|
||||
|
||||
expect(handler.nextCalls, 1);
|
||||
expect(sessionLifecycleService.endSessionCalls, 1);
|
||||
expect(
|
||||
sessionLifecycleService.lastEndReason,
|
||||
SessionEndReason.unauthorized,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,22 +1,24 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/interceptors/token_interceptor.dart';
|
||||
import 'package:eatvio_app/core/token_storage.dart';
|
||||
import 'package:eatvio_app/core/websockets/ws_manager.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
|
||||
import 'package:eatvio_app/core/token_storage.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../fixtures/doubles/session_doubles.dart';
|
||||
|
||||
class MockRequestOptions extends Mock implements RequestOptions {}
|
||||
|
||||
class MockRequestInterceptorHandler extends Mock implements RequestInterceptorHandler {}
|
||||
class MockRequestInterceptorHandler extends Mock
|
||||
implements RequestInterceptorHandler {}
|
||||
|
||||
class MockErrorInterceptorHandler extends Mock implements ErrorInterceptorHandler {}
|
||||
class MockErrorInterceptorHandler extends Mock
|
||||
implements ErrorInterceptorHandler {}
|
||||
|
||||
class MockResponse extends Mock implements Response {}
|
||||
|
||||
class MockWsManager extends Mock implements WsManager {}
|
||||
|
||||
class MockDioException extends Mock implements DioException {}
|
||||
|
||||
class FakeRequestOptions extends Fake implements RequestOptions {}
|
||||
@@ -32,23 +34,18 @@ void main() {
|
||||
});
|
||||
group('TokenInterceptor', () {
|
||||
late TokenInterceptor interceptor;
|
||||
late FakeSessionLifecycleService fakeSession;
|
||||
|
||||
setUp(() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
|
||||
final mockWsManager = MockWsManager();
|
||||
when(() => mockWsManager.clear()).thenAnswer((_) async {});
|
||||
|
||||
await sl.reset(); // Сбрасываем sl перед каждым тестом
|
||||
sl.registerSingleton<WsManager>(mockWsManager);
|
||||
|
||||
TokenStorage.clearCache();
|
||||
|
||||
await sl.reset();
|
||||
fakeSession = FakeSessionLifecycleService();
|
||||
sl.registerSingleton<SessionLifecycleService>(fakeSession);
|
||||
|
||||
interceptor = TokenInterceptor();
|
||||
// Сбрасываем токен перед каждым тестом
|
||||
try {
|
||||
await TokenStorage.removeTokenInStorage();
|
||||
} catch (e) {
|
||||
// Игнорируем ошибки при очистке
|
||||
}
|
||||
await TokenStorage.removeTokenInStorage();
|
||||
});
|
||||
|
||||
group('onRequest', () {
|
||||
@@ -59,6 +56,7 @@ void main() {
|
||||
final headers = <String, dynamic>{};
|
||||
final options = MockRequestOptions();
|
||||
when(() => options.headers).thenReturn(headers);
|
||||
when(() => options.extra).thenReturn(<String, dynamic>{});
|
||||
final handler = MockRequestInterceptorHandler();
|
||||
|
||||
when(() => handler.next(any())).thenReturn(null);
|
||||
@@ -73,6 +71,7 @@ void main() {
|
||||
final headers = <String, dynamic>{};
|
||||
final options = MockRequestOptions();
|
||||
when(() => options.headers).thenReturn(headers);
|
||||
when(() => options.extra).thenReturn(<String, dynamic>{});
|
||||
final handler = MockRequestInterceptorHandler();
|
||||
|
||||
when(() => handler.next(any())).thenReturn(null);
|
||||
@@ -89,6 +88,7 @@ void main() {
|
||||
final headers = <String, dynamic>{};
|
||||
final options = MockRequestOptions();
|
||||
when(() => options.headers).thenReturn(headers);
|
||||
when(() => options.extra).thenReturn(<String, dynamic>{});
|
||||
final handler = MockRequestInterceptorHandler();
|
||||
|
||||
when(() => handler.next(any())).thenReturn(null);
|
||||
@@ -101,11 +101,16 @@ void main() {
|
||||
});
|
||||
|
||||
group('onError', () {
|
||||
test('удаляет токен и перенаправляет на auth при 401', () async {
|
||||
test('завершает сессию при 401 для текущего токена', () async {
|
||||
const token = 'test-token';
|
||||
await TokenStorage.saveTokenInStorage(token);
|
||||
|
||||
final options = MockRequestOptions();
|
||||
when(
|
||||
() => options.headers,
|
||||
).thenReturn({'Authorization': 'Bearer $token'});
|
||||
when(() => options.extra).thenReturn({'authToken': token});
|
||||
|
||||
final response = MockResponse();
|
||||
when(() => response.statusCode).thenReturn(401);
|
||||
|
||||
@@ -116,14 +121,10 @@ void main() {
|
||||
final handler = MockErrorInterceptorHandler();
|
||||
when(() => handler.next(any())).thenReturn(null);
|
||||
|
||||
// Проверяем, что токен был удален
|
||||
final tokenBefore = await TokenStorage.getTokenFromStorage();
|
||||
expect(tokenBefore, token);
|
||||
|
||||
await interceptor.onError(error, handler);
|
||||
|
||||
final tokenAfter = await TokenStorage.getTokenFromStorage();
|
||||
expect(tokenAfter, null);
|
||||
expect(fakeSession.endSessionCalls, 1);
|
||||
expect(fakeSession.lastEndReason, SessionEndReason.unauthorized);
|
||||
verify(() => handler.next(error)).called(1);
|
||||
});
|
||||
|
||||
|
||||
223
test/core/session/session_lifecycle_service_test.dart
Normal file
223
test/core/session/session_lifecycle_service_test.dart
Normal file
@@ -0,0 +1,223 @@
|
||||
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/prefs.dart';
|
||||
import 'package:eatvio_app/core/prefs_keys.dart';
|
||||
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
|
||||
import 'package:eatvio_app/core/token_storage.dart';
|
||||
import 'package:eatvio_app/core/websockets/ws_manager.dart';
|
||||
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/new_messages_cubit/new_messages_cubit.dart';
|
||||
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
||||
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class _FakeWsManager implements WsManager {
|
||||
int clearCalls = 0;
|
||||
|
||||
@override
|
||||
Future<void> clear() async {
|
||||
clearCalls++;
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) {
|
||||
throw UnimplementedError('WsManager.${invocation.memberName}');
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeErrorReporter implements ErrorReporter {
|
||||
int reportCalls = 0;
|
||||
|
||||
@override
|
||||
Future<void> report(
|
||||
Object error,
|
||||
StackTrace? stackTrace, {
|
||||
String? origin,
|
||||
Map<String, Object?>? extras,
|
||||
}) async {
|
||||
reportCalls++;
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeProfileAvatarCubit implements ProfileAvatarCubit {
|
||||
int resetCalls = 0;
|
||||
|
||||
@override
|
||||
void reset() {
|
||||
resetCalls++;
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) {
|
||||
throw UnimplementedError('ProfileAvatarCubit.${invocation.memberName}');
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeChatsCubit implements ChatsCubit {
|
||||
int resetCalls = 0;
|
||||
|
||||
@override
|
||||
void reset() {
|
||||
resetCalls++;
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) {
|
||||
throw UnimplementedError('ChatsCubit.${invocation.memberName}');
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeNotificationsCubit implements NotificationsCubit {
|
||||
int resetCalls = 0;
|
||||
|
||||
@override
|
||||
void reset() {
|
||||
resetCalls++;
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) {
|
||||
throw UnimplementedError('NotificationsCubit.${invocation.memberName}');
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeNewMessagesCubit implements NewMessagesCubit {
|
||||
int resetCalls = 0;
|
||||
|
||||
@override
|
||||
void reset() {
|
||||
resetCalls++;
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) {
|
||||
throw UnimplementedError('NewMessagesCubit.${invocation.memberName}');
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeFeedCubit implements FeedCubit {
|
||||
int resetCalls = 0;
|
||||
|
||||
@override
|
||||
void reset() {
|
||||
resetCalls++;
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) {
|
||||
throw UnimplementedError('FeedCubit.${invocation.memberName}');
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeVideoCallSessionCubit implements VideoCallSessionCubit {
|
||||
int endCallCalls = 0;
|
||||
|
||||
@override
|
||||
Future<void> endCall() async {
|
||||
endCallCalls++;
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) {
|
||||
throw UnimplementedError('VideoCallSessionCubit.${invocation.memberName}');
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
late _FakeWsManager wsManager;
|
||||
late _FakeProfileAvatarCubit profileAvatarCubit;
|
||||
late _FakeChatsCubit chatsCubit;
|
||||
late _FakeNotificationsCubit notificationsCubit;
|
||||
late _FakeNewMessagesCubit newMessagesCubit;
|
||||
late _FakeFeedCubit feedCubit;
|
||||
late _FakeVideoCallSessionCubit videoCallSessionCubit;
|
||||
late SessionLifecycleService service;
|
||||
|
||||
setUp(() async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'token': 'token-a',
|
||||
'socketId': 'socket-a',
|
||||
PrefsKeys.customBaseUrl.name: 'https://old.example',
|
||||
PrefsKeys.isEmailVerificationSkipped.name: true,
|
||||
PrefsKeys.registrationDate.name: '2024-01-01',
|
||||
PrefsKeys.welcomeScreenWasShowed.name: true,
|
||||
});
|
||||
TokenStorage.clearCache();
|
||||
await sl.reset();
|
||||
|
||||
wsManager = _FakeWsManager();
|
||||
profileAvatarCubit = _FakeProfileAvatarCubit();
|
||||
chatsCubit = _FakeChatsCubit();
|
||||
notificationsCubit = _FakeNotificationsCubit();
|
||||
newMessagesCubit = _FakeNewMessagesCubit();
|
||||
feedCubit = _FakeFeedCubit();
|
||||
videoCallSessionCubit = _FakeVideoCallSessionCubit();
|
||||
|
||||
sl.registerSingleton<ProfileAvatarCubit>(profileAvatarCubit);
|
||||
sl.registerSingleton<ChatsCubit>(chatsCubit);
|
||||
sl.registerSingleton<NotificationsCubit>(notificationsCubit);
|
||||
sl.registerSingleton<NewMessagesCubit>(newMessagesCubit);
|
||||
sl.registerSingleton<FeedCubit>(feedCubit);
|
||||
sl.registerSingleton<VideoCallSessionCubit>(videoCallSessionCubit);
|
||||
sl.registerSingleton<Prefs>(Prefs(await SharedPreferences.getInstance()));
|
||||
|
||||
service = SessionLifecycleService(
|
||||
wsManager: wsManager,
|
||||
errorReporter: _FakeErrorReporter(),
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await sl.reset();
|
||||
TokenStorage.clearCache();
|
||||
});
|
||||
|
||||
group('SessionLifecycleService.endSession', () {
|
||||
test(
|
||||
'resets user-scoped state and clears only session-scoped keys',
|
||||
() async {
|
||||
await service.endSession(reason: SessionEndReason.logout);
|
||||
|
||||
expect(wsManager.clearCalls, 1);
|
||||
expect(profileAvatarCubit.resetCalls, 1);
|
||||
expect(chatsCubit.resetCalls, 1);
|
||||
expect(notificationsCubit.resetCalls, 1);
|
||||
expect(newMessagesCubit.resetCalls, 1);
|
||||
expect(feedCubit.resetCalls, 1);
|
||||
expect(videoCallSessionCubit.endCallCalls, 1);
|
||||
|
||||
expect(await TokenStorage.getTokenFromStorage(), isNull);
|
||||
expect(await SocketIdStorage.getSocketIdFromStorage(), isNull);
|
||||
|
||||
final prefs = sl<Prefs>();
|
||||
expect(prefs.getString(PrefsKeys.customBaseUrl), 'https://old.example');
|
||||
expect(prefs.getBool(PrefsKeys.isEmailVerificationSkipped), isNull);
|
||||
expect(prefs.getString(PrefsKeys.registrationDate), isNull);
|
||||
expect(prefs.getBool(PrefsKeys.welcomeScreenWasShowed), isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
test('is idempotent for concurrent calls', () async {
|
||||
final first = service.endSession(reason: SessionEndReason.logout);
|
||||
final second = service.endSession(reason: SessionEndReason.unauthorized);
|
||||
|
||||
await Future.wait([first, second]);
|
||||
|
||||
expect(wsManager.clearCalls, 1);
|
||||
expect(profileAvatarCubit.resetCalls, 1);
|
||||
expect(chatsCubit.resetCalls, 1);
|
||||
expect(notificationsCubit.resetCalls, 1);
|
||||
expect(newMessagesCubit.resetCalls, 1);
|
||||
expect(feedCubit.resetCalls, 1);
|
||||
expect(videoCallSessionCubit.endCallCalls, 1);
|
||||
|
||||
expect(await TokenStorage.getTokenFromStorage(), isNull);
|
||||
expect(await SocketIdStorage.getSocketIdFromStorage(), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../../../../fixtures/doubles/session_doubles.dart';
|
||||
import 'auth_cubit_test.mocks.dart';
|
||||
|
||||
@GenerateNiceMocks([
|
||||
@@ -75,6 +76,7 @@ void main() {
|
||||
profileRepository: mockProfileRepository,
|
||||
firebaseApi: mockFirebaseApi,
|
||||
prefs: mockPrefs,
|
||||
sessionLifecycleService: FakeSessionLifecycleService(),
|
||||
);
|
||||
|
||||
expect(cubit!.state, isA<AuthState>());
|
||||
@@ -86,6 +88,7 @@ void main() {
|
||||
profileRepository: mockProfileRepository,
|
||||
firebaseApi: mockFirebaseApi,
|
||||
prefs: mockPrefs,
|
||||
sessionLifecycleService: FakeSessionLifecycleService(),
|
||||
);
|
||||
|
||||
const email = 'test@example.com';
|
||||
@@ -116,6 +119,7 @@ void main() {
|
||||
profileRepository: mockProfileRepository,
|
||||
firebaseApi: mockFirebaseApi,
|
||||
prefs: mockPrefs,
|
||||
sessionLifecycleService: FakeSessionLifecycleService(),
|
||||
);
|
||||
|
||||
const email = 'test@example.com';
|
||||
@@ -140,6 +144,7 @@ void main() {
|
||||
profileRepository: mockProfileRepository,
|
||||
firebaseApi: mockFirebaseApi,
|
||||
prefs: mockPrefs,
|
||||
sessionLifecycleService: FakeSessionLifecycleService(),
|
||||
);
|
||||
|
||||
when(mockPrefs.getBool(any)).thenReturn(false);
|
||||
@@ -159,6 +164,7 @@ void main() {
|
||||
profileRepository: mockProfileRepository,
|
||||
firebaseApi: mockFirebaseApi,
|
||||
prefs: mockPrefs,
|
||||
sessionLifecycleService: FakeSessionLifecycleService(),
|
||||
);
|
||||
|
||||
when(mockPrefs.getBool(any)).thenReturn(true);
|
||||
@@ -174,6 +180,7 @@ void main() {
|
||||
profileRepository: mockProfileRepository,
|
||||
firebaseApi: mockFirebaseApi,
|
||||
prefs: mockPrefs,
|
||||
sessionLifecycleService: FakeSessionLifecycleService(),
|
||||
);
|
||||
|
||||
when(mockFirebaseApi.registerPushToken())
|
||||
@@ -190,6 +197,7 @@ void main() {
|
||||
profileRepository: mockProfileRepository,
|
||||
firebaseApi: mockFirebaseApi,
|
||||
prefs: mockPrefs,
|
||||
sessionLifecycleService: FakeSessionLifecycleService(),
|
||||
);
|
||||
|
||||
// TokenStorage.haveTokenInStorage() вернет false
|
||||
|
||||
@@ -13,6 +13,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../../../fixtures/doubles/auth_doubles.dart';
|
||||
import '../../../../fixtures/doubles/profile_doubles.dart';
|
||||
import '../../../../fixtures/doubles/session_doubles.dart';
|
||||
import '../../../../fixtures/fixture_reader.dart';
|
||||
import 'auth_cubit_test.mocks.dart';
|
||||
|
||||
@@ -26,6 +27,7 @@ void main() {
|
||||
late FixtureProfileRepository profileRepository;
|
||||
late MockPrefs mockPrefs;
|
||||
late MockFirebaseApi mockFirebaseApi;
|
||||
late FakeSessionLifecycleService fakeSession;
|
||||
late Map<String, dynamic> credentialsFixture;
|
||||
|
||||
late TokenModel tokenFixture;
|
||||
@@ -37,6 +39,7 @@ void main() {
|
||||
profileRepository = FixtureProfileRepository();
|
||||
mockPrefs = MockPrefs();
|
||||
mockFirebaseApi = MockFirebaseApi();
|
||||
fakeSession = FakeSessionLifecycleService();
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
|
||||
// Mock Fluttertoast plugin
|
||||
@@ -59,12 +62,12 @@ void main() {
|
||||
});
|
||||
|
||||
test('начальное состояние - AuthState', () {
|
||||
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi);
|
||||
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession);
|
||||
expect(authCubit!.state, const AuthState());
|
||||
});
|
||||
|
||||
test('getTokenByCredentials - успешная авторизация', () async {
|
||||
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi);
|
||||
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession);
|
||||
|
||||
final result = await authCubit!.getTokenByCredentials(credentialsFixture['email'] as String, credentialsFixture['password'] as String);
|
||||
|
||||
@@ -77,7 +80,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('getTokenByCredentials - переход в состояние loading', () async {
|
||||
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi);
|
||||
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession);
|
||||
authRepository.responseDelay = const Duration(milliseconds: 50);
|
||||
|
||||
final future = authCubit!.getTokenByCredentials(credentialsFixture['email'] as String, credentialsFixture['password'] as String);
|
||||
@@ -88,7 +91,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('getTokenByCredentials - обработка ошибки авторизации', () async {
|
||||
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi);
|
||||
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession);
|
||||
authRepository.error = Exception('Неверный адрес электронной почты или пароль');
|
||||
|
||||
final result = await authCubit!.getTokenByCredentials('wrong@example.com', 'wrongpassword');
|
||||
@@ -102,7 +105,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('getTokenByCredentials - обработка различных типов ошибок', () async {
|
||||
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi);
|
||||
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession);
|
||||
authRepository.error = const FormatException('Invalid credentials format');
|
||||
|
||||
final result = await authCubit!.getTokenByCredentials('invalid', 'pass');
|
||||
@@ -115,7 +118,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('getTokenByCredentials - проверка вызова с правильными параметрами', () async {
|
||||
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi);
|
||||
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession);
|
||||
|
||||
await authCubit!.getTokenByCredentials('user@test.com', 'securePassword123');
|
||||
|
||||
@@ -124,7 +127,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('getTokenByCredentials - множественные вызовы', () async {
|
||||
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi);
|
||||
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession);
|
||||
|
||||
await authCubit!.getTokenByCredentials('first@test.com', 'password1');
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
@@ -138,7 +141,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('registerAnonymousPushToken - регистрирует токен если не был отправлен', () async {
|
||||
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi);
|
||||
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession);
|
||||
when(mockPrefs.getBool(PrefsKeys.hasAnonymousTokenBeenSent)).thenReturn(false);
|
||||
when(mockFirebaseApi.registerAnonymousPushToken()).thenAnswer((_) async => {});
|
||||
when(mockPrefs.setBool(PrefsKeys.hasAnonymousTokenBeenSent, true)).thenAnswer((_) async => {});
|
||||
@@ -150,7 +153,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('registerAnonymousPushToken - не регистрирует токен если уже был отправлен', () async {
|
||||
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi);
|
||||
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession);
|
||||
when(mockPrefs.getBool(PrefsKeys.hasAnonymousTokenBeenSent)).thenReturn(true);
|
||||
|
||||
await authCubit!.registerAnonymousPushToken();
|
||||
@@ -159,7 +162,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('registerPushToken - регистрирует push токен', () async {
|
||||
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi);
|
||||
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession);
|
||||
when(mockFirebaseApi.registerPushToken()).thenAnswer((_) async => {});
|
||||
|
||||
await authCubit!.registerPushToken();
|
||||
@@ -168,7 +171,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('registerAnonymousPushToken - обрабатывает null значение hasAnonymousTokenBeenSent', () async {
|
||||
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi);
|
||||
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession);
|
||||
when(mockPrefs.getBool(PrefsKeys.hasAnonymousTokenBeenSent)).thenReturn(null);
|
||||
when(mockFirebaseApi.registerAnonymousPushToken()).thenAnswer((_) async => {});
|
||||
when(mockPrefs.setBool(PrefsKeys.hasAnonymousTokenBeenSent, true)).thenAnswer((_) async => {});
|
||||
|
||||
@@ -14,6 +14,7 @@ import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../../../../fixtures/doubles/session_doubles.dart';
|
||||
import '../../../../../fixtures/fixture_reader.dart';
|
||||
import 'oauth_cubit_test.mocks.dart';
|
||||
|
||||
@@ -37,6 +38,7 @@ void main() {
|
||||
late MockAuthRepository mockAuthRepository;
|
||||
late MockPrefs mockPrefs;
|
||||
late _RecordingFirebaseApi firebaseApi;
|
||||
late FakeSessionLifecycleService fakeSession;
|
||||
late TokenModel tokenFixture;
|
||||
late Map<String, dynamic> signInRequestFixture;
|
||||
late Map<String, dynamic> signUpRequestFixture;
|
||||
@@ -55,6 +57,7 @@ void main() {
|
||||
mockAuthRepository = MockAuthRepository();
|
||||
mockPrefs = MockPrefs();
|
||||
firebaseApi = _RecordingFirebaseApi();
|
||||
fakeSession = FakeSessionLifecycleService();
|
||||
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
|
||||
@@ -87,7 +90,7 @@ void main() {
|
||||
test('начальное состояние - OAuthState', () {
|
||||
oauthCubit = OAuthCubit(
|
||||
authRepository: mockAuthRepository,
|
||||
firebaseApi: firebaseApi,
|
||||
firebaseApi: firebaseApi, sessionLifecycleService: fakeSession,
|
||||
prefs: mockPrefs,
|
||||
);
|
||||
expect(oauthCubit!.state, const OAuthState());
|
||||
@@ -96,7 +99,7 @@ void main() {
|
||||
test('signInViaSocialMedia - успешный вход через соцсеть', () async {
|
||||
oauthCubit = OAuthCubit(
|
||||
authRepository: mockAuthRepository,
|
||||
firebaseApi: firebaseApi,
|
||||
firebaseApi: firebaseApi, sessionLifecycleService: fakeSession,
|
||||
prefs: mockPrefs,
|
||||
);
|
||||
|
||||
@@ -129,7 +132,7 @@ void main() {
|
||||
test('signInViaSocialMedia - переход в состояние loading', () async {
|
||||
oauthCubit = OAuthCubit(
|
||||
authRepository: mockAuthRepository,
|
||||
firebaseApi: firebaseApi,
|
||||
firebaseApi: firebaseApi, sessionLifecycleService: fakeSession,
|
||||
prefs: mockPrefs,
|
||||
);
|
||||
|
||||
@@ -160,7 +163,7 @@ void main() {
|
||||
test('signInViaSocialMedia - обработка ошибки', () async {
|
||||
oauthCubit = OAuthCubit(
|
||||
authRepository: mockAuthRepository,
|
||||
firebaseApi: firebaseApi,
|
||||
firebaseApi: firebaseApi, sessionLifecycleService: fakeSession,
|
||||
prefs: mockPrefs,
|
||||
);
|
||||
|
||||
@@ -189,7 +192,7 @@ void main() {
|
||||
test('signUpViaSocialMedia - успешная регистрация через соцсеть', () async {
|
||||
oauthCubit = OAuthCubit(
|
||||
authRepository: mockAuthRepository,
|
||||
firebaseApi: firebaseApi,
|
||||
firebaseApi: firebaseApi, sessionLifecycleService: fakeSession,
|
||||
prefs: mockPrefs,
|
||||
);
|
||||
|
||||
@@ -237,8 +240,7 @@ void main() {
|
||||
final loadedState = oauthCubit!.state as OAuthStateLoaded;
|
||||
expect(loadedState.token, tokenFixture.token);
|
||||
expect(await TokenStorage.getTokenFromStorage(), tokenFixture.token);
|
||||
expect(firebaseApi.registerPushTokenCalls, 1);
|
||||
expect(firebaseApi.hadAuthToken, isTrue);
|
||||
expect(fakeSession.onAuthenticatedCalls, 1);
|
||||
});
|
||||
|
||||
test(
|
||||
@@ -246,7 +248,7 @@ void main() {
|
||||
() async {
|
||||
oauthCubit = OAuthCubit(
|
||||
authRepository: mockAuthRepository,
|
||||
firebaseApi: firebaseApi,
|
||||
firebaseApi: firebaseApi, sessionLifecycleService: fakeSession,
|
||||
prefs: mockPrefs,
|
||||
);
|
||||
|
||||
@@ -289,7 +291,7 @@ void main() {
|
||||
test('signUpViaSocialMedia - обработка ошибки валидации email', () async {
|
||||
oauthCubit = OAuthCubit(
|
||||
authRepository: mockAuthRepository,
|
||||
firebaseApi: firebaseApi,
|
||||
firebaseApi: firebaseApi, sessionLifecycleService: fakeSession,
|
||||
prefs: mockPrefs,
|
||||
);
|
||||
|
||||
@@ -350,7 +352,7 @@ void main() {
|
||||
() async {
|
||||
oauthCubit = OAuthCubit(
|
||||
authRepository: mockAuthRepository,
|
||||
firebaseApi: firebaseApi,
|
||||
firebaseApi: firebaseApi, sessionLifecycleService: fakeSession,
|
||||
prefs: mockPrefs,
|
||||
);
|
||||
|
||||
@@ -379,7 +381,7 @@ void main() {
|
||||
test('requestOAuthVerification - обработка ошибки', () async {
|
||||
oauthCubit = OAuthCubit(
|
||||
authRepository: mockAuthRepository,
|
||||
firebaseApi: firebaseApi,
|
||||
firebaseApi: firebaseApi, sessionLifecycleService: fakeSession,
|
||||
prefs: mockPrefs,
|
||||
);
|
||||
|
||||
@@ -403,7 +405,7 @@ void main() {
|
||||
() async {
|
||||
oauthCubit = OAuthCubit(
|
||||
authRepository: mockAuthRepository,
|
||||
firebaseApi: firebaseApi,
|
||||
firebaseApi: firebaseApi, sessionLifecycleService: fakeSession,
|
||||
prefs: mockPrefs,
|
||||
);
|
||||
|
||||
@@ -458,15 +460,14 @@ void main() {
|
||||
final loadedState = oauthCubit!.state as OAuthStateLoaded;
|
||||
expect(loadedState.token, tokenFixture.token);
|
||||
expect(await TokenStorage.getTokenFromStorage(), tokenFixture.token);
|
||||
expect(firebaseApi.registerPushTokenCalls, 1);
|
||||
expect(firebaseApi.hadAuthToken, isTrue);
|
||||
expect(fakeSession.onAuthenticatedCalls, 1);
|
||||
},
|
||||
);
|
||||
|
||||
test('confirmOAuthVerification - обработка ошибки', () async {
|
||||
oauthCubit = OAuthCubit(
|
||||
authRepository: mockAuthRepository,
|
||||
firebaseApi: firebaseApi,
|
||||
firebaseApi: firebaseApi, sessionLifecycleService: fakeSession,
|
||||
prefs: mockPrefs,
|
||||
);
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../../../fixtures/doubles/auth_doubles.dart';
|
||||
import '../../../../fixtures/doubles/profile_doubles.dart';
|
||||
import '../../../../fixtures/doubles/session_doubles.dart';
|
||||
import '../../../../fixtures/fixture_reader.dart';
|
||||
import 'basic_widget_test.mocks.dart';
|
||||
|
||||
@@ -36,7 +37,7 @@ void main() {
|
||||
mockFirebaseApi = MockFirebaseApi();
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
|
||||
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi);
|
||||
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: FakeSessionLifecycleService());
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
@@ -55,7 +56,7 @@ void main() {
|
||||
|
||||
testWidgets('should create AuthCubit successfully', (WidgetTester tester) async {
|
||||
// Arrange & Act
|
||||
final cubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi);
|
||||
final cubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: FakeSessionLifecycleService());
|
||||
|
||||
// Assert
|
||||
expect(cubit, isA<AuthCubit>());
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/token_storage.dart';
|
||||
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
|
||||
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_state.dart';
|
||||
import 'package:eatvio_app/features/profile/models/profile_model.dart';
|
||||
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class MockProfileRepository extends Mock implements ProfileRepository {}
|
||||
|
||||
const _profileA = ProfileModel(
|
||||
id: 1,
|
||||
slug: 'user-a',
|
||||
name: 'User A',
|
||||
email: 'a@example.com',
|
||||
phoneConfirmationState: 'confirmed',
|
||||
isPublicProfile: true,
|
||||
isBlockedByMe: false,
|
||||
isBlockedByAuthor: false,
|
||||
isBlockedByUser: false,
|
||||
isEntitySubscribedByMe: false,
|
||||
profilePhotoUrl: 'avatars/a.png',
|
||||
roles: [],
|
||||
badges: [],
|
||||
subscriptions: [],
|
||||
subscribers: [],
|
||||
);
|
||||
|
||||
const _profileB = ProfileModel(
|
||||
id: 2,
|
||||
slug: 'user-b',
|
||||
name: 'User B',
|
||||
email: 'b@example.com',
|
||||
phoneConfirmationState: 'confirmed',
|
||||
isPublicProfile: true,
|
||||
isBlockedByMe: false,
|
||||
isBlockedByAuthor: false,
|
||||
isBlockedByUser: false,
|
||||
isEntitySubscribedByMe: false,
|
||||
profilePhotoUrl: 'avatars/b.png',
|
||||
roles: [],
|
||||
badges: [],
|
||||
subscriptions: [],
|
||||
subscribers: [],
|
||||
);
|
||||
|
||||
const _details = ProfileDetailsModel(
|
||||
firstName: 'User',
|
||||
lastName: 'Test',
|
||||
birthdate: '1990-01-01',
|
||||
gender: 'male',
|
||||
country: 'Russia',
|
||||
city: 'Moscow',
|
||||
lang: 'ru',
|
||||
weight: 70.0,
|
||||
height: 175,
|
||||
chestCircumference: 100,
|
||||
waistCircumference: 80,
|
||||
hipCircumference: 90,
|
||||
bloodPressureUp: 120,
|
||||
bloodPressureDown: 80,
|
||||
hasHypo: false,
|
||||
hasHyper: false,
|
||||
aboutAlergy: '-',
|
||||
aboutTakingPils: '-',
|
||||
aboutSportHobbies: '-',
|
||||
favoriteFoods: '-',
|
||||
unfavoriteFoods: '-',
|
||||
additionalInfo: '-',
|
||||
isMoodFillable: true,
|
||||
isBedtimeFillable: true,
|
||||
activityCoefficient: 1.5,
|
||||
);
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
late MockProfileRepository repository;
|
||||
ProfileAvatarCubit? cubit;
|
||||
|
||||
setUp(() async {
|
||||
await sl.reset();
|
||||
SharedPreferences.setMockInitialValues({'token': 'token-a'});
|
||||
TokenStorage.clearCache();
|
||||
repository = MockProfileRepository();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
if (cubit != null && !cubit!.isClosed) {
|
||||
await cubit!.close();
|
||||
}
|
||||
cubit = null;
|
||||
await sl.reset();
|
||||
TokenStorage.clearCache();
|
||||
});
|
||||
|
||||
void stubSuccessfulProfile(ProfileModel profile) {
|
||||
when(() => repository.getMyProfile()).thenAnswer((_) async => profile);
|
||||
when(
|
||||
() => repository.getMyProfileDetails(),
|
||||
).thenAnswer((_) async => _details);
|
||||
}
|
||||
|
||||
test('reset emits loading and clears the cached profile', () async {
|
||||
stubSuccessfulProfile(_profileA);
|
||||
cubit = ProfileAvatarCubit(profileRepository: repository);
|
||||
|
||||
await cubit!.ensureFreshForCurrentSession();
|
||||
expect(cubit!.state, isA<ProfileAvatarStateLoaded>());
|
||||
|
||||
cubit!.reset();
|
||||
expect(cubit!.state, isA<ProfileAvatarStateLoading>());
|
||||
|
||||
await cubit!.ensureFreshForCurrentSession();
|
||||
|
||||
verify(() => repository.getMyProfile()).called(2);
|
||||
expect(cubit!.state, isA<ProfileAvatarStateLoaded>());
|
||||
});
|
||||
|
||||
test('late getMyProfile completion after reset is ignored', () async {
|
||||
final profileCompleter = Completer<ProfileModel>();
|
||||
when(
|
||||
() => repository.getMyProfile(),
|
||||
).thenAnswer((_) => profileCompleter.future);
|
||||
when(
|
||||
() => repository.getMyProfileDetails(),
|
||||
).thenAnswer((_) async => _details);
|
||||
|
||||
cubit = ProfileAvatarCubit(profileRepository: repository);
|
||||
|
||||
final pendingRequest = cubit!.getMyProfile();
|
||||
await pumpEventQueue();
|
||||
expect(cubit!.state, isA<ProfileAvatarStateLoading>());
|
||||
|
||||
cubit!.reset();
|
||||
profileCompleter.complete(_profileA);
|
||||
await pendingRequest;
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(cubit!.state, isA<ProfileAvatarStateLoading>());
|
||||
verifyNever(() => repository.getMyProfileDetails());
|
||||
});
|
||||
|
||||
test(
|
||||
'ensureFreshForCurrentSession does not refetch for the same token',
|
||||
() async {
|
||||
stubSuccessfulProfile(_profileA);
|
||||
cubit = ProfileAvatarCubit(profileRepository: repository);
|
||||
|
||||
await cubit!.ensureFreshForCurrentSession();
|
||||
await cubit!.ensureFreshForCurrentSession();
|
||||
|
||||
verify(() => repository.getMyProfile()).called(1);
|
||||
expect(cubit!.state, isA<ProfileAvatarStateLoaded>());
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'ensureFreshForCurrentSession refetches after the token changes',
|
||||
() async {
|
||||
final profiles = <ProfileModel>[_profileA, _profileB];
|
||||
when(
|
||||
() => repository.getMyProfile(),
|
||||
).thenAnswer((_) async => profiles.removeAt(0));
|
||||
when(
|
||||
() => repository.getMyProfileDetails(),
|
||||
).thenAnswer((_) async => _details);
|
||||
|
||||
cubit = ProfileAvatarCubit(profileRepository: repository);
|
||||
|
||||
await cubit!.ensureFreshForCurrentSession();
|
||||
final firstState = cubit!.state as ProfileAvatarStateLoaded;
|
||||
expect(firstState.profile.id, _profileA.id);
|
||||
|
||||
await TokenStorage.saveTokenInStorage('token-b');
|
||||
await cubit!.ensureFreshForCurrentSession();
|
||||
|
||||
final secondState = cubit!.state as ProfileAvatarStateLoaded;
|
||||
expect(secondState.profile.id, _profileB.id);
|
||||
expect(secondState.profile.profilePhotoUrl, _profileB.profilePhotoUrl);
|
||||
verify(() => repository.getMyProfile()).called(2);
|
||||
},
|
||||
);
|
||||
|
||||
test('errors emit ProfileAvatarStateError and do not navigate', () async {
|
||||
when(
|
||||
() => repository.getMyProfile(),
|
||||
).thenAnswer((_) async => throw Exception('profile request failed'));
|
||||
|
||||
cubit = ProfileAvatarCubit(profileRepository: repository);
|
||||
|
||||
await cubit!.getMyProfile();
|
||||
|
||||
expect(cubit!.state, isA<ProfileAvatarStateError>());
|
||||
expect(
|
||||
(cubit!.state as ProfileAvatarStateError).errorMessage,
|
||||
contains('profile request failed'),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:eatvio_app/core/token_storage.dart';
|
||||
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
|
||||
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_state.dart';
|
||||
import 'package:eatvio_app/features/profile/models/profile_model.dart';
|
||||
@@ -5,6 +6,7 @@ import 'package:eatvio_app/features/profile/repositories/profile_repository.dart
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'profile_avatar_cubit_test.mocks.dart';
|
||||
|
||||
@@ -16,6 +18,8 @@ void main() {
|
||||
late MockProfileRepository mockRepository;
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({'token': 'test-token'});
|
||||
TokenStorage.clearCache();
|
||||
mockRepository = MockProfileRepository();
|
||||
});
|
||||
|
||||
|
||||
@@ -526,11 +526,11 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test('getChatByUserSlug - возвращает 404 при отсутствии чата', () async {
|
||||
test('getChatByUserSlug - возвращает null, если в ответе нет id', () async {
|
||||
const slug = 'user-slug';
|
||||
final response = HttpResponse(
|
||||
{},
|
||||
Response(requestOptions: RequestOptions(path: ''), statusCode: 404),
|
||||
Response(requestOptions: RequestOptions(path: ''), statusCode: 200),
|
||||
);
|
||||
|
||||
when(
|
||||
@@ -539,7 +539,7 @@ void main() {
|
||||
|
||||
final result = await chatRepository.getChatByUserSlug(slug);
|
||||
|
||||
expect(result, '404');
|
||||
expect(result, isNull);
|
||||
verify(mockChatService.getChatByUserSlug(slug: slug)).called(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -16,5 +16,29 @@ void main() {
|
||||
expect(find.byType(BecomeConsultantScreen), findsOneWidget);
|
||||
expect(find.text('Стать консультантом'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('keeps the bottom action above the system navigation area', (tester) async {
|
||||
const bottomInset = 34.0;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: ThemeHelper().themeData(),
|
||||
builder: (context, child) => MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(padding: const EdgeInsets.only(bottom: bottomInset)),
|
||||
child: child!,
|
||||
),
|
||||
home: const BecomeConsultantScreen(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final buttonRect = tester.getRect(
|
||||
find.ancestor(of: find.text('Начать регистрацию'), matching: find.byType(Ink)).first,
|
||||
);
|
||||
final viewHeight = tester.view.physicalSize.height / tester.view.devicePixelRatio;
|
||||
|
||||
expect(buttonRect.bottom, lessThanOrEqualTo(viewHeight - bottomInset));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -156,6 +156,55 @@ void main() {
|
||||
});
|
||||
|
||||
group('ConsultationModel', () {
|
||||
test('fromJson parses chat_id', () {
|
||||
final json = {
|
||||
'id': 1,
|
||||
'chat_id': 'direct-chat-uuid',
|
||||
'last_state': 'started',
|
||||
'plan_title': 'Test Plan',
|
||||
'user_name': 'User 1',
|
||||
'user_slug': 'user-1',
|
||||
'consultant_payment': 1000.0,
|
||||
'duration': 30,
|
||||
'current_diary_day': 5,
|
||||
'user_profile_photo_url': 'photo.jpg',
|
||||
'consultation_option_price': 2000,
|
||||
'start_date': '2024-01-01T00:00:00Z',
|
||||
'end_date': '2024-01-31T00:00:00Z',
|
||||
'consultant_id': 1,
|
||||
'diaries': [],
|
||||
'personal_diet_id': null,
|
||||
};
|
||||
|
||||
final model = ConsultationModel.fromJson(json);
|
||||
|
||||
expect(model.chatId, 'direct-chat-uuid');
|
||||
});
|
||||
|
||||
test('fromJson allows missing chat_id', () {
|
||||
final json = {
|
||||
'id': 1,
|
||||
'last_state': 'started',
|
||||
'plan_title': 'Test Plan',
|
||||
'user_name': 'User 1',
|
||||
'user_slug': 'user-1',
|
||||
'consultant_payment': 1000.0,
|
||||
'duration': 30,
|
||||
'current_diary_day': 5,
|
||||
'user_profile_photo_url': 'photo.jpg',
|
||||
'consultation_option_price': 2000,
|
||||
'start_date': '2024-01-01T00:00:00Z',
|
||||
'end_date': '2024-01-31T00:00:00Z',
|
||||
'consultant_id': 1,
|
||||
'diaries': [],
|
||||
'personal_diet_id': null,
|
||||
};
|
||||
|
||||
final model = ConsultationModel.fromJson(json);
|
||||
|
||||
expect(model.chatId, isNull);
|
||||
});
|
||||
|
||||
test('toJson serializes ConsultationModel correctly', () {
|
||||
final model = ConsultationModel(
|
||||
id: 1,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:eatvio_app/core/enums/base_state.dart';
|
||||
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
|
||||
import 'package:eatvio_app/features/consultations/models/consultation_model.dart';
|
||||
import 'package:eatvio_app/features/consultations/presentation/cubit/consultations_cubit.dart';
|
||||
import 'package:eatvio_app/features/consultations/presentation/cubit/consultations_state.dart';
|
||||
@@ -8,14 +9,21 @@ import 'package:mocktail/mocktail.dart';
|
||||
|
||||
class MockConsultationsRepository extends Mock implements ConsultationsRepository {}
|
||||
|
||||
class MockChatRepository extends Mock implements ChatRepository {}
|
||||
|
||||
void main() {
|
||||
group('ConsultationsCubit', () {
|
||||
late ConsultationsCubit cubit;
|
||||
late MockConsultationsRepository mockRepository;
|
||||
late MockChatRepository mockChatRepository;
|
||||
|
||||
setUp(() {
|
||||
mockRepository = MockConsultationsRepository();
|
||||
cubit = ConsultationsCubit(consultationsRepository: mockRepository);
|
||||
mockChatRepository = MockChatRepository();
|
||||
cubit = ConsultationsCubit(
|
||||
consultationsRepository: mockRepository,
|
||||
chatRepository: mockChatRepository,
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
@@ -47,5 +55,33 @@ void main() {
|
||||
|
||||
expect(cubit.state.state, BaseState.error);
|
||||
});
|
||||
|
||||
test('getChatIdByUserSlug - возвращает id чата', () async {
|
||||
when(() => mockChatRepository.getChatByUserSlug('john-doe'))
|
||||
.thenAnswer((_) async => 'chat-uuid');
|
||||
|
||||
final chatId = await cubit.getChatIdByUserSlug('john-doe');
|
||||
|
||||
expect(chatId, 'chat-uuid');
|
||||
verify(() => mockChatRepository.getChatByUserSlug('john-doe')).called(1);
|
||||
});
|
||||
|
||||
test('getChatIdByUserSlug - возвращает null при ошибке', () async {
|
||||
when(() => mockChatRepository.getChatByUserSlug(any()))
|
||||
.thenThrow(Exception('Error'));
|
||||
|
||||
final chatId = await cubit.getChatIdByUserSlug('john-doe');
|
||||
|
||||
expect(chatId, isNull);
|
||||
});
|
||||
|
||||
test('getChatIdByUserSlug - возвращает null когда чата нет', () async {
|
||||
when(() => mockChatRepository.getChatByUserSlug(any()))
|
||||
.thenAnswer((_) async => null);
|
||||
|
||||
final chatId = await cubit.getChatIdByUserSlug('john-doe');
|
||||
|
||||
expect(chatId, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/app_initializer.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/features/consultations/models/consultation_model.dart';
|
||||
import 'package:eatvio_app/features/consultations/presentation/cubit/consultations_cubit.dart';
|
||||
import 'package:eatvio_app/features/consultations/presentation/widgets/consultation_card_widget.dart';
|
||||
import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:network_image_mock/network_image_mock.dart';
|
||||
|
||||
class MockConsultationsCubit extends Mock implements ConsultationsCubit {}
|
||||
|
||||
void main() {
|
||||
group('ConsultationCardWidget smoke', () {
|
||||
setUpAll(() async {
|
||||
@@ -28,6 +36,20 @@ void main() {
|
||||
AppConfig(buildType: AppBuildType.tests)
|
||||
..setBaseUrl('http://localhost'),
|
||||
);
|
||||
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(
|
||||
const MethodChannel('PonnamKarthik/fluttertoast'),
|
||||
(MethodCall methodCall) async => null,
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(
|
||||
const MethodChannel('PonnamKarthik/fluttertoast'),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
final mockConsultation = ConsultationModel(
|
||||
@@ -64,7 +86,8 @@ void main() {
|
||||
personalDietId: null,
|
||||
);
|
||||
|
||||
Widget wrap(Widget child) {
|
||||
Widget wrap(Widget child, {ConsultationsCubit? cubit}) {
|
||||
final consultationsCubit = cubit ?? MockConsultationsCubit();
|
||||
return MaterialApp.router(
|
||||
theme: ThemeHelper().themeData(),
|
||||
localizationsDelegates: const [
|
||||
@@ -77,7 +100,20 @@ void main() {
|
||||
routerConfig: GoRouter(
|
||||
initialLocation: '/',
|
||||
routes: [
|
||||
GoRoute(path: '/', builder: (context, state) => child),
|
||||
GoRoute(
|
||||
path: '/',
|
||||
builder: (context, state) => BlocProvider<ConsultationsCubit>.value(
|
||||
value: consultationsCubit,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/chat-screen',
|
||||
name: 'chatScreen',
|
||||
builder: (context, state) => Scaffold(
|
||||
body: Text('chat:${(state.extra as Map)['uuid']}'),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/profile-screen',
|
||||
name: 'profileScreen',
|
||||
@@ -163,5 +199,118 @@ void main() {
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('opens chat with consultation user when chat icon tapped', (
|
||||
tester,
|
||||
) async {
|
||||
final cubit = MockConsultationsCubit();
|
||||
when(() => cubit.stream).thenAnswer((_) => const Stream.empty());
|
||||
when(() => cubit.getChatIdByUserSlug('john-doe'))
|
||||
.thenAnswer((_) async => 'chat-uuid');
|
||||
|
||||
await mockNetworkImagesFor(() async {
|
||||
await tester.pumpWidget(
|
||||
wrap(
|
||||
ConsultationCardWidget(consultationsModel: mockConsultation),
|
||||
cubit: cubit,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byIcon(Icons.chat_outlined));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('chat:chat-uuid'), findsOneWidget);
|
||||
verify(() => cubit.getChatIdByUserSlug('john-doe')).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'opens chat directly with chat id without requesting it again',
|
||||
(tester) async {
|
||||
final cubit = MockConsultationsCubit();
|
||||
when(() => cubit.stream).thenAnswer((_) => const Stream.empty());
|
||||
final consultationWithChatId = mockConsultation.copyWith(
|
||||
chatId: 'direct-chat-uuid',
|
||||
);
|
||||
|
||||
await mockNetworkImagesFor(() async {
|
||||
await tester.pumpWidget(
|
||||
wrap(
|
||||
ConsultationCardWidget(
|
||||
consultationsModel: consultationWithChatId,
|
||||
),
|
||||
cubit: cubit,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byIcon(Icons.chat_outlined));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('chat:direct-chat-uuid'), findsOneWidget);
|
||||
verifyNever(() => cubit.getChatIdByUserSlug(any()));
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('shows error and stays on card when chat id is null', (
|
||||
tester,
|
||||
) async {
|
||||
final cubit = MockConsultationsCubit();
|
||||
when(() => cubit.stream).thenAnswer((_) => const Stream.empty());
|
||||
when(() => cubit.getChatIdByUserSlug('john-doe'))
|
||||
.thenAnswer((_) async => null);
|
||||
|
||||
await mockNetworkImagesFor(() async {
|
||||
await tester.pumpWidget(
|
||||
wrap(
|
||||
ConsultationCardWidget(consultationsModel: mockConsultation),
|
||||
cubit: cubit,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byIcon(Icons.chat_outlined));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
|
||||
expect(find.textContaining('chat:'), findsNothing);
|
||||
expect(
|
||||
find.byType(ConsultationCardWidget),
|
||||
findsOneWidget,
|
||||
);
|
||||
verify(() => cubit.getChatIdByUserSlug('john-doe')).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('does not request chat id twice while first is pending', (
|
||||
tester,
|
||||
) async {
|
||||
final cubit = MockConsultationsCubit();
|
||||
when(() => cubit.stream).thenAnswer((_) => const Stream.empty());
|
||||
final completer = Completer<String?>();
|
||||
when(() => cubit.getChatIdByUserSlug('john-doe'))
|
||||
.thenAnswer((_) => completer.future);
|
||||
|
||||
await mockNetworkImagesFor(() async {
|
||||
await tester.pumpWidget(
|
||||
wrap(
|
||||
ConsultationCardWidget(consultationsModel: mockConsultation),
|
||||
cubit: cubit,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byIcon(Icons.chat_outlined));
|
||||
await tester.tap(find.byIcon(Icons.chat_outlined));
|
||||
|
||||
completer.complete('chat-uuid');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('chat:chat-uuid'), findsOneWidget);
|
||||
verify(() => cubit.getChatIdByUserSlug('john-doe')).called(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ import 'package:eatvio_app/features/diary/models/diary_day_model.dart';
|
||||
import 'package:eatvio_app/features/diary/models/nutrition_model.dart';
|
||||
import 'package:eatvio_app/core/widgets/data_error_widget.dart';
|
||||
import 'package:eatvio_app/features/diary/presentation/widgets/meal_widget.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_card.dart';
|
||||
import 'package:eatvio_app/features/insights/repositories/insights_repository.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
@@ -23,6 +25,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
|
||||
import '../../../../fixtures/doubles/insights_doubles.dart';
|
||||
import 'diary_screen_smoke_test.mocks.dart';
|
||||
|
||||
@GenerateNiceMocks([
|
||||
@@ -44,6 +47,7 @@ void main() {
|
||||
AppConfig(buildType: AppBuildType.tests)
|
||||
..setBaseUrl('http://localhost'),
|
||||
);
|
||||
sl.registerLazySingleton<InsightsRepository>(() => FakeInsightsRepository());
|
||||
});
|
||||
|
||||
Widget wrap(Widget child) {
|
||||
@@ -383,6 +387,76 @@ void main() {
|
||||
expect(find.byType(Text), findsWidgets);
|
||||
});
|
||||
|
||||
Future<void> pumpDiaryWithInsights(
|
||||
WidgetTester tester, {
|
||||
required bool isConsultantView,
|
||||
String? ownerSlug,
|
||||
required FakeInsightsRepository insightsRepository,
|
||||
}) async {
|
||||
final diaryCubit = MockDiaryCubit();
|
||||
final state = loadedState(hasChosenPlan: false).copyWith(isConsultantView: isConsultantView);
|
||||
when(diaryCubit.state).thenReturn(state);
|
||||
when(diaryCubit.stream).thenAnswer((_) => const Stream<DiaryState>.empty());
|
||||
when(diaryCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(
|
||||
diaryCubit.fetchDiaryV2(needLoading: anyNamed('needLoading'), diaryId: anyNamed('diaryId')),
|
||||
).thenAnswer((_) async => true);
|
||||
|
||||
final commentsCubit = MockConsultantCommentsCubit();
|
||||
when(commentsCubit.state).thenReturn(ConsultantCommentsState(commentsModel: ConsultantCommentModel(comment: '')));
|
||||
when(commentsCubit.stream).thenAnswer((_) => const Stream<ConsultantCommentsState>.empty());
|
||||
when(commentsCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
|
||||
sl.registerFactory<DiaryCubit>(() => diaryCubit);
|
||||
sl.registerFactory<ConsultantCommentsCubit>(() => commentsCubit);
|
||||
sl.unregister<InsightsRepository>();
|
||||
sl.registerLazySingleton<InsightsRepository>(() => insightsRepository);
|
||||
|
||||
await tester.pumpWidget(
|
||||
wrap(isConsultantView ? UserDiaryScreen(diaryId: 1, ownerSlug: ownerSlug) : DiaryScreen(date: DateTime(2025, 1, 1))),
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
}
|
||||
|
||||
testWidgets('own diary shows insights card for own data with add button enabled', (tester) async {
|
||||
final insightsRepository = FakeInsightsRepository();
|
||||
|
||||
await pumpDiaryWithInsights(tester, isConsultantView: false, insightsRepository: insightsRepository);
|
||||
|
||||
expect(find.byType(InsightsCard), findsOneWidget);
|
||||
expect(tester.widget<InsightsCard>(find.byType(InsightsCard)).slug, isNull);
|
||||
expect(tester.widget<InsightsCard>(find.byType(InsightsCard)).canAdd, isTrue);
|
||||
expect(insightsRepository.insightsRequests.single.slug, isNull);
|
||||
});
|
||||
|
||||
testWidgets('consultant view shows insights card of the client without add button', (tester) async {
|
||||
final insightsRepository = FakeInsightsRepository();
|
||||
|
||||
await pumpDiaryWithInsights(tester, isConsultantView: true, ownerSlug: 'client', insightsRepository: insightsRepository);
|
||||
|
||||
final card = tester.widget<InsightsCard>(find.byType(InsightsCard));
|
||||
expect(card.slug, 'client');
|
||||
expect(card.canAdd, isFalse);
|
||||
expect(insightsRepository.insightsRequests.single.slug, 'client');
|
||||
});
|
||||
|
||||
testWidgets('consultant view without owner slug hides insights card', (tester) async {
|
||||
final insightsRepository = FakeInsightsRepository();
|
||||
|
||||
await pumpDiaryWithInsights(tester, isConsultantView: true, insightsRepository: insightsRepository);
|
||||
|
||||
expect(find.byType(InsightsCard), findsNothing);
|
||||
expect(insightsRepository.insightsRequests, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('consultant view hides insights card on 403', (tester) async {
|
||||
final insightsRepository = FakeInsightsRepository(error: const InsightsForbidden());
|
||||
|
||||
await pumpDiaryWithInsights(tester, isConsultantView: true, ownerSlug: 'client', insightsRepository: insightsRepository);
|
||||
|
||||
expect(find.byKey(const Key('insights_card')), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('shows add button for a finished day inside the edit window', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
285
test/features/insights/domain/insights_text_builder_test.dart
Normal file
285
test/features/insights/domain/insights_text_builder_test.dart
Normal file
@@ -0,0 +1,285 @@
|
||||
import 'package:eatvio_app/features/insights/domain/insights_text_builder.dart';
|
||||
import 'package:eatvio_app/features/insights/models/insights_period.dart';
|
||||
import 'package:eatvio_app/features/profile/models/circumference_model.dart';
|
||||
import 'package:eatvio_app/features/profile/models/weight_model.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
WeightModel _weight(double weight, {double? bmi, int day = 1}) {
|
||||
final date = DateTime(2026, 11, day, 12);
|
||||
return WeightModel(
|
||||
id: day,
|
||||
userId: 1,
|
||||
weight: weight,
|
||||
createdAt: date,
|
||||
updatedAt: date,
|
||||
bmi: bmi,
|
||||
);
|
||||
}
|
||||
|
||||
CircumferenceModel _circumference({
|
||||
double waist = 80,
|
||||
double chest = 95,
|
||||
double hips = 100,
|
||||
int day = 1,
|
||||
}) {
|
||||
final date = DateTime(2026, 11, day, 12);
|
||||
return CircumferenceModel(
|
||||
id: day,
|
||||
userId: 1,
|
||||
chestCircumference: chest,
|
||||
waistCircumference: waist,
|
||||
hipCircumference: hips,
|
||||
createdAt: date,
|
||||
updatedAt: date,
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
late InsightsTextBuilder builder;
|
||||
|
||||
setUpAll(() async {
|
||||
final s = await S.load(const Locale('ru'));
|
||||
builder = InsightsTextBuilder(s);
|
||||
});
|
||||
|
||||
group('форматирование', () {
|
||||
test('formatNumber: один знак без хвостового нуля', () {
|
||||
expect(builder.formatNumber(3), '3');
|
||||
expect(builder.formatNumber(3.5), '3.5');
|
||||
expect(builder.formatNumber(3.04), '3');
|
||||
expect(builder.formatNumber(3.26), '3.3');
|
||||
});
|
||||
|
||||
test('formatLabel: dd.MM', () {
|
||||
expect(builder.formatLabel(DateTime(2026, 11, 9, 12)), '09.11');
|
||||
});
|
||||
|
||||
test('periodLabel: все периоды', () {
|
||||
expect(builder.periodLabel(BodyPeriod.month), 'месяц');
|
||||
expect(builder.periodLabel(BodyPeriod.threeMonths), '3 месяца');
|
||||
expect(builder.periodLabel(BodyPeriod.sixMonths), '6 месяцев');
|
||||
expect(builder.periodLabel(BodyPeriod.year), 'год');
|
||||
expect(builder.periodLabel(BodyPeriod.all), 'всё время');
|
||||
});
|
||||
});
|
||||
|
||||
group('вес', () {
|
||||
test('снижение', () {
|
||||
final model = builder.buildWeight([
|
||||
_weight(74.5),
|
||||
_weight(73.2, day: 9),
|
||||
], BodyPeriod.month);
|
||||
|
||||
expect(model.deltaText, 'Снизили на 1.3 кг за месяц');
|
||||
expect(model.delta, -1.3);
|
||||
expect(model.last, 73.2);
|
||||
expect(model.headline, '73.2');
|
||||
expect(model.unit, 'кг');
|
||||
expect(model.labels, ['01.11', '09.11']);
|
||||
expect(model.values, [74.5, 73.2]);
|
||||
});
|
||||
|
||||
test('набор', () {
|
||||
final model = builder.buildWeight([
|
||||
_weight(70),
|
||||
_weight(72, day: 3),
|
||||
], BodyPeriod.threeMonths);
|
||||
|
||||
expect(model.deltaText, 'Набрали 2 кг за 3 месяца');
|
||||
expect(model.delta, 2);
|
||||
});
|
||||
|
||||
test('порог 0.05: меньше — не изменился', () {
|
||||
final model = builder.buildWeight([
|
||||
_weight(70),
|
||||
_weight(70.04, day: 3),
|
||||
], BodyPeriod.year);
|
||||
|
||||
expect(model.deltaText, 'Вес не изменился за год');
|
||||
});
|
||||
|
||||
test('порог 0.05: ровно 0.25 — уже изменение', () {
|
||||
final model = builder.buildWeight([
|
||||
_weight(70),
|
||||
_weight(70.25, day: 3),
|
||||
], BodyPeriod.all);
|
||||
|
||||
expect(model.deltaText, 'Набрали 0.3 кг за всё время');
|
||||
});
|
||||
|
||||
test('одна точка: недостаточно данных, но headline есть', () {
|
||||
final model = builder.buildWeight([_weight(72)], BodyPeriod.month);
|
||||
|
||||
expect(model.deltaText, 'Недостаточно данных');
|
||||
expect(model.delta, isNull);
|
||||
expect(model.headline, '72.0');
|
||||
});
|
||||
|
||||
test('нет точек', () {
|
||||
final model = builder.buildWeight([], BodyPeriod.month);
|
||||
|
||||
expect(model.deltaText, 'Недостаточно данных');
|
||||
expect(model.last, isNull);
|
||||
expect(model.headline, '—');
|
||||
expect(model.values, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('категории ИМТ (границы)', () {
|
||||
final cases = <double, String>{
|
||||
15.9: 'выраженный дефицит массы',
|
||||
16.0: 'недостаточная масса',
|
||||
18.4: 'недостаточная масса',
|
||||
18.5: 'норма',
|
||||
24.9: 'норма',
|
||||
25.0: 'избыточная масса',
|
||||
29.9: 'избыточная масса',
|
||||
30.0: 'ожирение I ст.',
|
||||
34.9: 'ожирение I ст.',
|
||||
35.0: 'ожирение II ст.',
|
||||
39.9: 'ожирение II ст.',
|
||||
40.0: 'ожирение III ст.',
|
||||
55.0: 'ожирение III ст.',
|
||||
};
|
||||
|
||||
cases.forEach((bmi, expected) {
|
||||
test('$bmi → $expected', () {
|
||||
expect(builder.bmiCategory(bmi), expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('ИМТ', () {
|
||||
test('без роста: пусто и подсказка', () {
|
||||
final model = builder.buildBmi(
|
||||
[_weight(70, bmi: 22)],
|
||||
BodyPeriod.month,
|
||||
null,
|
||||
);
|
||||
|
||||
expect(model.values, isEmpty);
|
||||
expect(model.headline, '—');
|
||||
expect(model.hint, 'Укажите рост, чтобы посчитать ИМТ.');
|
||||
expect(model.deltaText, 'Укажите рост, чтобы посчитать ИМТ.');
|
||||
expect(model.category, isNull);
|
||||
});
|
||||
|
||||
test('нулевой рост считается отсутствующим', () {
|
||||
final model = builder.buildBmi(
|
||||
[_weight(70, bmi: 22)],
|
||||
BodyPeriod.month,
|
||||
0,
|
||||
);
|
||||
|
||||
expect(model.values, isEmpty);
|
||||
});
|
||||
|
||||
test('ИМТ ниже', () {
|
||||
final model = builder.buildBmi(
|
||||
[_weight(80, bmi: 26.5), _weight(75, bmi: 24.2, day: 5)],
|
||||
BodyPeriod.threeMonths,
|
||||
176,
|
||||
);
|
||||
|
||||
expect(model.deltaText, 'ИМТ ниже на 2.3 за 3 месяца · норма');
|
||||
expect(model.category, 'норма');
|
||||
expect(model.headline, '24.2');
|
||||
expect(model.last, 24.2);
|
||||
expect(model.delta, -2.3);
|
||||
expect(model.hint, contains('Считаем при росте 176 см.'));
|
||||
});
|
||||
|
||||
test('ИМТ выше', () {
|
||||
final model = builder.buildBmi(
|
||||
[_weight(70, bmi: 24), _weight(80, bmi: 27, day: 5)],
|
||||
BodyPeriod.month,
|
||||
170,
|
||||
);
|
||||
|
||||
expect(model.deltaText, 'ИМТ выше на 3 за месяц · избыточная масса');
|
||||
});
|
||||
|
||||
test('ИМТ не изменился', () {
|
||||
final model = builder.buildBmi(
|
||||
[_weight(70, bmi: 22), _weight(70, bmi: 22.01, day: 5)],
|
||||
BodyPeriod.year,
|
||||
170,
|
||||
);
|
||||
|
||||
expect(model.deltaText, 'ИМТ не изменился за год · норма');
|
||||
});
|
||||
|
||||
test('точки с bmi = null пропускаются', () {
|
||||
final model = builder.buildBmi(
|
||||
[_weight(70, bmi: null), _weight(71, bmi: 22, day: 3)],
|
||||
BodyPeriod.month,
|
||||
170,
|
||||
);
|
||||
|
||||
expect(model.values, [22]);
|
||||
expect(model.labels, ['03.11']);
|
||||
expect(model.deltaText, 'Недостаточно данных');
|
||||
});
|
||||
|
||||
test('рост есть, точек нет', () {
|
||||
final model = builder.buildBmi([], BodyPeriod.month, 170);
|
||||
|
||||
expect(model.values, isEmpty);
|
||||
expect(model.deltaText, 'Недостаточно данных');
|
||||
expect(model.hint, contains('170'));
|
||||
});
|
||||
});
|
||||
|
||||
group('замеры', () {
|
||||
test('по каждой части: меньше / больше / без изменений', () {
|
||||
final model = builder.buildMeasure([
|
||||
_circumference(waist: 82, chest: 96, hips: 100),
|
||||
_circumference(waist: 80.5, chest: 98, hips: 100, day: 9),
|
||||
], BodyPeriod.month);
|
||||
|
||||
expect(
|
||||
model.deltaText,
|
||||
'Талия меньше на 1.5 см, Грудь больше на 2 см, Бёдра без изменений за месяц',
|
||||
);
|
||||
expect(model.headline, '81');
|
||||
expect(model.waist, [82, 80.5]);
|
||||
expect(model.labels, ['01.11', '09.11']);
|
||||
});
|
||||
|
||||
test('одна точка — недостаточно данных', () {
|
||||
final model = builder.buildMeasure([_circumference()], BodyPeriod.month);
|
||||
|
||||
expect(model.deltaText, 'Недостаточно данных');
|
||||
expect(model.headline, '80');
|
||||
});
|
||||
|
||||
test('нет точек', () {
|
||||
final model = builder.buildMeasure([], BodyPeriod.month);
|
||||
|
||||
expect(model.headline, '—');
|
||||
expect(model.deltaText, 'Недостаточно данных');
|
||||
});
|
||||
});
|
||||
|
||||
group('build', () {
|
||||
test('сортирует по дате, daily/intra = null', () {
|
||||
final payload = builder.build(
|
||||
weights: [_weight(73, bmi: 23, day: 9), _weight(75, bmi: 24, day: 1)],
|
||||
circumferences: [
|
||||
_circumference(waist: 79, day: 9),
|
||||
_circumference(waist: 81),
|
||||
],
|
||||
period: BodyPeriod.month,
|
||||
heightCm: 175,
|
||||
);
|
||||
|
||||
expect(payload.weight.values, [75, 73]);
|
||||
expect(payload.bmi.values, [24, 23]);
|
||||
expect(payload.measure.waist, [81, 79]);
|
||||
expect(payload.daily, isNull);
|
||||
expect(payload.intra, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
Map<String, dynamic> _fixture(String name) {
|
||||
return jsonDecode(File('test/fixtures/insights/$name').readAsStringSync())
|
||||
as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('InsightsPayloadModel', () {
|
||||
test('парсит полный payload (weight/bmi/measure/daily/intra)', () {
|
||||
final model = InsightsPayloadModel.fromJson(
|
||||
_fixture('insights_payload.json'),
|
||||
);
|
||||
|
||||
expect(model.weight.values, [74.5, 73.9, 73.2]);
|
||||
expect(model.weight.last, 73.2);
|
||||
expect(model.weight.delta, -1.3);
|
||||
expect(model.weight.unit, 'кг');
|
||||
|
||||
expect(model.measure.waist, [82.0, 80.5]);
|
||||
expect(model.measure.chest, [96.0, 96.0]);
|
||||
|
||||
expect(model.daily, isNotNull);
|
||||
expect(model.daily!.kcal, [1850.0, 2010.0, 1930.0]);
|
||||
expect(model.daily!.average, 1930);
|
||||
|
||||
expect(model.intra, isNotNull);
|
||||
expect(model.intra!.bars, hasLength(2));
|
||||
expect(model.intra!.bars.first.x, 8.5);
|
||||
expect(model.intra!.bars.first.c, 240);
|
||||
expect(model.intra!.satiety.last.y, 4);
|
||||
expect(model.intra!.wake, 7.5);
|
||||
expect(model.intra!.isToday, isTrue);
|
||||
});
|
||||
|
||||
test('bmi.values пустой и last/delta/category = null', () {
|
||||
final model = InsightsPayloadModel.fromJson(
|
||||
_fixture('insights_payload.json'),
|
||||
);
|
||||
|
||||
expect(model.bmi.values, isEmpty);
|
||||
expect(model.bmi.last, isNull);
|
||||
expect(model.bmi.delta, isNull);
|
||||
expect(model.bmi.category, isNull);
|
||||
expect(model.bmi.deltaText, contains('рост'));
|
||||
});
|
||||
|
||||
test('intra без wake/sleep и null в average/percent', () {
|
||||
final model = InsightsPayloadModel.fromJson(
|
||||
_fixture('insights_payload_minimal.json'),
|
||||
);
|
||||
|
||||
expect(model.intra!.wake, isNull);
|
||||
expect(model.intra!.sleep, isNull);
|
||||
expect(model.intra!.bars, isEmpty);
|
||||
expect(model.daily!.average, isNull);
|
||||
expect(model.daily!.percent, isNull);
|
||||
expect(model.weight.last, isNull);
|
||||
});
|
||||
|
||||
test('daily и intra необязательны', () {
|
||||
final json = _fixture('insights_payload_minimal.json')
|
||||
..remove('daily')
|
||||
..remove('intra');
|
||||
|
||||
final model = InsightsPayloadModel.fromJson(json);
|
||||
|
||||
expect(model.daily, isNull);
|
||||
expect(model.intra, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user