diff --git a/.gitea/workflows/test-flight-cd.yaml b/.gitea/workflows/test-flight-cd.yaml index 9d810d8e..c9649f0e 100644 --- a/.gitea/workflows/test-flight-cd.yaml +++ b/.gitea/workflows/test-flight-cd.yaml @@ -11,6 +11,29 @@ jobs: - name: Checkout uses: actions/checkout@v2 + - name: Checkout env repository + uses: actions/checkout@v2 + with: + repository: mso7nueh/eatvio_app_env + path: env_repo + token: ${{ vars.MSO7NUEH_TOKEN }} + ref: main + + - name: Copy .env file + run: | + cp env_repo/.env .env + + - name: Load environment variables from .env + run: | + set -a + source .env + set +a + # Export all variables to GITHUB_ENV + while IFS= read -r line; do + if [[ $line =~ ^[A-Za-z_][A-Za-z0-9_]*= ]]; then + echo "$line" >> $GITHUB_ENV + fi + done < .env - name: Unlock login keychain run: | @@ -18,12 +41,25 @@ jobs: env: KEYCHAIN_PASSWORD: ${{ vars.IOS_RUNNER_CI_PASSWORD }} + - name: Setup API key + run: | + echo "${API_APPSTORE_KEY}" | tr -d '\n' | base64 --decode > ios/AuthKey.p8 + chmod 600 ios/AuthKey.p8 + ls -la ios/AuthKey.p8 + echo "File size: $(wc -c < ios/AuthKey.p8) bytes" + echo "Contents of ios directory:" + ls -la ios/ + env: + API_APPSTORE_KEY: ${{ vars.API_APPSTORE_KEY }} + - name: Flutter build run: | flutter clean flutter pub get flutter pub upgrade flutter pub run build_runner build --delete-conflicting-outputs + # Configure Flutter for release mode + flutter build ios --config-only --release cd ios pod update fastlane beta @@ -31,6 +67,8 @@ jobs: LANG: en_US.UTF-8 LC_ALL: en_US.UTF-8 MATCH_PASSWORD: ${{ vars.IOS_RUNNER_CI_PASSWORD }} + FASTLANE_PASSWORD: ${{ vars.FASTLANE_PASSWORD }} MATCH_GIT_URL: http://gitea.office.local:3000/mso7nueh/fastlane FASTLANE_USER: eatvio2023@icloud.com - FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD: ${{ vars.FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD }} \ No newline at end of file + FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD: ${{ vars.FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD }} + API_APPSTORE_KEY: ${{ vars.API_APPSTORE_KEY }} diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 75372b09..f1f63162 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -3,7 +3,7 @@ name: Eatvio App Android Test on: [ pull_request ] env: - flutter_version: "3.32.5" + flutter_version: "3.35.1" java_version: "21.x" flutter_channel: "stable" my_local_cache_dir: "/opt/hostedtoolcache/" @@ -16,6 +16,30 @@ jobs: steps: - uses: actions/checkout@v3 + - name: Checkout env repository + uses: actions/checkout@v3 + with: + repository: mso7nueh/eatvio_app_env + path: env_repo + token: ${{ vars.MSO7NUEH_TOKEN }} + ref: main + + - name: Copy .env file + run: | + cp env_repo/.env .env + + - name: Load environment variables from .env + run: | + set -a + source .env + set +a + # Export all variables to GITHUB_ENV + while IFS= read -r line; do + if [[ $line =~ ^[A-Za-z_][A-Za-z0-9_]*= ]]; then + echo "$line" >> $GITHUB_ENV + fi + done < .env + - name: Create key.properties file run: | cat > android/key.properties < diff --git a/android/gradle.properties b/android/gradle.properties index 25971708..f018a618 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,3 +1,3 @@ -org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true android.enableJetifier=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index f587a472..e603bd33 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-all.zip diff --git a/android/settings.gradle b/android/settings.gradle index 833c476b..e173b2c9 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -18,11 +18,11 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "8.7.3" apply false + id "com.android.application" version "8.12.0" apply false // START: FlutterFire Configuration - id "com.google.gms.google-services" version "4.4.2" apply false + id "com.google.gms.google-services" version "4.4.3" apply false // END: FlutterFire Configuration - id "org.jetbrains.kotlin.android" version "2.0.21" apply false + id "org.jetbrains.kotlin.android" version "2.2.10" apply false } include ":app" diff --git a/assets/icons/resend.svg b/assets/icons/resend.svg new file mode 100644 index 00000000..245a03be --- /dev/null +++ b/assets/icons/resend.svg @@ -0,0 +1,3 @@ + + + diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist index 8c6e5614..86343d86 100644 --- a/ios/Flutter/AppFrameworkInfo.plist +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -21,6 +21,6 @@ CFBundleVersion 1.0 MinimumOSVersion - 12.0 + 16.0 diff --git a/ios/Gemfile b/ios/Gemfile index 7a118b49..791ac593 100644 --- a/ios/Gemfile +++ b/ios/Gemfile @@ -1,3 +1,3 @@ source "https://rubygems.org" -gem "fastlane" +gem "fastlane", "~> 2.217" diff --git a/ios/Podfile b/ios/Podfile index 53a8d390..4fe34ec9 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -platform :ios, '14.0' +platform :ios, '16.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' @@ -39,7 +39,7 @@ post_install do |installer| flutter_additional_ios_build_settings(target) target.build_configurations.each do |config| config.build_settings["EXCLUDED_ARCHS[sdk=iphonesimulator*]"] = "arm64" - config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '14.0' + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '16.0' config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [ '$(inherited)', ## dart: PermissionGroup.camera diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index d2269b71..7d740632 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -370,7 +370,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -388,7 +388,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Distribution: ITVIO, OOO (VL68C36HYS)"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 52; + CURRENT_PROJECT_VERSION = 71; DEVELOPMENT_TEAM = VL68C36HYS; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = ( @@ -406,7 +406,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - MARKETING_VERSION = 1.0.50; + MARKETING_VERSION = 1.2.3; PRODUCT_BUNDLE_IDENTIFIER = com.eatvioapp.app; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = "match AppStore com.eatvioapp.app 1747050044"; @@ -466,7 +466,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -518,7 +518,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -538,7 +538,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Distribution: ITVIO, OOO (VL68C36HYS)"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 52; + CURRENT_PROJECT_VERSION = 71; DEVELOPMENT_TEAM = VL68C36HYS; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = ( @@ -556,7 +556,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - MARKETING_VERSION = 1.0.50; + MARKETING_VERSION = 1.2.3; PRODUCT_BUNDLE_IDENTIFIER = com.eatvioapp.app; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = "match AppStore com.eatvioapp.app 1747050044"; @@ -576,7 +576,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Distribution: ITVIO, OOO (VL68C36HYS)"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 52; + CURRENT_PROJECT_VERSION = 71; DEVELOPMENT_TEAM = VL68C36HYS; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = ( @@ -594,7 +594,7 @@ "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - MARKETING_VERSION = 1.0.50; + MARKETING_VERSION = 1.2.3; PRODUCT_BUNDLE_IDENTIFIER = com.eatvioapp.app; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = "match AppStore com.eatvioapp.app 1747050044"; diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 9c12df59..1a6f2553 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -41,7 +41,7 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - eatvio_app + Итвио CFBundlePackageType APPL CFBundleShortVersionString @@ -21,7 +21,7 @@ CFBundleSignature ???? CFBundleVersion - 52 + 71 ITSAppUsesNonExemptEncryption LSRequiresIPhoneOS diff --git a/ios/fastlane/Fastfile b/ios/fastlane/Fastfile index 4073b355..3a74adf3 100644 --- a/ios/fastlane/Fastfile +++ b/ios/fastlane/Fastfile @@ -18,9 +18,69 @@ default_platform(:ios) platform :ios do desc "Push a new beta build to TestFlight" lane :beta do - match(type: "appstore") + UI.message "Current working directory: #{Dir.pwd}" + UI.message "Checking API key file..." + + possible_paths = [ + "../AuthKey.p8", + "./AuthKey.p8", + "../../AuthKey.p8", + File.expand_path("../AuthKey.p8"), + File.expand_path("./AuthKey.p8") + ] + + auth_key_path = nil + possible_paths.each do |path| + if File.exist?(path) + UI.message "Found AuthKey.p8 at: #{path}" + auth_key_path = File.expand_path(path) + break + end + end + + if auth_key_path.nil? + UI.user_error!("API key file not found. Tried paths: #{possible_paths.join(', ')}") + end + + UI.message "Using auth_key_path: #{auth_key_path}" + + # Configure Flutter for release mode + UI.message "Configuring Flutter for release mode..." + sh("cd .. && flutter build ios --config-only --release") + + match(type: "appstore", readonly: true) increment_build_number(xcodeproj: "Runner.xcodeproj") - build_app(workspace: "Runner.xcworkspace", scheme: "Runner", export_method: "app-store", codesigning_identity: "Apple Distribution") - upload_to_testflight + + build_app( + workspace: "Runner.xcworkspace", + scheme: "Runner", + export_method: "app-store", + codesigning_identity: "Apple Distribution", + ) + + + + 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 #{get_build_number(xcodeproj: 'Runner.xcodeproj')}", + verbose: true + ) + rescue => ex + UI.error "Upload to TestFlight failed: #{ex.message}" + UI.error "Full error: #{ex.backtrace.join("\n")}" + raise ex + end end end diff --git a/ios/fastlane/report.xml b/ios/fastlane/report.xml index c86e2b65..0d039029 100644 --- a/ios/fastlane/report.xml +++ b/ios/fastlane/report.xml @@ -5,22 +5,24 @@ - + - + - + - + + + diff --git a/lib/core/app_router.dart b/lib/core/app_router.dart index 5e9f2cf6..e1ae5612 100644 --- a/lib/core/app_router.dart +++ b/lib/core/app_router.dart @@ -1,5 +1,3 @@ -import 'dart:io'; -import 'package:device_info_plus/device_info_plus.dart'; import 'package:dio/dio.dart'; import 'package:eatvio_app/core/const.dart'; import 'package:eatvio_app/core/firebase_api.dart'; @@ -26,6 +24,7 @@ import 'package:eatvio_app/features/consultations/presentation/pages/consultatio 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/pages/blocked_users_screen.dart'; +import 'package:eatvio_app/features/menu/presentation/pages/notifications_settings_screen.dart'; import 'package:eatvio_app/features/order_info/presentation/info_by_order_screen.dart'; import 'package:eatvio_app/features/create_diet/presentation/create_diet_screen.dart'; import 'package:eatvio_app/features/create_diet/presentation/diet_preview_screen.dart'; @@ -114,21 +113,13 @@ class AppRouter { name: 'authScreen', builder: (context, state) => const AuthScreen(), routes: [ - GoRoute( - path: 'registration-screen', - name: 'registrationScreen', - builder: (context, state) => const RegistrationScreen(), - ), + GoRoute(path: 'registration-screen', name: 'registrationScreen', builder: (context, state) => const RegistrationScreen()), GoRoute( path: 'forget-password-screen', name: 'forgetPasswordScreen', - builder: (context, state) => ForgetPasswordScreen(isAuth: state.extra as bool,), - ), - GoRoute( - path: 'scan-qr-code-screen', - name: 'scanQRCodeScreen', - builder: (context, state) => const ScanQRCodeScreen(), + builder: (context, state) => ForgetPasswordScreen(isAuth: state.extra as bool), ), + GoRoute(path: 'scan-qr-code-screen', name: 'scanQRCodeScreen', builder: (context, state) => const ScanQRCodeScreen()), ], ), ShellRoute( @@ -166,19 +157,23 @@ class AppRouter { GoRoute( path: '/diary-screen', name: 'diaryScreen', - pageBuilder: (context, state) => const NoTransitionPage( - child: DiaryScreen(), - ), + pageBuilder: (context, state) => const NoTransitionPage(child: DiaryScreen()), ), GoRoute( - path: ConsultationsScreen.path, - name: ConsultationsScreen.name, - pageBuilder: (context, state) => const NoTransitionPage(child: ConsultationsScreen())), - GoRoute(path: '/chat-select-screen', name: 'chatSelectScreen', pageBuilder: (context, state) => const NoTransitionPage(child: ChatsScreen())), + path: ConsultationsScreen.path, + name: ConsultationsScreen.name, + pageBuilder: (context, state) => const NoTransitionPage(child: ConsultationsScreen()), + ), GoRoute( - path: '/notifications-screen', - name: 'notificationsScreen', - pageBuilder: (context, state) => const NoTransitionPage(child: NotificationsScreen())), + path: '/chat-select-screen', + name: 'chatSelectScreen', + pageBuilder: (context, state) => const NoTransitionPage(child: ChatsScreen()), + ), + GoRoute( + path: '/notifications-screen', + name: 'notificationsScreen', + pageBuilder: (context, state) => const NoTransitionPage(child: NotificationsScreen()), + ), GoRoute( path: '/menu-screen', name: 'menuScreen', @@ -195,32 +190,16 @@ class AppRouter { ), ], ), - GoRoute( - name: 'blockedUsersScreen', - path: '/blocked-users-screen', - builder: (context, state) => const BlockedUsersScreen(), - ), + GoRoute(name: 'blockedUsersScreen', path: '/blocked-users-screen', builder: (context, state) => const BlockedUsersScreen()), GoRoute( path: '/welcome-screen', name: 'welcomeScreen', builder: (context, state) => const WelcomeScreen(), redirect: (context, state) async { try { - if (await TokenStorage.haveTokenInStorage()) { - final firebaseApi = sl(); - final fbToken = await firebaseApi.getToken(); - final deviceInfo = DeviceInfoPlugin(); - await sl().post( - '/api/profile/notification/token/register', - data: { - 'platform': Platform.isIOS ? 'ios' : 'android', - 'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id, - 'token': fbToken, - }, - ); - } final sharedPreferences = await SharedPreferences.getInstance(); final welcomeScreenWasShowed = sharedPreferences.getBool('welcomeScreenWasShowed') ?? false; + await sl().registerPushToken(); if (welcomeScreenWasShowed == false) { return null; } else if (await TokenStorage.haveTokenInStorage()) { @@ -229,7 +208,7 @@ class AppRouter { return '/auth-screen'; } } catch (e) { - rethrow; + return '/feed-screen'; } }, ), @@ -254,26 +233,10 @@ class AppRouter { } }, ), - GoRoute( - path: ConsultationsArchiveScreen.path, - name: ConsultationsArchiveScreen.name, - builder: (context, state) => const ConsultationsArchiveScreen(), - ), - GoRoute( - path: ConsultantFinancesScreen.path, - name: ConsultantFinancesScreen.name, - builder: (context, state) => const ConsultantFinancesScreen(), - ), - GoRoute( - path: UserPurchasesScreen.path, - name: UserPurchasesScreen.name, - builder: (context, state) => const UserPurchasesScreen(), - ), - GoRoute( - path: '/user-finance-screen', - name: 'userFinanceScreen', - builder: (context, state) => const UserFinanceScreen(), - ), + GoRoute(path: ConsultationsArchiveScreen.path, name: ConsultationsArchiveScreen.name, builder: (context, state) => const ConsultationsArchiveScreen()), + GoRoute(path: ConsultantFinancesScreen.path, name: ConsultantFinancesScreen.name, builder: (context, state) => const ConsultantFinancesScreen()), + GoRoute(path: UserPurchasesScreen.path, name: UserPurchasesScreen.name, builder: (context, state) => const UserPurchasesScreen()), + GoRoute(path: '/user-finance-screen', name: 'userFinanceScreen', builder: (context, state) => const UserFinanceScreen()), GoRoute( path: CardSettingsScreen.path, name: CardSettingsScreen.name, @@ -288,11 +251,7 @@ class AppRouter { builder: (context, state) { final slug = state.uri.queryParameters['slug']; if (slug != null) { - return AboutPlanScreen( - slug: slug, - navigateToComments: false, - index: null, - ); + return AboutPlanScreen(slug: slug, navigateToComments: false, index: null); } final data = state.extra as Map; @@ -306,29 +265,23 @@ class AppRouter { builder: (context, state) { final slug = state.uri.queryParameters['slug']; if (slug != null) { - return AboutRecipeScreen( - recipeSlug: slug, - navigateToComments: false, - index: null, - ); + return AboutRecipeScreen(recipeSlug: slug, navigateToComments: false, index: null); } final data = state.extra as Map; - return AboutRecipeScreen( - recipeSlug: data['recipe'] as String, - index: data['index'] as int?, - navigateToComments: data['navigateToComments'] as bool, - ); + return AboutRecipeScreen(recipeSlug: data['recipe'] as String, index: data['index'] as int?, navigateToComments: data['navigateToComments'] as bool); }, ), GoRoute( path: RecipeCreateScreen.path, name: RecipeCreateScreen.name, - builder: (context, state) => RecipeCreateScreen( - recipe: state.extra != null ? state.extra as Recipe : null, - ), + builder: (context, state) => RecipeCreateScreen(recipe: state.extra != null ? state.extra as Recipe : null), + ), + GoRoute( + path: '/profile-screen', + name: 'profileScreen', + builder: (context, state) => ProfileScreen(author: state.extra as Author), ), - GoRoute(path: '/profile-screen', name: 'profileScreen', builder: (context, state) => ProfileScreen(author: state.extra as Author)), GoRoute( path: '/subs-screen/:slug', name: 'subsScreen', @@ -340,11 +293,7 @@ class AppRouter { builder: (context, state) { final slug = state.uri.queryParameters['slug']; if (slug != null) { - return AboutDietScreen( - slug: slug, - index: null, - navigateToComments: false, - ); + return AboutDietScreen(slug: slug, index: null, navigateToComments: false); } final data = state.extra as Map; return AboutDietScreen(slug: data['slug'] as String, index: data['index'] as int?, navigateToComments: data['navigateToComments'] as bool); @@ -356,19 +305,18 @@ class AppRouter { builder: (context, state) => BlogPostCreateScreen(feedBlogPostModel: state.extra != null ? state.extra as FeedBlogPostModel : null), ), GoRoute( - path: '/blog-post-screen', - name: 'blogPostScreen', - builder: (context, state) { - final id = state.uri.queryParameters['id']; - final parsedId = int.tryParse(id ?? ''); - if (parsedId != null) { - return BlogPostScreen( - id: parsedId, - ); - } + path: '/blog-post-screen', + name: 'blogPostScreen', + builder: (context, state) { + final id = state.uri.queryParameters['id']; + final parsedId = int.tryParse(id ?? ''); + if (parsedId != null) { + return BlogPostScreen(id: parsedId); + } - return BlogPostScreen(id: state.extra as int); - }), + return BlogPostScreen(id: state.extra as int); + }, + ), GoRoute( path: '/chat-screen', name: 'chatScreen', @@ -376,10 +324,7 @@ class AppRouter { final data = state.extra as Map; final uuid = data['uuid'] as String; final sendInitialMessage = data['sendInitialMessage'] as bool?; - return ChatScreen( - uuid: uuid, - sendInitialMessage: sendInitialMessage, - ); + return ChatScreen(uuid: uuid, sendInitialMessage: sendInitialMessage); }, ), GoRoute( @@ -412,7 +357,11 @@ class AppRouter { name: 'physicalFormScreen', builder: (context, state) => PhysicalFormScreen(settingsEntity: state.extra as SettingsEntity), ), - GoRoute(path: 'pressure-screen', name: 'pressureScreen', builder: (context, state) => PressureScreen(settingsEntity: state.extra as SettingsEntity)), + GoRoute( + path: 'pressure-screen', + name: 'pressureScreen', + builder: (context, state) => PressureScreen(settingsEntity: state.extra as SettingsEntity), + ), GoRoute( path: 'change-password-screen', name: 'changePasswordScreen', @@ -429,9 +378,7 @@ class AppRouter { GoRoute( path: '/stepper-become-consultant-screen', name: 'stepperBecomeConsultantScreen', - builder: (context, state) => StepperBecomeConsultantScreen( - slug: state.extra == null ? null : state.extra as String, - ), + builder: (context, state) => StepperBecomeConsultantScreen(slug: state.extra == null ? null : state.extra as String), ), GoRoute(path: '/calendar-screen', name: 'calendarScreen', builder: (context, state) => const CalendarScreen()), GoRoute(path: '/support-screen', name: 'supportScreen', builder: (context, state) => const SupportScreen()), @@ -459,7 +406,11 @@ class AppRouter { GoRoute(path: CreatePlanScreen.path, name: CreatePlanScreen.name, builder: (context, state) => const CreatePlanScreen()), GoRoute(path: MyDietsScreen.path, name: MyDietsScreen.name, builder: (context, state) => const MyDietsScreen()), GoRoute(path: MyPlansScreen.path, name: MyPlansScreen.name, builder: (context, state) => const MyPlansScreen()), - GoRoute(path: UserDetailsScreen.path, name: UserDetailsScreen.name, builder: (context, state) => UserDetailsScreen(slug: state.extra as String)), + GoRoute( + path: UserDetailsScreen.path, + name: UserDetailsScreen.name, + builder: (context, state) => UserDetailsScreen(slug: state.extra as String), + ), GoRoute(path: DevToolsScreen.path, name: DevToolsScreen.name, builder: (context, state) => const DevToolsScreen()), GoRoute(path: ConsultantRatingScreen.path, name: ConsultantRatingScreen.name, builder: (context, state) => const ConsultantRatingScreen()), GoRoute( @@ -469,10 +420,7 @@ class AppRouter { final data = state.extra as Map; final planId = data['planId'] as int; final successfulPayment = data['successfulPayment'] as bool?; - return InfoByOrderScreen( - planId: planId, - successfulPayment: successfulPayment, - ); + return InfoByOrderScreen(planId: planId, successfulPayment: successfulPayment); }, ), GoRoute( @@ -482,10 +430,7 @@ class AppRouter { final data = state.extra as Map; final planId = data['planId'] as int; final url = data['url'] as String; - return PaymentScreen( - url: url, - planId: planId, - ); + return PaymentScreen(url: url, planId: planId); }, ), GoRoute( @@ -495,10 +440,7 @@ class AppRouter { final data = state.extra as Map; final diaryId = data['diaryId'] as int; final currentPlanDay = data['currentDiaryDay'] as int; - return UserDiaryScreen( - diaryId: diaryId, - currentPlanDay: currentPlanDay, - ); + return UserDiaryScreen(diaryId: diaryId, currentPlanDay: currentPlanDay); }, ), GoRoute( @@ -506,16 +448,9 @@ class AppRouter { name: 'verifyScreen', builder: (context, state) => VerifyScreen(passKey: state.extra as String), ), - GoRoute( - path: '/verify-email-screen', - name: 'verifyEmailScreen', - builder: (context, state) => const VerifyEmailScreen(), - ), - GoRoute( - path: MyRecipesScreen.path, - name: MyRecipesScreen.name, - builder: (context, state) => const MyRecipesScreen(), - ), + GoRoute(path: '/verify-email-screen', name: 'verifyEmailScreen', builder: (context, state) => const VerifyEmailScreen()), + GoRoute(path: MyRecipesScreen.path, name: MyRecipesScreen.name, builder: (context, state) => const MyRecipesScreen()), + GoRoute(path: '/notifications-settings-screen', name: 'notificationsSettingsScreen', builder: (context, state) => const NotificationsSettingsScreen()), GoRoute( path: PersonalDietSuggestionScreen.path, diff --git a/lib/core/comments/presentation/comments_actions_bottom_sheet.dart b/lib/core/comments/presentation/comments_actions_bottom_sheet.dart index deeea5ff..3c1c3859 100644 --- a/lib/core/comments/presentation/comments_actions_bottom_sheet.dart +++ b/lib/core/comments/presentation/comments_actions_bottom_sheet.dart @@ -1,11 +1,10 @@ -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_action_widget.dart'; import 'package:eatvio_app/core/widgets/bottom_sheet_handler_bar.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/generated/l10n.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_svg/svg.dart'; import 'package:go_router/go_router.dart'; void showCommentsActionsBottomSheet( @@ -23,7 +22,7 @@ void showCommentsActionsBottomSheet( isScrollControlled: true, backgroundColor: Colors.transparent, builder: (context) => Container( - margin: EdgeInsets.only(left: 8, right: 8, bottom: MediaQuery.of(context).padding.bottom), + margin: EdgeInsets.only(left: 8, right: 8, bottom: MediaQuery.of(context).padding.bottom + 20), decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), color: appTheme.whiteA700, @@ -123,7 +122,7 @@ class _CommentsActionsBottomSheetState extends State ), const BottomSheetHandlerBar(), if (widget.isMyComment) - _ActionWidget( + BottomSheetActionWidget( text: s.edit, iconPath: 'assets/icons/edit_paint.svg', needToPop: false, @@ -132,13 +131,13 @@ class _CommentsActionsBottomSheetState extends State }), ) else if (widget.reply != null) - _ActionWidget( + BottomSheetActionWidget( text: s.reply, iconPath: 'assets/icons/reply.svg', onTap: widget.reply!, ), if (widget.reply != null) const CustomDivider(), - _ActionWidget( + BottomSheetActionWidget( text: s.copy, iconPath: 'assets/icons/copy2.svg', onTap: widget.copy, @@ -146,21 +145,21 @@ class _CommentsActionsBottomSheetState extends State if (widget.isMyComment) ...[ if (widget.reply != null) ...[ const CustomDivider(), - _ActionWidget( + BottomSheetActionWidget( text: s.reply, iconPath: 'assets/icons/reply.svg', onTap: widget.reply!, ), ], const CustomDivider(), - _ActionWidget( + BottomSheetActionWidget( text: s.deleteComment, iconPath: 'assets/icons/red_trash.svg', onTap: widget.delete, ), ] else ...[ const CustomDivider(), - _ActionWidget( + BottomSheetActionWidget( text: s.complain, iconPath: 'assets/icons/complain.svg', onTap: widget.complain, @@ -172,32 +171,3 @@ class _CommentsActionsBottomSheetState extends State } } -class _ActionWidget extends StatelessWidget { - const _ActionWidget({required this.text, required this.iconPath, required this.onTap, this.needToPop = true}); - final String text; - final String iconPath; - final void Function() onTap; - final bool needToPop; - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: () { - if (needToPop) Navigator.pop(context); - onTap(); - }, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: Row( - children: [ - Text( - text, - style: TextStyles.textLMedium.copyWith(color: appTheme.black), - ), - const Spacer(), - SvgPicture.asset(iconPath) - ], - ), - ), - ); - } -} diff --git a/lib/core/const.dart b/lib/core/const.dart index 4f38a54e..98f7bc4a 100644 --- a/lib/core/const.dart +++ b/lib/core/const.dart @@ -1,5 +1,8 @@ -// String baseUrl = 'https://eatvio.ru'; -String baseUrl = 'https://dev2.eatvio.com'; +import 'package:eatvio_app/core/env_config.dart'; + +String baseUrl = EnvConfig.baseUrl; +bool hidePlans = const bool.fromEnvironment('HIDE_PLANS', defaultValue: false); +const buildDate = String.fromEnvironment('BUILD_DATE', defaultValue: ''); const List durationValues = [7, 14, 21]; diff --git a/lib/core/credentials_storage.dart b/lib/core/credentials_storage.dart deleted file mode 100644 index 6e9870e5..00000000 --- a/lib/core/credentials_storage.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; - -abstract class CredentialsStorage { - static Future haveCredentialsInStorage() async { - try { - if (await const FlutterSecureStorage().read(key: 'email') == null || await const FlutterSecureStorage().read(key: 'password') == null) { - return false; - } else { - return true; - } - } catch (e) { - return false; - } - } - - static Future> getCredentialsFromStorage() async { - return { - 'email': (await const FlutterSecureStorage().read(key: 'email'))!, - 'password': (await const FlutterSecureStorage().read(key: 'password'))!, - }; - } - - static Future saveCredentials(String email, String password) async { - await const FlutterSecureStorage().write(key: 'email', value: email); - await const FlutterSecureStorage().write(key: 'password', value: password); - } - - static Future removeCredentials() async { - await const FlutterSecureStorage().delete(key: 'email'); - await const FlutterSecureStorage().delete(key: 'password'); - } -} diff --git a/lib/core/env_config.dart b/lib/core/env_config.dart new file mode 100644 index 00000000..a3404aeb --- /dev/null +++ b/lib/core/env_config.dart @@ -0,0 +1,46 @@ +import 'package:flutter_dotenv/flutter_dotenv.dart'; + +class EnvConfig { + static String get baseUrl => dotenv.env['BASE_URL'] ?? ''; + static String get devBaseUrl => dotenv.env['DEV_BASE_URL'] ?? ''; + + // Sentry Configuration + static String get sentryDsn => dotenv.env['SENTRY_DSN'] ?? ''; + + // AppMetrica Configuration + static String get appMetricaApiKey => dotenv.env['APPMETRICA_KEY'] ?? ''; + + // Firebase Configuration + static String get firebaseProjectId => dotenv.env['FIREBASE_PROJECT_ID'] ?? ''; + static String get firebaseDatabaseUrl => dotenv.env['FIREBASE_DATABASE_URL'] ?? ''; + static String get firebaseStorageBucket => dotenv.env['FIREBASE_STORAGE_BUCKET'] ?? ''; + static String get firebaseMessagingSenderId => dotenv.env['FIREBASE_MESSAGING_SENDER_ID'] ?? ''; + + // Firebase Android Configuration + static String get firebaseAndroidApiKey => dotenv.env['FIREBASE_ANDROID_API_KEY'] ?? ''; + static String get firebaseAndroidAppId => dotenv.env['FIREBASE_ANDROID_APP_ID'] ?? ''; + + // Firebase iOS Configuration + static String get firebaseIosApiKey => dotenv.env['FIREBASE_IOS_API_KEY'] ?? ''; + static String get firebaseIosAppId => dotenv.env['FIREBASE_IOS_APP_ID'] ?? ''; + static String get firebaseIosBundleId => dotenv.env['FIREBASE_IOS_BUNDLE_ID'] ?? ''; + + // App Store URLs + static String get appStoreUrl => dotenv.env['APP_STORE_URL'] ?? ''; + static String get rustoreUrl => dotenv.env['RUSTORE_URL'] ?? ''; + + // Telegram Support + static String get telegramSupportUrl => dotenv.env['TELEGRAM_SUPPORT_URL'] ?? ''; + static String get supportEmail => dotenv.env['SUPPORT_EMAIL'] ?? ''; + + // Test Credentials (for development only) + static String get testEmail => dotenv.env['TEST_EMAIL'] ?? ''; + static String get testPassword => dotenv.env['TEST_PASSWORD'] ?? ''; + + // Email Validation API + static String get emailValidationApiUrl => dotenv.env['EMAIL_VALIDATION_API_URL'] ?? ''; + + // Connection Check URLs + static String get googleCheckUrl => dotenv.env['GOOGLE_CHECK_URL'] ?? ''; + static String get yandexCheckUrl => dotenv.env['YANDEX_CHECK_URL'] ?? ''; +} diff --git a/lib/core/firebase_api.dart b/lib/core/firebase_api.dart index fbf8e5c1..572b56f6 100644 --- a/lib/core/firebase_api.dart +++ b/lib/core/firebase_api.dart @@ -1,3 +1,10 @@ +import 'dart:developer'; +import 'dart:io'; + +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:dio/dio.dart'; +import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/core/token_storage.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; class FirebaseApi { @@ -7,4 +14,22 @@ class FirebaseApi { await _firebaseMessaging.requestPermission(); return await _firebaseMessaging.getToken(); } + + Future registerPushToken() async { + if (await TokenStorage.haveTokenInStorage()) { + try { + final deviceInfo = DeviceInfoPlugin(); + await sl().post( + '/api/profile/notification/token/register', + data: { + 'platform': Platform.isIOS ? 'ios' : 'android', + 'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id, + 'token': await getToken(), + }, + ); + } catch (e, st) { + log(e.toString(), stackTrace: st); + } + } + } } diff --git a/lib/core/locator_service.dart b/lib/core/locator_service.dart index edfc8db0..0ca95d77 100644 --- a/lib/core/locator_service.dart +++ b/lib/core/locator_service.dart @@ -20,6 +20,9 @@ import 'package:eatvio_app/features/cancel_plan_bottom_sheet/service/cancel_plan import 'package:eatvio_app/features/complaining/presentation/cubit/complaining_cubit.dart'; import 'package:eatvio_app/features/complaining/repositories/complaining_repository.dart'; import 'package:eatvio_app/features/complaining/services/complaining_service.dart'; +import 'package:eatvio_app/features/consultant_rating/presentation/cubit/consultant_rating_cubit.dart'; +import 'package:eatvio_app/features/consultant_rating/repositories/consultant_rating_repository.dart'; +import 'package:eatvio_app/features/consultant_rating/services/consultant_rating_service.dart'; import 'package:eatvio_app/features/consultations/presentation/cubit/consultations_cubit.dart'; import 'package:eatvio_app/features/consultations/repositories/consultations_repository.dart'; import 'package:eatvio_app/features/consultations/services/consultation_service.dart'; @@ -40,8 +43,11 @@ import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/new_ import 'package:eatvio_app/features/finances_and_consultations_archive/cubit/consultant_finances/consultant_finances_cubit.dart'; import 'package:eatvio_app/features/finances_and_consultations_archive/cubit/user_purchases/user_purchases_cubit.dart'; import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_cubit.dart'; +import 'package:eatvio_app/features/menu/presentation/bloc/notification_settings_cubit/notification_settings_cubit.dart'; import 'package:eatvio_app/features/menu/presentation/repositories/blocked_users_repository.dart'; +import 'package:eatvio_app/features/menu/repositories/notification_settings_repository.dart'; import 'package:eatvio_app/features/menu/services/blocked_users_service.dart'; +import 'package:eatvio_app/features/menu/services/notification_settings_service.dart'; import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart'; import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart'; import 'package:eatvio_app/features/calendar/presentation/bloc/calendar_cubit.dart'; @@ -110,7 +116,6 @@ import 'package:eatvio_app/features/settings/presentation/bloc/settings_cubit/se import 'package:eatvio_app/features/settings/repositories/service_repository.dart'; import 'package:eatvio_app/features/settings/services/settings_service.dart'; import 'package:eatvio_app/features/user_details/presentation/cubit/user_details_cubit.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:get_it/get_it.dart'; import 'package:eatvio_app/features/profile/presentation/bloc/plans_cubit/plans_cubit.dart' as profile_plans; @@ -137,14 +142,16 @@ Future init() async { sl.registerFactory(() => PlansCubit(feedRepository: sl(), planRepository: sl())); sl.registerFactory(() => SettingsCubit(profileRepository: sl(), settingsRepository: sl(), consultantRepository: sl())); sl.registerFactory(() => RegisterConsultantCubit(consultantRepository: sl())); - sl.registerFactory(() => DiaryCubit( - chatRepository: sl(), - diaryRepository: sl(), - glossaryRepository: sl(), - profileRepository: sl(), - dietRepository: sl(), - settingsRepository: sl(), - )); + sl.registerFactory( + () => DiaryCubit( + chatRepository: sl(), + diaryRepository: sl(), + glossaryRepository: sl(), + profileRepository: sl(), + dietRepository: sl(), + settingsRepository: sl(), + ), + ); sl.registerFactory(() => AboutDietCubit(dietsRepository: sl(), profileRepository: sl())); sl.registerFactory(() => BlogPostCreateCubit(blogPostRepository: sl())); sl.registerFactory(() => SubCubit(profileRepository: sl())); @@ -173,6 +180,8 @@ Future init() async { sl.registerFactory(() => ResetPasswordCubit(resetPasswordRepository: sl())); sl.registerFactory(() => BlogPostCubit(sl())); sl.registerFactory(() => BlockedUsersCubit(blockedUsersRepository: sl())); + sl.registerFactory(() => NotificationSettingsCubit(notificationSettingsRepository: sl())); + sl.registerFactory(() => ConsultantRatingCubit(sl())); sl.registerFactory(() => PersonalDietSuggestionCubit(dietRepository: sl(), personalDietSuggestionService: sl())); // Repository @@ -205,6 +214,8 @@ Future init() async { sl.registerFactory(() => ResetPasswordRepository(resetPasswordService: sl())); sl.registerLazySingleton(() => BlockedUsersRepository(blockedUserService: sl())); sl.registerLazySingleton(() => CommentsRepository(service: sl())); + sl.registerLazySingleton(() => NotificationSettingsRepository(sl())); + sl.registerFactory(() => ConsultantRatingRepository(sl())); sl.registerLazySingleton(() => PersonalDietSuggestionRepository(personalDietSuggestionService: sl())); // Services @@ -234,29 +245,19 @@ Future init() async { sl.registerLazySingleton(() => CancelPlanService(sl())); sl.registerLazySingleton(() => CreatePlanService(sl())); sl.registerLazySingleton(() => NotificationsService(sl())); - sl.registerLazySingleton(() => MyRecipesService(sl())); sl.registerLazySingleton(() => FirebaseApi()); sl.registerLazySingleton(() => ResetPasswordService(sl())); sl.registerLazySingleton(() => BlockedUserService(sl())); sl.registerLazySingleton(() => CommentsService(sl())); + sl.registerLazySingleton(() => MyRecipesService(sl())); + sl.registerLazySingleton(() => ConsultantRatingService(sl())); + sl.registerLazySingleton(() => NotificationSettingsService(sl())); sl.registerLazySingleton(() => PersonalDietSuggestionService(sl())); // External sl.registerLazySingleton( - () => Dio( - BaseOptions( - baseUrl: baseUrl, - headers: { - 'accept': 'application/json', - 'content-type': 'application/json', - }, - ), - )..interceptors.addAll( - [ - TokenInterceptor(), - PhoneVerifyErrorInterceptor(), - ], - ), + () => + Dio(BaseOptions(baseUrl: baseUrl, headers: {'accept': 'application/json', 'content-type': 'application/json'})) + ..interceptors.addAll([TokenInterceptor(), PhoneVerifyErrorInterceptor()]), ); - sl.registerLazySingleton(() async => const FlutterSecureStorage()); } diff --git a/lib/core/token_storage.dart b/lib/core/token_storage.dart index 30906264..f67e919c 100644 --- a/lib/core/token_storage.dart +++ b/lib/core/token_storage.dart @@ -1,9 +1,10 @@ -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:shared_preferences/shared_preferences.dart'; abstract class TokenStorage { static Future haveTokenInStorage() async { + final sharedPreferences = await SharedPreferences.getInstance(); try { - if (await const FlutterSecureStorage().read(key: 'token') == null) { + if (sharedPreferences.getString('token') == null) { return false; } else { return true; @@ -13,19 +14,27 @@ abstract class TokenStorage { } } - static Future getTokenFromStorage() async => await const FlutterSecureStorage().read(key: 'token'); - - static Future saveTokenInStorage(String token) async { - await const FlutterSecureStorage().write(key: 'token', value: token); + static Future getTokenFromStorage() async { + final sharedPreferences = await SharedPreferences.getInstance(); + return sharedPreferences.getString('token'); } - static Future removeTokenInStorage() async => (await const FlutterSecureStorage().delete(key: 'token')); + static Future saveTokenInStorage(String token) async { + final sharedPreferences = await SharedPreferences.getInstance(); + await sharedPreferences.setString('token', token); + } + + static Future removeTokenInStorage() async { + final sharedPreferences = await SharedPreferences.getInstance(); + await sharedPreferences.remove('token'); + } } abstract class SocketIdStorage { static Future haveSocketIdInStorage() async { + final sharedPreferences = await SharedPreferences.getInstance(); try { - if (await const FlutterSecureStorage().read(key: 'socketId') == null) { + if (sharedPreferences.getString('socketId') == null) { return false; } else { return true; @@ -35,11 +44,18 @@ abstract class SocketIdStorage { } } - static Future getSocketIdFromStorage() async => await const FlutterSecureStorage().read(key: 'socketId'); - - static Future saveSocketIdInStorage(String socketId) async { - await const FlutterSecureStorage().write(key: 'socketId', value: socketId); + static Future getSocketIdFromStorage() async { + final sharedPreferences = await SharedPreferences.getInstance(); + return sharedPreferences.getString('socketId'); } - static Future removeSocketIdInStorage() async => (await const FlutterSecureStorage().delete(key: 'socketId')); + static Future saveSocketIdInStorage(String socketId) async { + final sharedPreferences = await SharedPreferences.getInstance(); + await sharedPreferences.setString('socketId', socketId); + } + + static Future removeSocketIdInStorage() async { + final sharedPreferences = await SharedPreferences.getInstance(); + await sharedPreferences.remove('socketId'); + } } diff --git a/lib/core/utils/date_time_utils.dart b/lib/core/utils/date_time_utils.dart index bcc82aeb..701fa80e 100644 --- a/lib/core/utils/date_time_utils.dart +++ b/lib/core/utils/date_time_utils.dart @@ -3,11 +3,30 @@ import 'package:intl/intl.dart'; const String dateTimeFormatPattern = 'dd/MM/yyyy'; +/// Преобразует дату из формата гггг-мм-дд чч:мм:сс в дд.мм.гггг +String formatBuildDate(String dateString) { + if (dateString.isEmpty) return ''; + + try { + final dateTimeParts = dateString.split(' '); + if (dateTimeParts.isNotEmpty) { + final datePart = dateTimeParts[0]; + final dateParts = datePart.split('-'); + if (dateParts.length == 3) { + final year = dateParts[0]; + final month = dateParts[1]; + final day = dateParts[2]; + return '$day.$month.$year'; + } + } + } catch (e) { + return dateString; + } + return dateString; +} + extension DateTimeExtension on DateTime { - String format([ - String pattern = dateTimeFormatPattern, - String? locale, - ]) { + String format([String pattern = dateTimeFormatPattern, String? locale]) { final localDate = isUtc ? toLocal() : this; if (locale != null && locale.isNotEmpty) { initializeDateFormatting(locale); @@ -19,17 +38,13 @@ extension DateTimeExtension on DateTime { final localDate = isUtc ? toLocal() : this; final now = DateTime.now(); final yesterday = DateTime(now.year, now.month, now.day).subtract(const Duration(days: 1)); - return localDate.year == yesterday.year && - localDate.month == yesterday.month && - localDate.day == yesterday.day; + return localDate.year == yesterday.year && localDate.month == yesterday.month && localDate.day == yesterday.day; } bool isToday() { final localDate = isUtc ? toLocal() : this; final now = DateTime.now(); - return now.year == localDate.year && - now.month == localDate.month && - now.day == localDate.day; + return now.year == localDate.year && now.month == localDate.month && now.day == localDate.day; } } diff --git a/lib/core/widgets/bottom_sheet_action_widget.dart b/lib/core/widgets/bottom_sheet_action_widget.dart new file mode 100644 index 00000000..004b5567 --- /dev/null +++ b/lib/core/widgets/bottom_sheet_action_widget.dart @@ -0,0 +1,33 @@ +import 'package:eatvio_app/core/theme/custom_text_style.dart'; +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; + +class BottomSheetActionWidget extends StatelessWidget { + const BottomSheetActionWidget({super.key, required this.text, required this.iconPath, required this.onTap, this.needToPop = true, this.color}); + final String text; + final String iconPath; + final void Function() onTap; + final bool needToPop; + final Color? color; + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () { + if (needToPop) Navigator.pop(context); + onTap(); + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Row( + children: [ + Expanded( + child: Text(text, style: TextStyles.textLMedium.copyWith(color: appTheme.black)), + ), + SvgPicture.asset(iconPath, colorFilter: color != null ? ColorFilter.mode(color!, BlendMode.srcIn) : null), + ], + ), + ), + ); + } +} diff --git a/lib/core/widgets/custom_drop_down.dart b/lib/core/widgets/custom_drop_down.dart index aca39b44..9580afe7 100644 --- a/lib/core/widgets/custom_drop_down.dart +++ b/lib/core/widgets/custom_drop_down.dart @@ -2,7 +2,8 @@ import 'package:eatvio_app/core/data/selectionPopupModel/selection_popup_model.d import 'package:flutter/material.dart'; class CustomDropDown extends StatelessWidget { - const CustomDropDown({super.key, + const CustomDropDown({ + super.key, this.alignment, this.width, this.focusNode, @@ -48,12 +49,7 @@ class CustomDropDown extends StatelessWidget { @override Widget build(BuildContext context) { - return alignment != null - ? Align( - alignment: alignment ?? Alignment.center, - child: _buildDropDownWidget(context), - ) - : _buildDropDownWidget(context); + return alignment != null ? Align(alignment: alignment ?? Alignment.center, child: _buildDropDownWidget(context)) : _buildDropDownWidget(context); } DropdownButtonFormField _buildDropDownWidget(BuildContext context) { @@ -61,7 +57,7 @@ class CustomDropDown extends StatelessWidget { focusNode: focusNode ?? FocusNode(), icon: const Icon(Icons.keyboard_arrow_down_rounded), autofocus: autofocus!, - value: value, + initialValue: value, isDense: true, isExpanded: true, items: items?.map((SelectionPopupModel item) { @@ -69,12 +65,7 @@ class CustomDropDown extends StatelessWidget { value: item, child: Text( item.title, - style: hintStyle ?? - const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w400, - color: Colors.black, - ), + style: hintStyle ?? const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, color: Colors.black), ), ); }).toList(), @@ -90,19 +81,9 @@ class CustomDropDown extends StatelessWidget { InputDecoration _buildInputDecoration(BuildContext context) { return InputDecoration( - labelStyle: const TextStyle( - fontSize: 16, - color: Colors.black, - fontWeight: FontWeight.w400, - ), + labelStyle: const TextStyle(fontSize: 16, color: Colors.black, fontWeight: FontWeight.w400), hintText: hintText ?? "", - hintStyle: hintStyle ?? - TextStyle( - fontSize: 16, - fontWeight: FontWeight.w400, - color: Colors.grey.shade400, - height: 1, - ), + hintStyle: hintStyle ?? TextStyle(fontSize: 16, fontWeight: FontWeight.w400, color: Colors.grey.shade400, height: 1), prefixIcon: prefix, prefixIconConstraints: prefixConstraints, suffixIcon: suffix, @@ -111,18 +92,9 @@ class CustomDropDown extends StatelessWidget { contentPadding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12), fillColor: const Color(0xFFF6F6F6), filled: filled, - border: const OutlineInputBorder( - borderRadius: BorderRadius.all(Radius.circular(20)), - borderSide: BorderSide.none, - ), - enabledBorder: const OutlineInputBorder( - borderRadius: BorderRadius.all(Radius.circular(20)), - borderSide: BorderSide.none, - ), - focusedBorder: const OutlineInputBorder( - borderRadius: BorderRadius.all(Radius.circular(20)), - borderSide: BorderSide.none, - ), + border: const OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(20)), borderSide: BorderSide.none), + enabledBorder: const OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(20)), borderSide: BorderSide.none), + focusedBorder: const OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(20)), borderSide: BorderSide.none), ); } } diff --git a/lib/core/widgets/custom_drop_down_button.dart b/lib/core/widgets/custom_drop_down_button.dart index 714382e7..ac8f3c2b 100644 --- a/lib/core/widgets/custom_drop_down_button.dart +++ b/lib/core/widgets/custom_drop_down_button.dart @@ -3,14 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; class CustomDropDownButton extends StatelessWidget { - const CustomDropDownButton({ - super.key, - required this.items, - required this.onChanged, - this.value, - this.hint, - this.hasError = false, - }); + const CustomDropDownButton({super.key, required this.items, required this.onChanged, this.value, this.hint, this.hasError = false}); final T? value; final List> items; final void Function(T?)? onChanged; @@ -20,11 +13,7 @@ class CustomDropDownButton extends StatelessWidget { Widget build(BuildContext context) { return DropdownButtonFormField( hint: hint, - icon: SvgPicture.asset( - 'assets/icons/arrow_down2.svg', - width: 12, - height: 8, - ), + icon: SvgPicture.asset('assets/icons/arrow_down2.svg', width: 12, height: 8), borderRadius: BorderRadius.circular(20), dropdownColor: appTheme.lightGray0, decoration: InputDecoration( @@ -43,7 +32,7 @@ class CustomDropDownButton extends StatelessWidget { fillColor: appTheme.lightGray0, filled: true, ), - value: value, + initialValue: value, items: items, onChanged: onChanged, ); diff --git a/lib/features/auth/presentation/pages/auth_screen.dart b/lib/features/auth/presentation/pages/auth_screen.dart index 10fb4172..73162bd2 100644 --- a/lib/features/auth/presentation/pages/auth_screen.dart +++ b/lib/features/auth/presentation/pages/auth_screen.dart @@ -1,6 +1,7 @@ import 'dart:async'; 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'; @@ -46,12 +47,20 @@ class _AuthScreenState extends State { super.initState(); var keyboardVisibilityController = KeyboardVisibilityController(); keyboardSubscription = keyboardVisibilityController.onChange.listen((bool visible) { - setState(() { - isFocused = visible; - }); + if (mounted) { + setState(() { + isFocused = visible; + }); + } }); } + @override + void dispose() { + keyboardSubscription.cancel(); + super.dispose(); + } + Future _reconnectWebSocket(BuildContext context) async { try { await sl().clear(); @@ -66,15 +75,13 @@ class _AuthScreenState extends State { await sl().connectToWebSocket(); await sl().getNotifications(); await sl().connectToWebSocket(); + await sl().registerPushToken(); showSuccessToast('Успешный вход'); } catch (e) { rethrow; } } - AppRouter.navigatorKey.currentContext?.go( - '/feed-screen', - extra: {'needToInitializeWebSocket': false}, - ); + AppRouter.navigatorKey.currentContext?.go('/feed-screen', extra: {'needToInitializeWebSocket': false}); } @override @@ -100,11 +107,7 @@ class _AuthScreenState extends State { GoRouter.of(context).pushNamed(DevToolsScreen.name); } }, - child: SvgPicture.asset( - ImageConstant.imgLogoEatvio, - height: isFocused ? 64 : MediaQuery.of(context).size.width - 64, - fit: BoxFit.contain, - ), + child: SvgPicture.asset(ImageConstant.imgLogoEatvio, height: isFocused ? 64 : MediaQuery.of(context).size.width - 64, fit: BoxFit.contain), ), ), body: SafeArea( @@ -135,10 +138,7 @@ class _AuthScreenState extends State { hidePassword = !hidePassword; }); }, - icon: Icon( - hidePassword ? Icons.visibility_outlined : Icons.visibility_off_outlined, - color: Colors.grey, - ), + icon: Icon(hidePassword ? Icons.visibility_outlined : Icons.visibility_off_outlined, color: Colors.grey), ), ), const SizedBox(height: 16), @@ -147,11 +147,7 @@ class _AuthScreenState extends State { onTap: () => GoRouter.of(context).goNamed('forgetPasswordScreen', extra: false), child: Text( 'Забыли пароль?', - style: TextStyle( - color: appTheme.blueA400, - decorationColor: appTheme.blueA400, - decoration: TextDecoration.underline, - ), + style: TextStyle(color: appTheme.blueA400, decorationColor: appTheme.blueA400, decoration: TextDecoration.underline), ), ), ), @@ -164,11 +160,7 @@ class _AuthScreenState extends State { onTap: () => GoRouter.of(context).goNamed('registrationScreen'), child: Text( 'Зарегистрироваться', - style: TextStyle( - color: appTheme.blueA400, - decorationColor: appTheme.blueA400, - decoration: TextDecoration.underline, - ), + style: TextStyle(color: appTheme.blueA400, decorationColor: appTheme.blueA400, decoration: TextDecoration.underline), ), ), ], @@ -179,23 +171,22 @@ class _AuthScreenState extends State { textAlign: TextAlign.center, text: TextSpan( text: 'Нажимая "Войти" или "Зарегистрироваться", Вы соглашаетесь \n', - style: const TextStyle( - fontSize: 12, - color: Colors.black, - ), + style: const TextStyle(fontSize: 12, color: Colors.black), children: [ const TextSpan(text: 'c '), - TextSpan( - text: ' Публичной офертой', - style: TextStyle( - fontSize: 12, - color: appTheme.blueA400, - decorationColor: appTheme.blueA400, - decoration: TextDecoration.underline, + if (!hidePlans) ...[ + TextSpan( + text: ' Публичной офертой', + style: TextStyle( + fontSize: 12, + color: appTheme.blueA400, + decorationColor: appTheme.blueA400, + decoration: TextDecoration.underline, + ), + recognizer: TapGestureRecognizer()..onTap = () async => await launchUrl(Uri.parse('$baseUrl/oferta_bs.pdf')), ), - recognizer: TapGestureRecognizer()..onTap = () async => await launchUrl(Uri.parse('$baseUrl/oferta_bs.pdf')), - ), - const TextSpan(text: ' и '), + const TextSpan(text: ' и '), + ], TextSpan( text: 'Политикой конфиденциальности\nи защитой персональных данных', style: TextStyle( @@ -221,17 +212,12 @@ class _AuthScreenState extends State { highlightElevation: 0, elevation: 0, focusElevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), backgroundColor: state is AuthStateLoading ? Colors.black.withAlpha(50) : appTheme.blueA400, onPressed: state is AuthStateLoading ? null : () async { - final isAuthenticated = await authCubit.getTokenByCredentials( - emailController.text, - passwordController.text, - ); + final isAuthenticated = await authCubit.getTokenByCredentials(emailController.text, passwordController.text); if (isAuthenticated) { if (context.mounted) { _reconnectWebSocket(context); @@ -240,10 +226,7 @@ class _AuthScreenState extends State { }, label: state is AuthStateLoading ? const CupertinoActivityIndicator() - : Text( - 'Войти', - style: theme.textTheme.titleSmall!.copyWith(color: Colors.white), - ), + : Text('Войти', style: theme.textTheme.titleSmall!.copyWith(color: Colors.white)), ), ), const SizedBox(width: 8), @@ -255,9 +238,7 @@ class _AuthScreenState extends State { highlightElevation: 0, elevation: 0, focusElevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), backgroundColor: state is AuthStateLoading ? Colors.black.withAlpha(50) : appTheme.blueA400, onPressed: state is AuthStateLoading ? null @@ -272,14 +253,10 @@ class _AuthScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - const Text( - 'Где взять QR-код?\n', - style: TextStyle( - fontSize: 18, - ), - ), + const Text('Где взять QR-код?\n', style: TextStyle(fontSize: 18)), Text( - '1. Войдите на сайте $baseUrl в свой аккаунт\n\n2. Перейдите в настройки профиля\n\n3. В разделе "Доп информация" перейдите на "QR Login"'), + '1. Войдите на сайте $baseUrl в свой аккаунт\n\n2. Перейдите в настройки профиля\n\n3. В разделе "Доп информация" перейдите на "QR Login"', + ), const SizedBox(height: 12), Row( mainAxisAlignment: MainAxisAlignment.end, @@ -291,15 +268,10 @@ class _AuthScreenState extends State { }, child: Ink( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - ), + decoration: BoxDecoration(borderRadius: BorderRadius.circular(8)), child: const Text( 'Отмена', - style: TextStyle( - color: Colors.black, - fontWeight: FontWeight.w400, - ), + style: TextStyle(color: Colors.black, fontWeight: FontWeight.w400), ), ), ), @@ -312,15 +284,10 @@ class _AuthScreenState extends State { }, child: Ink( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - ), + decoration: BoxDecoration(borderRadius: BorderRadius.circular(8)), child: Text( 'Перейти', - style: TextStyle( - color: appTheme.blueA400, - fontWeight: FontWeight.w600, - ), + style: TextStyle(color: appTheme.blueA400, fontWeight: FontWeight.w600), ), ), ), @@ -335,10 +302,7 @@ class _AuthScreenState extends State { }, label: state is AuthStateLoading ? const CupertinoActivityIndicator() - : const Icon( - Icons.qr_code_scanner_rounded, - color: Colors.white, - ), + : const Icon(Icons.qr_code_scanner_rounded, color: Colors.white), ), ), ], diff --git a/lib/features/auth/presentation/pages/scan_qr_code_screen.dart b/lib/features/auth/presentation/pages/scan_qr_code_screen.dart index 411cb3a5..83296f0e 100644 --- a/lib/features/auth/presentation/pages/scan_qr_code_screen.dart +++ b/lib/features/auth/presentation/pages/scan_qr_code_screen.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:dio/dio.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/token_storage.dart'; import 'package:eatvio_app/core/utils/toasts.dart'; @@ -92,6 +93,7 @@ class _ScanQRCodeScreenState extends State { await sl().connectToWebSocket(); await sl().getNotifications(); await sl().connectToWebSocket(); + await sl().registerPushToken(); } catch (e) { rethrow; } diff --git a/lib/features/chat/models/chat_message_model.dart b/lib/features/chat/models/chat_message_model.dart index 7224912a..8dd8545a 100644 --- a/lib/features/chat/models/chat_message_model.dart +++ b/lib/features/chat/models/chat_message_model.dart @@ -15,7 +15,11 @@ abstract class ChatMessageModel with _$ChatMessageModel { @JsonKey(name: 'read_by_to_user', defaultValue: false) required bool isRead, @JsonKey(name: 'is_author_message_auth_user') required bool? isCurrentUserAuthor, @JsonKey(name: 'room_id') required String? roomId, + @Default(MessageState.sent) @JsonKey(includeToJson: false, includeFromJson: false) MessageState messageState, + @Default(null) @JsonKey(includeToJson: false, includeFromJson: false) String? uuid, }) = _ChatMessageModel; factory ChatMessageModel.fromJson(Map json) => _$ChatMessageModelFromJson(json); } + +enum MessageState { loading, sent, error } diff --git a/lib/features/chat/models/chat_room_model.dart b/lib/features/chat/models/chat_room_model.dart index dc091ea6..c5ee559f 100644 --- a/lib/features/chat/models/chat_room_model.dart +++ b/lib/features/chat/models/chat_room_model.dart @@ -9,7 +9,7 @@ part 'chat_room_model.g.dart'; @freezed abstract class ChatRoomModel with _$ChatRoomModel { const factory ChatRoomModel({ - required String id, + required String id, @JsonKey(name: 'chat_room_setting') required ChatRoomSettingsModel chatRoomSettings, required Users users, @JsonKey(name: 'chosen_plan_id') required int? chosenPlanId, @@ -22,13 +22,9 @@ abstract class ChatRoomModel with _$ChatRoomModel { @freezed abstract class Users with _$Users { const factory Users({ - @JsonKey(name: 'from_chat_user') required ChatUserModel sender, @JsonKey(name: 'to_chat_user') required ChatUserModel receiver, - }) = _Users; factory Users.fromJson(Map json) => _$UsersFromJson(json); } - - diff --git a/lib/features/chat/models/chat_room_settings_model.dart b/lib/features/chat/models/chat_room_settings_model.dart index fa332843..737d0c82 100644 --- a/lib/features/chat/models/chat_room_settings_model.dart +++ b/lib/features/chat/models/chat_room_settings_model.dart @@ -6,7 +6,7 @@ part 'chat_room_settings_model.g.dart'; @freezed abstract class ChatRoomSettingsModel with _$ChatRoomSettingsModel { const factory ChatRoomSettingsModel({ - required String name, + @JsonKey(name: 'room_name') required String name, required String state, }) = _ChatRoomSettingsModel; diff --git a/lib/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart b/lib/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart index f4c95d12..5206889b 100644 --- a/lib/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart +++ b/lib/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart @@ -4,6 +4,7 @@ import 'dart:developer'; import 'dart:io'; import 'package:audioplayers/audioplayers.dart'; +import 'package:collection/collection.dart'; import 'package:dart_pusher_channels/dart_pusher_channels.dart'; import 'package:easy_debounce/easy_debounce.dart'; import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart'; @@ -19,6 +20,7 @@ import 'package:eatvio_app/features/my_finance/models/card_model.dart'; import 'package:eatvio_app/features/my_finance/repositories/cards_repository.dart'; import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:uuid/uuid.dart'; class ChatRoomCubit extends Cubit { final ChatRepository chatRepository; @@ -31,12 +33,9 @@ class ChatRoomCubit extends Cubit { late int lastPage; bool isLoading = false; Timer? _timer; + var uuid = const Uuid(); - ChatRoomCubit({ - required this.chatRepository, - required this.wsManager, - required this.cardsRepository, - }) : super(const ChatRoomState.loading()); + ChatRoomCubit({required this.chatRepository, required this.wsManager, required this.cardsRepository}) : super(const ChatRoomState.loading()); void setTimer() { _timer?.cancel(); @@ -89,10 +88,7 @@ class ChatRoomCubit extends Cubit { } } - Future initChat( - String uuid, { - bool sendInitialMessage = false, - }) async { + Future initChat(String uuid, {bool sendInitialMessage = false}) async { try { final chatRoom = await chatRepository.getChatRoom(uuid); ChosenPlanModel? chosenPlan; @@ -171,11 +167,7 @@ class ChatRoomCubit extends Cubit { List messagesList = [message, ...(state as ChatRoomStateLoaded).chatMessages]; chatRepository.emitNewMessageToChats(message); emit((state as ChatRoomStateLoaded).copyWith(chatMessages: messagesList, isLoading: isLoading)); - EasyDebounce.debounce( - 'searchChatRoom', - const Duration(milliseconds: 300), - () => sl().getChatRooms(), - ); + EasyDebounce.debounce('searchChatRoom', const Duration(milliseconds: 300), () => sl().getChatRooms()); }); typingSubscription = wsManager.clientTypingStream?.listen((event) { @@ -183,22 +175,14 @@ class ChatRoomCubit extends Cubit { EasyDebounce.debounce( 'client-typing', const Duration(seconds: 3), - () => emit( - (state as ChatRoomStateLoaded).copyWith( - isTyping: false, - isLoading: isLoading, - ), - ), + () => emit((state as ChatRoomStateLoaded).copyWith(isTyping: false, isLoading: isLoading)), ); }); } - Future sendMessage({ - String? data, - List? files, - required String userSlug, - }) async { + Future sendMessage({String? data, List? files, required String userSlug}) async { try { + final messageUuid = uuid.v4(); final compressedFiles = files != null ? await ImageCompressUtility.compressFilesList(List.from(files)) : null; AudioPlayer().play((AssetSource('audio/send_message.wav'))); emit( @@ -213,16 +197,39 @@ class ChatRoomCubit extends Cubit { isRead: true, isCurrentUserAuthor: true, roomId: '', + messageState: MessageState.loading, + uuid: messageUuid, ), ...(state as ChatRoomStateLoaded).chatMessages, ], ), ); - await chatRepository.sendMessage( - data: data, - files: compressedFiles, - slug: userSlug, - ); + + try { + await chatRepository.sendMessage(data: data, files: compressedFiles, slug: userSlug); + emit( + (state as ChatRoomStateLoaded).copyWith( + chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) { + if (elem.uuid == messageUuid) { + return elem.copyWith(messageState: MessageState.sent); + } + return elem; + }).toList(), + ), + ); + } catch (e) { + emit( + (state as ChatRoomStateLoaded).copyWith( + chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) { + if (elem.uuid == messageUuid) { + return elem.copyWith(messageState: MessageState.error); + } + return elem; + }).toList(), + ), + ); + } + sl().getChatRooms(); return true; } catch (e, st) { log(e.toString(), stackTrace: st); @@ -267,7 +274,10 @@ class ChatRoomCubit extends Cubit { try { final planId = (state as ChatRoomStateLoaded).chatRoom.chosenPlan?.id; if (planId == null) return false; - await chatRepository.complainAboutConsultation(id: planId, body: PlanComplainModel(description: description)); + await chatRepository.complainAboutConsultation( + id: planId, + body: PlanComplainModel(description: description), + ); return true; } catch (e) { log(e.toString()); @@ -295,4 +305,70 @@ class ChatRoomCubit extends Cubit { typingSubscription?.cancel(); super.close(); } + + void deleteMessage(String uuid) { + final currentState = state; + if (currentState is ChatRoomStateLoaded) { + emit(currentState.copyWith(chatMessages: currentState.chatMessages.where((message) => message.uuid != uuid).toList())); + } + } + + Future resendMessage({required String uuid, required String userSlug}) async { + final currentState = state; + if (currentState is ChatRoomStateLoaded) { + final message = currentState.chatMessages.firstWhereOrNull((message) => message.uuid == uuid); + if (message != null) { + try { + AudioPlayer().play((AssetSource('audio/send_message.wav'))); + emit( + (state as ChatRoomStateLoaded).copyWith( + chatMessages: [ + ChatMessageModel( + id: 1, + data: message.data, + mediaData: message.mediaData, + authorId: 1, + updatedAt: DateTime.now(), + isRead: true, + isCurrentUserAuthor: true, + roomId: '', + messageState: MessageState.loading, + uuid: uuid, + ), + ...(state as ChatRoomStateLoaded).chatMessages.where((message) => message.uuid != uuid), + ], + ), + ); + + try { + await chatRepository.sendMessage(data: message.data, files: message.mediaData as List?, slug: userSlug); + emit( + (state as ChatRoomStateLoaded).copyWith( + chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) { + if (elem.uuid == uuid) { + return elem.copyWith(messageState: MessageState.sent); + } + return elem; + }).toList(), + ), + ); + } catch (e) { + emit( + (state as ChatRoomStateLoaded).copyWith( + chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) { + if (elem.uuid == uuid) { + return elem.copyWith(messageState: MessageState.error); + } + return elem; + }).toList(), + ), + ); + } + sl().getChatRooms(); + } catch (e, st) { + log(e.toString(), stackTrace: st); + } + } + } + } } diff --git a/lib/features/chat/presentation/pages/chat_screen.dart b/lib/features/chat/presentation/pages/chat_screen.dart index e8029471..99b29de3 100644 --- a/lib/features/chat/presentation/pages/chat_screen.dart +++ b/lib/features/chat/presentation/pages/chat_screen.dart @@ -13,6 +13,8 @@ import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.da import 'package:eatvio_app/features/chat/presentation/widgets/about_order_button.dart'; import 'package:eatvio_app/features/chat/presentation/widgets/chat_bubble_widget.dart'; import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_cubit.dart'; +import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_state.dart'; import 'package:eatvio_app/core/theme/custom_text_style.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/features/chat/presentation/widgets/notification_banner.dart'; @@ -93,7 +95,10 @@ class _ChatScreenState extends State { if (state is ChatRoomStateLoaded) { return _buildChat(context, state); } else if (state is ChatRoomStateError) { - return Scaffold(appBar: AppBar(), body: DataErrorWidget(onReload: () => chatRoomCubit.initChat(widget.uuid))); + return Scaffold( + appBar: AppBar(), + body: DataErrorWidget(onReload: () => chatRoomCubit.initChat(widget.uuid)), + ); } else { return const Scaffold(body: Loader()); } @@ -170,34 +175,19 @@ class _ChatScreenState extends State { width: 40, height: 40, alignment: Alignment.center, - decoration: const BoxDecoration( - color: Color(0xFFEBF4FF), - shape: BoxShape.circle, - ), + decoration: const BoxDecoration(color: Color(0xFFEBF4FF), shape: BoxShape.circle), child: Text( state.chatRoom.users.receiver.name.split(' ').first[0] + state.chatRoom.users.receiver.name.split(' ')[1][0], - style: const TextStyle( - color: Color(0xFF7F9CF5), - fontWeight: FontWeight.w400, - fontSize: 20, - ), + style: const TextStyle(color: Color(0xFF7F9CF5), fontWeight: FontWeight.w400, fontSize: 20), ), ), const SizedBox(width: 16), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - state.chatRoom.users.receiver.name, - style: CustomTextStyles.titleSmallBold.copyWith( - color: PrimaryColors().black, - ), - ), + Text(state.chatRoom.users.receiver.name, style: CustomTextStyles.titleSmallBold.copyWith(color: PrimaryColors().black)), AnimatedCrossFade( - firstChild: Text( - 'Печатает...', - style: CustomTextStyles.bodySmallMulishPrimary, - ), + firstChild: Text('Печатает...', style: CustomTextStyles.bodySmallMulishPrimary), firstCurve: Curves.easeIn, secondCurve: Curves.easeOut, secondChild: const SizedBox.shrink(), @@ -253,9 +243,9 @@ class _ChatScreenState extends State { if (!result) return; if (!context.mounted) return; context.read().reduceUnreadMessagesCount( - chatId: state.chatRoom.id, - count: unreadMessagesIdsCopy.length, - ); + chatId: state.chatRoom.id, + count: unreadMessagesIdsCopy.length, + ); unreadMessagesIdsCopy.clear(); }); } @@ -266,6 +256,9 @@ class _ChatScreenState extends State { text: state.chatMessages[index].data.trim(), isCurrentUser: isCurrentUserAuthor, mediaData: state.chatMessages[index].mediaData ?? [], + messageState: state.chatMessages[index].messageState, + messageUuid: state.chatMessages[index].uuid ?? '', + userSlug: state.chatRoom.users.receiver.slug, ), ), ], @@ -292,6 +285,9 @@ class _ChatScreenState extends State { text: state.chatMessages[index].data.trim(), isCurrentUser: isCurrentUserAuthor, mediaData: state.chatMessages[index].mediaData ?? [], + messageState: state.chatMessages[index].messageState, + messageUuid: state.chatMessages[index].uuid ?? '', + userSlug: state.chatRoom.users.receiver.slug, ), ); }, @@ -364,11 +360,7 @@ class _ChatScreenState extends State { final imageIndex = _imageIndexAt(pickedFiles, i); context.pushTransparentRoute( - PhotoViewScreen( - imageUrls: imageFiles.map((e) => e.path).toList(), - isLocal: true, - index: imageIndex, - ), + PhotoViewScreen(imageUrls: imageFiles.map((e) => e.path).toList(), isLocal: true, index: imageIndex), ); }, child: Hero( @@ -376,14 +368,7 @@ class _ChatScreenState extends State { transitionOnUserGestures: true, child: ClipRRect( borderRadius: BorderRadius.circular(8), - child: SizedBox( - height: 104, - width: 104, - child: Image.file( - pickedFiles[i], - fit: BoxFit.cover, - ), - ), + child: SizedBox(height: 104, width: 104, child: Image.file(pickedFiles[i], fit: BoxFit.cover)), ), ), ) @@ -400,15 +385,8 @@ class _ChatScreenState extends State { }, child: Container( padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: appTheme.gray50, - shape: BoxShape.circle, - ), - child: const Icon( - size: 20, - Icons.close, - color: Colors.black, - ), + decoration: BoxDecoration(color: appTheme.gray50, shape: BoxShape.circle), + child: const Icon(size: 20, Icons.close, color: Colors.black), ), ), ), @@ -430,7 +408,10 @@ class _ChatScreenState extends State { child: Ink( height: 40, width: 40, - child: Transform.rotate(angle: pi / 4, child: const Icon(Icons.attach_file_rounded, size: 30, color: Color(0xFF858E99))), + child: Transform.rotate( + angle: pi / 4, + child: const Icon(Icons.attach_file_rounded, size: 30, color: Color(0xFF858E99)), + ), ), ), const SizedBox(width: 8), @@ -452,7 +433,9 @@ class _ChatScreenState extends State { hintStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w400, height: 1, color: Color(0xFFAEAEB2)), filled: true, border: OutlineInputBorder( - borderRadius: BorderRadius.circular(100), borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1)), + borderRadius: BorderRadius.circular(100), + borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1), + ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(100), borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1), @@ -468,7 +451,27 @@ class _ChatScreenState extends State { const SizedBox(width: 8), InkWell( customBorder: const CircleBorder(), - onTap: () { + onTap: () async { + // Проверяем, заблокирован ли пользователь + final blockedUsersCubit = sl(); + await blockedUsersCubit.getBlockedUsers(); + final blockedState = blockedUsersCubit.state; + + if (blockedState is BlockedUsersStateLoaded) { + final isBlocked = blockedState.profilesList.any((profile) => profile.slug == state.chatRoom.users.receiver.slug); + if (isBlocked) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Вы не можете отправить сообщение заблокированному пользователю'), + backgroundColor: Colors.red, + ), + ); + } + return; + } + } + if (textEditingController.text.isNotEmpty || pickedFiles.isNotEmpty) { chatRoomCubit.sendMessage( data: textEditingController.text.isEmpty ? null : textEditingController.text.trim(), @@ -528,13 +531,7 @@ class _ChatScreenState extends State { const Icon(Icons.attach_file, size: 32, color: Colors.black54), Padding( padding: const EdgeInsets.symmetric(horizontal: 4), - child: Text( - fileName, - textAlign: TextAlign.center, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 12), - ), + child: Text(fileName, textAlign: TextAlign.center, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12)), ), ], ), @@ -582,13 +579,7 @@ class _ChatScreenState extends State { // await chatRoomCubit.sendMessage(files: [File(image.path)], userSlug: slug); // } }, - child: const Row( - children: [ - Icon(Icons.photo_camera), - SizedBox(width: 8), - Text('Фото'), - ], - ), + child: const Row(children: [Icon(Icons.photo_camera), SizedBox(width: 8), Text('Фото')]), ), CupertinoActionSheetAction( onPressed: () async { @@ -603,13 +594,7 @@ class _ChatScreenState extends State { // await chatRoomCubit.sendMessage(files: files, userSlug: slug); } }, - child: const Row( - children: [ - Icon(Icons.photo_library_rounded), - SizedBox(width: 8), - Text('Галерея'), - ], - ), + child: const Row(children: [Icon(Icons.photo_library_rounded), SizedBox(width: 8), Text('Галерея')]), ), CupertinoActionSheetAction( onPressed: () async { @@ -646,20 +631,10 @@ class _ChatScreenState extends State { // await chatRoomCubit.sendMessage(files: files, userSlug: slug); } }, - child: const Row( - children: [ - Icon(Icons.file_present_rounded), - SizedBox(width: 8), - Text('Файлы'), - ], - ), + child: const Row(children: [Icon(Icons.file_present_rounded), SizedBox(width: 8), Text('Файлы')]), ), ], - cancelButton: CupertinoActionSheetAction( - onPressed: () => Navigator.of(context).pop(), - isDestructiveAction: true, - child: const Text('Отмена'), - ), + cancelButton: CupertinoActionSheetAction(onPressed: () => Navigator.of(context).pop(), isDestructiveAction: true, child: const Text('Отмена')), ), ); } @@ -668,7 +643,10 @@ class _ChatScreenState extends State { return Center( child: Padding( padding: const EdgeInsets.only(top: 32), - child: Text(DateFormat.yMMMMd('RU').format(date), style: const TextStyle(height: 1, color: Color(0xFF8393A1), fontWeight: FontWeight.w500)), + child: Text( + DateFormat.yMMMMd('RU').format(date), + style: const TextStyle(height: 1, color: Color(0xFF8393A1), fontWeight: FontWeight.w500), + ), ), ); } diff --git a/lib/features/chat/presentation/pages/empty_chat_screen.dart b/lib/features/chat/presentation/pages/empty_chat_screen.dart index 7b543f86..d6a87c46 100644 --- a/lib/features/chat/presentation/pages/empty_chat_screen.dart +++ b/lib/features/chat/presentation/pages/empty_chat_screen.dart @@ -12,6 +12,8 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_cubit.dart'; +import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_state.dart'; class EmptyChatScreen extends StatefulWidget { final Author author; @@ -133,21 +135,45 @@ class _EmptyChatScreenState extends State { InkWell( customBorder: const CircleBorder(), onTap: () async { + // Проверяем, заблокирован ли пользователь + final blockedUsersCubit = sl(); + await blockedUsersCubit.getBlockedUsers(); + final blockedState = blockedUsersCubit.state; + + if (blockedState is BlockedUsersStateLoaded) { + final isBlocked = blockedState.profilesList.any((profile) => profile.slug == widget.author.authorSlug); + if (isBlocked) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Вы не можете отправить сообщение заблокированному пользователю'), + backgroundColor: Colors.red, + ), + ); + } + return; + } + } + if (textEditingController.text.isNotEmpty) { await sl().sendMessage( data: textEditingController.text.trim(), slug: widget.author.authorSlug, ); - setState(() { - textEditingController.clear(); - }); + if (mounted) { + setState(() { + textEditingController.clear(); + }); + } final chatIdOrError = await sl().getChatByUserSlug(widget.author.authorSlug); - AppRouter.navigatorKey.currentContext?.pushReplacementNamed( - 'chatScreen', - extra: { - 'uuid': chatIdOrError, - }, - ); + if (context.mounted) { + AppRouter.navigatorKey.currentContext?.pushReplacementNamed( + 'chatScreen', + extra: { + 'uuid': chatIdOrError, + }, + ); + } } }, child: Ink( @@ -175,20 +201,44 @@ class _EmptyChatScreenState extends State { CupertinoActionSheetAction( onPressed: () async { Navigator.of(context).pop(); + + // Проверяем, заблокирован ли пользователь + final blockedUsersCubit = sl(); + await blockedUsersCubit.getBlockedUsers(); + final blockedState = blockedUsersCubit.state; + + if (blockedState is BlockedUsersStateLoaded) { + final isBlocked = blockedState.profilesList.any((profile) => profile.slug == widget.author.authorSlug); + if (isBlocked) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Вы не можете отправить сообщение заблокированному пользователю'), + backgroundColor: Colors.red, + ), + ); + } + return; + } + } + final picker = ImagePicker(); - XFile? image = await picker.pickImage(source: ImageSource.camera); - if (image != null) { + List? images = await picker.pickMultiImage(); + if (images.isNotEmpty) { + List files = images.map((image) => File(image.path)).toList(); await sl().sendMessage( - files: [File(image.path)], + files: files, slug: widget.author.authorSlug, ); final chatIdOrError = await sl().getChatByUserSlug(widget.author.authorSlug); - AppRouter.navigatorKey.currentContext?.pushReplacementNamed( - 'chatScreen', - extra: { - 'uuid': chatIdOrError, - }, - ); + if (mounted) { + AppRouter.navigatorKey.currentContext?.pushReplacementNamed( + 'chatScreen', + extra: { + 'uuid': chatIdOrError, + }, + ); + } } }, child: const Row( @@ -211,12 +261,14 @@ class _EmptyChatScreenState extends State { slug: widget.author.authorSlug, ); final chatIdOrError = await sl().getChatByUserSlug(widget.author.authorSlug); - AppRouter.navigatorKey.currentContext?.pushReplacementNamed( - 'chatScreen', - extra: { - 'uuid': chatIdOrError, - }, - ); + if (mounted) { + AppRouter.navigatorKey.currentContext?.pushReplacementNamed( + 'chatScreen', + extra: { + 'uuid': chatIdOrError, + }, + ); + } } }, child: const Row( @@ -261,12 +313,14 @@ class _EmptyChatScreenState extends State { slug: widget.author.authorSlug, ); final chatIdOrError = await sl().getChatByUserSlug(widget.author.authorSlug); - AppRouter.navigatorKey.currentContext?.pushReplacementNamed( - 'chatScreen', - extra: { - 'uuid': chatIdOrError, - }, - ); + if (mounted) { + AppRouter.navigatorKey.currentContext?.pushReplacementNamed( + 'chatScreen', + extra: { + 'uuid': chatIdOrError, + }, + ); + } } }, child: const Row( diff --git a/lib/features/chat/presentation/pages/photo_view_screen.dart b/lib/features/chat/presentation/pages/photo_view_screen.dart index 260c051d..1c17d40e 100644 --- a/lib/features/chat/presentation/pages/photo_view_screen.dart +++ b/lib/features/chat/presentation/pages/photo_view_screen.dart @@ -1,5 +1,3 @@ -import 'dart:io'; - import 'package:cached_network_image/cached_network_image.dart'; import 'package:dismissible_page/dismissible_page.dart'; import 'package:eatvio_app/core/const.dart'; @@ -11,7 +9,7 @@ import 'package:photo_view/photo_view_gallery.dart'; import 'package:url_launcher/url_launcher.dart'; class PhotoViewScreen extends StatefulWidget { - final List imageUrls; + final List imageUrls; final int index; final bool isLocal; @@ -50,14 +48,12 @@ class _PhotoViewScreenState extends State { ), scrollPhysics: const BouncingScrollPhysics(), builder: (BuildContext context, int index) { + final imageProvider = widget.isLocal + ? FileImage(widget.imageUrls[index]) as ImageProvider + : CachedNetworkImageProvider(widget.imageUrls[index]) as ImageProvider; + return PhotoViewGalleryPageOptions( - imageProvider: widget.isLocal - ? FileImage( - File(widget.imageUrls[index]), - ) as ImageProvider - : CachedNetworkImageProvider( - widget.imageUrls[index], - ), + imageProvider: imageProvider, initialScale: PhotoViewComputedScale.contained, minScale: PhotoViewComputedScale.contained, heroAttributes: PhotoViewHeroAttributes( diff --git a/lib/features/chat/presentation/widgets/chat_bubble_widget.dart b/lib/features/chat/presentation/widgets/chat_bubble_widget.dart index d2f5f1bd..eb387691 100644 --- a/lib/features/chat/presentation/widgets/chat_bubble_widget.dart +++ b/lib/features/chat/presentation/widgets/chat_bubble_widget.dart @@ -5,8 +5,13 @@ import 'package:dismissible_page/dismissible_page.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/utils/date_time_utils.dart'; +import 'package:eatvio_app/features/chat/models/chat_message_model.dart'; +import 'package:eatvio_app/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart'; import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart'; +import 'package:eatvio_app/features/chat/presentation/widgets/message_action_bottom_sheet.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_svg/svg.dart'; import 'package:url_launcher/url_launcher.dart'; class ChatBubbleWidget extends StatelessWidget { @@ -17,6 +22,9 @@ class ChatBubbleWidget extends StatelessWidget { required this.isCurrentUser, required this.mediaData, required this.isRead, + required this.messageState, + required this.messageUuid, + required this.userSlug }); final DateTime sendTime; @@ -24,7 +32,9 @@ class ChatBubbleWidget extends StatelessWidget { final bool isCurrentUser; final bool isRead; final List mediaData; - + final MessageState messageState; + final String messageUuid; + final String userSlug; @override Widget build(BuildContext context) { final size = MediaQuery.sizeOf(context); @@ -38,17 +48,33 @@ class ChatBubbleWidget extends StatelessWidget { mainAxisAlignment: isCurrentUser ? MainAxisAlignment.end : MainAxisAlignment.start, children: [ if (isCurrentUser) - Padding( - padding: const EdgeInsets.only(right: 4, bottom: 4), - child: Text( - sendTime.format('HH:mm', 'ru'), - style: const TextStyle( - color: Color(0xFF7B8794), - height: 1, - fontSize: 10, - ), + switch (messageState) { + MessageState.loading => Padding( + padding: const EdgeInsets.only(right: 4, bottom: 4), + child: SizedBox(width: 10, height: 10, child: CircularProgressIndicator(strokeWidth: 2, color: appTheme.blueA400)), ), - ), + + MessageState.sent => Padding( + padding: const EdgeInsets.only(right: 4, bottom: 4), + child: Text(sendTime.format('HH:mm', 'ru'), style: const TextStyle(color: Color(0xFF7B8794), height: 1, fontSize: 10)), + ), + + MessageState.error => GestureDetector( + onTap: () { + showMessageActionsBottomSheet( + context, + resend: () { + context.read().resendMessage(uuid: messageUuid, userSlug: userSlug); + }, + delete: () { + context.read().deleteMessage(messageUuid); + }, + ); + }, + child: Padding(padding: const EdgeInsets.only(right: 4, bottom: 4), child: SvgPicture.asset('assets/icons/error_icon.svg')), + ), + }, + Container( constraints: BoxConstraints(maxWidth: size.width * 0.7), decoration: BoxDecoration( @@ -87,33 +113,19 @@ class ChatBubbleWidget extends StatelessWidget { throw Exception('Could not launch ${mediaData[index].toString()}'); } }, - leading: const Icon( - Icons.file_copy_outlined, - color: Colors.black, - ), - trailing: const Icon( - Icons.download_outlined, - color: Colors.black, - ), + leading: const Icon(Icons.file_copy_outlined, color: Colors.black), + trailing: const Icon(Icons.download_outlined, color: Colors.black), title: Text( mediaData[index].toString().replaceAll('chat_files/', ''), maxLines: 2, - style: const TextStyle( - color: Colors.black, - fontSize: 12, - overflow: TextOverflow.ellipsis, - ), + style: const TextStyle(color: Colors.black, fontSize: 12, overflow: TextOverflow.ellipsis), ), ); } return GestureDetector( onTap: () { context.pushTransparentRoute( - PhotoViewScreen( - imageUrls: mediaData.map((e) => '$e').toList(), - index: index, - isLocal: mediaData[index] is File ? true : false, - ), + PhotoViewScreen(imageUrls: mediaData, index: index, isLocal: mediaData[index] is File ? true : false), ); }, child: Hero( @@ -121,11 +133,7 @@ class ChatBubbleWidget extends StatelessWidget { transitionOnUserGestures: true, child: ClipRRect( borderRadius: BorderRadius.circular(16), - child: mediaData[index] is File - ? Image.file(mediaData[index]) - : CachedNetworkImage( - imageUrl: '${mediaData[index]}', - ), + child: mediaData[index] is File ? Image.file(mediaData[index]) : CachedNetworkImage(imageUrl: '${mediaData[index]}'), ), ), ); @@ -137,11 +145,7 @@ class ChatBubbleWidget extends StatelessWidget { ), ), if (mediaData.isNotEmpty && text.isNotEmpty) const SizedBox(height: 8), - if (text.isNotEmpty) - Text( - text, - style: TextStyles.textFS13FW400.copyWith(color: appTheme.black, fontSize: 16), - ), + if (text.isNotEmpty) Text(text, style: TextStyles.textFS13FW400.copyWith(color: appTheme.black, fontSize: 16)), ], ), ), @@ -151,12 +155,7 @@ class ChatBubbleWidget extends StatelessWidget { padding: const EdgeInsets.only(left: 4, bottom: 4), child: Text( sendTime.format('HH:mm', 'ru'), - style: const TextStyle( - color: Color(0xFF7B8794), - height: 1, - fontSize: 10, - fontWeight: FontWeight.w500, - ), + style: const TextStyle(color: Color(0xFF7B8794), height: 1, fontSize: 10, fontWeight: FontWeight.w500), ), ), ], diff --git a/lib/features/chat/presentation/widgets/message_action_bottom_sheet.dart b/lib/features/chat/presentation/widgets/message_action_bottom_sheet.dart new file mode 100644 index 00000000..1f455764 --- /dev/null +++ b/lib/features/chat/presentation/widgets/message_action_bottom_sheet.dart @@ -0,0 +1,48 @@ +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/core/widgets/bottom_sheet_action_widget.dart'; +import 'package:eatvio_app/core/widgets/bottom_sheet_handler_bar.dart'; +import 'package:eatvio_app/core/widgets/custom_divider.dart'; +import 'package:eatvio_app/generated/l10n.dart'; +import 'package:flutter/material.dart'; + +void showMessageActionsBottomSheet(BuildContext context, {required void Function() resend, required void Function() delete}) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => Container( + margin: EdgeInsets.only(left: 8, right: 8, bottom: MediaQuery.of(context).padding.bottom + 20), + decoration: BoxDecoration(borderRadius: BorderRadius.circular(12), color: appTheme.whiteA700), + child: MessageActionsBottomSheet(resend: resend, delete: delete), + ), + ); +} + +class MessageActionsBottomSheet extends StatefulWidget { + const MessageActionsBottomSheet({super.key, required this.resend, required this.delete}); + final void Function() resend; + final void Function() delete; + + @override + State createState() => _MessageActionsBottomSheet(); +} + +class _MessageActionsBottomSheet extends State { + @override + Widget build(BuildContext context) { + final s = S.of(context); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 7), + const BottomSheetHandlerBar(), + BottomSheetActionWidget(text: s.resend, iconPath: 'assets/icons/resend.svg', onTap: widget.resend), + const CustomDivider(), + BottomSheetActionWidget(text: s.delete, iconPath: 'assets/icons/trash_can.svg', onTap: widget.delete, color: appTheme.red), + ], + ), + ); + } +} diff --git a/lib/features/chat/repositories/chat_repository.dart b/lib/features/chat/repositories/chat_repository.dart index d682c9a0..b1961b2a 100644 --- a/lib/features/chat/repositories/chat_repository.dart +++ b/lib/features/chat/repositories/chat_repository.dart @@ -63,7 +63,8 @@ class ChatRepository { Future getChatRoom(String uuid) async { try { return (await chatService.getChatRoom(uuid: uuid)); - } catch (e) { + } catch (e, st) { + log(e.toString(), stackTrace: st); rethrow; } } diff --git a/lib/features/chat/services/chat_service.dart b/lib/features/chat/services/chat_service.dart index dee63eab..89d65749 100644 --- a/lib/features/chat/services/chat_service.dart +++ b/lib/features/chat/services/chat_service.dart @@ -13,18 +13,18 @@ part 'chat_service.g.dart'; abstract class ChatService { factory ChatService(Dio dio, {String baseUrl}) = _ChatService; - @GET('/api/chat/index') + @GET('/api/v2/chat/index') Future getChatRooms({ @Query('page') required int page, - @Query('find') String? query, + @Query('filters[data][\$conversation_name]') String? query, }); - @GET('/api/chat/show/{uuid}') + @GET('/api/v2/chat/show/{uuid}') Future getChatRoom({ @Path() required String uuid, }); - @POST('/api/chat/message/send/{slug}') + @POST('/api/v2/chat/message/send/{slug}') @MultiPart() Future sendMessage({ @Header('X-Socket-ID') required String socketId, @@ -33,13 +33,13 @@ abstract class ChatService { @Part(name: 'media_data[]', contentType: 'multipart/form-data') List? mediaData, }); - @GET('/api/chat/show/{id}/messages?page={page}') + @GET('/api/v2/chat/show/{id}/messages?page={page}') Future getChatMessages({ @Path() required String id, @Path() required int page, }); - @GET('/api/chat/get_by_user/{user}') + @GET('/api/v2/chat/get_by_user/{user}') Future getChatRoomIdByUserSlug({ @Path() required String user, }); @@ -82,7 +82,7 @@ abstract class ChatService { @Body() required PlanComplainModel body, }); - @GET('/api/v2/chat/get_by_user/{slug}') + @GET('/api/chat/get_by_user/{slug}') Future getChatByUserSlug({ @Path() required String slug, }); diff --git a/lib/features/complaining/presentation/complaining_bottom_sheet.dart b/lib/features/complaining/presentation/complaining_bottom_sheet.dart index e3f42616..aa5e1b90 100644 --- a/lib/features/complaining/presentation/complaining_bottom_sheet.dart +++ b/lib/features/complaining/presentation/complaining_bottom_sheet.dart @@ -9,6 +9,7 @@ import 'package:eatvio_app/core/widgets/custom_text_form_field.dart'; import 'package:eatvio_app/core/widgets/loader.dart'; import 'package:eatvio_app/features/complaining/presentation/cubit/complaining_cubit.dart'; import 'package:eatvio_app/features/complaining/presentation/cubit/complaining_state.dart'; +import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -194,63 +195,74 @@ class _LawViolationWidgetState extends State<_LawViolationWidget> { @override Widget build(BuildContext context) { final s = S.of(context); - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - const BottomSheetHandlerBar(), - const SizedBox(height: 20), - Text( - s.lawViolation, - style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black), - ), - const SizedBox(height: 8), - SizedBox( - width: MediaQuery.sizeOf(context).width * 0.7, - child: Text( - s.what_do_you_want_to_complaint_about, - textAlign: TextAlign.center, - style: TextStyles.textMMedium.copyWith(color: appTheme.black), - ), - ), - const SizedBox(height: 24), - ...tags.mapIndexed((index, element) { - return Row( - children: [ - Checkbox( - activeColor: appTheme.blueA400, - side: BorderSide(width: 2, color: tags[index].$1 ? appTheme.blueA400 : appTheme.blueGray), - value: element.$1, - onChanged: (isChecked) { - setState(() { - if (isChecked == null) return; - if (index == tags.length - 1 && !isChecked) { - context.read().toggleDescription(); - } - tags[index] = (isChecked, element.$2); - final activeTags = tags.where((elem) => elem.$1).map((e) => e.$2).toList(); - context.read().setTags(activeTags); - }); + return BlocBuilder( + builder: (context, state) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const BottomSheetHandlerBar(), + const SizedBox(height: 20), + Text( + s.lawViolation, + style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black), + ), + const SizedBox(height: 8), + SizedBox( + width: MediaQuery.sizeOf(context).width * 0.7, + child: Text( + s.what_do_you_want_to_complaint_about, + textAlign: TextAlign.center, + style: TextStyles.textMMedium.copyWith(color: appTheme.black), + ), + ), + const SizedBox(height: 24), + ...tags.mapIndexed((index, element) { + return Row( + children: [ + Checkbox( + activeColor: appTheme.blueA400, + side: BorderSide(width: 2, color: tags[index].$1 ? appTheme.blueA400 : appTheme.blueGray), + value: element.$1, + onChanged: (isChecked) { + setState(() { + if (isChecked == null) return; + if (index == tags.length - 1 && !isChecked) { + context.read().toggleDescription(); + } + tags[index] = (isChecked, element.$2); + final activeTags = tags.where((elem) => elem.$1).map((e) => e.$2).toList(); + context.read().setTags(activeTags); + }); + }, + ), + Text( + element.$2, + style: TextStyles.textLSemibold.copyWith(color: appTheme.black), + ), + ], + ); + }), + if (tags.last.$1) ...[ + const SizedBox(height: 12), + CustomTextFormField( + hintText: s.type_here_your_complaint, + hasError: (state.complainInfo.description?.length ?? 10) < 10, + onChanged: (value) { + context.read().setDescription(value); }, ), - Text( - element.$2, - style: TextStyles.textLSemibold.copyWith(color: appTheme.black), - ), + if ((state.complainInfo.description?.length ?? 10) < 10) ...[ + const SizedBox(height: 4), + const ValidationErrorWidget(error: 'Минимум 10 символов'), + ] ], - ); - }), - if (tags.last.$1) ...[ - const SizedBox(height: 12), - CustomTextFormField( - hintText: s.type_here_your_complaint, - onChanged: (value) { - context.read().setDescription(value); - }, - ), - ], - const SizedBox(height: 20), - const _ComplainButton(), - ], + const SizedBox(height: 20), + _ComplainButton( + isDisabled: (state.complainInfo.description?.length ?? 0) < 10, + ), + ], + ); + }, ); } } @@ -515,8 +527,8 @@ class _ComplaintFailedWidget extends StatelessWidget { } class _ComplainButton extends StatelessWidget { - const _ComplainButton(); - + const _ComplainButton({this.isDisabled = false}); + final bool isDisabled; @override Widget build(BuildContext context) { final s = S.of(context); @@ -534,6 +546,10 @@ class _ComplainButton extends StatelessWidget { backgroundColor: WidgetStatePropertyAll(appTheme.blueA400), ), onPressed: () async { + if (isDisabled) { + context.read().setDescription(''); + return; + } await context.read().complain(); }, ); diff --git a/lib/features/consultant/become_consultant/presentation/pages/last_step_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/last_step_screen.dart index b5afd001..d44920cb 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/last_step_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/last_step_screen.dart @@ -25,24 +25,16 @@ class _LastStepScreenState extends State { children: [ const Padding( padding: EdgeInsets.symmetric(horizontal: 16), - child: Text( - 'Последний шаг', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.w700, - height: 1.1, - ), - ), + child: Text('Последний шаг', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, height: 1.1)), ), const SizedBox(height: 16), CheckboxListTile( value: checkboxState, - title: const Text( - 'Я ознакомлен и принимаю договор публичной офёрты', - style: TextStyle( - fontSize: 18, - height: 1, - ), + title: Text( + hidePlans + ? 'Я ознакомлен и принимаю политику конфиденциальности и защиту персональных данных' + : 'Я ознакомлен и принимаю договор публичной офёрты', + style: const TextStyle(fontSize: 18, height: 1), ), onChanged: (_) { setState(() { @@ -53,25 +45,14 @@ class _LastStepScreenState extends State { activeColor: appTheme.blueA400, contentPadding: const EdgeInsets.symmetric(horizontal: 16), ), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 16, - ), - child: _divider(context), - ), + Padding(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), child: _divider(context)), Padding( padding: const EdgeInsets.symmetric(horizontal: 4), child: TextButton( - onPressed: () async => await launchUrl(Uri.parse('$baseUrl/oferta_bs.pdf')), - child: const Text( - 'Договор офёрты', - style: TextStyle( - height: 1, - fontSize: 16, - color: Color(0xFF323F4B), - decoration: TextDecoration.underline, - ), + onPressed: () async => await launchUrl(Uri.parse(hidePlans ? '$baseUrl/docs/privacy.pdf' : '$baseUrl/oferta_bs.pdf')), + child: Text( + hidePlans ? 'Политика конфиденциальности и пользовательское соглашение' : 'Договор офёрты', + style: const TextStyle(height: 1, fontSize: 16, color: Color(0xFF323F4B), decoration: TextDecoration.underline), ), ), ), @@ -84,10 +65,7 @@ class _LastStepScreenState extends State { return Container( height: 0.4, width: MediaQuery.of(context).size.width - 32, - decoration: BoxDecoration( - color: Colors.black26, - borderRadius: BorderRadius.circular(16), - ), + decoration: BoxDecoration(color: Colors.black26, borderRadius: BorderRadius.circular(16)), ); } } diff --git a/lib/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart index 76b90a26..b8fed20b 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart @@ -23,81 +23,105 @@ class _RegistrationAsScreenState extends State { children: [ const Padding( padding: EdgeInsets.symmetric(horizontal: 16), - child: Text( - 'Регистрация в качестве', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.w700, - height: 1.1, - ), - ), + child: Text('Регистрация в качестве', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, height: 1.1)), ), const SizedBox(height: 16), - RadioListTile( - value: RegistrationAsType.physical, - groupValue: registrationAsType, - title: const Text( - 'Физического лица', - style: TextStyle( - fontSize: 18, - height: 1, + Column( + children: [ + Row( + children: [ + Radio( + value: RegistrationAsType.physical, + // ignore: deprecated_member_use + groupValue: registrationAsType, + activeColor: appTheme.blueA400, + // ignore: deprecated_member_use + onChanged: (value) { + setState(() { + registrationAsType = value!; + }); + }, + ), + const Expanded( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 8), + child: Text('Физического лица', style: TextStyle(fontSize: 18, height: 1)), + ), + ), + ], ), - ), - activeColor: appTheme.blueA400, - onChanged: (value) {}, - contentPadding: const EdgeInsets.symmetric(horizontal: 8), - ), - RadioListTile( - value: RegistrationAsType.selfBusy, - groupValue: registrationAsType, - title: const Text( - 'Самозанятого', - style: TextStyle( - fontSize: 18, - height: 1, + Row( + children: [ + Radio( + value: RegistrationAsType.selfBusy, + // ignore: deprecated_member_use + groupValue: registrationAsType, + activeColor: appTheme.blueA400, + // ignore: deprecated_member_use + onChanged: (value) { + setState(() { + registrationAsType = value!; + }); + }, + ), + const Expanded( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 8), + child: Text('Самозанятого', style: TextStyle(fontSize: 18, height: 1)), + ), + ), + ], ), - ), - onChanged: null, - contentPadding: const EdgeInsets.symmetric(horizontal: 8), - ), - RadioListTile( - value: RegistrationAsType.iP, - groupValue: registrationAsType, - title: const Text( - 'Индивидуального предпринимателя', - style: TextStyle( - fontSize: 18, - height: 1, + Row( + children: [ + Radio( + value: RegistrationAsType.iP, + // ignore: deprecated_member_use + groupValue: registrationAsType, + activeColor: appTheme.blueA400, + // ignore: deprecated_member_use + onChanged: (value) { + setState(() { + registrationAsType = value!; + }); + }, + ), + const Expanded( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 8), + child: Text('Индивидуального предпринимателя', style: TextStyle(fontSize: 18, height: 1)), + ), + ), + ], ), - ), - onChanged: null, - contentPadding: const EdgeInsets.symmetric(horizontal: 8), - ), - RadioListTile( - value: RegistrationAsType.ooo, - groupValue: registrationAsType, - title: const Text( - 'Общества с ограниченной ответственностью', - style: TextStyle( - fontSize: 18, - height: 1, + Row( + children: [ + Radio( + value: RegistrationAsType.ooo, + // ignore: deprecated_member_use + groupValue: registrationAsType, + activeColor: appTheme.blueA400, + // ignore: deprecated_member_use + onChanged: (value) { + setState(() { + registrationAsType = value!; + }); + }, + ), + const Expanded( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 8), + child: Text('Общества с ограниченной ответственностью', style: TextStyle(fontSize: 18, height: 1)), + ), + ), + ], ), - ), - onChanged: null, - contentPadding: const EdgeInsets.symmetric(horizontal: 8), - ), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 16, - ), - child: _divider(context), + ], ), + Padding(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), child: _divider(context)), const Padding( padding: EdgeInsets.symmetric(horizontal: 16), - child: Text( - '* На данный момент регистрация доступна только в качестве физического лица. Остальные варианты скоро появятся', - ), + child: Text('* На данный момент регистрация доступна только в качестве физического лица. Остальные варианты скоро появятся'), ), ], ), @@ -108,10 +132,7 @@ class _RegistrationAsScreenState extends State { return Container( height: 0.4, width: MediaQuery.of(context).size.width - 32, - decoration: BoxDecoration( - color: Colors.black26, - borderRadius: BorderRadius.circular(16), - ), + decoration: BoxDecoration(color: Colors.black26, borderRadius: BorderRadius.circular(16)), ); } } diff --git a/lib/features/consultant_rating/models/rating_model.dart b/lib/features/consultant_rating/models/rating_model.dart index 164008dd..870d1c82 100644 --- a/lib/features/consultant_rating/models/rating_model.dart +++ b/lib/features/consultant_rating/models/rating_model.dart @@ -1,16 +1,42 @@ +import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'rating_model.freezed.dart'; part 'rating_model.g.dart'; @freezed -abstract class RatingModel with _$RatingModel { +abstract class RatingPaginationModel with _$RatingPaginationModel { + factory RatingPaginationModel({ + required List data, + }) = _RatingPaginationModel; + factory RatingPaginationModel.fromJson(Map json) => _$RatingPaginationModelFromJson(json); +} + +@freezed +abstract class RatingModel with _$RatingModel { factory RatingModel({ - required DateTime date, - required String title, - required int amount, + @JsonKey(name: 'created_at', fromJson: parseDate) required DateTime date, + @JsonKey(name: 'log_data') required RatingEnum type, + @JsonKey(name: 'log_scores') required int amount, }) = _RatingModel; factory RatingModel.fromJson(Map json) => _$RatingModelFromJson(json); -} \ No newline at end of file +} + +enum RatingEnum { + @JsonValue('ADDED_RECIPE') + addedRecipe, + @JsonValue('ADDED_PUBLIC_DIET') + addedPublicDiet, + @JsonValue('ADDED_BLOG_POST') + addedBlogPost, + @JsonValue('INVITED_A_NEW_USER') + invitedUser, + @JsonValue('HAS_MEDICAL_DEGREE') + hasMedicalDegree, + @JsonValue('CONSULTED') + consulted, + @JsonValue('PRODUCT_NUTRITINAL_VALUE_APPROVED') + addedGlossary; +} diff --git a/lib/features/consultant_rating/models/statistics_model.dart b/lib/features/consultant_rating/models/statistics_model.dart new file mode 100644 index 00000000..bbac675b --- /dev/null +++ b/lib/features/consultant_rating/models/statistics_model.dart @@ -0,0 +1,19 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'statistics_model.freezed.dart'; +part 'statistics_model.g.dart'; + +@freezed +abstract class StatisticsModel with _$StatisticsModel { + + factory StatisticsModel({ + @JsonKey(name: 'count_referal') int? countReferal, + @JsonKey(name: 'count_blog_posts') int? blogPostsCount, + @JsonKey(name: 'count_recipes') int? recipesCount, + @JsonKey(name: 'count_diets') int? dietsCount, + @JsonKey(name: 'count_consultants') int? consultationsCount, + @JsonKey(name: 'count_products_nutritional_values') int? qwe, + }) = _StatisticsModel; + + factory StatisticsModel.fromJson(Map json) => _$StatisticsModelFromJson(json); +} \ No newline at end of file diff --git a/lib/features/consultant_rating/presentation/consultant_rating_screen.dart b/lib/features/consultant_rating/presentation/consultant_rating_screen.dart index 285e5d63..8c6cb3d4 100644 --- a/lib/features/consultant_rating/presentation/consultant_rating_screen.dart +++ b/lib/features/consultant_rating/presentation/consultant_rating_screen.dart @@ -1,12 +1,16 @@ +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/custom_outlined_button.dart'; -import 'package:eatvio_app/features/consultant_rating/models/rating_model.dart'; +import 'package:eatvio_app/core/widgets/data_error_widget.dart'; +import 'package:eatvio_app/features/consultant_rating/presentation/cubit/consultant_rating_cubit.dart'; +import 'package:eatvio_app/features/consultant_rating/presentation/cubit/consultant_rating_state.dart'; import 'package:eatvio_app/features/consultant_rating/presentation/widgets/rating_history_widget.dart'; import 'package:eatvio_app/features/consultant_rating/presentation/widgets/rating_widget.dart'; import 'package:eatvio_app/features/consultant_rating/presentation/widgets/statistic_widget.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; class ConsultantRatingScreen extends StatelessWidget { static const name = 'consultantRatingScreen'; @@ -15,7 +19,10 @@ class ConsultantRatingScreen extends StatelessWidget { @override Widget build(BuildContext context) { - return const ConsultantRatingPageContent(); + return BlocProvider( + create: (context) => sl()..getConsultantStatistics()..getScoreLogs(), + child: const ConsultantRatingPageContent(), + ); } } @@ -25,52 +32,56 @@ class ConsultantRatingPageContent extends StatelessWidget { @override Widget build(BuildContext context) { final s = S.of(context); - return Scaffold( - backgroundColor: appTheme.gray100, - appBar: AppBar( - centerTitle: true, - title: Text( - s.rating_and_statistics, - style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black), - ), - ), - body: SingleChildScrollView( - child: Column( - children: [ - const SizedBox(height: 16), - const RatingWidget( - rating: 1058, + return BlocBuilder( + builder: (context, state) { + return Scaffold( + backgroundColor: appTheme.gray100, + appBar: AppBar( + centerTitle: true, + title: Text( + s.rating_and_statistics, + style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black), ), - const SizedBox(height: 16), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: CustomOutlinedButton( - text: s.copy_referral_link, - height: 52, - buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700), - buttonStyle: ButtonStyle( - side: const WidgetStatePropertyAll(BorderSide.none), - backgroundColor: WidgetStatePropertyAll(appTheme.blueA400), + ), + body: state.error != null + ? DataErrorWidget(onReload: () async { + await context.read().getConsultantStatistics(); + if (!context.mounted) return; + await context.read().getScoreLogs(); + }) + : SingleChildScrollView( + child: Column( + children: [ + const SizedBox(height: 16), + const RatingWidget( + rating: 1058, + ), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: CustomOutlinedButton( + text: s.copy_referral_link, + height: 52, + buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700), + buttonStyle: ButtonStyle( + side: const WidgetStatePropertyAll(BorderSide.none), + backgroundColor: WidgetStatePropertyAll(appTheme.blueA400), + ), + onPressed: () {}, + ), + ), + const SizedBox(height: 16), + StatisticWidget(isLoading: state.isStatisticsLoading, statistics: state.statistics,), + const SizedBox(height: 16), + RatingHistoryWidget( + isLoading: state.isRatingLoading, + ratingHistory: state.ratings, + ), + ], + ), ), - onPressed: () {}, - ), - ), - const SizedBox(height: 16), - const StatisticWidget(), - const SizedBox(height: 16), - RatingHistoryWidget( - ratingHistory: [ - RatingModel(date: DateTime.now(), title: ' djkasjdasjkdkjasjkdjkaskjdjklasjkld', amount: 201300), - RatingModel(date: DateTime.now(), title: 'djkasjdasjkdkjasjkdjkaskjdjklasjkld', amount: 89323), - RatingModel(date: DateTime.now(), title: 'djkasjdasjkdkjasjkdjkaskjdjklasjkld', amount: 20), - RatingModel(date: DateTime.now(), title: 'djkasjdasjkdkjasjkdjkaskjdjklasjkld', amount: 1212), - RatingModel(date: DateTime.now(), title: 'qweqweeq', amount: 323), - RatingModel(date: DateTime.now(), title: 'ee', amount: 1), - ], - ), - ], - ), - ), + ); + }, ); } } diff --git a/lib/features/consultant_rating/presentation/cubit/consultant_rating_cubit.dart b/lib/features/consultant_rating/presentation/cubit/consultant_rating_cubit.dart new file mode 100644 index 00000000..291f450c --- /dev/null +++ b/lib/features/consultant_rating/presentation/cubit/consultant_rating_cubit.dart @@ -0,0 +1,32 @@ +import 'dart:developer'; + +import 'package:eatvio_app/features/consultant_rating/presentation/cubit/consultant_rating_state.dart'; +import 'package:eatvio_app/features/consultant_rating/repositories/consultant_rating_repository.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class ConsultantRatingCubit extends Cubit { + final ConsultantRatingRepository repository; + ConsultantRatingCubit(this.repository) : super(ConsultantRatingState()); + + Future getConsultantStatistics({String? date}) async { + try { + emit(state.copyWith(isStatisticsLoading: true)); + final statistics = await repository.getConsultantStatistics(date: date); + emit(state.copyWith(isStatisticsLoading: false, statistics: statistics, error: null)); + } catch (e, st) { + emit(state.copyWith(isStatisticsLoading: true, error: e.toString())); + log(e.toString(), stackTrace: st); + } + } + + Future getScoreLogs({String? date}) async { + try { + emit(state.copyWith(isRatingLoading: true)); + final scoreLogs = await repository.getScoreLogs(date: date); + emit(state.copyWith(isRatingLoading: false, ratings: scoreLogs, error: null)); + } catch (e, st) { + emit(state.copyWith(isRatingLoading: true, error: e.toString())); + log(e.toString(), stackTrace: st); + } + } +} diff --git a/lib/features/consultant_rating/presentation/cubit/consultant_rating_state.dart b/lib/features/consultant_rating/presentation/cubit/consultant_rating_state.dart new file mode 100644 index 00000000..20cc458f --- /dev/null +++ b/lib/features/consultant_rating/presentation/cubit/consultant_rating_state.dart @@ -0,0 +1,16 @@ +import 'package:eatvio_app/features/consultant_rating/models/rating_model.dart'; +import 'package:eatvio_app/features/consultant_rating/models/statistics_model.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'consultant_rating_state.freezed.dart'; + +@freezed +abstract class ConsultantRatingState with _$ConsultantRatingState { + factory ConsultantRatingState({ + @Default(false) bool isStatisticsLoading, + @Default(false) bool isRatingLoading, + @Default(null) StatisticsModel? statistics, + @Default(null) String? error, + @Default([]) List ratings, + }) = _ConsultantRatingState; +} diff --git a/lib/features/consultant_rating/presentation/widgets/period_filter_widget.dart b/lib/features/consultant_rating/presentation/widgets/period_filter_widget.dart index 7ad0fa7e..3f15be8e 100644 --- a/lib/features/consultant_rating/presentation/widgets/period_filter_widget.dart +++ b/lib/features/consultant_rating/presentation/widgets/period_filter_widget.dart @@ -1,42 +1,29 @@ import 'package:eatvio_app/core/theme/custom_text_style.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/core/utils/date_time_utils.dart'; import 'package:eatvio_app/core/widgets/custom_drop_down_button.dart'; +import 'package:eatvio_app/features/consultant_rating/presentation/cubit/consultant_rating_cubit.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; class PeriodFilterWidget extends StatefulWidget { - const PeriodFilterWidget({super.key}); - + const PeriodFilterWidget({super.key, required this.isStatistic}); + final bool isStatistic; @override State createState() => _PeriodFilterWidgetState(); } class _PeriodFilterWidgetState extends State { - final List periodDropDownTitles = []; - int periodDropDownIndex = 0; - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - final s = S.of(context); - if (periodDropDownTitles.isEmpty) { - periodDropDownTitles.addAll([ - s.for_whole_period_of_time, - s.for_a_year, - s.for_a_month, - s.for_a_week, - ]); - } - } - + PeriodFilterStatus _currentState = PeriodFilterStatus.all; @override Widget build(BuildContext context) { final s = S.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - CustomDropDownButton( + CustomDropDownButton( hint: Text( s.choose_diet_duration, style: TextStyles.textSMedium.copyWith(color: appTheme.black), @@ -44,24 +31,59 @@ class _PeriodFilterWidgetState extends State { onChanged: (value) { if (value != null) { setState(() { - periodDropDownIndex = periodDropDownTitles.indexOf(value); + _currentState = value; }); + if (widget.isStatistic) { + if (value == PeriodFilterStatus.all) { + context.read().getConsultantStatistics(); + return; + } + context.read().getConsultantStatistics(date: value.value); + return; + } + if (value == PeriodFilterStatus.all) { + context.read().getScoreLogs(); + return; + } + context.read().getScoreLogs(date: value.value); + return; } }, - items: periodDropDownTitles + items: PeriodFilterStatus.values .mapIndexed( (index, value) => DropdownMenuItem( - value: periodDropDownTitles[index], + value: PeriodFilterStatus.values[index], child: Text( - periodDropDownTitles[index], + PeriodFilterStatus.values[index].name, style: TextStyles.textMMedium.copyWith(color: appTheme.black), ), ), ) .toList(), - value: periodDropDownTitles[periodDropDownIndex], + value: _currentState, ), ], ); } } + +enum PeriodFilterStatus { + all, + month, + week, + year; + + String get name => switch (this) { + PeriodFilterStatus.all => 'Все периоды', + PeriodFilterStatus.month => 'За месяц', + PeriodFilterStatus.week => 'За неделю', + PeriodFilterStatus.year => 'За год', + }; + + String? get value => switch (this) { + PeriodFilterStatus.all => null, + PeriodFilterStatus.month => DateTime.now().subtract(const Duration(days: 30)).format('yyyy-MM-dd'), + PeriodFilterStatus.week => DateTime.now().subtract(const Duration(days: 7)).format('yyyy-MM-dd'), + PeriodFilterStatus.year => DateTime.now().subtract(const Duration(days: 365)).format('yyyy-MM-dd'), + }; +} diff --git a/lib/features/consultant_rating/presentation/widgets/rating_history_item.dart b/lib/features/consultant_rating/presentation/widgets/rating_history_item.dart index 3a3cff6e..4bafd1b3 100644 --- a/lib/features/consultant_rating/presentation/widgets/rating_history_item.dart +++ b/lib/features/consultant_rating/presentation/widgets/rating_history_item.dart @@ -11,7 +11,6 @@ class RatingHistoryItem extends StatelessWidget { @override Widget build(BuildContext context) { return Container( - height: 80, padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: appTheme.gray100, @@ -29,7 +28,7 @@ class RatingHistoryItem extends StatelessWidget { date.format('dd.MM.yyyy'), style: TextStyles.textXSSemibold.copyWith(color: appTheme.gray), ), - const Spacer(), + const SizedBox(height: 12), Text( title, style: TextStyles.textMMedium.copyWith(color: appTheme.black), diff --git a/lib/features/consultant_rating/presentation/widgets/rating_history_widget.dart b/lib/features/consultant_rating/presentation/widgets/rating_history_widget.dart index 0413dda5..d6f8969b 100644 --- a/lib/features/consultant_rating/presentation/widgets/rating_history_widget.dart +++ b/lib/features/consultant_rating/presentation/widgets/rating_history_widget.dart @@ -1,5 +1,6 @@ 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/loader.dart'; import 'package:eatvio_app/core/widgets/main_background_bouble.dart'; import 'package:eatvio_app/features/consultant_rating/models/rating_model.dart'; import 'package:eatvio_app/features/consultant_rating/presentation/widgets/period_filter_widget.dart'; @@ -9,7 +10,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; class RatingHistoryWidget extends StatelessWidget { - const RatingHistoryWidget({super.key, required this.ratingHistory}); + const RatingHistoryWidget({super.key, required this.ratingHistory, required this.isLoading}); + final bool isLoading; final List ratingHistory; @override Widget build(BuildContext context) { @@ -20,50 +22,60 @@ class RatingHistoryWidget extends StatelessWidget { right: 16, top: 16, ), - child: Column( - children: [ - Row( - children: [ - Text( - s.rating_history, - style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black), - ), - const Spacer(), - SvgPicture.asset( - 'assets/icons/question_mark.svg', - height: 22, - width: 22, - colorFilter: ColorFilter.mode( - appTheme.blueA400, - BlendMode.srcIn, + child: isLoading + ? const Center(child: Loader()) + : Column( + children: [ + Row( + children: [ + Text( + s.rating_history, + style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black), + ), + const Spacer(), + SvgPicture.asset( + 'assets/icons/question_mark.svg', + height: 22, + width: 22, + colorFilter: ColorFilter.mode( + appTheme.blueA400, + BlendMode.srcIn, + ), + ), + ], ), - ), - ], - ), - const SizedBox( - height: 16, - ), - const PeriodFilterWidget(), - const SizedBox( - height: 12, - ), - ListView.builder( - physics: const NeverScrollableScrollPhysics(), - itemBuilder: (context, index) { - return Padding( - padding: index == ratingHistory.length - 1 ? EdgeInsets.zero : const EdgeInsets.only(bottom: 12), - child: RatingHistoryItem( - title: ratingHistory[index].title, - amount: ratingHistory[index].amount, - date: ratingHistory[index].date, + const SizedBox( + height: 16, ), - ); - }, - itemCount: ratingHistory.length, - shrinkWrap: true, - ), - ], - ), + const PeriodFilterWidget(isStatistic: false), + const SizedBox( + height: 12, + ), + ListView.builder( + physics: const NeverScrollableScrollPhysics(), + itemBuilder: (context, index) { + return Padding( + padding: index == ratingHistory.length - 1 ? EdgeInsets.zero : const EdgeInsets.only(bottom: 12), + child: RatingHistoryItem( + title: switch (ratingHistory[index].type) { + RatingEnum.addedRecipe => 'Добавление рецепта', + RatingEnum.addedPublicDiet => 'Добавление публичной диеты', + RatingEnum.addedBlogPost => 'Добавление поста', + RatingEnum.invitedUser => 'Регистрация по реф. ссылке', + RatingEnum.hasMedicalDegree => 'Получение мед. степени', + RatingEnum.consulted => 'Консультация', + RatingEnum.addedGlossary => 'Добавление продукта в глоссарий', + }, + amount: ratingHistory[index].amount, + date: ratingHistory[index].date, + ), + ); + }, + itemCount: ratingHistory.length, + shrinkWrap: true, + ), + ], + ), ); } } diff --git a/lib/features/consultant_rating/presentation/widgets/statistic_widget.dart b/lib/features/consultant_rating/presentation/widgets/statistic_widget.dart index b7f97e2b..3be44129 100644 --- a/lib/features/consultant_rating/presentation/widgets/statistic_widget.dart +++ b/lib/features/consultant_rating/presentation/widgets/statistic_widget.dart @@ -1,6 +1,8 @@ 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/loader.dart'; import 'package:eatvio_app/core/widgets/main_background_bouble.dart'; +import 'package:eatvio_app/features/consultant_rating/models/statistics_model.dart'; import 'package:eatvio_app/features/consultant_rating/presentation/widgets/period_filter_widget.dart'; import 'package:eatvio_app/features/consultant_rating/presentation/widgets/statistic_bouble.dart'; import 'package:eatvio_app/generated/l10n.dart'; @@ -8,14 +10,15 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; class StatisticWidget extends StatelessWidget { - const StatisticWidget({super.key}); - + const StatisticWidget({super.key, required this.isLoading, required this.statistics}); + final bool isLoading; + final StatisticsModel? statistics; @override Widget build(BuildContext context) { final s = S.of(context); return MainBackgroundBouble( padding: const EdgeInsets.all(16), - child: Column( + child: isLoading ? const Center(child: Loader()) : Column( children: [ Row( children: [ @@ -38,15 +41,17 @@ class StatisticWidget extends StatelessWidget { const SizedBox( height: 16, ), - const PeriodFilterWidget(), + const PeriodFilterWidget( + isStatistic: true, + ), const SizedBox( height: 12, ), Row( children: [ - Expanded(child: StatisticBouble(number: 12, title: s.invited_people_count)), + Expanded(child: StatisticBouble(number: statistics?.countReferal ?? 0, title: s.invited_people_count)), const SizedBox(width: 8), - Expanded(child: StatisticBouble(number: 12, title: s.published_posts_count)), + Expanded(child: StatisticBouble(number: statistics?.blogPostsCount ?? 0, title: s.published_posts_count)), ], ), const SizedBox( @@ -54,9 +59,9 @@ class StatisticWidget extends StatelessWidget { ), Row( children: [ - Expanded(child: StatisticBouble(number: 12, title: s.published_recipes_count)), + Expanded(child: StatisticBouble(number: statistics?.recipesCount ?? 0, title: s.published_recipes_count)), const SizedBox(width: 8), - Expanded(child: StatisticBouble(number: 12, title: s.published_diets_count)), + Expanded(child: StatisticBouble(number: statistics?.dietsCount ?? 0, title: s.published_diets_count)), ], ), const SizedBox( @@ -64,9 +69,9 @@ class StatisticWidget extends StatelessWidget { ), Row( children: [ - Expanded(child: StatisticBouble(number: 12, title: s.consultations_count)), + Expanded(child: StatisticBouble(number: statistics?.consultationsCount ?? 0, title: s.consultations_count)), const SizedBox(width: 8), - Expanded(child: StatisticBouble(number: 12, title: s.active_consultations_count)), + Expanded(child: StatisticBouble(number: statistics?.qwe ?? 0, title: s.active_consultations_count)), ], ), const SizedBox( diff --git a/lib/features/consultant_rating/repositories/consultant_rating_repository.dart b/lib/features/consultant_rating/repositories/consultant_rating_repository.dart new file mode 100644 index 00000000..b4ac3770 --- /dev/null +++ b/lib/features/consultant_rating/repositories/consultant_rating_repository.dart @@ -0,0 +1,26 @@ +import 'package:eatvio_app/features/consultant_rating/models/rating_model.dart'; +import 'package:eatvio_app/features/consultant_rating/models/statistics_model.dart'; +import 'package:eatvio_app/features/consultant_rating/services/consultant_rating_service.dart'; + +class ConsultantRatingRepository { + final ConsultantRatingService service; + + ConsultantRatingRepository(this.service); + + Future getConsultantStatistics({String? date}) async { + try { + return await service.getConsultantStatistics(date: date); + } catch (e) { + rethrow; + } + } + + Future> getScoreLogs({String? date}) async { + try { + final data = (await service.getScoreLogs(date: date)).data; + return data; + } catch (e) { + rethrow; + } + } +} diff --git a/lib/features/consultant_rating/services/consultant_rating_service.dart b/lib/features/consultant_rating/services/consultant_rating_service.dart new file mode 100644 index 00000000..c4412afb --- /dev/null +++ b/lib/features/consultant_rating/services/consultant_rating_service.dart @@ -0,0 +1,18 @@ +import 'package:dio/dio.dart'; +import 'package:eatvio_app/features/consultant_rating/models/rating_model.dart'; +import 'package:eatvio_app/features/consultant_rating/models/statistics_model.dart'; + +import 'package:retrofit/retrofit.dart'; + +part 'consultant_rating_service.g.dart'; + +@RestApi() +abstract class ConsultantRatingService { + factory ConsultantRatingService(Dio dio, {String baseUrl}) = _ConsultantRatingService; + + @GET('/api/consultants/rating/statistics') + Future getConsultantStatistics({@Query('filters[duration][\$gte]') String? date}); + + @GET('/api/consultants/score/logs') + Future getScoreLogs({@Query('filters[duration][\$gte]') String? date}); +} diff --git a/lib/features/consultations/presentation/widgets/consultation_card_widget.dart b/lib/features/consultations/presentation/widgets/consultation_card_widget.dart index 5173ab2d..760a8783 100644 --- a/lib/features/consultations/presentation/widgets/consultation_card_widget.dart +++ b/lib/features/consultations/presentation/widgets/consultation_card_widget.dart @@ -14,10 +14,7 @@ import 'package:go_router/go_router.dart'; class ConsultationCardWidget extends StatefulWidget { final ConsultationModel consultationsModel; - const ConsultationCardWidget({ - super.key, - required this.consultationsModel, - }); + const ConsultationCardWidget({super.key, required this.consultationsModel}); @override State createState() => _ConsultationCardWidgetState(); @@ -32,13 +29,7 @@ class _ConsultationCardWidgetState extends State { 'Дневник заполнен, прокомментирован консультантом и после этого клиент просмотрел комментарии.', ]; - final List legendColors = const [ - Colors.white, - Color(0xFFF0C497), - Color(0xFFFF9500), - Color(0xFF2F80ED), - Color(0xFF4CD964), - ]; + final List legendColors = const [Colors.white, Color(0xFFF0C497), Color(0xFFFF9500), Color(0xFF2F80ED), Color(0xFF4CD964)]; late final List<(int, DiaryModel)> givenDaysWithDayNumber; final List> legendStates = [ @@ -46,7 +37,7 @@ class _ConsultationCardWidgetState extends State { [DiaryStateEnum.started, DiaryStateEnum.lastDayStarted], [DiaryStateEnum.finished, DiaryStateEnum.lastDayFinished], [DiaryStateEnum.commented, DiaryStateEnum.lastDayCommented], - [DiaryStateEnum.reviewedByUser] + [DiaryStateEnum.reviewedByUser], ]; @override void initState() { @@ -72,32 +63,19 @@ class _ConsultationCardWidgetState extends State { children: [ Row( children: [ - CircleAvatar( - radius: 30, - backgroundImage: NetworkImage(baseUrl + widget.consultationsModel.userProfilePhotoUrl), - ), + CircleAvatar(radius: 30, backgroundImage: NetworkImage(baseUrl + widget.consultationsModel.userProfilePhotoUrl)), const SizedBox(width: 12), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( widget.consultationsModel.userName, - style: const TextStyle( - height: 1, - color: Color(0xFF323F4B), - fontSize: 16, - fontWeight: FontWeight.w600, - ), + style: const TextStyle(height: 1, color: Color(0xFF323F4B), fontSize: 16, fontWeight: FontWeight.w600), ), const SizedBox(height: 4), Text( widget.consultationsModel.planTitle, - style: const TextStyle( - height: 1, - color: Color(0xFF88949F), - fontSize: 14, - fontWeight: FontWeight.w500, - ), + style: const TextStyle(height: 1, color: Color(0xFF88949F), fontSize: 14, fontWeight: FontWeight.w500), ), ], ), @@ -127,12 +105,7 @@ class _ConsultationCardWidgetState extends State { children: [ Text( 'День ${widget.consultationsModel.currentDiaryDay} из ${widget.consultationsModel.duration}', - style: const TextStyle( - height: 1, - color: Color(0xFF323F4B), - fontSize: 16, - fontWeight: FontWeight.w500, - ), + style: const TextStyle(height: 1, color: Color(0xFF323F4B), fontSize: 16, fontWeight: FontWeight.w500), ), GestureDetector( onTap: () { @@ -149,12 +122,7 @@ class _ConsultationCardWidgetState extends State { children: [ const Text( 'Легенда', - style: TextStyle( - height: 1, - fontSize: 20, - fontWeight: FontWeight.w700, - color: Color(0xFF323F4B), - ), + style: TextStyle(height: 1, fontSize: 20, fontWeight: FontWeight.w700, color: Color(0xFF323F4B)), ), const SizedBox(height: 16), ListView.separated( @@ -170,11 +138,7 @@ class _ConsultationCardWidgetState extends State { ), ); }, - child: const Icon( - Icons.help_outline_rounded, - color: Color(0xFF323F4B), - size: 20, - ), + child: const Icon(Icons.help_outline_rounded, color: Color(0xFF323F4B), size: 20), ), ], ), @@ -185,7 +149,8 @@ class _ConsultationCardWidgetState extends State { children: List.generate(widget.consultationsModel.duration, (index) { final day = givenDaysWithDayNumber.firstWhereOrNull((e) => e.$1 == index); final hasActivity = day != null && day.$2.state != DiaryStateEnum.init; - final canConsultantOpenThisDay = day != null && + final canConsultantOpenThisDay = + day != null && (day.$2.state != DiaryStateEnum.init && day.$2.state != DiaryStateEnum.started && day.$2.state != DiaryStateEnum.lastDayStarted); return Padding( padding: const EdgeInsets.only(right: 12), @@ -195,81 +160,74 @@ class _ConsultationCardWidgetState extends State { InkWell( onTap: () { if (!hasActivity || !canConsultantOpenThisDay) { - showErrorDialog(context, - title: s.you_can_not_open_this_day, description: '${widget.consultationsModel.userName} ${s.has_not_filled_it_yet}'); + showErrorDialog( + context, + title: s.you_can_not_open_this_day, + description: '${widget.consultationsModel.userName} ${s.has_not_filled_it_yet}', + ); return; } - GoRouter.of(context).push(UserDiaryScreen.path, extra: { - 'diaryId': day.$2.id, - 'currentDiaryDay': index + 1, - }); + GoRouter.of(context).push(UserDiaryScreen.path, extra: {'diaryId': day.$2.id, 'currentDiaryDay': index + 1}); }, borderRadius: BorderRadius.circular(100), child: Ink( decoration: BoxDecoration( - color: hasActivity ? appTheme.dividerColor : appTheme.dividerColor.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(100)), + color: hasActivity ? appTheme.dividerColor : appTheme.dividerColor.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(100), + ), width: 58, height: 32, child: Center( child: Text( (index + 1).toString(), - style: TextStyles.textMSemibold.copyWith( - color: hasActivity ? appTheme.gray : appTheme.gray.withValues(alpha: 0.4), - ), + style: TextStyles.textMSemibold.copyWith(color: hasActivity ? appTheme.gray : appTheme.gray.withValues(alpha: 0.4)), ), ), ), ), if (hasActivity) ...[ - const SizedBox( - height: 4, - ), + const SizedBox(height: 4), switch (day.$2.state) { DiaryStateEnum.init => const SizedBox.shrink(), DiaryStateEnum.started => Container( - width: 8, - height: 8, - decoration: BoxDecoration(shape: BoxShape.circle, color: legendColors[1]), - ), + width: 8, + height: 8, + decoration: BoxDecoration(shape: BoxShape.circle, color: legendColors[1]), + ), DiaryStateEnum.lastDayStarted => Container( - width: 8, - height: 8, - decoration: BoxDecoration(shape: BoxShape.circle, color: legendColors[1]), - ), + width: 8, + height: 8, + decoration: BoxDecoration(shape: BoxShape.circle, color: legendColors[1]), + ), DiaryStateEnum.finished => Container( - width: 8, - height: 8, - decoration: BoxDecoration(shape: BoxShape.circle, color: legendColors[2]), - ), + width: 8, + height: 8, + decoration: BoxDecoration(shape: BoxShape.circle, color: legendColors[2]), + ), DiaryStateEnum.lastDayFinished => Container( - width: 8, - height: 8, - decoration: BoxDecoration(shape: BoxShape.circle, color: legendColors[2]), - ), + width: 8, + height: 8, + decoration: BoxDecoration(shape: BoxShape.circle, color: legendColors[2]), + ), DiaryStateEnum.commented => Container( - width: 8, - height: 8, - decoration: BoxDecoration(shape: BoxShape.circle, color: legendColors[3]), - ), + width: 8, + height: 8, + decoration: BoxDecoration(shape: BoxShape.circle, color: legendColors[3]), + ), DiaryStateEnum.lastDayCommented => Container( - width: 8, - height: 8, - decoration: BoxDecoration(shape: BoxShape.circle, color: legendColors[3]), - ), + width: 8, + height: 8, + decoration: BoxDecoration(shape: BoxShape.circle, color: legendColors[3]), + ), DiaryStateEnum.reviewedByUser => Container( - width: 8, - height: 8, - decoration: BoxDecoration(shape: BoxShape.circle, color: legendColors[4]), - ), + width: 8, + height: 8, + decoration: BoxDecoration(shape: BoxShape.circle, color: legendColors[4]), + ), }, - const SizedBox( - height: 12, - ), + const SizedBox(height: 12), ] else - const SizedBox( - height: 24, - ), + const SizedBox(height: 24), ], ), ); @@ -280,11 +238,7 @@ class _ConsultationCardWidgetState extends State { const SizedBox(height: 12), Text( '${widget.consultationsModel.consultationOptionPrice.toStringAsFixed(0)} ₽', - style: const TextStyle( - color: Color(0xFF019A3E), - fontSize: 20, - fontWeight: FontWeight.w600, - ), + style: const TextStyle(color: Color(0xFF019A3E), fontSize: 20, fontWeight: FontWeight.w600), ), const SizedBox(height: 16), Row( @@ -293,36 +247,26 @@ class _ConsultationCardWidgetState extends State { borderRadius: BorderRadius.circular(20), onTap: () { final lastActiveDay = givenDaysWithDayNumber - .where( - (e) => e.$2.state == DiaryStateEnum.finished || e.$2.state == DiaryStateEnum.lastDayFinished, - ) + .where((e) => e.$2.state == DiaryStateEnum.finished || e.$2.state == DiaryStateEnum.lastDayFinished) .lastOrNull; if (lastActiveDay != null) { - GoRouter.of(context).push(UserDiaryScreen.path, extra: { - 'diaryId': lastActiveDay.$2.id, - 'currentDiaryDay': lastActiveDay.$1, - }); - GoRouter.of(context).push(UserDiaryScreen.path, extra: lastActiveDay.$2.id); + GoRouter.of(context).push(UserDiaryScreen.path, extra: {'diaryId': lastActiveDay.$2.id, 'currentDiaryDay': lastActiveDay.$1}); return; } - showErrorDialog(context, - title: s.you_can_not_open_this_day, description: '${widget.consultationsModel.userName} ${s.has_not_filled_it_yet}'); + showErrorDialog( + context, + title: s.you_can_not_open_this_day, + description: '${widget.consultationsModel.userName} ${s.has_not_filled_it_yet}', + ); }, child: Ink( width: MediaQuery.of(context).size.width - 32 - 52 - 8, padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - color: appTheme.blueA400, - ), + decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400), child: const Center( child: Text( 'Комментировать отчёт', - style: TextStyle( - color: Colors.white, - fontSize: 16, - fontWeight: FontWeight.w600, - ), + style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w600), ), ), ), @@ -334,16 +278,8 @@ class _ConsultationCardWidgetState extends State { child: Ink( height: 52, width: 52, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - color: const Color(0xFFF6F6F6), - ), - child: const Center( - child: Icon( - Icons.chat_outlined, - size: 24, - ), - ), + decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: const Color(0xFFF6F6F6)), + child: const Center(child: Icon(Icons.chat_outlined, size: 24)), ), ), ], @@ -361,21 +297,13 @@ class _ConsultationCardWidgetState extends State { Container( height: 20, width: 20, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: color, - ), + decoration: BoxDecoration(shape: BoxShape.circle, color: color), ), const SizedBox(width: 12), Flexible( child: Text( text, - style: const TextStyle( - height: 1, - fontSize: 16, - fontWeight: FontWeight.w500, - color: Color(0xFF323F4B), - ), + style: const TextStyle(height: 1, fontSize: 16, fontWeight: FontWeight.w500, color: Color(0xFF323F4B)), ), ), ], diff --git a/lib/features/consultations/services/consultation_service.dart b/lib/features/consultations/services/consultation_service.dart index f0a89220..8f09e4a1 100644 --- a/lib/features/consultations/services/consultation_service.dart +++ b/lib/features/consultations/services/consultation_service.dart @@ -50,6 +50,7 @@ class ConsultationService { else if (state != null && stateComparison != null) 'filters[last_state][${stateComparison.value}]': state, if (dateComparison != null && date != null) 'filters[start_date][${dateComparison.value}]': date, + 'sort[]': 'created_at:desc', }, ); return ConsultationPaginationModel.fromJson(response.data); @@ -77,7 +78,8 @@ class ConsultationService { final response = await dio.get( '/api/finances/my_finances', queryParameters: { - if (dateComparison != null && date != null) 'filters[end_date]': date, + if (date != null && dateComparison != null) 'filters[start_date][${dateComparison.value}]': date, + 'sort[]': 'start_date:desc', }, ); final data = response.data as Map; @@ -96,7 +98,8 @@ class ConsultationService { final response = await dio.get( '/api/finances/my_purchases', queryParameters: { - if (dateComparison != null && date != null) 'filters[start_date]': date, + if (date != null && dateComparison != null) 'filters[start_date][${dateComparison.value}]': date, + 'sort[]': 'start_date:desc', }, ); final data = response.data as Map; diff --git a/lib/features/create_recipe/reporitories/recipe_repository.dart b/lib/features/create_recipe/reporitories/recipe_repository.dart index f0f860e9..289233a2 100644 --- a/lib/features/create_recipe/reporitories/recipe_repository.dart +++ b/lib/features/create_recipe/reporitories/recipe_repository.dart @@ -57,7 +57,7 @@ class RecipeRepository { 'type': recipe.type, 'state': 'active', 'title': recipe.title, - 'tags': recipe.tags, + 'tags[]': recipe.tags ?? [], 'description_md': recipe.description, 'video_url': recipe.videoUrl, 'ingredients': ingredientsMap, diff --git a/lib/features/dev_tools/presentation/dev_tools_screen.dart b/lib/features/dev_tools/presentation/dev_tools_screen.dart index a4ff7730..4922a87d 100644 --- a/lib/features/dev_tools/presentation/dev_tools_screen.dart +++ b/lib/features/dev_tools/presentation/dev_tools_screen.dart @@ -9,6 +9,7 @@ import 'package:eatvio_app/core/utils/toasts.dart'; import 'package:eatvio_app/core/widgets/custom_outlined_button.dart'; import 'package:eatvio_app/core/widgets/custom_text_form_field.dart'; import 'package:eatvio_app/features/dev_tools/test_cases.dart'; +import 'package:eatvio_app/core/env_config.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -97,10 +98,15 @@ class _DevToolsScreenContentState extends State { padding: const EdgeInsets.symmetric(horizontal: 10), child: Column( children: [ - Padding(padding: const EdgeInsets.all(8.0), child: Row(children: [Text('BaseUrl: $baseUrlForTest')])), + Padding( + padding: const EdgeInsets.all(8.0), + child: Row(children: [Text('BaseUrl: $baseUrlForTest')]), + ), Row( children: [ - Expanded(child: CustomTextFormField(controller: controller, hintText: 'type new base url')), + Expanded( + child: CustomTextFormField(controller: controller, hintText: 'type new base url'), + ), IconButton( onPressed: () { setState(() { @@ -116,13 +122,13 @@ class _DevToolsScreenContentState extends State { ), const SizedBox(height: 10), CustomOutlinedButton( - text: baseUrl == 'https://dev2.eatvio.com' ? 'Set production base url' : 'Set dev2 base url', + text: baseUrl == EnvConfig.devBaseUrl ? 'Set production base url' : 'Set dev2 base url', onPressed: () { setState(() { - if (baseUrl == 'https://eatvio.ru') { - baseUrlForTest = 'https://dev2.eatvio.com'; + if (baseUrl == EnvConfig.baseUrl) { + baseUrlForTest = EnvConfig.devBaseUrl; } else { - baseUrlForTest = 'https://eatvio.ru'; + baseUrlForTest = EnvConfig.baseUrl; } baseUrl = baseUrlForTest.toLowerCase(); sl().options.baseUrl = baseUrl; @@ -132,7 +138,7 @@ class _DevToolsScreenContentState extends State { buttonStyle: const ButtonStyle(), ), SizedBox(height: baseUrlForTest != baseUrl ? 40 : 10), - if (baseUrl.toLowerCase() != 'https://eatvio.ru') + if (baseUrl.toLowerCase() != EnvConfig.baseUrl.toLowerCase()) ListView.builder( shrinkWrap: true, itemCount: testCases.length, @@ -175,12 +181,7 @@ class _DevToolsScreenContentState extends State { builder: (context) => AlertDialog( title: const Text('Test Failed'), content: SingleChildScrollView(child: Text(result.error ?? 'No error details.')), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('OK'), - ), - ], + actions: [TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('OK'))], ), ); } @@ -197,12 +198,7 @@ class _DevToolsScreenContentState extends State { builder: (context) => AlertDialog( title: const Text('Test Failed'), content: SingleChildScrollView(child: Text('$e\n$stack')), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('OK'), - ), - ], + actions: [TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('OK'))], ), ); } @@ -216,10 +212,10 @@ class _DevToolsScreenContentState extends State { TestState.fail => Container(width: 20, height: 20, color: Colors.red), TestState.notRun => Container(width: 20, height: 20, color: Colors.grey), TestState.loading => const SizedBox( - width: 20, - height: 20, - child: SizedBox(height: 20, width: 20, child: Center(child: CircularProgressIndicator())), - ), + width: 20, + height: 20, + child: SizedBox(height: 20, width: 20, child: Center(child: CircularProgressIndicator())), + ), }, ], ), diff --git a/lib/features/dev_tools/test_cases.dart b/lib/features/dev_tools/test_cases.dart index eb72a1e7..3d43bd2a 100644 --- a/lib/features/dev_tools/test_cases.dart +++ b/lib/features/dev_tools/test_cases.dart @@ -35,6 +35,7 @@ import 'package:eatvio_app/features/recipe/services/about_recipe_service.dart'; import 'package:eatvio_app/features/recipe/services/my_recipes_service.dart'; import 'package:eatvio_app/features/registration/services/registration_service.dart'; import 'package:eatvio_app/features/settings/services/settings_service.dart'; +import 'package:eatvio_app/core/env_config.dart'; class TestResult { final bool success; @@ -477,7 +478,9 @@ class AuthServiceTest implements TestCases { final dio = sl(); final authService = AuthService(dio, baseUrl: baseUrl); // Use dummy credentials, expecting failure but no crash - await authService.getTokenByCredentials(credentials: const CredentialsModel(email: 'reshetnick.mike+user1@gmail.com', password: '1234567890')); + await authService.getTokenByCredentials( + credentials: CredentialsModel(email: EnvConfig.testEmail, password: EnvConfig.testPassword), + ); return TestResult(false, 'Should not succeed'); } catch (e) { return TestResult(true, 'Exception expected for dummy credentials'); diff --git a/lib/features/diary/presentation/bloc/diary_cubit.dart b/lib/features/diary/presentation/bloc/diary_cubit.dart index c973bcaa..6e6f8891 100644 --- a/lib/features/diary/presentation/bloc/diary_cubit.dart +++ b/lib/features/diary/presentation/bloc/diary_cubit.dart @@ -136,15 +136,7 @@ class DiaryCubit extends Cubit { final fifthMeal = MealModel(id: 0, time: '0', typeNumber: 5, satiety: 0, mood: 0, portions: fifthMealPortions ?? [], photos: []); emit( DiaryState.loadedTomorrowWithDiet( - diary: diaryDay.copyWith( - meals: [ - firstMeal, - secondMeal, - thirdMeal, - fourthMeal, - fifthMeal, - ], - ), + diary: diaryDay.copyWith(meals: [firstMeal, secondMeal, thirdMeal, fourthMeal, fifthMeal]), selectedDay: selectedDay, selectedMeal: selectedMeal, ), @@ -156,7 +148,7 @@ class DiaryCubit extends Cubit { Future fetchDiary([bool needLoading = false, int? diaryId, int? currentDay]) async { this.diaryId ??= diaryId; - + final isConsultantView = this.diaryId != null; if (needLoading) { emit(const DiaryState.loading()); selectedMeal = 1; @@ -179,17 +171,22 @@ class DiaryCubit extends Cubit { int? currentPlanDay; String? planName; if (diaryDay.chosenPlanId != null) { - consultantReview = await diaryRepository.getDiaryComments(planId: diaryDay.chosenPlanId!, diaryId: diaryDay.id); final chosenPlanModel = await chatRepository.getChosenPlan(id: diaryDay.chosenPlanId!); + if (!isConsultantView && + (diaryDay.typeOfDiary.diaryState == DiaryStateEnum.lastDayCommented || diaryDay.typeOfDiary.diaryState == DiaryStateEnum.commented)) { + await diaryRepository.viewCommentOfDiaryDayByConsultant(diaryId: diaryDay.id); + } + consultantReview = await diaryRepository.getDiaryComments(planId: diaryDay.chosenPlanId!, diaryId: diaryDay.id); planDuration = chosenPlanModel.plan.duration; planName = chosenPlanModel.plan.name; - currentPlanDay = currentDay ?? DateTime.now().difference(chosenPlanModel.startDate).inDays + 1; + currentPlanDay = consultantReview.currentPlanDay; } final nutrition = await getNutrition(diaryDay.id); final isDiaryFinished = diaryDay.typeOfDiary.diaryState == DiaryStateEnum.finished || diaryDay.typeOfDiary.diaryState == DiaryStateEnum.lastDayFinished; - final isDiaryCommented = diaryDay.typeOfDiary.diaryState == DiaryStateEnum.commented || + final isDiaryCommented = + diaryDay.typeOfDiary.diaryState == DiaryStateEnum.commented || diaryDay.typeOfDiary.diaryState == DiaryStateEnum.lastDayCommented || diaryDay.typeOfDiary.diaryState == DiaryStateEnum.reviewedByUser; emit( @@ -198,7 +195,7 @@ class DiaryCubit extends Cubit { nutrition: nutrition, selectedDay: selectedDay, selectedMeal: selectedMeal, - isConsultantView: this.diaryId != null, + isConsultantView: isConsultantView, consultantReview: consultantReview, isDiaryFinished: isDiaryFinished, isDiaryCommented: isDiaryCommented, @@ -206,7 +203,7 @@ class DiaryCubit extends Cubit { chosenPlanDuration: planDuration, chosenPlanName: planName, isMoodEnabled: isMoodEnabled, - isWakeUpAndRetireEnabled: isWakeUpAndRetireTimeEnabled + isWakeUpAndRetireEnabled: isWakeUpAndRetireTimeEnabled, ), ); } catch (e, st) { @@ -215,17 +212,8 @@ class DiaryCubit extends Cubit { } } - Future finishDiaryDay({ - required int diaryId, - required String wakeUp, - required String retire, - }) async { - await diaryRepository.finishDiaryDay( - diaryId: diaryId, - wakeUp: wakeUp, - retire: retire, - comment: comment.isEmpty ? null : comment, - ); + Future finishDiaryDay({required int diaryId, required String wakeUp, required String retire}) async { + await diaryRepository.finishDiaryDay(diaryId: diaryId, wakeUp: wakeUp, retire: retire, comment: comment.isEmpty ? null : comment); await fetchDiary(); } @@ -241,6 +229,7 @@ class DiaryCubit extends Cubit { required int quantity, int? productNutritionalValueId, }) async { + emit((state as DiaryStateLoaded).copyWith(isLoadingAddingProductInMeal: true)); await diaryRepository.addPortionToMeal( diaryId: diaryId, mealId: mealId, diff --git a/lib/features/diary/presentation/bloc/diary_state.dart b/lib/features/diary/presentation/bloc/diary_state.dart index d5e8e39d..4779e2ea 100644 --- a/lib/features/diary/presentation/bloc/diary_state.dart +++ b/lib/features/diary/presentation/bloc/diary_state.dart @@ -29,6 +29,7 @@ abstract class DiaryState with _$DiaryState { @Default(false) bool isDiaryCommented, @Default(false) bool isWakeUpAndRetireEnabled, @Default(false) bool isMoodEnabled, + @Default(false) bool isLoadingAddingProductInMeal, }) = DiaryStateLoaded; const factory DiaryState.loadedTomorrowWithDiet({ diff --git a/lib/features/diary/presentation/widgets/add_product_modal.dart b/lib/features/diary/presentation/widgets/add_product_modal.dart index b55a979f..9b6c2536 100644 --- a/lib/features/diary/presentation/widgets/add_product_modal.dart +++ b/lib/features/diary/presentation/widgets/add_product_modal.dart @@ -14,12 +14,7 @@ class AddProductModal extends StatefulWidget { final DiaryStateLoaded state; final DiaryCubit diaryCubit; - const AddProductModal({ - super.key, - this.portion, - required this.state, - required this.diaryCubit, - }); + const AddProductModal({super.key, this.portion, required this.state, required this.diaryCubit}); @override State createState() => _AddProductModalState(); @@ -58,75 +53,62 @@ class _AddProductModalState extends State { return StatefulBuilder( builder: (BuildContext context, void Function(void Function()) setState) { return Padding( - padding: const EdgeInsets.all(16) + - EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom, - ), + padding: const EdgeInsets.all(16) + EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ const Text( 'Добавление блюда', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.w600, - color: Color(0xFF323F4B), - ), + style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600, color: Color(0xFF323F4B)), ), const SizedBox(height: 16), Row( mainAxisSize: MainAxisSize.min, children: [ SizedBox( - width: MediaQuery.of(context).size.width * 0.8 - 32, + width: MediaQuery.of(context).size.width * 0.76 - 32, child: CustomTextFormField( controller: productNameController, autofocus: true, hintText: 'Введите название продукта/блюда', onChanged: (query) { - EasyDebounce.debounce( - 'glossary-search', - const Duration(milliseconds: 250), - () async { - selectedGlossary = null; - if (query.isEmpty) { - glossariesList = []; - setState(() {}); - return; - } - glossariesList = await widget.diaryCubit.getGlossaryListByQuery(query); + EasyDebounce.debounce('glossary-search', const Duration(milliseconds: 250), () async { + selectedGlossary = null; + if (query.isEmpty) { + glossariesList = []; setState(() {}); - }, - ); + return; + } + glossariesList = await widget.diaryCubit.getGlossaryListByQuery(query); + setState(() {}); + }); }, ), ), const SizedBox(width: 4), SizedBox( - width: MediaQuery.of(context).size.width * 0.2 - 4, + width: MediaQuery.of(context).size.width * 0.24 - 4, child: CustomTextFormField( controller: quantityController, keyboardType: TextInputType.number, hintText: selectedGlossary == null ? '100' : selectedGlossary!.grInPortion == 0 - ? '100' - : '1', + ? '100' + : '1', suffixText: selectedGlossary == null ? 'гр' : selectedGlossary?.grInPortion == 0 - ? 'гр' - : 'шт', + ? 'гр' + : 'шт', ), ), ], ), glossariesList.isEmpty ? const SizedBox(height: 16) : const SizedBox.shrink(), Container( - constraints: const BoxConstraints( - maxHeight: 256, - ), + constraints: const BoxConstraints(maxHeight: 256), child: ListView.separated( physics: const BouncingScrollPhysics(), shrinkWrap: true, @@ -141,28 +123,12 @@ class _AddProductModalState extends State { setState(() {}); FocusManager.instance.primaryFocus?.nextFocus(); }, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - title: Text( - glossariesList[i].name, - style: const TextStyle( - height: 1, - ), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + title: Text(glossariesList[i].name, style: const TextStyle(height: 1)), subtitle: glossariesList[i].grInPortion == 0 ? null - : Text( - '${glossariesList[i].grInPortion} г', - style: TextStyle( - height: 1, - color: Colors.grey.shade600, - ), - ), - trailing: Icon( - Icons.add_rounded, - color: Colors.grey.shade400, - ), + : Text('${glossariesList[i].grInPortion} г', style: TextStyle(height: 1, color: Colors.grey.shade600)), + trailing: Icon(Icons.add_rounded, color: Colors.grey.shade400), contentPadding: const EdgeInsets.only(left: 12), ); }, @@ -188,14 +154,8 @@ class _AddProductModalState extends State { child: Ink( width: 52, height: 52, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - color: const Color(0xFFF6F6F6), - ), - child: const Icon( - Icons.delete_outline_rounded, - color: Colors.red, - ), + decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: const Color(0xFFF6F6F6)), + child: const Icon(Icons.delete_outline_rounded, color: Colors.red), ), ), if (widget.portion != null) const SizedBox(width: 8), @@ -209,8 +169,8 @@ class _AddProductModalState extends State { portion: selectedGlossary == null ? 'gr' : selectedGlossary!.grInPortion == 0 - ? 'gr' - : 'pcs', + ? 'gr' + : 'pcs', quantity: int.parse(quantityController.text), productNutritionalValueId: selectedGlossary?.id, ); @@ -223,8 +183,8 @@ class _AddProductModalState extends State { portion: selectedGlossary == null ? 'gr' : selectedGlossary!.grInPortion == 0 - ? 'gr' - : 'pcs', + ? 'gr' + : 'pcs', quantity: int.parse(quantityController.text), productNutritionalValueId: selectedGlossary?.id, portionState: widget.portion!.portionState, @@ -237,13 +197,8 @@ class _AddProductModalState extends State { useRootNavigator: true, context: context, builder: (context) => Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom, - ), - child: GlossaryModerationRequestModalWidget( - productName: productNameController.text, - diaryCubit: widget.diaryCubit, - ), + padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), + child: GlossaryModerationRequestModalWidget(productName: productNameController.text, diaryCubit: widget.diaryCubit), ), ); } @@ -252,18 +207,11 @@ class _AddProductModalState extends State { child: Ink( width: MediaQuery.of(context).size.width - (widget.portion == null ? 32 : 92), height: 52, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - color: appTheme.blueA400, - ), + decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400), child: Center( child: Text( widget.portion == null ? 'Добавить' : 'Сохранить', - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - color: Colors.white, - ), + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white), ), ), ), diff --git a/lib/features/diary/presentation/widgets/product_loader_widget.dart b/lib/features/diary/presentation/widgets/product_loader_widget.dart new file mode 100644 index 00000000..7b679354 --- /dev/null +++ b/lib/features/diary/presentation/widgets/product_loader_widget.dart @@ -0,0 +1,26 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +class ProductLoaderWidget extends StatefulWidget { + const ProductLoaderWidget({ + super.key, + }); + + @override + State createState() => _ProductLoaderWidgetState(); +} + +class _ProductLoaderWidgetState extends State { + @override + Widget build(BuildContext context) { + return ListTile( + horizontalTitleGap: 0, + contentPadding: const EdgeInsets.only(right: 24, left: 12), + title: const CupertinoActivityIndicator(), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + tileColor: const Color(0xFFF6FAFF), + ); + } +} diff --git a/lib/features/diary/presentation/widgets/products_list_widget.dart b/lib/features/diary/presentation/widgets/products_list_widget.dart index 13bbe7fc..6882af08 100644 --- a/lib/features/diary/presentation/widgets/products_list_widget.dart +++ b/lib/features/diary/presentation/widgets/products_list_widget.dart @@ -1,5 +1,6 @@ import 'package:eatvio_app/features/diary/presentation/bloc/diary_cubit.dart'; import 'package:eatvio_app/features/diary/presentation/bloc/diary_state.dart'; +import 'package:eatvio_app/features/diary/presentation/widgets/product_loader_widget.dart'; import 'package:eatvio_app/features/diary/presentation/widgets/product_widget.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -25,6 +26,11 @@ class _ProductsListWidgetState extends State { ProductWidget(portion: state.diary.meals[mealIndex].portions[i]), i < state.diary.meals[mealIndex].portions.length - 1 ? const SizedBox(height: 8) : const SizedBox.shrink(), ], + if (state.isLoadingAddingProductInMeal) + const Padding( + padding: EdgeInsets.only(top: 8), + child: ProductLoaderWidget(), + ), ], ); } diff --git a/lib/features/diary/repositories/diary_repository.dart b/lib/features/diary/repositories/diary_repository.dart index e807e3d0..2ac30803 100644 --- a/lib/features/diary/repositories/diary_repository.dart +++ b/lib/features/diary/repositories/diary_repository.dart @@ -297,4 +297,12 @@ class DiaryRepository { rethrow; } } + + Future viewCommentOfDiaryDayByConsultant({required int diaryId}) async { + try { + await diaryService.viewCommentOfDiaryDayByConsultant(diaryId: diaryId); + } catch (e) { + rethrow; + } + } } diff --git a/lib/features/diary/services/diary_service.dart b/lib/features/diary/services/diary_service.dart index bb6672d3..d9967200 100644 --- a/lib/features/diary/services/diary_service.dart +++ b/lib/features/diary/services/diary_service.dart @@ -150,4 +150,9 @@ abstract class DiaryService { @Path() required int planId, @Path() required int diaryId, }); + + @POST('/api/diary/{diaryId}/consultant/comment/view') + Future viewCommentOfDiaryDayByConsultant({ + @Path() required int diaryId, + }); } diff --git a/lib/features/feed/presentation/pages/feed_screen.dart b/lib/features/feed/presentation/pages/feed_screen.dart index b6869040..ce29b884 100644 --- a/lib/features/feed/presentation/pages/feed_screen.dart +++ b/lib/features/feed/presentation/pages/feed_screen.dart @@ -1,4 +1,5 @@ import 'package:easy_debounce/easy_debounce.dart'; +import 'package:eatvio_app/core/const.dart'; import 'package:eatvio_app/core/widgets/add_item_button.dart'; import 'package:eatvio_app/core/widgets/custom_search_bar.dart'; import 'package:eatvio_app/features/create_diet/presentation/create_diet_screen.dart'; @@ -32,24 +33,12 @@ class FeedScreen extends StatelessWidget { Widget build(BuildContext context) { return MultiBlocProvider( providers: [ - BlocProvider( - create: (context) => sl()..loadRecipesList(), - ), - BlocProvider( - create: (context) => sl()..getProfile(), - ), - BlocProvider( - create: (context) => sl()..loadFeedBlogPostList(), - ), - BlocProvider( - create: (context) => sl()..loadDiets(), - ), - BlocProvider( - create: (context) => sl()..loadPlans(), - ), - BlocProvider( - create: (context) => sl()..getConsultants(), - ), + BlocProvider(create: (context) => sl()..loadRecipesList()), + BlocProvider(create: (context) => sl()..getProfile()), + BlocProvider(create: (context) => sl()..loadFeedBlogPostList()), + BlocProvider(create: (context) => sl()..loadDiets()), + if (!hidePlans) BlocProvider(create: (context) => sl()..loadPlans()), + BlocProvider(create: (context) => sl()..getConsultants()), ], child: const FeedScreenContent(), ); @@ -69,7 +58,8 @@ class _FeedScreenContentState extends State { final dietsController = TextEditingController(); final plansController = TextEditingController(); final consultantsController = TextEditingController(); - final List chipsLabelList = ['Все', 'Рецепты', 'Диеты', 'Планы питания', 'Консультанты']; + + late final List chipsLabelList; int _selectedChipIndex = 0; final blogPostsScrollController = ScrollController(); final recipesScrollController = ScrollController(); @@ -78,12 +68,13 @@ class _FeedScreenContentState extends State { final consultantsScrollController = ScrollController(); @override - void initState() { + void initState() { + // Initialize chipsLabelList based on hidePlans setting + chipsLabelList = hidePlans ? ['Все', 'Рецепты', 'Диеты', 'Консультанты'] : ['Все', 'Рецепты', 'Диеты', 'Планы питания', 'Консультанты']; + consultantsScrollController.addListener(_onScrollConsultants); super.initState(); - } - - + } void _onScrollConsultants() { if (consultantsScrollController.offset >= consultantsScrollController.position.maxScrollExtent && !consultantsScrollController.position.outOfRange) {} @@ -108,45 +99,36 @@ class _FeedScreenContentState extends State { heroTag: 'recipesTagsFilter', backgroundColor: appTheme.blueA400, shape: const CircleBorder(), - child: const Icon( - Icons.filter_list_rounded, - color: Colors.white, - ), + child: const Icon(Icons.filter_list_rounded, color: Colors.white), onPressed: () async { final recipesCubit = context.read(); await showModalBottomSheet( useRootNavigator: true, context: context, - builder: (context) => TagsModalWidget( - recipesCubit: recipesCubit, - ), + builder: (context) => TagsModalWidget(recipesCubit: recipesCubit), ); recipesCubit.loadRecipesList(); }, ) : null, - appBar: AppBar( - backgroundColor: Colors.white, - toolbarHeight: 0, - ), + appBar: AppBar(backgroundColor: Colors.white, toolbarHeight: 0), backgroundColor: Colors.grey.shade200, body: NestedScrollView( controller: switch (_selectedChipIndex) { 0 => blogPostsScrollController, 1 => recipesScrollController, 2 => dietsScrollController, - 3 => plansScrollController, - 4 => consultantsScrollController, - _ => blogPostsScrollController + 3 when !hidePlans => plansScrollController, + 3 when hidePlans => consultantsScrollController, + 4 when !hidePlans => consultantsScrollController, + _ => blogPostsScrollController, }, headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { return [ BlocBuilder( builder: (context, state) { return SliverAppBar( - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(bottom: Radius.circular(12)), - ), + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(bottom: Radius.circular(12))), backgroundColor: Colors.white, foregroundColor: Colors.white, automaticallyImplyLeading: false, @@ -172,78 +154,71 @@ class _FeedScreenContentState extends State { searchController: switch (_selectedChipIndex) { 1 => recipesController, 2 => dietsController, - 3 => plansController, - 4 => consultantsController, + 3 when !hidePlans => plansController, + 3 when hidePlans => consultantsController, + 4 when !hidePlans => consultantsController, _ => postsController, }, onChanged: (query) { if (query.length > 2) { - EasyDebounce.debounce( - 'searchFeed', - const Duration(milliseconds: 500), - () { - switch (_selectedChipIndex) { - case 0: - context.read().loadFeedBlogPostList(query: query); - break; - case 1: - context.read().loadRecipesList(query: query); - break; - case 2: - context.read().loadDiets(query: query); - break; - case 3: - context.read().loadPlans(query: query); - break; - case 4: - context.read().getConsultants(); - break; - } - }, - ); + EasyDebounce.debounce('searchFeed', const Duration(milliseconds: 500), () { + switch (_selectedChipIndex) { + case 0: + context.read().loadFeedBlogPostList(query: query); + break; + case 1: + context.read().loadRecipesList(query: query); + break; + case 2: + context.read().loadDiets(query: query); + break; + case 3 when !hidePlans: + context.read().loadPlans(query: query); + break; + case 3 when hidePlans: + case 4 when !hidePlans: + context.read().getConsultants(); + break; + } + }); } else { - EasyDebounce.debounce( - 'searchFeed', - const Duration(milliseconds: 500), - () { - switch (_selectedChipIndex) { - case 0: - context.read().loadFeedBlogPostList(); - break; - case 1: - context.read().loadRecipesList(); - break; - case 2: - context.read().loadDiets(); - break; - case 3: - context.read().loadPlans(); - break; - case 4: - context.read().getConsultants(); - break; - } - }, - ); + EasyDebounce.debounce('searchFeed', const Duration(milliseconds: 500), () { + switch (_selectedChipIndex) { + case 0: + context.read().loadFeedBlogPostList(); + break; + case 1: + context.read().loadRecipesList(); + break; + case 2: + context.read().loadDiets(); + break; + case 3 when !hidePlans: + context.read().loadPlans(); + break; + case 3 when hidePlans: + case 4 when !hidePlans: + context.read().getConsultants(); + break; + } + }); } }, ), const SizedBox(width: 12), switch (_selectedChipIndex) { - 1 => AddItemButton( - onTap: () => GoRouter.of(context).pushNamed(RecipeCreateScreen.name), - ), + 1 => AddItemButton(onTap: () => GoRouter.of(context).pushNamed(RecipeCreateScreen.name)), 2 => AddItemButton( - onTap: state.profile?.roles.contains('consultant') ?? false - ? () async { - await GoRouter.of(context).pushNamed(CreateDietScreen.name); - } - : null, - ), - 3 => AddItemButton( - onTap: state.profile?.roles.contains('consultant') ?? false ? () => GoRouter.of(context).pushNamed(CreatePlanScreen.name) : null, - ), - _ => const AddItemButton() + onTap: state.profile?.roles.contains('consultant') ?? false + ? () async { + await GoRouter.of(context).pushNamed(CreateDietScreen.name); + } + : null, + ), + 3 when !hidePlans => AddItemButton( + onTap: state.profile?.roles.contains('consultant') ?? false ? () => GoRouter.of(context).pushNamed(CreatePlanScreen.name) : null, + ), + _ => const AddItemButton(), }, ], ), @@ -256,8 +231,9 @@ class _FeedScreenContentState extends State { 0 => const BlogPosts(), 1 => const Recipes(), 2 => const Diets(), - 3 => const Plans(), - 4 => const Consultants(), + 3 when !hidePlans => const Plans(), + 3 when hidePlans => const Consultants(), + 4 when !hidePlans => const Consultants(), _ => Center(child: Text(chipsLabelList[_selectedChipIndex])), }, ), diff --git a/lib/features/feed/presentation/widgets/blog_post_widget.dart b/lib/features/feed/presentation/widgets/blog_post_widget.dart index 6479d0d9..23eaeded 100644 --- a/lib/features/feed/presentation/widgets/blog_post_widget.dart +++ b/lib/features/feed/presentation/widgets/blog_post_widget.dart @@ -245,9 +245,9 @@ class _BlogPostWidgetState extends State { const SizedBox(width: 8), FeedIconButton( svgIcon: SvgPicture.asset( - height: 20, - width: 20, - 'assets/icons/share.svg', + height: 15, + width: 12, + 'assets/icons/repost.svg', colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn), ), onTap: () { diff --git a/lib/features/feed/presentation/widgets/chip_group.dart b/lib/features/feed/presentation/widgets/chip_group.dart index a8d88da1..183a5a76 100644 --- a/lib/features/feed/presentation/widgets/chip_group.dart +++ b/lib/features/feed/presentation/widgets/chip_group.dart @@ -2,12 +2,7 @@ import 'package:eatvio_app/features/feed/presentation/widgets/choice_chip_widget import 'package:flutter/widgets.dart'; class ChipGroup extends StatelessWidget { - const ChipGroup({ - super.key, - required this.selectedChipIndex, - required this.chipsLabelList, - required this.setSelectedChipIndex, - }); + const ChipGroup({super.key, required this.selectedChipIndex, required this.chipsLabelList, required this.setSelectedChipIndex}); final int selectedChipIndex; final List chipsLabelList; final void Function(int) setSelectedChipIndex; @@ -22,7 +17,7 @@ class ChipGroup extends StatelessWidget { child: Row( children: [ ...List.generate( - 5, + chipsLabelList.length, (index) => Padding( padding: const EdgeInsets.only(right: 8.0), child: ChoiceChipWidget( diff --git a/lib/features/feed/presentation/widgets/diets_widget.dart b/lib/features/feed/presentation/widgets/diets_widget.dart index 34964d1a..d91cdd74 100644 --- a/lib/features/feed/presentation/widgets/diets_widget.dart +++ b/lib/features/feed/presentation/widgets/diets_widget.dart @@ -17,7 +17,7 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:go_router/go_router.dart'; import 'package:share_plus/share_plus.dart'; -class DietsWidget extends StatefulWidget { +class DietsWidget extends StatefulWidget { final Diet diet; final int index; const DietsWidget({ diff --git a/lib/features/feed/presentation/widgets/recipes_widget.dart b/lib/features/feed/presentation/widgets/recipes_widget.dart index 7720c6cd..c0dbeb39 100644 --- a/lib/features/feed/presentation/widgets/recipes_widget.dart +++ b/lib/features/feed/presentation/widgets/recipes_widget.dart @@ -231,7 +231,7 @@ class _RecipesWidgetState extends State { final box = context.findRenderObject() as RenderBox?; SharePlus.instance.share( ShareParams( - uri: Uri.parse('$baseUrl/ru/blog_post/${widget.recipe.id}'), + uri: Uri.parse('$baseUrl/ru/recipe/${widget.recipe.id}'), sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, ), ); diff --git a/lib/features/finances_and_consultations_archive/cubit/archive/consultations_archive_cubit.dart b/lib/features/finances_and_consultations_archive/cubit/archive/consultations_archive_cubit.dart index dd6c713e..a2f6daf0 100644 --- a/lib/features/finances_and_consultations_archive/cubit/archive/consultations_archive_cubit.dart +++ b/lib/features/finances_and_consultations_archive/cubit/archive/consultations_archive_cubit.dart @@ -31,9 +31,7 @@ class ConsultationsArchiveCubit extends Cubit { dateComparison: state.periodFilterStatus == PeriodFilterStatus.all ? null : FiltersComparisonEnum.greaterThanOrEqual, stateComparison: state.consultationFilterStatus == ConsultationFilterStatus.all ? null - : state.consultationFilterStatus == ConsultationFilterStatus.inProgress - ? FiltersComparisonEnum.notInArray - : FiltersComparisonEnum.equal, + : FiltersComparisonEnum.equal, ); emit(state.copyWith(consultations: consultations, isLoading: false)); } catch (e) { @@ -67,20 +65,17 @@ class ConsultationsArchiveCubit extends Cubit { enum ConsultationFilterStatus { all, canceled, - finished, - inProgress; + finished; String get name => switch (this) { ConsultationFilterStatus.all => 'Все консультации', ConsultationFilterStatus.canceled => 'Отменён', ConsultationFilterStatus.finished => 'Завершён', - ConsultationFilterStatus.inProgress => 'В процессе', }; String? get value => switch (this) { ConsultationFilterStatus.all => null, ConsultationFilterStatus.canceled => 'canceled_by_the_initiator', ConsultationFilterStatus.finished => 'charge_was_collected', - ConsultationFilterStatus.inProgress => 'active', }; } diff --git a/lib/features/finances_and_consultations_archive/cubit/archive/consultations_archive_state.dart b/lib/features/finances_and_consultations_archive/cubit/archive/consultations_archive_state.dart index c54f8073..dfe84a55 100644 --- a/lib/features/finances_and_consultations_archive/cubit/archive/consultations_archive_state.dart +++ b/lib/features/finances_and_consultations_archive/cubit/archive/consultations_archive_state.dart @@ -14,7 +14,7 @@ abstract class ConsultationsArchiveState with _$ConsultationsArchiveState { @Default([]) List consultations, @Default(false) isLoading, @Default(null) String? error, - @Default(null) PeriodFilterStatus? periodFilterStatus, + @Default(PeriodFilterStatus.all) PeriodFilterStatus? periodFilterStatus, @Default(null) ConsultationFilterStatus? consultationFilterStatus, required StatisticModel statisticModel, }) = _ConsultationsArchiveState; diff --git a/lib/features/finances_and_consultations_archive/cubit/consultant_finances/consultant_finances_state.dart b/lib/features/finances_and_consultations_archive/cubit/consultant_finances/consultant_finances_state.dart index fff61e45..4e1123e1 100644 --- a/lib/features/finances_and_consultations_archive/cubit/consultant_finances/consultant_finances_state.dart +++ b/lib/features/finances_and_consultations_archive/cubit/consultant_finances/consultant_finances_state.dart @@ -10,7 +10,7 @@ abstract class ConsultantFinancesState with _$ConsultantFinancesState { @Default([]) List financesModels, @Default(false) isLoading, @Default(null) String? error, - @Default(null) PeriodFilterStatus? periodFilterStatus, + @Default(PeriodFilterStatus.all) PeriodFilterStatus? periodFilterStatus, }) = _ConsultantFinancesState; } diff --git a/lib/features/finances_and_consultations_archive/cubit/user_purchases/user_purchases_state.dart b/lib/features/finances_and_consultations_archive/cubit/user_purchases/user_purchases_state.dart index 7ae22c03..65a53842 100644 --- a/lib/features/finances_and_consultations_archive/cubit/user_purchases/user_purchases_state.dart +++ b/lib/features/finances_and_consultations_archive/cubit/user_purchases/user_purchases_state.dart @@ -10,6 +10,6 @@ abstract class UserPurchasesState with _$UserPurchasesState { @Default([]) List purchasesModels, @Default(false) isLoading, @Default(null) String? error, - @Default(null) PeriodFilterStatus? periodFilterStatus, + @Default(PeriodFilterStatus.all) PeriodFilterStatus? periodFilterStatus, }) = _UserPurchasesState; } diff --git a/lib/features/finances_and_consultations_archive/presentation/consultant_finances_screen.dart b/lib/features/finances_and_consultations_archive/presentation/consultant_finances_screen.dart index 7e731474..085e9fa6 100644 --- a/lib/features/finances_and_consultations_archive/presentation/consultant_finances_screen.dart +++ b/lib/features/finances_and_consultations_archive/presentation/consultant_finances_screen.dart @@ -96,7 +96,7 @@ class _ConsultantFinancesScreenState extends State { if (value == null) return; context.read().setFinancesPeriod(value); }, - value: state.periodFilterStatus ?? PeriodFilterStatus.values.first, + value: state.periodFilterStatus ?? PeriodFilterStatus.all, ), const SizedBox(height: 12), if (state.financesModels.isNotEmpty) @@ -129,7 +129,7 @@ class _ConsultantFinancesScreenState extends State { ); }), Text( - state.financesModels[index].startDate.format('yyyy.MM.dd'), + state.financesModels[index].startDate.format('dd.MM.yy'), style: const TextStyle( color: Color(0xFF6D6F71), height: 1, diff --git a/lib/features/finances_and_consultations_archive/presentation/consultations_archive_screen.dart b/lib/features/finances_and_consultations_archive/presentation/consultations_archive_screen.dart index ec2d4900..c356e9fb 100644 --- a/lib/features/finances_and_consultations_archive/presentation/consultations_archive_screen.dart +++ b/lib/features/finances_and_consultations_archive/presentation/consultations_archive_screen.dart @@ -195,7 +195,7 @@ class _ConsultationsArchiveScreenState extends State ), ), Text( - state.consultations[index].startDate.format('yyyy.MM.dd'), + state.consultations[index].startDate.format('dd.MM.yy'), style: const TextStyle( color: Color(0xFF6D6F71), height: 1, diff --git a/lib/features/internet_connection_checker/internet_connection_checker_screen.dart b/lib/features/internet_connection_checker/internet_connection_checker_screen.dart index 3aa1bc34..576eb891 100644 --- a/lib/features/internet_connection_checker/internet_connection_checker_screen.dart +++ b/lib/features/internet_connection_checker/internet_connection_checker_screen.dart @@ -10,6 +10,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_native_splash/flutter_native_splash.dart'; import 'package:flutter_svg/svg.dart'; import 'package:http/http.dart' as http; +import 'package:eatvio_app/core/env_config.dart'; class InternetConnectionCheckerScreen extends StatefulWidget { const InternetConnectionCheckerScreen({super.key, required this.child}); @@ -66,12 +67,7 @@ class _InternetConnectionCheckerScreenState extends State json) => _$NotificationSettingsModelFromJson(json); +} + +@freezed +abstract class DiaryReminderNotifications with _$DiaryReminderNotifications { + const factory DiaryReminderNotifications({ + @JsonKey(name: 'first_reminder') required ReminderSettings firstReminder, + @JsonKey(name: 'second_reminder') required ReminderSettings secondReminder, + @JsonKey(name: 'third_reminder') required ReminderSettings thirdReminder, + }) = _DiaryReminderNotifications; + + factory DiaryReminderNotifications.fromJson(Map json) => _$DiaryReminderNotificationsFromJson(json); +} + +@freezed +abstract class ReminderSettings with _$ReminderSettings { + const factory ReminderSettings({ + required bool enabled, + @JsonKey(name: 'local_time_hours') required int localTimeHours, + @JsonKey(name: 'local_time_mins') required int localTimeMins, + }) = _ReminderSettings; + + factory ReminderSettings.fromJson(Map json) => _$ReminderSettingsFromJson(json); +} diff --git a/lib/features/menu/presentation/bloc/notification_settings_cubit/notification_settings_cubit.dart b/lib/features/menu/presentation/bloc/notification_settings_cubit/notification_settings_cubit.dart new file mode 100644 index 00000000..6c5c567c --- /dev/null +++ b/lib/features/menu/presentation/bloc/notification_settings_cubit/notification_settings_cubit.dart @@ -0,0 +1,33 @@ +import 'dart:developer'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:eatvio_app/features/menu/presentation/bloc/notification_settings_cubit/notification_settings_state.dart'; +import 'package:eatvio_app/features/menu/repositories/notification_settings_repository.dart'; + +class NotificationSettingsCubit extends Cubit { + final NotificationSettingsRepository _notificationSettingsRepository; + + NotificationSettingsCubit({required NotificationSettingsRepository notificationSettingsRepository}) + : _notificationSettingsRepository = notificationSettingsRepository, + super(const NotificationSettingsStateLoading()); + + Future getNotificationSettings() async { + emit(const NotificationSettingsStateLoading()); + try { + final notificationSettings = await _notificationSettingsRepository.getNotificationSettings(); + emit(NotificationSettingsStateLoaded(notificationSettings: notificationSettings)); + } catch (e, stackTrace) { + log('Error getting notification settings: $e', stackTrace: stackTrace); + emit(NotificationSettingsStateError(errorMessage: e.toString())); + } + } + + Future updateNotificationSettings(Map data) async { + try { + final notificationSettings = await _notificationSettingsRepository.updateNotificationSettings(data); + emit(NotificationSettingsStateLoaded(notificationSettings: notificationSettings)); + } catch (e, stackTrace) { + log('Error updating notification settings: $e', stackTrace: stackTrace); + emit(NotificationSettingsStateError(errorMessage: e.toString())); + } + } +} diff --git a/lib/features/menu/presentation/bloc/notification_settings_cubit/notification_settings_state.dart b/lib/features/menu/presentation/bloc/notification_settings_cubit/notification_settings_state.dart new file mode 100644 index 00000000..ef461f7c --- /dev/null +++ b/lib/features/menu/presentation/bloc/notification_settings_cubit/notification_settings_state.dart @@ -0,0 +1,31 @@ +import 'package:equatable/equatable.dart'; +import 'package:eatvio_app/features/menu/models/notification_settings_model.dart'; + +abstract class NotificationSettingsState extends Equatable { + const NotificationSettingsState(); + + @override + List get props => []; +} + +class NotificationSettingsStateLoading extends NotificationSettingsState { + const NotificationSettingsStateLoading(); +} + +class NotificationSettingsStateLoaded extends NotificationSettingsState { + final NotificationSettingsModel notificationSettings; + + const NotificationSettingsStateLoaded({required this.notificationSettings}); + + @override + List get props => [notificationSettings]; +} + +class NotificationSettingsStateError extends NotificationSettingsState { + final String errorMessage; + + const NotificationSettingsStateError({required this.errorMessage}); + + @override + List get props => [errorMessage]; +} diff --git a/lib/features/menu/presentation/pages/menu_screen.dart b/lib/features/menu/presentation/pages/menu_screen.dart index f5b5f3d0..5c350d3c 100644 --- a/lib/features/menu/presentation/pages/menu_screen.dart +++ b/lib/features/menu/presentation/pages/menu_screen.dart @@ -1,9 +1,12 @@ import 'package:dio/dio.dart'; import 'package:eatvio_app/core/const.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/locator_service.dart'; import 'package:eatvio_app/core/theme/theme_helper.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/my_diets/presentation/my_diets_screen.dart'; @@ -14,6 +17,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:go_router/go_router.dart'; +import 'package:package_info_plus/package_info_plus.dart'; class MenuScreen extends StatelessWidget { final ProfileModel profileModel; @@ -37,21 +41,34 @@ class MenuScreen extends StatelessWidget { if (profileModel.roles.contains('consultant')) _divider(context), if (profileModel.roles.contains('consultant')) _tile(leading: ImageConstant.icnPlans, title: 'Мои планы', onTap: () => GoRouter.of(context).push(MyPlansScreen.path)), - _divider(context), - _tile( - leading: 'assets/icons/finance.svg', - title: 'Мои финансы', - onTap: () { - if (profileModel.roles.contains('consultant')) { - GoRouter.of(context).push(ConsultantFinancesScreen.path); - return; - } - GoRouter.of(context).push(UserPurchasesScreen.path); - }, - ), + if (!hidePlans) ...[ + _divider(context), + _tile( + leading: 'assets/icons/finance.svg', + title: 'Мои финансы', + onTap: () { + if (profileModel.roles.contains('consultant')) { + GoRouter.of(context).push(ConsultantFinancesScreen.path); + return; + } + GoRouter.of(context).push(UserPurchasesScreen.path); + }, + ), + ], + if (profileModel.roles.contains('consultant')) _divider(context), + if (profileModel.roles.contains('consultant')) + _tile( + leading: 'assets/icons/finance.svg', + title: 'Мой рейтинг', + onTap: () { + GoRouter.of(context).push(ConsultantRatingScreen.path); + }, + ), _divider(context), _tile(leading: ImageConstant.icnGlossary, title: 'Глоссарий', onTap: () => GoRouter.of(context).push('/glossary-screen')), _divider(context), + _tile(leading: 'assets/icons/notifications.svg', title: 'Уведомления', onTap: () => GoRouter.of(context).pushNamed('notificationsSettingsScreen')), + _divider(context), _tile(leading: ImageConstant.icnSupport, title: 'Поддержка', onTap: () => GoRouter.of(context).push('/support-screen')), _divider(context), _tile(leading: 'assets/icons/blocked_users.svg', title: 'Чёрный список', onTap: () => GoRouter.of(context).pushNamed('blockedUsersScreen')), @@ -65,6 +82,8 @@ class MenuScreen extends StatelessWidget { // ), _divider(context), _tile(leading: ImageConstant.icnExit, title: 'Выход', onTap: _onTapLogout(context)), + const SizedBox(height: 10), + const AppInfo(), ], ), ), @@ -122,3 +141,42 @@ class MenuScreen extends StatelessWidget { }; } } + +class AppInfo extends StatefulWidget { + const AppInfo({super.key}); + + @override + State createState() => _AppInfoState(); +} + +class _AppInfoState extends State { + String version = ''; + String buildNumber = ''; + @override + void didChangeDependencies() { + PackageInfo.fromPlatform().then((PackageInfo packageInfo) { + setState(() { + version = packageInfo.version; + buildNumber = packageInfo.buildNumber; + }); + }); + super.didChangeDependencies(); + } + + @override + Widget build(BuildContext context) { + return Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsetsGeometry.only(left: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Версия приложения: $version ($buildNumber)', style: TextStyles.textXSSemibold.copyWith(color: appTheme.gray)), + if (buildDate != '') Text('Дата сборки: ${formatBuildDate(buildDate)}', style: TextStyles.textXSSemibold.copyWith(color: appTheme.gray)), + ], + ), + ), + ); + } +} diff --git a/lib/features/menu/presentation/pages/notifications_settings_screen.dart b/lib/features/menu/presentation/pages/notifications_settings_screen.dart new file mode 100644 index 00000000..1c1065a3 --- /dev/null +++ b/lib/features/menu/presentation/pages/notifications_settings_screen.dart @@ -0,0 +1,262 @@ +import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/menu/models/notification_settings_model.dart'; +import 'package:eatvio_app/features/menu/presentation/bloc/notification_settings_cubit/notification_settings_cubit.dart'; +import 'package:eatvio_app/features/menu/presentation/bloc/notification_settings_cubit/notification_settings_state.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class NotificationsSettingsScreen extends StatelessWidget { + const NotificationsSettingsScreen({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider(create: (context) => sl()..getNotificationSettings(), child: const NotificationsSettingsContent()); + } +} + +class NotificationsSettingsContent extends StatelessWidget { + const NotificationsSettingsContent({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Уведомления')), + body: BlocBuilder( + builder: (context, state) { + if (state is NotificationSettingsStateLoading) { + return const Center(child: CircularProgressIndicator()); + } else if (state is NotificationSettingsStateError) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('Ошибка: ${state.errorMessage}'), + ElevatedButton( + onPressed: () { + context.read().getNotificationSettings(); + }, + child: const Text('Повторить'), + ), + ], + ), + ); + } else if (state is NotificationSettingsStateLoaded) { + return _buildSettingsContent(context, state.notificationSettings); + } + return const Center(child: Text('Нет данных')); + }, + ), + ); + } + + Widget _buildSettingsContent(BuildContext context, NotificationSettingsModel notificationSettings) { + final cubit = context.read(); + return Column( + children: [ + _tile( + padding: const EdgeInsets.symmetric(horizontal: 16), + onTap: () {}, + title: 'Уведомление о новых обновлениях', + subtitle: null, + trailing: CupertinoSwitch( + value: notificationSettings.newUpdatesNotification, + activeTrackColor: appTheme.blueA400, + onChanged: (newValue) { + _updateNotificationSetting(cubit, {'new_updates_notification': newValue}); + }, + ), + ), + _divider(context), + _tile( + padding: const EdgeInsets.symmetric(horizontal: 16), + onTap: () {}, + title: 'Уведомления о взвешивании', + subtitle: null, + trailing: CupertinoSwitch( + value: notificationSettings.weightMeasurementsNotification, + activeTrackColor: appTheme.blueA400, + onChanged: (newValue) { + _updateNotificationSetting(cubit, {'weight_measurements_notification': newValue}); + }, + ), + ), + _divider(context), + _tile( + padding: const EdgeInsets.symmetric(horizontal: 16), + onTap: () {}, + title: 'Email уведомления', + subtitle: null, + trailing: CupertinoSwitch( + value: notificationSettings.isEmailNotificationsEnabled, + activeTrackColor: appTheme.blueA400, + onChanged: (newValue) { + _updateNotificationSetting(cubit, {'is_email_notifications_enabled': newValue}); + }, + ), + ), + _divider(context), + _tile( + padding: const EdgeInsets.symmetric(horizontal: 16), + onTap: () {}, + title: 'Push уведомления', + subtitle: null, + trailing: CupertinoSwitch( + value: notificationSettings.isPushNotificationsEnabled, + activeTrackColor: appTheme.blueA400, + onChanged: (newValue) { + _updateNotificationSetting(cubit, {'is_push_notifications_enabled': newValue}); + }, + ), + ), + _divider(context), + const SizedBox(height: 16), + _buildDiaryRemindersSection(context, notificationSettings.diaryReminderNotifications, cubit), + ], + ); + } + + Widget _buildDiaryRemindersSection(BuildContext context, DiaryReminderNotifications diaryReminderNotifications, NotificationSettingsCubit cubit) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'Напоминания о дневнике', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: appTheme.blueA400), + ), + ), + _buildReminderTile(context, 'Первое напоминание', diaryReminderNotifications.firstReminder, 'first_reminder', cubit), + _buildReminderTile(context, 'Второе напоминание', diaryReminderNotifications.secondReminder, 'second_reminder', cubit), + _buildReminderTile(context, 'Третье напоминание', diaryReminderNotifications.thirdReminder, 'third_reminder', cubit), + ], + ); + } + + void _updateNotificationSetting(NotificationSettingsCubit cubit, Map data) { + cubit.updateNotificationSettings(data); + } + + void _updateReminderSetting(NotificationSettingsCubit cubit, String reminderType, Map data) { + final updateData = { + 'diary_reminder_notifications': {reminderType: data}, + }; + cubit.updateNotificationSettings(updateData); + } + + Widget _buildReminderTile(BuildContext context, String title, ReminderSettings reminder, String reminderType, NotificationSettingsCubit cubit) { + return _tile( + padding: const EdgeInsets.symmetric(horizontal: 16), + onTap: reminder.enabled + ? () { + _showTimePicker(context, reminder, reminderType, cubit); + } + : null, + title: title, + subtitle: reminder.enabled + ? Row( + children: [ + Icon(Icons.access_time, size: 16, color: appTheme.blueA400), + const SizedBox(width: 8), + Text( + '${reminder.localTimeHours.toString().padLeft(2, '0')}:${reminder.localTimeMins.toString().padLeft(2, '0')}', + style: TextStyle(fontSize: 14, color: appTheme.blueA400, fontWeight: FontWeight.w500), + ), + const SizedBox(width: 4), + const Text('(нажмите для изменения)', style: TextStyle(fontSize: 12, color: Colors.grey)), + ], + ) + : null, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + CupertinoSwitch( + value: reminder.enabled, + activeTrackColor: appTheme.blueA400, + onChanged: (newValue) { + _updateReminderSetting(cubit, reminderType, { + 'enabled': newValue, + 'local_time_hours': reminder.localTimeHours, + 'local_time_mins': reminder.localTimeMins, + }); + }, + ), + ], + ), + ); + } + + void _showTimePicker(BuildContext context, ReminderSettings reminder, String reminderType, NotificationSettingsCubit cubit) { + int selectedHour = reminder.localTimeHours; + int selectedMinute = reminder.localTimeMins; + + showCupertinoModalPopup( + context: context, + builder: (BuildContext context) { + return Container( + height: 300, + padding: const EdgeInsets.only(top: 6.0), + margin: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), + color: CupertinoColors.systemBackground.resolveFrom(context), + child: SafeArea( + top: false, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + CupertinoButton(child: const Text('Отмена'), onPressed: () => Navigator.of(context).pop()), + CupertinoButton( + child: const Text('Готово'), + onPressed: () { + _updateReminderSetting(cubit, reminderType, { + 'enabled': reminder.enabled, + 'local_time_hours': selectedHour, + 'local_time_mins': selectedMinute, + }); + Navigator.of(context).pop(); + }, + ), + ], + ), + Expanded( + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.time, + use24hFormat: true, + initialDateTime: DateTime(2024, 1, 1, selectedHour, selectedMinute), + onDateTimeChanged: (DateTime newDateTime) { + selectedHour = newDateTime.hour; + selectedMinute = newDateTime.minute; + }, + ), + ), + ], + ), + ), + ); + }, + ); + } + + ListTile _tile({required String title, required Function()? onTap, Widget? trailing, EdgeInsets? padding, Widget? subtitle}) { + return ListTile( + contentPadding: padding, + onTap: onTap, + title: Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500, height: 1)), + subtitle: subtitle, + trailing: trailing ?? const Icon(Icons.arrow_forward_ios_rounded, size: 12, color: Colors.black38), + ); + } + + Center _divider(BuildContext context) { + return Center( + child: Container( + height: 0.4, + width: MediaQuery.of(context).size.width - 32, + decoration: BoxDecoration(color: Colors.black26, borderRadius: BorderRadius.circular(16)), + ), + ); + } +} diff --git a/lib/features/menu/repositories/notification_settings_repository.dart b/lib/features/menu/repositories/notification_settings_repository.dart new file mode 100644 index 00000000..e0aeaa31 --- /dev/null +++ b/lib/features/menu/repositories/notification_settings_repository.dart @@ -0,0 +1,16 @@ +import 'package:eatvio_app/features/menu/models/notification_settings_model.dart'; +import 'package:eatvio_app/features/menu/services/notification_settings_service.dart'; + +class NotificationSettingsRepository { + final NotificationSettingsService _notificationSettingsService; + + NotificationSettingsRepository(this._notificationSettingsService); + + Future getNotificationSettings() async { + return await _notificationSettingsService.getNotificationSettings(); + } + + Future updateNotificationSettings(Map data) async { + return await _notificationSettingsService.updateNotificationSettings(data: data); + } +} diff --git a/lib/features/menu/services/notification_settings_service.dart b/lib/features/menu/services/notification_settings_service.dart new file mode 100644 index 00000000..f489267f --- /dev/null +++ b/lib/features/menu/services/notification_settings_service.dart @@ -0,0 +1,16 @@ +import 'package:dio/dio.dart'; +import 'package:eatvio_app/features/menu/models/notification_settings_model.dart'; +import 'package:retrofit/retrofit.dart'; + +part 'notification_settings_service.g.dart'; + +@RestApi() +abstract class NotificationSettingsService { + factory NotificationSettingsService(Dio dio, {String baseUrl}) = _NotificationSettingsService; + + @GET('/api/profile/notification_settings') + Future getNotificationSettings(); + + @POST('/api/profile/update_notification_settings') + Future updateNotificationSettings({@Body() required Map data}); +} diff --git a/lib/features/notifications/models/notification_model.dart b/lib/features/notifications/models/notification_model.dart index e8489a5a..55c79e51 100644 --- a/lib/features/notifications/models/notification_model.dart +++ b/lib/features/notifications/models/notification_model.dart @@ -1,8 +1,7 @@ -import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart'; - sealed class BaseNotificationModel { abstract final NotificationType type; abstract final bool isRead; + abstract final DateTime createdAt; } class PlanSubscriptionStartedToAuthorNotification implements BaseNotificationModel { @@ -13,6 +12,8 @@ class PlanSubscriptionStartedToAuthorNotification implements BaseNotificationMod final String planName; final String customerName; final String chatId; + @override + final DateTime createdAt; PlanSubscriptionStartedToAuthorNotification({ required this.type, @@ -20,15 +21,17 @@ class PlanSubscriptionStartedToAuthorNotification implements BaseNotificationMod required this.customerName, required this.chatId, required this.isRead, + required this.createdAt, }); - factory PlanSubscriptionStartedToAuthorNotification.fromJson(Map json, NotificationType type, {bool? isRead}) { + factory PlanSubscriptionStartedToAuthorNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return PlanSubscriptionStartedToAuthorNotification( type: type, planName: json['plan_name'], customerName: json['user_name'], chatId: json['chat_id'], isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); } } @@ -40,20 +43,24 @@ class PlanSubscriptionCanceledToAuthorNotification implements BaseNotificationMo final bool isRead; final String planName; final String customerName; + @override + final DateTime createdAt; PlanSubscriptionCanceledToAuthorNotification({ required this.type, required this.planName, required this.customerName, required this.isRead, + required this.createdAt, }); - factory PlanSubscriptionCanceledToAuthorNotification.fromJson(Map json, NotificationType type, {bool? isRead}) { + factory PlanSubscriptionCanceledToAuthorNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return PlanSubscriptionCanceledToAuthorNotification( type: type, planName: json['plan_name'], customerName: json['user_name'], isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); } } @@ -65,20 +72,18 @@ class ChosenPlanStartedToClientNotification implements BaseNotificationModel { final bool isRead; final String planName; final int planId; + @override + final DateTime createdAt; - ChosenPlanStartedToClientNotification({ - required this.type, - required this.planName, - required this.planId, - required this.isRead, - }); + ChosenPlanStartedToClientNotification({required this.type, required this.planName, required this.planId, required this.isRead, required this.createdAt}); - factory ChosenPlanStartedToClientNotification.fromJson(Map json, NotificationType type, {bool? isRead}) { + factory ChosenPlanStartedToClientNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return ChosenPlanStartedToClientNotification( type: type, planName: json['plan_name'], planId: json['plan_id'], isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); } } @@ -89,15 +94,18 @@ class ChosenPlanTomorrowReminderToClientNotification implements BaseNotification @override final bool isRead; final String planName; + @override + final DateTime createdAt; - ChosenPlanTomorrowReminderToClientNotification({ - required this.type, - required this.planName, - required this.isRead, - }); + ChosenPlanTomorrowReminderToClientNotification({required this.type, required this.planName, required this.isRead, required this.createdAt}); - factory ChosenPlanTomorrowReminderToClientNotification.fromJson(Map json, NotificationType type, {bool? isRead}) { - return ChosenPlanTomorrowReminderToClientNotification(type: type, planName: json['plan_name'], isRead: isRead ?? false); + factory ChosenPlanTomorrowReminderToClientNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return ChosenPlanTomorrowReminderToClientNotification( + type: type, + planName: json['plan_name'], + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + ); } } @@ -106,12 +114,17 @@ class DiaryReminderNotification implements BaseNotificationModel { final NotificationType type; @override final bool isRead; - - - DiaryReminderNotification({required this.type, required this.isRead}); + @override + final DateTime createdAt; - factory DiaryReminderNotification.fromJson(Map json, NotificationType type, {bool? isRead}) { - return DiaryReminderNotification(type: type, isRead: isRead ?? false); + DiaryReminderNotification({required this.type, required this.isRead, required this.createdAt}); + + factory DiaryReminderNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return DiaryReminderNotification( + type: type, + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + ); } } @@ -121,18 +134,17 @@ class ConsultantLeftCommentNotification implements BaseNotificationModel { @override final bool isRead; final DateTime date; + @override + final DateTime createdAt; - ConsultantLeftCommentNotification({ - required this.type, - required this.date, - required this.isRead, - }); + ConsultantLeftCommentNotification({required this.type, required this.date, required this.isRead, required this.createdAt}); - factory ConsultantLeftCommentNotification.fromJson(Map json, NotificationType type, {bool? isRead}) { + factory ConsultantLeftCommentNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return ConsultantLeftCommentNotification( type: type, - date: parseDate(json['diary_date']), + date: parseDate(json['diary_date'] ?? DateTime.now().toIso8601String()), isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); } } @@ -142,16 +154,16 @@ class PersonalizedDietIsDefinedNotification implements BaseNotificationModel { final NotificationType type; @override final bool isRead; + @override + final DateTime createdAt; - PersonalizedDietIsDefinedNotification({ - required this.type, - required this.isRead, - }); + PersonalizedDietIsDefinedNotification({required this.type, required this.isRead, required this.createdAt}); - factory PersonalizedDietIsDefinedNotification.fromJson(Map json, NotificationType type, {bool? isRead}) { + factory PersonalizedDietIsDefinedNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return PersonalizedDietIsDefinedNotification( type: type, isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); } } @@ -162,17 +174,16 @@ class ChosenPlanFinishedToClientNotification implements BaseNotificationModel { @override final bool isRead; final String planName; - ChosenPlanFinishedToClientNotification({ - required this.type, - required this.planName, - required this.isRead, - }); + @override + final DateTime createdAt; + ChosenPlanFinishedToClientNotification({required this.type, required this.planName, required this.isRead, required this.createdAt}); - factory ChosenPlanFinishedToClientNotification.fromJson(Map json, NotificationType type, {bool? isRead}) { + factory ChosenPlanFinishedToClientNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return ChosenPlanFinishedToClientNotification( type: type, planName: json['plan_name'], isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); } } @@ -183,17 +194,16 @@ class PlanSubscriptionCanceledToCustomerNotification implements BaseNotification @override final bool isRead; final String planName; - PlanSubscriptionCanceledToCustomerNotification({ - required this.type, - required this.planName, - required this.isRead, - }); + @override + final DateTime createdAt; + PlanSubscriptionCanceledToCustomerNotification({required this.type, required this.planName, required this.isRead, required this.createdAt}); - factory PlanSubscriptionCanceledToCustomerNotification.fromJson(Map json, NotificationType type, {bool? isRead}) { + factory PlanSubscriptionCanceledToCustomerNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return PlanSubscriptionCanceledToCustomerNotification( type: type, planName: json['plan_name'], isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); } } @@ -205,19 +215,23 @@ class ChosenPlanStartedToConsultantNotification implements BaseNotificationModel final bool isRead; final String planName; final String customerName; + @override + final DateTime createdAt; ChosenPlanStartedToConsultantNotification({ required this.type, required this.isRead, required this.planName, required this.customerName, + required this.createdAt, }); - factory ChosenPlanStartedToConsultantNotification.fromJson(Map json, NotificationType type, {bool? isRead}) { + factory ChosenPlanStartedToConsultantNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return ChosenPlanStartedToConsultantNotification( type: type, isRead: isRead ?? false, planName: json['plan_name'], customerName: json['client_name'], + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); } } @@ -229,19 +243,23 @@ class ChosenPlanTomorrowReminderToConsultantNotification implements BaseNotifica final bool isRead; final String planName; final String customerName; + @override + final DateTime createdAt; ChosenPlanTomorrowReminderToConsultantNotification({ required this.type, required this.planName, required this.customerName, required this.isRead, + required this.createdAt, }); - factory ChosenPlanTomorrowReminderToConsultantNotification.fromJson(Map json, NotificationType type, {bool? isRead}) { + factory ChosenPlanTomorrowReminderToConsultantNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return ChosenPlanTomorrowReminderToConsultantNotification( type: type, planName: json['plan_name'], customerName: json['client_name'], isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); } } @@ -253,19 +271,23 @@ class PlanSubscriptionPaidToAuthorNotification implements BaseNotificationModel final bool isRead; final String planName; final String customerName; + @override + final DateTime createdAt; PlanSubscriptionPaidToAuthorNotification({ required this.type, required this.isRead, required this.planName, required this.customerName, + required this.createdAt, }); - factory PlanSubscriptionPaidToAuthorNotification.fromJson(Map json, NotificationType type, {bool? isRead}) { + factory PlanSubscriptionPaidToAuthorNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return PlanSubscriptionPaidToAuthorNotification( type: type, isRead: isRead ?? false, planName: json['plan_name'], customerName: json['user_name'], + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); } } @@ -278,21 +300,525 @@ class ChosenPlanFinishedToConsultantNotification implements BaseNotificationMode final String planName; final String customerName; final int planId; + @override + final DateTime createdAt; ChosenPlanFinishedToConsultantNotification({ required this.type, required this.planName, required this.customerName, required this.planId, required this.isRead, + required this.createdAt, }); - factory ChosenPlanFinishedToConsultantNotification.fromJson(Map json, NotificationType type, {bool? isRead}) { + factory ChosenPlanFinishedToConsultantNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return ChosenPlanFinishedToConsultantNotification( type: type, planName: json['plan_name'], customerName: json['client_name'], planId: json['plan_id'], isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + ); + } +} + +class ChosenPlanLastDayFinishedForUser implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + @override + final DateTime createdAt; + + ChosenPlanLastDayFinishedForUser({required this.type, required this.isRead, required this.createdAt}); + + factory ChosenPlanLastDayFinishedForUser.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return ChosenPlanLastDayFinishedForUser( + type: type, + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + ); + } +} + +class ChosenPlanConsultantLastDayStarted implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + final String planName; + final int planId; + final String chatId; + final String chatUrl; + @override + final DateTime createdAt; + + ChosenPlanConsultantLastDayStarted({ + required this.type, + required this.planName, + required this.planId, + required this.chatId, + required this.chatUrl, + required this.isRead, + required this.createdAt, + }); + + factory ChosenPlanConsultantLastDayStarted.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return ChosenPlanConsultantLastDayStarted( + type: type, + planName: json['plan_name'], + planId: json['plan_id'], + chatId: json['chat_id'], + chatUrl: json['chat_url'], + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + ); + } +} + +class ComplaintToModerationForUser implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + final String message; + final int entityId; + final String entityType; + @override + final DateTime createdAt; + + ComplaintToModerationForUser({ + required this.type, + required this.message, + required this.entityId, + required this.entityType, + required this.isRead, + required this.createdAt, + }); + + factory ComplaintToModerationForUser.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return ComplaintToModerationForUser( + type: type, + message: json['message'], + entityId: json['entity_id'], + entityType: json['entity_type'], + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + ); + } +} + +class CommentReplied implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + final String entityName; + final int commentedEntityId; + final String commentedEntityType; + final String author; + final int authorId; + final int parentCommentId; + @override + final DateTime createdAt; + + CommentReplied({ + required this.type, + required this.entityName, + required this.commentedEntityId, + required this.commentedEntityType, + required this.author, + required this.authorId, + required this.parentCommentId, + required this.isRead, + required this.createdAt, + }); + + factory CommentReplied.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return CommentReplied( + type: type, + entityName: json['entity_name'], + commentedEntityId: json['commented_entity_id'], + commentedEntityType: json['commented_entity_type'], + author: json['author'], + authorId: json['author_id'], + parentCommentId: json['parrent_comment_id'], + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + ); + } +} + +class ScoreAdded implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + final String scoreKey; + final int scorableId; + final String scorableType; + final String scorableName; + final String scorableSlug; + final int scoreValue; + @override + final DateTime createdAt; + + ScoreAdded({ + required this.type, + required this.scoreKey, + required this.scorableId, + required this.scorableType, + required this.scorableName, + required this.scorableSlug, + required this.scoreValue, + required this.isRead, + required this.createdAt, + }); + + factory ScoreAdded.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return ScoreAdded( + type: type, + scoreKey: json['score_key'], + scorableId: json['scorable_id'], + scorableType: json['scorable_type'], + scorableName: json['scorable_name'] ?? '', + scorableSlug: json['scorable_slug'] ?? '', + scoreValue: json['score_value'], + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + ); + } +} + +class EntityCommented implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + final String entityName; + final int entityId; + final String entityType; + final String entityTranslatedType; + final String entityUrl; + final String authorName; + final int authorId; + final String authorSlug; + @override + final DateTime createdAt; + + EntityCommented({ + required this.type, + required this.entityName, + required this.entityId, + required this.entityType, + required this.entityTranslatedType, + required this.entityUrl, + required this.authorName, + required this.authorId, + required this.authorSlug, + required this.isRead, + required this.createdAt, + }); + + factory EntityCommented.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return EntityCommented( + type: type, + entityName: json['entity_name'], + entityId: json['entity_id'], + entityType: json['entity_type'], + entityTranslatedType: json['entity_traslated_type'], + entityUrl: json['entity_url'], + authorName: json['author_name'], + authorId: json['author_id'], + authorSlug: json['author_slug'], + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + ); + } +} + +class PlanSubscriptionExpiredNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + final String planName; + final String customerName; + @override + final DateTime createdAt; + + PlanSubscriptionExpiredNotification({required this.type, required this.planName, required this.customerName, required this.isRead, required this.createdAt}); + + factory PlanSubscriptionExpiredNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return PlanSubscriptionExpiredNotification( + type: type, + planName: json['plan_name'], + customerName: json['user_name'], + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + ); + } +} + +class PlanSubscriptionRenewedNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + final String planName; + final String customerName; + final String renewalDate; + @override + final DateTime createdAt; + + PlanSubscriptionRenewedNotification({ + required this.type, + required this.planName, + required this.customerName, + required this.renewalDate, + required this.isRead, + required this.createdAt, + }); + + factory PlanSubscriptionRenewedNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return PlanSubscriptionRenewedNotification( + type: type, + planName: json['plan_name'], + customerName: json['user_name'], + renewalDate: json['renewal_date'], + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + ); + } +} + +class ConsultantAssignedNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + final String consultantName; + final String planName; + final int planId; + @override + final DateTime createdAt; + + ConsultantAssignedNotification({ + required this.type, + required this.consultantName, + required this.planName, + required this.planId, + required this.isRead, + required this.createdAt, + }); + + factory ConsultantAssignedNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return ConsultantAssignedNotification( + type: type, + consultantName: json['consultant_name'], + planName: json['plan_name'], + planId: json['plan_id'], + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + ); + } +} + +class ConsultantUnassignedNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + final String consultantName; + final String planName; + final int planId; + @override + final DateTime createdAt; + + ConsultantUnassignedNotification({ + required this.type, + required this.consultantName, + required this.planName, + required this.planId, + required this.isRead, + required this.createdAt, + }); + + factory ConsultantUnassignedNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return ConsultantUnassignedNotification( + type: type, + consultantName: json['consultant_name'], + planName: json['plan_name'], + planId: json['plan_id'], + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + ); + } +} + +class PaymentFailedNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + final String planName; + final String errorMessage; + final String paymentMethod; + @override + final DateTime createdAt; + + PaymentFailedNotification({ + required this.type, + required this.planName, + required this.errorMessage, + required this.paymentMethod, + required this.isRead, + required this.createdAt, + }); + + factory PaymentFailedNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return PaymentFailedNotification( + type: type, + planName: json['plan_name'], + errorMessage: json['error_message'], + paymentMethod: json['payment_method'], + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + ); + } +} + +class PaymentSuccessfulNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + final String planName; + final double amount; + final String currency; + final String transactionId; + @override + final DateTime createdAt; + + PaymentSuccessfulNotification({ + required this.type, + required this.planName, + required this.amount, + required this.currency, + required this.transactionId, + required this.isRead, + required this.createdAt, + }); + + factory PaymentSuccessfulNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return PaymentSuccessfulNotification( + type: type, + planName: json['plan_name'], + amount: (json['amount'] as num).toDouble(), + currency: json['currency'], + transactionId: json['transaction_id'], + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + ); + } +} + +class GoalAchievedNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + final String goalName; + final String goalType; + final double achievedValue; + final double targetValue; + @override + final DateTime createdAt; + + GoalAchievedNotification({ + required this.type, + required this.goalName, + required this.goalType, + required this.achievedValue, + required this.targetValue, + required this.isRead, + required this.createdAt, + }); + + factory GoalAchievedNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return GoalAchievedNotification( + type: type, + goalName: json['goal_name'], + goalType: json['goal_type'], + achievedValue: (json['achieved_value'] as num).toDouble(), + targetValue: (json['target_value'] as num).toDouble(), + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + ); + } +} + +class WorkoutReminderNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + final String workoutName; + final String workoutType; + final int duration; + @override + final DateTime createdAt; + + WorkoutReminderNotification({ + required this.type, + required this.workoutName, + required this.workoutType, + required this.duration, + required this.isRead, + required this.createdAt, + }); + + factory WorkoutReminderNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return WorkoutReminderNotification( + type: type, + workoutName: json['workout_name'], + workoutType: json['workout_type'], + duration: json['duration'], + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + ); + } +} + +class MealReminderNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + final String mealName; + final String mealType; + final int calories; + @override + final DateTime createdAt; + + MealReminderNotification({ + required this.type, + required this.mealName, + required this.mealType, + required this.calories, + required this.isRead, + required this.createdAt, + }); + + factory MealReminderNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return MealReminderNotification( + type: type, + mealName: json['meal_name'], + mealType: json['meal_type'], + calories: json['calories'], + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); } } @@ -302,14 +828,943 @@ class UnknownNotification implements BaseNotificationModel { final NotificationType type; @override final bool isRead; + @override + final DateTime createdAt; - UnknownNotification({ + UnknownNotification({required this.type, this.isRead = true, DateTime? createdAt}) : createdAt = createdAt ?? DateTime.fromMillisecondsSinceEpoch(0); +} + +class DietCancelledNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + final String dietName; + @override + final DateTime createdAt; + + DietCancelledNotification({required this.type, required this.dietName, required this.isRead, required this.createdAt}); + + factory DietCancelledNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return DietCancelledNotification( + type: type, + dietName: json['diet_name'] ?? '', + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + ); + } +} + +class DietStartedNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + final String dietName; + @override + final DateTime createdAt; + + DietStartedNotification({required this.type, required this.dietName, required this.isRead, required this.createdAt}); + + factory DietStartedNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return DietStartedNotification( + type: type, + dietName: json['diet_name'] ?? '', + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + ); + } +} + +class DietExpiredNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + final String dietName; + @override + final DateTime createdAt; + + DietExpiredNotification({required this.type, required this.dietName, required this.isRead, required this.createdAt}); + + factory DietExpiredNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return DietExpiredNotification( + type: type, + dietName: json['diet_name'] ?? '', + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + ); + } +} + +class DietWillStartedInNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + final String dietName; + final String startDate; + @override + final DateTime createdAt; + + DietWillStartedInNotification({required this.type, required this.dietName, required this.startDate, required this.isRead, required this.createdAt}); + + factory DietWillStartedInNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return DietWillStartedInNotification( + type: type, + dietName: json['diet_name'] ?? '', + startDate: json['start_date'] ?? '', + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + ); + } +} + +class DietCanNotBeStartedNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + final String dietName; + final String reason; + @override + final DateTime createdAt; + + DietCanNotBeStartedNotification({required this.type, required this.dietName, required this.reason, required this.isRead, required this.createdAt}); + + factory DietCanNotBeStartedNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return DietCanNotBeStartedNotification( + type: type, + dietName: json['diet_name'] ?? '', + reason: json['reason'] ?? '', + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + ); + } +} + +class PersonalDietCreationNeededNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + final String planName; + final String clientName; + @override + final DateTime createdAt; + + PersonalDietCreationNeededNotification({required this.type, required this.planName, required this.clientName, required this.isRead, required this.createdAt}); + + factory PersonalDietCreationNeededNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return PersonalDietCreationNeededNotification( + type: type, + planName: json['plan_name'] ?? '', + clientName: json['client_name'] ?? '', + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + ); + } +} + +class ChosenPlanClientLastDayFinishedNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + final String planName; + @override + final DateTime createdAt; + + ChosenPlanClientLastDayFinishedNotification({required this.type, required this.planName, required this.isRead, required this.createdAt}); + + factory ChosenPlanClientLastDayFinishedNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return ChosenPlanClientLastDayFinishedNotification( + type: type, + planName: json['plan_name'] ?? '', + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + ); + } +} + +class ChosenPlanClientLastDayStartedNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + final String planName; + @override + final DateTime createdAt; + + ChosenPlanClientLastDayStartedNotification({required this.type, required this.planName, required this.isRead, required this.createdAt}); + + factory ChosenPlanClientLastDayStartedNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return ChosenPlanClientLastDayStartedNotification( + type: type, + planName: json['plan_name'] ?? '', + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + ); + } +} + +class StayOnTrackToAuthorNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + final String planName; + final String clientName; + @override + final DateTime createdAt; + + StayOnTrackToAuthorNotification({required this.type, required this.planName, required this.clientName, required this.isRead, required this.createdAt}); + + factory StayOnTrackToAuthorNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return StayOnTrackToAuthorNotification( + type: type, + planName: json['plan_name'] ?? '', + clientName: json['client_name'] ?? '', + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + ); + } +} + +class ClientStartPlanSubscriptionToSubscriberNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + final String planName; + @override + final DateTime createdAt; + + ClientStartPlanSubscriptionToSubscriberNotification({required this.type, required this.planName, required this.isRead, required this.createdAt}); + + factory ClientStartPlanSubscriptionToSubscriberNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return ClientStartPlanSubscriptionToSubscriberNotification( + type: type, + planName: json['plan_name'] ?? '', + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + ); + } +} + +class ConsultantConfirmPlanSubscriptionToSubscriberNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + final String planName; + final String consultantName; + @override + final DateTime createdAt; + + ConsultantConfirmPlanSubscriptionToSubscriberNotification({ required this.type, - this.isRead = true, + required this.planName, + required this.consultantName, + required this.isRead, + required this.createdAt, }); + + factory ConsultantConfirmPlanSubscriptionToSubscriberNotification.fromJson( + Map json, + NotificationType type, { + bool? isRead, + String? createdAt, + }) { + return ConsultantConfirmPlanSubscriptionToSubscriberNotification( + type: type, + planName: json['plan_name'] ?? '', + consultantName: json['consultant_name'] ?? '', + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + ); + } +} + +class ClientPaidPlanSubscriptionToAuthorNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + final String planName; + final String clientName; + final double amount; + @override + final DateTime createdAt; + + ClientPaidPlanSubscriptionToAuthorNotification({ + required this.type, + required this.planName, + required this.clientName, + required this.amount, + required this.isRead, + required this.createdAt, + }); + + factory ClientPaidPlanSubscriptionToAuthorNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return ClientPaidPlanSubscriptionToAuthorNotification( + type: type, + planName: json['plan_name'] ?? '', + clientName: json['client_name'] ?? '', + amount: (json['amount'] as num?)?.toDouble() ?? 0.0, + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + ); + } +} + +class ConsultantLeftDiaryCommentNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + final String planName; + final String consultantName; + final DateTime diaryDate; + @override + final DateTime createdAt; + + ConsultantLeftDiaryCommentNotification({ + required this.type, + required this.planName, + required this.consultantName, + required this.diaryDate, + required this.isRead, + required this.createdAt, + }); + + factory ConsultantLeftDiaryCommentNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return ConsultantLeftDiaryCommentNotification( + type: type, + planName: json['plan_name'] ?? '', + consultantName: json['consultant_name'] ?? '', + diaryDate: parseDate(json['diary_date'] ?? DateTime.now().toIso8601String()), + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + ); + } +} + +class ConsultantConfirmPlanSubscriptionToAuthorNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + final String planName; + final String consultantName; + @override + final DateTime createdAt; + + ConsultantConfirmPlanSubscriptionToAuthorNotification({ + required this.type, + required this.planName, + required this.consultantName, + required this.isRead, + required this.createdAt, + }); + + factory ConsultantConfirmPlanSubscriptionToAuthorNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return ConsultantConfirmPlanSubscriptionToAuthorNotification( + type: type, + planName: json['plan_name'] ?? '', + consultantName: json['consultant_name'] ?? '', + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + ); + } +} + +class ClientPaidPlanSubscriptionToSubscriberNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + final String planName; + final double amount; + @override + final DateTime createdAt; + + ClientPaidPlanSubscriptionToSubscriberNotification({ + required this.type, + required this.planName, + required this.amount, + required this.isRead, + required this.createdAt, + }); + + factory ClientPaidPlanSubscriptionToSubscriberNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return ClientPaidPlanSubscriptionToSubscriberNotification( + type: type, + planName: json['plan_name'] ?? '', + amount: (json['amount'] as num?)?.toDouble() ?? 0.0, + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + ); + } +} + +class ClientStartPlanSubscriptionToAuthorNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + final String planName; + final String clientName; + @override + final DateTime createdAt; + + ClientStartPlanSubscriptionToAuthorNotification({ + required this.type, + required this.planName, + required this.clientName, + required this.isRead, + required this.createdAt, + }); + + factory ClientStartPlanSubscriptionToAuthorNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return ClientStartPlanSubscriptionToAuthorNotification( + type: type, + planName: json['plan_name'] ?? '', + clientName: json['client_name'] ?? '', + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + ); + } +} + +class ClientCancelPlanSubscriptionToAuthorNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + final String planName; + final String clientName; + @override + final DateTime createdAt; + + ClientCancelPlanSubscriptionToAuthorNotification({ + required this.type, + required this.planName, + required this.clientName, + required this.isRead, + required this.createdAt, + }); + + factory ClientCancelPlanSubscriptionToAuthorNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return ClientCancelPlanSubscriptionToAuthorNotification( + type: type, + planName: json['plan_name'] ?? '', + clientName: json['client_name'] ?? '', + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + ); + } +} + +class ClientCancelPlanSubscriptionToSubscriberNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + final String planName; + @override + final DateTime createdAt; + + ClientCancelPlanSubscriptionToSubscriberNotification({required this.type, required this.planName, required this.isRead, required this.createdAt}); + + factory ClientCancelPlanSubscriptionToSubscriberNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return ClientCancelPlanSubscriptionToSubscriberNotification( + type: type, + planName: json['plan_name'] ?? '', + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + ); + } +} + +class UserDailyReportCompletedNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + final String planName; + final String clientName; + final DateTime reportDate; + @override + final DateTime createdAt; + + UserDailyReportCompletedNotification({ + required this.type, + required this.planName, + required this.clientName, + required this.reportDate, + required this.isRead, + required this.createdAt, + }); + + factory UserDailyReportCompletedNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return UserDailyReportCompletedNotification( + type: type, + planName: json['plan_name'] ?? '', + clientName: json['client_name'] ?? '', + reportDate: parseDate(json['report_date'] ?? DateTime.now().toIso8601String()), + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + ); + } +} + +class StayOnTrackToCustomerNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + final String planName; + final String consultantName; + @override + final DateTime createdAt; + + StayOnTrackToCustomerNotification({required this.type, required this.planName, required this.consultantName, required this.isRead, required this.createdAt}); + + factory StayOnTrackToCustomerNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return StayOnTrackToCustomerNotification( + type: type, + planName: json['plan_name'] ?? '', + consultantName: json['consultant_name'] ?? '', + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + ); + } +} + +class PhoneChangedNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + final String oldPhone; + final String newPhone; + @override + final DateTime createdAt; + + PhoneChangedNotification({required this.type, required this.oldPhone, required this.newPhone, required this.isRead, required this.createdAt}); + + factory PhoneChangedNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return PhoneChangedNotification( + type: type, + oldPhone: json['old_phone'] ?? '', + newPhone: json['new_phone'] ?? '', + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + ); + } +} + +class SubscriptionNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + final String subscriptionType; + final String planName; + @override + final DateTime createdAt; + + SubscriptionNotification({required this.type, required this.subscriptionType, required this.planName, required this.isRead, required this.createdAt}); + + factory SubscriptionNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return SubscriptionNotification( + type: type, + subscriptionType: json['subscription_type'] ?? '', + planName: json['plan_name'] ?? '', + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + ); + } +} + +class BadgeAwardedNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + final String badgeName; + final String badgeDescription; + @override + final DateTime createdAt; + + BadgeAwardedNotification({required this.type, required this.badgeName, required this.badgeDescription, required this.isRead, required this.createdAt}); + + factory BadgeAwardedNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return BadgeAwardedNotification( + type: type, + badgeName: json['badge_name'] ?? '', + badgeDescription: json['badge_description'] ?? '', + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + ); + } +} + +class BadgeRevokedNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + final String badgeName; + final String reason; + @override + final DateTime createdAt; + + BadgeRevokedNotification({required this.type, required this.badgeName, required this.reason, required this.isRead, required this.createdAt}); + + factory BadgeRevokedNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return BadgeRevokedNotification( + type: type, + badgeName: json['badge_name'] ?? '', + reason: json['reason'] ?? '', + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + ); + } +} + +class ConsultantRequestStartedNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + final String consultantName; + @override + final DateTime createdAt; + + ConsultantRequestStartedNotification({required this.type, required this.consultantName, required this.isRead, required this.createdAt}); + + factory ConsultantRequestStartedNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return ConsultantRequestStartedNotification( + type: type, + consultantName: json['consultant_name'] ?? '', + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + ); + } +} + +class ClientCloseComplaintSuccessNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + final String complaintType; + final String entityName; + @override + final DateTime createdAt; + + ClientCloseComplaintSuccessNotification({ + required this.type, + required this.complaintType, + required this.entityName, + required this.isRead, + required this.createdAt, + }); + + factory ClientCloseComplaintSuccessNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return ClientCloseComplaintSuccessNotification( + type: type, + complaintType: json['complaint_type'] ?? '', + entityName: json['entity_name'] ?? '', + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + ); + } +} + +class ClientRefundSuccessNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + final String planName; + final double amount; + final String reason; + @override + final DateTime createdAt; + + ClientRefundSuccessNotification({ + required this.type, + required this.planName, + required this.amount, + required this.reason, + required this.isRead, + required this.createdAt, + }); + + factory ClientRefundSuccessNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return ClientRefundSuccessNotification( + type: type, + planName: json['plan_name'] ?? '', + amount: (json['amount'] as num?)?.toDouble() ?? 0.0, + reason: json['reason'] ?? '', + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + ); + } +} + +class ConsultantRefundSuccessNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + final String planName; + final String clientName; + final double amount; + @override + final DateTime createdAt; + + ConsultantRefundSuccessNotification({ + required this.type, + required this.planName, + required this.clientName, + required this.amount, + required this.isRead, + required this.createdAt, + }); + + factory ConsultantRefundSuccessNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return ConsultantRefundSuccessNotification( + type: type, + planName: json['plan_name'] ?? '', + clientName: json['client_name'] ?? '', + amount: (json['amount'] as num?)?.toDouble() ?? 0.0, + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + ); + } +} + +class ClientRejectSuccessNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + final String planName; + final String reason; + @override + final DateTime createdAt; + + ClientRejectSuccessNotification({required this.type, required this.planName, required this.reason, required this.isRead, required this.createdAt}); + + factory ClientRejectSuccessNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return ClientRejectSuccessNotification( + type: type, + planName: json['plan_name'] ?? '', + reason: json['reason'] ?? '', + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + ); + } +} + +class ConsultantRejectSuccessNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + final String planName; + final String clientName; + final String reason; + @override + final DateTime createdAt; + + ConsultantRejectSuccessNotification({ + required this.type, + required this.planName, + required this.clientName, + required this.reason, + required this.isRead, + required this.createdAt, + }); + + factory ConsultantRejectSuccessNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return ConsultantRejectSuccessNotification( + type: type, + planName: json['plan_name'] ?? '', + clientName: json['client_name'] ?? '', + reason: json['reason'] ?? '', + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + ); + } +} + +class ScoreSubstractedNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + final String scoreKey; + final int scorableId; + final String scorableType; + final String scorableName; + final String scorableSlug; + final int scoreValue; + @override + final DateTime createdAt; + + ScoreSubstractedNotification({ + required this.type, + required this.scoreKey, + required this.scorableId, + required this.scorableType, + required this.scorableName, + required this.scorableSlug, + required this.scoreValue, + required this.isRead, + required this.createdAt, + }); + + factory ScoreSubstractedNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return ScoreSubstractedNotification( + type: type, + scoreKey: json['score_key'], + scorableId: json['scorable_id'], + scorableType: json['scorable_type'], + scorableName: json['scorable_name'] ?? '', + scorableSlug: json['scorable_slug'] ?? '', + scoreValue: json['score_value'], + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + ); + } +} + +class ProductStatusUpdatedForAuthorNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + final String productName; + final String oldStatus; + final String newStatus; + @override + final DateTime createdAt; + + ProductStatusUpdatedForAuthorNotification({ + required this.type, + required this.productName, + required this.oldStatus, + required this.newStatus, + required this.isRead, + required this.createdAt, + }); + + factory ProductStatusUpdatedForAuthorNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return ProductStatusUpdatedForAuthorNotification( + type: type, + productName: json['product_name'] ?? '', + oldStatus: json['old_status'] ?? '', + newStatus: json['new_status'] ?? '', + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + ); + } +} + +class DiaryReminderForUsersNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + @override + final DateTime createdAt; + + DiaryReminderForUsersNotification({required this.type, required this.isRead, required this.createdAt}); + + factory DiaryReminderForUsersNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return DiaryReminderForUsersNotification( + type: type, + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + ); + } } enum NotificationType { + // Diet related + dietCancelled, + dietStarted, + dietExpired, + dietWillStartedIn, + dietCanNotBeStarted, + + // Plan related + personalDietCreationNeeded, + chosenPlanClientLastDayFinished, + chosenPlanConsultantLastDayStarted, + chosenPlanClientLastDayStarted, + stayOnTrackToAuthor, + chosenPlanStartedToClient, + clientStartPlanSubscriptionToSubscriber, + consultantConfirmPlanSubscriptionToSubscriber, + clientPaidPlanSubscriptionToAuthor, + chosenPlanTomorrowReminderToClient, + chosenPlanTomorrowReminderToConsultant, + chosenPlanStartedToConsultant, + consultantLeftDiaryComment, + chosenPlanFinishedToClient, + consultantConfirmPlanSubscriptionToAuthor, + clientPaidPlanSubscriptionToSubscriber, + clientStartPlanSubscriptionToAuthor, + clientCancelPlanSubscriptionToAuthor, + clientCancelPlanSubscriptionToSubscriber, + userDailyReportCompleted, + chosenPlanFinishedToConsultant, + stayOnTrackToCustomer, + + // Comments + entityCommented, + commentReplied, + + // Profile + phoneChanged, + + // Subscription + subscription, + + // Badges + badgeAwarded, + badgeRevoked, + + // Consultant + consultantRequestStarted, + + // Complaints + clientCloseComplaintSuccess, + complaintToModerationForUser, + + // Finance + clientRefundSuccess, + consultantRefundSuccess, + clientRejectSuccess, + consultantRejectSuccess, + + // Score + scoreAdded, + scoreSubstracted, + + // Product + productStatusUpdatedForAuthor, + + // Diary + diaryReminderForUsers, + + // Legacy types (keeping for backward compatibility) planSubscriptionStartedToAuthor, planSubscriptionCanceledToAuthor, chosenPlanStartedToClientNotification, @@ -317,28 +1772,100 @@ enum NotificationType { diaryReminderNotification, consultantLeftComment, personalizedDietIsDefined, - chosenPlanFinishedToClient, planSubscriptionCanceledToCustomer, chosenPlanStartedToConsultantNotification, chosenPlanTomorrowReminderToConsultantNotification, planSubscriptionPaidToAuthor, - chosenPlanFinishedToConsultant, + chosenPlanLastDayFinishedForUser, + planSubscriptionExpired, + planSubscriptionRenewed, + consultantAssigned, + consultantUnassigned, + paymentFailed, + paymentSuccessful, + goalAchieved, + workoutReminder, + mealReminder, unknown; static NotificationType getTypeFromString(String type) => switch (type) { - 'App\\Notifications\\Plan\\PlanSubscriptionStartedToAuthor' => NotificationType.planSubscriptionStartedToAuthor, - 'App\\Notifications\\Plan\\PlanSubscriptionCanceledToAuthor' => NotificationType.planSubscriptionCanceledToAuthor, - 'App\\Notifications\\Plan\\ChosenPlanStartedToClientNotification' => NotificationType.chosenPlanStartedToClientNotification, - 'App\\Notifications\\Plan\\ChosenPlanTomorrowReminderToClientNotification' => NotificationType.chosenPlanStartedToClientNotification, - 'App\\Notifications\\DiaryReminderNotification' => NotificationType.diaryReminderNotification, - 'App\\Notifications\\Plan\\ConsultantLeftComment' => NotificationType.consultantLeftComment, - 'App\\Notifications\\Plan\\PersonalizedDietIsDefined' => NotificationType.personalizedDietIsDefined, - 'App\\Notifications\\Plan\\ChosenPlanFinishedToClient' => NotificationType.chosenPlanFinishedToClient, - 'App\\Notifications\\Plan\\PlanSubscriptionCanceledToCustomer' => NotificationType.planSubscriptionCanceledToCustomer, - 'App\\Notifications\\Plan\\ChosenPlanStartedToConsultantNotification' => NotificationType.chosenPlanStartedToConsultantNotification, - 'App\\Notifications\\Plan\\ChosenPlanTomorrowReminderToConsultantNotification' => NotificationType.chosenPlanTomorrowReminderToConsultantNotification, - 'App\\Notifications\\Plan\\PlanSubscriptionPaidToAuthor' => NotificationType.planSubscriptionPaidToAuthor, - 'App\\Notifications\\Plan\\ChosenPlanFinishedToConsultant' => NotificationType.chosenPlanFinishedToConsultant, - _ => NotificationType.unknown, - }; + // New type-based mapping + 'diet-cancelled' => NotificationType.dietCancelled, + 'entity-commented' => NotificationType.entityCommented, + 'comment-replied' => NotificationType.commentReplied, + 'phone-changed' => NotificationType.phoneChanged, + 'diet-started' => NotificationType.dietStarted, + 'personal-diet-creation-needed-to-author' => NotificationType.personalDietCreationNeeded, + 'chosen-plan-client-last-day-finished' => NotificationType.chosenPlanClientLastDayFinished, + 'chosen-plan-consultant-last-day-started' => NotificationType.chosenPlanConsultantLastDayStarted, + 'chosen-plan-client-last-day-started' => NotificationType.chosenPlanClientLastDayStarted, + 'subscription' => NotificationType.subscription, + 'badge-awarded' => NotificationType.badgeAwarded, + 'badge-revoked' => NotificationType.badgeRevoked, + 'diet-expired' => NotificationType.dietExpired, + 'consultant-request-started' => NotificationType.consultantRequestStarted, + 'diet-will-started-in' => NotificationType.dietWillStartedIn, + 'client-close-complaint-success' => NotificationType.clientCloseComplaintSuccess, + 'complaint-to-moderation-for-user' => NotificationType.complaintToModerationForUser, + 'client-refund-success' => NotificationType.clientRefundSuccess, + 'consultant-refund-success' => NotificationType.consultantRefundSuccess, + 'client-reject-success' => NotificationType.clientRejectSuccess, + 'consultant-reject-success' => NotificationType.consultantRejectSuccess, + 'score-added' => NotificationType.scoreAdded, + 'score-substracted' => NotificationType.scoreSubstracted, + 'product-status-updated-for-author' => NotificationType.productStatusUpdatedForAuthor, + 'diet-can-not-started' => NotificationType.dietCanNotBeStarted, + 'stay-on-track-to-author' => NotificationType.stayOnTrackToAuthor, + 'chosen-plan-started-to-client' => NotificationType.chosenPlanStartedToClient, + 'client-start-plan-subscription-to-subscriber' => NotificationType.clientStartPlanSubscriptionToSubscriber, + 'consultant-confirm-plan-subscription-to-subscriber' => NotificationType.consultantConfirmPlanSubscriptionToSubscriber, + 'client-paid-plan-subscription-to-author' => NotificationType.clientPaidPlanSubscriptionToAuthor, + 'chosen-plan-tomorrow-reminder-to-client' => NotificationType.chosenPlanTomorrowReminderToClient, + 'chosen-plan-tomorrow-reminder-to-consultant' => NotificationType.chosenPlanTomorrowReminderToConsultant, + 'chosen-plan-started-to-consultant' => NotificationType.chosenPlanStartedToConsultant, + 'consultant-left-diary-comment' => NotificationType.consultantLeftDiaryComment, + 'chosen-plan-finished-to-client' => NotificationType.chosenPlanFinishedToClient, + 'consultant-confirm-plan-subscription-to-author' => NotificationType.consultantConfirmPlanSubscriptionToAuthor, + 'client-paid-plan-subscription-to-subscriber' => NotificationType.clientPaidPlanSubscriptionToSubscriber, + 'client-start-plan-subscription-to-author' => NotificationType.clientStartPlanSubscriptionToAuthor, + 'client-cancel-plan-subscription-to-author' => NotificationType.clientCancelPlanSubscriptionToAuthor, + 'client-cancel-plan-subscription-to-subscriber' => NotificationType.clientCancelPlanSubscriptionToSubscriber, + 'user-daily-report-completed' => NotificationType.userDailyReportCompleted, + 'chosen-plan-finished-to-consultant' => NotificationType.chosenPlanFinishedToConsultant, + 'stay-on-track-to-customer' => NotificationType.stayOnTrackToCustomer, + 'diary-reminder-for-users' => NotificationType.diaryReminderForUsers, + + // Legacy mapping + 'App\\Notifications\\Plan\\PlanSubscriptionStartedToAuthor' => NotificationType.planSubscriptionStartedToAuthor, + 'App\\Notifications\\Plan\\PlanSubscriptionCanceledToAuthor' => NotificationType.planSubscriptionCanceledToAuthor, + 'App\\Notifications\\Plan\\ChosenPlanStartedToClientNotification' => NotificationType.chosenPlanStartedToClientNotification, + 'App\\Notifications\\Plan\\ChosenPlanTomorrowReminderToClientNotification' => NotificationType.chosenPlanTomorrowReminderToClientNotification, + 'App\\Notifications\\DiaryReminderNotification' => NotificationType.diaryReminderNotification, + 'App\\Notifications\\Plan\\ConsultantLeftComment' => NotificationType.consultantLeftComment, + 'App\\Notifications\\Plan\\PersonalizedDietIsDefined' => NotificationType.personalizedDietIsDefined, + 'App\\Notifications\\Plan\\ChosenPlanFinishedToClient' => NotificationType.chosenPlanFinishedToClient, + 'App\\Notifications\\Plan\\PlanSubscriptionCanceledToCustomer' => NotificationType.planSubscriptionCanceledToCustomer, + 'App\\Notifications\\Plan\\ChosenPlanStartedToConsultantNotification' => NotificationType.chosenPlanStartedToConsultantNotification, + 'App\\Notifications\\Plan\\ChosenPlanTomorrowReminderToConsultantNotification' => NotificationType.chosenPlanTomorrowReminderToConsultantNotification, + 'App\\Notifications\\Plan\\PlanSubscriptionPaidToAuthor' => NotificationType.planSubscriptionPaidToAuthor, + 'App\\Notifications\\Plan\\ChosenPlanFinishedToConsultant' => NotificationType.chosenPlanFinishedToConsultant, + 'App\\Notifications\\ChosenPlan\\LastDayFinishedForUser' => NotificationType.chosenPlanLastDayFinishedForUser, + 'App\\Notifications\\ChosenPlan\\LastDayStartedForConsultant' => NotificationType.chosenPlanConsultantLastDayStarted, + 'App\\Notifications\\ComplaintToModerationNotify' => NotificationType.complaintToModerationForUser, + 'App\\Notifications\\Comment\\Replied' => NotificationType.commentReplied, + 'App\\Notifications\\Score\\ScoreAdded' => NotificationType.scoreAdded, + 'App\\Notifications\\Comment\\EntityCommented' => NotificationType.entityCommented, + 'App\\Notifications\\Plan\\PlanSubscriptionExpired' => NotificationType.planSubscriptionExpired, + 'App\\Notifications\\Plan\\PlanSubscriptionRenewed' => NotificationType.planSubscriptionRenewed, + 'App\\Notifications\\Plan\\ConsultantAssigned' => NotificationType.consultantAssigned, + 'App\\Notifications\\Plan\\ConsultantUnassigned' => NotificationType.consultantUnassigned, + 'App\\Notifications\\Payment\\PaymentFailed' => NotificationType.paymentFailed, + 'App\\Notifications\\Payment\\PaymentSuccessful' => NotificationType.paymentSuccessful, + 'App\\Notifications\\Goal\\GoalAchieved' => NotificationType.goalAchieved, + 'App\\Notifications\\Workout\\WorkoutReminder' => NotificationType.workoutReminder, + 'App\\Notifications\\Meal\\MealReminder' => NotificationType.mealReminder, + _ => NotificationType.unknown, + }; } + +DateTime parseDate(String date) => DateTime.parse(date).toLocal(); diff --git a/lib/features/notifications/presentation/notifications_cubit/notifications_cubit.dart b/lib/features/notifications/presentation/notifications_cubit/notifications_cubit.dart index 16cf1f40..8c8c5aaa 100644 --- a/lib/features/notifications/presentation/notifications_cubit/notifications_cubit.dart +++ b/lib/features/notifications/presentation/notifications_cubit/notifications_cubit.dart @@ -13,10 +13,7 @@ class NotificationsCubit extends Cubit { final WsManager wsManager; final NotificationsRepository notificationsRepository; StreamSubscription? _newNotificationsSubscription; - NotificationsCubit({ - required this.wsManager, - required this.notificationsRepository, - }) : super(const NotificationsState.empty()); + NotificationsCubit({required this.wsManager, required this.notificationsRepository}) : super(const NotificationsState.empty()); Future connectToWebSocket() async { await _newNotificationsSubscription?.cancel(); @@ -27,21 +24,243 @@ class NotificationsCubit extends Cubit { BaseNotificationModel _parseNotification(Map notificationJson, NotificationType type) { try { + final createdAt = notificationJson['created_at']; + final isRead = notificationJson['read_at'] != null; + return switch (type) { - NotificationType.planSubscriptionStartedToAuthor => PlanSubscriptionStartedToAuthorNotification.fromJson(notificationJson, type), - NotificationType.planSubscriptionCanceledToAuthor => PlanSubscriptionCanceledToAuthorNotification.fromJson(notificationJson, type), - NotificationType.chosenPlanStartedToClientNotification => ChosenPlanStartedToClientNotification.fromJson(notificationJson, type), - NotificationType.chosenPlanTomorrowReminderToClientNotification => ChosenPlanTomorrowReminderToClientNotification.fromJson(notificationJson, type), - NotificationType.diaryReminderNotification => DiaryReminderNotification.fromJson(notificationJson, type), - NotificationType.consultantLeftComment => ConsultantLeftCommentNotification.fromJson(notificationJson, type), - NotificationType.personalizedDietIsDefined => PersonalizedDietIsDefinedNotification.fromJson(notificationJson, type), - NotificationType.chosenPlanFinishedToClient => ChosenPlanFinishedToClientNotification.fromJson(notificationJson, type), - NotificationType.planSubscriptionCanceledToCustomer => PlanSubscriptionCanceledToAuthorNotification.fromJson(notificationJson, type), - NotificationType.chosenPlanStartedToConsultantNotification => ChosenPlanStartedToConsultantNotification.fromJson(notificationJson, type), - NotificationType.chosenPlanTomorrowReminderToConsultantNotification => - ChosenPlanTomorrowReminderToConsultantNotification.fromJson(notificationJson, type), - NotificationType.planSubscriptionPaidToAuthor => PlanSubscriptionPaidToAuthorNotification.fromJson(notificationJson, type), - NotificationType.chosenPlanFinishedToConsultant => ChosenPlanFinishedToConsultantNotification.fromJson(notificationJson, type), + NotificationType.planSubscriptionStartedToAuthor => PlanSubscriptionStartedToAuthorNotification.fromJson( + notificationJson, + type, + isRead: isRead, + createdAt: createdAt, + ), + NotificationType.planSubscriptionCanceledToAuthor => PlanSubscriptionCanceledToAuthorNotification.fromJson( + notificationJson, + type, + isRead: isRead, + createdAt: createdAt, + ), + NotificationType.chosenPlanStartedToClientNotification => ChosenPlanStartedToClientNotification.fromJson( + notificationJson, + type, + isRead: isRead, + createdAt: createdAt, + ), + NotificationType.chosenPlanTomorrowReminderToClientNotification => ChosenPlanTomorrowReminderToClientNotification.fromJson( + notificationJson, + type, + isRead: isRead, + createdAt: createdAt, + ), + NotificationType.chosenPlanConsultantLastDayStarted => ChosenPlanConsultantLastDayStarted.fromJson( + notificationJson, + type, + isRead: isRead, + createdAt: createdAt, + ), + NotificationType.diaryReminderNotification => DiaryReminderNotification.fromJson(notificationJson, type, isRead: isRead, createdAt: createdAt), + NotificationType.consultantLeftComment => ConsultantLeftCommentNotification.fromJson(notificationJson, type, isRead: isRead, createdAt: createdAt), + NotificationType.personalizedDietIsDefined => PersonalizedDietIsDefinedNotification.fromJson( + notificationJson, + type, + isRead: isRead, + createdAt: createdAt, + ), + NotificationType.planSubscriptionCanceledToCustomer => PlanSubscriptionCanceledToAuthorNotification.fromJson( + notificationJson, + type, + isRead: isRead, + createdAt: createdAt, + ), + NotificationType.chosenPlanStartedToConsultantNotification => ChosenPlanStartedToConsultantNotification.fromJson( + notificationJson, + type, + isRead: isRead, + createdAt: createdAt, + ), + NotificationType.chosenPlanTomorrowReminderToConsultantNotification => ChosenPlanTomorrowReminderToConsultantNotification.fromJson( + notificationJson, + type, + isRead: isRead, + createdAt: createdAt, + ), + NotificationType.planSubscriptionPaidToAuthor => PlanSubscriptionPaidToAuthorNotification.fromJson( + notificationJson, + type, + isRead: isRead, + createdAt: createdAt, + ), + NotificationType.chosenPlanLastDayFinishedForUser => ChosenPlanLastDayFinishedForUser.fromJson( + notificationJson, + type, + isRead: isRead, + createdAt: createdAt, + ), + // New notification types + NotificationType.complaintToModerationForUser => ComplaintToModerationForUser.fromJson(notificationJson, type, isRead: isRead, createdAt: createdAt), + NotificationType.commentReplied => CommentReplied.fromJson(notificationJson, type, isRead: isRead, createdAt: createdAt), + NotificationType.scoreAdded => ScoreAdded.fromJson(notificationJson, type, isRead: isRead, createdAt: createdAt), + NotificationType.entityCommented => EntityCommented.fromJson(notificationJson, type, isRead: isRead, createdAt: createdAt), + // Новые типы уведомлений + NotificationType.planSubscriptionExpired => PlanSubscriptionExpiredNotification.fromJson(notificationJson, type, isRead: isRead, createdAt: createdAt), + NotificationType.planSubscriptionRenewed => PlanSubscriptionRenewedNotification.fromJson(notificationJson, type, isRead: isRead, createdAt: createdAt), + NotificationType.consultantAssigned => ConsultantAssignedNotification.fromJson(notificationJson, type, isRead: isRead, createdAt: createdAt), + NotificationType.consultantUnassigned => ConsultantUnassignedNotification.fromJson(notificationJson, type, isRead: isRead, createdAt: createdAt), + NotificationType.paymentFailed => PaymentFailedNotification.fromJson(notificationJson, type, isRead: isRead, createdAt: createdAt), + NotificationType.paymentSuccessful => PaymentSuccessfulNotification.fromJson(notificationJson, type, isRead: isRead, createdAt: createdAt), + NotificationType.goalAchieved => GoalAchievedNotification.fromJson(notificationJson, type, isRead: isRead, createdAt: createdAt), + NotificationType.workoutReminder => WorkoutReminderNotification.fromJson(notificationJson, type, isRead: isRead, createdAt: createdAt), + NotificationType.mealReminder => MealReminderNotification.fromJson(notificationJson, type, isRead: isRead, createdAt: createdAt), + // New notification types + NotificationType.dietCancelled => DietCancelledNotification.fromJson(notificationJson, type, isRead: isRead, createdAt: createdAt), + NotificationType.dietStarted => DietStartedNotification.fromJson(notificationJson, type, isRead: isRead, createdAt: createdAt), + NotificationType.dietExpired => DietExpiredNotification.fromJson(notificationJson, type, isRead: isRead, createdAt: createdAt), + NotificationType.dietWillStartedIn => DietWillStartedInNotification.fromJson(notificationJson, type, isRead: isRead, createdAt: createdAt), + NotificationType.dietCanNotBeStarted => DietCanNotBeStartedNotification.fromJson(notificationJson, type, isRead: isRead, createdAt: createdAt), + NotificationType.personalDietCreationNeeded => PersonalDietCreationNeededNotification.fromJson( + notificationJson, + type, + isRead: isRead, + createdAt: createdAt, + ), + NotificationType.chosenPlanClientLastDayFinished => ChosenPlanClientLastDayFinishedNotification.fromJson( + notificationJson, + type, + isRead: isRead, + createdAt: createdAt, + ), + NotificationType.chosenPlanClientLastDayStarted => ChosenPlanClientLastDayStartedNotification.fromJson( + notificationJson, + type, + isRead: isRead, + createdAt: createdAt, + ), + NotificationType.stayOnTrackToAuthor => StayOnTrackToAuthorNotification.fromJson(notificationJson, type, isRead: isRead, createdAt: createdAt), + NotificationType.chosenPlanStartedToClient => ChosenPlanStartedToClientNotification.fromJson( + notificationJson, + type, + isRead: isRead, + createdAt: createdAt, + ), + NotificationType.clientStartPlanSubscriptionToSubscriber => ClientStartPlanSubscriptionToSubscriberNotification.fromJson( + notificationJson, + type, + isRead: isRead, + createdAt: createdAt, + ), + NotificationType.consultantConfirmPlanSubscriptionToSubscriber => ConsultantConfirmPlanSubscriptionToSubscriberNotification.fromJson( + notificationJson, + type, + isRead: isRead, + createdAt: createdAt, + ), + NotificationType.clientPaidPlanSubscriptionToAuthor => ClientPaidPlanSubscriptionToAuthorNotification.fromJson( + notificationJson, + type, + isRead: isRead, + createdAt: createdAt, + ), + NotificationType.chosenPlanTomorrowReminderToClient => ChosenPlanTomorrowReminderToClientNotification.fromJson( + notificationJson, + type, + isRead: isRead, + createdAt: createdAt, + ), + NotificationType.chosenPlanTomorrowReminderToConsultant => ChosenPlanTomorrowReminderToConsultantNotification.fromJson( + notificationJson, + type, + isRead: isRead, + createdAt: createdAt, + ), + NotificationType.chosenPlanStartedToConsultant => ChosenPlanStartedToConsultantNotification.fromJson( + notificationJson, + type, + isRead: isRead, + createdAt: createdAt, + ), + NotificationType.consultantLeftDiaryComment => ConsultantLeftDiaryCommentNotification.fromJson( + notificationJson, + type, + isRead: isRead, + createdAt: createdAt, + ), + NotificationType.chosenPlanFinishedToClient => ChosenPlanFinishedToClientNotification.fromJson( + notificationJson, + type, + isRead: isRead, + createdAt: createdAt, + ), + NotificationType.consultantConfirmPlanSubscriptionToAuthor => ConsultantConfirmPlanSubscriptionToAuthorNotification.fromJson( + notificationJson, + type, + isRead: isRead, + createdAt: createdAt, + ), + NotificationType.clientPaidPlanSubscriptionToSubscriber => ClientPaidPlanSubscriptionToSubscriberNotification.fromJson( + notificationJson, + type, + isRead: isRead, + createdAt: createdAt, + ), + NotificationType.clientStartPlanSubscriptionToAuthor => ClientStartPlanSubscriptionToAuthorNotification.fromJson( + notificationJson, + type, + isRead: isRead, + createdAt: createdAt, + ), + NotificationType.clientCancelPlanSubscriptionToAuthor => ClientCancelPlanSubscriptionToAuthorNotification.fromJson( + notificationJson, + type, + isRead: isRead, + createdAt: createdAt, + ), + NotificationType.clientCancelPlanSubscriptionToSubscriber => ClientCancelPlanSubscriptionToSubscriberNotification.fromJson( + notificationJson, + type, + isRead: isRead, + createdAt: createdAt, + ), + NotificationType.userDailyReportCompleted => UserDailyReportCompletedNotification.fromJson( + notificationJson, + type, + isRead: isRead, + createdAt: createdAt, + ), + NotificationType.chosenPlanFinishedToConsultant => ChosenPlanFinishedToConsultantNotification.fromJson( + notificationJson, + type, + isRead: isRead, + createdAt: createdAt, + ), + NotificationType.stayOnTrackToCustomer => StayOnTrackToCustomerNotification.fromJson(notificationJson, type, isRead: isRead, createdAt: createdAt), + NotificationType.phoneChanged => PhoneChangedNotification.fromJson(notificationJson, type, isRead: isRead, createdAt: createdAt), + NotificationType.subscription => SubscriptionNotification.fromJson(notificationJson, type, isRead: isRead, createdAt: createdAt), + NotificationType.badgeAwarded => BadgeAwardedNotification.fromJson(notificationJson, type, isRead: isRead, createdAt: createdAt), + NotificationType.badgeRevoked => BadgeRevokedNotification.fromJson(notificationJson, type, isRead: isRead, createdAt: createdAt), + NotificationType.consultantRequestStarted => ConsultantRequestStartedNotification.fromJson( + notificationJson, + type, + isRead: isRead, + createdAt: createdAt, + ), + NotificationType.clientCloseComplaintSuccess => ClientCloseComplaintSuccessNotification.fromJson( + notificationJson, + type, + isRead: isRead, + createdAt: createdAt, + ), + NotificationType.clientRefundSuccess => ClientRefundSuccessNotification.fromJson(notificationJson, type, isRead: isRead, createdAt: createdAt), + NotificationType.consultantRefundSuccess => ConsultantRefundSuccessNotification.fromJson(notificationJson, type, isRead: isRead, createdAt: createdAt), + NotificationType.clientRejectSuccess => ClientRejectSuccessNotification.fromJson(notificationJson, type, isRead: isRead, createdAt: createdAt), + NotificationType.consultantRejectSuccess => ConsultantRejectSuccessNotification.fromJson(notificationJson, type, isRead: isRead, createdAt: createdAt), + NotificationType.scoreSubstracted => ScoreSubstractedNotification.fromJson(notificationJson, type, isRead: isRead, createdAt: createdAt), + NotificationType.productStatusUpdatedForAuthor => ProductStatusUpdatedForAuthorNotification.fromJson( + notificationJson, + type, + isRead: isRead, + createdAt: createdAt, + ), + NotificationType.diaryReminderForUsers => DiaryReminderForUsersNotification.fromJson(notificationJson, type, isRead: isRead, createdAt: createdAt), NotificationType.unknown => UnknownNotification(type: type), }; } catch (e) { @@ -51,36 +270,112 @@ class NotificationsCubit extends Cubit { void _handleNotification(String notificationData) { final json = jsonDecode(notificationData) as Map; - final stringifiedType = json['notification_class_name']; - final type = NotificationType.getTypeFromString(stringifiedType); + + // Улучшенная логика определения типа уведомления + String? stringifiedType; + + // Сначала проверяем поле 'type' в корне JSON + if (json.containsKey('type') && json['type'] != null) { + stringifiedType = json['type'].toString(); + log('Found type in root: $stringifiedType'); + } + // Затем проверяем поле 'notification_class_name' в корне JSON + else if (json.containsKey('notification_class_name') && json['notification_class_name'] != null) { + stringifiedType = json['notification_class_name'].toString(); + log('Found notification_class_name in root: $stringifiedType'); + } + // Если есть поле 'data', проверяем внутри него + else if (json.containsKey('data') && json['data'] is Map) { + final data = json['data'] as Map; + if (data.containsKey('type') && data['type'] != null) { + stringifiedType = data['type'].toString(); + log('Found type in data: $stringifiedType'); + } else if (data.containsKey('notification_class_name') && data['notification_class_name'] != null) { + stringifiedType = data['notification_class_name'].toString(); + log('Found notification_class_name in data: $stringifiedType'); + } + } + + // Если тип не найден, используем unknown + final type = stringifiedType != null ? NotificationType.getTypeFromString(stringifiedType) : NotificationType.unknown; + log('Final type: $type, stringifiedType: $stringifiedType'); + final notification = _parseNotification(json, type); _notifications.insert(0, notification); - emit( - NotificationsState.loaded( - notifications: _notifications, - notificationsCount: _notifications.length, - hasUnreadNotifications: true, - ), - ); + + final currentState = state; + if (currentState is NotificationsStateLoaded) { + emit(currentState.copyWith(notifications: _notifications, notificationsCount: _notifications.length, hasUnreadNotifications: true)); + } } Future getNotifications() async { try { - final notifications = await notificationsRepository.getNotifications(page: 1); - _notifications.clear(); - _notifications.addAll(notifications ?? []); - emit( - NotificationsState.loaded( - notifications: _notifications, - notificationsCount: _notifications.length, - hasUnreadNotifications: (notifications ?? []).where((elem) => elem.isRead == false).isNotEmpty, - ), - ); + final result = await notificationsRepository.getNotifications(page: 1); + if (result != null) { + final notifications = result['notifications'] as List; + final pagination = result['pagination'] as Map; + + _notifications.clear(); + _notifications.addAll(notifications); + + final currentPage = pagination['current_page'] as int; + final lastPage = pagination['last_page'] as int; + final hasNextPage = currentPage < lastPage; + + emit( + NotificationsState.loaded( + notifications: _notifications, + notificationsCount: _notifications.length, + hasUnreadNotifications: notifications.where((elem) => elem.isRead == false).isNotEmpty, + currentPage: currentPage, + hasNextPage: hasNextPage, + ), + ); + } } catch (e, st) { log(e.toString(), stackTrace: st); } } + Future loadMoreNotifications() async { + final currentState = state; + if (currentState is NotificationsStateLoaded && currentState.hasNextPage && !currentState.isLoadingMore) { + try { + emit(currentState.copyWith(isLoadingMore: true)); + + final nextPage = currentState.currentPage + 1; + final result = await notificationsRepository.getNotifications(page: nextPage); + + if (result != null) { + final newNotifications = result['notifications'] as List; + final pagination = result['pagination'] as Map; + + _notifications.addAll(newNotifications); + + final currentPage = pagination['current_page'] as int; + final lastPage = pagination['last_page'] as int; + final hasNextPage = currentPage < lastPage; + + emit( + currentState.copyWith( + notifications: _notifications, + notificationsCount: _notifications.length, + currentPage: currentPage, + hasNextPage: hasNextPage, + isLoadingMore: false, + ), + ); + } else { + emit(currentState.copyWith(isLoadingMore: false)); + } + } catch (e, st) { + log(e.toString(), stackTrace: st); + emit(currentState.copyWith(isLoadingMore: false)); + } + } + } + Future markNotificationsAsRead() async { final currentState = state; if (currentState is NotificationsStateLoaded) { diff --git a/lib/features/notifications/presentation/notifications_cubit/notifications_state.dart b/lib/features/notifications/presentation/notifications_cubit/notifications_state.dart index 9c221b31..391e199e 100644 --- a/lib/features/notifications/presentation/notifications_cubit/notifications_state.dart +++ b/lib/features/notifications/presentation/notifications_cubit/notifications_state.dart @@ -14,6 +14,9 @@ abstract class NotificationsState with _$NotificationsState { required int notificationsCount, @Default(false) bool isLoading, @Default(false) bool hasUnreadNotifications, + @Default(1) int currentPage, + @Default(true) bool hasNextPage, + @Default(false) bool isLoadingMore, }) = NotificationsStateLoaded; const factory NotificationsState.error({ diff --git a/lib/features/notifications/presentation/notifications_screen.dart b/lib/features/notifications/presentation/notifications_screen.dart index ff48617f..58e3c65f 100644 --- a/lib/features/notifications/presentation/notifications_screen.dart +++ b/lib/features/notifications/presentation/notifications_screen.dart @@ -10,9 +10,38 @@ import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -class NotificationsScreen extends StatelessWidget { +class NotificationsScreen extends StatefulWidget { const NotificationsScreen({super.key}); + @override + State createState() => _NotificationsScreenState(); +} + +class _NotificationsScreenState extends State { + final ScrollController _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + void _onScroll() { + if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) { + final cubit = context.read(); + final state = cubit.state; + if (state is NotificationsStateLoaded && state.hasNextPage && !state.isLoadingMore) { + cubit.loadMoreNotifications(); + } + } + } + @override Widget build(BuildContext context) { final s = S.of(context); @@ -20,62 +49,65 @@ class NotificationsScreen extends StatelessWidget { builder: (BuildContext context, NotificationsState state) { return Scaffold( backgroundColor: appTheme.gray100, - appBar: AppBar( - title: const Text('Уведомления'), - ), + appBar: AppBar(title: const Text('Уведомления')), body: state is NotificationsStateLoaded - ? Stack(children: [ - Padding( - padding: const EdgeInsets.only(top: 8.0), - child: Container( - padding: const EdgeInsets.only(top: 16), - decoration: BoxDecoration( - color: appTheme.whiteA700, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(12), - topRight: Radius.circular(12), + ? Stack( + children: [ + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Container( + padding: const EdgeInsets.only(top: 16), + decoration: BoxDecoration( + color: appTheme.whiteA700, + borderRadius: const BorderRadius.only(topLeft: Radius.circular(12), topRight: Radius.circular(12)), + ), + child: state.isLoading + ? const Center(child: Loader()) + : CustomScrollView( + controller: _scrollController, + slivers: [ + SliverList.separated( + separatorBuilder: (context, index) => + const Padding(padding: EdgeInsets.symmetric(vertical: 12, horizontal: 16), child: CustomDivider()), + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: NotificationWidget(notification: state.notifications[index]), + ); + }, + itemCount: state.notifications.length, + ), + if (state.isLoadingMore) + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(16.0), + child: Center(child: Loader()), + ), + ), + ], + ), + ), + ), + Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.only(left: 16, right: 16, bottom: 16), + child: CustomOutlinedButton( + text: s.markAsRead, + height: 52, + buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700), + buttonStyle: ButtonStyle( + side: const WidgetStatePropertyAll(BorderSide.none), + backgroundColor: WidgetStatePropertyAll(appTheme.blueA400), + ), + onPressed: () async { + context.read().markNotificationsAsRead(); + }, ), ), - child: state.isLoading - ? const Center(child: Loader()) - : CustomScrollView( - slivers: [ - SliverList.separated( - separatorBuilder: (context, index) => const Padding( - padding: EdgeInsets.symmetric(vertical: 12, horizontal: 16), - child: CustomDivider(), - ), - itemBuilder: (context, index) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: NotificationWidget( - notification: state.notifications[index], - ), - ); - }, - itemCount: state.notifications.length, - ) - ], - ), ), - ), - Align( - alignment: Alignment.bottomCenter, - child: Padding( - padding: const EdgeInsets.only(left: 16, right: 16, bottom: 16), - child: CustomOutlinedButton( - text: s.markAsRead, - height: 52, - buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700), - buttonStyle: - ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)), - onPressed: () async { - context.read().markNotificationsAsRead(); - }, - ), - ), - ) - ]) + ], + ) : const Placeholder(), ); }, diff --git a/lib/features/notifications/presentation/widgets/notification_widget.dart b/lib/features/notifications/presentation/widgets/notification_widget.dart index 83157593..ed196b00 100644 --- a/lib/features/notifications/presentation/widgets/notification_widget.dart +++ b/lib/features/notifications/presentation/widgets/notification_widget.dart @@ -15,45 +15,105 @@ class NotificationWidget extends StatelessWidget { Widget build(BuildContext context) { return switch (notification) { PlanSubscriptionStartedToAuthorNotification() => _PlanSubscriptionStartedToAuthorWidget( - notification: notification as PlanSubscriptionStartedToAuthorNotification, - ), + notification: notification as PlanSubscriptionStartedToAuthorNotification, + ), PlanSubscriptionCanceledToAuthorNotification() => _PlanSubscriptionCanceledToAuthorWidget( - notification: notification as PlanSubscriptionCanceledToAuthorNotification, - ), - ChosenPlanStartedToClientNotification() => _ChosenPlanStartedToClientWidget( - notification: notification as ChosenPlanStartedToClientNotification, - ), + notification: notification as PlanSubscriptionCanceledToAuthorNotification, + ), + ChosenPlanStartedToClientNotification() => _ChosenPlanStartedToClientWidget(notification: notification as ChosenPlanStartedToClientNotification), ChosenPlanTomorrowReminderToClientNotification() => _ChosenPlanTomorrowReminderToClientWidget( - notification: notification as ChosenPlanTomorrowReminderToClientNotification, - ), - DiaryReminderNotification() => _DiaryReminderWidget( - notification: notification as DiaryReminderNotification, - ), - ConsultantLeftCommentNotification() => _ConsultantLeftCommentWidget( - notification: notification as ConsultantLeftCommentNotification, - ), - PersonalizedDietIsDefinedNotification() => _PersonalizedDietIsDefinedWidget( - notification: notification as PersonalizedDietIsDefinedNotification, - ), - ChosenPlanFinishedToClientNotification() => _ChosenPlanFinishedToClientWidget( - notification: notification as ChosenPlanFinishedToClientNotification, - ), + notification: notification as ChosenPlanTomorrowReminderToClientNotification, + ), + DiaryReminderNotification() => _DiaryReminderWidget(notification: notification as DiaryReminderNotification), + ConsultantLeftCommentNotification() => _ConsultantLeftCommentWidget(notification: notification as ConsultantLeftCommentNotification), + PersonalizedDietIsDefinedNotification() => _PersonalizedDietIsDefinedWidget(notification: notification as PersonalizedDietIsDefinedNotification), + ChosenPlanFinishedToClientNotification() => _ChosenPlanFinishedToClientWidget(notification: notification as ChosenPlanFinishedToClientNotification), PlanSubscriptionCanceledToCustomerNotification() => _PlanSubscriptionCanceledToCustomerWidget( - notification: notification as PlanSubscriptionCanceledToCustomerNotification, - ), + notification: notification as PlanSubscriptionCanceledToCustomerNotification, + ), ChosenPlanStartedToConsultantNotification() => _ChosenPlanStartedToConsultantWidget( - notification: notification as ChosenPlanStartedToConsultantNotification, - ), + notification: notification as ChosenPlanStartedToConsultantNotification, + ), ChosenPlanTomorrowReminderToConsultantNotification() => _ChosenPlanTomorrowReminderToConsultantWidget( - notification: notification as ChosenPlanTomorrowReminderToConsultantNotification, - ), - PlanSubscriptionPaidToAuthorNotification() => _PlanSubscriptionPaidToAuthorWidget( - notification: notification as PlanSubscriptionPaidToAuthorNotification, - ), + notification: notification as ChosenPlanTomorrowReminderToConsultantNotification, + ), + PlanSubscriptionPaidToAuthorNotification() => _PlanSubscriptionPaidToAuthorWidget(notification: notification as PlanSubscriptionPaidToAuthorNotification), ChosenPlanFinishedToConsultantNotification() => _ChosenPlanFinishedToConsultantWidget( - notification: notification as ChosenPlanFinishedToConsultantNotification, - ), - UnknownNotification() => const SizedBox.shrink(), + notification: notification as ChosenPlanFinishedToConsultantNotification, + ), + ChosenPlanLastDayFinishedForUser() => _ChosenPlanLastDayFinishedForUserWidget(notification: notification as ChosenPlanLastDayFinishedForUser), + ChosenPlanConsultantLastDayStarted() => _ChosenPlanConsultantLastDayStartedWidget(notification: notification as ChosenPlanConsultantLastDayStarted), + ComplaintToModerationForUser() => _ComplaintToModerationForUserWidget(notification: notification as ComplaintToModerationForUser), + CommentReplied() => _CommentRepliedWidget(notification: notification as CommentReplied), + ScoreAdded() => _ScoreAddedWidget(notification: notification as ScoreAdded), + EntityCommented() => _EntityCommentedWidget(notification: notification as EntityCommented), + // Новые типы уведомлений + PlanSubscriptionExpiredNotification() => _PlanSubscriptionExpiredWidget(notification: notification as PlanSubscriptionExpiredNotification), + PlanSubscriptionRenewedNotification() => _PlanSubscriptionRenewedWidget(notification: notification as PlanSubscriptionRenewedNotification), + ConsultantAssignedNotification() => _ConsultantAssignedWidget(notification: notification as ConsultantAssignedNotification), + ConsultantUnassignedNotification() => _ConsultantUnassignedWidget(notification: notification as ConsultantUnassignedNotification), + PaymentFailedNotification() => _PaymentFailedWidget(notification: notification as PaymentFailedNotification), + PaymentSuccessfulNotification() => _PaymentSuccessfulWidget(notification: notification as PaymentSuccessfulNotification), + GoalAchievedNotification() => _GoalAchievedWidget(notification: notification as GoalAchievedNotification), + WorkoutReminderNotification() => _WorkoutReminderWidget(notification: notification as WorkoutReminderNotification), + MealReminderNotification() => _MealReminderWidget(notification: notification as MealReminderNotification), + // New notification types + DietCancelledNotification() => _DietCancelledWidget(notification: notification as DietCancelledNotification), + DietStartedNotification() => _DietStartedWidget(notification: notification as DietStartedNotification), + DietExpiredNotification() => _DietExpiredWidget(notification: notification as DietExpiredNotification), + DietWillStartedInNotification() => _DietWillStartedInWidget(notification: notification as DietWillStartedInNotification), + DietCanNotBeStartedNotification() => _DietCanNotBeStartedWidget(notification: notification as DietCanNotBeStartedNotification), + PersonalDietCreationNeededNotification() => _PersonalDietCreationNeededWidget(notification: notification as PersonalDietCreationNeededNotification), + ChosenPlanClientLastDayFinishedNotification() => _ChosenPlanClientLastDayFinishedWidget( + notification: notification as ChosenPlanClientLastDayFinishedNotification, + ), + ChosenPlanClientLastDayStartedNotification() => _ChosenPlanClientLastDayStartedWidget( + notification: notification as ChosenPlanClientLastDayStartedNotification, + ), + StayOnTrackToAuthorNotification() => _StayOnTrackToAuthorWidget(notification: notification as StayOnTrackToAuthorNotification), + ClientStartPlanSubscriptionToSubscriberNotification() => _ClientStartPlanSubscriptionToSubscriberWidget( + notification: notification as ClientStartPlanSubscriptionToSubscriberNotification, + ), + ConsultantConfirmPlanSubscriptionToSubscriberNotification() => _ConsultantConfirmPlanSubscriptionToSubscriberWidget( + notification: notification as ConsultantConfirmPlanSubscriptionToSubscriberNotification, + ), + ClientPaidPlanSubscriptionToAuthorNotification() => _ClientPaidPlanSubscriptionToAuthorWidget( + notification: notification as ClientPaidPlanSubscriptionToAuthorNotification, + ), + ConsultantLeftDiaryCommentNotification() => _ConsultantLeftDiaryCommentWidget(notification: notification as ConsultantLeftDiaryCommentNotification), + ConsultantConfirmPlanSubscriptionToAuthorNotification() => _ConsultantConfirmPlanSubscriptionToAuthorWidget( + notification: notification as ConsultantConfirmPlanSubscriptionToAuthorNotification, + ), + ClientPaidPlanSubscriptionToSubscriberNotification() => _ClientPaidPlanSubscriptionToSubscriberWidget( + notification: notification as ClientPaidPlanSubscriptionToSubscriberNotification, + ), + ClientStartPlanSubscriptionToAuthorNotification() => _ClientStartPlanSubscriptionToAuthorWidget( + notification: notification as ClientStartPlanSubscriptionToAuthorNotification, + ), + ClientCancelPlanSubscriptionToAuthorNotification() => _ClientCancelPlanSubscriptionToAuthorWidget( + notification: notification as ClientCancelPlanSubscriptionToAuthorNotification, + ), + ClientCancelPlanSubscriptionToSubscriberNotification() => _ClientCancelPlanSubscriptionToSubscriberWidget( + notification: notification as ClientCancelPlanSubscriptionToSubscriberNotification, + ), + UserDailyReportCompletedNotification() => _UserDailyReportCompletedWidget(notification: notification as UserDailyReportCompletedNotification), + StayOnTrackToCustomerNotification() => _StayOnTrackToCustomerWidget(notification: notification as StayOnTrackToCustomerNotification), + PhoneChangedNotification() => _PhoneChangedWidget(notification: notification as PhoneChangedNotification), + SubscriptionNotification() => _SubscriptionWidget(notification: notification as SubscriptionNotification), + BadgeAwardedNotification() => _BadgeAwardedWidget(notification: notification as BadgeAwardedNotification), + BadgeRevokedNotification() => _BadgeRevokedWidget(notification: notification as BadgeRevokedNotification), + ConsultantRequestStartedNotification() => _ConsultantRequestStartedWidget(notification: notification as ConsultantRequestStartedNotification), + ClientCloseComplaintSuccessNotification() => _ClientCloseComplaintSuccessWidget(notification: notification as ClientCloseComplaintSuccessNotification), + ClientRefundSuccessNotification() => _ClientRefundSuccessWidget(notification: notification as ClientRefundSuccessNotification), + ConsultantRefundSuccessNotification() => _ConsultantRefundSuccessWidget(notification: notification as ConsultantRefundSuccessNotification), + ClientRejectSuccessNotification() => _ClientRejectSuccessWidget(notification: notification as ClientRejectSuccessNotification), + ConsultantRejectSuccessNotification() => _ConsultantRejectSuccessWidget(notification: notification as ConsultantRejectSuccessNotification), + ScoreSubstractedNotification() => _ScoreSubstractedWidget(notification: notification as ScoreSubstractedNotification), + ProductStatusUpdatedForAuthorNotification() => _ProductStatusUpdatedForAuthorWidget( + notification: notification as ProductStatusUpdatedForAuthorNotification, + ), + DiaryReminderForUsersNotification() => _DiaryReminderForUsersWidget(notification: notification as DiaryReminderForUsersNotification), + UnknownNotification() => _UnknownNotificationWidget(notification: notification as UnknownNotification), }; } } @@ -68,6 +128,7 @@ class _PlanSubscriptionStartedToAuthorWidget extends StatelessWidget { return _BaseNotificationWidget( isRead: notification.isRead, notificationsText: s.userSubscribedToPlan(notification.customerName, notification.planName), + createdAt: notification.createdAt, ); } } @@ -82,6 +143,7 @@ class _PlanSubscriptionCanceledToAuthorWidget extends StatelessWidget { return _BaseNotificationWidget( isRead: notification.isRead, notificationsText: s.userCanceldThePlanSubscription(notification.customerName, notification.planName), + createdAt: notification.createdAt, ); } } @@ -93,10 +155,7 @@ class _ChosenPlanStartedToClientWidget extends StatelessWidget { Widget build(BuildContext context) { final s = S.of(context); - return _BaseNotificationWidget( - isRead: notification.isRead, - notificationsText: s.planStarted(notification.planName), - ); + return _BaseNotificationWidget(isRead: notification.isRead, notificationsText: s.planStarted(notification.planName), createdAt: notification.createdAt); } } @@ -110,6 +169,7 @@ class _ChosenPlanTomorrowReminderToClientWidget extends StatelessWidget { return _BaseNotificationWidget( isRead: notification.isRead, notificationsText: s.tomorrowYourPlanWillStart(notification.planName), + createdAt: notification.createdAt, ); } } @@ -120,10 +180,7 @@ class _DiaryReminderWidget extends StatelessWidget { @override Widget build(BuildContext context) { final s = S.of(context); - return _BaseNotificationWidget( - isRead: notification.isRead, - notificationsText: s.fillYasterday, - ); + return _BaseNotificationWidget(isRead: notification.isRead, notificationsText: s.fillYasterday, createdAt: notification.createdAt); } } @@ -137,6 +194,7 @@ class _ConsultantLeftCommentWidget extends StatelessWidget { return _BaseNotificationWidget( isRead: notification.isRead, notificationsText: s.consultantLeftComment(notification.date.format('dd.MM.yyyy')), + createdAt: notification.createdAt, ); } } @@ -146,7 +204,7 @@ class _PersonalizedDietIsDefinedWidget extends StatelessWidget { final PersonalizedDietIsDefinedNotification notification; @override Widget build(BuildContext context) { - return Container(); + return _BaseNotificationWidget(isRead: notification.isRead, notificationsText: 'Персональная диета определена', createdAt: notification.createdAt); } } @@ -160,6 +218,7 @@ class _ChosenPlanFinishedToClientWidget extends StatelessWidget { return _BaseNotificationWidget( isRead: notification.isRead, notificationsText: '${s.congratsYouHaveFinishedPlan} «${notification.planName}»', + createdAt: notification.createdAt, ); } } @@ -174,6 +233,7 @@ class _PlanSubscriptionCanceledToCustomerWidget extends StatelessWidget { return _BaseNotificationWidget( isRead: notification.isRead, notificationsText: s.youCanceledSubscriptionToPlan(notification.planName), + createdAt: notification.createdAt, ); } } @@ -188,6 +248,7 @@ class _ChosenPlanStartedToConsultantWidget extends StatelessWidget { return _BaseNotificationWidget( isRead: notification.isRead, notificationsText: s.planWithUserStarted(notification.planName, notification.customerName), + createdAt: notification.createdAt, ); } } @@ -202,6 +263,7 @@ class _ChosenPlanTomorrowReminderToConsultantWidget extends StatelessWidget { return _BaseNotificationWidget( isRead: notification.isRead, notificationsText: s.tommorowPlanWithUserWillStart(notification.planName, notification.customerName), + createdAt: notification.createdAt, ); } } @@ -216,6 +278,7 @@ class _PlanSubscriptionPaidToAuthorWidget extends StatelessWidget { return _BaseNotificationWidget( isRead: notification.isRead, notificationsText: s.userPaidThePlan(notification.customerName, notification.planName), + createdAt: notification.createdAt, ); } } @@ -226,58 +289,767 @@ class _ChosenPlanFinishedToConsultantWidget extends StatelessWidget { @override Widget build(BuildContext context) { final s = S.of(context); - return Column(children: [ - Text( - '${s.congratsYouHaveFinishedPlan} «${notification.planName}»', - style: TextStyles.textMSemibold.copyWith(color: appTheme.black), - ), - const SizedBox(height: 12), - Text( - s.userConfirmedPlanCompletionWithoutSum, - style: TextStyles.textSMedium.copyWith(color: appTheme.black), - ), - const SizedBox(height: 12), - CustomOutlinedButton( - text: s.collectCharge, - buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700), - buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.green)), - onPressed: () { - GoRouter.of(context).pushNamed(CardSettingsScreen.name, extra: notification.planId); - }, - ), - const SizedBox(height: 12), - ]); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('${s.congratsYouHaveFinishedPlan} «${notification.planName}»', style: TextStyles.textMSemibold.copyWith(color: appTheme.black)), + const SizedBox(height: 12), + Text(s.userConfirmedPlanCompletionWithoutSum, style: TextStyles.textSMedium.copyWith(color: appTheme.black)), + const SizedBox(height: 12), + CustomOutlinedButton( + text: s.collectCharge, + buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700), + buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.green)), + onPressed: () { + GoRouter.of(context).pushNamed(CardSettingsScreen.name, extra: notification.planId); + }, + ), + ], + ), + ), + if (!notification.isRead) + Container( + decoration: BoxDecoration(color: appTheme.blueA400, shape: BoxShape.circle), + width: 8, + height: 8, + ), + ], + ), + const SizedBox(height: 4), + Text( + '${notification.createdAt.day} ${_getMonthName(notification.createdAt.month)} ${notification.createdAt.hour.toString().padLeft(2, '0')}:${notification.createdAt.minute.toString().padLeft(2, '0')}', + style: TextStyles.textSMedium.copyWith(color: appTheme.gray600), + ), + ], + ); + } + + String _getMonthName(int month) { + switch (month) { + case 1: + return 'янв'; + case 2: + return 'фев'; + case 3: + return 'мар'; + case 4: + return 'апр'; + case 5: + return 'май'; + case 6: + return 'июн'; + case 7: + return 'июл'; + case 8: + return 'авг'; + case 9: + return 'сен'; + case 10: + return 'окт'; + case 11: + return 'ноя'; + case 12: + return 'дек'; + default: + return ''; + } } } // CardSettingsScreen class _BaseNotificationWidget extends StatelessWidget { - const _BaseNotificationWidget({required this.notificationsText, required this.isRead}); + const _BaseNotificationWidget({required this.notificationsText, required this.isRead, required this.createdAt}); final String notificationsText; final bool isRead; + final DateTime createdAt; @override Widget build(BuildContext context) { - return Row( + return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - SizedBox( - width: MediaQuery.sizeOf(context).width * 0.6, - child: Text( - notificationsText, - style: TextStyles.textMMedium.copyWith(color: appTheme.black), - ), - ), - if (!isRead) ...[ - const Spacer(), - Container( - decoration: BoxDecoration( - color: appTheme.blueA400, - shape: BoxShape.circle, + Row( + children: [ + Expanded( + child: Text(notificationsText, style: TextStyles.textMMedium.copyWith(color: appTheme.black)), ), - width: 8, - height: 8, - ) - ] + if (!isRead) + Container( + decoration: BoxDecoration(color: appTheme.blueA400, shape: BoxShape.circle), + width: 8, + height: 8, + ), + ], + ), + const SizedBox(height: 4), + Text( + '${createdAt.day} ${_getMonthName(createdAt.month)} ${createdAt.hour.toString().padLeft(2, '0')}:${createdAt.minute.toString().padLeft(2, '0')}', + style: TextStyles.textSMedium.copyWith(color: appTheme.gray600), + ), ], ); } + + String _getMonthName(int month) { + switch (month) { + case 1: + return 'янв'; + case 2: + return 'фев'; + case 3: + return 'мар'; + case 4: + return 'апр'; + case 5: + return 'май'; + case 6: + return 'июн'; + case 7: + return 'июл'; + case 8: + return 'авг'; + case 9: + return 'сен'; + case 10: + return 'окт'; + case 11: + return 'ноя'; + case 12: + return 'дек'; + default: + return ''; + } + } +} + +class _ChosenPlanLastDayFinishedForUserWidget extends StatelessWidget { + const _ChosenPlanLastDayFinishedForUserWidget({required this.notification}); + final ChosenPlanLastDayFinishedForUser notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget(isRead: notification.isRead, notificationsText: 'Последний день плана завершен', createdAt: notification.createdAt); + } +} + +class _ChosenPlanConsultantLastDayStartedWidget extends StatelessWidget { + const _ChosenPlanConsultantLastDayStartedWidget({required this.notification}); + final ChosenPlanConsultantLastDayStarted notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Последний день плана начался: ${notification.planName}', + createdAt: notification.createdAt, + ); + } +} + +class _ComplaintToModerationForUserWidget extends StatelessWidget { + const _ComplaintToModerationForUserWidget({required this.notification}); + final ComplaintToModerationForUser notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget(isRead: notification.isRead, notificationsText: 'Жалоба отправлена на модерацию', createdAt: notification.createdAt); + } +} + +class _CommentRepliedWidget extends StatelessWidget { + const _CommentRepliedWidget({required this.notification}); + final CommentReplied notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Ответ на комментарий: ${notification.entityName}', + createdAt: notification.createdAt, + ); + } +} + +class _ScoreAddedWidget extends StatelessWidget { + const _ScoreAddedWidget({required this.notification}); + final ScoreAdded notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Добавлены баллы: ${notification.scoreValue}', + createdAt: notification.createdAt, + ); + } +} + +class _EntityCommentedWidget extends StatelessWidget { + const _EntityCommentedWidget({required this.notification}); + final EntityCommented notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Комментарий к ${notification.entityTranslatedType}: ${notification.entityName}', + createdAt: notification.createdAt, + ); + } +} + +class _UnknownNotificationWidget extends StatelessWidget { + const _UnknownNotificationWidget({required this.notification}); + final UnknownNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Неизвестное уведомление: ${notification.type.toString()}', + createdAt: notification.createdAt, + ); + } +} + +// Новые виджеты для дополнительных типов уведомлений +class _PlanSubscriptionExpiredWidget extends StatelessWidget { + const _PlanSubscriptionExpiredWidget({required this.notification}); + final PlanSubscriptionExpiredNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Подписка на план "${notification.planName}" истекла', + createdAt: notification.createdAt, + ); + } +} + +class _PlanSubscriptionRenewedWidget extends StatelessWidget { + const _PlanSubscriptionRenewedWidget({required this.notification}); + final PlanSubscriptionRenewedNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Подписка на план "${notification.planName}" продлена до ${notification.renewalDate}', + createdAt: notification.createdAt, + ); + } +} + +class _ConsultantAssignedWidget extends StatelessWidget { + const _ConsultantAssignedWidget({required this.notification}); + final ConsultantAssignedNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'К плану "${notification.planName}" назначен консультант ${notification.consultantName}', + createdAt: notification.createdAt, + ); + } +} + +class _ConsultantUnassignedWidget extends StatelessWidget { + const _ConsultantUnassignedWidget({required this.notification}); + final ConsultantUnassignedNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Консультант ${notification.consultantName} отстранен от плана "${notification.planName}"', + createdAt: notification.createdAt, + ); + } +} + +class _PaymentFailedWidget extends StatelessWidget { + const _PaymentFailedWidget({required this.notification}); + final PaymentFailedNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Ошибка оплаты плана "${notification.planName}": ${notification.errorMessage}', + createdAt: notification.createdAt, + ); + } +} + +class _PaymentSuccessfulWidget extends StatelessWidget { + const _PaymentSuccessfulWidget({required this.notification}); + final PaymentSuccessfulNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Успешная оплата плана "${notification.planName}" на сумму ${notification.amount} ${notification.currency}', + createdAt: notification.createdAt, + ); + } +} + +class _GoalAchievedWidget extends StatelessWidget { + const _GoalAchievedWidget({required this.notification}); + final GoalAchievedNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Достигнута цель "${notification.goalName}": ${notification.achievedValue}/${notification.targetValue}', + createdAt: notification.createdAt, + ); + } +} + +class _WorkoutReminderWidget extends StatelessWidget { + const _WorkoutReminderWidget({required this.notification}); + final WorkoutReminderNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Напоминание о тренировке "${notification.workoutName}" (${notification.duration} мин)', + createdAt: notification.createdAt, + ); + } +} + +class _MealReminderWidget extends StatelessWidget { + const _MealReminderWidget({required this.notification}); + final MealReminderNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Напоминание о приеме пищи "${notification.mealName}" (${notification.calories} ккал)', + createdAt: notification.createdAt, + ); + } +} + +// New notification widget classes +class _DietCancelledWidget extends StatelessWidget { + const _DietCancelledWidget({required this.notification}); + final DietCancelledNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Диета "${notification.dietName}" отменена', + createdAt: notification.createdAt, + ); + } +} + +class _DietStartedWidget extends StatelessWidget { + const _DietStartedWidget({required this.notification}); + final DietStartedNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Диета "${notification.dietName}" началась', + createdAt: notification.createdAt, + ); + } +} + +class _DietExpiredWidget extends StatelessWidget { + const _DietExpiredWidget({required this.notification}); + final DietExpiredNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Диета "${notification.dietName}" истекла', + createdAt: notification.createdAt, + ); + } +} + +class _DietWillStartedInWidget extends StatelessWidget { + const _DietWillStartedInWidget({required this.notification}); + final DietWillStartedInNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Диета "${notification.dietName}" начнется ${notification.startDate}', + createdAt: notification.createdAt, + ); + } +} + +class _DietCanNotBeStartedWidget extends StatelessWidget { + const _DietCanNotBeStartedWidget({required this.notification}); + final DietCanNotBeStartedNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Диета "${notification.dietName}" не может быть начата: ${notification.reason}', + createdAt: notification.createdAt, + ); + } +} + +class _PersonalDietCreationNeededWidget extends StatelessWidget { + const _PersonalDietCreationNeededWidget({required this.notification}); + final PersonalDietCreationNeededNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Требуется создание персональной диеты для клиента ${notification.clientName} по плану "${notification.planName}"', + createdAt: notification.createdAt, + ); + } +} + +class _ChosenPlanClientLastDayFinishedWidget extends StatelessWidget { + const _ChosenPlanClientLastDayFinishedWidget({required this.notification}); + final ChosenPlanClientLastDayFinishedNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Последний день плана "${notification.planName}" завершен', + createdAt: notification.createdAt, + ); + } +} + +class _ChosenPlanClientLastDayStartedWidget extends StatelessWidget { + const _ChosenPlanClientLastDayStartedWidget({required this.notification}); + final ChosenPlanClientLastDayStartedNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Последний день плана "${notification.planName}" начался', + createdAt: notification.createdAt, + ); + } +} + +class _StayOnTrackToAuthorWidget extends StatelessWidget { + const _StayOnTrackToAuthorWidget({required this.notification}); + final StayOnTrackToAuthorNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Клиент ${notification.clientName} остается на плане "${notification.planName}"', + createdAt: notification.createdAt, + ); + } +} + +class _ClientStartPlanSubscriptionToSubscriberWidget extends StatelessWidget { + const _ClientStartPlanSubscriptionToSubscriberWidget({required this.notification}); + final ClientStartPlanSubscriptionToSubscriberNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Подписка на план "${notification.planName}" началась', + createdAt: notification.createdAt, + ); + } +} + +class _ConsultantConfirmPlanSubscriptionToSubscriberWidget extends StatelessWidget { + const _ConsultantConfirmPlanSubscriptionToSubscriberWidget({required this.notification}); + final ConsultantConfirmPlanSubscriptionToSubscriberNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Консультант ${notification.consultantName} подтвердил подписку на план "${notification.planName}"', + createdAt: notification.createdAt, + ); + } +} + +class _ClientPaidPlanSubscriptionToAuthorWidget extends StatelessWidget { + const _ClientPaidPlanSubscriptionToAuthorWidget({required this.notification}); + final ClientPaidPlanSubscriptionToAuthorNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Клиент ${notification.clientName} оплатил подписку на план "${notification.planName}" на сумму ${notification.amount}', + createdAt: notification.createdAt, + ); + } +} + +class _ConsultantLeftDiaryCommentWidget extends StatelessWidget { + const _ConsultantLeftDiaryCommentWidget({required this.notification}); + final ConsultantLeftDiaryCommentNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Консультант ${notification.consultantName} оставил комментарий к дневнику по плану "${notification.planName}"', + createdAt: notification.createdAt, + ); + } +} + +class _ConsultantConfirmPlanSubscriptionToAuthorWidget extends StatelessWidget { + const _ConsultantConfirmPlanSubscriptionToAuthorWidget({required this.notification}); + final ConsultantConfirmPlanSubscriptionToAuthorNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Консультант ${notification.consultantName} подтвердил подписку на план "${notification.planName}"', + createdAt: notification.createdAt, + ); + } +} + +class _ClientPaidPlanSubscriptionToSubscriberWidget extends StatelessWidget { + const _ClientPaidPlanSubscriptionToSubscriberWidget({required this.notification}); + final ClientPaidPlanSubscriptionToSubscriberNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Оплачена подписка на план "${notification.planName}" на сумму ${notification.amount}', + createdAt: notification.createdAt, + ); + } +} + +class _ClientStartPlanSubscriptionToAuthorWidget extends StatelessWidget { + const _ClientStartPlanSubscriptionToAuthorWidget({required this.notification}); + final ClientStartPlanSubscriptionToAuthorNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Клиент ${notification.clientName} начал подписку на план "${notification.planName}"', + createdAt: notification.createdAt, + ); + } +} + +class _ClientCancelPlanSubscriptionToAuthorWidget extends StatelessWidget { + const _ClientCancelPlanSubscriptionToAuthorWidget({required this.notification}); + final ClientCancelPlanSubscriptionToAuthorNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Клиент ${notification.clientName} отменил подписку на план "${notification.planName}"', + createdAt: notification.createdAt, + ); + } +} + +class _ClientCancelPlanSubscriptionToSubscriberWidget extends StatelessWidget { + const _ClientCancelPlanSubscriptionToSubscriberWidget({required this.notification}); + final ClientCancelPlanSubscriptionToSubscriberNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Подписка на план "${notification.planName}" отменена', + createdAt: notification.createdAt, + ); + } +} + +class _UserDailyReportCompletedWidget extends StatelessWidget { + const _UserDailyReportCompletedWidget({required this.notification}); + final UserDailyReportCompletedNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Клиент ${notification.clientName} завершил ежедневный отчет по плану "${notification.planName}"', + createdAt: notification.createdAt, + ); + } +} + +class _StayOnTrackToCustomerWidget extends StatelessWidget { + const _StayOnTrackToCustomerWidget({required this.notification}); + final StayOnTrackToCustomerNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Консультант ${notification.consultantName} остается на плане "${notification.planName}"', + createdAt: notification.createdAt, + ); + } +} + +class _PhoneChangedWidget extends StatelessWidget { + const _PhoneChangedWidget({required this.notification}); + final PhoneChangedNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Номер телефона изменен с ${notification.oldPhone} на ${notification.newPhone}', + createdAt: notification.createdAt, + ); + } +} + +class _SubscriptionWidget extends StatelessWidget { + const _SubscriptionWidget({required this.notification}); + final SubscriptionNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Подписка ${notification.subscriptionType} на план "${notification.planName}"', + createdAt: notification.createdAt, + ); + } +} + +class _BadgeAwardedWidget extends StatelessWidget { + const _BadgeAwardedWidget({required this.notification}); + final BadgeAwardedNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Получен значок "${notification.badgeName}": ${notification.badgeDescription}', + createdAt: notification.createdAt, + ); + } +} + +class _BadgeRevokedWidget extends StatelessWidget { + const _BadgeRevokedWidget({required this.notification}); + final BadgeRevokedNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Отозван значок "${notification.badgeName}": ${notification.reason}', + createdAt: notification.createdAt, + ); + } +} + +class _ConsultantRequestStartedWidget extends StatelessWidget { + const _ConsultantRequestStartedWidget({required this.notification}); + final ConsultantRequestStartedNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Запрос к консультанту ${notification.consultantName} начат', + createdAt: notification.createdAt, + ); + } +} + +class _ClientCloseComplaintSuccessWidget extends StatelessWidget { + const _ClientCloseComplaintSuccessWidget({required this.notification}); + final ClientCloseComplaintSuccessNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Жалоба на ${notification.complaintType} "${notification.entityName}" успешно закрыта', + createdAt: notification.createdAt, + ); + } +} + +class _ClientRefundSuccessWidget extends StatelessWidget { + const _ClientRefundSuccessWidget({required this.notification}); + final ClientRefundSuccessNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Возврат средств по плану "${notification.planName}" на сумму ${notification.amount}: ${notification.reason}', + createdAt: notification.createdAt, + ); + } +} + +class _ConsultantRefundSuccessWidget extends StatelessWidget { + const _ConsultantRefundSuccessWidget({required this.notification}); + final ConsultantRefundSuccessNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Возврат средств клиенту ${notification.clientName} по плану "${notification.planName}" на сумму ${notification.amount}', + createdAt: notification.createdAt, + ); + } +} + +class _ClientRejectSuccessWidget extends StatelessWidget { + const _ClientRejectSuccessWidget({required this.notification}); + final ClientRejectSuccessNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Отклонение по плану "${notification.planName}": ${notification.reason}', + createdAt: notification.createdAt, + ); + } +} + +class _ConsultantRejectSuccessWidget extends StatelessWidget { + const _ConsultantRejectSuccessWidget({required this.notification}); + final ConsultantRejectSuccessNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Отклонение клиентом ${notification.clientName} по плану "${notification.planName}": ${notification.reason}', + createdAt: notification.createdAt, + ); + } +} + +class _ScoreSubstractedWidget extends StatelessWidget { + const _ScoreSubstractedWidget({required this.notification}); + final ScoreSubstractedNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Вычтено ${notification.scoreValue} баллов за ${notification.scorableName}', + createdAt: notification.createdAt, + ); + } +} + +class _ProductStatusUpdatedForAuthorWidget extends StatelessWidget { + const _ProductStatusUpdatedForAuthorWidget({required this.notification}); + final ProductStatusUpdatedForAuthorNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Статус продукта "${notification.productName}" изменен с ${notification.oldStatus} на ${notification.newStatus}', + createdAt: notification.createdAt, + ); + } +} + +class _DiaryReminderForUsersWidget extends StatelessWidget { + const _DiaryReminderForUsersWidget({required this.notification}); + final DiaryReminderForUsersNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget(isRead: notification.isRead, notificationsText: 'Напоминание о заполнении дневника', createdAt: notification.createdAt); + } } diff --git a/lib/features/notifications/repository/notifications_repository.dart b/lib/features/notifications/repository/notifications_repository.dart index beef645f..267faa89 100644 --- a/lib/features/notifications/repository/notifications_repository.dart +++ b/lib/features/notifications/repository/notifications_repository.dart @@ -1,14 +1,15 @@ -import 'package:eatvio_app/features/notifications/models/notification_model.dart'; +import 'dart:developer'; import 'package:eatvio_app/features/notifications/service/notifications_service.dart'; class NotificationsRepository { final NotificationsService notificationsService; NotificationsRepository({required this.notificationsService}); - Future?> getNotifications({required int page}) { + Future?> getNotifications({required int page}) { try { return notificationsService.getNotifications(page: page); } catch (e) { + log(e.toString()); rethrow; } } diff --git a/lib/features/notifications/service/notifications_service.dart b/lib/features/notifications/service/notifications_service.dart index 860aab55..5b3ca78b 100644 --- a/lib/features/notifications/service/notifications_service.dart +++ b/lib/features/notifications/service/notifications_service.dart @@ -5,48 +5,425 @@ class NotificationsService { final Dio dio; NotificationsService(this.dio); - Future?> getNotifications({required int page}) async { + Future?> getNotifications({required int page}) async { try { final response = await dio.get('/api/profile/notifications?page=$page'); if (response.statusCode == 200) { final data = response.data['data'] as List; + final notifications = data.map((notificationJson) { try { - final stringifiedType = notificationJson['data']['notification_class_name']; + // Улучшенная логика определения типа уведомления + String? stringifiedType; + + // Сначала проверяем поле 'type' в корне JSON + if (notificationJson.containsKey('type') && notificationJson['type'] != null) { + stringifiedType = notificationJson['type'].toString(); + } + // Затем проверяем поле 'notification_class_name' в корне JSON + else if (notificationJson.containsKey('notification_class_name') && notificationJson['notification_class_name'] != null) { + stringifiedType = notificationJson['notification_class_name'].toString(); + } + // Если есть поле 'data', проверяем внутри него + else if (notificationJson.containsKey('data') && notificationJson['data'] is Map) { + final data = notificationJson['data'] as Map; + if (data.containsKey('type') && data['type'] != null) { + stringifiedType = data['type'].toString(); + } else if (data.containsKey('notification_class_name') && data['notification_class_name'] != null) { + stringifiedType = data['notification_class_name'].toString(); + } + } + final readDate = notificationJson['read_at']; - final type = NotificationType.getTypeFromString(stringifiedType); + final createdAt = notificationJson['created_at']; + final type = stringifiedType != null ? NotificationType.getTypeFromString(stringifiedType) : NotificationType.unknown; + return switch (type) { - NotificationType.planSubscriptionStartedToAuthor => - PlanSubscriptionStartedToAuthorNotification.fromJson(notificationJson['data'], type, isRead: readDate != null), - NotificationType.planSubscriptionCanceledToAuthor => - PlanSubscriptionCanceledToAuthorNotification.fromJson(notificationJson['data'], type, isRead: readDate != null), - NotificationType.chosenPlanStartedToClientNotification => - ChosenPlanStartedToClientNotification.fromJson(notificationJson['data'], type, isRead: readDate != null), - NotificationType.chosenPlanTomorrowReminderToClientNotification => - ChosenPlanTomorrowReminderToClientNotification.fromJson(notificationJson['data'], type, isRead: readDate != null), - NotificationType.diaryReminderNotification => DiaryReminderNotification.fromJson(notificationJson['data'], type, isRead: readDate != null), - NotificationType.consultantLeftComment => ConsultantLeftCommentNotification.fromJson(notificationJson['data'], type, isRead: readDate != null), - NotificationType.personalizedDietIsDefined => - PersonalizedDietIsDefinedNotification.fromJson(notificationJson['data'], type, isRead: readDate != null), - NotificationType.chosenPlanFinishedToClient => - ChosenPlanFinishedToClientNotification.fromJson(notificationJson['data'], type, isRead: readDate != null), - NotificationType.planSubscriptionCanceledToCustomer => - PlanSubscriptionCanceledToAuthorNotification.fromJson(notificationJson['data'], type, isRead: readDate != null), - NotificationType.chosenPlanStartedToConsultantNotification => - ChosenPlanStartedToConsultantNotification.fromJson(notificationJson['data'], type, isRead: readDate != null), - NotificationType.chosenPlanTomorrowReminderToConsultantNotification => - ChosenPlanTomorrowReminderToConsultantNotification.fromJson(notificationJson['data'], type, isRead: readDate != null), - NotificationType.planSubscriptionPaidToAuthor => - PlanSubscriptionPaidToAuthorNotification.fromJson(notificationJson['data'], type, isRead: readDate != null), - NotificationType.chosenPlanFinishedToConsultant => - ChosenPlanFinishedToConsultantNotification.fromJson(notificationJson['data'], type, isRead: readDate != null), - NotificationType.unknown => UnknownNotification(type: type), + NotificationType.planSubscriptionStartedToAuthor => PlanSubscriptionStartedToAuthorNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.planSubscriptionCanceledToAuthor => PlanSubscriptionCanceledToAuthorNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.chosenPlanStartedToClientNotification => ChosenPlanStartedToClientNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.chosenPlanTomorrowReminderToClientNotification => ChosenPlanTomorrowReminderToClientNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.diaryReminderNotification => DiaryReminderNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.consultantLeftComment => ConsultantLeftCommentNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.personalizedDietIsDefined => PersonalizedDietIsDefinedNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.chosenPlanFinishedToClient => ChosenPlanFinishedToClientNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.planSubscriptionCanceledToCustomer => PlanSubscriptionCanceledToAuthorNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.chosenPlanStartedToConsultantNotification => ChosenPlanStartedToConsultantNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.chosenPlanTomorrowReminderToConsultantNotification => ChosenPlanTomorrowReminderToConsultantNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.planSubscriptionPaidToAuthor => PlanSubscriptionPaidToAuthorNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.chosenPlanFinishedToConsultant => ChosenPlanFinishedToConsultantNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.chosenPlanLastDayFinishedForUser => ChosenPlanLastDayFinishedForUser.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + // Новые типы уведомлений + NotificationType.chosenPlanConsultantLastDayStarted => ChosenPlanConsultantLastDayStarted.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.complaintToModerationForUser => ComplaintToModerationForUser.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.commentReplied => CommentReplied.fromJson(notificationJson['data'], type, isRead: readDate != null, createdAt: createdAt), + NotificationType.scoreAdded => ScoreAdded.fromJson(notificationJson['data'], type, isRead: readDate != null, createdAt: createdAt), + NotificationType.entityCommented => EntityCommented.fromJson(notificationJson['data'], type, isRead: readDate != null, createdAt: createdAt), + // Новые типы уведомлений + NotificationType.planSubscriptionExpired => PlanSubscriptionExpiredNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.planSubscriptionRenewed => PlanSubscriptionRenewedNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.consultantAssigned => ConsultantAssignedNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.consultantUnassigned => ConsultantUnassignedNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.paymentFailed => PaymentFailedNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.paymentSuccessful => PaymentSuccessfulNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.goalAchieved => GoalAchievedNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.workoutReminder => WorkoutReminderNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.mealReminder => MealReminderNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + // New notification types + NotificationType.dietCancelled => DietCancelledNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.dietStarted => DietStartedNotification.fromJson(notificationJson['data'], type, isRead: readDate != null, createdAt: createdAt), + NotificationType.dietExpired => DietExpiredNotification.fromJson(notificationJson['data'], type, isRead: readDate != null, createdAt: createdAt), + NotificationType.dietWillStartedIn => DietWillStartedInNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.dietCanNotBeStarted => DietCanNotBeStartedNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.personalDietCreationNeeded => PersonalDietCreationNeededNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.chosenPlanClientLastDayFinished => ChosenPlanClientLastDayFinishedNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.chosenPlanClientLastDayStarted => ChosenPlanClientLastDayStartedNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.stayOnTrackToAuthor => StayOnTrackToAuthorNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.chosenPlanStartedToClient => ChosenPlanStartedToClientNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.clientStartPlanSubscriptionToSubscriber => ClientStartPlanSubscriptionToSubscriberNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.consultantConfirmPlanSubscriptionToSubscriber => ConsultantConfirmPlanSubscriptionToSubscriberNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.clientPaidPlanSubscriptionToAuthor => ClientPaidPlanSubscriptionToAuthorNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.chosenPlanTomorrowReminderToClient => ChosenPlanTomorrowReminderToClientNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.chosenPlanTomorrowReminderToConsultant => ChosenPlanTomorrowReminderToConsultantNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.chosenPlanStartedToConsultant => ChosenPlanStartedToConsultantNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.consultantLeftDiaryComment => ConsultantLeftDiaryCommentNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.consultantConfirmPlanSubscriptionToAuthor => ConsultantConfirmPlanSubscriptionToAuthorNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.clientPaidPlanSubscriptionToSubscriber => ClientPaidPlanSubscriptionToSubscriberNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.clientStartPlanSubscriptionToAuthor => ClientStartPlanSubscriptionToAuthorNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.clientCancelPlanSubscriptionToAuthor => ClientCancelPlanSubscriptionToAuthorNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.clientCancelPlanSubscriptionToSubscriber => ClientCancelPlanSubscriptionToSubscriberNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.userDailyReportCompleted => UserDailyReportCompletedNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.stayOnTrackToCustomer => StayOnTrackToCustomerNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.phoneChanged => PhoneChangedNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.subscription => SubscriptionNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.badgeAwarded => BadgeAwardedNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.badgeRevoked => BadgeRevokedNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.consultantRequestStarted => ConsultantRequestStartedNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.clientCloseComplaintSuccess => ClientCloseComplaintSuccessNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.clientRefundSuccess => ClientRefundSuccessNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.consultantRefundSuccess => ConsultantRefundSuccessNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.clientRejectSuccess => ClientRejectSuccessNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.consultantRejectSuccess => ConsultantRejectSuccessNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.scoreSubstracted => ScoreSubstractedNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.productStatusUpdatedForAuthor => ProductStatusUpdatedForAuthorNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.diaryReminderForUsers => DiaryReminderForUsersNotification.fromJson( + notificationJson, + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.unknown => UnknownNotification(type: type, createdAt: parseDate(createdAt)), }; } catch (e) { - return UnknownNotification(type: NotificationType.unknown); + return UnknownNotification(type: NotificationType.unknown, createdAt: DateTime.now()); } }).toList(); - return notifications.where((notification) => notification.type != NotificationType.unknown).toList(); + + // Извлекаем информацию о пагинации из корня ответа + final currentPage = response.data['current_page'] as int; + final lastPage = response.data['last_page'] as int; + final perPage = response.data['per_page'] as int; + final total = response.data['total'] as int; + + return { + 'notifications': notifications, + 'pagination': {'current_page': currentPage, 'last_page': lastPage, 'per_page': perPage, 'total': total}, + }; } return null; } catch (e) { @@ -62,3 +439,5 @@ class NotificationsService { } } } + +DateTime parseDate(String date) => DateTime.parse(date).toLocal(); diff --git a/lib/features/profile/models/profile_model.dart b/lib/features/profile/models/profile_model.dart index 72e91d0d..03fd57f3 100644 --- a/lib/features/profile/models/profile_model.dart +++ b/lib/features/profile/models/profile_model.dart @@ -14,10 +14,12 @@ abstract class ProfileModel with _$ProfileModel { @JsonKey(name: "email") required String email, @JsonKey(name: "rating_place") int? ratingPlace, @JsonKey(name: "email_verified_at") DateTime? emailVerifiedAt, + @JsonKey(name: "phone_confirmation_state") required String phoneConfirmationState, @JsonKey(name: "created_at") DateTime? createdAt, @JsonKey(name: "updated_at") DateTime? updatedAt, @JsonKey(name: "is_public_profile", fromJson: parseBool) required bool isPublicProfile, @JsonKey(name: "is_blocked_by_me", fromJson: parseBool) required bool isBlockedByMe, + @JsonKey(name: "is_blocked_by_user", fromJson: parseBool, defaultValue: false) required bool isBlockedByUser, @JsonKey(name: 'is_entity_subscribed_by_me') required bool isEntitySubscribedByMe, @JsonKey(name: "slug") required String slug, @JsonKey(name: "profile_photo_url") required String profilePhotoUrl, diff --git a/lib/features/profile/presentation/pages/profile_screen.dart b/lib/features/profile/presentation/pages/profile_screen.dart index 07ab1bd2..990beed1 100644 --- a/lib/features/profile/presentation/pages/profile_screen.dart +++ b/lib/features/profile/presentation/pages/profile_screen.dart @@ -45,10 +45,7 @@ import 'package:url_launcher/url_launcher.dart'; class ProfileScreen extends StatefulWidget { final Author? author; - const ProfileScreen({ - super.key, - this.author, - }); + const ProfileScreen({super.key, this.author}); @override State createState() => _ProfileScreenState(); @@ -91,11 +88,44 @@ class _ProfileScreenState extends State { } if (isFirstFetch) { canSub = !state.profile.isEntitySubscribedByMe; - if (state.profile.roles.contains('consultant')) { + if (state.profile.roles.contains('consultant') && !hidePlans) { plansCubit.getPlans(state.profile.slug); } isFirstFetch = false; } + + // Проверяем, заблокировал ли пользователь меня + if (state.profile.isBlockedByUser) { + return Scaffold( + appBar: AppBar(), + body: Center( + child: Padding( + padding: const EdgeInsets.all(32.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.block, + size: 64, + color: Colors.grey[400], + ), + const SizedBox(height: 16), + Text( + 'Пользователь ограничил доступ к своей странице', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w500, + color: Colors.grey[600], + ), + ), + ], + ), + ), + ), + ); + } + return widget.author == null || widget.author!.authorIsMe ? _myProfileScreen(state) : _profileScreen(state); } else if (state is ProfileStateError) { return Scaffold( @@ -109,10 +139,7 @@ class _ProfileScreenState extends State { ), ); } else { - return Scaffold( - appBar: AppBar(), - body: const Loader(), - ); + return Scaffold(appBar: AppBar(), body: const Loader()); } }, ), @@ -130,15 +157,13 @@ class _ProfileScreenState extends State { Container( decoration: const BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.vertical( - bottom: Radius.circular(16), - ), + borderRadius: BorderRadius.vertical(bottom: Radius.circular(16)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ _header(state), - if (state.profile.roles.contains('consultant')) + if (state.profile.roles.contains('consultant') && state.consultantInfo != null) Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -147,28 +172,15 @@ class _ProfileScreenState extends State { const SizedBox(width: 16), const Text( 'Консультант', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Color( - 0xFF323F4B, - ), - ), + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF323F4B)), ), const SizedBox(width: 12), Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: const Color(0xFFF6FAFF), - borderRadius: BorderRadius.circular(12), - ), + decoration: BoxDecoration(color: const Color(0xFFF6FAFF), borderRadius: BorderRadius.circular(12)), child: Text( '${state.consultantInfo!.ratingPlace}-й в рейтинге', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: appTheme.blueA400, - ), + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: appTheme.blueA400), ), ), ], @@ -177,17 +189,10 @@ class _ProfileScreenState extends State { const Row( children: [ SizedBox(width: 16), - Icon( - Icons.done_rounded, - color: Colors.green, - ), + Icon(Icons.done_rounded, color: Colors.green), Text( 'Медицинское образование', - style: TextStyle( - color: Color(0xFF323F4B), - fontSize: 14, - fontWeight: FontWeight.w600, - ), + style: TextStyle(color: Color(0xFF323F4B), fontSize: 14, fontWeight: FontWeight.w600), ), ], ), @@ -202,10 +207,10 @@ class _ProfileScreenState extends State { ), _subscriptionsAndSubscribers(state), if (state.profile.badges.isNotEmpty) Padding(padding: const EdgeInsets.only(bottom: 16), child: _badges(state)), - if (state.profile.roles.contains('consultant')) _socialLinks(state), - if (state.profile.roles.contains('consultant')) _education(state), - if (state.profile.roles.contains('consultant')) _diplomasAndCertificates(state), - if (state.profile.roles.contains('consultant')) _activePlans(state), + if (state.profile.roles.contains('consultant') && state.consultantInfo != null && !state.profile.isBlockedByMe) _socialLinks(state), + if (state.profile.roles.contains('consultant') && state.consultantInfo != null && !state.profile.isBlockedByMe) _education(state), + if (state.profile.roles.contains('consultant') && state.consultantInfo != null && !state.profile.isBlockedByMe) _diplomasAndCertificates(state), + if (state.profile.roles.contains('consultant') && state.consultantInfo != null && !hidePlans && !state.profile.isBlockedByMe) _activePlans(state), _blogPosts(state), ], ), @@ -216,7 +221,7 @@ class _ProfileScreenState extends State { Scaffold _myProfileScreen(ProfileStateLoaded state) { return Scaffold( backgroundColor: Colors.grey.shade200, - appBar: AppBar(), + appBar: AppBar(toolbarHeight: widget.author == null ? 0 : null), body: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -224,9 +229,7 @@ class _ProfileScreenState extends State { Container( decoration: const BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.vertical( - bottom: Radius.circular(16), - ), + borderRadius: BorderRadius.vertical(bottom: Radius.circular(16)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.center, @@ -241,28 +244,15 @@ class _ProfileScreenState extends State { const SizedBox(width: 16), const Text( 'Консультант', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Color( - 0xFF323F4B, - ), - ), + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF323F4B)), ), const SizedBox(width: 12), Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: const Color(0xFFF6FAFF), - borderRadius: BorderRadius.circular(12), - ), + decoration: BoxDecoration(color: const Color(0xFFF6FAFF), borderRadius: BorderRadius.circular(12)), child: Text( '${state.consultantInfo!.ratingPlace}-й в рейтинге', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: appTheme.blueA400, - ), + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: appTheme.blueA400), ), ), ], @@ -271,17 +261,10 @@ class _ProfileScreenState extends State { const Row( children: [ SizedBox(width: 16), - Icon( - Icons.done_rounded, - color: Colors.green, - ), + Icon(Icons.done_rounded, color: Colors.green), Text( 'Медицинское образование', - style: TextStyle( - color: Color(0xFF323F4B), - fontSize: 14, - fontWeight: FontWeight.w600, - ), + style: TextStyle(color: Color(0xFF323F4B), fontSize: 14, fontWeight: FontWeight.w600), ), ], ), @@ -297,7 +280,7 @@ class _ProfileScreenState extends State { if (state.profile.roles.contains('consultant')) _education(state), if (state.profile.roles.contains('consultant')) _diplomasAndCertificates(state), Padding(padding: const EdgeInsets.only(bottom: 16), child: _charts()), - if (state.profile.roles.contains('consultant')) _activePlans(state), + if (state.profile.roles.contains('consultant') && !hidePlans) _activePlans(state), _newBlogPostButton(), _blogPosts(state), ], @@ -307,7 +290,7 @@ class _ProfileScreenState extends State { } Widget _activePlans(ProfileStateLoaded state) { - if (!state.profile.roles.contains('consultant')) { + if (!state.profile.roles.contains('consultant') || hidePlans) { return const SizedBox.shrink(); } return BlocBuilder( @@ -326,12 +309,7 @@ class _ProfileScreenState extends State { children: [ Text( 'Активные планы', - style: TextStyle( - color: Color(0xFF323F4B), - fontSize: 24, - height: 1, - fontWeight: FontWeight.w600, - ), + style: TextStyle(color: Color(0xFF323F4B), fontSize: 24, height: 1, fontWeight: FontWeight.w600), ), ], ), @@ -341,10 +319,7 @@ class _ProfileScreenState extends State { height: 781, child: PageView.builder( itemBuilder: (BuildContext context, int index) { - return PlanWidget( - plan: state.plansList[index], - index: index, - ); + return PlanWidget(plan: state.plansList[index], index: index); }, onPageChanged: (bullet) { setState(() { @@ -368,17 +343,11 @@ class _ProfileScreenState extends State { Container _bullets(int currentBullet, int bulletCount) { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(20), - ), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20)), child: Row( mainAxisSize: MainAxisSize.min, children: [ - for (int i = 0; i < bulletCount; i++) ...[ - _bullet(i == currentBullet), - i < bulletCount - 1 ? const SizedBox(width: 4) : const SizedBox.shrink(), - ], + for (int i = 0; i < bulletCount; i++) ...[_bullet(i == currentBullet), i < bulletCount - 1 ? const SizedBox(width: 4) : const SizedBox.shrink()], ], ), ); @@ -389,10 +358,7 @@ class _ProfileScreenState extends State { width: isActive ? 10 : 8, height: isActive ? 10 : 8, duration: const Duration(milliseconds: 150), - decoration: BoxDecoration( - color: isActive ? appTheme.blueA400 : const Color(0xFFD7D9DC), - shape: BoxShape.circle, - ), + decoration: BoxDecoration(color: isActive ? appTheme.blueA400 : const Color(0xFFD7D9DC), shape: BoxShape.circle), ); } @@ -409,25 +375,15 @@ class _ProfileScreenState extends State { borderRadius: BorderRadius.circular(8), child: Ink( padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: const Color(0xFFF6FAFF), - borderRadius: BorderRadius.circular(8), - ), + decoration: BoxDecoration(color: const Color(0xFFF6FAFF), borderRadius: BorderRadius.circular(8)), child: const Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( - Icons.add_circle_outline_rounded, - color: Color(0xFF2F80ED), - ), + Icon(Icons.add_circle_outline_rounded, color: Color(0xFF2F80ED)), SizedBox(width: 12), Text( 'Новая запись', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - color: Color(0xFF2F80ED), - ), + style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF2F80ED)), ), ], ), @@ -452,12 +408,7 @@ class _ProfileScreenState extends State { children: [ const Text( 'Дипломы\nи сертификаты', - style: TextStyle( - color: Color(0xFF323F4B), - fontSize: 24, - height: 1, - fontWeight: FontWeight.w600, - ), + style: TextStyle(color: Color(0xFF323F4B), fontSize: 24, height: 1, fontWeight: FontWeight.w600), ), GestureDetector( onTap: () { @@ -465,11 +416,7 @@ class _ProfileScreenState extends State { }, child: Text( widget.author == null || widget.author!.authorIsMe ? 'Редактировать' : '', - style: const TextStyle( - color: Color(0xFF2F80ED), - fontSize: 16, - fontWeight: FontWeight.w500, - ), + style: const TextStyle(color: Color(0xFF2F80ED), fontSize: 16, fontWeight: FontWeight.w500), ), ), ], @@ -489,17 +436,14 @@ class _ProfileScreenState extends State { child: Row( children: [ if (state.consultantInfo!.photos.isNotEmpty) - ...List.generate( - state.consultantInfo!.photos.length * 2 - 1, - (index) { - if (index.isEven) { - final photo = state.consultantInfo!.photos[index ~/ 2]; - return _diplomaImage(photo.url); - } else { - return const SizedBox(width: 8); - } - }, - ), + ...List.generate(state.consultantInfo!.photos.length * 2 - 1, (index) { + if (index.isEven) { + final photo = state.consultantInfo!.photos[index ~/ 2]; + return _diplomaImage(photo.url); + } else { + return const SizedBox(width: 8); + } + }), ], ), ), @@ -513,24 +457,13 @@ class _ProfileScreenState extends State { GestureDetector _diplomaImage(String url) { return GestureDetector( onTap: () { - context.pushTransparentRoute( - rootNavigator: true, - PhotoViewScreen( - imageUrls: [url], - index: 0, - ), - ); + context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: [url], index: 0)); }, child: Hero( tag: url, child: ClipRRect( borderRadius: BorderRadius.circular(8), - child: CachedNetworkImage( - imageUrl: url, - fit: BoxFit.fitHeight, - width: MediaQuery.of(context).size.width / 3 - (60 / 3), - height: 160, - ), + child: CachedNetworkImage(imageUrl: url, fit: BoxFit.fitHeight, width: MediaQuery.of(context).size.width / 3 - (60 / 3), height: 160), ), ), ); @@ -553,11 +486,7 @@ class _ProfileScreenState extends State { children: [ const Text( 'Образование', - style: TextStyle( - color: Color(0xFF323F4B), - fontSize: 24, - fontWeight: FontWeight.w600, - ), + style: TextStyle(color: Color(0xFF323F4B), fontSize: 24, fontWeight: FontWeight.w600), ), GestureDetector( onTap: () { @@ -565,11 +494,7 @@ class _ProfileScreenState extends State { }, child: Text( widget.author == null || widget.author!.authorIsMe ? 'Редактировать' : '', - style: const TextStyle( - color: Color(0xFF2F80ED), - fontSize: 16, - fontWeight: FontWeight.w500, - ), + style: const TextStyle(color: Color(0xFF2F80ED), fontSize: 16, fontWeight: FontWeight.w500), ), ), ], @@ -588,8 +513,10 @@ class _ProfileScreenState extends State { ListView.separated( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), - itemBuilder: (context, index) => _educationBlock(state.consultantInfo!.educationModel.completedCourses![index].values.first.title, - state.consultantInfo!.educationModel.completedCourses![index].values.first.date), + itemBuilder: (context, index) => _educationBlock( + state.consultantInfo!.educationModel.completedCourses![index].values.first.title, + state.consultantInfo!.educationModel.completedCourses![index].values.first.date, + ), separatorBuilder: (context, index) => const SizedBox(height: 8), itemCount: state.consultantInfo!.educationModel.completedCourses!.length, ), @@ -619,22 +546,12 @@ class _ProfileScreenState extends State { children: [ Text( title, - style: const TextStyle( - height: 1, - color: Color(0xFF323F4B), - fontWeight: FontWeight.w500, - fontSize: 16, - ), + style: const TextStyle(height: 1, color: Color(0xFF323F4B), fontWeight: FontWeight.w500, fontSize: 16), ), const SizedBox(height: 4), Text( formattedDate, - style: const TextStyle( - height: 1, - color: Color(0xFF6D6F71), - fontWeight: FontWeight.w500, - fontSize: 14, - ), + style: const TextStyle(height: 1, color: Color(0xFF6D6F71), fontWeight: FontWeight.w500, fontSize: 14), ), ], ), @@ -644,7 +561,7 @@ class _ProfileScreenState extends State { } SingleChildRenderObjectWidget _socialLinks(ProfileStateLoaded state) { - if (!state.profile.roles.contains('consultant')) { + if (!state.profile.roles.contains('consultant') && state.consultantInfo != null) { return const SizedBox.shrink(); } return Padding( @@ -658,12 +575,7 @@ class _ProfileScreenState extends State { children: [ _socialLinkButton( () { - launchUrl( - Uri.parse( - state.consultantInfo!.educationModel.vkLink ?? '', - ), - mode: LaunchMode.externalApplication, - ); + launchUrl(Uri.parse(state.consultantInfo!.educationModel.vkLink ?? ''), mode: LaunchMode.externalApplication); }, 'assets/icons/vk.svg', state.consultantInfo!.educationModel.vkLink!.isEmpty ? true : false, @@ -671,12 +583,7 @@ class _ProfileScreenState extends State { const SizedBox(width: 12), _socialLinkButton( () { - launchUrl( - Uri.parse( - state.consultantInfo!.educationModel.tgLink ?? '', - ), - mode: LaunchMode.externalApplication, - ); + launchUrl(Uri.parse(state.consultantInfo!.educationModel.tgLink ?? ''), mode: LaunchMode.externalApplication); }, 'assets/icons/telegram.svg', state.consultantInfo!.educationModel.tgLink!.isEmpty ? true : false, @@ -684,12 +591,7 @@ class _ProfileScreenState extends State { const SizedBox(width: 12), _socialLinkButton( () { - launchUrl( - Uri.parse( - state.consultantInfo!.educationModel.personalSiteLink ?? '', - ), - mode: LaunchMode.externalApplication, - ); + launchUrl(Uri.parse(state.consultantInfo!.educationModel.personalSiteLink ?? ''), mode: LaunchMode.externalApplication); }, 'assets/icons/web.svg', state.consultantInfo!.educationModel.personalSiteLink!.isEmpty ? true : false, @@ -707,10 +609,7 @@ class _ProfileScreenState extends State { borderRadius: BorderRadius.circular(20), child: Ink( width: MediaQuery.of(context).size.width / 3 - (56 / 3), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - color: const Color(0xFFF6F6F6), - ), + decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: const Color(0xFFF6F6F6)), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 16), child: SvgPicture.asset( @@ -732,20 +631,13 @@ class _ProfileScreenState extends State { child: Ink( padding: const EdgeInsets.all(8), width: MediaQuery.of(context).size.width, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - color: Colors.white, - ), + decoration: BoxDecoration(borderRadius: BorderRadius.circular(12), color: Colors.white), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( 'Достижения', - style: TextStyle( - color: Color(0xFF6D6F71), - fontSize: 14, - fontWeight: FontWeight.w600, - ), + style: TextStyle(color: Color(0xFF6D6F71), fontSize: 14, fontWeight: FontWeight.w600), ), const SizedBox(height: 8), SingleChildScrollView( @@ -757,51 +649,25 @@ class _ProfileScreenState extends State { ? Tooltip( message: state.profile.badges[i].description, triggerMode: TooltipTriggerMode.tap, - textStyle: const TextStyle( - color: Colors.black, - fontSize: 16, - fontWeight: FontWeight.w500, - ), + textStyle: const TextStyle(color: Colors.black, fontSize: 16, fontWeight: FontWeight.w500), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(4), - boxShadow: [ - BoxShadow( - color: Colors.black.withAlpha((255 * 0.2).toInt()), - blurRadius: 32, - ) - ], - ), - child: SvgPicture.network( - '$baseUrl/storage/${state.profile.badges[i].image}', - width: 40, - height: 40, + boxShadow: [BoxShadow(color: Colors.black.withAlpha((255 * 0.2).toInt()), blurRadius: 32)], ), + child: SvgPicture.network('$baseUrl/storage/${state.profile.badges[i].image}', width: 40, height: 40), ) : Tooltip( message: state.profile.badges[i].description, margin: const EdgeInsets.symmetric(horizontal: 32), triggerMode: TooltipTriggerMode.tap, - textStyle: const TextStyle( - color: Colors.black, - fontSize: 16, - fontWeight: FontWeight.w500, - ), + textStyle: const TextStyle(color: Colors.black, fontSize: 16, fontWeight: FontWeight.w500), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(4), - boxShadow: [ - BoxShadow( - color: Colors.black.withAlpha((255 * 0.2).toInt()), - blurRadius: 32, - ) - ], - ), - child: CachedNetworkImage( - imageUrl: '$baseUrl/storage/${state.profile.badges[i].image}', - width: 40, - height: 40, + boxShadow: [BoxShadow(color: Colors.black.withAlpha((255 * 0.2).toInt()), blurRadius: 32)], ), + child: CachedNetworkImage(imageUrl: '$baseUrl/storage/${state.profile.badges[i].image}', width: 40, height: 40), ), i < state.profile.badges.length - 1 ? const SizedBox(width: 8) : const SizedBox.shrink(), ], @@ -821,21 +687,12 @@ class _ProfileScreenState extends State { child: Row( children: [ InkWell( - onTap: () => GoRouter.of(context).pushNamed( - 'subsScreen', - extra: SubType.subscriptions, - pathParameters: { - 'slug': state.profile.slug, - }, - ), + onTap: () => GoRouter.of(context).pushNamed('subsScreen', extra: SubType.subscriptions, pathParameters: {'slug': state.profile.slug}), borderRadius: BorderRadius.circular(12), child: Ink( padding: const EdgeInsets.all(8), width: MediaQuery.of(context).size.width / 2 - 6, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - color: Colors.white, - ), + decoration: BoxDecoration(borderRadius: BorderRadius.circular(12), color: Colors.white), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.end, @@ -845,47 +702,27 @@ class _ProfileScreenState extends State { children: [ const Text( 'Подписки', - style: TextStyle( - color: Color(0xFF6D6F71), - fontSize: 14, - fontWeight: FontWeight.w600, - ), + style: TextStyle(color: Color(0xFF6D6F71), fontSize: 14, fontWeight: FontWeight.w600), ), Text( '${state.profile.subscriptions.length}', - style: const TextStyle( - color: Color(0xFF323F4B), - fontSize: 22, - fontWeight: FontWeight.w700, - ), + style: const TextStyle(color: Color(0xFF323F4B), fontSize: 22, fontWeight: FontWeight.w700), ), ], ), - Padding( - padding: const EdgeInsets.only(right: 8), - child: _miniAvatars(state.profile.subscriptions), - ), + Padding(padding: const EdgeInsets.only(right: 8), child: _miniAvatars(state.profile.subscriptions)), ], ), ), ), const SizedBox(width: 4), InkWell( - onTap: () => GoRouter.of(context).pushNamed( - 'subsScreen', - extra: SubType.subscribers, - pathParameters: { - 'slug': state.profile.slug, - }, - ), + onTap: () => GoRouter.of(context).pushNamed('subsScreen', extra: SubType.subscribers, pathParameters: {'slug': state.profile.slug}), borderRadius: BorderRadius.circular(12), child: Ink( padding: const EdgeInsets.all(8), width: MediaQuery.of(context).size.width / 2 - 6, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - color: Colors.white, - ), + decoration: BoxDecoration(borderRadius: BorderRadius.circular(12), color: Colors.white), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.end, @@ -895,26 +732,15 @@ class _ProfileScreenState extends State { children: [ const Text( 'Подписчики', - style: TextStyle( - color: Color(0xFF6D6F71), - fontSize: 14, - fontWeight: FontWeight.w600, - ), + style: TextStyle(color: Color(0xFF6D6F71), fontSize: 14, fontWeight: FontWeight.w600), ), Text( '${state.profile.subscribers.length}', - style: const TextStyle( - color: Color(0xFF323F4B), - fontSize: 22, - fontWeight: FontWeight.w700, - ), + style: const TextStyle(color: Color(0xFF323F4B), fontSize: 22, fontWeight: FontWeight.w700), ), ], ), - Padding( - padding: const EdgeInsets.only(right: 8), - child: _miniAvatars(state.profile.subscribers), - ), + Padding(padding: const EdgeInsets.only(right: 8), child: _miniAvatars(state.profile.subscribers)), ], ), ), @@ -932,13 +758,7 @@ class _ProfileScreenState extends State { for (int i = 0; i < maxLength; i++) Align( widthFactor: 0.8, - child: CircleAvatar( - radius: 14, - foregroundImage: NetworkImage( - urls[i], - ), - backgroundImage: const AssetImage('assets/images/no_avatar.png'), - ), + child: CircleAvatar(radius: 14, foregroundImage: NetworkImage(urls[i]), backgroundImage: const AssetImage('assets/images/no_avatar.png')), ), ], ); @@ -949,10 +769,7 @@ class _ProfileScreenState extends State { padding: const EdgeInsets.symmetric(vertical: 16), shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), - itemBuilder: (context, index) => ProfileBlogPostWidget( - index: index, - profileCubit: profileCubit, - ), + itemBuilder: (context, index) => ProfileBlogPostWidget(index: index, profileCubit: profileCubit), separatorBuilder: (context, index) => const SizedBox(height: 8), itemCount: state.blogPosts.length, ); @@ -966,13 +783,7 @@ class _ProfileScreenState extends State { return Container( alignment: Alignment.centerLeft, padding: const EdgeInsets.all(16), - child: Text( - text, - style: TextStyle( - color: Colors.black.withValues(alpha: 0.4), - height: 1, - ), - ), + child: Text(text, style: TextStyle(color: Colors.black.withValues(alpha: 0.4), height: 1)), ); } @@ -982,105 +793,92 @@ class _ProfileScreenState extends State { child: Row( children: [ PopupMenuButton( - onSelected: (value) async { - if (value == 'view') { - context.pushTransparentRoute( - rootNavigator: true, - PhotoViewScreen( - imageUrls: [baseUrl + state.profile.profilePhotoUrl], - index: 0, - ), - ); - } - if (value == 'replace') { - showCupertinoModalPopup( - context: context, - builder: (context) { - final s = S.of(context); - return CupertinoActionSheet( - actions: [ - CupertinoActionSheetAction( - onPressed: () async { - PermissionHandler.checkPermission( - permission: Permission.camera, - title: s.allowAccessToCamera, - description: s.allowAccessToCameraDescription, - onGranted: () async { - AppRouter.navigatorKey.currentContext?.pop(); - final picker = ImagePicker(); - XFile? image = await picker.pickImage(source: ImageSource.camera); - if (image != null) { - await profileCubit.uploadPhoto(File(image.path)); - await sl().getMyProfile(); - } + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + color: Colors.white, + constraints: const BoxConstraints(minWidth: 260), + surfaceTintColor: Colors.transparent, + elevation: 2, + itemBuilder: (context) => [ + _buildCompactItem( + icon: 'assets/icons/eye.svg', + text: 'Посмотреть', + onTap: () async { + context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: [baseUrl + state.profile.profilePhotoUrl], index: 0)); + }, + ), + if (widget.author == null || widget.author!.authorIsMe) ...[ + _buildDivider(), + _buildCompactItem( + icon: 'assets/icons/edit_paint.svg', + text: 'Заменить', + onTap: () async { + showCupertinoModalPopup( + context: context, + builder: (context) { + final s = S.of(context); + return CupertinoActionSheet( + actions: [ + CupertinoActionSheetAction( + onPressed: () async { + PermissionHandler.checkPermission( + permission: Permission.camera, + title: s.allowAccessToCamera, + description: s.allowAccessToCameraDescription, + onGranted: () async { + AppRouter.navigatorKey.currentContext?.pop(); + final picker = ImagePicker(); + XFile? image = await picker.pickImage(source: ImageSource.camera); + if (image != null) { + await profileCubit.uploadPhoto(File(image.path)); + await sl().getMyProfile(); + } + }, + context: context, + ); }, - context: context, - ); - }, - child: const Row( - children: [ - Icon(Icons.photo_camera), - SizedBox(width: 8), - Text('Фото'), - ], - ), - ), - CupertinoActionSheetAction( - onPressed: () async { - PermissionHandler.checkPermission( - permission: Permission.photos, - title: s.allowAcessToGalery, - description: s.allowAcessToGaleryDescription, - onGranted: () async { - AppRouter.navigatorKey.currentContext?.pop(); - final picker = ImagePicker(); - XFile? image = await picker.pickImage(source: ImageSource.gallery); - if (image != null) { - await profileCubit.uploadPhoto(File(image.path)); - await sl().getMyProfile(); - } + child: const Row(children: [Icon(Icons.photo_camera), SizedBox(width: 8), Text('Фото')]), + ), + CupertinoActionSheetAction( + onPressed: () async { + PermissionHandler.checkPermission( + permission: Permission.photos, + title: s.allowAcessToGalery, + description: s.allowAcessToGaleryDescription, + onGranted: () async { + AppRouter.navigatorKey.currentContext?.pop(); + final picker = ImagePicker(); + XFile? image = await picker.pickImage(source: ImageSource.gallery); + if (image != null) { + await profileCubit.uploadPhoto(File(image.path)); + await sl().getMyProfile(); + } + }, + context: context, + ); }, - context: context, - ); - }, - child: const Row( - children: [ - Icon(Icons.photo), - SizedBox(width: 8), - Text('Галерея'), - ], + child: const Row(children: [Icon(Icons.photo), SizedBox(width: 8), Text('Галерея')]), + ), + ], + cancelButton: CupertinoActionSheetAction( + onPressed: () => Navigator.of(context).pop(), + isDestructiveAction: true, + child: const Text('Отмена'), ), - ), - ], - cancelButton: CupertinoActionSheetAction( - onPressed: () => Navigator.of(context).pop(), - isDestructiveAction: true, - child: const Text('Отмена'), - ), + ); + }, ); }, - ); - } - if (value == 'delete') { - await profileCubit.deletePhoto(); - await sl().getMyProfile(); - } - }, - itemBuilder: (context) => [ - const PopupMenuItem( - value: 'view', - child: Text('Посмотреть'), - ), - if (widget.author == null || widget.author!.authorIsMe) - const PopupMenuItem( - value: 'replace', - child: Text('Заменить'), ), - if (widget.author == null || widget.author!.authorIsMe) - const PopupMenuItem( - value: 'delete', - child: Text('Удалить'), + _buildDivider(), + _buildCompactItem( + icon: 'assets/icons/red_trash.svg', + text: 'Удалить', + onTap: () async { + await profileCubit.deletePhoto(); + await sl().getMyProfile(); + }, ), + ], ], child: Hero( tag: state.profile.profilePhotoUrl, @@ -1099,12 +897,7 @@ class _ProfileScreenState extends State { children: [ Text( '${state.profileDetails.firstName} ${state.profileDetails.lastName}', - style: const TextStyle( - color: Colors.black, - fontWeight: FontWeight.w600, - fontSize: 16, - height: 1, - ), + style: const TextStyle(color: Colors.black, fontWeight: FontWeight.w600, fontSize: 16, height: 1), ), const SizedBox(height: 8), GestureDetector( @@ -1122,11 +915,7 @@ class _ProfileScreenState extends State { children: [ const Text( 'Подробнее', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.w700, - color: Color(0xFF323F4B), - ), + style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700, color: Color(0xFF323F4B)), ), const SizedBox(height: 16), _additional('Дата рожденя: ', state.profileDetails.birthdate), @@ -1146,19 +935,11 @@ class _ProfileScreenState extends State { }, child: const Row( children: [ - Icon( - Icons.info_outline, - size: 20, - color: Color(0xFF6D6F71), - ), + Icon(Icons.info_outline, size: 20, color: Color(0xFF6D6F71)), SizedBox(width: 4), Text( 'Дополнительно', - style: TextStyle( - color: Color(0xFF6D6F71), - fontSize: 14, - fontWeight: FontWeight.w500, - ), + style: TextStyle(color: Color(0xFF6D6F71), fontSize: 14, fontWeight: FontWeight.w500), ), ], ), @@ -1167,62 +948,68 @@ class _ProfileScreenState extends State { ), const Spacer(), PopupMenuButton( - onSelected: (value) { - if (value == 'share') { - final box = context.findRenderObject() as RenderBox?; - SharePlus.instance.share( - ShareParams( - uri: Uri.parse('$baseUrl/ru/profile/${state.profile.slug}'), - sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, - ), - ); - } else if (value == 'block') { - isFirstFetch = true; - sl().blockUser(state.profile.slug); - widget.author == null || widget.author!.authorIsMe ? profileCubit.getMyProfile() : profileCubit.getProfileBySlug(widget.author!.authorSlug); - if (state.profile.roles.contains('consultant')) { - plansCubit.getPlans(state.profile.slug); - } - isFirstFetch = false; - } else if (value == 'unblock') { - isFirstFetch = true; - sl().unblockUser(state.profile.slug); - widget.author == null || widget.author!.authorIsMe ? profileCubit.getMyProfile() : profileCubit.getProfileBySlug(widget.author!.authorSlug); - if (state.profile.roles.contains('consultant')) { - plansCubit.getPlans(state.profile.slug); - } - isFirstFetch = false; - } else { - showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (context) => ComplainingBottomSheet( - complainedContentType: ComplainedContent.profile, - slug: widget.author?.authorSlug, - ), - ); - } - }, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + color: Colors.white, + constraints: const BoxConstraints(minWidth: 260), + surfaceTintColor: Colors.transparent, + elevation: 2, itemBuilder: (context) => [ - const PopupMenuItem( - value: 'share', - child: Text('Поделиться'), + _buildCompactItem( + icon: 'assets/icons/share2.svg', + text: 'Поделиться', + onTap: () { + final box = context.findRenderObject() as RenderBox?; + SharePlus.instance.share( + ShareParams(uri: Uri.parse('$baseUrl/ru/profile/${state.profile.slug}'), sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size), + ); + }, ), - if (widget.author != null) - const PopupMenuItem( - value: 'complain', - child: Text('Пожаловаться'), + if (widget.author != null) ...[ + _buildDivider(), + _buildCompactItem( + icon: 'assets/icons/complain.svg', + text: 'Пожаловаться', + onTap: () { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => ComplainingBottomSheet(complainedContentType: ComplainedContent.profile, slug: widget.author?.authorSlug), + ); + }, ), - if (widget.author != null && !state.profile.isBlockedByMe) - const PopupMenuItem( - value: 'block', - child: Text('Заблокировать'), + ], + if (widget.author != null && !state.profile.isBlockedByMe) ...[ + _buildDivider(), + _buildCompactItem( + icon: 'assets/icons/complain.svg', + text: 'Заблокировать', + onTap: () { + isFirstFetch = true; + sl().blockUser(state.profile.slug); + widget.author == null || widget.author!.authorIsMe ? profileCubit.getMyProfile() : profileCubit.getProfileBySlug(widget.author!.authorSlug); + if (state.profile.roles.contains('consultant')) { + plansCubit.getPlans(state.profile.slug); + } + isFirstFetch = false; + }, ), - if (widget.author != null && state.profile.isBlockedByMe) - const PopupMenuItem( - value: 'unblock', - child: Text('Разблокировать'), + ], + if (widget.author != null && state.profile.isBlockedByMe) ...[ + _buildDivider(), + _buildCompactItem( + icon: 'assets/icons/complain.svg', + text: 'Разблокировать', + onTap: () { + isFirstFetch = true; + sl().unblockUser(state.profile.slug); + widget.author == null || widget.author!.authorIsMe ? profileCubit.getMyProfile() : profileCubit.getProfileBySlug(widget.author!.authorSlug); + if (state.profile.roles.contains('consultant')) { + plansCubit.getPlans(state.profile.slug); + } + isFirstFetch = false; + }, ), + ], ], child: const Icon(Icons.more_horiz_rounded), ), @@ -1231,24 +1018,43 @@ class _ProfileScreenState extends State { ); } + PopupMenuItem _buildCompactItem({required String icon, required String text, required Function() onTap}) { + return PopupMenuItem( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + height: 36, + onTap: onTap, + child: SizedBox( + width: double.infinity, + child: Row( + children: [ + Text(text, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)), + const Spacer(), + SvgPicture.asset(icon), + ], + ), + ), + ); + } + + PopupMenuItem _buildDivider() { + return PopupMenuItem( + enabled: false, + height: 1, + padding: EdgeInsets.zero, + child: Container(margin: const EdgeInsets.symmetric(horizontal: 12), height: 1, color: appTheme.dividerColor), + ); + } + Row _additional(String title, String value) { return Row( children: [ Text( title, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: Color(0xFF6D6F71), - ), + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500, color: Color(0xFF6D6F71)), ), Text( value, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: Color(0xFF323F4B), - ), + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500, color: Color(0xFF323F4B)), ), ], ); @@ -1264,16 +1070,8 @@ class _ProfileScreenState extends State { alignment: Alignment.center, padding: const EdgeInsets.all(8), width: (MediaQuery.of(context).size.width - 40) / 2, - decoration: BoxDecoration( - color: appTheme.blueA400.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(16), - ), - child: Text( - 'Сообщение', - style: TextStyle( - color: appTheme.blueA400, - ), - ), + decoration: BoxDecoration(color: appTheme.blueA400.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(16)), + child: Text('Сообщение', style: TextStyle(color: appTheme.blueA400)), ), ), const SizedBox(width: 8), @@ -1288,16 +1086,8 @@ class _ProfileScreenState extends State { alignment: Alignment.center, padding: const EdgeInsets.all(8), width: (MediaQuery.of(context).size.width - 40) / 2, - decoration: BoxDecoration( - color: !canSub ? Colors.grey.shade200 : appTheme.blueA400, - borderRadius: BorderRadius.circular(16), - ), - child: Text( - !canSub ? 'Отписаться' : 'Подписаться', - style: TextStyle( - color: !canSub ? Colors.black : Colors.white, - ), - ), + decoration: BoxDecoration(color: !canSub ? Colors.grey.shade200 : appTheme.blueA400, borderRadius: BorderRadius.circular(16)), + child: Text(!canSub ? 'Отписаться' : 'Подписаться', style: TextStyle(color: !canSub ? Colors.black : Colors.white)), ), ), const SizedBox(width: 16), @@ -1316,33 +1106,17 @@ class _ProfileScreenState extends State { 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), - ], - ), + 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, - ); + return WeightChartWidget(weightPoints: weightPoints, profileCubit: profileCubit); case 1: - return CircumferenceChartWidget( - circumferencePoints: circumferencePoints, - profileCubit: profileCubit, - ); + return CircumferenceChartWidget(circumferencePoints: circumferencePoints, profileCubit: profileCubit); case 2: - return ImtChartWidget( - weightPoints: weightPoints, - ); + return ImtChartWidget(weightPoints: weightPoints); default: return const SizedBox.shrink(); } @@ -1375,16 +1149,10 @@ class _ProfileScreenState extends State { pressElevation: 0, showCheckmark: false, side: BorderSide.none, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), + 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, - ), + labelStyle: TextStyle(color: appTheme.gray600, fontWeight: FontWeight.w500, fontSize: 12), selected: _selectedIntervalChipIndex == index, onSelected: (bool selected) { profileCubit.getMyProfile(period: chipsIntervalList[index]); @@ -1401,16 +1169,10 @@ class _ProfileScreenState extends State { pressElevation: 0, showCheckmark: false, side: BorderSide.none, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), + 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, - ), + labelStyle: TextStyle(color: index == _selectedChipIndex ? Colors.white : appTheme.gray600, fontWeight: FontWeight.w500, fontSize: 12), selected: _selectedChipIndex == index, backgroundColor: appTheme.gray100, onSelected: (bool selected) { diff --git a/lib/features/profile/repositories/profile_repository.dart b/lib/features/profile/repositories/profile_repository.dart index 75879d15..4867d85a 100644 --- a/lib/features/profile/repositories/profile_repository.dart +++ b/lib/features/profile/repositories/profile_repository.dart @@ -12,9 +12,7 @@ import 'package:eatvio_app/features/profile/services/profile_service.dart'; class ProfileRepository { final ProfileService profileService; - ProfileRepository({ - required this.profileService, - }); + ProfileRepository({required this.profileService}); Future getProfileBySlug(String slug) async { try { @@ -48,11 +46,7 @@ class ProfileRepository { } } - Future addCircumference({ - required int chestCircumference, - required int waistCircumference, - required int hipCircumference, - }) async { + Future addCircumference({required int chestCircumference, required int waistCircumference, required int hipCircumference}) async { try { return (await profileService.addCircumference( chestCircumference: chestCircumference, @@ -64,9 +58,7 @@ class ProfileRepository { } } - Future addWeight({ - required double weight, - }) async { + Future addWeight({required double weight}) async { try { return (await profileService.addWeight(weight: weight)); } catch (e) { @@ -74,52 +66,32 @@ class ProfileRepository { } } - Future> getBlogPostsByAuthorSlug({ - required String slug, - }) async { + Future> getBlogPostsByAuthorSlug({required String slug}) async { try { - return (await profileService.getBlogPostByAuthorSlug( - slug: slug, - )); + return (await profileService.getBlogPostByAuthorSlug(slug: slug)); } catch (e) { rethrow; } } - Future> getWeight({ - required String period, - String? slug, - }) async { + Future> getWeight({required String period, String? slug}) async { try { if (slug != null) { - return (await profileService.getWeightBySlug( - period: period, - slug: slug, - )); + return (await profileService.getWeightBySlug(period: period, slug: slug)); } else { - return (await profileService.getWeight( - period: period, - )); + return (await profileService.getWeight(period: period)); } } catch (e) { rethrow; } } - Future> getCircumferences({ - required String period, - String? slug, - }) async { + Future> getCircumferences({required String period, String? slug}) async { try { if (slug != null) { - return (await profileService.getCircumferencesBySlug( - period: period, - slug: slug, - )); + return (await profileService.getCircumferencesBySlug(period: period, slug: slug)); } else { - return (await profileService.getCircumferences( - period: period, - )); + return (await profileService.getCircumferences(period: period)); } } catch (e) { rethrow; @@ -184,15 +156,20 @@ class ProfileRepository { Future getPassKey(String phone) async { return (jsonDecode( - (await profileService.getPassKey(json: '{"route_name": "user_detailz.change-phone-verify", "route_params": {"phone": "$phone"}}'))))['key_is']!; + (await profileService.getPassKey(json: '{"route_name": "user_detailz.change-phone-verify", "route_params": {"phone": "$phone"}}')), + ))['key_is']!; } Future getMyPhone() async { return (jsonDecode(await profileService.getMyPhone())['phone']); } - Future sendEmailCode() async { - return await profileService.sendEmailCode(); + Future sendEmailCode({String? newEmail}) async { + if (newEmail != null) { + return await profileService.sendEmailCode(data: {'new_email': newEmail}); + } else { + return await profileService.sendEmailCode(); + } } Future tryVerifyEmailByCode(String code) async { @@ -203,4 +180,12 @@ class ProfileRepository { return false; } } + + Future deleteAccount() async { + try { + await profileService.deleteAccount(); + } catch (e) { + rethrow; + } + } } diff --git a/lib/features/profile/services/profile_service.dart b/lib/features/profile/services/profile_service.dart index 06af37b9..b313f27f 100644 --- a/lib/features/profile/services/profile_service.dart +++ b/lib/features/profile/services/profile_service.dart @@ -16,9 +16,7 @@ abstract class ProfileService { factory ProfileService(Dio dio, {String baseUrl}) = _ProfileService; @GET('/api/profile/show/{slug}') - Future getProfileBySlug({ - @Path() required String slug, - }); + Future getProfileBySlug({@Path() required String slug}); @GET('/api/profile/index') Future getMyProfile(); @@ -27,9 +25,7 @@ abstract class ProfileService { Future getMyProfileDetails(); @GET('/api/profile/detailz/{slug}') - Future getProfileDetailsBySlug({ - @Path() required String slug, - }); + Future getProfileDetailsBySlug({@Path() required String slug}); @POST('/api/profile/addCircumference') Future addCircumference({ @@ -39,64 +35,38 @@ abstract class ProfileService { }); @POST('/api/profile/addWeight') - Future addWeight({ - @Part() required double weight, - }); + Future addWeight({@Part() required double weight}); @GET('/api/blogPosts/search_by_author/{slug}') - Future> getBlogPostByAuthorSlug({ - @Path() required String slug, - }); + Future> getBlogPostByAuthorSlug({@Path() required String slug}); @GET('/api/profile/measure/weight/{period}') - Future> getWeight({ - @Path() required String period, - }); + Future> getWeight({@Path() required String period}); @GET('/api/profile/measure/weight/{period}/{slug}') - Future> getWeightBySlug({ - @Path() required String period, - @Path() required String slug, - }); + Future> getWeightBySlug({@Path() required String period, @Path() required String slug}); @GET('/api/profile/measure/circumferences/{period}') - Future> getCircumferences({ - @Path() required String period, - }); + Future> getCircumferences({@Path() required String period}); - @GET('/api/profile/measure/circumferences/{period}/{user}') - Future> getCircumferencesBySlug({ - @Path() required String period, - @Path() required String slug, - }); + @GET('/api/profile/measure/circumferences/{period}/{slug}') + Future> getCircumferencesBySlug({@Path() required String period, @Path() required String slug}); @GET('/api/profile/{slug}/subscribers') - Future getSubscribers({ - @Path() required String slug, - @Query('filters[name][\$startsWith]') String? query, - }); + Future getSubscribers({@Path() required String slug, @Query('filters[name][\$startsWith]') String? query}); @GET('/api/profile/{slug}/subscriptions') - Future getSubscriptions({ - @Path() required String slug, - @Query('filters[name][\$startsWith]') String? query, - }); + Future getSubscriptions({@Path() required String slug, @Query('filters[name][\$startsWith]') String? query}); @POST('/api/profile/subscribe/{slug}') - Future subscribe({ - @Path() required String slug, - }); + Future subscribe({@Path() required String slug}); @POST('/api/profile/unsubscribe/{slug}') - Future unsubscribe({ - @Path() required String slug, - }); + Future unsubscribe({@Path() required String slug}); @POST('/api/profile/photo/upload') @MultiPart() - Future uploadPhoto({ - @Part(name: 'photo') required File photo, - }); + Future uploadPhoto({@Part(name: 'photo') required File photo}); @DELETE('/api/profile/photo/delete') Future deletePhoto(); @@ -105,18 +75,17 @@ abstract class ProfileService { Future getChosenDiets(); @POST('/api/profile/make_pass_key') - Future getPassKey({ - @Body() required String json, - }); + Future getPassKey({@Body() required String json}); @GET('/api/profile/my_phone') Future getMyPhone(); @POST('/api/profile/send_email_code') - Future sendEmailCode(); + Future sendEmailCode({@Body() Map data = const {}}); @POST('/api/profile/verificate_email_code') - Future tryVerifyEmailByCode({ - @Body() required String code, - }); + Future tryVerifyEmailByCode({@Body() required String code}); + + @DELETE('/api/profile/delete_my_account') + Future deleteAccount(); } diff --git a/lib/features/registration/presentation/bloc/registration_cubit/registration_cubit.dart b/lib/features/registration/presentation/bloc/registration_cubit/registration_cubit.dart index 83592c3b..ccf9cae4 100644 --- a/lib/features/registration/presentation/bloc/registration_cubit/registration_cubit.dart +++ b/lib/features/registration/presentation/bloc/registration_cubit/registration_cubit.dart @@ -1,66 +1,69 @@ 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/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'; import 'package:eatvio_app/features/registration/repositories/registration_repository.dart'; +import 'package:eatvio_app/features/registration/services/registration_analytics_service.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; class RegistrationCubit extends Cubit { final RegistrationRepository registrationRepository; - RegistrationCubit({ - required this.registrationRepository, - }) : super( - const RegistrationState( - sheet: UserRegistrationSheet( - firstName: '', - lastName: '', - email: '', - password: '', - passwordConfirmation: '', - birthdate: '', - gender: '', - location: '', - responseBody: '{}', - lang: 'Русский', - weight: 0, - height: 0, - chestCircumference: 0, - waistCircumference: 0, - hipCircumference: 0, - bloodPressureUp: 0, - bloodPressureDown: 0, - hasHypo: 0, - hasHyper: 0, - aboutAllergy: '', - aboutTakingPils: '', - aboutSportHobbies: '', - favoriteFoods: '', - unfavoriteFoods: '', - additionalInfo: '', - phone: '', - ), + RegistrationCubit({required this.registrationRepository}) + : super( + const RegistrationState( + sheet: UserRegistrationSheet( + firstName: '', + lastName: '', + email: '', + password: '', + passwordConfirmation: '', + birthdate: '', + gender: '', + location: '', + responseBody: '{}', + lang: 'Русский', + weight: 0, + height: 0, + chestCircumference: 0, + waistCircumference: 0, + hipCircumference: 0, + bloodPressureUp: 0, + bloodPressureDown: 0, + hasHypo: 0, + hasHyper: 0, + aboutAllergy: '', + aboutTakingPils: '', + aboutSportHobbies: '', + favoriteFoods: '', + unfavoriteFoods: '', + additionalInfo: '', + phone: '', ), - ); + ), + ); void updateSheet(UserRegistrationSheet sheet) => emit(RegistrationState(sheet: sheet)); Future tryRegistration() async { try { final token = await registrationRepository.tryRegister( - state.sheet.copyWith( - phone: state.sheet.phone.replaceAll(' ', '').replaceAll('(', '').replaceAll(')', ''), - responseBody: '{}', - ), + state.sheet.copyWith(phone: state.sheet.phone.replaceAll(' ', '').replaceAll('(', '').replaceAll(')', ''), responseBody: '{}'), ); await TokenStorage.saveTokenInStorage(token); - AppRouter.navigatorKey.currentContext?.pushNamed('verifyEmailScreen'); + await sl().registerPushToken(); + RegistrationAnalyticsService.reportRegistrationComplete(); + AppRouter.navigatorKey.currentContext?.replaceNamed('verifyEmailScreen'); } catch (e, st) { log(e.toString(), stackTrace: st); + RegistrationAnalyticsService.reportRegistrationError(e.toString()); + emit(RegistrationState(sheet: state.sheet, error: e.toString())); } } diff --git a/lib/features/registration/presentation/bloc/registration_cubit/registration_state.dart b/lib/features/registration/presentation/bloc/registration_cubit/registration_state.dart index e4347d20..6a280864 100644 --- a/lib/features/registration/presentation/bloc/registration_cubit/registration_state.dart +++ b/lib/features/registration/presentation/bloc/registration_cubit/registration_state.dart @@ -7,5 +7,6 @@ part 'registration_state.freezed.dart'; abstract class RegistrationState with _$RegistrationState { const factory RegistrationState({ required UserRegistrationSheet sheet, + String? error, }) = _RegistrationState; } diff --git a/lib/features/registration/presentation/bloc/verify_email_cubit/verify_email_cubit.dart b/lib/features/registration/presentation/bloc/verify_email_cubit/verify_email_cubit.dart index 649c1fb8..57554130 100644 --- a/lib/features/registration/presentation/bloc/verify_email_cubit/verify_email_cubit.dart +++ b/lib/features/registration/presentation/bloc/verify_email_cubit/verify_email_cubit.dart @@ -1,13 +1,12 @@ import 'package:eatvio_app/features/profile/repositories/profile_repository.dart'; import 'package:eatvio_app/features/registration/presentation/bloc/verify_email_cubit/verify_email_state.dart'; +import 'package:eatvio_app/features/registration/services/registration_analytics_service.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class VerifyEmailCubit extends Cubit { final ProfileRepository profileRepository; - VerifyEmailCubit({ - required this.profileRepository, - }) : super(const VerifyEmailState.loading()); + VerifyEmailCubit({required this.profileRepository}) : super(const VerifyEmailState.loading()); Future sendEmailCode() async { try { @@ -24,6 +23,7 @@ class VerifyEmailCubit extends Cubit { emit(const VerifyEmailState.verifyLoading()); final result = await profileRepository.tryVerifyEmailByCode(code); if (result == true) { + RegistrationAnalyticsService.reportEmailVerification(); emit(const VerifyEmailState.verifySuccess()); } else { emit(const VerifyEmailState.verifyError()); @@ -32,4 +32,16 @@ class VerifyEmailCubit extends Cubit { emit(const VerifyEmailState.verifyError()); } } + + Future changeEmail(String newEmail) async { + try { + emit(const VerifyEmailState.changeEmailLoading()); + await profileRepository.sendEmailCode(newEmail: newEmail); + emit(const VerifyEmailState.changeEmailSuccess()); + // Обновляем email в состоянии после успешной смены + emit(VerifyEmailState.loaded(email: newEmail)); + } catch (e) { + emit(const VerifyEmailState.changeEmailError()); + } + } } diff --git a/lib/features/registration/presentation/bloc/verify_email_cubit/verify_email_state.dart b/lib/features/registration/presentation/bloc/verify_email_cubit/verify_email_state.dart index 718b2cce..6cb91a32 100644 --- a/lib/features/registration/presentation/bloc/verify_email_cubit/verify_email_state.dart +++ b/lib/features/registration/presentation/bloc/verify_email_cubit/verify_email_state.dart @@ -6,13 +6,9 @@ part 'verify_email_state.freezed.dart'; abstract class VerifyEmailState with _$VerifyEmailState { const factory VerifyEmailState.loading() = VerifyEmailStateLoading; - const factory VerifyEmailState.loaded({ - required String email, - }) = VerifyEmailStateLoaded; + const factory VerifyEmailState.loaded({required String email}) = VerifyEmailStateLoaded; - const factory VerifyEmailState.error({ - required String errorMessage, - }) = VerifyEmailStateError; + const factory VerifyEmailState.error({required String errorMessage}) = VerifyEmailStateError; const factory VerifyEmailState() = _VerifyEmailState; @@ -21,4 +17,10 @@ abstract class VerifyEmailState with _$VerifyEmailState { const factory VerifyEmailState.verifySuccess() = VerifyEmailStateVerifySuccess; const factory VerifyEmailState.verifyError() = VerifyEmailStateVerifyError; + + const factory VerifyEmailState.changeEmailLoading() = VerifyEmailStateChangeEmailLoading; + + const factory VerifyEmailState.changeEmailSuccess() = VerifyEmailStateChangeEmailSuccess; + + const factory VerifyEmailState.changeEmailError() = VerifyEmailStateChangeEmailError; } diff --git a/lib/features/registration/presentation/pages/gender_screen.dart b/lib/features/registration/presentation/pages/gender_screen.dart index 4403b54e..e58df3d4 100644 --- a/lib/features/registration/presentation/pages/gender_screen.dart +++ b/lib/features/registration/presentation/pages/gender_screen.dart @@ -27,48 +27,46 @@ class _GenderScreenState extends State { padding: const EdgeInsets.only(bottom: 80, top: 16, left: 16, right: 16), child: Column( children: [ - RadioListTile( - contentPadding: EdgeInsets.zero, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - title: const Text('Мужской'), - value: 'male', - groupValue: currentValue, - onChanged: (newValue) { - final registrationCubit = context.read(); - registrationCubit.updateSheet(registrationCubit.state.sheet.copyWith(gender: 'male')); - setState(() { - currentValue = 'male'; - }); - }, - secondary: Image.asset( - 'assets/images/img_male.png', - width: 40, - height: 40, - ), + Row( + children: [ + Radio( + value: 'male', + // ignore: deprecated_member_use + groupValue: currentValue, + // ignore: deprecated_member_use + onChanged: (newValue) { + final registrationCubit = context.read(); + registrationCubit.updateSheet(registrationCubit.state.sheet.copyWith(gender: 'male')); + setState(() { + currentValue = 'male'; + }); + }, + ), + Expanded( + child: Row(children: [Image.asset('assets/images/img_male.png', width: 40, height: 40), const SizedBox(width: 8), const Text('Мужской')]), + ), + ], ), const SizedBox(height: 16), - RadioListTile( - contentPadding: EdgeInsets.zero, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - title: const Text('Женский'), - value: 'female', - groupValue: currentValue, - onChanged: (newValue) { - final registrationCubit = context.read(); - registrationCubit.updateSheet(registrationCubit.state.sheet.copyWith(gender: 'female')); - setState(() { - currentValue = 'female'; - }); - }, - secondary: Image.asset( - 'assets/images/img_female.png', - width: 40, - height: 40, - ), + Row( + children: [ + Radio( + value: 'female', + // ignore: deprecated_member_use + groupValue: currentValue, + // ignore: deprecated_member_use + onChanged: (newValue) { + final registrationCubit = context.read(); + registrationCubit.updateSheet(registrationCubit.state.sheet.copyWith(gender: 'female')); + setState(() { + currentValue = 'female'; + }); + }, + ), + Expanded( + child: Row(children: [Image.asset('assets/images/img_female.png', width: 40, height: 40), const SizedBox(width: 8), const Text('Женский')]), + ), + ], ), ], ), diff --git a/lib/features/registration/presentation/pages/registration_screen.dart b/lib/features/registration/presentation/pages/registration_screen.dart index 69ae62d5..9f30ca33 100644 --- a/lib/features/registration/presentation/pages/registration_screen.dart +++ b/lib/features/registration/presentation/pages/registration_screen.dart @@ -17,6 +17,7 @@ import 'package:eatvio_app/features/registration/presentation/pages/medicine_int import 'package:eatvio_app/features/registration/presentation/pages/name_screen.dart'; import 'package:eatvio_app/features/registration/presentation/pages/phone_screen.dart'; import 'package:eatvio_app/features/registration/presentation/pages/weight_screen.dart'; +import 'package:eatvio_app/features/registration/services/registration_analytics_service.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; @@ -43,142 +44,127 @@ class _RegistrationScreenState extends State { Widget build(BuildContext context) { return BlocProvider( create: (BuildContext context) => registrationCubit, - child: BlocBuilder( - builder: (BuildContext context, RegistrationState state) { - return Scaffold( - floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, - floatingActionButton: Row( - children: [ - Container( - height: 48, - width: MediaQuery.of(context).size.width * 0.2, - padding: const EdgeInsets.only(left: 16, right: 4), - child: FloatingActionButton.extended( - heroTag: 'backButtonRegistration', - highlightElevation: 0, - focusElevation: 0, - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - backgroundColor: const Color(0xFFEAF2FD), - onPressed: () async { - setState(() { - pageController.previousPage( - duration: const Duration(milliseconds: 350), - curve: Curves.easeInOut, - ); - }); - }, - label: Icon( - Icons.arrow_back_ios_new_rounded, - color: appTheme.blueA400, - ), - ), - ), - Container( - height: 48, - width: MediaQuery.of(context).size.width * 0.8, - padding: const EdgeInsets.only(left: 4, right: 16), - child: FloatingActionButton.extended( - heroTag: 'nextButtonRegistration', - highlightElevation: 0, - elevation: 0, - focusElevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - backgroundColor: appTheme.blueA400, - onPressed: () async { - if (await canGoNext(state, context)) { - setState(() { - pageController.nextPage( - duration: const Duration(milliseconds: 350), - curve: Curves.easeInOut, - ); - }); - } - }, - label: Text( - 'Продолжить', - style: theme.textTheme.titleSmall!.copyWith(color: Colors.white), - ), - ), - ), - ], - ), - appBar: AppBar( - automaticallyImplyLeading: false, - leading: Builder( - builder: (context) => IconButton( - icon: const Icon(Icons.close_rounded), - onPressed: () => GoRouter.of(context).pop(), - ), + child: BlocListener( + listener: (context, state) { + if (state.error != null && state.error!.isNotEmpty) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Ошибка при регистрации'), + content: Text(state.error!), + actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))], ), - centerTitle: true, - title: SvgPicture.asset( - ImageConstant.imgLogoEatvio, - height: 48, - fit: BoxFit.contain, - ), - actions: [ - Padding( - padding: const EdgeInsets.only(right: 16), - child: Text('шаг $step из 15'), - ), - ], - bottom: PreferredSize( - preferredSize: Size(MediaQuery.of(context).size.width, 32), - child: Padding( - padding: const EdgeInsets.all(16), - child: TweenAnimationBuilder( - duration: const Duration(milliseconds: 350), - curve: Curves.easeInOut, - tween: Tween( - begin: 1 / 15, - end: progressValue, - ), - builder: (context, value, _) => LinearProgressIndicator( - value: value, - borderRadius: BorderRadius.circular(16), - minHeight: 8, - backgroundColor: Colors.grey.shade100, - valueColor: AlwaysStoppedAnimation(appTheme.blueA400), - ), - ), - ), - ), - ), - body: PageView( - physics: const NeverScrollableScrollPhysics(), - controller: pageController, - onPageChanged: (index) { - setState(() { - step = index + 1; - progressValue = step / 15; - }); - }, - children: [ - // LanguageSelectScreen(sheet: state.sheet), - PhoneScreen(sheet: state.sheet), - CredentialsScreen(sheet: state.sheet), - NameScreen(sheet: state.sheet), - GenderScreen(sheet: state.sheet), - DateOfBirthScreen(sheet: state.sheet), - CityOfLivingScreen(sheet: state.sheet), - BodyCircumferencesScreen(sheet: state.sheet), - WeightScreen(sheet: state.sheet), - HeightScreen(sheet: state.sheet), - BloodPressureScreen(sheet: state.sheet), - AllergiesScreen(sheet: state.sheet), - MedicineIntakeScreen(sheet: state.sheet), - FavoriteSportsScreen(sheet: state.sheet), - FavoriteFoodsScreen(sheet: state.sheet), - AdditionalInformationScreen(sheet: state.sheet), - ], - ), - ); + ); + } }, + child: BlocBuilder( + builder: (BuildContext context, RegistrationState state) { + return Scaffold( + floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, + floatingActionButton: Row( + children: [ + Container( + height: 48, + width: MediaQuery.of(context).size.width * 0.2, + padding: const EdgeInsets.only(left: 16, right: 4), + child: FloatingActionButton.extended( + heroTag: 'backButtonRegistration', + highlightElevation: 0, + focusElevation: 0, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + backgroundColor: const Color(0xFFEAF2FD), + onPressed: () async { + setState(() { + pageController.previousPage(duration: const Duration(milliseconds: 350), curve: Curves.easeInOut); + }); + }, + label: Icon(Icons.arrow_back_ios_new_rounded, color: appTheme.blueA400), + ), + ), + Container( + height: 48, + width: MediaQuery.of(context).size.width * 0.8, + padding: const EdgeInsets.only(left: 4, right: 16), + child: FloatingActionButton.extended( + heroTag: 'nextButtonRegistration', + highlightElevation: 0, + elevation: 0, + focusElevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + backgroundColor: appTheme.blueA400, + onPressed: () async { + if (await canGoNext(state, context)) { + setState(() { + pageController.nextPage(duration: const Duration(milliseconds: 350), curve: Curves.easeInOut); + }); + } + }, + label: Text('Продолжить', style: theme.textTheme.titleSmall!.copyWith(color: Colors.white)), + ), + ), + ], + ), + appBar: AppBar( + automaticallyImplyLeading: false, + leading: Builder( + builder: (context) => IconButton(icon: const Icon(Icons.close_rounded), onPressed: () => GoRouter.of(context).pop()), + ), + centerTitle: true, + title: SvgPicture.asset(ImageConstant.imgLogoEatvio, height: 48, fit: BoxFit.contain), + actions: [Padding(padding: const EdgeInsets.only(right: 16), child: Text('шаг $step из 15'))], + bottom: PreferredSize( + preferredSize: Size(MediaQuery.of(context).size.width, 32), + child: Padding( + padding: const EdgeInsets.all(16), + child: TweenAnimationBuilder( + duration: const Duration(milliseconds: 350), + curve: Curves.easeInOut, + tween: Tween(begin: 1 / 15, end: progressValue), + builder: (context, value, _) => LinearProgressIndicator( + value: value, + borderRadius: BorderRadius.circular(16), + minHeight: 8, + backgroundColor: Colors.grey.shade100, + valueColor: AlwaysStoppedAnimation(appTheme.blueA400), + ), + ), + ), + ), + ), + body: PageView( + physics: const NeverScrollableScrollPhysics(), + controller: pageController, + onPageChanged: (index) { + setState(() { + step = index + 1; + progressValue = step / 15; + }); + // Report analytics for step completion + RegistrationAnalyticsService.reportRegistrationStep(step); + }, + children: [ + // LanguageSelectScreen(sheet: state.sheet), + PhoneScreen(sheet: state.sheet), + CredentialsScreen(sheet: state.sheet), + NameScreen(sheet: state.sheet), + GenderScreen(sheet: state.sheet), + DateOfBirthScreen(sheet: state.sheet), + CityOfLivingScreen(sheet: state.sheet), + BodyCircumferencesScreen(sheet: state.sheet), + WeightScreen(sheet: state.sheet), + HeightScreen(sheet: state.sheet), + BloodPressureScreen(sheet: state.sheet), + AllergiesScreen(sheet: state.sheet), + MedicineIntakeScreen(sheet: state.sheet), + FavoriteSportsScreen(sheet: state.sheet), + FavoriteFoodsScreen(sheet: state.sheet), + AdditionalInformationScreen(sheet: state.sheet), + ], + ), + ); + }, + ), ), ); } @@ -192,12 +178,7 @@ class _RegistrationScreenState extends State { builder: (context) => AlertDialog( title: const Text('Ошибка'), content: const Text('Введите корректный номер телефона'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('OK'), - ), - ], + actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))], ), ); return false; @@ -205,12 +186,7 @@ class _RegistrationScreenState extends State { return true; } case 2: - List errors = validateCredentialsFields( - state.sheet.email, - state.sheet.password, - state.sheet.passwordConfirmation, - context, - ); + List errors = validateCredentialsFields(state.sheet.email, state.sheet.password, state.sheet.passwordConfirmation, context); if (errors.isEmpty) { return true; } else { @@ -225,22 +201,13 @@ class _RegistrationScreenState extends State { builder: (context) => AlertDialog( title: const Text('Ошибка'), content: Text(errorMessage), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('OK'), - ), - ], + actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))], ), ); return false; } case 3: - List errors = validateNameFields( - state.sheet.firstName.toString(), - state.sheet.lastName.toString(), - context, - ); + List errors = validateNameFields(state.sheet.firstName.toString(), state.sheet.lastName.toString(), context); if (errors.isEmpty) { return true; } else { @@ -255,12 +222,7 @@ class _RegistrationScreenState extends State { builder: (context) => AlertDialog( title: const Text('Ошибка'), content: Text(errorMessage), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('OK'), - ), - ], + actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))], ), ); return false; @@ -274,12 +236,7 @@ class _RegistrationScreenState extends State { builder: (context) => AlertDialog( title: const Text('Ошибка'), content: const Text('Выберете пол'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('OK'), - ), - ], + actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))], ), ); return false; @@ -293,12 +250,7 @@ class _RegistrationScreenState extends State { builder: (context) => AlertDialog( title: const Text('Ошибка'), content: const Text('Заполните все поля'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('OK'), - ), - ], + actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))], ), ); return false; @@ -324,12 +276,7 @@ class _RegistrationScreenState extends State { builder: (context) => AlertDialog( title: const Text('Ошибка'), content: Text(errorMessage), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('OK'), - ), - ], + actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))], ), ); return false; @@ -340,10 +287,7 @@ class _RegistrationScreenState extends State { barrierDismissible: false, useSafeArea: false, context: context, - builder: (context) => const PopScope( - canPop: false, - child: Loader(), - ), + builder: (context) => const PopScope(canPop: false, child: Loader()), ); await registrationCubit.tryRegistration(); if (context.mounted) { @@ -354,9 +298,7 @@ class _RegistrationScreenState extends State { Navigator.pop(context); showDialog( context: context, - builder: (context) => const AlertDialog( - title: Text('Ошибка при попытке регистрации'), - ), + builder: (context) => const AlertDialog(title: Text('Ошибка при попытке регистрации')), ); } } @@ -366,12 +308,7 @@ class _RegistrationScreenState extends State { } } - List validateCircumferencesFields( - String chestCircumference, - String waistCircumference, - String hipCircumference, - BuildContext context, - ) { + List validateCircumferencesFields(String chestCircumference, String waistCircumference, String hipCircumference, BuildContext context) { final s = S.of(context); List errors = []; @@ -387,12 +324,7 @@ class _RegistrationScreenState extends State { return errors; } - List validateCredentialsFields( - String email, - String password, - String passwordConfirmation, - BuildContext context, - ) { + List validateCredentialsFields(String email, String password, String passwordConfirmation, BuildContext context) { final s = S.of(context); List errors = []; @@ -416,11 +348,7 @@ class _RegistrationScreenState extends State { return errors; } - List validateNameFields( - String firstName, - String lastName, - BuildContext context, - ) { + List validateNameFields(String firstName, String lastName, BuildContext context) { List errors = []; if (firstName.isEmpty) { diff --git a/lib/features/registration/presentation/pages/verify_email_screen.dart b/lib/features/registration/presentation/pages/verify_email_screen.dart index ed4ea11f..4ade2581 100644 --- a/lib/features/registration/presentation/pages/verify_email_screen.dart +++ b/lib/features/registration/presentation/pages/verify_email_screen.dart @@ -1,7 +1,15 @@ +import 'dart:async'; + 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/utils/toasts.dart'; +import 'package:eatvio_app/core/widgets/custom_outlined_button.dart'; import 'package:eatvio_app/features/registration/presentation/bloc/verify_email_cubit/verify_email_cubit.dart'; import 'package:eatvio_app/features/registration/presentation/bloc/verify_email_cubit/verify_email_state.dart'; +import 'package:eatvio_app/features/registration/presentation/widgets/change_email_dialog.dart'; +import 'package:eatvio_app/features/registration/presentation/widgets/code_not_received_dialog.dart'; +import 'package:eatvio_app/features/registration/services/registration_analytics_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; @@ -18,11 +26,17 @@ class _VerifyEmailScreenState extends State { final List _controllers = List.generate(4, (_) => TextEditingController()); String email = ''; late final GoRouter goRouter; + late Timer _timer; + int _secondsRemaining = 60; + bool _canSendAgain = false; @override void initState() { super.initState(); goRouter = GoRouter.of(context); + _startTimer(); + // Report email verification screen opened + RegistrationAnalyticsService.reportRegistrationStep(16); // Email verification step } @override @@ -36,6 +50,23 @@ class _VerifyEmailScreenState extends State { super.dispose(); } + void _startTimer() { + setState(() { + _canSendAgain = false; + _secondsRemaining = 60; + }); + _timer = Timer.periodic(const Duration(seconds: 1), (timer) { + setState(() { + if (_secondsRemaining > 0) { + _secondsRemaining--; + } else { + _canSendAgain = true; + _timer.cancel(); + } + }); + }); + } + void _checkAndSubmitCode(BuildContext context) { final code = _controllers.map((c) => c.text).join(); if (code.length == 4) { @@ -47,48 +78,96 @@ class _VerifyEmailScreenState extends State { Widget build(BuildContext context) { return BlocProvider( create: (_) => sl()..sendEmailCode(), - child: BlocConsumer( - listener: (context, state) { - if (state is VerifyEmailStateLoaded) { - setState(() { - email = state.email; - }); - } else if (state is VerifyEmailStateVerifySuccess) { - showSuccessToast('Почта успешно подтверждена'); - goRouter.pushNamed('feedScreen'); - } else if (state is VerifyEmailStateVerifyError) { - showErrorToast("Неверный код. Попробуйте снова."); - for (var controller in _controllers) { - controller.clear(); - } - FocusScope.of(context).requestFocus(_focusNodes[0]); - } - }, - builder: (context, state) { - return Scaffold( - appBar: AppBar(title: const Text("Подтвердите почту")), - body: Padding( - padding: const EdgeInsets.all(24.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text("Код отправлен на почту\n$email", textAlign: TextAlign.center), - const SizedBox(height: 20), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: List.generate(4, (index) => _buildCodeField(context, index)), + child: Builder( + builder: (context) { + return BlocConsumer( + listener: (context, state) { + if (state is VerifyEmailStateLoaded) { + setState(() { + email = state.email; + }); + _startTimer(); + } else if (state is VerifyEmailStateVerifySuccess) { + showSuccessToast('Почта успешно подтверждена'); + goRouter.pushNamed('feedScreen'); + } else if (state is VerifyEmailStateVerifyError) { + showErrorToast("Неверный код. Попробуйте снова."); + for (var controller in _controllers) { + controller.clear(); + } + FocusScope.of(context).requestFocus(_focusNodes[0]); + } else if (state is VerifyEmailStateChangeEmailSuccess) { + showSuccessToast('Код подтверждения отправлен на новый email'); + // Диалог закроется автоматически в ChangeEmailDialog при успешной смене + } else if (state is VerifyEmailStateChangeEmailError) { + showErrorToast('Ошибка при смене email. Попробуйте снова.'); + } + }, + builder: (context, state) { + return Scaffold( + appBar: AppBar(title: const Text("Подтвердите почту")), + body: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text("Код отправлен на почту\n$email", textAlign: TextAlign.center), + const SizedBox(height: 20), + Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: List.generate(4, (index) => _buildCodeField(context, index))), + const SizedBox(height: 16), + GestureDetector( + onTap: () { + final verifyEmailCubit = context.read(); + showDialog(context: context, builder: (context) => CodeNotReceivedDialog( + currentEmail: email, + verifyEmailCubit: verifyEmailCubit, + )); + }, + child: Text( + 'Не приходит код?', + style: TextStyles.textSMedium.copyWith(color: appTheme.blueA400, decoration: TextDecoration.underline), + ), + ), + const SizedBox(height: 24), + CustomOutlinedButton( + text: 'Запросить код повторно', + height: 52, + isDisabled: !_canSendAgain, + buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700), + buttonStyle: ButtonStyle( + side: const WidgetStatePropertyAll(BorderSide.none), + backgroundColor: WidgetStatePropertyAll(_canSendAgain ? appTheme.blueA400 : appTheme.gray), + ), + onPressed: () async { + await context.read().sendEmailCode(); + _startTimer(); + }, + ), + const SizedBox(height: 8), + if (!_canSendAgain) + Text("Зпросить код повторно можно через $_secondsRemaining с.", style: TextStyles.textSMedium.copyWith(color: appTheme.gray)), + const SizedBox(height: 16), + CustomOutlinedButton( + text: 'Сменить email', + height: 52, + buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.blueA400), + buttonStyle: ButtonStyle( + side: WidgetStatePropertyAll(BorderSide(color: appTheme.blueA400)), + backgroundColor: const WidgetStatePropertyAll(Colors.transparent), + ), + onPressed: () { + final verifyEmailCubit = context.read(); + showDialog( + context: context, + builder: (context) => ChangeEmailDialog(currentEmail: email, verifyEmailCubit: verifyEmailCubit), + ); + }, + ), + ], ), - const SizedBox(height: 20), - const Text("Запросить код повторно", style: TextStyle(color: Colors.grey)), - TextButton( - onPressed: () { - context.read().sendEmailCode(); - }, - child: const Text("Не приходит код?"), - ), - ], - ), - ), + ), + ); + }, ); }, ), diff --git a/lib/features/registration/presentation/pages/verify_screen.dart b/lib/features/registration/presentation/pages/verify_screen.dart index 7f8aede5..8d5df9df 100644 --- a/lib/features/registration/presentation/pages/verify_screen.dart +++ b/lib/features/registration/presentation/pages/verify_screen.dart @@ -1,5 +1,7 @@ import 'package:eatvio_app/core/const.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/core/widgets/loader.dart'; +import 'package:eatvio_app/features/registration/services/registration_analytics_service.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:lottie/lottie.dart'; @@ -8,10 +10,7 @@ import 'package:webview_flutter/webview_flutter.dart'; class VerifyScreen extends StatefulWidget { final String passKey; - const VerifyScreen({ - super.key, - required this.passKey, - }); + const VerifyScreen({super.key, required this.passKey}); @override State createState() => _VerifyScreenState(); @@ -19,16 +18,26 @@ class VerifyScreen extends StatefulWidget { class _VerifyScreenState extends State { late final WebViewController _controller; + bool _isLoading = true; @override void initState() { super.initState(); + // Report phone verification screen opened + RegistrationAnalyticsService.reportRegistrationStep(17); // Phone verification step _controller = WebViewController() ..setJavaScriptMode(JavaScriptMode.unrestricted) ..setNavigationDelegate( NavigationDelegate( + onPageFinished: (url) { + setState(() { + _isLoading = false; + }); + }, onNavigationRequest: (NavigationRequest request) async { if (request.url.contains('user_detailz') && !request.url.contains('change-phone-verify') || request.url.contains('email/verify')) { + // Report phone verification success + RegistrationAnalyticsService.reportPhoneVerification(); GoRouter.of(context).pop(); await showModalBottomSheet( useRootNavigator: true, @@ -43,27 +52,15 @@ class _VerifyScreenState extends State { const Text( 'Номер телефона\nуспешно привязан', textAlign: TextAlign.center, - style: TextStyle( - fontSize: 20, - height: 1, - fontWeight: FontWeight.w600, - ), + style: TextStyle(fontSize: 20, height: 1, fontWeight: FontWeight.w600), ), const SizedBox(height: 16), - Lottie.asset( - 'assets/lottie_animations/success.json', - width: 160, - height: 160, - ), + Lottie.asset('assets/lottie_animations/success.json', width: 160, height: 160), const SizedBox(height: 16), const Text( 'Теперь Вам доступен весь функционал!', textAlign: TextAlign.center, - style: TextStyle( - fontSize: 16, - height: 1, - fontWeight: FontWeight.w500, - ), + style: TextStyle(fontSize: 16, height: 1, fontWeight: FontWeight.w500), ), const SizedBox(height: 16), InkWell( @@ -73,18 +70,11 @@ class _VerifyScreenState extends State { child: Ink( width: MediaQuery.of(context).size.width - 32, height: 52, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - color: appTheme.blueA400, - ), + decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400), child: const Center( child: Text( 'Закрыть', - style: TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.w600, - ), + style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600), ), ), ), @@ -105,11 +95,14 @@ class _VerifyScreenState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar( - toolbarHeight: 0, - ), + appBar: AppBar(toolbarHeight: 0), body: SafeArea( - child: WebViewWidget(controller: _controller), + child: Stack( + children: [ + WebViewWidget(controller: _controller), + if (_isLoading) const Center(child: Loader()), + ], + ), ), ); } diff --git a/lib/features/registration/presentation/pages/webview_registration_screen.dart b/lib/features/registration/presentation/pages/webview_registration_screen.dart index abb6537d..05c0ffc1 100644 --- a/lib/features/registration/presentation/pages/webview_registration_screen.dart +++ b/lib/features/registration/presentation/pages/webview_registration_screen.dart @@ -4,6 +4,7 @@ import 'dart:developer'; import 'package:eatvio_app/core/const.dart'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/features/auth/presentation/bloc/auth_cubit/auth_cubit.dart'; +import 'package:eatvio_app/features/registration/services/registration_analytics_service.dart'; import 'package:flutter/material.dart'; import 'package:webview_flutter/webview_flutter.dart'; @@ -24,6 +25,8 @@ class _WebViewRegistrationScreenState extends State { void initState() { super.initState(); authCubit = sl(); + // Report webview registration started + RegistrationAnalyticsService.reportRegistrationStep(18); // Webview registration step _controller = WebViewController() ..setJavaScriptMode(JavaScriptMode.unrestricted) ..addJavaScriptChannel( @@ -43,10 +46,9 @@ class _WebViewRegistrationScreenState extends State { NavigationDelegate( onNavigationRequest: (NavigationRequest request) { if (request.url.contains('profile')) { - authCubit.getTokenByCredentials( - email!, - password!, - ); + // Report webview registration completion + RegistrationAnalyticsService.reportRegistrationComplete(); + authCubit.getTokenByCredentials(email!, password!); } return NavigationDecision.navigate; }, @@ -86,12 +88,8 @@ class _WebViewRegistrationScreenState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar( - toolbarHeight: 0, - ), - body: SafeArea( - child: WebViewWidget(controller: _controller), - ), + appBar: AppBar(toolbarHeight: 0), + body: SafeArea(child: WebViewWidget(controller: _controller)), ); } } diff --git a/lib/features/registration/presentation/widgets/change_email_dialog.dart b/lib/features/registration/presentation/widgets/change_email_dialog.dart new file mode 100644 index 00000000..266e9f04 --- /dev/null +++ b/lib/features/registration/presentation/widgets/change_email_dialog.dart @@ -0,0 +1,174 @@ +import 'package:eatvio_app/core/theme/custom_text_style.dart'; +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/core/utils/toasts.dart'; +import 'package:eatvio_app/core/widgets/custom_outlined_button.dart'; +import 'package:eatvio_app/features/registration/presentation/bloc/verify_email_cubit/verify_email_cubit.dart'; +import 'package:eatvio_app/features/registration/presentation/bloc/verify_email_cubit/verify_email_state.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class ChangeEmailDialog extends StatefulWidget { + final String currentEmail; + final VerifyEmailCubit verifyEmailCubit; + + const ChangeEmailDialog({super.key, required this.currentEmail, required this.verifyEmailCubit}); + + @override + State createState() => _ChangeEmailDialogState(); +} + +class _ChangeEmailDialogState extends State { + final TextEditingController _emailController = TextEditingController(); + final FocusNode _emailFocusNode = FocusNode(); + bool _isLoading = false; + String? _errorText; + + @override + void initState() { + super.initState(); + _emailController.text = widget.currentEmail; + } + + @override + void dispose() { + _emailController.dispose(); + _emailFocusNode.dispose(); + super.dispose(); + } + + bool _isValidEmail(String email) { + return RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(email); + } + + void _validateEmail() { + final email = _emailController.text.trim(); + if (email.isEmpty) { + setState(() { + _errorText = 'Введите email'; + }); + } else if (!_isValidEmail(email)) { + setState(() { + _errorText = 'Введите корректный email'; + }); + } else if (email == widget.currentEmail) { + setState(() { + _errorText = 'Новый email должен отличаться от текущего'; + }); + } else { + setState(() { + _errorText = null; + }); + } + } + + Future _changeEmail() async { + _validateEmail(); + if (_errorText != null) return; + + setState(() { + _isLoading = true; + }); + + try { + await widget.verifyEmailCubit.changeEmail(_emailController.text.trim()); + // Диалог закроется автоматически при успешной смене email через основной экран + } catch (e) { + if (mounted) { + showErrorToast('Ошибка при смене email. Попробуйте снова.'); + setState(() { + _isLoading = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: widget.verifyEmailCubit, + child: BlocListener( + listener: (context, state) { + if (state is VerifyEmailStateChangeEmailSuccess) { + Navigator.of(context).pop(); // Закрываем диалог при успешной смене + } else if (state is VerifyEmailStateChangeEmailError) { + setState(() { + _isLoading = false; + }); + } + }, + child: Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Сменить email', style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)), + const SizedBox(height: 16), + Text('Введите новый email для получения кода подтверждения', style: TextStyles.textSMedium.copyWith(color: appTheme.gray)), + const SizedBox(height: 20), + TextField( + controller: _emailController, + focusNode: _emailFocusNode, + keyboardType: TextInputType.emailAddress, + decoration: InputDecoration( + hintText: 'Введите новый email', + hintStyle: TextStyles.textSMedium.copyWith(color: appTheme.gray), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: appTheme.gray), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: appTheme.blueA400), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: appTheme.red), + ), + errorText: _errorText, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + ), + onChanged: (_) => _validateEmail(), + onSubmitted: (_) => _changeEmail(), + ), + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: CustomOutlinedButton( + text: 'Отмена', + height: 52, + buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.black), + buttonStyle: ButtonStyle( + side: const WidgetStatePropertyAll(BorderSide.none), + backgroundColor: WidgetStatePropertyAll(appTheme.lightGray0), + ), + onPressed: () => Navigator.of(context).pop(), + ), + ), + const SizedBox(width: 12), + Expanded( + child: CustomOutlinedButton( + text: _isLoading ? 'Отправка...' : 'Сменить', + height: 52, + isDisabled: _isLoading, + buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700), + buttonStyle: ButtonStyle( + side: const WidgetStatePropertyAll(BorderSide.none), + backgroundColor: WidgetStatePropertyAll(_isLoading ? appTheme.gray : appTheme.blueA400), + ), + onPressed: _isLoading ? null : _changeEmail, + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/registration/presentation/widgets/code_not_received_dialog.dart b/lib/features/registration/presentation/widgets/code_not_received_dialog.dart new file mode 100644 index 00000000..3baa5742 --- /dev/null +++ b/lib/features/registration/presentation/widgets/code_not_received_dialog.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/gestures.dart'; +import 'package:eatvio_app/core/theme/custom_text_style.dart'; +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/registration/presentation/widgets/change_email_dialog.dart'; +import 'package:eatvio_app/features/registration/presentation/bloc/verify_email_cubit/verify_email_cubit.dart'; + +class CodeNotReceivedDialog extends StatelessWidget { + final String currentEmail; + final VerifyEmailCubit verifyEmailCubit; + + const CodeNotReceivedDialog({super.key, required this.currentEmail, required this.verifyEmailCubit}); + + void _showChangeEmailDialog(BuildContext context) { + Navigator.of(context).pop(); // Закрываем текущий диалог + showDialog( + context: context, + builder: (context) => ChangeEmailDialog(currentEmail: currentEmail, verifyEmailCubit: verifyEmailCubit), + ); + } + + @override + Widget build(BuildContext context) { + return Dialog( + backgroundColor: Colors.transparent, + child: Container( + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16.0)), + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Почему может не приходить код?', style: TextStyles.textLSemibold.copyWith(color: appTheme.black)), + const SizedBox(height: 16), + RichText( + text: TextSpan( + style: TextStyles.textMMedium.copyWith(color: appTheme.gray600), + children: [ + const TextSpan(text: 'Опечатка в email: Если адрес введён неправильно, код не придёт. ', style: TextStyles.textFS13FW400), + TextSpan( + text: 'Ввести email повторно.', + style: TextStyles.textMMedium.copyWith(color: appTheme.blueA400, decoration: TextDecoration.underline), + recognizer: TapGestureRecognizer()..onTap = () => _showChangeEmailDialog(context), + ), + const TextSpan( + style: TextStyles.textFS13FW400, + text: + '\n\nПроверьте спам/промо/мусорную папку: Код может попасть туда из-за фильтров почтового провайдера\n\nПодождите 1-2 минуты: Иногда email задерживается из-за сетевых проблем.\n\nУбедитесь в стабильном соединении: Проверьте интернет и уведомления на устройстве.', + ), + ], + ), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () => Navigator.of(context).pop(), + style: ElevatedButton.styleFrom( + backgroundColor: appTheme.blueA400, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: Text('Понятно', style: TextStyles.textMSemibold.copyWith(color: Colors.white)), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/registration/repositories/registration_repository.dart b/lib/features/registration/repositories/registration_repository.dart index 0c3008e0..548684b9 100644 --- a/lib/features/registration/repositories/registration_repository.dart +++ b/lib/features/registration/repositories/registration_repository.dart @@ -1,10 +1,9 @@ - import 'package:dio/dio.dart'; import 'package:eatvio_app/core/locator_service.dart'; -import 'package:eatvio_app/core/utils/toasts.dart'; import 'package:eatvio_app/features/registration/models/user_registration_model.dart'; import 'package:eatvio_app/features/registration/models/location_model.dart'; import 'package:eatvio_app/features/registration/services/registration_service.dart'; +import 'package:eatvio_app/core/env_config.dart'; class RegistrationRepository { final RegistrationService registrationService; @@ -14,13 +13,55 @@ class RegistrationRepository { Future tryRegister(UserRegistrationSheet sheet) async { try { final response = await registrationService.register(sheet: sheet.toJson()); - try { - return (response['token']!); - } catch (e) { - showErrorToast(response.toString()); - rethrow; + if (response.containsKey('token')) { + return response['token']!; + } else { + // Если нет токена, но есть message или errors + String errorMsg = ''; + if (response.containsKey('message')) { + errorMsg += '${response['message']!}\n'; + } + if (response.containsKey('errors')) { + final errors = response['errors']; + if (errors != null && errors is Map) { + (errors as Map).forEach((key, value) { + if (value is List && value.isNotEmpty) { + errorMsg += '${value.join('\n')}\n'; + } + }); + } + } + if (errorMsg.isNotEmpty) { + throw errorMsg.trim(); + } else { + throw 'Ошибка регистрации: $response'; + } + } + } on DioException catch (e) { + if (e.response != null && e.response?.statusCode == 422) { + final data = e.response?.data; + String errorMsg = ''; + if (data is Map) { + if (data.containsKey('message')) { + errorMsg += data['message'] + '\n'; + } + if (data.containsKey('errors')) { + final errors = data['errors']; + if (errors != null && errors is Map) { + (errors).forEach((key, value) { + if (value is List && value.isNotEmpty) { + errorMsg += '${value.join('\n')}\n'; + } + }); + } + } + } + if (errorMsg.isNotEmpty) { + throw errorMsg.trim(); + } else { + throw 'Ошибка регистрации: $data'; + } } - } catch (e) { rethrow; } } @@ -43,7 +84,7 @@ class RegistrationRepository { Future emailIsReachable(String email) async { try { - final response = await sl().getUri(Uri.parse('https://api.validate.email/validate?email=$email')); + final response = await sl().getUri(Uri.parse('${EnvConfig.emailValidationApiUrl}?email=$email')); if (response.data['result']['reachable'] != 'invalid') { return true; } else { diff --git a/lib/features/registration/services/registration_analytics_service.dart b/lib/features/registration/services/registration_analytics_service.dart new file mode 100644 index 00000000..3bfc69d1 --- /dev/null +++ b/lib/features/registration/services/registration_analytics_service.dart @@ -0,0 +1,30 @@ +import 'package:appmetrica_plugin/appmetrica_plugin.dart'; + +class RegistrationAnalyticsService { + static const String _eventPrefix = 'registration_step'; + + /// Reports registration step completion + static void reportRegistrationStep(int stepNumber) { + AppMetrica.reportEvent('$_eventPrefix $stepNumber'); + } + + /// Reports email verification completion + static void reportEmailVerification() { + AppMetrica.reportEvent('registration_email_verify'); + } + + /// Reports phone verification completion + static void reportPhoneVerification() { + AppMetrica.reportEvent('registration_phone_verify'); + } + + /// Reports registration completion + static void reportRegistrationComplete() { + AppMetrica.reportEvent('registration_complete'); + } + + /// Reports registration error + static void reportRegistrationError(String error) { + AppMetrica.reportEvent('registration_error'); + } +} diff --git a/lib/features/settings/presentation/bloc/settings_cubit/settings_cubit.dart b/lib/features/settings/presentation/bloc/settings_cubit/settings_cubit.dart index d3995d6a..ae498824 100644 --- a/lib/features/settings/presentation/bloc/settings_cubit/settings_cubit.dart +++ b/lib/features/settings/presentation/bloc/settings_cubit/settings_cubit.dart @@ -22,7 +22,11 @@ class SettingsCubit extends Cubit { final profile = await profileRepository.getMyProfile(); late final ConsultationRequestModel? consultantInfo; try { - consultantInfo = await consultantRepository.getMyRequestStatus(profile.slug); + if (profile.phoneConfirmationState.contains('confirmed')) { + consultantInfo = await consultantRepository.getMyRequestStatus(profile.slug); + } else { + consultantInfo = null; + } } catch (e) { consultantInfo = null; } @@ -67,4 +71,13 @@ class SettingsCubit extends Cubit { await init(); } } + + Future deleteAccount() async { + try { + await profileRepository.deleteAccount(); + return true; + } catch (e) { + return false; + } + } } diff --git a/lib/features/settings/presentation/pages/physical_activity_screen.dart b/lib/features/settings/presentation/pages/physical_activity_screen.dart index 3c6b000c..df91a4f6 100644 --- a/lib/features/settings/presentation/pages/physical_activity_screen.dart +++ b/lib/features/settings/presentation/pages/physical_activity_screen.dart @@ -7,10 +7,7 @@ import 'package:go_router/go_router.dart'; class PhysicalActivityScreen extends StatefulWidget { final SettingsEntity settingsEntity; - const PhysicalActivityScreen({ - super.key, - required this.settingsEntity, - }); + const PhysicalActivityScreen({super.key, required this.settingsEntity}); @override State createState() => _PhysicalActivityScreenState(); @@ -46,69 +43,108 @@ class _PhysicalActivityScreenState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar( - title: const Text('Физическая нагрузка'), - ), + appBar: AppBar(title: const Text('Физическая нагрузка')), bottomSheet: _saveButton(), body: SingleChildScrollView( physics: const BouncingScrollPhysics(), padding: const EdgeInsets.only(top: 16, bottom: 80), child: Column( children: [ - RadioListTile( - contentPadding: const EdgeInsets.only(left: 8, right: 16), - title: const Text('1,2 — минимальный (сидячая работа, отсутствие физических нагрузок);'), - value: PhysicalActivity.minimum, - groupValue: _physicalActivity, - onChanged: (PhysicalActivity? value) { - setState(() { - _physicalActivity = value; - }); - }, + Row( + children: [ + Radio( + value: PhysicalActivity.minimum, + // ignore: deprecated_member_use + groupValue: _physicalActivity, + // ignore: deprecated_member_use + onChanged: (PhysicalActivity? value) { + setState(() { + _physicalActivity = value; + }); + }, + ), + const Expanded( + child: Padding( + padding: EdgeInsets.only(left: 8, right: 16), + child: Text('1,2 — минимальный (сидячая работа, отсутствие физических нагрузок);'), + ), + ), + ], ), - RadioListTile( - contentPadding: const EdgeInsets.only(left: 8, right: 16), - title: const Text('1,375 — низкий (тренировки не менее 20 мин 1–3 раза в неделю);'), - value: PhysicalActivity.low, - groupValue: _physicalActivity, - onChanged: (PhysicalActivity? value) { - setState(() { - _physicalActivity = value; - }); - }, + Row( + children: [ + Radio( + value: PhysicalActivity.low, + // ignore: deprecated_member_use + groupValue: _physicalActivity, + // ignore: deprecated_member_use + onChanged: (PhysicalActivity? value) { + setState(() { + _physicalActivity = value; + }); + }, + ), + const Expanded( + child: Padding(padding: EdgeInsets.only(left: 8, right: 16), child: Text('1,375 — низкий (тренировки не менее 20 мин 1–3 раза в неделю);')), + ), + ], ), - RadioListTile( - contentPadding: const EdgeInsets.only(left: 8, right: 16), - title: const Text('1,55 — умеренный (тренировки 30–60 мин 3–4 раза в неделю);'), - value: PhysicalActivity.medium, - groupValue: _physicalActivity, - onChanged: (PhysicalActivity? value) { - setState(() { - _physicalActivity = value; - }); - }, + Row( + children: [ + Radio( + value: PhysicalActivity.medium, + // ignore: deprecated_member_use + groupValue: _physicalActivity, + // ignore: deprecated_member_use + onChanged: (PhysicalActivity? value) { + setState(() { + _physicalActivity = value; + }); + }, + ), + const Expanded( + child: Padding(padding: EdgeInsets.only(left: 8, right: 16), child: Text('1,55 — умеренный (тренировки 30–60 мин 3–4 раза в неделю);')), + ), + ], ), - RadioListTile( - contentPadding: const EdgeInsets.only(left: 8, right: 16), - title: const Text('1,7 — высокий (тренировки 30–60 мин 5–7 раза в неделю; тяжёлая физическая работа);'), - value: PhysicalActivity.high, - groupValue: _physicalActivity, - onChanged: (PhysicalActivity? value) { - setState(() { - _physicalActivity = value; - }); - }, + Row( + children: [ + Radio( + value: PhysicalActivity.high, + // ignore: deprecated_member_use + groupValue: _physicalActivity, + // ignore: deprecated_member_use + onChanged: (PhysicalActivity? value) { + setState(() { + _physicalActivity = value; + }); + }, + ), + const Expanded( + child: Padding( + padding: EdgeInsets.only(left: 8, right: 16), + child: Text('1,7 — высокий (тренировки 30–60 мин 5–7 раза в неделю; тяжёлая физическая работа);'), + ), + ), + ], ), - RadioListTile( - contentPadding: const EdgeInsets.only(left: 8, right: 16), - title: const Text('1,9 — экстремальный (несколько интенсивных тренировок в день'), - value: PhysicalActivity.extreme, - groupValue: _physicalActivity, - onChanged: (PhysicalActivity? value) { - setState(() { - _physicalActivity = value; - }); - }, + Row( + children: [ + Radio( + value: PhysicalActivity.extreme, + // ignore: deprecated_member_use + groupValue: _physicalActivity, + // ignore: deprecated_member_use + onChanged: (PhysicalActivity? value) { + setState(() { + _physicalActivity = value; + }); + }, + ), + const Expanded( + child: Padding(padding: EdgeInsets.only(left: 8, right: 16), child: Text('1,9 — экстремальный (несколько интенсивных тренировок в день')), + ), + ], ), ], ), @@ -141,8 +177,8 @@ class _PhysicalActivityScreenState extends State { } widget.settingsEntity.settingsCubit.updateProfileDetails( profileDetailsModel: (widget.settingsEntity.settingsCubit.state as SettingsStateLoaded).profileDetails.copyWith( - activityCoefficient: activityCoefficient, - ), + activityCoefficient: activityCoefficient, + ), ); GoRouter.of(context).pop(); }, @@ -150,19 +186,11 @@ class _PhysicalActivityScreenState extends State { child: Ink( height: 52, width: MediaQuery.of(context).size.width - 32, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - color: appTheme.blueA400, - ), + decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400), child: const Center( child: Text( 'Сохранить', - style: TextStyle( - color: Colors.white, - height: 1, - fontWeight: FontWeight.w600, - fontSize: 18, - ), + style: TextStyle(color: Colors.white, height: 1, fontWeight: FontWeight.w600, fontSize: 18), ), ), ), diff --git a/lib/features/settings/presentation/pages/settings_screen.dart b/lib/features/settings/presentation/pages/settings_screen.dart index 67460955..1c304f29 100644 --- a/lib/features/settings/presentation/pages/settings_screen.dart +++ b/lib/features/settings/presentation/pages/settings_screen.dart @@ -1,17 +1,22 @@ -import 'package:eatvio_app/core/const.dart'; +import 'package:eatvio_app/core/app_router.dart'; +import 'package:eatvio_app/core/theme/custom_text_style.dart'; +import 'package:eatvio_app/core/widgets/bottom_sheet_handler_bar.dart'; +import 'package:eatvio_app/core/widgets/bottom_sheet_with_blur.dart'; +import 'package:eatvio_app/core/widgets/custom_outlined_button.dart'; import 'package:eatvio_app/core/widgets/data_error_widget.dart'; import 'package:eatvio_app/core/widgets/loader.dart'; +import 'package:eatvio_app/core/widgets/show_error_dialog.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart'; import 'package:eatvio_app/features/settings/entities/settings_entity.dart'; import 'package:eatvio_app/features/settings/presentation/bloc/settings_cubit/settings_cubit.dart'; import 'package:eatvio_app/features/settings/presentation/bloc/settings_cubit/settings_state.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'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; -import 'package:url_launcher/url_launcher.dart'; class SettingsScreen extends StatefulWidget { const SettingsScreen({super.key}); @@ -44,9 +49,7 @@ class _SettingsScreenState extends State { builder: (BuildContext context, SettingsState state) { if (state is SettingsStateLoaded) { return Scaffold( - appBar: AppBar( - title: const Text('Настройки'), - ), + appBar: AppBar(title: const Text('Настройки')), body: SafeArea( child: SingleChildScrollView( padding: const EdgeInsets.symmetric(vertical: 16), @@ -55,12 +58,10 @@ class _SettingsScreenState extends State { children: [ _additionalInfo(state), const SizedBox(height: 12), - _notifications(state), - const SizedBox(height: 12), - _aboutEatvio(state), - const SizedBox(height: 12), + // _notifications(state), + // const SizedBox(height: 12), _becomeConsultantButton(state), - _deleteAccountButton(state), + _deleteAccountButton(state, settingsCubit), ], ), ), @@ -69,16 +70,12 @@ class _SettingsScreenState extends State { } if (state is SettingsStateError) { return Scaffold( - appBar: AppBar( - title: const Text('Настройки'), - ), + appBar: AppBar(title: const Text('Настройки')), body: DataErrorWidget(onReload: () => settingsCubit.init()), ); } return Scaffold( - appBar: AppBar( - title: const Text('Настройки'), - ), + appBar: AppBar(title: const Text('Настройки')), body: const Loader(), ); }, @@ -94,22 +91,13 @@ class _SettingsScreenState extends State { padding: EdgeInsets.symmetric(horizontal: 16), child: Text( 'Дополнительаная информация', - style: TextStyle( - fontSize: 16, - height: 1, - color: Colors.grey, - fontFamily: 'Mulish', - ), + style: TextStyle(fontSize: 16, height: 1, color: Colors.grey, fontFamily: 'Mulish'), ), ), _tile( onTap: () => GoRouter.of(context).pushNamed( 'generalInfoScreen', - extra: SettingsEntity( - profileSettingsModel: state.profileSettings, - profileDetailsModel: state.profileDetails, - settingsCubit: settingsCubit, - ), + extra: SettingsEntity(profileSettingsModel: state.profileSettings, profileDetailsModel: state.profileDetails, settingsCubit: settingsCubit), ), title: 'Общая информация', ), @@ -117,11 +105,7 @@ class _SettingsScreenState extends State { _tile( onTap: () => GoRouter.of(context).pushNamed( 'physicalFormScreen', - extra: SettingsEntity( - profileSettingsModel: state.profileSettings, - profileDetailsModel: state.profileDetails, - settingsCubit: settingsCubit, - ), + extra: SettingsEntity(profileSettingsModel: state.profileSettings, profileDetailsModel: state.profileDetails, settingsCubit: settingsCubit), ), title: 'Физическая форма', ), @@ -129,11 +113,7 @@ class _SettingsScreenState extends State { _tile( onTap: () => GoRouter.of(context).pushNamed( 'pressureScreen', - extra: SettingsEntity( - profileSettingsModel: state.profileSettings, - profileDetailsModel: state.profileDetails, - settingsCubit: settingsCubit, - ), + extra: SettingsEntity(profileSettingsModel: state.profileSettings, profileDetailsModel: state.profileDetails, settingsCubit: settingsCubit), ), title: 'Давление', ), @@ -141,11 +121,7 @@ class _SettingsScreenState extends State { _tile( onTap: () => GoRouter.of(context).pushNamed( 'otherInfoScreen', - extra: SettingsEntity( - profileSettingsModel: state.profileSettings, - profileDetailsModel: state.profileDetails, - settingsCubit: settingsCubit, - ), + extra: SettingsEntity(profileSettingsModel: state.profileSettings, profileDetailsModel: state.profileDetails, settingsCubit: settingsCubit), ), title: 'Прочая информация', ), @@ -153,31 +129,17 @@ class _SettingsScreenState extends State { _tile( onTap: () => GoRouter.of(context).pushNamed( 'physicalActivityScreen', - extra: SettingsEntity( - profileSettingsModel: state.profileSettings, - profileDetailsModel: state.profileDetails, - settingsCubit: settingsCubit, - ), + extra: SettingsEntity(profileSettingsModel: state.profileSettings, profileDetailsModel: state.profileDetails, settingsCubit: settingsCubit), ), title: 'Физическая нагрузка', ), _divider(context), - _tile( - onTap: () => GoRouter.of(context).pushNamed( - 'forgetPasswordScreen', - extra: true, - ), - title: 'Сменить пароль', - ), + _tile(onTap: () => GoRouter.of(context).pushNamed('forgetPasswordScreen', extra: true), title: 'Сменить пароль'), _divider(context), _tile( onTap: () => GoRouter.of(context).pushNamed( 'changeEmailScreen', - extra: SettingsEntity( - profileSettingsModel: state.profileSettings, - profileDetailsModel: state.profileDetails, - settingsCubit: settingsCubit, - ), + extra: SettingsEntity(profileSettingsModel: state.profileSettings, profileDetailsModel: state.profileDetails, settingsCubit: settingsCubit), ), title: 'Сменить Email', ), @@ -185,20 +147,14 @@ class _SettingsScreenState extends State { ); } + // ignore: unused_element Column _notifications(SettingsStateLoaded state) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Padding( padding: EdgeInsets.symmetric(horizontal: 16), - child: Text( - 'Уведомления', - style: TextStyle( - fontSize: 16, - height: 1, - color: Colors.grey, - ), - ), + child: Text('Уведомления', style: TextStyle(fontSize: 16, height: 1, color: Colors.grey)), ), _tile( padding: const EdgeInsets.symmetric(horizontal: 16), @@ -206,11 +162,7 @@ class _SettingsScreenState extends State { setState(() { updatesNotificationsSwitchState = !updatesNotificationsSwitchState; }); - settingsCubit.updateProfileSettings( - profileSettingsModel: state.profileSettings.copyWith( - newUpdatesNotifications: updatesNotificationsSwitchState, - ), - ); + settingsCubit.updateProfileSettings(profileSettingsModel: state.profileSettings.copyWith(newUpdatesNotifications: updatesNotificationsSwitchState)); }, title: 'Уведомление о новых обновлениях', trailing: CupertinoSwitch( @@ -221,9 +173,7 @@ class _SettingsScreenState extends State { updatesNotificationsSwitchState = newValue; }); settingsCubit.updateProfileSettings( - profileSettingsModel: state.profileSettings.copyWith( - newUpdatesNotifications: updatesNotificationsSwitchState, - ), + profileSettingsModel: state.profileSettings.copyWith(newUpdatesNotifications: updatesNotificationsSwitchState), ); }, ), @@ -236,9 +186,7 @@ class _SettingsScreenState extends State { fillDiaryNotificationsSwitchState = !fillDiaryNotificationsSwitchState; }); settingsCubit.updateProfileSettings( - profileSettingsModel: state.profileSettings.copyWith( - diaryReminderNotifications: fillDiaryNotificationsSwitchState, - ), + profileSettingsModel: state.profileSettings.copyWith(diaryReminderNotifications: fillDiaryNotificationsSwitchState), ); }, title: 'Напоминание о заполнении дневника', @@ -250,9 +198,7 @@ class _SettingsScreenState extends State { fillDiaryNotificationsSwitchState = newValue; }); settingsCubit.updateProfileSettings( - profileSettingsModel: state.profileSettings.copyWith( - diaryReminderNotifications: fillDiaryNotificationsSwitchState, - ), + profileSettingsModel: state.profileSettings.copyWith(diaryReminderNotifications: fillDiaryNotificationsSwitchState), ); }, ), @@ -264,11 +210,7 @@ class _SettingsScreenState extends State { setState(() { moodSwitchState = !moodSwitchState; }); - settingsCubit.updateProfileSettings( - profileSettingsModel: state.profileSettings.copyWith( - isMoodFillable: moodSwitchState, - ), - ); + settingsCubit.updateProfileSettings(profileSettingsModel: state.profileSettings.copyWith(isMoodFillable: moodSwitchState)); }, title: 'Указывать “настроение” в заполнение дневника', trailing: CupertinoSwitch( @@ -278,11 +220,7 @@ class _SettingsScreenState extends State { setState(() { moodSwitchState = newValue; }); - settingsCubit.updateProfileSettings( - profileSettingsModel: state.profileSettings.copyWith( - isMoodFillable: moodSwitchState, - ), - ); + settingsCubit.updateProfileSettings(profileSettingsModel: state.profileSettings.copyWith(isMoodFillable: moodSwitchState)); }, ), ), @@ -293,11 +231,7 @@ class _SettingsScreenState extends State { setState(() { bedTimeSwitchState = !bedTimeSwitchState; }); - settingsCubit.updateProfileSettings( - profileSettingsModel: state.profileSettings.copyWith( - isBedtimeFillable: bedTimeSwitchState, - ), - ); + settingsCubit.updateProfileSettings(profileSettingsModel: state.profileSettings.copyWith(isBedtimeFillable: bedTimeSwitchState)); }, title: 'Указывать “отход ко сну” в заполнение дневника', trailing: CupertinoSwitch( @@ -307,11 +241,7 @@ class _SettingsScreenState extends State { setState(() { bedTimeSwitchState = newValue; }); - settingsCubit.updateProfileSettings( - profileSettingsModel: state.profileSettings.copyWith( - isBedtimeFillable: bedTimeSwitchState, - ), - ); + settingsCubit.updateProfileSettings(profileSettingsModel: state.profileSettings.copyWith(isBedtimeFillable: bedTimeSwitchState)); }, ), ), @@ -319,34 +249,6 @@ class _SettingsScreenState extends State { ); } - Column _aboutEatvio(SettingsStateLoaded state) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Padding( - padding: EdgeInsets.symmetric(horizontal: 16), - child: Text( - 'О Eatvio', - style: TextStyle( - fontSize: 16, - height: 1, - color: Colors.grey, - ), - ), - ), - _tile( - title: 'О компании', - onTap: () async => await launchUrl(Uri.parse('$baseUrl/ru/company-details')), - ), - _divider(context), - _tile( - title: 'Пользовательское соглашение', - onTap: () async => await launchUrl(Uri.parse('$baseUrl/oferta_bs.pdf')), - ), - ], - ); - } - SingleChildRenderObjectWidget _becomeConsultantButton(SettingsStateLoaded state) { if (state.consultationRequestModel == null) { return Padding( @@ -357,19 +259,9 @@ class _SettingsScreenState extends State { child: Ink( height: 52, width: MediaQuery.of(context).size.width - 16, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - color: appTheme.blueA400, - ), + decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400), child: const Center( - child: Text( - 'Стать консультантом', - style: TextStyle( - color: Colors.white, - height: 1, - fontSize: 18, - ), - ), + child: Text('Стать консультантом', style: TextStyle(color: Colors.white, height: 1, fontSize: 18)), ), ), ), @@ -381,19 +273,9 @@ class _SettingsScreenState extends State { child: Ink( height: 52, width: MediaQuery.of(context).size.width - 16, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - color: const Color(0xFFEAEAEA), - ), + decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: const Color(0xFFEAEAEA)), child: const Center( - child: Text( - 'Данные на проверке', - style: TextStyle( - color: Colors.black, - height: 1, - fontSize: 18, - ), - ), + child: Text('Данные на проверке', style: TextStyle(color: Colors.black, height: 1, fontSize: 18)), ), ), ); @@ -406,19 +288,9 @@ class _SettingsScreenState extends State { child: Ink( height: 52, width: MediaQuery.of(context).size.width - 16, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - color: const Color(0x33FFCC00), - ), + decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: const Color(0x33FFCC00)), child: const Center( - child: Text( - 'Заявка обработана', - style: TextStyle( - color: Colors.black, - height: 1, - fontSize: 18, - ), - ), + child: Text('Заявка обработана', style: TextStyle(color: Colors.black, height: 1, fontSize: 18)), ), ), ), @@ -428,57 +300,37 @@ class _SettingsScreenState extends State { return const SizedBox.shrink(); } - Padding _deleteAccountButton(SettingsStateLoaded state) { + Padding _deleteAccountButton(SettingsStateLoaded state, SettingsCubit settingsCubit) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: InkWell( - onTap: () {}, + onTap: () async { + showBottomSheetWithBlur( + userRootNavigator: true, + context: context, + height: null, + child: DeleteAccountBottomSheet(cubit: settingsCubit), + ); + }, borderRadius: BorderRadius.circular(20), child: Ink( height: 52, width: MediaQuery.of(context).size.width - 16, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - color: const Color(0xFFEAEAEA), - ), + decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: const Color(0xFFEAEAEA)), child: const Center( - child: Text( - 'Удалить аккаунт', - style: TextStyle( - color: Colors.black, - height: 1, - fontSize: 18, - ), - ), + child: Text('Удалить аккаунт', style: TextStyle(color: Colors.black, height: 1, fontSize: 18)), ), ), ), ); } - ListTile _tile({ - required String title, - required Function() onTap, - Widget? trailing, - EdgeInsets? padding, - }) { + ListTile _tile({required String title, required Function() onTap, Widget? trailing, EdgeInsets? padding}) { return ListTile( contentPadding: padding, onTap: onTap, - title: Text( - title, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - height: 1, - ), - ), - trailing: trailing ?? - const Icon( - Icons.arrow_forward_ios_rounded, - size: 12, - color: Colors.black38, - ), + title: Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500, height: 1)), + trailing: trailing ?? const Icon(Icons.arrow_forward_ios_rounded, size: 12, color: Colors.black38), ); } @@ -487,10 +339,65 @@ class _SettingsScreenState extends State { child: Container( height: 0.4, width: MediaQuery.of(context).size.width - 32, - decoration: BoxDecoration( - color: Colors.black26, - borderRadius: BorderRadius.circular(16), - ), + decoration: BoxDecoration(color: Colors.black26, borderRadius: BorderRadius.circular(16)), + ), + ); + } +} + +class DeleteAccountBottomSheet extends StatefulWidget { + const DeleteAccountBottomSheet({super.key, required this.cubit}); + final SettingsCubit cubit; + + @override + State createState() => _DeleteAccountBottomSheet(); +} + +class _DeleteAccountBottomSheet extends State { + bool? isFailure; + @override + Widget build(BuildContext context) { + final s = S.of(context); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + const BottomSheetHandlerBar(), + const SizedBox(height: 20), + Text(s.accountDeleting, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)), + const SizedBox(height: 16), + Text( + s.youShureYouWantToDeleteAccount, + style: TextStyles.textSMedium.copyWith(color: appTheme.black), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + s.itCantBeRecovered, + style: TextStyles.textSMedium.copyWith(color: appTheme.black), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + CustomOutlinedButton( + text: s.delete, + height: 52, + buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700), + buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)), + onPressed: () async { + final result = await widget.cubit.deleteAccount(); + if (result == true) { + AppRouter.navigatorKey.currentContext?.go('/auth-screen'); + return; + } + AppRouter.navigatorKey.currentContext?.pop(); + if (!context.mounted) return; + showErrorDialog(context, description: s.cannotDeleteAccount); + }, + ), + const SizedBox(height: 40), + ], ), ); } diff --git a/lib/features/support/presentation/pages/support_screen.dart b/lib/features/support/presentation/pages/support_screen.dart index b550040f..6aa44a42 100644 --- a/lib/features/support/presentation/pages/support_screen.dart +++ b/lib/features/support/presentation/pages/support_screen.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:url_launcher/url_launcher.dart'; +import 'package:eatvio_app/core/env_config.dart'; class SupportScreen extends StatelessWidget { const SupportScreen({super.key}); @@ -15,48 +16,28 @@ class SupportScreen extends StatelessWidget { final s = S.of(context); return Scaffold( - appBar: AppBar( - title: const Text('Поддержка'), - ), + appBar: AppBar(title: const Text('Поддержка')), body: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Padding( padding: EdgeInsets.all(16), - child: Text( - 'Если у вас есть вопросы по работе нашего приложения, вы можете задать их, перейдя по ссылке:', - style: TextStyle( - fontSize: 16, - ), - ), + child: Text('Если у вас есть вопросы по работе нашего приложения, вы можете задать их, перейдя по ссылке:', style: TextStyle(fontSize: 16)), ), InkWell( onTap: () { - launchUrl( - Uri.parse('https://t.me/eatvio'), - mode: LaunchMode.externalApplication, - ); + launchUrl(Uri.parse(EnvConfig.telegramSupportUrl), mode: LaunchMode.externalApplication); }, borderRadius: BorderRadius.circular(16), child: Ink( width: double.infinity, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - ), + decoration: BoxDecoration(borderRadius: BorderRadius.circular(16)), padding: const EdgeInsets.all(16), child: Row( children: [ - SvgPicture.asset( - 'assets/icons/telegram.svg', - colorFilter: const ColorFilter.mode(Colors.black, BlendMode.srcIn), - ), + SvgPicture.asset('assets/icons/telegram.svg', colorFilter: const ColorFilter.mode(Colors.black, BlendMode.srcIn)), const SizedBox(width: 12), - const Text( - 'Telegram: t.me/eatvio', - style: TextStyle( - fontSize: 16, - ), - ), + Text('Telegram: ${EnvConfig.telegramSupportUrl.replaceFirst('https://', '')}', style: const TextStyle(fontSize: 16)), ], ), ), @@ -64,26 +45,19 @@ class SupportScreen extends StatelessWidget { const SizedBox(height: 8), InkWell( onTap: () { - Clipboard.setData(const ClipboardData(text: 'help@eatv.io')); + Clipboard.setData(ClipboardData(text: EnvConfig.supportEmail)); showInfoToast(s.mail_was_copied); }, borderRadius: BorderRadius.circular(16), child: Ink( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - ), + decoration: BoxDecoration(borderRadius: BorderRadius.circular(16)), width: double.infinity, padding: const EdgeInsets.all(16), - child: const Row( + child: Row( children: [ - Icon(Icons.alternate_email_rounded), - SizedBox(width: 12), - Text( - 'E-mail: help@eatv.io', - style: TextStyle( - fontSize: 16, - ), - ), + const Icon(Icons.alternate_email_rounded), + const SizedBox(width: 12), + Text('E-mail: ${EnvConfig.supportEmail}', style: const TextStyle(fontSize: 16)), ], ), ), @@ -92,40 +66,26 @@ class SupportScreen extends StatelessWidget { const SizedBox(height: 16), const Padding( padding: EdgeInsets.symmetric(horizontal: 16), - child: Text( - 'Правовые документы', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.w500, - ), - ), + child: Text('Правовые документы', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w500)), ), const SizedBox(height: 12), - InkWell( - onTap: () async => await launchUrl(Uri.parse('$baseUrl/docs/public_offer.pdf')), - borderRadius: BorderRadius.circular(16), - child: const Padding( - padding: EdgeInsets.symmetric(horizontal: 16, vertical: 4), - child: Text( - 'Публичная офёрта', - style: TextStyle( - fontSize: 16, - ), + if (!hidePlans) ...[ + InkWell( + onTap: () async => await launchUrl(Uri.parse('$baseUrl/docs/public_offer.pdf')), + borderRadius: BorderRadius.circular(16), + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Text('Публичная офёрта', style: TextStyle(fontSize: 16)), ), ), - ), - const SizedBox(height: 8), + const SizedBox(height: 8), + ], InkWell( onTap: () async => await launchUrl(Uri.parse('$baseUrl/docs/privacy.pdf')), borderRadius: BorderRadius.circular(16), child: const Padding( padding: EdgeInsets.symmetric(horizontal: 16, vertical: 4), - child: Text( - 'Политика конфиденциальности', - style: TextStyle( - fontSize: 16, - ), - ), + child: Text('Политика конфиденциальности', style: TextStyle(fontSize: 16)), ), ), const SizedBox(height: 8), @@ -134,12 +94,7 @@ class SupportScreen extends StatelessWidget { borderRadius: BorderRadius.circular(16), child: const Padding( padding: EdgeInsets.symmetric(horizontal: 16, vertical: 4), - child: Text( - 'О компании', - style: TextStyle( - fontSize: 16, - ), - ), + child: Text('О компании', style: TextStyle(fontSize: 16)), ), ), ], diff --git a/lib/features/update_screen/presentation/update_screen.dart b/lib/features/update_screen/presentation/update_screen.dart index 186987c2..68ec9640 100644 --- a/lib/features/update_screen/presentation/update_screen.dart +++ b/lib/features/update_screen/presentation/update_screen.dart @@ -6,6 +6,7 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../../../core/theme/theme_helper.dart'; +import '../../../../core/env_config.dart'; class UpdateScreen extends StatelessWidget { const UpdateScreen({super.key}); @@ -17,16 +18,7 @@ class UpdateScreen extends StatelessWidget { body: Stack( clipBehavior: Clip.none, children: [ - Positioned( - left: -20, - top: -20, - right: -20, - bottom: -20, - child: Image.asset( - 'assets/images/bg_welcome.png', - fit: BoxFit.cover, - ), - ), + Positioned(left: -20, top: -20, right: -20, bottom: -20, child: Image.asset('assets/images/bg_welcome.png', fit: BoxFit.cover)), Positioned.fill( child: SafeArea( child: Padding( @@ -48,47 +40,26 @@ class UpdateScreen extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 16), - const Text( - 'Пора обновиться! ⚡', - style: TextStyle( - fontSize: 28, - fontWeight: FontWeight.w800, - height: 1, - ), - ), + const Text('Пора обновиться! ⚡', style: TextStyle(fontSize: 28, fontWeight: FontWeight.w800, height: 1)), const SizedBox(height: 20), const Text( 'Мы выпустили важное обновление\nс новыми функциями и исправлениями.\nЧтобы продолжить пользоваться\nприложением без сбоев, обновите\nего прямо сейчас.', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - height: 1.15, - ), + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500, height: 1.15), ), const SizedBox(height: 32), InkWell( onTap: () async { - final Uri url = Platform.isAndroid - ? Uri.parse('https://www.rustore.ru/catalog/app/com.eatvioapp.app') - : Uri.parse('https://apps.apple.com/app/idYOUR_APP_ID'); + final Uri url = Platform.isAndroid ? Uri.parse(EnvConfig.rustoreUrl) : Uri.parse(EnvConfig.appStoreUrl); await launchUrl(url); }, borderRadius: BorderRadius.circular(8), child: Ink( height: 52, - decoration: BoxDecoration( - color: appTheme.blueA400, - borderRadius: BorderRadius.circular(8), - ), + decoration: BoxDecoration(color: appTheme.blueA400, borderRadius: BorderRadius.circular(8)), child: const Center( child: Text( 'Обновить', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - height: 1, - color: Colors.white, - ), + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, height: 1, color: Colors.white), ), ), ), diff --git a/lib/features/user_details/presentation/cubit/user_details_cubit.dart b/lib/features/user_details/presentation/cubit/user_details_cubit.dart index c6eb36b7..0ce0bd93 100644 --- a/lib/features/user_details/presentation/cubit/user_details_cubit.dart +++ b/lib/features/user_details/presentation/cubit/user_details_cubit.dart @@ -20,19 +20,11 @@ class UserDetailsCubit extends Cubit { } } - void getChartData({ - String period = '1', - required String slug, - }) async { + void getChartData({String period = '1', required String slug}) async { try { - final weightPoints = await profileRepository.getWeight(period: period); - final circumferencePoints = await profileRepository.getCircumferences(period: period); - emit( - state.copyWith( - weightPoints: weightPoints, - circumferencePoints: circumferencePoints, - ), - ); + 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) { log(e.toString(), stackTrace: stackTrace); emit(state.copyWith(error: e.toString(), isLoading: false)); diff --git a/lib/features/user_details/presentation/user_details_screen.dart b/lib/features/user_details/presentation/user_details_screen.dart index f79b990e..4907e3dd 100644 --- a/lib/features/user_details/presentation/user_details_screen.dart +++ b/lib/features/user_details/presentation/user_details_screen.dart @@ -31,9 +31,7 @@ class UserDetailsScreen extends StatelessWidget { Widget build(BuildContext context) { return BlocProvider( create: (context) => sl()..getUserDetails(slug), - child: UserDetailsContent( - slug: slug, - ), + child: UserDetailsContent(slug: slug), ); } } @@ -79,27 +77,15 @@ class _UserDetailsContentState extends State { actions: [ PortalTarget( visible: isMoreOptionsVisible, - portalFollower: MoreOptions( - path: 'profile/${widget.slug}', - complainedContentType: ComplainedContent.profile, - slug: widget.slug, - ), - anchor: const Aligned( - shiftToWithinBound: AxisFlag(x: true, y: true), - follower: Alignment.topRight, - target: Alignment.bottomRight, - ), + portalFollower: MoreOptions(path: 'profile/${widget.slug}', complainedContentType: ComplainedContent.profile, slug: widget.slug), + anchor: const Aligned(shiftToWithinBound: AxisFlag(x: true, y: true), follower: Alignment.topRight, target: Alignment.bottomRight), child: IconButton( onPressed: () { setState(() { isMoreOptionsVisible = !isMoreOptionsVisible; }); }, - icon: SvgPicture.asset( - 'assets/icons/more_button.svg', - width: 20, - height: 20, - ), + icon: SvgPicture.asset('assets/icons/more_button.svg', width: 20, height: 20), ), ), ], @@ -124,25 +110,16 @@ class _UserDetailsContentState extends State { initiallyExpanded: true, title: s.general_info, children: [ - Text( - s.date_of_birth, - style: TextStyles.textSMedium.copyWith(color: appTheme.black), - ), + Text(s.date_of_birth, style: TextStyles.textSMedium.copyWith(color: appTheme.black)), const SizedBox(height: 4), CustomTextFormField( readOnly: true, controller: TextEditingController(text: DateTime.parse(profileDetails.birthdate).format('dd.MM.yyyy')), ), const SizedBox(height: 16), - Text( - s.sex, - style: TextStyles.textSMedium.copyWith(color: appTheme.black), - ), + Text(s.sex, style: TextStyles.textSMedium.copyWith(color: appTheme.black)), const SizedBox(height: 4), - CustomTextFormField( - readOnly: true, - controller: TextEditingController(text: profileDetails.gender.parseGender()), - ), + CustomTextFormField(readOnly: true, controller: TextEditingController(text: profileDetails.gender.parseGender())), const SizedBox(height: 16), ], ), @@ -155,15 +132,9 @@ class _UserDetailsContentState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - s.height, - style: TextStyles.textSMedium.copyWith(color: appTheme.black), - ), + Text(s.height, style: TextStyles.textSMedium.copyWith(color: appTheme.black)), const SizedBox(height: 4), - CustomTextFormField( - readOnly: true, - controller: TextEditingController(text: profileDetails.height.toString()), - ), + CustomTextFormField(readOnly: true, controller: TextEditingController(text: profileDetails.height.toString())), ], ), ), @@ -172,15 +143,9 @@ class _UserDetailsContentState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - s.weight, - style: TextStyles.textSMedium.copyWith(color: appTheme.black), - ), + Text(s.weight, style: TextStyles.textSMedium.copyWith(color: appTheme.black)), const SizedBox(height: 4), - CustomTextFormField( - readOnly: true, - controller: TextEditingController(text: profileDetails.weight.toStringAsFixed(0)), - ), + CustomTextFormField(readOnly: true, controller: TextEditingController(text: profileDetails.weight.toStringAsFixed(0))), ], ), ), @@ -193,15 +158,9 @@ class _UserDetailsContentState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - s.chest_circumference, - style: TextStyles.textSMedium.copyWith(color: appTheme.black), - ), + Text(s.chest_circumference, style: TextStyles.textSMedium.copyWith(color: appTheme.black)), const SizedBox(height: 4), - CustomTextFormField( - readOnly: true, - controller: TextEditingController(text: profileDetails.chestCircumference.toString()), - ), + CustomTextFormField(readOnly: true, controller: TextEditingController(text: profileDetails.chestCircumference.toString())), ], ), ), @@ -210,15 +169,9 @@ class _UserDetailsContentState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - s.waist_circumference, - style: TextStyles.textSMedium.copyWith(color: appTheme.black), - ), + Text(s.waist_circumference, style: TextStyles.textSMedium.copyWith(color: appTheme.black)), const SizedBox(height: 4), - CustomTextFormField( - readOnly: true, - controller: TextEditingController(text: profileDetails.waistCircumference.toString()), - ), + CustomTextFormField(readOnly: true, controller: TextEditingController(text: profileDetails.waistCircumference.toString())), ], ), ), @@ -227,15 +180,9 @@ class _UserDetailsContentState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - s.hip_circumference, - style: TextStyles.textSMedium.copyWith(color: appTheme.black), - ), + Text(s.hip_circumference, style: TextStyles.textSMedium.copyWith(color: appTheme.black)), const SizedBox(height: 4), - CustomTextFormField( - readOnly: true, - controller: TextEditingController(text: profileDetails.hipCircumference.toString()), - ), + CustomTextFormField(readOnly: true, controller: TextEditingController(text: profileDetails.hipCircumference.toString())), ], ), ), @@ -253,15 +200,9 @@ class _UserDetailsContentState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - s.upper, - style: TextStyles.textSMedium.copyWith(color: appTheme.black), - ), + Text(s.upper, style: TextStyles.textSMedium.copyWith(color: appTheme.black)), const SizedBox(height: 4), - CustomTextFormField( - readOnly: true, - controller: TextEditingController(text: profileDetails.bloodPressureUp.toString()), - ), + CustomTextFormField(readOnly: true, controller: TextEditingController(text: profileDetails.bloodPressureUp.toString())), ], ), ), @@ -270,10 +211,7 @@ class _UserDetailsContentState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - s.lower, - style: TextStyles.textSMedium.copyWith(color: appTheme.black), - ), + Text(s.lower, style: TextStyles.textSMedium.copyWith(color: appTheme.black)), const SizedBox(height: 4), CustomTextFormField( readOnly: true, @@ -291,15 +229,9 @@ class _UserDetailsContentState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - s.has_hypo, - style: TextStyles.textSMedium.copyWith(color: appTheme.black), - ), + Text(s.has_hypo, style: TextStyles.textSMedium.copyWith(color: appTheme.black)), const SizedBox(height: 4), - CustomTextFormField( - readOnly: true, - controller: TextEditingController(text: profileDetails.hasHypo.toString()), - ), + CustomTextFormField(readOnly: true, controller: TextEditingController(text: profileDetails.hasHypo.toString())), ], ), ), @@ -308,15 +240,9 @@ class _UserDetailsContentState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - s.has_hyper, - style: TextStyles.textSMedium.copyWith(color: appTheme.black), - ), + Text(s.has_hyper, style: TextStyles.textSMedium.copyWith(color: appTheme.black)), const SizedBox(height: 4), - CustomTextFormField( - readOnly: true, - controller: TextEditingController(text: profileDetails.hasHyper.toString()), - ), + CustomTextFormField(readOnly: true, controller: TextEditingController(text: profileDetails.hasHyper.toString())), ], ), ), @@ -328,60 +254,27 @@ class _UserDetailsContentState extends State { ExpandableBouble( title: s.other_info, children: [ - Text( - s.has_alergy, - style: TextStyles.textSMedium.copyWith(color: appTheme.black), - ), + Text(s.has_alergy, style: TextStyles.textSMedium.copyWith(color: appTheme.black)), const SizedBox(height: 4), - CustomTextFormField( - readOnly: true, - controller: TextEditingController(text: profileDetails.aboutAlergy), - ), + CustomTextFormField(readOnly: true, controller: TextEditingController(text: profileDetails.aboutAlergy)), const SizedBox(height: 16), - Text( - s.takes_pills, - style: TextStyles.textSMedium.copyWith(color: appTheme.black), - ), + Text(s.takes_pills, style: TextStyles.textSMedium.copyWith(color: appTheme.black)), const SizedBox(height: 4), - CustomTextFormField( - readOnly: true, - controller: TextEditingController(text: profileDetails.aboutTakingPils), - ), + CustomTextFormField(readOnly: true, controller: TextEditingController(text: profileDetails.aboutTakingPils)), const SizedBox(height: 16), - Text( - s.sport_hobbies, - style: TextStyles.textSMedium.copyWith(color: appTheme.black), - ), + Text(s.sport_hobbies, style: TextStyles.textSMedium.copyWith(color: appTheme.black)), const SizedBox(height: 4), - CustomTextFormField( - readOnly: true, - controller: TextEditingController(text: profileDetails.aboutSportHobbies), - ), + CustomTextFormField(readOnly: true, controller: TextEditingController(text: profileDetails.aboutSportHobbies)), const SizedBox(height: 16), - Text( - s.liked_groceries, - style: TextStyles.textSMedium.copyWith(color: appTheme.black), - ), + Text(s.liked_groceries, style: TextStyles.textSMedium.copyWith(color: appTheme.black)), const SizedBox(height: 4), - CustomTextFormField( - readOnly: true, - controller: TextEditingController(text: profileDetails.favoriteFoods), - ), + CustomTextFormField(readOnly: true, controller: TextEditingController(text: profileDetails.favoriteFoods)), const SizedBox(height: 16), - Text( - s.not_liked_groceries, - style: TextStyles.textSMedium.copyWith(color: appTheme.black), - ), + Text(s.not_liked_groceries, style: TextStyles.textSMedium.copyWith(color: appTheme.black)), const SizedBox(height: 4), - CustomTextFormField( - readOnly: true, - controller: TextEditingController(text: profileDetails.unfavoriteFoods), - ), + CustomTextFormField(readOnly: true, controller: TextEditingController(text: profileDetails.unfavoriteFoods)), const SizedBox(height: 16), - Text( - s.additional_information, - style: TextStyles.textSMedium.copyWith(color: appTheme.black), - ), + Text(s.additional_information, style: TextStyles.textSMedium.copyWith(color: appTheme.black)), const SizedBox(height: 4), SizedBox( height: 112, @@ -413,10 +306,7 @@ class _UserDetailsContentState extends State { ), ); } - return Scaffold( - appBar: AppBar(), - body: const Loader(), - ); + return Scaffold(appBar: AppBar(), body: const Loader()); }, ); } @@ -432,31 +322,17 @@ class _UserDetailsContentState extends State { 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), - ], - ), + 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, - ); + return WeightChartWidget(weightPoints: weightPoints); case 1: - return CircumferenceChartWidget( - circumferencePoints: circumferencePoints, - ); + return CircumferenceChartWidget(circumferencePoints: circumferencePoints); case 2: - return ImtChartWidget( - weightPoints: weightPoints, - ); + return ImtChartWidget(weightPoints: weightPoints); default: return const SizedBox.shrink(); } @@ -489,16 +365,10 @@ class _UserDetailsContentState extends State { pressElevation: 0, showCheckmark: false, side: BorderSide.none, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), + 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, - ), + labelStyle: TextStyle(color: appTheme.gray600, fontWeight: FontWeight.w500, fontSize: 12), selected: _selectedIntervalChipIndex == index, onSelected: (bool selected) { context.read().getChartData(period: chipsIntervalList[index], slug: widget.slug); @@ -515,16 +385,10 @@ class _UserDetailsContentState extends State { pressElevation: 0, showCheckmark: false, side: BorderSide.none, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), + 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, - ), + labelStyle: TextStyle(color: index == _selectedChipIndex ? Colors.white : appTheme.gray600, fontWeight: FontWeight.w500, fontSize: 12), selected: _selectedChipIndex == index, backgroundColor: appTheme.gray100, onSelected: (bool selected) { diff --git a/lib/firebase_options.dart b/lib/firebase_options.dart index dcf61a5d..6450332e 100644 --- a/lib/firebase_options.dart +++ b/lib/firebase_options.dart @@ -1,8 +1,8 @@ // File generated by FlutterFire CLI. // ignore_for_file: type=lint import 'package:firebase_core/firebase_core.dart' show FirebaseOptions; -import 'package:flutter/foundation.dart' - show defaultTargetPlatform, kIsWeb, TargetPlatform; +import 'package:flutter/foundation.dart' show defaultTargetPlatform, kIsWeb, TargetPlatform; +import 'package:eatvio_app/core/env_config.dart'; /// Default [FirebaseOptions] for use with your Firebase apps. /// @@ -43,28 +43,26 @@ class DefaultFirebaseOptions { 'you can reconfigure this by running the FlutterFire CLI again.', ); default: - throw UnsupportedError( - 'DefaultFirebaseOptions are not supported for this platform.', - ); + throw UnsupportedError('DefaultFirebaseOptions are not supported for this platform.'); } } - static const FirebaseOptions android = FirebaseOptions( - apiKey: 'AIzaSyBUe1UqAAMfgMcd4hWdRoLF1Os-yn-HE9k', - appId: '1:613980912922:android:4de95e5f2d50ba69ecc0a3', - messagingSenderId: '613980912922', - projectId: 'eatvio-3467b', - databaseURL: 'https://eatvio-3467b-default-rtdb.europe-west1.firebasedatabase.app', - storageBucket: 'eatvio-3467b.firebasestorage.app', + static FirebaseOptions get android => FirebaseOptions( + apiKey: EnvConfig.firebaseAndroidApiKey, + appId: EnvConfig.firebaseAndroidAppId, + messagingSenderId: EnvConfig.firebaseMessagingSenderId, + projectId: EnvConfig.firebaseProjectId, + databaseURL: EnvConfig.firebaseDatabaseUrl, + storageBucket: EnvConfig.firebaseStorageBucket, ); - static const FirebaseOptions ios = FirebaseOptions( - apiKey: 'AIzaSyBnE8tQjL9_1HuXrTnqNy6qbXpoenO36yQ', - appId: '1:613980912922:ios:c557d140a1212e63ecc0a3', - messagingSenderId: '613980912922', - projectId: 'eatvio-3467b', - databaseURL: 'https://eatvio-3467b-default-rtdb.europe-west1.firebasedatabase.app', - storageBucket: 'eatvio-3467b.firebasestorage.app', - iosBundleId: 'com.eatvioapp.app', + static FirebaseOptions get ios => FirebaseOptions( + apiKey: EnvConfig.firebaseIosApiKey, + appId: EnvConfig.firebaseIosAppId, + messagingSenderId: EnvConfig.firebaseMessagingSenderId, + projectId: EnvConfig.firebaseProjectId, + databaseURL: EnvConfig.firebaseDatabaseUrl, + storageBucket: EnvConfig.firebaseStorageBucket, + iosBundleId: EnvConfig.firebaseIosBundleId, ); } diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart index 96c0a8d0..80747c5d 100644 --- a/lib/generated/intl/messages_en.dart +++ b/lib/generated/intl/messages_en.dart @@ -51,6 +51,9 @@ class MessageLookup extends MessageLookupByLibrary { "about_diet": MessageLookupByLibrary.simpleMessage("Об этой диете"), "about_plan": MessageLookupByLibrary.simpleMessage("Об этом плане"), "about_recipe": MessageLookupByLibrary.simpleMessage("Об этом рецепте"), + "accountDeleting": MessageLookupByLibrary.simpleMessage( + "Удаление аккаунта", + ), "active": MessageLookupByLibrary.simpleMessage("Активен"), "active_consultations_count": MessageLookupByLibrary.simpleMessage( "Кол-во активных консультаций", @@ -124,6 +127,9 @@ class MessageLookup extends MessageLookupByLibrary { ), "cancel_diet": MessageLookupByLibrary.simpleMessage("Отменить диету?"), "cancel_order": MessageLookupByLibrary.simpleMessage("Отменить заказ"), + "cannotDeleteAccount": MessageLookupByLibrary.simpleMessage( + "Не получилось удалить ваш аккаунт", + ), "carb_type": MessageLookupByLibrary.simpleMessage("Мучное"), "carb_type_desc": MessageLookupByLibrary.simpleMessage( "Если вы любите мучные продукты, но хотите придерживаться диеты, вам подходит диета с добавленим мучных блюд на основе цельнозерновых вариантов. В них больше сложных углеводов и клетчатки, что способствует длительному чувству сытости и поддерживает здоровье пищеварительной системы.", @@ -267,6 +273,7 @@ class MessageLookup extends MessageLookupByLibrary { ), "days": MessageLookupByLibrary.simpleMessage("дней"), "deception": MessageLookupByLibrary.simpleMessage("Обман"), + "delete": MessageLookupByLibrary.simpleMessage("Удалить"), "deleteComment": MessageLookupByLibrary.simpleMessage( "Удалить комментарий", ), @@ -426,6 +433,9 @@ class MessageLookup extends MessageLookupByLibrary { "invited_people_count": MessageLookupByLibrary.simpleMessage( "Количество приглашенных", ), + "itCantBeRecovered": MessageLookupByLibrary.simpleMessage( + "Его нельзя будет восстановить", + ), "label": MessageLookupByLibrary.simpleMessage("Название"), "language": MessageLookupByLibrary.simpleMessage("Язык"), "last_name": MessageLookupByLibrary.simpleMessage("Фамилия"), @@ -642,6 +652,7 @@ class MessageLookup extends MessageLookupByLibrary { "Снять с публикации", ), "reply": MessageLookupByLibrary.simpleMessage("Ответить"), + "resend": MessageLookupByLibrary.simpleMessage("Отправить повторно"), "save": MessageLookupByLibrary.simpleMessage("Сохранить"), "send": MessageLookupByLibrary.simpleMessage("Отправить"), "send_complain": MessageLookupByLibrary.simpleMessage("Отправить жалобу"), @@ -799,6 +810,9 @@ class MessageLookup extends MessageLookupByLibrary { "youShureYouWantToCancelOrder": MessageLookupByLibrary.simpleMessage( "Вы уверены, что хотите отменить заказ?", ), + "youShureYouWantToDeleteAccount": MessageLookupByLibrary.simpleMessage( + "Вы уверены, что хотите удалить аккаунт?", + ), "you_can_not_open_this_day": MessageLookupByLibrary.simpleMessage( "Вы не можете перейти в этот день", ), diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart index 29dc55a3..f990a557 100644 --- a/lib/generated/l10n.dart +++ b/lib/generated/l10n.dart @@ -3494,6 +3494,61 @@ class S { args: [], ); } + + /// `Удаление аккаунта` + String get accountDeleting { + return Intl.message( + 'Удаление аккаунта', + name: 'accountDeleting', + desc: '', + args: [], + ); + } + + /// `Вы уверены, что хотите удалить аккаунт?` + String get youShureYouWantToDeleteAccount { + return Intl.message( + 'Вы уверены, что хотите удалить аккаунт?', + name: 'youShureYouWantToDeleteAccount', + desc: '', + args: [], + ); + } + + /// `Его нельзя будет восстановить` + String get itCantBeRecovered { + return Intl.message( + 'Его нельзя будет восстановить', + name: 'itCantBeRecovered', + desc: '', + args: [], + ); + } + + /// `Удалить` + String get delete { + return Intl.message('Удалить', name: 'delete', desc: '', args: []); + } + + /// `Не получилось удалить ваш аккаунт` + String get cannotDeleteAccount { + return Intl.message( + 'Не получилось удалить ваш аккаунт', + name: 'cannotDeleteAccount', + desc: '', + args: [], + ); + } + + /// `Отправить повторно` + String get resend { + return Intl.message( + 'Отправить повторно', + name: 'resend', + desc: '', + args: [], + ); + } } class AppLocalizationDelegate extends LocalizationsDelegate { diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 21000e10..0cfa4d5d 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -414,6 +414,12 @@ "choose_plan_start_date_with_personal_diet": "Выберите дату старта плана питания с учетом составления персональной диеты", "chosenPlanStartDateDescr": "Дату старта плана можно выбрать не ранее, чем через два. Такое ограничение дается на создание персональной диеты", "createPersonalDiet": "Составьте персональную диету", - "goToMyDiets": "Перейти к моим диетам" + "goToMyDiets": "Перейти к моим диетам", + "accountDeleting": "Удаление аккаунта", + "youShureYouWantToDeleteAccount": "Вы уверены, что хотите удалить аккаунт?", + "itCantBeRecovered": "Его нельзя будет восстановить", + "delete": "Удалить", + "cannotDeleteAccount": "Не получилось удалить ваш аккаунт", + "resend": "Отправить повторно" } diff --git a/lib/main.dart b/lib/main.dart index 005afaac..60293ed8 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,3 +1,4 @@ +import 'dart:io' as io; import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/core/locator_service.dart' as di; import 'package:eatvio_app/core/locator_service.dart'; @@ -11,6 +12,8 @@ import 'package:eatvio_app/features/internet_connection_checker/internet_connect import 'package:eatvio_app/firebase_options.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; @@ -18,22 +21,32 @@ import 'package:flutter_native_splash/flutter_native_splash.dart'; import 'package:flutter_portal/flutter_portal.dart'; import 'package:keyboard_dismisser/keyboard_dismisser.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:eatvio_app/core/env_config.dart'; +import 'package:appmetrica_plugin/appmetrica_plugin.dart'; Future main() async { WidgetsBinding widgetsBinding = SentryWidgetsFlutterBinding.ensureInitialized(); FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding); + await dotenv.load(fileName: ".env"); + await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); + await FirebaseMessaging.instance.requestPermission(); await di.init(); - await SentryFlutter.init( - (options) { - options.dsn = 'http://ba7f2653067776f3034e6e89850e85a1@sentry.md-desk.ru:9000/2'; - options.tracesSampleRate = 1.0; - options.debug = false; - }, - appRunner: () => runApp(const MyApp()), - ); + if (kDebugMode) { + io.HttpClient.enableTimelineLogging = true; + } + + AppMetrica.activate(AppMetricaConfig(EnvConfig.appMetricaApiKey)); + AppMetrica.reportEvent('Application started'); + + await SentryFlutter.init((options) { + options.dsn = EnvConfig.sentryDsn; + options.tracesSampleRate = 1.0; + options.debug = false; + }, appRunner: () => runApp(const MyApp())); } class MyApp extends StatelessWidget { diff --git a/pubspec.yaml b/pubspec.yaml index 74b00964..ae850a35 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,10 +1,10 @@ name: eatvio_app description: A new Flutter project. -version: 1.2.2+52 +version: 1.2.11+71 publish_to: 'none' environment: - sdk: '>=3.3.4 <4.0.0' + sdk: '>=3.8.0 <4.0.0' dependencies: flutter: @@ -13,38 +13,37 @@ dependencies: flutter_localizations: sdk: flutter - flutter_secure_storage: ^9.2.4 flutter_bloc: ^9.1.1 cached_network_image: ^3.4.1 flutter_svg: ^2.2.0 equatable: ^2.0.7 - freezed_annotation: ^3.0.0 + freezed_annotation: ^3.1.0 json_annotation: ^4.9.0 validators: ^3.0.0 path_provider: ^2.1.5 carousel_slider_plus: ^7.1.1 - flutter_form_builder: ^10.0.1 - image_picker: ^1.1.2 + flutter_form_builder: ^10.2.0 + image_picker: ^1.2.0 flutter_typeahead: ^5.2.0 - go_router: ^16.0.0 + go_router: ^16.1.0 keyboard_dismisser: ^3.0.0 - retrofit: ^4.5.0 - get_it: ^8.0.3 - dio: ^5.8.0+1 + retrofit: ^4.7.1 + get_it: ^8.2.0 + dio: ^5.9.0 dart_pusher_channels: ^1.2.3 photo_view: ^0.15.0 dismissible_page: ^1.0.2 - url_launcher: ^6.3.1 + url_launcher: ^6.3.2 easy_debounce: ^2.0.3 badges: ^3.1.2 - google_fonts: ^6.2.1 - syncfusion_flutter_charts: ^30.1.38 - syncfusion_flutter_gauges: ^30.1.38 + google_fonts: ^6.3.0 + syncfusion_flutter_charts: ^30.2.5 + syncfusion_flutter_gauges: ^30.2.5 table_calendar: ^3.2.0 flutter_keyboard_visibility: ^6.0.0 - share_plus: ^11.0.0 + share_plus: ^11.1.0 flutter_html: ^3.0.0 - file_picker: ^10.2.0 + file_picker: ^10.3.1 audioplayers: ^6.5.0 intl: ^0.20.2 mask_text_input_formatter: ^2.9.0 @@ -55,30 +54,33 @@ dependencies: flutter_image_compress: ^2.4.0 image: ^4.5.4 dotted_border: ^3.1.0 - http: ^1.4.0 + http: ^1.5.0 flutter_native_splash: ^2.4.6 webview_flutter: ^4.13.0 - sentry_flutter: ^8.14.2 - connectivity_plus: ^6.1.4 + sentry_flutter: ^9.6.0 + connectivity_plus: ^6.1.5 visibility_detector: ^0.4.0+2 - firebase_core: ^3.15.0 - firebase_messaging: ^15.2.8 + firebase_core: ^4.0.0 + firebase_messaging: ^16.0.0 http_parser: ^4.1.2 path: ^1.9.1 collection: ^1.19.1 shared_preferences: ^2.5.3 version: ^3.0.2 - package_info_plus: ^8.3.0 + package_info_plus: ^8.3.1 device_info_plus: ^11.5.0 - permission_handler: ^12.0.0+1 + permission_handler: ^12.0.1 qr_code_scanner_plus: ^2.0.10+1 + flutter_dotenv: ^5.2.1 + uuid: ^4.5.1 + appmetrica_plugin: ^3.3.0 dev_dependencies: - build_runner: ^2.5.4 - freezed: ^3.0.6 - json_serializable: ^6.9.5 - retrofit_generator: ^9.2.0 - mockito: ^5.4.6 + build_runner: ^2.7.0 + freezed: ^3.2.0 + json_serializable: ^6.10.0 + retrofit_generator: ^10.0.2 + mockito: ^5.5.0 flutter_test: sdk: flutter @@ -88,6 +90,7 @@ dev_dependencies: flutter: uses-material-design: true assets: + - .env - assets/ - assets/images/ - assets/audio/ diff --git a/test/features/profile/services/profile_service_test.dart b/test/features/profile/services/profile_service_test.dart index b2c51b01..8c666344 100644 --- a/test/features/profile/services/profile_service_test.dart +++ b/test/features/profile/services/profile_service_test.dart @@ -17,8 +17,10 @@ void main() { (_) async => await Future.value( const ProfileModel( id: 0, + isBlockedByUser: false, name: 'Alexander Avanesyan', email: 'mso7nueh@mail.ru', + phoneConfirmationState: '', isPublicProfile: true, isEntitySubscribedByMe: false, isBlockedByMe: false,