diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 31ef8af3..00000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 4ad594f6..db73bc05 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -3,19 +3,19 @@ name: Eatvio App Android Test on: [ pull_request ] env: - flutter_version: "3.38.9" + flutter_version: "3.41.2" java_version: "21.x" flutter_channel: "stable" jobs: build: - runs-on: [ dev-office ] + runs-on: [ ubuntu-latest ] steps: - - uses: actions/checkout@v3 + - uses: https://github.com/actions/checkout@v3 - name: Set up JDK - uses: actions/setup-java@v3 + uses: https://github.com/actions/setup-java@v3 with: distribution: "zulu" java-version: ${{ env.java_version }} @@ -24,7 +24,7 @@ jobs: - name: Install Android SDK run: | apt-get update - apt-get install -y openjdk-11-jdk wget + apt-get install -y openjdk-11-jdk wget xz-utils tar wget https://dl.google.com/android/repository/commandlinetools-linux-7583922_latest.zip -O commandlinetools.zip unzip commandlinetools.zip mkdir -p $HOME/Android/Sdk/cmdline-tools/latest @@ -44,7 +44,7 @@ jobs: apt-get install -y jq - id: flutter-action - uses: actions/flutter-action@v2 + uses: https://github.com/subosito/flutter-action@v2 with: channel: ${{ env.flutter_channel }} flutter-version: ${{ env.flutter_version }} @@ -79,7 +79,7 @@ jobs: echo "APK_NAME=$APK_NAME" >> $GITHUB_ENV - name: Upload APK artifact - uses: actions/upload-artifact@v3 + uses: https://github.com/actions/upload-artifact@v3 with: name: ${{ env.APK_NAME }} path: ${{ env.APK_NAME }} diff --git a/.gitea/workflows/docker-run-tests.yml b/.gitea/workflows/docker-run-tests.yml index 08fb5031..17902e45 100644 --- a/.gitea/workflows/docker-run-tests.yml +++ b/.gitea/workflows/docker-run-tests.yml @@ -3,7 +3,7 @@ name: Eatvio App Android Test on custom docker on: [ pull_request ] env: - flutter_version: "3.32.0" + flutter_version: "3.41.2" java_version: "21.x" flutter_channel: "stable" my_local_cache_dir: "/opt/hostedtoolcache/" @@ -15,7 +15,7 @@ jobs: runs-on: [ dev-office-flutter ] steps: - - uses: actions/checkout@v3 + - uses: https://github.com/actions/checkout@v3 - name: Set up Android environment variables id: "android-env-setup" @@ -52,7 +52,7 @@ jobs: # echo "APK_NAME=$APK_NAME" >> $GITHUB_ENV # - name: Upload APK artifact - # uses: actions/upload-artifact@v3 + # uses: https://github.com/actions/upload-artifact@v3 # with: # name: ${{ env.APK_NAME }} # path: ${{ env.APK_NAME }} diff --git a/.gitea/workflows/test-flight-cd.yaml b/.gitea/workflows/test-flight-cd.yaml index 7ca43518..0355bd9c 100644 --- a/.gitea/workflows/test-flight-cd.yaml +++ b/.gitea/workflows/test-flight-cd.yaml @@ -9,10 +9,10 @@ jobs: runs-on: [macos-arm64] steps: - name: Checkout - uses: actions/checkout@v2 + uses: https://github.com/actions/checkout@v2 - name: Checkout env repository - uses: actions/checkout@v2 + uses: https://github.com/actions/checkout@v2 with: repository: mso7nueh/eatvio_app_env path: env_repo diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index f07c8dd3..27f86097 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.38.9" + flutter_version: "3.41.2" java_version: "21.x" flutter_channel: "stable" my_local_cache_dir: "/opt/hostedtoolcache/" @@ -11,13 +11,23 @@ env: jobs: build: - runs-on: [ dev-office ] + runs-on: [ ubuntu-latest ] steps: - - uses: actions/checkout@v3 + - uses: https://github.com/actions/checkout@v3 + + - name: Set proxy server + env: + PROXY_USER: ${{ secrets.PROXY_USER }} + PROXY_PASSWORD: ${{ secrets.PROXY_PASSWORD }} + run: | + echo "Setting up proxy..." + cat </etc/apt/apt.conf.d/proxy.conf + Acquire::http::Proxy "socks5h://${PROXY_USER}:${PROXY_PASSWORD}@10.55.5.202:19843/"; + EOF - name: Checkout env repository - uses: actions/checkout@v3 + uses: https://github.com/actions/checkout@v3 with: repository: mso7nueh/eatvio_app_env path: env_repo @@ -56,13 +66,13 @@ jobs: - name: Cache Android SDK id: cache-sdk - uses: "https://gitea.com/actions/cache@v4" + uses: "https://github.com/actions/cache@v4" with: path: ${{ env.my_local_cache_dir }} key: ${{ runner.os }}-cache-sdk - name: Set up JDK - uses: actions/setup-java@v3 + uses: https://github.com/actions/setup-java@v3 with: distribution: "zulu" java-version: ${{ env.java_version }} @@ -99,16 +109,23 @@ jobs: echo "PATH=$PATH:${{ env.my_local_cache_dir }}Android/Sdk/cmdline-tools/latest/bin:${{ env.my_local_cache_dir }}Android/Sdk/platform-tools:${{ env.my_local_cache_dir }}Android/Sdk/build-tools/30.0.3:${{ env.my_local_cache_dir }}Android/Sdk/build-tools/34.0.0" >> $GITHUB_ENV echo "ANDROID_HOME=${{ env.my_local_cache_dir }}Android/Sdk" >> $GITHUB_ENV - - id: flutter-action - uses: actions/flutter-action@v2 - with: - channel: ${{ env.flutter_channel }} - flutter-version: ${{ env.flutter_version }} - cache: true - cache-key: "flutter-:os:-:channel:-:version:-:arch:-:hash:" # optional, change this to force refresh cache - cache-path: "${{ runner.tool_cache }}/flutter/:channel:-:version:-:arch:" # optional, change this to specify the cache path - pub-cache-key: "flutter-pub:os:-:channel:-:version:-:arch:-:hash:" # optional, change this to force refresh cache of dart pub get dependencies - pub-cache-path: "${{ runner.tool_cache }}/flutter/:channel:-:version:-:arch:" # optional, change this to specify the cache path + + - name: Set up Flutter + run: | + FLUTTER_DIR="${{ env.my_local_cache_dir }}flutter" + if [ ! -f "$FLUTTER_DIR/bin/flutter" ]; then + echo "Downloading Flutter ${{ env.flutter_version }}..." + curl -fsSL -o /tmp/flutter.tar.xz "https://storage.googleapis.com/flutter_infra_release/releases/${{ env.flutter_channel }}/linux/flutter_linux_${{ env.flutter_version }}-${{ env.flutter_channel }}.tar.xz" + echo "Extracting Flutter..." + mkdir -p "${{ env.my_local_cache_dir }}" + tar xf /tmp/flutter.tar.xz -C "${{ env.my_local_cache_dir }}" + rm /tmp/flutter.tar.xz + else + echo "Flutter already cached at $FLUTTER_DIR" + fi + echo "$FLUTTER_DIR/bin" >> $GITHUB_PATH + echo "$FLUTTER_DIR/bin/cache/dart-sdk/bin" >> $GITHUB_PATH + echo "$HOME/.pub-cache/bin" >> $GITHUB_PATH # - name: Print flutter-action outputs # shell: bash @@ -126,7 +143,9 @@ jobs: - name: Fix dir permissions run: | - git config --global --add safe.directory /opt/hostedtoolcache/flutter/${{ env.flutter_channel }}-${{ env.flutter_version }}-${{ steps.flutter-action.outputs.ARCHITECTURE }} + ARCH=$(uname -m) + if [ "$ARCH" = "x86_64" ]; then ARCH="x64"; fi + git config --global --add safe.directory "${{ env.my_local_cache_dir }}flutter" flutter config --android-sdk="$ANDROID_HOME" flutter doctor -v @@ -220,7 +239,7 @@ jobs: echo "APK_NAME=$APK_NAME" >> $GITHUB_ENV - name: Upload APK artifact - uses: actions/upload-artifact@v3 + uses: https://github.com/actions/upload-artifact@v3 with: name: ${{ env.APK_NAME }} path: ${{ env.APK_NAME }} diff --git a/.gitignore b/.gitignore index 530ea713..6b66540b 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,8 @@ env/.env .env android/key.properties app/upload-keystore.jks -coverage/ \ No newline at end of file +coverage/ +backend/ +.fvm/ +.fvmrc +/eatvio \ No newline at end of file diff --git a/android/app/agconnect-services.json b/android/app/agconnect-services.json index 8ca899db..e2f8dec1 100644 --- a/android/app/agconnect-services.json +++ b/android/app/agconnect-services.json @@ -1 +1,94 @@ -{"agcgw":{"url":"connect-drru.hispace.dbankcloud.ru","backurl":"connect-drru.hispace.dbankcloud.cn","websocketurl":"connect-ws-drru.hispace.dbankcloud.ru","websocketbackurl":"connect-ws-drru.hispace.dbankcloud.cn"},"agcgw_all":{"SG":"connect-dra.dbankcloud.cn","SG_back":"connect-dra.hispace.hicloud.com","CN":"connect-drcn.dbankcloud.cn","CN_back":"connect-drcn.hispace.hicloud.com","RU":"connect-drru.hispace.dbankcloud.ru","RU_back":"connect-drru.hispace.dbankcloud.cn","DE":"connect-dre.dbankcloud.cn","DE_back":"connect-dre.hispace.hicloud.com"},"websocketgw_all":{"SG":"connect-ws-dra.hispace.dbankcloud.cn","SG_back":"connect-ws-dra.hispace.dbankcloud.com","CN":"connect-ws-drcn.hispace.dbankcloud.cn","CN_back":"connect-ws-drcn.hispace.dbankcloud.com","RU":"connect-ws-drru.hispace.dbankcloud.ru","RU_back":"connect-ws-drru.hispace.dbankcloud.cn","DE":"connect-ws-dre.hispace.dbankcloud.cn","DE_back":"connect-ws-dre.hispace.dbankcloud.com"},"client":{"cp_id":"20007000036693451","product_id":"461323198429446894","client_id":"1595824543740483264","client_secret":"2A1727B0D31AA9BE1F5D9E8A5CDA1A67B9EF084AC9FB68D6C633A3CCB08ECDEE","project_id":"461323198429446894","app_id":"113238569","api_key":"DQEDAJNRITp9M3uc1rPHyT8PzLc7bnVcKJAeavZZk0TQNMx9ifvBebr+JOmZe9tEvjnLwv7c1hAHXmPGR/gWG86iABDrCVotq+NGnw==","package_name":"com.eatvioapp.app"},"oauth_client":{"client_id":"113238569","client_type":1},"app_info":{"app_id":"113238569","package_name":"com.eatvioapp.app"},"service":{"analytics":{"collector_url":"datacollector-drru.dt.dbankcloud.ru,datacollector-drru.dt.hicloud.com","collector_url_cn":"datacollector-drcn.dt.hicloud.com,datacollector-drcn.dt.dbankcloud.cn","collector_url_de":"datacollector-dre.dt.hicloud.com,datacollector-dre.dt.dbankcloud.cn","collector_url_ru":"datacollector-drru.dt.dbankcloud.ru,datacollector-drru.dt.hicloud.com","collector_url_sg":"datacollector-dra.dt.hicloud.com,datacollector-dra.dt.dbankcloud.cn","resource_id":"p1","channel_id":""},"ml":{"mlservice_url":"ml-api-drru.ai.dbankcloud.ru,ml-api-drru.ai.dbankcloud.cn"},"cloudstorage":{"storage_url":"https://agc-storage-drcn.platform.dbankcloud.cn","storage_url_ru":"https://agc-storage-drru.cloud.huawei.ru","storage_url_sg":"https://ops-dra.agcstorage.link","storage_url_de":"https://ops-dre.agcstorage.link","storage_url_cn":"https://agc-storage-drcn.platform.dbankcloud.cn","storage_url_ru_back":"https://agc-storage-drru.cloud.huawei.ru","storage_url_sg_back":"https://agc-storage-dra.cloud.huawei.asia","storage_url_de_back":"https://agc-storage-dre.cloud.huawei.eu","storage_url_cn_back":"https://agc-storage-drcn.cloud.huawei.com.cn"},"search":{"url":"https://search-drru.cloud.huawei.ru"},"edukit":{"edu_url":"edukit.cloud.huawei.com.cn","dh_url":"edukit.cloud.huawei.com.cn"}},"region":"RU","configuration_version":"3.0","appInfos":[{"package_name":"com.eatvioapp.app","client":{"app_id":"113238569"},"app_info":{"package_name":"com.eatvioapp.app","app_id":"113238569"},"oauth_client":{"client_type":1,"client_id":"113238569"}}]} \ No newline at end of file +{ + "agcgw" : { + "url" : "connect-drru.hispace.dbankcloud.ru", + "backurl" : "connect-drru.hispace.dbankcloud.cn", + "websocketurl" : "connect-ws-drru.hispace.dbankcloud.ru", + "websocketbackurl" : "connect-ws-drru.hispace.dbankcloud.cn" + }, + "agcgw_all" : { + "SG" : "connect-dra.dbankcloud.cn", + "SG_back" : "connect-dra.hispace.hicloud.com", + "CN" : "connect-drcn.dbankcloud.cn", + "CN_back" : "connect-drcn.hispace.hicloud.com", + "RU" : "connect-drru.hispace.dbankcloud.ru", + "RU_back" : "connect-drru.hispace.dbankcloud.cn", + "DE" : "connect-dre.dbankcloud.cn", + "DE_back" : "connect-dre.hispace.hicloud.com" + }, + "websocketgw_all" : { + "SG" : "connect-ws-dra.hispace.dbankcloud.cn", + "SG_back" : "connect-ws-dra.hispace.dbankcloud.com", + "CN" : "connect-ws-drcn.hispace.dbankcloud.cn", + "CN_back" : "connect-ws-drcn.hispace.dbankcloud.com", + "RU" : "connect-ws-drru.hispace.dbankcloud.ru", + "RU_back" : "connect-ws-drru.hispace.dbankcloud.cn", + "DE" : "connect-ws-dre.hispace.dbankcloud.cn", + "DE_back" : "connect-ws-dre.hispace.dbankcloud.com" + }, + "client" : { + "cp_id" : "20007000036693451", + "product_id" : "461323198429446894", + "client_id" : "1595824543740483264", + "client_secret" : "2A1727B0D31AA9BE1F5D9E8A5CDA1A67B9EF084AC9FB68D6C633A3CCB08ECDEE", + "project_id" : "461323198429446894", + "app_id" : "113238569", + "api_key" : "DQEDAJNRITp9M3uc1rPHyT8PzLc7bnVcKJAeavZZk0TQNMx9ifvBebr+JOmZe9tEvjnLwv7c1hAHXmPGR/gWG86iABDrCVotq+NGnw==", + "package_name" : "com.eatvioapp.app" + }, + "oauth_client" : { + "client_id" : "113238569", + "client_type" : 1 + }, + "app_info" : { + "app_id" : "113238569", + "package_name" : "com.eatvioapp.app" + }, + "service" : { + "analytics" : { + "collector_url" : "datacollector-drru.dt.dbankcloud.ru,datacollector-drru.dt.hicloud.com", + "collector_url_cn" : "datacollector-drcn.dt.hicloud.com,datacollector-drcn.dt.dbankcloud.cn", + "collector_url_de" : "datacollector-dre.dt.hicloud.com,datacollector-dre.dt.dbankcloud.cn", + "collector_url_ru" : "datacollector-drru.dt.dbankcloud.ru,datacollector-drru.dt.hicloud.com", + "collector_url_sg" : "datacollector-dra.dt.hicloud.com,datacollector-dra.dt.dbankcloud.cn", + "resource_id" : "p1", + "channel_id" : "" + }, + "ml" : { + "mlservice_url" : "ml-api-drru.ai.dbankcloud.ru,ml-api-drru.ai.dbankcloud.cn" + }, + "cloudstorage" : { + "storage_url" : "https://agc-storage-drcn.platform.dbankcloud.cn", + "storage_url_ru" : "https://agc-storage-drru.cloud.huawei.ru", + "storage_url_sg" : "https://ops-dra.agcstorage.link", + "storage_url_de" : "https://ops-dre.agcstorage.link", + "storage_url_cn" : "https://agc-storage-drcn.platform.dbankcloud.cn", + "storage_url_ru_back" : "https://agc-storage-drru.cloud.huawei.ru", + "storage_url_sg_back" : "https://agc-storage-dra.cloud.huawei.asia", + "storage_url_de_back" : "https://agc-storage-dre.cloud.huawei.eu", + "storage_url_cn_back" : "https://agc-storage-drcn.cloud.huawei.com.cn" + }, + "search" : { + "url" : "https://search-drru.cloud.huawei.ru" + }, + "edukit" : { + "edu_url" : "edukit.cloud.huawei.com.cn", + "dh_url" : "edukit.cloud.huawei.com.cn" + } + }, + "region" : "RU", + "configuration_version" : "3.0", + "appInfos" : [ { + "package_name" : "com.eatvioapp.app", + "client" : { + "app_id" : "113238569" + }, + "app_info" : { + "package_name" : "com.eatvioapp.app", + "app_id" : "113238569" + }, + "oauth_client" : { + "client_type" : 1, + "client_id" : "113238569" + } + } ] +} \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 864f1a2c..e7a94938 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -21,6 +21,12 @@ + + + methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName) + methodChannel?.setMethodCallHandler { call, result -> when (call.method) { "getHuaweiToken" -> { CoroutineScope(Dispatchers.IO).launch { @@ -47,17 +48,21 @@ class MainActivity: FlutterActivity() { } if (appId.isNullOrBlank()) { + android.util.Log.e("HMS", "Missing Huawei appId. Ensure agconnect-services.json or manifest meta-data is configured.") runOnUiThread { result.error("HMS_TOKEN_ERROR", "Missing Huawei appId. Ensure agconnect-services.json or manifest meta-data is configured.", null) } return@launch } + android.util.Log.i("HMS", "Getting token for appId: $appId") val token = HmsInstanceId.getInstance(applicationContext) .getToken(appId, "HCM") + android.util.Log.i("HMS", "Token retrieved successfully") runOnUiThread { result.success(token) } } catch (e: Exception) { + android.util.Log.e("HMS", "Error getting HMS token: ${e.message}", e) runOnUiThread { result.error("HMS_TOKEN_ERROR", e.message, null) } @@ -67,5 +72,33 @@ class MainActivity: FlutterActivity() { else -> result.notImplemented() } } + + // Обрабатываем возможный action_url, с которым была открыта Activity (например, из HMS уведомления). + handleIntentForActionUrl(intent) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + // Обработка нового интента, когда Activity уже запущена (тап по уведомлению). + handleIntentForActionUrl(intent) + } + + private fun handleIntentForActionUrl(intent: Intent?) { + if (intent == null) return + + // Вариант 1: action_url лежит в extras. + val fromExtras = intent.extras?.getString("action_url") + + // Вариант 2: action_url передан как data Uri. + val dataUri = intent.data + val fromData = dataUri?.toString() + + val actionUrl = fromExtras ?: fromData + if (actionUrl.isNullOrBlank()) return + + android.util.Log.i("HMS", "MainActivity.handleIntentForActionUrl: $actionUrl") + + // Проксируем URL во Flutter, где уже есть общая логика навигации по диплинкам. + methodChannel?.invokeMethod("openActionUrl", actionUrl) } } diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html index 746fadde..379f47c9 100644 --- a/android/build/reports/problems/problems-report.html +++ b/android/build/reports/problems/problems-report.html @@ -650,7 +650,7 @@ code + .copy-button { diff --git a/assets/icons/crossed_eye.svg b/assets/icons/crossed_eye.svg new file mode 100644 index 00000000..f00fc63a --- /dev/null +++ b/assets/icons/crossed_eye.svg @@ -0,0 +1,3 @@ + + + diff --git a/lib/core/app_router.dart b/lib/core/app_router.dart index 776aea35..4b778cbd 100644 --- a/lib/core/app_router.dart +++ b/lib/core/app_router.dart @@ -14,7 +14,7 @@ import 'package:go_router/go_router.dart'; class AppRouter { static GlobalKey navigatorKey = GlobalKey(); - // Домены для deep links + // Домены для deep links (в т.ч. ссылки из пушей dev2.eatvio.com) static const List _deepLinkHosts = ['eatvio.ru', 'www.eatvio.ru']; static GoRouter appRouter = GoRouter( @@ -30,64 +30,59 @@ class AppRouter { ], ); - /// Обработка редиректов для deep links - /// Возвращает null для обычной навигации, чтобы не мешать внутренним переходам - static String? _handleRedirect(BuildContext context, GoRouterState state) { - final uri = state.uri; + static String? _resolveDeepLinkUri(Uri uri) { final host = uri.host.toLowerCase(); final segments = uri.pathSegments; - // Проверяем, является ли это deep link по домену final isDeepLink = _deepLinkHosts.contains(host); - - // Если это не deep link, не мешаем обычной навигации + if (!isDeepLink) { return null; } - // Логирование для отладки - developer.log( - 'Deep link detected: host=$host, path=${uri.path}, segments=$segments', - name: 'AppRouter', - ); + developer.log('Deep link detected: host=$host, path=${uri.path}, segments=$segments', name: 'AppRouter'); - // Проверка на пустой путь или некорректный формат if (segments.isEmpty) { developer.log('Empty segments in deep link, redirecting to feed', name: 'AppRouter'); FlutterNativeSplash.remove(); return RouteConstants.feedScreen; } - // Проверка языка (первый сегмент должен быть ru или en) + if (segments.length >= 4 && + segments[0] == 'api' && + segments[1] == 'v2' && + segments[2] == 'chat' && + segments[3] == 'show') { + developer.log('Deep link: api/v2/chat/show → chat-select-screen', name: 'AppRouter'); + FlutterNativeSplash.remove(); + return RouteConstants.chatSelectScreen; + } + if (segments[0] != 'ru' && segments[0] != 'en') { - developer.log( - 'Invalid language segment: ${segments[0]}, redirecting to feed', - name: 'AppRouter', - ); + developer.log('Invalid language segment: ${segments[0]}, redirecting to feed', name: 'AppRouter'); FlutterNativeSplash.remove(); return RouteConstants.feedScreen; } - // Обработка deep links с учетом языка - // Формат: /{lang}/{type}/{identifier?} if (segments.length == 4) { - // Формат: /{lang}/diary/{...} final type = segments[1]; - + if (type == 'diary') { developer.log('Deep link: diary, redirecting to diary-screen', name: 'AppRouter'); FlutterNativeSplash.remove(); return RouteConstants.diaryScreen; } - - developer.log( - 'Unknown deep link type for 4 segments: $type, redirecting to feed', - name: 'AppRouter', - ); + if (type == 'consultations' && segments[2] == 'status') { + final consultationId = segments[3]; + developer.log('Deep link: consultations/status/$consultationId', name: 'AppRouter'); + FlutterNativeSplash.remove(); + return '${RouteConstants.consultationsScreen}?id=$consultationId'; + } + + developer.log('Unknown deep link type for 4 segments: $type, redirecting to feed', name: 'AppRouter'); FlutterNativeSplash.remove(); return RouteConstants.feedScreen; } else if (segments.length == 3) { - // Формат: /{lang}/{type}/{identifier} final type = segments[1]; final contentIdentifier = segments[2]; @@ -104,55 +99,111 @@ class AppRouter { developer.log('Deep link: plan/$contentIdentifier', name: 'AppRouter'); FlutterNativeSplash.remove(); return '/about-plan-screen?slug=$contentIdentifier'; - case 'diet': - developer.log('Deep link: diet/$contentIdentifier', name: 'AppRouter'); + case 'diet': { + final dietId = int.tryParse(contentIdentifier); + if (dietId != null) { + developer.log('Deep link: diet/id=$dietId', name: 'AppRouter'); + FlutterNativeSplash.remove(); + return '/about-diet-screen?id=$dietId'; + } + developer.log('Deep link: diet/slug=$contentIdentifier', name: 'AppRouter'); FlutterNativeSplash.remove(); return '/about-diet-screen?slug=$contentIdentifier'; + } case 'chat': developer.log('Deep link: chat/$contentIdentifier', name: 'AppRouter'); FlutterNativeSplash.remove(); return RouteConstants.chatSelectScreen; case 'profile': + // В диплинке передан slug — открываем профиль пользователя по slug. developer.log('Deep link: profile/$contentIdentifier', name: 'AppRouter'); FlutterNativeSplash.remove(); return '/profile-screen?slug=$contentIdentifier'; + case 'badge': + // В badge нет slug, открываем вкладку «Мой профиль». + developer.log('Deep link: badge → my-profile tab', name: 'AppRouter'); + FlutterNativeSplash.remove(); + return RouteConstants.myProfileScreen; case 'diary': developer.log('Deep link: diary/$contentIdentifier', name: 'AppRouter'); FlutterNativeSplash.remove(); return RouteConstants.diaryScreen; + case 'consultant_diary_comment': + developer.log('Deep link: consultant_diary_comment/$contentIdentifier → diary', name: 'AppRouter'); + FlutterNativeSplash.remove(); + return RouteConstants.diaryScreen; default: - developer.log( - 'Unknown deep link type: $type, redirecting to feed', - name: 'AppRouter', - ); + developer.log('Unknown deep link type: $type, redirecting to feed', name: 'AppRouter'); FlutterNativeSplash.remove(); return RouteConstants.feedScreen; } } else if (segments.length == 2) { - // Формат: /{lang}/{type} - только для chat final type = segments[1]; - + if (type == 'chat') { developer.log('Deep link: chat', name: 'AppRouter'); FlutterNativeSplash.remove(); return RouteConstants.chatSelectScreen; - } else { - developer.log( - 'Unknown deep link type for 2 segments: $type, redirecting to feed', - name: 'AppRouter', - ); - FlutterNativeSplash.remove(); - return RouteConstants.feedScreen; } - } else { - // Неизвестный формат deep link - developer.log( - 'Unknown deep link format: ${segments.length} segments, redirecting to feed', - name: 'AppRouter', - ); + if (type == 'diary') { + developer.log('Deep link: diary', name: 'AppRouter'); + FlutterNativeSplash.remove(); + return RouteConstants.diaryScreen; + } + + developer.log('Unknown deep link type for 2 segments: $type, redirecting to feed', name: 'AppRouter'); FlutterNativeSplash.remove(); return RouteConstants.feedScreen; } + + developer.log('Unknown deep link format: ${segments.length} segments, redirecting to feed', name: 'AppRouter'); + FlutterNativeSplash.remove(); + return RouteConstants.feedScreen; + } + + static String? _handleRedirect(BuildContext context, GoRouterState state) { + return _resolveDeepLinkUri(state.uri); + } + + static void navigateByDeepLinkUrl(String url) { + if (url.isEmpty) { + developer.log('navigateByDeepLinkUrl: empty url, redirecting to feed', name: 'AppRouter'); + appRouter.go(RouteConstants.feedScreen); + return; + } + + Uri? uri; + try { + uri = Uri.tryParse(url); + } catch (e) { + developer.log('navigateByDeepLinkUrl: failed to parse url=$url, error=$e, redirecting to feed', name: 'AppRouter'); + appRouter.go(RouteConstants.feedScreen); + return; + } + + if (uri == null) { + developer.log('navigateByDeepLinkUrl: uri is null for url=$url, redirecting to feed', name: 'AppRouter'); + appRouter.go(RouteConstants.feedScreen); + return; + } + + if (uri.host.isEmpty && uri.pathSegments.isNotEmpty) { + uri = Uri( + scheme: 'https', + host: _deepLinkHosts.first, + pathSegments: uri.pathSegments, + queryParameters: uri.queryParameters.isEmpty ? null : uri.queryParameters, + ); + } + + final target = _resolveDeepLinkUri(uri); + + if (target != null) { + appRouter.go(target); + } else { + developer.log('navigateByDeepLinkUrl: unresolved url=$url (normalized=${uri.toString()}), redirecting to feed', name: 'AppRouter'); + appRouter.go(RouteConstants.feedScreen); + } } static void navigateToRegisterViaSocial({required AuthResultEntity authResult, required String provider}) { diff --git a/lib/core/di/cubit_registrations.dart b/lib/core/di/cubit_registrations.dart index 7b12e0fb..6f26ab95 100644 --- a/lib/core/di/cubit_registrations.dart +++ b/lib/core/di/cubit_registrations.dart @@ -45,7 +45,7 @@ import 'package:eatvio_app/features/profile/presentation/bloc/plans_cubit/plans_ import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart'; import 'package:eatvio_app/features/profile/presentation/bloc/sub_cubit/sub_cubit.dart'; import 'package:eatvio_app/features/recipe/presentation/cubit/about_recipe_cubit.dart'; -import 'package:eatvio_app/features/recipe/presentation/cubit/my_recipes_cubit/my_recieps_cubit.dart'; +import 'package:eatvio_app/features/recipe/presentation/cubit/my_recipes_cubit/my_recipes_cubit.dart'; import 'package:eatvio_app/features/registration/presentation/bloc/registration_cubit/registration_cubit.dart'; import 'package:eatvio_app/features/registration/presentation/bloc/verify_email_cubit/verify_email_cubit.dart'; import 'package:eatvio_app/features/registration_v2/cubit/registration_cubit.dart'; @@ -114,7 +114,7 @@ class CubitRegistrations { // User content cubits sl.registerFactory(() => MyDietsCubit(myDietsRepository: sl(), createDietRepository: sl())); sl.registerFactory(() => MyPlansCubit(myPlansRepository: sl())); - sl.registerFactory(() => MyRecipesCubit(myRecipesRepository: sl(), aboutRecipeRepository: sl())); + sl.registerFactory(() => MyRecipesCubit(myRecipesRepository: sl(), aboutRecipeRepository: sl(), recipeRepository: sl())); // Other feature cubits sl.registerFactory(() => CalendarCubit(calendarRepository: sl())); diff --git a/lib/core/firebase_api.dart b/lib/core/firebase_api.dart index 73e1e891..1d09d7a3 100644 --- a/lib/core/firebase_api.dart +++ b/lib/core/firebase_api.dart @@ -5,7 +5,6 @@ import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/app_initializer.dart'; import 'package:eatvio_app/core/utils/toasts.dart'; import 'package:flutter/services.dart'; - import 'package:device_info_plus/device_info_plus.dart'; import 'package:dio/dio.dart'; import 'package:eatvio_app/core/locator_service.dart'; @@ -50,7 +49,7 @@ class FirebaseApi { await sl().post( '/api/anonymous_token', data: { - 'platform': Platform.isIOS ? 'ios' : 'android', + 'platform': Platform.isIOS ? 'ios' : (await _isHuaweiDevice()) ? 'huawei' : 'android', 'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id, 'token': await getToken(), }, diff --git a/lib/core/notifications/push_navigation_handler.dart b/lib/core/notifications/push_navigation_handler.dart new file mode 100644 index 00000000..b26db5e3 --- /dev/null +++ b/lib/core/notifications/push_navigation_handler.dart @@ -0,0 +1,91 @@ +import 'dart:developer' as developer; + +import 'package:eatvio_app/core/app_router.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +/// Отвечает за навигацию по диплинкам, пришедшим в пуш-уведомлениях. +class PushNavigationHandler { + static bool _initialized = false; + static const MethodChannel _hmsChannel = MethodChannel('com.eatvioapp/hms_push'); + + /// Инициализация обработчиков Firebase Messaging. + /// Должна вызываться один раз после старта приложения. + static Future initialize() async { + if (_initialized) return; + _initialized = true; + + // Сообщение, по которому запустили приложение из "убитого" состояния. + try { + final initialMessage = await FirebaseMessaging.instance.getInitialMessage(); + if (initialMessage != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _handleMessageInternal(initialMessage, source: 'initialMessage'); + }); + } + } catch (e) { + developer.log( + 'PushNavigationHandler: error while getting initialMessage: $e', + name: 'PushNavigationHandler', + ); + } + + // Тап по уведомлению, когда приложение было в фоне. + FirebaseMessaging.onMessageOpenedApp.listen( + (message) => _handleMessageInternal(message, source: 'onMessageOpenedApp'), + ); + + // Подписка на события от native-кода (HMS через MethodChannel). + _hmsChannel.setMethodCallHandler((call) async { + if (call.method == 'openActionUrl') { + final url = call.arguments as String? ?? ''; + handleActionUrlFromNative(url); + } + }); + } + + /// Входная точка для навигации из native-кода (например, HMS через MethodChannel). + static void handleActionUrlFromNative(String url) { + if (url.isEmpty) { + developer.log( + 'PushNavigationHandler.handleActionUrlFromNative: empty url', + name: 'PushNavigationHandler', + ); + return; + } + + developer.log( + 'PushNavigationHandler.handleActionUrlFromNative: navigating by url=$url', + name: 'PushNavigationHandler', + ); + + AppRouter.navigateByDeepLinkUrl(url); + } + + /// Публичный метод для единичной обработки сообщения (если понадобится где-то ещё). + static void handleMessage(RemoteMessage message) { + _handleMessageInternal(message, source: 'manualHandle'); + } + + static void _handleMessageInternal(RemoteMessage message, {required String source}) { + final data = message.data; + final actionUrl = data['action_url'] as String?; + + if (actionUrl == null || actionUrl.isEmpty) { + developer.log( + 'PushNavigationHandler: no action_url in message data, source=$source, data=$data', + name: 'PushNavigationHandler', + ); + return; + } + + developer.log( + 'PushNavigationHandler: navigating by action_url=$actionUrl, source=$source', + name: 'PushNavigationHandler', + ); + + AppRouter.navigateByDeepLinkUrl(actionUrl); + } +} + diff --git a/lib/core/routing/auth_routes.dart b/lib/core/routing/auth_routes.dart index 772cd2eb..4d46e8de 100644 --- a/lib/core/routing/auth_routes.dart +++ b/lib/core/routing/auth_routes.dart @@ -10,17 +10,14 @@ import 'package:eatvio_app/features/registration/presentation/pages/verify_email import 'package:eatvio_app/features/registration_v2/presentation/pages/registration_v2_page.dart'; import 'package:go_router/go_router.dart'; -/// Маршруты для аутентификации class AuthRoutes { - /// Создание маршрутов аутентификации + static List createRoutes() { return [ GoRoute( path: RouteConstants.authScreen, name: 'authScreen', builder: (context, state) => const AuthScreen(), - // registerAnonymousPushToken вызывается в AuthCubit.initializeUser() - // и не должен блокировать навигацию routes: [ GoRoute(path: RouteConstants.registrationScreen, name: 'registrationScreen', builder: (context, state) => const RegistrationScreen()), GoRoute(path: RouteConstants.registrationScreenV2, name: 'registrationScreenV2', builder: (context, state) => const RegistrationV2Page()), diff --git a/lib/core/routing/content_routes.dart b/lib/core/routing/content_routes.dart index 0556fab6..dcb3d182 100644 --- a/lib/core/routing/content_routes.dart +++ b/lib/core/routing/content_routes.dart @@ -17,12 +17,20 @@ class ContentRoutes { name: 'aboutPlanScreen', builder: (context, state) { final slug = state.uri.queryParameters['slug']; + final commentIdStr = state.uri.queryParameters['commentId']; + final commentId = int.tryParse(commentIdStr ?? ''); + if (slug != null) { - return AboutPlanScreen(slug: slug, navigateToComments: false, index: null); + return AboutPlanScreen(slug: slug, navigateToComments: commentId != null, index: null, highlightedCommentId: commentId); } final data = state.extra as Map; - return AboutPlanScreen(slug: data['slug'] as String, index: data['index'] as int?, navigateToComments: data['navigateToComments'] as bool); + return AboutPlanScreen( + slug: data['slug'] as String, + index: data['index'] as int?, + navigateToComments: data['navigateToComments'] as bool? ?? (data['commentId'] != null), + highlightedCommentId: data['commentId'] as int?, + ); }, ), GoRoute( @@ -30,12 +38,20 @@ class ContentRoutes { name: 'aboutRecipesScreen', builder: (context, state) { final slug = state.uri.queryParameters['slug']; + final commentIdStr = state.uri.queryParameters['commentId']; + final commentId = int.tryParse(commentIdStr ?? ''); + if (slug != null) { - return AboutRecipeScreen(recipeSlug: slug, navigateToComments: false, index: null); + return AboutRecipeScreen(recipeSlug: slug, navigateToComments: commentId != null, index: null, highlightedCommentId: commentId); } 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? ?? (data['commentId'] != null), + highlightedCommentId: data['commentId'] as int?, + ); }, ), GoRoute( @@ -43,16 +59,28 @@ class ContentRoutes { name: 'aboutDietScreen', builder: (context, state) { final slug = state.uri.queryParameters['slug']; + final idStr = state.uri.queryParameters['id']; + final id = int.tryParse(idStr ?? ''); + final commentIdStr = state.uri.queryParameters['commentId']; + final commentId = int.tryParse(commentIdStr ?? ''); + if (slug != null) { - return AboutDietScreen(id: null, slug: slug, index: null, navigateToComments: false); + return AboutDietScreen(id: null, slug: slug, index: null, navigateToComments: commentId != null, highlightedCommentId: commentId); } - final data = state.extra as Map; - return AboutDietScreen( - id: data['id'] as int?, - slug: data['slug'] as String?, - index: data['index'] as int?, - navigateToComments: data['navigateToComments'] as bool, - ); + if (id != null && id > 0) { + return AboutDietScreen(id: id, slug: null, index: null, navigateToComments: commentId != null, highlightedCommentId: commentId); + } + final data = state.extra as Map?; + if (data != null) { + return AboutDietScreen( + id: data['id'] as int?, + slug: data['slug'] as String?, + index: data['index'] as int?, + navigateToComments: data['navigateToComments'] as bool? ?? (data['commentId'] != null), + highlightedCommentId: data['commentId'] as int?, + ); + } + return const AboutDietScreen(id: null, slug: null, index: null, navigateToComments: false, highlightedCommentId: null); }, ), GoRoute( @@ -65,12 +93,20 @@ class ContentRoutes { name: 'blogPostScreen', builder: (context, state) { final id = state.uri.queryParameters['id']; + final commentIdStr = state.uri.queryParameters['commentId']; + final commentId = int.tryParse(commentIdStr ?? ''); final parsedId = int.tryParse(id ?? ''); + if (parsedId != null) { - return BlogPostScreen(id: parsedId); + return BlogPostScreen(id: parsedId, highlightedCommentId: commentId); } - return BlogPostScreen(id: state.extra as int); + if (state.extra is int) { + return BlogPostScreen(id: state.extra as int); + } + + final data = state.extra as Map; + return BlogPostScreen(id: data['id'] as int, highlightedCommentId: data['commentId'] as int?); }, ), ]; diff --git a/lib/core/routing/main_navigation_routes.dart b/lib/core/routing/main_navigation_routes.dart index 88a8db04..2557ddcf 100644 --- a/lib/core/routing/main_navigation_routes.dart +++ b/lib/core/routing/main_navigation_routes.dart @@ -1,6 +1,7 @@ import 'package:eatvio_app/core/routing/route_constants.dart'; import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart'; import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_state.dart'; +import 'package:eatvio_app/features/bottom_navigation_bar/presentation/widgets/bottom_nav_scope.dart'; import 'package:eatvio_app/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget.dart'; import 'package:eatvio_app/features/chat/presentation/pages/chats_screen.dart'; import 'package:eatvio_app/features/consultations/presentation/pages/consultations_screen.dart'; @@ -31,8 +32,11 @@ class MainNavigationRoutes { profileAvatarCubit.getMyProfile(); }); } - - return BottomNavigationBarWidget(child: child); + + return _MainShellWithNavScope( + currentLocation: state.matchedLocation, + child: child, + ); }, routes: [ GoRoute( @@ -58,7 +62,7 @@ class MainNavigationRoutes { GoRoute( path: RouteConstants.diaryScreen, name: 'diaryScreen', - pageBuilder: (context, state) => const NoTransitionPage(child: DiaryScreen()), + pageBuilder: (context, state) => NoTransitionPage(child: DiaryScreen(date: state.extra as DateTime?)), ), GoRoute( path: ConsultationsScreen.path, @@ -68,7 +72,10 @@ class MainNavigationRoutes { GoRoute( path: RouteConstants.chatSelectScreen, name: 'chatSelectScreen', - pageBuilder: (context, state) => const NoTransitionPage(child: ChatsScreen()), + pageBuilder: (context, state) { + final initialSearchQuery = state.extra is String ? state.extra as String : null; + return NoTransitionPage(child: ChatsScreen(initialSearchQuery: initialSearchQuery)); + }, ), GoRoute( path: RouteConstants.notificationsScreen, @@ -94,3 +101,33 @@ class MainNavigationRoutes { ]; } } + +/// Wraps the shell with [BottomNavScope] so descendants (e.g. notifications) +/// can switch tabs via [BottomNavController.switchToRoute] instead of pushing. +class _MainShellWithNavScope extends StatefulWidget { + const _MainShellWithNavScope({ + required this.child, + required this.currentLocation, + }); + + final Widget child; + final String currentLocation; + + @override + State<_MainShellWithNavScope> createState() => _MainShellWithNavScopeState(); +} + +class _MainShellWithNavScopeState extends State<_MainShellWithNavScope> { + final BottomNavController _navController = BottomNavController(); + + @override + Widget build(BuildContext context) { + return BottomNavScope( + controller: _navController, + child: BottomNavigationBarWidget( + currentLocation: widget.currentLocation, + child: widget.child, + ), + ); + } +} diff --git a/lib/core/routing/other_routes.dart b/lib/core/routing/other_routes.dart index 038474ca..1b03ce4c 100644 --- a/lib/core/routing/other_routes.dart +++ b/lib/core/routing/other_routes.dart @@ -71,16 +71,16 @@ class OtherRoutes { try { final profileRepository = sl(); final profile = await profileRepository.getMyProfile(); - + final firebaseApi = sl(); - await firebaseApi.registerPushToken(); + await firebaseApi.registerPushToken(); if (profile.emailVerifiedAt != null || (prefs.getBool(PrefsKeys.isEmailVerificationSkipped) ?? false)) { return RouteConstants.feedScreen; } return RouteConstants.verifyEmailScreen; } catch (e) { - return '/auth-screen'; + return '/auth-screen'; } } else { return '/auth-screen'; @@ -111,12 +111,12 @@ class OtherRoutes { // При любой ошибке пропускаем проверку версии return null; }); - + if (versions == null) { // Если проверка не удалась, пропускаем экран обновления return RouteConstants.welcomeScreen; } - + PackageInfo packageInfo = await PackageInfo.fromPlatform(); String version = packageInfo.version; Version currentVersion = Version.parse(version); @@ -153,22 +153,35 @@ class OtherRoutes { GoRoute( path: RecipeCreateScreen.path, name: RecipeCreateScreen.name, - builder: (context, state) => RecipeCreateScreen(recipe: state.extra != null ? state.extra as Recipe : null), + builder: (context, state) { + if (state.extra is Map) { + final data = state.extra as Map; + return RecipeCreateScreen( + recipe: data['recipe'] as Recipe?, + autoValidate: data['autoValidate'] as bool? ?? false, + ); + } + return RecipeCreateScreen(recipe: state.extra != null ? state.extra as Recipe : null); + }, ), GoRoute( path: CreateDietScreen.path, name: CreateDietScreen.name, builder: (context, state) { - final diet = state.extra as Diet?; - return CreateDietScreen(diet: diet); + final data = state.extra as Map?; + final diet = data?['diet'] as Diet?; + final isEditing = data?['isEditing'] as bool?; + return CreateDietScreen(diet: diet, isEditing: isEditing); }, ), GoRoute( path: DietPreviewScreen.path, name: DietPreviewScreen.name, builder: (context, state) { - final data = state.extra as Diet; - return DietPreviewScreen(diet: data); + final data = state.extra as Map; + final diet = data['diet'] as Diet; + final isImageInBase64 = data['isImageInBase64'] as bool; + return DietPreviewScreen(diet: diet, isImageInBase64: isImageInBase64); }, ), GoRoute(path: RouteConstants.createPlanScreen, name: 'createPlanScreen', builder: (context, state) => const CreatePlanScreen()), @@ -191,7 +204,7 @@ class OtherRoutes { GoRoute( path: '${RouteConstants.subsScreen}/:slug', name: 'subsScreen', - builder: (context, state) => SubsScreen(slug: state.pathParameters['slug']!, subType: state.extra as SubType), + builder: (context, state) => SubsScreen(slug: state.pathParameters['slug'], subType: state.extra as SubType), ), // Chat routes @@ -216,7 +229,12 @@ class OtherRoutes { GoRoute( path: RouteConstants.stepperBecomeConsultantScreen, name: 'stepperBecomeConsultantScreen', - builder: (context, state) => StepperBecomeConsultantScreen(slug: state.extra == null ? null : state.extra as String), + builder: (context, state) { + final data = state.extra as Map; + final slug = data['slug'] as String?; + final isCanceled = data['isCanceled'] as bool?; + return StepperBecomeConsultantScreen(slug: slug, isCanceled: isCanceled,); + }, ), GoRoute(path: RouteConstants.calendarScreen, name: 'calendarScreen', builder: (context, state) => const CalendarScreen()), GoRoute(path: RouteConstants.supportScreen, name: 'supportScreen', builder: (context, state) => const SupportScreen()), @@ -272,8 +290,7 @@ class OtherRoutes { builder: (context, state) { 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); }, ), GoRoute( diff --git a/lib/core/websockets/ws_manager.dart b/lib/core/websockets/ws_manager.dart index 3a711177..cf84354c 100644 --- a/lib/core/websockets/ws_manager.dart +++ b/lib/core/websockets/ws_manager.dart @@ -11,6 +11,7 @@ import 'package:eatvio_app/core/websockets/ws_service.dart'; import 'package:eatvio_app/core/token_storage.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart'; +import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart'; class WsManager { final WsService wsService; @@ -111,6 +112,7 @@ class WsManager { } }); _subscribe(); + await _connectCubitsToStreams(); } catch (e, st) { await Sentry.captureException(e, stackTrace: st); log(e.toString(), stackTrace: st); @@ -118,6 +120,20 @@ class WsManager { } } + /// Подключает ChatsCubit и NotificationsCubit к стримам сокета. + /// Вызывается после успешного initialize(), чтобы уведомления и чаты работали + /// и при входе уже залогиненного пользователя (редирект на feed), не только после экрана логина. + Future _connectCubitsToStreams() async { + try { + await sl().connectToWebSocket(); + await sl().getNotifications(); + await sl().connectToWebSocket(); + } catch (e, st) { + log('WsManager._connectCubitsToStreams: $e', stackTrace: st); + await Sentry.captureException(e, stackTrace: st); + } + } + Future _initPrivateNotificationsChannel() async { final token = await TokenStorage.getTokenFromStorage(); _privateNotificationChannel = client?.privateChannel( diff --git a/lib/core/widgets/info_row.dart b/lib/core/widgets/info_row.dart index 115dedc0..fbf0b8bf 100644 --- a/lib/core/widgets/info_row.dart +++ b/lib/core/widgets/info_row.dart @@ -12,15 +12,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; class InfoRow extends StatelessWidget { - const InfoRow({ - required this.type, - super.key, - this.diet, - this.itemsBackgroundColor, - this.disableDescriptionOfTypes = false, - this.plan, - this.recipe, - }); + const InfoRow({required this.type, super.key, this.diet, this.itemsBackgroundColor, this.disableDescriptionOfTypes = false, this.plan, this.recipe}); final ProductType type; final Color? itemsBackgroundColor; final Diet? diet; @@ -50,44 +42,13 @@ class InfoRow extends StatelessWidget { InfoBouble( backgroundColor: itemsBackgroundColor, children: [ - SvgPicture.asset( - 'assets/icons/time2.svg', - width: 13.3, - height: 13.3, - ), + SvgPicture.asset('assets/icons/time2.svg', width: 13.3, height: 13.3), const SizedBox(width: 4), - Text( - switch (type) { - ProductType.diet => '${diet?.duration} ${getDayForm(diet?.duration ?? 7, s)}', - ProductType.plan => '${plan?.duration} ${getDayForm(plan?.duration ?? 7, s)}', - ProductType.recipe => timeTitle ?? '', - }, - style: TextStyles.textXSSemibold.copyWith(color: appTheme.black), - ), - ], - ), - const SizedBox(width: 12), - InfoBouble( - backgroundColor: itemsBackgroundColor, - children: [ - SvgPicture.asset( - switch (type) { - ProductType.diet => 'assets/icons/people.svg', - ProductType.plan => 'assets/icons/people.svg', - ProductType.recipe => 'assets/icons/calories.svg', - }, - width: 13.3, - height: 13.3, - ), - const SizedBox(width: 4), - Text( - switch (type) { - ProductType.diet => '80 человек прошли', - ProductType.plan => '80 человек прошли', - ProductType.recipe => '${recipe?.calories.toStringAsFixed(0) ?? '0'} ккал', - }, - style: TextStyles.textXSSemibold.copyWith(color: appTheme.black), - ), + Text(switch (type) { + ProductType.diet => '${diet?.duration} ${getDayForm(diet?.duration ?? 7, s)}', + ProductType.plan => '${plan?.duration} ${getDayForm(plan?.duration ?? 7, s)}', + ProductType.recipe => timeTitle ?? '', + }, style: TextStyles.textXSSemibold.copyWith(color: appTheme.black)), ], ), const SizedBox(width: 12), @@ -95,11 +56,17 @@ class InfoRow extends StatelessWidget { InfoBouble( backgroundColor: itemsBackgroundColor, children: [ - SvgPicture.asset( - 'assets/icons/steps.svg', - width: 13.3, - height: 13.3, - ), + SvgPicture.asset('assets/icons/calories.svg', width: 13.3, height: 13.3), + const SizedBox(width: 4), + Text('${(recipe?.calories ?? 0).toStringAsFixed(0)} ккал', style: TextStyles.textXSSemibold.copyWith(color: appTheme.black)), + ], + ), + const SizedBox(width: 12), + if (type == ProductType.recipe) + InfoBouble( + backgroundColor: itemsBackgroundColor, + children: [ + SvgPicture.asset('assets/icons/steps.svg', width: 13.3, height: 13.3), const SizedBox(width: 4), Text( '${recipe?.steps?.length ?? 0} ${getStepForm(recipe?.steps?.length ?? 0)}', @@ -110,13 +77,7 @@ class InfoRow extends StatelessWidget { if (plan?.type.hasAtLeastOneType() == true || diet?.type.hasAtLeastOneType() == true) InfoBouble( backgroundColor: itemsBackgroundColor, - children: [ - TypeBadges( - type: diet?.type, - planType: plan?.type, - disableDescriptionOfTypes: disableDescriptionOfTypes, - ), - ], + children: [TypeBadges(type: diet?.type, planType: plan?.type, disableDescriptionOfTypes: disableDescriptionOfTypes)], ), ], ), diff --git a/lib/core/widgets/success_published_diet_bottom_sheet.dart b/lib/core/widgets/success_published_diet_bottom_sheet.dart index 223ed220..5b5625b7 100644 --- a/lib/core/widgets/success_published_diet_bottom_sheet.dart +++ b/lib/core/widgets/success_published_diet_bottom_sheet.dart @@ -8,32 +8,33 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; class SuccessPublishedDietOrPlanBottomSheet extends StatelessWidget { - const SuccessPublishedDietOrPlanBottomSheet({super.key, required this.title, this.isPlan = false}); + const SuccessPublishedDietOrPlanBottomSheet({super.key, required this.title, this.isPlan = false, this.isDrafted, this.isUpdated}); final String title; final bool isPlan; + final bool? isDrafted; + final bool? isUpdated; @override Widget build(BuildContext context) { final s = S.of(context); + final String text; + if (isDrafted == true) { + text = 'cохранена в черновиках'; + } else if (isUpdated == true) { + text = 'успешно обновлена'; + } else if (isPlan == true) { + text = 'опубликован'; + } else { + text = 'опубликованна'; + } + return Column( children: [ - const SizedBox( - height: 20, - ), + const SizedBox(height: 20), const BottomSheetHandlerBar(), - const SizedBox( - height: 20, - ), - Text( - title, - style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black), - ), - Text( - isPlan ? 'опубликован' : 'опубликованна', - style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black), - ), - const SizedBox( - height: 20, - ), + const SizedBox(height: 20), + Text(title, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)), + Text(text, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)), + const SizedBox(height: 20), Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: CustomOutlinedButton( @@ -43,15 +44,10 @@ class SuccessPublishedDietOrPlanBottomSheet extends StatelessWidget { text: s.ok, height: 52, buttonTextStyle: TextStyles.textSSemibold.copyWith(color: appTheme.whiteA700), - buttonStyle: ButtonStyle( - side: const WidgetStatePropertyAll(BorderSide.none), - backgroundColor: WidgetStatePropertyAll(appTheme.blueA400), - ), + buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)), ), ), - const SizedBox( - height: 20, - ), + const SizedBox(height: 20), ], ); } diff --git a/lib/features/auth/presentation/bloc/oauth_cubit/oauth_cubit.dart b/lib/features/auth/presentation/bloc/oauth_cubit/oauth_cubit.dart index daf156a8..a066c959 100644 --- a/lib/features/auth/presentation/bloc/oauth_cubit/oauth_cubit.dart +++ b/lib/features/auth/presentation/bloc/oauth_cubit/oauth_cubit.dart @@ -36,9 +36,29 @@ class OAuthCubit extends Cubit with RegistrationDateCacheMixin { final emailErrors = errors?['email'] as List?; final phoneErrors = errors?['phone'] as List?; - if ((emailErrors != null && emailErrors.isNotEmpty) || (phoneErrors != null && phoneErrors.isNotEmpty)) { - // Это ошибка валидации - нужно показать экран подтверждения - emit(const OAuthState.error(errorMessage: 'validation_error')); + final errorsList = []; + if (emailErrors != null && emailErrors.isNotEmpty) { + for (final error in emailErrors) { + final errorStr = error.toString(); + if (errorStr.contains('Некорректный формат email')) { + errorsList.add(RegistrationValidationErrors.incorrectEmailFormat); + } + if (errorStr.contains('почта уже существует') || errorStr.contains('Эл. почта уже существует')) { + errorsList.add(RegistrationValidationErrors.emailExist); + } + } + } + if (phoneErrors != null && phoneErrors.isNotEmpty) { + for (final error in phoneErrors) { + final errorStr = error.toString(); + if (errorStr.contains('phone уже существует') || errorStr.contains('телефон уже существует')) { + errorsList.add(RegistrationValidationErrors.phoneExist); + } + } + } + + if (errorsList.isNotEmpty) { + emit(OAuthState.error(errorMessage: 'validation_error', registrationValidationErrors: errorsList)); } else { emit(OAuthState.error(errorMessage: e.toString())); } @@ -126,7 +146,7 @@ class OAuthCubit extends Cubit with RegistrationDateCacheMixin { try { emit(const OAuthState.loading()); log('OAuthCubit: Requesting verification code for email: $email, phone: $phone'); - final response = await authRepository.requestOAuthVerification(email: null, phone: phone); + final response = await authRepository.requestOAuthVerification(email: email, phone: phone); if (response != null) { log('OAuthCubit: Verification code requested successfully, masked email: ${response.email}'); } @@ -188,4 +208,3 @@ class OAuthCubit extends Cubit with RegistrationDateCacheMixin { } } -enum RegistrationValidationErrors { incorrectEmailFormat, emailExist, phoneExist, incorrectDateOfBirthFormat } diff --git a/lib/features/auth/presentation/bloc/oauth_cubit/oauth_state.dart b/lib/features/auth/presentation/bloc/oauth_cubit/oauth_state.dart index 7da9ab7c..517222de 100644 --- a/lib/features/auth/presentation/bloc/oauth_cubit/oauth_state.dart +++ b/lib/features/auth/presentation/bloc/oauth_cubit/oauth_state.dart @@ -10,5 +10,10 @@ abstract class OAuthState with _$OAuthState { const factory OAuthState.loaded({required String token}) = OAuthStateLoaded; - const factory OAuthState.error({required String errorMessage}) = OAuthStateError; + const factory OAuthState.error({ + required String errorMessage, + List? registrationValidationErrors, + }) = OAuthStateError; } + +enum RegistrationValidationErrors { incorrectEmailFormat, emailExist, phoneExist, incorrectDateOfBirthFormat } diff --git a/lib/features/auth/presentation/pages/auth_screen.dart b/lib/features/auth/presentation/pages/auth_screen.dart index bf9074d9..d1c1ccfd 100644 --- a/lib/features/auth/presentation/pages/auth_screen.dart +++ b/lib/features/auth/presentation/pages/auth_screen.dart @@ -21,7 +21,6 @@ import 'package:eatvio_app/core/di/cubit_registrations.dart'; import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart'; import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart'; import 'package:eatvio_app/features/dev_tools/presentation/dev_tools_screen.dart'; -import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/gestures.dart'; @@ -80,9 +79,6 @@ class _AuthScreenState extends State { try { await sl().getMyProfile(); await sl().getChatRoomsWithLoading(); - await sl().connectToWebSocket(); - await sl().getNotifications(); - await sl().connectToWebSocket(); await sl().registerPushToken(); showSuccessToast('Успешный вход'); } catch (e) { @@ -213,9 +209,6 @@ class _AuthScreenState extends State { await sl().initialize(); await sl().getMyProfile(); await sl().getChatRoomsWithLoading(); - await sl().connectToWebSocket(); - await sl().getNotifications(); - await sl().connectToWebSocket(); await sl().registerPushToken(); showSuccessToast('Успешный вход'); } catch (e) { diff --git a/lib/features/auth/presentation/pages/register_via_social_screen.dart b/lib/features/auth/presentation/pages/register_via_social_screen.dart index 1b27237f..1ea307f0 100644 --- a/lib/features/auth/presentation/pages/register_via_social_screen.dart +++ b/lib/features/auth/presentation/pages/register_via_social_screen.dart @@ -13,7 +13,6 @@ import 'package:eatvio_app/core/websockets/ws_manager.dart'; import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart'; import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart'; import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart'; -import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart'; import 'package:eatvio_app/core/firebase_api.dart'; import 'package:eatvio_app/core/utils/toasts.dart'; import 'package:eatvio_app/core/utils/validation_utils.dart'; @@ -74,9 +73,6 @@ class _RegisterViaSocialScreenState extends State { try { await sl().getMyProfile(); await sl().getChatRoomsWithLoading(); - await sl().connectToWebSocket(); - await sl().getNotifications(); - await sl().connectToWebSocket(); await sl().registerPushToken(); showSuccessToast('Успешная регистрация'); } catch (e) { 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 723f95e9..4b90fe70 100644 --- a/lib/features/auth/presentation/pages/scan_qr_code_screen.dart +++ b/lib/features/auth/presentation/pages/scan_qr_code_screen.dart @@ -10,7 +10,6 @@ import 'package:eatvio_app/core/utils/toasts.dart'; import 'package:eatvio_app/core/websockets/ws_manager.dart'; import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart'; import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart'; -import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:qr_code_scanner_plus/qr_code_scanner_plus.dart'; @@ -76,9 +75,6 @@ class _ScanQRCodeScreenState extends State { try { await sl().getMyProfile(); await sl().getChatRoomsWithLoading(); - await sl().connectToWebSocket(); - await sl().getNotifications(); - await sl().connectToWebSocket(); await sl().registerPushToken(); } catch (e) { rethrow; diff --git a/lib/features/blog_post/presentation/pages/blog_post_screen.dart b/lib/features/blog_post/presentation/pages/blog_post_screen.dart index 70593f8c..bf50518a 100644 --- a/lib/features/blog_post/presentation/pages/blog_post_screen.dart +++ b/lib/features/blog_post/presentation/pages/blog_post_screen.dart @@ -21,8 +21,9 @@ import 'package:share_plus/share_plus.dart'; class BlogPostScreen extends StatefulWidget { final int id; + final int? highlightedCommentId; - const BlogPostScreen({super.key, required this.id}); + const BlogPostScreen({super.key, required this.id, this.highlightedCommentId}); @override State createState() => _BlogPostScreenState(); @@ -303,7 +304,13 @@ class _BlogPostScreenState extends State { const SizedBox(height: 16), if (state is CommentsStateLoading) const Loader(), if (state is CommentsStateLoaded) - CommentsWidget(id: widget.id.toString(), commentSourceType: CommentSourceType.blogPost, comment: state.comment, replyId: replyId), + CommentsWidget( + id: widget.id.toString(), + commentSourceType: CommentSourceType.blogPost, + comment: state.comment, + replyId: replyId, + highlightedCommentId: widget.highlightedCommentId, + ), ], ), ), diff --git a/lib/features/blog_post_create/presentation/pages/blog_post_create_screen.dart b/lib/features/blog_post_create/presentation/pages/blog_post_create_screen.dart index 0254d3c7..6da09b9e 100644 --- a/lib/features/blog_post_create/presentation/pages/blog_post_create_screen.dart +++ b/lib/features/blog_post_create/presentation/pages/blog_post_create_screen.dart @@ -568,6 +568,7 @@ class _BlogPostCreateScreenState extends State { CustomTextFormField(key: AppKeys.blogPostCreateScreenVideoLinkTextField, controller: videoUrlController), const SizedBox(height: 80), _createBlogPostButton(), + const SizedBox(height: 80), ], ), ), diff --git a/lib/features/bottom_navigation_bar/presentation/widgets/bottom_nav_scope.dart b/lib/features/bottom_navigation_bar/presentation/widgets/bottom_nav_scope.dart new file mode 100644 index 00000000..53ea8741 --- /dev/null +++ b/lib/features/bottom_navigation_bar/presentation/widgets/bottom_nav_scope.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; + +class BottomNavController { + BottomNavController(); + + void Function(String path, Object? extra)? _onSwitchToRoute; + + void register(void Function(String path, Object? extra) callback) { + _onSwitchToRoute = callback; + } + + void switchToRoute(String path, [Object? extra]) { + _onSwitchToRoute?.call(path, extra); + } +} + +class BottomNavScope extends InheritedWidget { + const BottomNavScope({ + required this.controller, + required super.child, + super.key, + }); + + final BottomNavController controller; + + static BottomNavController? maybeOf(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType()?.controller; + } + + static BottomNavController of(BuildContext context) { + final controller = maybeOf(context); + assert(controller != null, 'No BottomNavScope found in context'); + return controller!; + } + + @override + bool updateShouldNotify(BottomNavScope oldWidget) => controller != oldWidget.controller; +} diff --git a/lib/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget.dart b/lib/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget.dart index f177d57a..207b348a 100644 --- a/lib/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget.dart +++ b/lib/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget.dart @@ -1,5 +1,6 @@ import 'package:badges/badges.dart' as badges; import 'package:eatvio_app/core/app_config.dart'; +import 'package:eatvio_app/core/env_config.dart'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/utils/image_constant.dart'; import 'package:eatvio_app/features/dev_tools/presentation/dev_tools_screen.dart'; @@ -7,6 +8,7 @@ import 'package:eatvio_app/features/notifications/presentation/notifications_cub import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_state.dart'; import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart'; import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_state.dart'; +import 'package:eatvio_app/features/bottom_navigation_bar/presentation/widgets/bottom_nav_scope.dart'; import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart'; import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_state.dart'; import 'package:eatvio_app/generated/l10n.dart'; @@ -17,13 +19,16 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:go_router/go_router.dart'; class BottomNavigationBarWidget extends StatefulWidget { - final Widget child; - const BottomNavigationBarWidget({ super.key, required this.child, + required this.currentLocation, }); + final Widget child; + /// Текущий путь из GoRouter (matchedLocation), чтобы подсветка таба совпадала с экраном при навигации через go() (диплинки, пуши). + final String currentLocation; + @override State createState() => _BottomNavigationBarWidgetState(); } @@ -35,24 +40,9 @@ class _BottomNavigationBarWidgetState extends State { List _getPages(bool isConsultant) { if (isConsultant) { - return [ - '/my-profile-screen', - '/feed-screen', - '/diary-screen', - '/consultations-screen', - '/chat-select-screen', - '/notifications-screen', - '/menu-screen', - ]; + return ['/my-profile-screen', '/feed-screen', '/diary-screen', '/consultations-screen', '/chat-select-screen', '/notifications-screen', '/menu-screen']; } else { - return [ - '/my-profile-screen', - '/feed-screen', - '/diary-screen', - '/chat-select-screen', - '/notifications-screen', - '/menu-screen', - ]; + return ['/my-profile-screen', '/feed-screen', '/diary-screen', '/chat-select-screen', '/notifications-screen', '/menu-screen']; } } @@ -83,6 +73,67 @@ class _BottomNavigationBarWidgetState extends State { } } + /// Switch to the tab that corresponds to [path], optionally with [extra] + /// (e.g. date for diary). If [path] is not in the current tab list, falls back to push. + void _switchToRoute(String path, [Object? extra]) { + if (pages == null) return; + final index = pages!.indexOf(path); + if (index >= 0 && index != currentBottomNavBarIndex) { + setState(() { + currentBottomNavBarIndex = index; + }); + GoRouter.of(context).replace(path, extra: extra); + } else if (index >= 0) { + // Same tab but with new extra (e.g. diary with date) + GoRouter.of(context).replace(path, extra: extra); + } else { + GoRouter.of(context).push(path, extra: extra); + } + } + + static const Color _devAccentColor = Color(0xFFF59E0B); // amber-500 + + Widget _buildLogoIcon() { + final isProduction = sl().baseUrl == EnvConfig.baseUrl; + final logo = SvgPicture.asset( + ImageConstant.imgLogo, + height: 32, + colorFilter: isProduction ? null : const ColorFilter.mode(_devAccentColor, BlendMode.srcIn), + ); + if (isProduction) return logo; + return Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: _devAccentColor.withValues(alpha: 0.6), width: 1.5), + color: _devAccentColor.withValues(alpha: 0.08), + ), + child: logo, + ), + Positioned( + top: -6, + right: -4, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + decoration: BoxDecoration( + color: _devAccentColor, + borderRadius: BorderRadius.circular(4), + boxShadow: [BoxShadow(color: _devAccentColor.withValues(alpha: 0.4), blurRadius: 4, offset: const Offset(0, 1))], + ), + child: const Text( + 'DEV', + style: TextStyle(fontSize: 8, fontWeight: FontWeight.w700, color: Colors.white, letterSpacing: 0.3, height: 1.0), + ), + ), + ), + ], + ); + } + @override Widget build(BuildContext context) { final s = S.of(context); @@ -91,10 +142,26 @@ class _BottomNavigationBarWidgetState extends State { if (state is ProfileAvatarStateLoaded) { final profileModel = state.profile; final isConsultant = profileModel.roles.contains('consultant'); - + // Инициализируем pages на основе profileModel pages ??= _getPages(isConsultant); - + + // Expose tab switching to descendants (e.g. notification widget) + final controller = BottomNavScope.maybeOf(context); + if (controller != null) { + controller.register(_switchToRoute); + } + + // Подсветка таба по текущему маршруту (совпадает при навигации через go(), в т.ч. диплинки/пуши). + final pathWithoutQuery = widget.currentLocation.split('?').first; + final indexFromRoute = pages!.indexOf(pathWithoutQuery); + final effectiveIndex = indexFromRoute >= 0 ? indexFromRoute : currentBottomNavBarIndex; + if (indexFromRoute >= 0 && indexFromRoute != currentBottomNavBarIndex) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() => currentBottomNavBarIndex = indexFromRoute); + }); + } + return Scaffold( body: widget.child, bottomNavigationBar: BottomNavigationBar( @@ -103,7 +170,7 @@ class _BottomNavigationBarWidgetState extends State { selectedFontSize: 10, unselectedFontSize: 10, selectedItemColor: appTheme.blueA400, - currentIndex: currentBottomNavBarIndex, + currentIndex: effectiveIndex, items: [ BottomNavigationBarItem( label: '', @@ -117,52 +184,26 @@ class _BottomNavigationBarWidgetState extends State { ), BottomNavigationBarItem( label: 'Лента', - icon: Padding( - padding: const EdgeInsets.only(bottom: 2), - child: SvgPicture.asset( - colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn), - width: 20, - height: 20, - ImageConstant.icnFeed, - ), - ), - activeIcon: Padding( - padding: const EdgeInsets.only(bottom: 2), - child: SvgPicture.asset( - ImageConstant.icnFeed, - width: 20, - height: 20, - colorFilter: ColorFilter.mode( - appTheme.blueA400, - BlendMode.srcIn, - ), - ), - ), + icon: Padding( + padding: const EdgeInsets.only(bottom: 2), + child: SvgPicture.asset(colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn), width: 20, height: 20, ImageConstant.icnFeed), + ), + activeIcon: Padding( + padding: const EdgeInsets.only(bottom: 2), + child: SvgPicture.asset(ImageConstant.icnFeed, width: 20, height: 20, colorFilter: ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn)), + ), ), BottomNavigationBarItem( label: s.diary, - icon: Padding( - padding: const EdgeInsets.only(bottom: 2), - child: SvgPicture.asset( - colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn), - ImageConstant.icnDiary, - width: 20, - height: 20, - ), - ), - activeIcon: Padding( - padding: const EdgeInsets.only(bottom: 2), - child: SvgPicture.asset( - ImageConstant.icnDiary, - width: 20, - height: 20, - colorFilter: ColorFilter.mode( - appTheme.blueA400, - BlendMode.srcIn, + icon: Padding( + padding: const EdgeInsets.only(bottom: 2), + child: SvgPicture.asset(colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn), ImageConstant.icnDiary, width: 20, height: 20), + ), + activeIcon: Padding( + padding: const EdgeInsets.only(bottom: 2), + child: SvgPicture.asset(ImageConstant.icnDiary, width: 20, height: 20, colorFilter: ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn)), + ), ), - ), - ), - ), if (profileModel.roles.contains('consultant')) BottomNavigationBarItem( label: 'Консультации', @@ -181,97 +222,69 @@ class _BottomNavigationBarWidgetState extends State { 'assets/icons/consultations.svg', width: 20, height: 20, - colorFilter: ColorFilter.mode( - appTheme.blueA400, - BlendMode.srcIn, - ), - ), - ), - ), + colorFilter: ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn), + ), + ), + ), BottomNavigationBarItem( label: s.chat, - icon: BlocBuilder( - builder: (BuildContext context, ChatsState state) { - final hasUnreadMessages = state.chatsWithUnreadMessagesCount > 0; - return badges.Badge( - position: badges.BadgePosition.topEnd(), - badgeAnimation: const badges.BadgeAnimation.fade(), - badgeStyle: badges.BadgeStyle( - badgeColor: hasUnreadMessages ? Colors.red : Colors.transparent, + icon: BlocBuilder( + builder: (BuildContext context, ChatsState state) { + final hasUnreadMessages = state.chatsWithUnreadMessagesCount > 0; + return badges.Badge( + position: badges.BadgePosition.topEnd(), + badgeAnimation: const badges.BadgeAnimation.fade(), + badgeStyle: badges.BadgeStyle(badgeColor: hasUnreadMessages ? Colors.red : Colors.transparent), + badgeContent: Text( + hasUnreadMessages ? state.chatsWithUnreadMessagesCount.toString() : '', + style: TextStyle(color: hasUnreadMessages ? Colors.white : Colors.transparent, fontSize: 8), + ), + child: Padding( + padding: const EdgeInsets.only(bottom: 2), + child: SvgPicture.asset( + ImageConstant.icnChat, + width: 20, + height: 20, + colorFilter: pages![effectiveIndex] == '/chat-select-screen' + ? ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn) + : const ColorFilter.mode(Colors.grey, BlendMode.srcIn), + ), + ), + ); + }, ), - badgeContent: Text( - hasUnreadMessages ? state.chatsWithUnreadMessagesCount.toString() : '', - style: TextStyle( - color: hasUnreadMessages ? Colors.white : Colors.transparent, - fontSize: 8, - ), - ), - child: Padding( - padding: const EdgeInsets.only(bottom: 2), - child: SvgPicture.asset( - ImageConstant.icnChat, - width: 20, - height: 20, - colorFilter: pages![currentBottomNavBarIndex] == '/chat-select-screen' - ? ColorFilter.mode( - appTheme.blueA400, - BlendMode.srcIn, - ) - : const ColorFilter.mode(Colors.grey, BlendMode.srcIn), - ), - ), - ); - }, - ), ), BottomNavigationBarItem( label: 'Уведомления', - icon: BlocBuilder( - builder: (BuildContext context, NotificationsState state) { - return badges.Badge( - position: badges.BadgePosition.topEnd(), - badgeAnimation: const badges.BadgeAnimation.fade(), - showBadge: state is NotificationsStateLoaded && state.hasUnreadNotifications, - badgeContent: const Text( - '', - style: TextStyle( - color: Colors.white, - fontSize: 8, - ), - ), - child: Padding( - padding: const EdgeInsets.only(bottom: 2), - child: SvgPicture.asset( - ImageConstant.icnBell, - colorFilter: pages![currentBottomNavBarIndex] == '/notifications-screen' - ? ColorFilter.mode( - appTheme.blueA400, - BlendMode.srcIn, - ) - : const ColorFilter.mode(Colors.grey, BlendMode.srcIn), - width: 20, - height: 20, - ), - ), - ); - }, - ), - ), - BottomNavigationBarItem( - label: '', - icon: SvgPicture.asset( - ImageConstant.imgLogo, - height: 32, + icon: BlocBuilder( + builder: (BuildContext context, NotificationsState state) { + return badges.Badge( + position: badges.BadgePosition.topEnd(), + badgeAnimation: const badges.BadgeAnimation.fade(), + showBadge: state is NotificationsStateLoaded && state.hasUnreadNotifications, + badgeContent: const Text('', style: TextStyle(color: Colors.white, fontSize: 8)), + child: Padding( + padding: const EdgeInsets.only(bottom: 2), + child: SvgPicture.asset( + ImageConstant.icnBell, + colorFilter: pages![effectiveIndex] == '/notifications-screen' + ? ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn) + : const ColorFilter.mode(Colors.grey, BlendMode.srcIn), + width: 20, + height: 20, + ), + ), + ); + }, ), ), + BottomNavigationBarItem(label: '', icon: _buildLogoIcon()), ], ), ); } // Показываем child даже когда профиль еще загружается, чтобы экраны могли инициализироваться - return Scaffold( - body: widget.child, - ); + return Scaffold(body: widget.child); }, ); } diff --git a/lib/features/chat/presentation/pages/chats_screen.dart b/lib/features/chat/presentation/pages/chats_screen.dart index 539e971c..993bcfd3 100644 --- a/lib/features/chat/presentation/pages/chats_screen.dart +++ b/lib/features/chat/presentation/pages/chats_screen.dart @@ -11,7 +11,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class ChatsScreen extends StatefulWidget { - const ChatsScreen({super.key}); + const ChatsScreen({super.key, this.initialSearchQuery}); + + /// When set (e.g. from notification tap), the search field is pre-filled + /// and chat list is filtered by this query. + final String? initialSearchQuery; @override State createState() => _ChatsScreenState(); @@ -23,12 +27,17 @@ class _ChatsScreenState extends State { @override void initState() { super.initState(); - // Инициализация ChatsCubit при первом открытии экрана + final query = widget.initialSearchQuery?.trim(); + if (query != null && query.isNotEmpty) { + searchController.text = query; + } WidgetsBinding.instance.addPostFrameCallback((_) { final cubit = context.read(); final state = cubit.state; if (state.state == BaseState.initial) { - cubit.getChatRoomsWithLoading(); + cubit.getChatRoomsWithLoading(query: query?.isNotEmpty == true ? query : null); + } else if (query != null && query.isNotEmpty) { + cubit.getChatRooms(query: query); } }); } diff --git a/lib/features/chat/presentation/widgets/about_order_button.dart b/lib/features/chat/presentation/widgets/about_order_button.dart index f32ac3f3..26fe8b86 100644 --- a/lib/features/chat/presentation/widgets/about_order_button.dart +++ b/lib/features/chat/presentation/widgets/about_order_button.dart @@ -14,7 +14,7 @@ class AboutOrderButton extends StatelessWidget { child: Container( height: 44, decoration: BoxDecoration( - color: appTheme.gray50, + color: const Color(0xFFFFFDD8), borderRadius: BorderRadius.circular(20), ), child: Row( diff --git a/lib/features/chat/presentation/widgets/chose_plan_date_widget.dart b/lib/features/chat/presentation/widgets/chose_plan_date_widget.dart index 809422c1..5b385a5c 100644 --- a/lib/features/chat/presentation/widgets/chose_plan_date_widget.dart +++ b/lib/features/chat/presentation/widgets/chose_plan_date_widget.dart @@ -29,7 +29,7 @@ class _ChosePlanDateWidget extends State { if (widget.hasPersonalDiet) { startDate = [DateTime.now().add(const Duration(days: 2))]; } else { - startDate = [DateTime.now()]; + startDate = [DateTime.now().add(const Duration(days: 1))]; } } diff --git a/lib/features/chat/presentation/widgets/notification_banner.dart b/lib/features/chat/presentation/widgets/notification_banner.dart index 3a8203f9..c2a8069b 100644 --- a/lib/features/chat/presentation/widgets/notification_banner.dart +++ b/lib/features/chat/presentation/widgets/notification_banner.dart @@ -36,7 +36,7 @@ class _NotificationBannerState extends State { final s = S.of(context); return Container( decoration: BoxDecoration( - color: appTheme.gray50, + color: const Color(0xFFFFFDD8), borderRadius: BorderRadius.circular(20), ), child: Column( diff --git a/lib/features/consultant/become_consultant/models/consultant_cert_photo_model.dart b/lib/features/consultant/become_consultant/models/consultant_cert_photo_model.dart index 6fdcc1e8..aaec9b68 100644 --- a/lib/features/consultant/become_consultant/models/consultant_cert_photo_model.dart +++ b/lib/features/consultant/become_consultant/models/consultant_cert_photo_model.dart @@ -22,6 +22,9 @@ abstract class ConsultantCertPhotoModel with _$ConsultantCertPhotoModel { static const String visibleGroup = 'user_certs'; static const String hiddenGroup = 'hidden_user_certs'; + static const String pendingGroup = 'pending_user_certs'; bool get isHidden => group == hiddenGroup; + + bool get isActive => group == visibleGroup || group == hiddenGroup; } diff --git a/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart b/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart index 5d69fb96..6cc5e417 100644 --- a/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart +++ b/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart @@ -1,6 +1,5 @@ import 'dart:io'; import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart'; -import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart'; import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart'; @@ -16,13 +15,14 @@ class RegisterConsultantCubit extends Cubit { consultant: ConsultantProfileModel(socialLinks: SocialLinksModel(), hasMedicalEducation: true), loading: true, acceptOffer: false, + isUpdating: false, ), ); Future getMyRequestStatus(String slug) async { _update = true; final myRequestStatus = await consultantRepository.getMyRequestStatus(slug); - final certPhotos = await _fetchVisibleCertPhotos(); + final certPhotos = await consultantRepository.getConsultantCertPhotos(); List? certificates = myRequestStatus.educationModel.completedCourses?.map((courseMap) { final course = courseMap.values.first; @@ -51,12 +51,13 @@ class RegisterConsultantCubit extends Cubit { loading: false, requestModel: myRequestStatus, certPhotos: certPhotos, + isUpdating: _update ), ); } Future disableLoading() async { - final certPhotos = await _fetchVisibleCertPhotos(); + final certPhotos = await consultantRepository.getConsultantCertPhotos(); emit(state.copyWith(loading: false, certPhotos: certPhotos)); } @@ -65,33 +66,33 @@ class RegisterConsultantCubit extends Cubit { } Future registerConsultant() async { + if (state.newAddedPhotos.isNotEmpty) { + await consultantRepository.storeConsultantCertPhotos(files: state.newAddedPhotos); + } + if (state.deletedPhotoIds.isNotEmpty) { + for (final int id in state.deletedPhotoIds) { + await consultantRepository.deletePhoto(id); + } + } await consultantRepository.registerConsultant(consultant: state.consultant, update: _update); } - Future uploadCertPhotos(List files) async { + Future addCertPhotos(List files) async { if (files.isEmpty) { return; } final compressedFiles = await ImageCompressUtility.compressFilesList(files); - final uploaded = await consultantRepository.storeConsultantCertPhotos(files: compressedFiles); - emit(state.copyWith(certPhotos: _onlyVisible(uploaded))); + + emit(state.copyWith(newAddedPhotos: [...state.newAddedPhotos, ...compressedFiles])); } - Future removeCertPhoto(int id) async { - await consultantRepository.hideConsultantCertPhoto(id); - emit(state.copyWith(certPhotos: state.certPhotos.where((photo) => photo.id != id).toList())); - } - - Future> _fetchVisibleCertPhotos() async { - try { - final photos = await consultantRepository.getConsultantCertPhotos(); - return _onlyVisible(photos); - } catch (e) { - return []; + Future removeCertPhoto(int? id, String? path) async { + if (id != null) { + emit(state.copyWith(deletedPhotoIds: [...state.deletedPhotoIds, id])); + return; + } + if (path != null) { + emit(state.copyWith(newAddedPhotos: state.newAddedPhotos.where((file) => file.uri.path != path).toList())); } } - - List _onlyVisible(List photos) { - return photos.where((photo) => !photo.isHidden).toList(); - } } diff --git a/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart b/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart index ee6644a9..45baa2c6 100644 --- a/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart +++ b/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart'; @@ -11,7 +13,10 @@ abstract class RegisterConsultantState with _$RegisterConsultantState { required ConsultantProfileModel consultant, required bool acceptOffer, required bool loading, + required bool isUpdating, ConsultationRequestModel? requestModel, @Default([]) List certPhotos, + @Default([]) List newAddedPhotos, + @Default([]) List deletedPhotoIds, }) = _RegisterConsultantState; } diff --git a/lib/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen.dart index 4ad6db76..cf21b646 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen.dart @@ -33,7 +33,9 @@ class _PhotoOrScansScreenState extends State { const SizedBox(height: 16), BlocBuilder( builder: (context, state) { - final photos = state.certPhotos; + final photos = state.certPhotos.where((photo) => !state.deletedPhotoIds.contains(photo.id)).toList(); + final addedPhotos = state.newAddedPhotos; + final totalPhotosCount = photos.length + addedPhotos.length; return Column( children: [ if (isUploading) ...[const LinearProgressIndicator(), const SizedBox(height: 16)], @@ -47,7 +49,7 @@ class _PhotoOrScansScreenState extends State { mainAxisSpacing: 8, ), itemBuilder: (BuildContext context, int index) { - if (index == photos.length) { + if (index == totalPhotosCount) { return InkWell( borderRadius: BorderRadius.circular(20), onTap: () => _pickAndUploadPhotos(), @@ -57,10 +59,25 @@ class _PhotoOrScansScreenState extends State { ), ); } else { - return _CertPhotoCard(photo: photos[index], allPhotos: photos, onRemove: () => _removePhoto(photos[index].id)); + if (index < photos.length) { + final photo = photos[index]; + return _CertPhotoCard( + photo: photo, + allPhotos: photos, + onRemove: () => _removePhoto(id: photo.id), + ); + } else { + final localIndex = index - photos.length; + final localPhoto = addedPhotos[localIndex]; + return _LocalCertPhotoCard( + photo: localPhoto, + allPhotos: addedPhotos, + onRemove: () => _removePhoto(path: localPhoto.uri.path), + ); + } } }, - itemCount: photos.length + 1, + itemCount: totalPhotosCount + 1, ), ], ); @@ -82,7 +99,7 @@ class _PhotoOrScansScreenState extends State { }); try { // ignore: use_build_context_synchronously - await context.read().uploadCertPhotos(newImages.map((file) => File(file.path)).toList()); + await context.read().addCertPhotos(newImages.map((file) => File(file.path)).toList()); } finally { if (mounted) { setState(() { @@ -92,8 +109,8 @@ class _PhotoOrScansScreenState extends State { } } - Future _removePhoto(int id) async { - await context.read().removeCertPhoto(id); + Future _removePhoto({int? id, String? path}) async { + await context.read().removeCertPhoto(id, path); } } @@ -147,3 +164,61 @@ class _CertPhotoCard extends StatelessWidget { ); } } + +class _LocalCertPhotoCard extends StatelessWidget { + final File photo; + final List allPhotos; + final VoidCallback onRemove; + + const _LocalCertPhotoCard({required this.photo, required this.allPhotos, required this.onRemove}); + + @override + Widget build(BuildContext context) { + return Hero( + tag: photo.path, + child: Material( + borderRadius: BorderRadius.circular(20), + child: InkWell( + onTap: () { + context.pushTransparentRoute( + rootNavigator: true, + PhotoViewScreen( + imageUrls: allPhotos, + isLocal: true, + index: allPhotos.indexOf(photo), + ), + ); + }, + borderRadius: BorderRadius.circular(20), + child: Stack( + clipBehavior: Clip.none, + children: [ + Ink( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(20)), + image: DecorationImage( + fit: BoxFit.cover, + image: FileImage(photo), + ), + ), + ), + Positioned( + right: -12, + top: -12, + child: InkWell( + onTap: onRemove, + customBorder: const CircleBorder(), + child: Ink( + padding: const EdgeInsets.all(4), + decoration: const BoxDecoration(color: Color(0xFFF1F2F6), shape: BoxShape.circle), + child: const Icon(size: 20, Icons.close, color: Colors.black), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart index 494ffc36..515728c1 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart @@ -1,6 +1,5 @@ import 'package:eatvio_app/core/utils/image_constant.dart'; import 'package:eatvio_app/core/widgets/loader.dart'; -import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/additional_settings_screen.dart'; @@ -19,8 +18,8 @@ import 'package:lottie/lottie.dart'; class StepperBecomeConsultantScreen extends StatefulWidget { final String? slug; - - const StepperBecomeConsultantScreen({super.key, this.slug}); + final bool? isCanceled; + const StepperBecomeConsultantScreen({super.key, this.slug, this.isCanceled}); @override State createState() => _StepperBecomeConsultantScreenState(); @@ -63,25 +62,17 @@ class _StepperBecomeConsultantScreenState extends State Navigator.pop(context), - child: const Text('OK'), - ), - ], + actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))], ), ); }, @@ -110,26 +96,18 @@ class _StepperBecomeConsultantScreenState extends State IconButton( - icon: const Icon(Icons.close_rounded), - onPressed: () => GoRouter.of(context).pop(), - ), + 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 из 6'), - ), - ], + title: SvgPicture.asset(ImageConstant.imgLogoEatvio, height: 48, fit: BoxFit.contain), + actions: [Padding(padding: const EdgeInsets.only(right: 16), child: Text('шаг $step из 6'))], bottom: PreferredSize( preferredSize: Size(MediaQuery.of(context).size.width, 32), child: Padding( @@ -312,10 +251,7 @@ class _StepperBecomeConsultantScreenState extends State( duration: const Duration(milliseconds: 350), curve: Curves.easeInOut, - tween: Tween( - begin: 1 / 6, - end: progressValue, - ), + tween: Tween(begin: 1 / 6, end: progressValue), builder: (context, value, _) => LinearProgressIndicator( value: value, borderRadius: BorderRadius.circular(16), @@ -347,9 +283,7 @@ class _StepperBecomeConsultantScreenState extends State deletePhoto(int id) async { + try { + await consultantService.deletePhoto(id: id); + } catch (e, s) { + await Sentry.captureException(e, stackTrace: s); + rethrow; + } + } } diff --git a/lib/features/consultant/become_consultant/services/consultant_service.dart b/lib/features/consultant/become_consultant/services/consultant_service.dart index d7171b8e..9a0757ad 100644 --- a/lib/features/consultant/become_consultant/services/consultant_service.dart +++ b/lib/features/consultant/become_consultant/services/consultant_service.dart @@ -40,4 +40,7 @@ abstract class ConsultantService { @POST('/api/v2/consultant_request/cert_photos/un_hide') Future unHideConsultantCertPhoto({@Body() required Map payload}); + + @POST('/api/v2/consultant_request/cert_photos/{id}/queue-for-deletion') + Future deletePhoto({@Path() required int id}); } diff --git a/lib/features/consultations/presentation/widgets/consultation_card_widget.dart b/lib/features/consultations/presentation/widgets/consultation_card_widget.dart index b1529280..7efa25f7 100644 --- a/lib/features/consultations/presentation/widgets/consultation_card_widget.dart +++ b/lib/features/consultations/presentation/widgets/consultation_card_widget.dart @@ -172,8 +172,8 @@ class _ConsultationCardWidgetState extends State { ); 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,}); + }, borderRadius: BorderRadius.circular(100), child: Ink( decoration: BoxDecoration( @@ -255,7 +255,7 @@ class _ConsultationCardWidgetState extends State { .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: {'diaryId': lastActiveDay.$2.id,}); return; } showErrorDialog( diff --git a/lib/features/create_diet/presentation/create_diet_screen.dart b/lib/features/create_diet/presentation/create_diet_screen.dart index b77ac3b9..5d95e783 100644 --- a/lib/features/create_diet/presentation/create_diet_screen.dart +++ b/lib/features/create_diet/presentation/create_diet_screen.dart @@ -29,24 +29,24 @@ class CreateDietScreen extends StatelessWidget { static const name = 'createDietScreen'; static const path = '/create-diet-screen'; - const CreateDietScreen({super.key, this.diet}); + const CreateDietScreen({super.key, this.diet, this.isEditing}); final Diet? diet; - + final bool? isEditing; @override Widget build(BuildContext context) { return BlocProvider( create: (context) => sl()..setInitialData(diet), - child: CreateDietScreenContent(diet: diet), + child: CreateDietScreenContent(diet: diet, isEditing: isEditing), ); } } class CreateDietScreenContent extends StatefulWidget { - const CreateDietScreenContent({super.key, this.diet}); + const CreateDietScreenContent({super.key, this.diet, this.isEditing}); final Diet? diet; - + final bool? isEditing; @override State createState() => _CreateDietScreenContentState(); } @@ -58,6 +58,34 @@ class _CreateDietScreenContentState extends State { final GlobalKey _mdKey = GlobalKey(); bool _mdToolbarVisible = false; + final GlobalKey _nameKey = GlobalKey(); + final GlobalKey _durationKey = GlobalKey(); + final GlobalKey _statusKey = GlobalKey(); + final GlobalKey _typeKey = GlobalKey(); + final GlobalKey _imageKey = GlobalKey(); + final GlobalKey _planKey = GlobalKey(); + + void _scrollToFirstError(CreateDietState state) { + GlobalKey? key; + if (state.dietNameError != null) { + key = _nameKey; + } else if (state.dietDescriptionError != null) { + key = _mdKey; + } else if (state.dietDurationError != null) { + key = _durationKey; + } else if (state.dietStateError != null) { + key = _statusKey; + } else if (state.dietImageError != null) { + key = _imageKey; + } else if (state.dailyScheduleErrors.any((e) => e.isNotEmpty)) { + key = _planKey; + } + + if (key != null && key.currentContext != null) { + Scrollable.ensureVisible(key.currentContext!, duration: const Duration(milliseconds: 500), curve: Curves.easeInOut, alignment: 0.1); + } + } + @override void initState() { super.initState(); @@ -88,7 +116,7 @@ class _CreateDietScreenContentState extends State { context.pop(); showBottomSheetWithBlur( context: context, - child: SuccessPublishedDietOrPlanBottomSheet(title: '${state.dietData.name ?? 'Диета'} сохраненна в черновиках'), + child: SuccessPublishedDietOrPlanBottomSheet(title: state.dietData.name ?? 'Диета', isDrafted: true), ); }); } @@ -118,6 +146,7 @@ class _CreateDietScreenContentState extends State { children: [ const SizedBox(height: 16), CustomTextFormField( + key: _nameKey, hintText: s.enter_diet_name, controller: nameController, hasError: state.dietNameError != null, @@ -143,19 +172,20 @@ class _CreateDietScreenContentState extends State { ), if (state.dietDescriptionError != null) ...[const SizedBox(height: 4), ValidationErrorWidget(error: state.dietDescriptionError ?? '')], const SizedBox(height: 12), - const DietDurationDropDownWidget(), + DietDurationDropDownWidget(key: _durationKey), const SizedBox(height: 12), - StatusDropDownWidget(dietState: widget.diet?.state), + StatusDropDownWidget(key: _statusKey, dietState: widget.diet?.state), const SizedBox(height: 16), - const DietTypeChoiceWidget(), + DietTypeChoiceWidget(key: _typeKey), const SizedBox(height: 16), - const DietImagePickerWidget(), + DietImagePickerWidget(key: _imageKey), const SizedBox(height: 16), ], ), ), const SizedBox(height: 16), MainBackgroundBouble( + key: _planKey, child: Padding( padding: const EdgeInsets.symmetric(vertical: 16.0), child: Center( @@ -178,47 +208,131 @@ class _CreateDietScreenContentState extends State { const SizedBox(height: 12), Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Column( - children: [ - CustomOutlinedButton( - onPressed: state.isDietPublishing - ? () {} - : () async { - final diet = await context.read().getDietForPreview(); - if (diet == null) return; - if (!context.mounted) return; - GoRouter.of(context).pushNamed(DietPreviewScreen.name, extra: diet); + child: state.isDietBeingEdited || state.isDietPublishing + ? const Loader() + : Column( + children: [ + CustomOutlinedButton( + onPressed: () async { + if (state.isDietPublishing) return; + if (widget.diet != null && widget.isEditing == true) { + final dietState = state.dietState; + if (dietState == DietState.draft) { + context.read().setDietState(DietState.active); + } else { + context.read().setDietState(DietState.draft); + } + final result = await context.read().updateDiet(widget.diet!.id); + if (result == 'error') { + if (!context.mounted) return; + showErrorDialog(context, title: '${s.could_not_update_diet} ${state.dietData.name}'); + return; + } + if (dietState == DietState.draft) { + Future.delayed(const Duration(milliseconds: 500), () { + if (!context.mounted) return; + context.pop(); + showBottomSheetWithBlur( + userRootNavigator: true, + context: context, + child: SuccessPublishedDietOrPlanBottomSheet(title: state.dietData.name ?? 'Диета'), + ); + }); + } else { + Future.delayed(const Duration(milliseconds: 500), () { + if (!context.mounted) return; + context.pop(); + showBottomSheetWithBlur( + context: context, + child: SuccessPublishedDietOrPlanBottomSheet(title: state.dietData.name ?? 'Диета', isDrafted: true), + ); + }); + } + } else { + if (state.dietState != DietState.draft) { + context.read().setDietState(DietState.draft); + } + final result = await context.read().createDiet(); + if (result == 'error') { + if (!context.mounted) return; + showErrorDialog(context, title: '${s.could_not_publish_diet} ${state.dietData.name}'); + } + } }, - text: s.preview, - height: 52, - buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.gray), - buttonStyle: ButtonStyle( - side: const WidgetStatePropertyAll(BorderSide.none), - backgroundColor: WidgetStatePropertyAll(appTheme.dottedBorder), + text: state.dietState == DietState.draft && widget.isEditing == true ? s.publish : "В черновик", + height: 52, + buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.gray), + buttonStyle: ButtonStyle( + side: const WidgetStatePropertyAll(BorderSide.none), + backgroundColor: WidgetStatePropertyAll(appTheme.dottedBorder), + ), + ), + const SizedBox(height: 12), + CustomOutlinedButton( + onPressed: state.isDietPublishing || state.isDietBeingEdited + ? () {} + : () async { + final diet = await context.read().getDietForPreview(); + if (diet == null) { + if (!context.mounted) return; + _scrollToFirstError(context.read().state); + return; + } + if (!context.mounted) return; + GoRouter.of( + context, + ).pushNamed(DietPreviewScreen.name, extra: {'diet': diet, 'isImageInBase64': state.dietData.imageUrl == null}); + }, + text: s.preview, + height: 52, + buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.gray), + buttonStyle: ButtonStyle( + side: const WidgetStatePropertyAll(BorderSide.none), + backgroundColor: WidgetStatePropertyAll(appTheme.dottedBorder), + ), + ), + const SizedBox(height: 12), + + CustomOutlinedButton( + onPressed: () async { + if (state.isDietBeingEdited) return; + if (widget.diet != null && widget.isEditing == true) { + final result = await context.read().updateDiet(widget.diet!.id); + if (result == 'error') { + if (!context.mounted) return; + showErrorDialog(context, title: '${s.could_not_update_diet} ${state.dietData.name}'); + } else { + Future.delayed(const Duration(milliseconds: 500), () { + if (!context.mounted) return; + context.pop(); + showBottomSheetWithBlur( + userRootNavigator: true, + context: context, + child: SuccessPublishedDietOrPlanBottomSheet(title: state.dietData.name ?? 'Диета', isUpdated: true), + ); + }); + } + } else { + final result = await context.read().createDiet(); + if (result == 'validation') { + if (!context.mounted) return; + _scrollToFirstError(context.read().state); + } else if (result == 'error') { + if (!context.mounted) return; + showErrorDialog(context, title: '${s.could_not_publish_diet} ${state.dietData.name}'); + } + } + }, + text: widget.diet != null && widget.isEditing == true ? s.save : s.publish, + height: 52, + buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700), + buttonStyle: ButtonStyle( + side: const WidgetStatePropertyAll(BorderSide.none), + backgroundColor: WidgetStatePropertyAll(appTheme.blueA400), + ), + ), + ], ), - ), - const SizedBox(height: 12), - if (state.isDietPublishing) - const Loader() - else - CustomOutlinedButton( - onPressed: () async { - final result = await context.read().createDiet(); - if (result == 'error') { - if (!context.mounted) return; - showErrorDialog(context, title: '${s.could_not_publish_diet} ${state.dietData.name}'); - } - }, - text: s.publish, - height: 52, - buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700), - buttonStyle: ButtonStyle( - side: const WidgetStatePropertyAll(BorderSide.none), - backgroundColor: WidgetStatePropertyAll(appTheme.blueA400), - ), - ), - ], - ), ), const SizedBox(height: 20), ], diff --git a/lib/features/create_diet/presentation/cubit/create_diet_cubit.dart b/lib/features/create_diet/presentation/cubit/create_diet_cubit.dart index 8ecf89ba..4b7315ab 100644 --- a/lib/features/create_diet/presentation/cubit/create_diet_cubit.dart +++ b/lib/features/create_diet/presentation/cubit/create_diet_cubit.dart @@ -32,7 +32,7 @@ class CreateDietCubit extends Cubit { emit(state.copyWith(isLoading: true, isDietBeingEdited: true)); dietId = diet.id; final dailyScheduleWithNutrition = await createDietRepository.getDietPortionsNutrition(diet.id); - final countOfDays = dailyScheduleWithNutrition.length; + final countOfDays = diet.duration; final List>> dailySchedule = List.generate(countOfDays, (index) => [[], [], [], [], []]); for (final dayEntry in dailyScheduleWithNutrition.entries) { @@ -49,6 +49,12 @@ class CreateDietCubit extends Cubit { type['carb'] = diet.type.carb ?? false; type['light'] = diet.type.light ?? false; final appConfig = sl(); + String? imageUrl; + if (diet.image?.startsWith('https') == true) { + imageUrl = diet.image; + } else if (diet.image != null && diet.image?.isNotEmpty == true) { + imageUrl = '${appConfig.baseUrl}/storage/${diet.image}'; + } emit( state.copyWith.dietData( dailySchedule: dailySchedule, @@ -57,12 +63,10 @@ class CreateDietCubit extends Cubit { name: diet.name, duration: diet.duration, type: type, - // Avoid network image URLs during tests to prevent HTTP fetch attempts - imageUrl: appConfig.buildType == AppBuildType.tests ? null : '${appConfig.baseUrl}/storage/${diet.image}', + imageUrl: appConfig.buildType == AppBuildType.tests ? null : imageUrl, ), ); - emit(state.copyWith(dietState: diet.state, durationIndex: durationValues.indexOf(diet.duration))); - emit(state.copyWith(isLoading: true)); + emit(state.copyWith(isLoading: false, isDietBeingEdited: false, dietState: diet.state, durationIndex: durationValues.indexOf(diet.duration))); } void setDetails(String text) { @@ -195,11 +199,20 @@ class CreateDietCubit extends Cubit { } } + + static const int _maxImageBytesForBase64 = 5 * 1024 * 1024; + Future getDietForPreview() async { - if (!_isFromValid()) return null; + if (!_isFormValidForDraft()) return null; final authorProfile = await profileRepository.getMyProfile(); final imageBytes = (await state.dietData.image?.readAsBytes()) ?? []; - final imageInBase64 = base64.encode(imageBytes); + String image = ''; + if (imageBytes.isNotEmpty && imageBytes.length <= _maxImageBytesForBase64) { + image = base64.encode(imageBytes); + } + if (image.isEmpty && state.dietData.imageUrl != null) { + image = state.dietData.imageUrl ?? ''; + } final carbs = state.dietData.dailySchedule?.map((element) { return element.fold(0, (prev, element) { return prev + @@ -259,7 +272,7 @@ class CreateDietCubit extends Cubit { id: 0, name: state.dietData.name ?? '', slug: '', - image: imageInBase64, + image: image.isNotEmpty ? image : null, duration: state.dietData.duration ?? 0, state: state.dietData.state ?? DietState.active, type: DietType( @@ -302,11 +315,19 @@ class CreateDietCubit extends Cubit { Future createDiet() async { emit(state.copyWith(isDietPublishing: true)); - if (!_isFromValid()) { + if ([DietState.active, DietState.personal].contains(state.dietState) && !_isFromValid()) { + emit(state.copyWith(isDietPublishing: false)); + return 'validation'; + } else if (!_isFormValidForDraft()) { emit(state.copyWith(isDietPublishing: false)); return 'validation'; } try { + final dietState = state.dietData.state; + if (dietState == null) { + emit(state.copyWith(isDietPublishing: false, dietStateError: 'Статус диеты не выбран')); + return 'validation'; + } File? file; if (state.dietData.imageUrl != null) { file = await downloadImageAsFile(state.dietData.imageUrl!); @@ -315,18 +336,17 @@ class CreateDietCubit extends Cubit { if (state.dietData.image?.path != null) { image = await ImageCompressUtility.compressFile(File(state.dietData.image!.path)); } - final dietState = state.dietData.state!; final dailySchedule = state.dietData.dailySchedule; await createDietRepository.createDiet( name: state.dietData.name ?? '', duration: state.dietData.duration ?? 7, state: dietState, - image: image ?? file!, + image: image ?? file, meatType: state.dietData.type['meat'] == true ? '1' : '0', carbType: state.dietData.type['carb'] == true ? '1' : '0', vegType: state.dietData.type['veg'] == true ? '1' : '0', lightType: state.dietData.type['light'] == true ? '1' : '0', - details: state.dietData.details ?? '', + details: state.dietData.details, dailySchedule: dailySchedule ?? [], ); @@ -341,7 +361,15 @@ class CreateDietCubit extends Cubit { } Future updateDiet(int dietId) async { - if (!_isFromValid()) return 'error'; + emit(state.copyWith(isDietBeingEdited: true)); + if ([DietState.active, DietState.personal].contains(state.dietState) && !_isFromValid()) { + emit(state.copyWith(isDietBeingEdited: false)); + return 'error'; + } else if (!_isFormValidForDraft()) { + emit(state.copyWith(isDietBeingEdited: false)); + return 'error'; + } + try { File? image; if (state.dietData.image != null) { @@ -357,17 +385,37 @@ class CreateDietCubit extends Cubit { carbType: state.dietData.type['carb'] == true ? '1' : '0', vegType: state.dietData.type['veg'] == true ? '1' : '0', lightType: state.dietData.type['light'] == true ? '1' : '0', - details: state.dietData.details, + details: state.dietData.details?.isEmpty ?? true ? null : state.dietData.details, dailySchedule: state.dietData.dailySchedule, ); + emit(state.copyWith(isDietBeingEdited: false)); return null; } catch (e, st) { await Sentry.captureException(e, stackTrace: st); log(e.toString(), stackTrace: st); + emit(state.copyWith(isDietBeingEdited: false)); return 'error'; } } + bool _isFormValidForDraft() { + bool result = true; + if ((state.dietData.name?.length ?? 0) < 3) { + result = false; + emit(state.copyWith(dietNameError: 'Длинна названия не может быть меньше 3 символов')); + } + if (state.dietData.name == null || state.dietData.name == '') { + result = false; + emit(state.copyWith(dietNameError: 'Поле не заполнено')); + } + + if (state.dietData.details?.length != null && (state.dietData.details?.isNotEmpty ?? false) && (state.dietData.details?.length ?? 4) < 3) { + result = false; + emit(state.copyWith(dietDescriptionError: 'Длинна описания не может быть меньше 3 символов')); + } + return result; + } + bool _isFromValid() { bool result = true; if ((state.dietData.name?.length ?? 0) < 3) { @@ -394,11 +442,11 @@ class CreateDietCubit extends Cubit { result = false; emit(state.copyWith(dietStateError: 'Статус диеты не выбран')); } - if (state.dietData.image == null) { + if (state.dietData.image == null && state.dietData.imageUrl == null) { result = false; emit(state.copyWith(dietImageError: 'Не добавлена обложка')); } - result = _isDailyScheduleValid(); + result = _isDailyScheduleValid() && result; return result; } diff --git a/lib/features/create_diet/presentation/cubit/create_diet_state.dart b/lib/features/create_diet/presentation/cubit/create_diet_state.dart index dc280baa..c55f6b91 100644 --- a/lib/features/create_diet/presentation/cubit/create_diet_state.dart +++ b/lib/features/create_diet/presentation/cubit/create_diet_state.dart @@ -27,12 +27,11 @@ abstract class CreateDietState with _$CreateDietState { }) = _CreateDietState; factory CreateDietState.initial() => CreateDietState( - dailyScheduleErrors: List.filled(21, ''), - isDietPublishing: false, - isLoading: false, - errorMessage: null, - dietData: CreateDietModel( - type: Map.fromIterable(dietTypeValues, value: (element) => false), - ), - ); + dailyScheduleErrors: List.filled(21, ''), + isDietPublishing: false, + isLoading: false, + errorMessage: null, + isDietBeingEdited: false, + dietData: CreateDietModel(type: Map.fromIterable(dietTypeValues, value: (element) => false)), + ); } diff --git a/lib/features/create_diet/presentation/diet_preview_screen.dart b/lib/features/create_diet/presentation/diet_preview_screen.dart index f57548f0..fd5a2f3b 100644 --- a/lib/features/create_diet/presentation/diet_preview_screen.dart +++ b/lib/features/create_diet/presentation/diet_preview_screen.dart @@ -1,6 +1,9 @@ import 'dart:convert'; import 'dart:typed_data'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/enums/product_type.dart'; +import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/utils/localization_utils.dart'; import 'package:eatvio_app/core/widgets/custom_divider.dart'; import 'package:eatvio_app/core/widgets/info_row.dart'; @@ -18,10 +21,11 @@ import 'package:flutter_svg/svg.dart'; class DietPreviewScreen extends StatefulWidget { final Diet diet; + final bool isImageInBase64; late final List selectedIndexes; static const name = 'createDietScreenPreview'; static const path = '/create-diet-screen/preview'; - DietPreviewScreen({super.key, required this.diet}) { + DietPreviewScreen({super.key, required this.diet, required this.isImageInBase64}) { selectedIndexes = List.generate(diet.dailySchedule.length, (_) => 0); } @@ -31,11 +35,19 @@ class DietPreviewScreen extends StatefulWidget { class _DietPreviewScreenState extends State { final ScrollController scrollController = ScrollController(); - late final Uint8List imageData; + Uint8List? imageData; @override void initState() { super.initState(); - imageData = base64.decode(widget.diet.image); + if (widget.diet.image != null && widget.isImageInBase64) { + imageData = base64.decode(widget.diet.image!); + } + } + + @override + void dispose() { + scrollController.dispose(); + super.dispose(); } @override @@ -58,7 +70,7 @@ class _DietPreviewScreenState extends State { ), body: SingleChildScrollView( controller: scrollController, - padding: const EdgeInsets.only(bottom: 160), + padding: const EdgeInsets.only(bottom: 40), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -67,49 +79,95 @@ class _DietPreviewScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Stack( - children: [ - Image.memory(imageData, height: 300, width: MediaQuery.sizeOf(context).width, fit: BoxFit.fill), - // CachedNetworkImage( - // imageUrl: baseUrl + '/storage/' + widget.diet.image, - // ), - Positioned( - bottom: 16, - left: 0, - right: 0, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - InfoBouble( - backgroundColor: appTheme.whiteA700, - children: [ - SvgPicture.asset('assets/icons/eye.svg', width: 18, height: 12), - const SizedBox(width: 8), - Text(widget.diet.totalViews.toString(), style: TextStyles.textXSSemibold.copyWith(color: appTheme.black)), - ], - ), - const SizedBox(width: 12), - GestureDetector( - onTap: () {}, - child: InfoBouble( + if (imageData != null) + Stack( + children: [ + Image.memory(imageData!, height: 300, width: MediaQuery.sizeOf(context).width, fit: BoxFit.fill), + Positioned( + bottom: 16, + left: 0, + right: 0, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + InfoBouble( backgroundColor: appTheme.whiteA700, children: [ - SvgPicture.asset( - 'assets/icons/heart.svg', - width: 14, - height: 12, - colorFilter: widget.diet.wasLikedByMe ? ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn) : null, - ), + SvgPicture.asset('assets/icons/eye.svg', width: 18, height: 12), const SizedBox(width: 8), - Text(widget.diet.totalLikes.toString(), style: TextStyles.textXSSemibold.copyWith(color: appTheme.black)), + Text(widget.diet.totalViews.toString(), style: TextStyles.textXSSemibold.copyWith(color: appTheme.black)), ], ), - ), - ], + const SizedBox(width: 12), + GestureDetector( + onTap: () {}, + child: InfoBouble( + backgroundColor: appTheme.whiteA700, + children: [ + SvgPicture.asset( + 'assets/icons/heart.svg', + width: 14, + height: 12, + colorFilter: widget.diet.wasLikedByMe ? ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn) : null, + ), + const SizedBox(width: 8), + Text(widget.diet.totalLikes.toString(), style: TextStyles.textXSSemibold.copyWith(color: appTheme.black)), + ], + ), + ), + ], + ), ), - ), - ], - ), + ], + ) + else if (!widget.isImageInBase64 && widget.diet.image != null && (widget.diet.image?.isNotEmpty ?? false)) + Stack( + children: [ + CachedNetworkImage( + imageUrl: widget.diet.image?.startsWith('https') == true + ? widget.diet.image! + : '${sl().baseUrl}/storage/${widget.diet.image}', + height: 300, + width: MediaQuery.sizeOf(context).width, + fit: BoxFit.fill, + ), + Positioned( + bottom: 16, + left: 0, + right: 0, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + InfoBouble( + backgroundColor: appTheme.whiteA700, + children: [ + SvgPicture.asset('assets/icons/eye.svg', width: 18, height: 12), + const SizedBox(width: 8), + Text(widget.diet.totalViews.toString(), style: TextStyles.textXSSemibold.copyWith(color: appTheme.black)), + ], + ), + const SizedBox(width: 12), + GestureDetector( + onTap: () {}, + child: InfoBouble( + backgroundColor: appTheme.whiteA700, + children: [ + SvgPicture.asset( + 'assets/icons/heart.svg', + width: 14, + height: 12, + colorFilter: widget.diet.wasLikedByMe ? ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn) : null, + ), + const SizedBox(width: 8), + Text(widget.diet.totalLikes.toString(), style: TextStyles.textXSSemibold.copyWith(color: appTheme.black)), + ], + ), + ), + ], + ), + ), + ], + ), const SizedBox(height: 16), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), @@ -148,14 +206,6 @@ class _DietPreviewScreenState extends State { }, itemCount: widget.diet.dailySchedule.length, ), - const MainBackgroundBouble( - padding: EdgeInsets.symmetric(vertical: 16), - child: Column( - children: [ - SizedBox(height: 16), - ], - ), - ), ], ), ), diff --git a/lib/features/create_diet/presentation/widgets/add_meal_widget.dart b/lib/features/create_diet/presentation/widgets/add_meal_widget.dart index ececed87..a8eb36d5 100644 --- a/lib/features/create_diet/presentation/widgets/add_meal_widget.dart +++ b/lib/features/create_diet/presentation/widgets/add_meal_widget.dart @@ -185,7 +185,7 @@ class _AddMealWidgetState extends State { dailyNutrition: DailyNutrition(energy: calories, proteins: proteins, fats: fats, carbohydrates: carbohydrates), quantity: int.parse(quantityController.text), name: glossary.name, - productNutritionalValueId: glossary.id.toString(), + productNutritionalValueId: glossary.id, portion: measurementUnits, ); if (portionIndex == null) { @@ -212,7 +212,7 @@ class _AddMealWidgetState extends State { dailyNutrition: DailyNutrition(energy: calories, proteins: proteins, fats: fats, carbohydrates: carbohydrates), quantity: int.parse(quantityController.text), name: newGlossary.name, - productNutritionalValueId: newGlossary.id.toString(), + productNutritionalValueId: newGlossary.id, portion: measurementUnits, ); if (portionIndex == null) { diff --git a/lib/features/create_diet/repositories/create_diet_repository.dart b/lib/features/create_diet/repositories/create_diet_repository.dart index 07586d6a..feb37496 100644 --- a/lib/features/create_diet/repositories/create_diet_repository.dart +++ b/lib/features/create_diet/repositories/create_diet_repository.dart @@ -12,44 +12,56 @@ class CreateDietRepository { required String name, required int duration, required DietState state, - required String meatType, - required String carbType, - required String vegType, - required String lightType, - required String details, - required File image, - required List>> dailySchedule, + required String? meatType, + required String? carbType, + required String? vegType, + required String? lightType, + required String? details, + required File? image, + required List>>? dailySchedule, }) async { final dietMap = {}; - for (final (dayIndex, day) in dailySchedule.indexed) { - for (final (mealIndex, meal) in day.indexed) { - for (final (productIndex, product) in meal.indexed) { - final jsonProduct = product.toJson(); - dietMap.addAll({ - 'daily_schedule[${dayIndex + 1}][${mealIndex + 1}][$productIndex][name]': jsonProduct['name'], - 'daily_schedule[${dayIndex + 1}][${mealIndex + 1}][$productIndex][portion]': jsonProduct['portion'], - 'daily_schedule[${dayIndex + 1}][${mealIndex + 1}][$productIndex][quantity]': jsonProduct['quantity'], - 'daily_schedule[${dayIndex + 1}][${mealIndex + 1}][$productIndex][product_nutritional_value_id]': jsonProduct['product_nutritional_value_id'], - }); + if (dailySchedule != null) { + for (final (dayIndex, day) in dailySchedule.indexed) { + for (final (mealIndex, meal) in day.indexed) { + for (final (productIndex, product) in meal.indexed) { + final jsonProduct = product.toJson(); + dietMap.addAll({ + 'daily_schedule[${dayIndex + 1}][${mealIndex + 1}][$productIndex][name]': jsonProduct['name'], + 'daily_schedule[${dayIndex + 1}][${mealIndex + 1}][$productIndex][portion]': jsonProduct['portion'], + 'daily_schedule[${dayIndex + 1}][${mealIndex + 1}][$productIndex][quantity]': jsonProduct['quantity'], + 'daily_schedule[${dayIndex + 1}][${mealIndex + 1}][$productIndex][product_nutritional_value_id]': jsonProduct['product_nutritional_value_id'], + }); + } } } } + if (details != null) { + dietMap.addAll({'description_md': details}); + } - dietMap.addAll({ - 'name': name, - 'description_md': details, - 'duration': duration, - 'type[meat]': meatType, - 'type[carb]': carbType, - 'type[veg]': vegType, - 'type[light]': lightType, - 'image': MultipartFile.fromFileSync( - image.path, - filename: image.path.split(Platform.pathSeparator).last, - ), - }); + if (meatType != null) { + dietMap.addAll({'type[meat]': meatType}); + } - final formData = FormData.fromMap(dietMap); + if (carbType != null) { + dietMap.addAll({'type[carb]': carbType}); + } + + if (vegType != null) { + dietMap.addAll({'type[veg]': vegType}); + } + + if (lightType != null) { + dietMap.addAll({'type[light]': lightType}); + } + if (image != null) { + dietMap.addAll({'image': MultipartFile.fromFileSync(image.path, filename: image.path.split(Platform.pathSeparator).last)}); + } + + dietMap.addAll({'name': name, 'duration': duration}); + + final formData = FormData.fromMap(dietMap); try { switch (state) { case DietState.active: @@ -76,7 +88,12 @@ class CreateDietRepository { return MapEntry( int.parse(key), (value as Map).map((key, value) { - return MapEntry(int.parse(key), DailySchedule.fromJson(value as Map)); + final portionMap = Map.from(value as Map); + final pnvid = portionMap['product_nutritional_value_id']; + if (pnvid is String) { + portionMap['product_nutritional_value_id'] = int.tryParse(pnvid); + } + return MapEntry(int.parse(key), DailySchedule.fromJson(portionMap)); }), ); }), @@ -89,7 +106,7 @@ class CreateDietRepository { } } - Future updateDiet({ + Future updateDiet({ required int dietId, required DietState state, String? name, @@ -140,24 +157,24 @@ class CreateDietRepository { dietMap.addAll({'type[light]': lightType}); } if (image != null) { - dietMap.addAll({ - 'image': MultipartFile.fromFileSync( - image.path, - filename: image.path.split(Platform.pathSeparator).last, - ), - }); + dietMap.addAll({'image': MultipartFile.fromFileSync(image.path, filename: image.path.split(Platform.pathSeparator).last)}); } final formData = FormData.fromMap(dietMap); try { switch (state) { case DietState.active: - return await createDietService.updatePublished(diet: dietId, formData: formData); + final response = await createDietService.updatePublished(diet: dietId, formData: formData); + return response.response.statusCode ?? 0; case DietState.draft: - return await createDietService.updateDrafted(diet: dietId, formData: formData); + final response = await createDietService.updateDrafted(diet: dietId, formData: formData); + return response.response.statusCode ?? 0; case DietState.personal: - return await createDietService.updatePersonal(diet: dietId, formData: formData); + final response = await createDietService.updatePersonal(diet: dietId, formData: formData); + return response.response.statusCode ?? 0; } + } on DioException catch (e) { + return e.response?.statusCode ?? 0; } catch (e, s) { await Sentry.captureException(e, stackTrace: s); rethrow; diff --git a/lib/features/create_diet/services/create_diet_service.dart b/lib/features/create_diet/services/create_diet_service.dart index 3c0d8923..cc010293 100644 --- a/lib/features/create_diet/services/create_diet_service.dart +++ b/lib/features/create_diet/services/create_diet_service.dart @@ -28,21 +28,21 @@ abstract class CreateDietService { @POST('/api/diets/update/published/{diet}') @MultiPart() - Future updatePublished({ + Future updatePublished({ @Path() required int diet, @Body() required FormData formData, }); @POST('/api/diets/update/drafted/{diet}') @MultiPart() - Future updateDrafted({ + Future updateDrafted({ @Path() required int diet, @Body() required FormData formData, }); @POST('/api/diets/update/personalized/{diet}') @MultiPart() - Future updatePersonal({ + Future updatePersonal({ @Path() required int diet, @Body() required FormData formData, }); diff --git a/lib/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_cubit.dart b/lib/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_cubit.dart index 8f7bca23..e7537af7 100644 --- a/lib/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_cubit.dart +++ b/lib/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_cubit.dart @@ -172,6 +172,38 @@ class CreateRecipeCubit extends Cubit { } } + Future saveAsDraft([bool update = false, String? slug]) async { + try { + final model = state.entity.toModel(); + await recipeRepository.saveAsDraft(model, update, slug); + emit(state.copyWith(drafted: true)); + return true; + } catch (e, st) { + await Sentry.captureException(e, stackTrace: st); + log(e.toString(), stackTrace: st); + return false; + } + } + + bool validateForDraft() { + final isTitleValid = validateTitle(); + _clearNonTitleValidationErrors(); + return isTitleValid; + } + + void _clearNonTitleValidationErrors() { + emit(state.copyWith( + descriptionValidationError: null, + cookingTimeValidationError: null, + activeTimeValidationError: null, + servingsValidationError: null, + energyPerServingValidationError: null, + imageValidationError: null, + stepsValidationError: null, + ingredientsValidationError: null, + )); + } + Future downloadImageAsFile(String imageUrl) async { final response = await http.get(Uri.parse(imageUrl)); @@ -239,6 +271,7 @@ class CreateRecipeCubit extends Cubit { final isActiveCookTimeValid = validateActiveCookTime(); final isEnergyPerServingsValid = validateEnergyPerServing(); + final isIngredientsValid = validateIngredients(); final isStepsValid = validateSteps(); return isTitleValid && @@ -248,8 +281,19 @@ class CreateRecipeCubit extends Cubit { isCookTimeValid && isActiveCookTimeValid && isEnergyPerServingsValid && + isIngredientsValid && isStepsValid; } + + bool validateIngredients() { + if (state.entity.ingredients.isEmpty) { + emit(state.copyWith(ingredientsValidationError: 'Добавьте хотя бы один ингредиент')); + return false; + } else { + emit(state.copyWith(ingredientsValidationError: null)); + return true; + } + } bool validateTitle() { if (state.entity.title == null || (state.entity.title?.isEmpty ?? true)) { @@ -343,6 +387,10 @@ class CreateRecipeCubit extends Cubit { bool validateSteps() { bool isValid = true; + if (state.entity.steps.isEmpty) { + emit(state.copyWith(stepsValidationError: 'Добавьте хотя бы один шаг')); + return false; + } for (var step in state.entity.steps) { if (step.description == null || step.description!.trim().isEmpty) { isValid = false; diff --git a/lib/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_state.dart b/lib/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_state.dart index ef0efe78..23cb9895 100644 --- a/lib/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_state.dart +++ b/lib/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_state.dart @@ -8,6 +8,7 @@ abstract class CreateRecipeState with _$CreateRecipeState { const factory CreateRecipeState({ required RecipeCreateEntity entity, required bool loading, + @Default(false) bool drafted, String? titleValidationError, String? descriptionValidationError, String? cookingTimeValidationError, @@ -16,6 +17,7 @@ abstract class CreateRecipeState with _$CreateRecipeState { String? energyPerServingValidationError, String? imageValidationError, String? stepsValidationError, + String? ingredientsValidationError, }) = _CreateRecipeState; } diff --git a/lib/features/create_recipe/presentation/pages/recipe_create_screen.dart b/lib/features/create_recipe/presentation/pages/recipe_create_screen.dart index 932852ac..a749186d 100644 --- a/lib/features/create_recipe/presentation/pages/recipe_create_screen.dart +++ b/lib/features/create_recipe/presentation/pages/recipe_create_screen.dart @@ -19,8 +19,9 @@ import 'package:go_router/go_router.dart'; class RecipeCreateScreen extends StatefulWidget { final Recipe? recipe; + final bool autoValidate; - const RecipeCreateScreen({super.key, this.recipe}); + const RecipeCreateScreen({super.key, this.recipe, this.autoValidate = false}); static const name = 'recipeCreateScreen'; static const path = '/recipe-create-screen'; @@ -40,6 +41,11 @@ class _RecipeCreateScreenState extends State { createRecipeCubit = sl(); if (widget.recipe != null) { createRecipeCubit.recipeToEntity(widget.recipe!); + if (widget.autoValidate) { + WidgetsBinding.instance.addPostFrameCallback((_) { + createRecipeCubit.validateAll(); + }); + } } else { createRecipeCubit.disableLoading(); } @@ -81,55 +87,107 @@ class _RecipeCreateScreenState extends State { builder: (context, state) { return Builder( builder: (context) { - return _isRecipeBeingPublished - ? const Center(child: Loader()) - : InkWell( - key: AppKeys.recipeCreateScreenPublishButton, + if (_isRecipeBeingPublished) { + return const Center(child: Loader()); + } + return Column( + children: [ + InkWell( + key: AppKeys.recipeCreateScreenPublishButton, + onTap: () async { + setState(() { + _isRecipeBeingPublished = true; + }); + if (context.read().validateAll()) { + final isSuccess = await context.read().publishRecipe( + widget.recipe == null ? false : true, + widget.recipe?.slug, + ); + setState(() { + _isRecipeBeingPublished = false; + }); + if (isSuccess) { + Future.delayed(const Duration(milliseconds: 500), () { + if (!context.mounted) return; + context.pop(); + showBottomSheetWithBlur( + userRootNavigator: true, + context: context, + child: SuccessPublishedRecipeBottomSheet(title: state.entity.title ?? 'Рецепт'), + ); + }); + } else { + if (!context.mounted) return; + showErrorDialog(context, title: '${s.could_not_publish_recipe} ${state.entity.title}'); + } + } else { + setState(() { + _isRecipeBeingPublished = false; + }); + } + }, + borderRadius: BorderRadius.circular(20), + child: Ink( + width: MediaQuery.of(context).size.width - 32, + decoration: BoxDecoration(color: appTheme.blueA400, borderRadius: BorderRadius.circular(20)), + child: Container( + padding: const EdgeInsets.all(16), + alignment: Alignment.center, + child: Text( + (widget.recipe != null && widget.recipe?.state != 'drafted') ? 'Обновить' : 'Опубликовать', + style: const TextStyle(fontSize: 16, color: Colors.white, fontWeight: FontWeight.w600), + ), + ), + ), + ), + const SizedBox(height: 12), + if (widget.recipe == null || widget.recipe?.state == 'drafted') + InkWell( onTap: () async { setState(() { _isRecipeBeingPublished = true; }); - if (context.read().validateAll()) { - final isSuccess = await context.read().publishRecipe( - widget.recipe == null ? false : true, - widget.recipe?.slug, - ); + if (context.read().validateForDraft()) { + final isSuccess = await context.read().saveAsDraft( + widget.recipe == null ? false : true, + widget.recipe?.slug, + ); setState(() { _isRecipeBeingPublished = false; }); if (isSuccess) { - Future.delayed(const Duration(milliseconds: 500), () { - if (!context.mounted) return; - context.pop(); - showBottomSheetWithBlur( - userRootNavigator: true, - context: context, - child: SuccessPublishedRecipeBottomSheet(title: state.entity.title ?? 'Рецепт'), - ); - }); + if (!context.mounted) return; + context.pop(); } else { if (!context.mounted) return; - showErrorDialog(context, title: '${s.could_not_publish_recipe} ${state.entity.title}'); + showErrorDialog(context, title: 'Не удалось сохранить черновик'); } + } else { + setState(() { + _isRecipeBeingPublished = false; + }); } - setState(() { - _isRecipeBeingPublished = false; - }); }, borderRadius: BorderRadius.circular(20), child: Ink( width: MediaQuery.of(context).size.width - 32, - decoration: BoxDecoration(color: appTheme.blueA400, borderRadius: BorderRadius.circular(20)), + decoration: BoxDecoration( + color: Colors.transparent, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: appTheme.blueA400), + ), child: Container( padding: const EdgeInsets.all(16), alignment: Alignment.center, child: Text( - widget.recipe != null ? 'Обновить' : 'Опубликовать', - style: const TextStyle(fontSize: 16, color: Colors.white, fontWeight: FontWeight.w600), + widget.recipe != null ? 'Обновить черновик' : 'Сохранить как черновик', + style: TextStyle(fontSize: 16, color: appTheme.blueA400, fontWeight: FontWeight.w600), ), ), ), - ); + ), + ], + ); }, ); }, diff --git a/lib/features/create_recipe/presentation/widgets/add_portion_widget.dart b/lib/features/create_recipe/presentation/widgets/add_portion_widget.dart index 597bc09b..9fd0a25d 100644 --- a/lib/features/create_recipe/presentation/widgets/add_portion_widget.dart +++ b/lib/features/create_recipe/presentation/widgets/add_portion_widget.dart @@ -182,7 +182,7 @@ class _AddPortionWidgetState extends State { dailyNutrition: DailyNutrition(energy: calories, proteins: proteins, fats: fats, carbohydrates: carbohydrates), quantity: int.parse(quantityController.text), name: glossary.name, - productNutritionalValueId: glossary.id.toString(), + productNutritionalValueId: glossary.id, portion: measurementUnits, ); if (portionIndex == null) { @@ -212,7 +212,7 @@ class _AddPortionWidgetState extends State { dailyNutrition: DailyNutrition(energy: calories, proteins: proteins, fats: fats, carbohydrates: carbohydrates), quantity: int.parse(quantityController.text), name: newGlossary.name, - productNutritionalValueId: newGlossary.id.toString(), + productNutritionalValueId: newGlossary.id, portion: measurementUnits, ); if (portionIndex == null) { diff --git a/lib/features/create_recipe/presentation/widgets/ingredients_widget.dart b/lib/features/create_recipe/presentation/widgets/ingredients_widget.dart index 4b3261ab..6894c162 100644 --- a/lib/features/create_recipe/presentation/widgets/ingredients_widget.dart +++ b/lib/features/create_recipe/presentation/widgets/ingredients_widget.dart @@ -85,6 +85,14 @@ class _IngredientsWidgetState extends State { title: Text('Добавить ингредиент', style: TextStyle(color: appTheme.blueA400)), leading: Icon(Icons.add_rounded, color: appTheme.blueA400), ), + if (state.ingredientsValidationError != null) + Padding( + padding: const EdgeInsets.only(left: 16, top: 4), + child: Text( + state.ingredientsValidationError!, + style: const TextStyle(color: Colors.red, fontSize: 12), + ), + ), ], ), ), diff --git a/lib/features/create_recipe/reporitories/recipe_repository.dart b/lib/features/create_recipe/reporitories/recipe_repository.dart index b1b5fce9..6acc905b 100644 --- a/lib/features/create_recipe/reporitories/recipe_repository.dart +++ b/lib/features/create_recipe/reporitories/recipe_repository.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:developer'; import 'dart:io'; import 'package:dio/dio.dart'; import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart'; @@ -23,55 +24,94 @@ class RecipeRepository { } } - Future publishRecipe(RecipeModel recipe, [bool update = false, String? slug]) async { - try { - if (recipe.image == null || (recipe.image?.isEmpty ?? true)) { - return; - } - final image = await ImageCompressUtility.compressFile(File(recipe.image ?? '')); - final multiPartFile = await MultipartFile.fromFile(image.path); - - final ingredientsMap = recipe.ingredients?.map((ingredient) { - if (ingredient != null) { - return ingredient.toJson(); - } - }).toList() ?? - []; - - final stepsMap = []; - for (final Step? step in recipe.steps ?? []) { - if (step != null) { - if ((step.image == null || (step.image?.isEmpty ?? true)) && step.description != null) { - stepsMap.add({ - 'description_md': step.description ?? "", - }); - } else if (step.image != null && (step.image?.isNotEmpty ?? false)) { - final image = await ImageCompressUtility.compressFile(File(step.image ?? '')); - final multipartFile = await MultipartFile.fromFile(image.path); - stepsMap.add({ - 'description_md': step.description ?? "", - 'image': multipartFile, - }); - } + Future _buildStepsMap(List? steps) async { + final stepsMap = []; + for (final Step? step in steps ?? []) { + if (step != null) { + if ((step.image == null || (step.image?.isEmpty ?? true)) && step.description != null) { + stepsMap.add({'description_md': step.description ?? ''}); + } else if (step.image != null && (step.image?.isNotEmpty ?? false)) { + final compressed = await ImageCompressUtility.compressFile(File(step.image ?? '')); + final multipartFile = await MultipartFile.fromFile(compressed.path); + stepsMap.add({ + 'description_md': step.description ?? '', + 'image': multipartFile, + }); } } - final formMap = { - 'type': recipe.type, - 'state': 'active', - 'title': recipe.title, - 'tags[]': recipe.tags ?? [], - 'description_md': recipe.description, - 'video_url': recipe.videoUrl, - 'ingredients': ingredientsMap, - 'steps': stepsMap, - 'cooking_time': recipe.totalCookingTime, - 'active_time': recipe.activeCookTime, - 'image': multiPartFile, - 'servings': recipe.servings, - 'energy_per_serving': recipe.energyPerServing, - }; - formMap.removeWhere((k, v) => v == null || (v is String && v.isEmpty)); - final formData = FormData.fromMap(formMap); + } + return stepsMap; + } + + Future _buildPublishFormData(RecipeModel recipe) async { + if (recipe.image == null || (recipe.image?.isEmpty ?? true)) { + throw Exception('Recipe image is required'); + } + final image = await ImageCompressUtility.compressFile(File(recipe.image ?? '')); + final multiPartFile = await MultipartFile.fromFile(image.path); + + final ingredientsMap = recipe.ingredients + ?.map((i) => i?.toJson()) + .whereType>() + .toList() ?? + []; + + final stepsMap = await _buildStepsMap(recipe.steps); + + final formMap = { + 'type': recipe.type, + 'title': recipe.title, + 'tags[]': recipe.tags ?? [], + 'state': 'active', + 'description_md': recipe.description, + 'video_url': recipe.videoUrl, + 'ingredients': ingredientsMap, + 'steps': stepsMap, + 'cooking_time': recipe.totalCookingTime, + 'active_time': recipe.activeCookTime, + 'image': multiPartFile, + 'servings': recipe.servings, + 'energy_per_serving': recipe.energyPerServing, + }; + formMap.removeWhere((k, v) => v == null || (v is String && v.isEmpty)); + return FormData.fromMap(formMap); + } + + Future _buildDraftFormData(RecipeModel recipe) async { + final ingredientsMap = recipe.ingredients + ?.map((i) => i?.toJson()) + .whereType>() + .toList() ?? + []; + + final stepsMap = await _buildStepsMap(recipe.steps); + + final formMap = { + 'type': recipe.type, + 'title': recipe.title, + 'tags[]': recipe.tags ?? [], + 'description_md': recipe.description, + 'video_url': recipe.videoUrl, + 'ingredients': ingredientsMap, + 'steps': stepsMap, + 'cooking_time': recipe.totalCookingTime, + 'active_time': recipe.activeCookTime, + 'servings': recipe.servings, + 'energy_per_serving': recipe.energyPerServing, + }; + + if (recipe.image != null && (recipe.image?.isNotEmpty ?? false)) { + final compressed = await ImageCompressUtility.compressFile(File(recipe.image ?? '')); + formMap['image'] = await MultipartFile.fromFile(compressed.path); + } + + formMap.removeWhere((k, v) => v == null || (v is String && v.isEmpty)); + return FormData.fromMap(formMap); + } + + Future publishRecipe(RecipeModel recipe, [bool update = false, String? slug]) async { + try { + final formData = await _buildPublishFormData(recipe); if (update) { await recipeService.updateRecipe(slug: slug!, formData: formData); } else { @@ -83,6 +123,30 @@ class RecipeRepository { } } + Future saveAsDraft(RecipeModel recipe, [bool update = false, String? slug]) async { + try { + final formData = await _buildDraftFormData(recipe); + log(formData.fields.toString()); + if (update) { + await recipeService.updateRecipe(slug: slug!, formData: formData); + } else { + await recipeService.createDraftedRecipe(formData: formData); + } + } catch (e, s) { + await Sentry.captureException(e, stackTrace: s); + rethrow; + } + } + + Future draftExistingRecipe(int id) async { + try { + await recipeService.draftRecipe(id: id); + } catch (e, s) { + await Sentry.captureException(e, stackTrace: s); + rethrow; + } + } + Future getRecipe(String recipeID) async { try { return await recipeService.getRecipe(recipeID); diff --git a/lib/features/create_recipe/services/recipe_service.dart b/lib/features/create_recipe/services/recipe_service.dart index 41ee957a..4bed540a 100644 --- a/lib/features/create_recipe/services/recipe_service.dart +++ b/lib/features/create_recipe/services/recipe_service.dart @@ -33,7 +33,7 @@ abstract class CreateRecipeService { @POST('/api/recipes/create') @MultiPart() - Future publishRecipe({ + Future> publishRecipe({ @Body() required FormData formData, }); @@ -47,8 +47,19 @@ abstract class CreateRecipeService { @POST('/api/recipes/update/{slug}') @MultiPart() - Future updateRecipe({ + Future> updateRecipe({ @Path() required String slug, @Body() required FormData formData, }); + + @POST('/api/recipes/draft/{id}') + Future draftRecipe({ + @Path() required int id, + }); + + @POST('/api/recipes/create/drafted') + @MultiPart() + Future> createDraftedRecipe({ + @Body() required FormData formData, + }); } diff --git a/lib/features/diary/presentation/bloc/diary_cubit.dart b/lib/features/diary/presentation/bloc/diary_cubit.dart index 0bbb4f82..39a0a7e7 100644 --- a/lib/features/diary/presentation/bloc/diary_cubit.dart +++ b/lib/features/diary/presentation/bloc/diary_cubit.dart @@ -47,6 +47,12 @@ class DiaryCubit extends Cubit { } String comment = ''; + bool commentNeedsToBeFilled = false; + + void setInitialDate(DateTime? date) { + if (date == null) return; + selectedDay = date; + } Future setSelectedDay(DateTime day) async { final prevDay = selectedDay; @@ -77,6 +83,7 @@ class DiaryCubit extends Cubit { return true; } } + final diaryDay = await getDiary(); if (diaryDay == null) { emit(const DiaryState.error(errorMessage: 'Diary not found')); @@ -277,9 +284,15 @@ 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); - await updateCurrentDiary(); + Future finishDiaryDay({required int diaryId, required String wakeUp, required String retire}) async { + try { + await diaryRepository.finishDiaryDay(diaryId: diaryId, wakeUp: wakeUp, retire: retire, comment: comment.isEmpty ? null : comment); + await updateCurrentDiary(); + return true; + } catch (e, st) { + log(e.toString(), stackTrace: st); + return false; + } } Future getNutrition(int index) async { @@ -416,6 +429,7 @@ class DiaryCubit extends Cubit { void fillComment(String comment) { this.comment = comment; + commentNeedsToBeFilled = comment.isNotEmpty && comment.length < 3; } } diff --git a/lib/features/diary/presentation/pages/diary_screen.dart b/lib/features/diary/presentation/pages/diary_screen.dart index c7f832d4..01c75db2 100644 --- a/lib/features/diary/presentation/pages/diary_screen.dart +++ b/lib/features/diary/presentation/pages/diary_screen.dart @@ -7,6 +7,7 @@ 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/main_background_bouble.dart'; +import 'package:eatvio_app/core/widgets/show_error_dialog.dart'; import 'package:eatvio_app/features/diary/presentation/bloc/consultant_comments_cubit/consultant_comments_cubit.dart'; import 'package:eatvio_app/features/diary/presentation/bloc/diary_cubit.dart'; import 'package:eatvio_app/features/diary/presentation/bloc/diary_state.dart'; @@ -24,31 +25,29 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class DiaryScreen extends StatelessWidget { - const DiaryScreen({super.key}); - + const DiaryScreen({super.key, required this.date}); + final DateTime? date; @override Widget build(BuildContext context) { - return const DiaryScreenContent(); + return DiaryScreenContent(date: date); } } class UserDiaryScreen extends StatelessWidget { static const path = '/user-diary-screen'; static const name = 'userDiaryScreen'; - const UserDiaryScreen({super.key, required this.diaryId, required this.currentPlanDay}); + const UserDiaryScreen({super.key, required this.diaryId}); final int diaryId; - final int currentPlanDay; @override Widget build(BuildContext context) { - return DiaryScreenContent(diaryId: diaryId, currentPlanDay: currentPlanDay); + return DiaryScreenContent(diaryId: diaryId); } } class DiaryScreenContent extends StatefulWidget { - const DiaryScreenContent({super.key, this.diaryId, this.currentPlanDay}); + const DiaryScreenContent({super.key, this.diaryId, this.date}); final int? diaryId; - final int? currentPlanDay; - + final DateTime? date; @override State createState() => _DiaryScreenContentState(); } @@ -61,7 +60,11 @@ class _DiaryScreenContentState extends State { final s = S.of(context); return MultiBlocProvider( providers: [ - BlocProvider(create: (BuildContext context) => sl()..fetchDiaryV2(needLoading: true, diaryId: widget.diaryId)), + BlocProvider( + create: (BuildContext context) => sl() + ..setInitialDate(widget.date) + ..fetchDiaryV2(needLoading: true, diaryId: widget.diaryId), + ), BlocProvider(create: (context) => sl()), ], child: BlocBuilder( @@ -91,7 +94,7 @@ class _DiaryScreenContentState extends State { physics: const BouncingScrollPhysics(), child: Column( children: [ - if (widget.diaryId == null) const CalendarWidget(), + if (widget.diaryId == null) CalendarWidget(date: widget.date), if (state is DiaryStateLoading) ...[const SizedBox(height: 60), const Loader()], if (state is DiaryStateLoaded) WakeupAndRetireWidget( @@ -132,11 +135,14 @@ class _DiaryScreenContentState extends State { setState(() { _isDiaryFinishing = true; }); - await context.read().finishDiaryDay( + final isFinished = await context.read().finishDiaryDay( diaryId: state.diary.id, wakeUp: state.diary.wakeup.toString(), retire: state.diary.retire.toString(), ); + if (!isFinished && context.mounted) { + showErrorDialog(context); + } setState(() { _isDiaryFinishing = false; }); diff --git a/lib/features/diary/presentation/widgets/add_product_modal.dart b/lib/features/diary/presentation/widgets/add_product_modal.dart index c3d9067d..a0895bf9 100644 --- a/lib/features/diary/presentation/widgets/add_product_modal.dart +++ b/lib/features/diary/presentation/widgets/add_product_modal.dart @@ -132,7 +132,12 @@ class _AddProductModalState extends State { 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), + trailing: Icon( + productNameController.text.trim().toLowerCase() == glossariesList[i].name.toLowerCase() + ? Icons.radio_button_checked + : Icons.radio_button_off, + color: Colors.blueAccent, + ), contentPadding: const EdgeInsets.only(left: 12), ); }, @@ -166,7 +171,9 @@ class _AddProductModalState extends State { InkWell( onTap: () { // Автоматически выбираем первый элемент, если текст точно совпадает - if (selectedGlossary == null && glossariesList.isNotEmpty && productNameController.text.trim().toLowerCase() == glossariesList[0].name.toLowerCase()) { + if (selectedGlossary == null && + glossariesList.isNotEmpty && + productNameController.text.trim().toLowerCase() == glossariesList[0].name.toLowerCase()) { selectedGlossary = glossariesList[0]; } diff --git a/lib/features/diary/presentation/widgets/calendar_widget.dart b/lib/features/diary/presentation/widgets/calendar_widget.dart index cce80a55..bcf1ab09 100644 --- a/lib/features/diary/presentation/widgets/calendar_widget.dart +++ b/lib/features/diary/presentation/widgets/calendar_widget.dart @@ -8,7 +8,8 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:table_calendar/table_calendar.dart'; class CalendarWidget extends StatefulWidget { - const CalendarWidget({super.key}); + const CalendarWidget({super.key, this.date}); + final DateTime? date; @override State createState() => _CalendarWidgetState(); @@ -18,6 +19,23 @@ class _CalendarWidgetState extends State { CalendarFormat _calendarFormat = CalendarFormat.week; DateTime _focusedDay = DateTime.now(); DateTime _selectedDay = DateTime.now(); + @override + void initState() { + super.initState(); + if (widget.date != null) { + _selectedDay = widget.date!; + _focusedDay = widget.date!; + } + } + + @override + void didUpdateWidget(CalendarWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.date != null && widget.date != oldWidget.date) { + _selectedDay = widget.date!; + _focusedDay = widget.date!; + } + } @override Widget build(BuildContext context) { diff --git a/lib/features/diary/presentation/widgets/comment_for_consultant_widget.dart b/lib/features/diary/presentation/widgets/comment_for_consultant_widget.dart index 3e311b59..bf73970a 100644 --- a/lib/features/diary/presentation/widgets/comment_for_consultant_widget.dart +++ b/lib/features/diary/presentation/widgets/comment_for_consultant_widget.dart @@ -2,6 +2,7 @@ import 'package:easy_debounce/easy_debounce.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_text_form_field.dart'; +import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart'; import 'package:eatvio_app/features/diary/presentation/bloc/diary_cubit.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -15,10 +16,14 @@ class CommentForConsultantWidget extends StatefulWidget { class _CommentForConsultantWidgetState extends State { final controller = TextEditingController(); + bool _commentNeedsToBeFilled = false; + @override void initState() { super.initState(); - if (widget.comment != null) controller.text = widget.comment ?? ''; + if (widget.comment != null) { + controller.text = widget.comment ?? ''; + } } @override @@ -41,13 +46,27 @@ class _CommentForConsultantWidgetState extends State CustomTextFormField( controller: controller, onChanged: (value) { - EasyDebounce.debounce('client-typing', const Duration(milliseconds: 500), () => context.read().fillComment(value)); + EasyDebounce.debounce( + 'client-typing', + const Duration(milliseconds: 500), + () => context.read().fillComment(value), + ); + if (widget.comment == null) { + setState(() { + _commentNeedsToBeFilled = value.isNotEmpty && value.length < 3; + }); + } }, + hasError: _commentNeedsToBeFilled, hintText: 'Укажите дополнительную информацию', minLines: 4, maxLines: 4, readOnly: widget.comment != null, ), + if (_commentNeedsToBeFilled) ...[ + const SizedBox(height: 4), + const ValidationErrorWidget(error: 'Минимум 3 символа'), + ], ], ), ), diff --git a/lib/features/diary/presentation/widgets/finish_day_button_widget.dart b/lib/features/diary/presentation/widgets/finish_day_button_widget.dart index ba13dd96..d38e32e3 100644 --- a/lib/features/diary/presentation/widgets/finish_day_button_widget.dart +++ b/lib/features/diary/presentation/widgets/finish_day_button_widget.dart @@ -4,7 +4,9 @@ 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/show_error_dialog.dart'; +import 'package:eatvio_app/features/diary/presentation/bloc/diary_cubit.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; class FinishDayButtonWidget extends StatefulWidget { final Future Function() onPressed; @@ -18,6 +20,14 @@ class FinishDayButtonWidget extends StatefulWidget { class _FinishDayButtonWidgetState extends State { void _onTap() { + if (context.read().commentNeedsToBeFilled) { + showErrorDialog( + context, + title: 'Комментарий слишком короткий', + description: 'Минимум 3 символа в комментарии для консультанта.', + ); + return; + } if (!widget.hasProducts) { showErrorDialog( context, diff --git a/lib/features/diary/presentation/widgets/portion_comment_widget.dart b/lib/features/diary/presentation/widgets/portion_comment_widget.dart index f1acbdc1..8c4c1b13 100644 --- a/lib/features/diary/presentation/widgets/portion_comment_widget.dart +++ b/lib/features/diary/presentation/widgets/portion_comment_widget.dart @@ -22,6 +22,11 @@ class PortionCommentWidget extends StatefulWidget { class _PortionCommentWidgetState extends State { final controller = TextEditingController(); @override + void dispose() { + controller.dispose(); + super.dispose(); + } + @override void didUpdateWidget(PortionCommentWidget oldWidget) { super.didUpdateWidget(oldWidget); if (widget.portionId != oldWidget.portionId) { @@ -57,17 +62,19 @@ class _PortionCommentWidgetState extends State { hintText: s.typePortionComment, hasError: hasError, onChanged: (value) { + final cubit = context.read(); EasyDebounce.debounce( - 'client-typing', + 'portion-comment-${widget.portionId}', const Duration(milliseconds: 500), () { - final hasPortionComment = state.commentsModel.portions.any((portion) => portion.id == widget.portionId); + final currentState = cubit.state; + final hasPortionComment = currentState.commentsModel.portions.any((portion) => portion.id == widget.portionId); if (hasPortionComment) { - final portion = state.commentsModel.portions.firstWhere((portion) => portion.id == widget.portionId); - context.read().updatePortionComment(portion.copyWith(comment: value)); + final portion = currentState.commentsModel.portions.firstWhere((portion) => portion.id == widget.portionId); + cubit.updatePortionComment(portion.copyWith(comment: value)); return; } - context.read().addPortionComment(PortionCommentModel(id: widget.portionId, comment: value)); + cubit.addPortionComment(PortionCommentModel(id: widget.portionId, comment: value)); }, ); }, diff --git a/lib/features/diets/presentation/pages/about_diet_screen.dart b/lib/features/diets/presentation/pages/about_diet_screen.dart index 54d74215..3823b471 100644 --- a/lib/features/diets/presentation/pages/about_diet_screen.dart +++ b/lib/features/diets/presentation/pages/about_diet_screen.dart @@ -37,8 +37,9 @@ class AboutDietScreen extends StatefulWidget { final int? id; final int? index; final bool navigateToComments; + final int? highlightedCommentId; - const AboutDietScreen({super.key, required this.index, required this.navigateToComments, required this.slug, required this.id}); + const AboutDietScreen({super.key, required this.index, required this.navigateToComments, required this.slug, required this.id, this.highlightedCommentId}); @override State createState() => _AboutDietScreenState(); @@ -433,6 +434,7 @@ class _AboutDietScreenState extends State { commentSourceType: CommentSourceType.diet, comment: state.comment, replyId: replyId, + highlightedCommentId: widget.highlightedCommentId, ), ], ), diff --git a/lib/features/feed/models/diets_pagination_model.dart b/lib/features/feed/models/diets_pagination_model.dart index 5da0bf7e..aec36295 100644 --- a/lib/features/feed/models/diets_pagination_model.dart +++ b/lib/features/feed/models/diets_pagination_model.dart @@ -22,12 +22,12 @@ abstract class Diet with _$Diet { @JsonKey(name: "id") required int id, @JsonKey(name: "slug") required String slug, @JsonKey(name: "name") required String name, - @JsonKey(name: "image") required String image, + @JsonKey(name: "image") required String? image, @JsonKey(name: "duration") required int duration, @JsonKey(name: "state") required DietState state, - @JsonKey(name: "type") required DietType type, - @JsonKey(name: "description_md") required String details, - @JsonKey(name: "daily_schedule") required Map>> dailySchedule, + @JsonKey(name: "type", fromJson: parseDietTypeFromJson) required DietType type, + @JsonKey(name: "description_md", defaultValue: '') required String details, + @JsonKey(name: "daily_schedule", defaultValue: {}) required Map>> dailySchedule, @JsonKey(name: "author") required Author author, @JsonKey(name: "total_likes") required int totalLikes, @JsonKey(name: "total_views") required int totalViews, @@ -40,12 +40,20 @@ abstract class Diet with _$Diet { factory Diet.fromJson(Map json) => _$DietFromJson(json); } +DietType parseDietTypeFromJson(Map? json) { + if (json == null) { + return const DietType(carb: false, light: false, meat: false, veg: false); + } + + return DietType.fromJson(json); +} + enum DietState { - @JsonKey(name: 'active') + @JsonValue('active') active, - @JsonKey(name: 'drafted') + @JsonValue('drafted') draft, - @JsonKey(name: 'personal') + @JsonValue('personal') personal; @override @@ -63,9 +71,7 @@ enum DietState { @freezed abstract class CreatedDiet with _$CreatedDiet { - const factory CreatedDiet({ - @JsonKey(name: "id") required int id, - }) = _CreatedDiet; + const factory CreatedDiet({@JsonKey(name: "id") required int id}) = _CreatedDiet; factory CreatedDiet.fromJson(Map json) => _$CreatedDietFromJson(json); } @@ -99,7 +105,7 @@ abstract class DailySchedule with _$DailySchedule { @JsonKey(name: "portion") required Portion portion, @JsonKey(name: "quantity", fromJson: _parseQuantity) required int quantity, @JsonKey(name: 'nutrition', includeToJson: false) DailyNutrition? dailyNutrition, - @JsonKey(name: 'product_nutritional_value_id', includeToJson: true, includeFromJson: false, includeIfNull: false) String? productNutritionalValueId, + @JsonKey(name: 'product_nutritional_value_id', includeToJson: true, includeFromJson: true, includeIfNull: false) int? productNutritionalValueId, }) = _DailySchedule; factory DailySchedule.fromJson(Map json) => _$DailyScheduleFromJson(json); @@ -135,10 +141,10 @@ enum Portion { } String getValue() => switch (this) { - Portion.grams => 'gr', - Portion.milliliters => 'milliliters', - Portion.pcs => 'pcs', - }; + Portion.grams => 'gr', + Portion.milliliters => 'milliliters', + Portion.pcs => 'pcs', + }; } @freezed diff --git a/lib/features/feed/presentation/pages/feed_screen.dart b/lib/features/feed/presentation/pages/feed_screen.dart index cdeac843..dfc3e9d4 100644 --- a/lib/features/feed/presentation/pages/feed_screen.dart +++ b/lib/features/feed/presentation/pages/feed_screen.dart @@ -248,7 +248,7 @@ class _FeedScreenContentState extends State { 3 when hidePlans => AddItemButton( onTap: state.profile?.roles.contains('consultant') ?? false ? () async { - await GoRouter.of(context).pushNamed(CreateDietScreen.name); + await GoRouter.of(context).pushNamed(CreateDietScreen.name, extra: {'isEditing': false}); } : null, ), diff --git a/lib/features/feed/presentation/widgets/comments_widget.dart b/lib/features/feed/presentation/widgets/comments_widget.dart index f8b3ecba..61ba39d2 100644 --- a/lib/features/feed/presentation/widgets/comments_widget.dart +++ b/lib/features/feed/presentation/widgets/comments_widget.dart @@ -23,14 +23,60 @@ class CommentsWidget extends StatefulWidget { final CommentSourceType commentSourceType; final CommentModel comment; final int? replyId; + final int? highlightedCommentId; - const CommentsWidget({super.key, required this.id, required this.commentSourceType, required this.comment, this.replyId}); + const CommentsWidget({ + super.key, + required this.id, + required this.commentSourceType, + required this.comment, + this.replyId, + this.highlightedCommentId, + }); @override State createState() => _CommentsWidgetState(); } class _CommentsWidgetState extends State { + final Map commentKeys = {}; + + @override + void initState() { + super.initState(); + if (widget.highlightedCommentId != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollToHighlightedComment(); + }); + } + } + + @override + void didUpdateWidget(CommentsWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.highlightedCommentId != null && widget.highlightedCommentId != oldWidget.highlightedCommentId) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollToHighlightedComment(); + }); + } else if (widget.comment != oldWidget.comment && widget.highlightedCommentId != null) { + // Comments might have just loaded + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollToHighlightedComment(); + }); + } + } + + void _scrollToHighlightedComment() { + final key = commentKeys[widget.highlightedCommentId]; + if (key != null && key.currentContext != null) { + Scrollable.ensureVisible( + key.currentContext!, + duration: const Duration(milliseconds: 500), + curve: Curves.easeInOut, + ); + } + } + @override Widget build(BuildContext context) { final s = S.of(context); @@ -49,8 +95,10 @@ class _CommentsWidgetState extends State { } Widget _comment(S s, Comment comment, [double radius = 18, bool isChild = false]) { + final key = commentKeys.putIfAbsent(comment.id, () => GlobalKey()); final isMe = (sl().state as ProfileAvatarStateLoaded).profile.slug == comment.authorSlug; return GestureDetector( + key: key, onTap: () { showCommentsActionsBottomSheet( context, @@ -83,7 +131,7 @@ class _CommentsWidgetState extends State { child: Container( width: MediaQuery.of(context).size.width, decoration: BoxDecoration( - color: widget.replyId == comment.id ? appTheme.blueA400.withValues(alpha: 0.1) : Colors.white, + color: (widget.replyId == comment.id || widget.highlightedCommentId == comment.id) ? appTheme.blueA400.withValues(alpha: 0.1) : Colors.white, borderRadius: BorderRadius.circular(16), ), child: Padding( diff --git a/lib/features/my_diets/presentation/cubit/my_diets_cubit.dart b/lib/features/my_diets/presentation/cubit/my_diets_cubit.dart index 8a93ec17..398fdb63 100644 --- a/lib/features/my_diets/presentation/cubit/my_diets_cubit.dart +++ b/lib/features/my_diets/presentation/cubit/my_diets_cubit.dart @@ -291,16 +291,16 @@ class MyDietsCubit extends Cubit { } } - Future publishDiet(int dietId) async { + Future publishDiet(int dietId) async { try { - await createDietRepository.updateDiet( + final statusCode = await createDietRepository.updateDiet( dietId: dietId, state: DietState.active, ); - return true; + return statusCode; } catch (e, st) { log(e.toString(), stackTrace: st); - return false; + return 0; } } diff --git a/lib/features/my_diets/presentation/my_diets_screen.dart b/lib/features/my_diets/presentation/my_diets_screen.dart index 99d39b07..1c5bad54 100644 --- a/lib/features/my_diets/presentation/my_diets_screen.dart +++ b/lib/features/my_diets/presentation/my_diets_screen.dart @@ -60,15 +60,14 @@ class MyDietsScreenContent extends StatelessWidget { builder: (context, state) { return NestedScrollView( controller: switch (state.selectedStateIndex) { - 1 => personalDietsScrollController, - _ => activeDietsScrollController + 1 => draftDietsScrollController, + 2 => personalDietsScrollController, + _ => activeDietsScrollController, }, headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { return [ SliverAppBar( - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(bottom: Radius.circular(12)), - ), + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(bottom: Radius.circular(12))), backgroundColor: appTheme.gray100, foregroundColor: appTheme.gray100, automaticallyImplyLeading: false, @@ -78,20 +77,14 @@ class MyDietsScreenContent extends StatelessWidget { snap: true, scrolledUnderElevation: 0, bottom: PreferredSize( - preferredSize: Size( - MediaQuery.sizeOf(context).width, - 64, - ), + preferredSize: Size(MediaQuery.sizeOf(context).width, 64), child: Padding( padding: const EdgeInsets.only(bottom: 0), child: Column( mainAxisSize: MainAxisSize.min, children: [ const SizedBox(height: 8), - const Padding( - padding: EdgeInsets.symmetric(horizontal: 4), - child: MyDietsTab(), - ), + const Padding(padding: EdgeInsets.symmetric(horizontal: 4), child: MyDietsTab()), const SizedBox(height: 8), Row( children: [ @@ -102,24 +95,19 @@ class MyDietsScreenContent extends StatelessWidget { backgroundColor: appTheme.whiteA700, borderRadius: 100, searchController: switch (state.selectedStateIndex) { - 1 => personalDietsSearchController, - _ => activeDietsSearchController + 1 => draftDietsSearchController, + 2 => personalDietsSearchController, + _ => activeDietsSearchController, }, onChanged: (query) { - EasyDebounce.debounce( - 'searchDiets', - const Duration(milliseconds: 500), - () { - context.read().loadCurrentDiets(query: query == '' ? null : query); - }, - ); + EasyDebounce.debounce('searchDiets', const Duration(milliseconds: 500), () { + context.read().loadCurrentDiets(query: query == '' ? null : query); + }); }, ), ), const SizedBox(width: 12), - AddItemButton( - onTap: () => GoRouter.of(context).pushNamed(CreateDietScreen.name), - ), + AddItemButton(onTap: () => GoRouter.of(context).pushNamed(CreateDietScreen.name, extra: {'isEditing': false})), const SizedBox(width: 4), ], ), @@ -136,15 +124,14 @@ class MyDietsScreenContent extends StatelessWidget { child: BlocBuilder( builder: (BuildContext context, MyDietsState state) { if (state.isLoading) { - return Scaffold( - backgroundColor: Colors.grey.shade200, - body: const Loader(), - ); + return Scaffold(backgroundColor: Colors.grey.shade200, body: const Loader()); } else if (state.error != null) { return DataErrorWidget(onReload: () => context.read().loadAllDiets()); } else { - final gotDietLoadingError = state.activeDietsError != null && state.selectedStateIndex == 0 || - state.personalDietsError != null && state.selectedStateIndex == 1; + final gotDietLoadingError = + state.activeDietsError != null && state.selectedStateIndex == 0 || + state.draftDietsError != null && state.selectedStateIndex == 1 || + state.personalDietsError != null && state.selectedStateIndex == 2; if (gotDietLoadingError) { return DataErrorWidget(onReload: () => context.read().loadCurrentDiets()); @@ -155,7 +142,8 @@ class MyDietsScreenContent extends StatelessWidget { } final diets = switch (state.selectedStateIndex) { - 1 => state.personalDiets, + 2 => state.personalDiets, + 1 => state.draftDiets, _ => state.activeDiets, }; @@ -166,10 +154,7 @@ class MyDietsScreenContent extends StatelessWidget { shrinkWrap: true, physics: const BouncingScrollPhysics(), itemBuilder: (context, index) { - return MyDietWidget( - diet: diets[index], - key: ValueKey(diets[index].id), - ); + return MyDietWidget(diet: diets[index], key: ValueKey(diets[index].id)); }, separatorBuilder: (context, index) { return const SizedBox(height: 20); @@ -179,11 +164,7 @@ class MyDietsScreenContent extends StatelessWidget { Positioned( right: 0, bottom: 74, - child: FilterBadge( - filterBottomSheet: DietFilterBottomSheet( - myDietsCubit: context.read(), - ), - ), + child: FilterBadge(filterBottomSheet: DietFilterBottomSheet(myDietsCubit: context.read())), ), ], ); diff --git a/lib/features/my_diets/presentation/widgets/my_diet_widget.dart b/lib/features/my_diets/presentation/widgets/my_diet_widget.dart index f2fccd5c..0799fc77 100644 --- a/lib/features/my_diets/presentation/widgets/my_diet_widget.dart +++ b/lib/features/my_diets/presentation/widgets/my_diet_widget.dart @@ -1,19 +1,22 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:eatvio_app/core/app_config.dart'; -import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/theme/custom_text_style.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/enums/product_type.dart'; +import 'package:eatvio_app/core/utils/toasts.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/info_row.dart'; import 'package:eatvio_app/core/widgets/show_error_dialog.dart'; +import 'package:eatvio_app/core/widgets/success_published_diet_bottom_sheet.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'; import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; import 'package:eatvio_app/features/my_diets/presentation/cubit/my_diets_cubit.dart'; import 'package:eatvio_app/features/my_diets/presentation/cubit/my_diets_state.dart'; import 'package:eatvio_app/features/my_diets/presentation/widgets/diet_deleted_bottom_sheet.dart'; +import 'package:eatvio_app/features/my_diets/presentation/widgets/diet_drafetd_bottom_sheet.dart'; import 'package:eatvio_app/features/personal_diet_suggestion/presentation/personal_diet_suggestion_screen.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; @@ -33,6 +36,73 @@ class MyDietWidget extends StatefulWidget { } class _MyDietWidgetState extends State { + + Future _editDiet(Diet diet, BuildContext context) async{ + await GoRouter.of(context).pushNamed(CreateDietScreen.name, extra: {'diet': diet, 'isEditing': true}); + if (!context.mounted) return; + await context.read().loadAllDiets(); + } + + void _suggestDiet(Diet diet) { + GoRouter.of(context).pushNamed(PersonalDietSuggestionScreen.name, extra: diet.id); + } + + Future _shareDiet(int dietId) async { + await SharePlus.instance.share(ShareParams(uri: Uri.parse('${sl().baseUrl}/ru/diet/$dietId'))); + } + + Future _publishDiet(Diet diet, BuildContext context) async { + final statusCode = await context.read().publishDiet(widget.diet.id); + switch (statusCode) { + case 201: + if (!context.mounted) return; + showBottomSheetWithBlur( + context: context, + child: SuccessPublishedDietOrPlanBottomSheet(title: widget.diet.name), + ); + await context.read().loadAllDiets(); + break; + case 422: + showErrorToast('Пожалуйста, заполните все обязательные поля для публикации'); + break; + default: + showErrorToast('Произошла ошибка. Попробуйте ещё раз через некоторое время'); + } + } + + void _unpublishDiet(Diet diet, BuildContext context) async { + final result = await context.read().unpublishDiet(widget.diet.id); + if (result) { + if (!context.mounted) return; + showBottomSheetWithBlur( + context: context, + height: 250, + child: DietDraftedBottomSheet(title: widget.diet.name), + ); + await context.read().loadAllDiets(); + } else { + showErrorToast('Произошла ошибка. Попробуйте ещё раз через некоторое время'); + } + } + + void _createBasedOnChosen(Diet diet, BuildContext context) async { + await showErrorDialog(context, title: 'Предупреждение', description: 'Вы переходите к созданию новой диеты на основе скопированной'); + if (!context.mounted) return; + GoRouter.of(context).pushNamed(CreateDietScreen.name, extra: {'diet': diet, 'isEditing': false}); + } + + void _deleteDiet(Diet diet, BuildContext context) async { + final result = await context.read().deleteDiet(widget.diet.id); + if (result) { + if (!context.mounted) return; + showBottomSheetWithBlur( + context: context, + child: DietDeletedBottomSheet(title: widget.diet.name), + ); + await context.read().loadAllDiets(); + } + } + @override Widget build(BuildContext context) { final s = S.of(context); @@ -42,28 +112,34 @@ class _MyDietWidgetState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Stack( - children: [ - InkWell( - onTap: () => GoRouter.of(context).pushNamed('aboutDietScreen', extra: {'slug': widget.diet.slug, 'navigateToComments': false}), - child: ClipRRect( - borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), - child: CachedNetworkImage( - imageUrl: '${sl().baseUrl}/storage/${widget.diet.image}', - fit: BoxFit.fitWidth, - width: MediaQuery.of(context).size.width, + if (widget.diet.image != null && (widget.diet.image?.isNotEmpty ?? false)) + Stack( + children: [ + InkWell( + onTap: () { + if (widget.diet.state == DietState.draft) { + return; + } + GoRouter.of(context).pushNamed('aboutDietScreen', extra: {'slug': widget.diet.slug, 'navigateToComments': false}); + }, + child: ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), + child: CachedNetworkImage( + imageUrl: '${sl().baseUrl}/storage/${widget.diet.image}', + fit: BoxFit.fitWidth, + width: MediaQuery.of(context).size.width, + ), ), ), - ), - Positioned( - bottom: 16, - child: SizedBox( - width: MediaQuery.sizeOf(context).width, - child: InfoRow(diet: widget.diet, itemsBackgroundColor: appTheme.whiteA700, disableDescriptionOfTypes: true, type: ProductType.diet), + Positioned( + bottom: 16, + child: SizedBox( + width: MediaQuery.sizeOf(context).width, + child: InfoRow(diet: widget.diet, itemsBackgroundColor: appTheme.whiteA700, disableDescriptionOfTypes: true, type: ProductType.diet), + ), ), - ), - ], - ), + ], + ), const SizedBox(height: 12), Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -78,19 +154,20 @@ class _MyDietWidgetState extends State { padding: const EdgeInsets.symmetric(horizontal: 16), child: Builder( builder: (context) { - String details = widget.diet.details.length > 200 - ? '${widget.diet.details.substring(0, 200)}...' - : widget.diet.details; + String details = widget.diet.details.length > 200 ? '${widget.diet.details.substring(0, 200)}...' : widget.diet.details; - return MarkdownHtmlWidget( - markdownText: details, - fontSize: TextStyles.textSMedium.fontSize ?? 14, - textColor: appTheme.black, - ); + return MarkdownHtmlWidget(markdownText: details, fontSize: TextStyles.textSMedium.fontSize ?? 14, textColor: appTheme.black); }, ), ), ], + if (widget.diet.image == null || (widget.diet.image?.isEmpty ?? true)) ...[ + const SizedBox(height: 12), + SizedBox( + width: MediaQuery.sizeOf(context).width, + child: InfoRow(diet: widget.diet, itemsBackgroundColor: appTheme.dividerColor, disableDescriptionOfTypes: true, type: ProductType.diet), + ), + ], ], ), const SizedBox(height: 20), @@ -100,67 +177,38 @@ class _MyDietWidgetState extends State { padding: const EdgeInsets.symmetric(horizontal: 16.0), child: Column( children: [ - // Верхний ряд: Удалить + Редактировать (или + Поделиться для опубликованных) Row( children: [ - // Кнопка удаления (слева) - CustomOutlinedButton( - onPressed: () async { - final result = await context.read().deleteDiet(widget.diet.id); - if (result) { - if (!context.mounted) return; - showBottomSheetWithBlur( - context: context, - child: DietDeletedBottomSheet(title: widget.diet.name), - ); - await context.read().loadAllDiets(); - } - }, - height: 52, - width: 52, - leftIcon: SvgPicture.asset('assets/icons/trash_can.svg', width: 20, height: 20), - buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.red), - buttonStyle: ButtonStyle( - shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.circular(20))), - backgroundColor: WidgetStatePropertyAll(appTheme.lightGray0), - side: const WidgetStatePropertyAll(BorderSide.none), - ), - text: '', - ), - const SizedBox(width: 6), - // Кнопка редактирования (центр) Expanded( child: CustomOutlinedButton( onPressed: () async { if (state.selectedStateIndex == 1) { - await showErrorDialog(context, title: 'Предупреждение', description: 'Вы можете создать новую диету на основе существующей.'); - AppRouter.navigatorKey.currentContext?.pushNamed(CreateDietScreen.name, extra: widget.diet); + _publishDiet(widget.diet, context); + } else if (state.selectedStateIndex == 2) { + _suggestDiet(widget.diet); } else { - GoRouter.of(context).pushNamed(CreateDietScreen.name, extra: widget.diet); - } + _editDiet(widget.diet, context); + } + }, + text: switch (state.selectedStateIndex) { + 1 => s.publish, + 2 => s.toOffer, + _ => s.edit, }, - text: s.edit, height: 52, - buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.black), + buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700), buttonStyle: ButtonStyle( shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.circular(20))), - backgroundColor: WidgetStatePropertyAll(appTheme.lightGray0), + backgroundColor: WidgetStatePropertyAll(appTheme.blueA400), side: const WidgetStatePropertyAll(BorderSide.none), ), ), ), - // Кнопка поделиться (справа) для опубликованных if (state.selectedStateIndex == 0) ...[ const SizedBox(width: 6), CustomOutlinedButton( onPressed: () async { - final box = context.findRenderObject() as RenderBox?; - await SharePlus.instance.share( - ShareParams( - uri: Uri.parse('${sl().baseUrl}/ru/diet/${widget.diet.id}'), - sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, - ), - ); + await _shareDiet(widget.diet.id); }, height: 52, width: 52, @@ -179,26 +227,125 @@ class _MyDietWidgetState extends State { text: '', ), ], + if (state.selectedStateIndex == 2) ...[ + const SizedBox(width: 6), + CustomOutlinedButton( + onPressed: () async { + _createBasedOnChosen(widget.diet, context); + }, + height: 52, + width: 52, + leftIcon: SvgPicture.asset( + 'assets/icons/copy2.svg', + width: 20, + height: 20, + colorFilter: ColorFilter.mode(appTheme.whiteA700, BlendMode.srcIn), + ), + buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.red), + buttonStyle: ButtonStyle( + shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.circular(20))), + backgroundColor: WidgetStatePropertyAll(appTheme.blueA400), + side: const WidgetStatePropertyAll(BorderSide.none), + ), + text: '', + ), + ], ], ), - // Нижний ряд: Предложить (только для персональных) if (state.selectedStateIndex == 1) ...[ const SizedBox(height: 8), - CustomOutlinedButton( - onPressed: () { - GoRouter.of(context).pushNamed(PersonalDietSuggestionScreen.name, extra: widget.diet.id); - }, - text: s.toOffer, - height: 52, - buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700), - buttonStyle: ButtonStyle( - shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.circular(20))), - backgroundColor: WidgetStatePropertyAll(appTheme.blueA400), - side: const WidgetStatePropertyAll(BorderSide.none), - ), + Row( + children: [ + Expanded( + child: CustomOutlinedButton( + onPressed: () async { + GoRouter.of(context).pushNamed(DietPreviewScreen.name, extra: {'diet': widget.diet, 'isImageInBase64': false}); + }, + text: 'Превью', + height: 52, + buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.black), + buttonStyle: ButtonStyle( + shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.circular(20))), + backgroundColor: WidgetStatePropertyAll(appTheme.lightGray0), + side: const WidgetStatePropertyAll(BorderSide.none), + ), + ), + ), + const SizedBox(width: 6), + CustomOutlinedButton( + onPressed: () async { + _deleteDiet(widget.diet, context); + }, + height: 52, + width: 52, + leftIcon: SvgPicture.asset('assets/icons/trash_can.svg', width: 20, height: 20), + buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.red), + buttonStyle: ButtonStyle( + shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.circular(20))), + backgroundColor: WidgetStatePropertyAll(appTheme.lightGray0), + side: const WidgetStatePropertyAll(BorderSide.none), + ), + text: '', + ), + ], ), ], const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: CustomOutlinedButton( + onPressed: () async { + if (state.selectedStateIndex == 0) { + _unpublishDiet(widget.diet, context); + } else { + _editDiet(widget.diet, context); + } + }, + text: switch (state.selectedStateIndex) { + 0 => s.removeFromActive, + _ => s.edit, + }, + height: 52, + buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.black), + buttonStyle: ButtonStyle( + shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.circular(20))), + backgroundColor: WidgetStatePropertyAll(appTheme.lightGray0), + side: const WidgetStatePropertyAll(BorderSide.none), + ), + ), + ), + const SizedBox(width: 6), + CustomOutlinedButton( + onPressed: () async { + if (state.selectedStateIndex == 1) { + _createBasedOnChosen(widget.diet, context); + return; + } + _deleteDiet(widget.diet, context); + }, + height: 52, + width: 52, + leftIcon: switch (state.selectedStateIndex) { + 1 => SvgPicture.asset( + 'assets/icons/copy2.svg', + width: 20, + height: 20, + colorFilter: ColorFilter.mode(appTheme.gray, BlendMode.srcIn), + ), + _ => SvgPicture.asset('assets/icons/trash_can.svg', width: 20, height: 20), + }, + buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.red), + buttonStyle: ButtonStyle( + shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.circular(20))), + backgroundColor: WidgetStatePropertyAll(appTheme.lightGray0), + side: const WidgetStatePropertyAll(BorderSide.none), + ), + text: '', + ), + ], + ), + const SizedBox(height: 8), ], ), ); @@ -209,4 +356,3 @@ class _MyDietWidgetState extends State { ); } } - diff --git a/lib/features/my_diets/presentation/widgets/my_diets_tab.dart b/lib/features/my_diets/presentation/widgets/my_diets_tab.dart index be6a0d95..a653441c 100644 --- a/lib/features/my_diets/presentation/widgets/my_diets_tab.dart +++ b/lib/features/my_diets/presentation/widgets/my_diets_tab.dart @@ -21,6 +21,7 @@ class _MyDietsTabState extends State { final s = S.of(context); if (tabTitles.isEmpty) { tabTitles.add(s.published); + tabTitles.add(s.drafted); tabTitles.add(s.personal); } super.didChangeDependencies(); @@ -30,29 +31,37 @@ class _MyDietsTabState extends State { Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Container( - height: 48, - decoration: BoxDecoration( - color: appTheme.whiteA700, - borderRadius: BorderRadius.circular(100), - ), - padding: const EdgeInsets.all(4), + return Align( + alignment: Alignment.centerLeft, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, child: Row( children: [ - ...List.generate( - 2, - (index) => Expanded( - child: AnimatedOptionButton( - textStyle: TextStyles.textSMedium, - isSelected: state.selectedStateIndex == index, - text: tabTitles[index], - onTap: () { - widget.additionalOnTap?.call(); - context.read().changeSelectedStateIndex(index); - }, - ), + Container( + width: 484, + height: 48, + decoration: BoxDecoration( + color: appTheme.whiteA700, + borderRadius: BorderRadius.circular(100), + ), + padding: const EdgeInsets.all(4), + child: Row( + children: [ + ...List.generate( + 3, + (index) => Expanded( + child: AnimatedOptionButton( + textStyle: TextStyles.textSMedium, + isSelected: state.selectedStateIndex == index, + text: tabTitles[index], + onTap: () { + widget.additionalOnTap?.call(); + context.read().changeSelectedStateIndex(index); + }, + ), + ), + ), + ], ), ), ], diff --git a/lib/features/notifications/models/notification_model.dart b/lib/features/notifications/models/notification_model.dart index cc77616e..b178eb91 100644 --- a/lib/features/notifications/models/notification_model.dart +++ b/lib/features/notifications/models/notification_model.dart @@ -10,16 +10,24 @@ class PlanSubscriptionStartedToAuthorNotification implements BaseNotificationMod @override final bool isRead; final String planName; + final int planId; final String customerName; + final int userId; + final String userPhotoPath; final String chatId; + final String chatUrl; @override final DateTime createdAt; PlanSubscriptionStartedToAuthorNotification({ required this.type, required this.planName, + required this.planId, required this.customerName, + required this.userId, + required this.userPhotoPath, required this.chatId, + required this.chatUrl, required this.isRead, required this.createdAt, }); @@ -27,9 +35,13 @@ class PlanSubscriptionStartedToAuthorNotification implements BaseNotificationMod 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'], + planName: json['plan_name'] ?? '', + planId: json['plan_id'] ?? 0, + customerName: json['user_name'] ?? '', + userId: json['user_id'] ?? 0, + userPhotoPath: json['user_photo_path'] ?? '', + chatId: json['chat_id']?.toString() ?? '', + chatUrl: json['chat_url'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -42,14 +54,22 @@ class PlanSubscriptionCanceledToAuthorNotification implements BaseNotificationMo @override final bool isRead; final String planName; + final int planId; final String customerName; + final int userId; + final String userPhotoPath; + final String chatUrl; @override final DateTime createdAt; PlanSubscriptionCanceledToAuthorNotification({ required this.type, required this.planName, + required this.planId, required this.customerName, + required this.userId, + required this.userPhotoPath, + required this.chatUrl, required this.isRead, required this.createdAt, }); @@ -57,8 +77,12 @@ class PlanSubscriptionCanceledToAuthorNotification implements BaseNotificationMo factory PlanSubscriptionCanceledToAuthorNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return PlanSubscriptionCanceledToAuthorNotification( type: type, - planName: json['plan_name'], - customerName: json['user_name'], + planName: json['plan_name'] ?? '', + planId: json['plan_id'] ?? 0, + customerName: json['user_name'] ?? '', + userId: json['user_id'] ?? 0, + userPhotoPath: json['user_photo_path'] ?? '', + chatUrl: json['chat_url'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -72,16 +96,28 @@ class ChosenPlanStartedToClientNotification implements BaseNotificationModel { final bool isRead; final String planName; final int planId; + final String consultantName; + final String peerProfileUrl; @override final DateTime createdAt; - ChosenPlanStartedToClientNotification({required this.type, required this.planName, required this.planId, required this.isRead, required this.createdAt}); + ChosenPlanStartedToClientNotification({ + required this.type, + required this.planName, + required this.planId, + required this.consultantName, + required this.peerProfileUrl, + required this.isRead, + required this.createdAt, + }); factory ChosenPlanStartedToClientNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return ChosenPlanStartedToClientNotification( type: type, - planName: json['plan_name'], - planId: json['plan_id'], + planName: json['plan_name'] ?? '', + planId: json['plan_id'] ?? 0, + consultantName: json['consultant_name'] ?? '', + peerProfileUrl: json['peer_profile_url'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -94,15 +130,26 @@ class ChosenPlanTomorrowReminderToClientNotification implements BaseNotification @override final bool isRead; final String planName; + final String consultantName; + final int chatId; @override final DateTime createdAt; - ChosenPlanTomorrowReminderToClientNotification({required this.type, required this.planName, required this.isRead, required this.createdAt}); + ChosenPlanTomorrowReminderToClientNotification({ + required this.type, + required this.planName, + required this.consultantName, + required this.chatId, + required this.isRead, + required this.createdAt, + }); factory ChosenPlanTomorrowReminderToClientNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return ChosenPlanTomorrowReminderToClientNotification( type: type, - planName: json['plan_name'], + planName: json['plan_name'] ?? '', + consultantName: json['consultant_name'] ?? '', + chatId: json['chat_id'] ?? 0, isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -114,15 +161,19 @@ class DiaryReminderNotification implements BaseNotificationModel { final NotificationType type; @override final bool isRead; + final int? diaryId; + final String? message; @override final DateTime createdAt; - DiaryReminderNotification({required this.type, required this.isRead, required this.createdAt}); + DiaryReminderNotification({required this.type, required this.isRead, this.diaryId, this.message, required this.createdAt}); factory DiaryReminderNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return DiaryReminderNotification( type: type, isRead: isRead ?? false, + diaryId: json['diary_id'], + message: json['message'], createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); } @@ -133,16 +184,36 @@ class ConsultantLeftCommentNotification implements BaseNotificationModel { final NotificationType type; @override final bool isRead; + final int chosenPlanId; + final int diaryId; final DateTime date; + final String planName; + final String userName; + final String consultantName; @override final DateTime createdAt; - ConsultantLeftCommentNotification({required this.type, required this.date, required this.isRead, required this.createdAt}); + ConsultantLeftCommentNotification({ + required this.type, + required this.chosenPlanId, + required this.diaryId, + required this.date, + required this.planName, + required this.userName, + required this.consultantName, + required this.isRead, + required this.createdAt, + }); factory ConsultantLeftCommentNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return ConsultantLeftCommentNotification( type: type, + chosenPlanId: json['chosen_plan_id'] ?? 0, + diaryId: json['diary_id'] ?? 0, date: parseDate(json['diary_date'] ?? DateTime.now().toIso8601String()), + planName: json['plan_name'] ?? '', + userName: json['user_name'] ?? '', + consultantName: json['consultant_name'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -174,14 +245,34 @@ class ChosenPlanFinishedToClientNotification implements BaseNotificationModel { @override final bool isRead; final String planName; + final int chosenPlanId; + final String consultantName; + final String consultantPhotoPath; + final String consultantProfileUrl; + final int consultantId; @override final DateTime createdAt; - ChosenPlanFinishedToClientNotification({required this.type, required this.planName, required this.isRead, required this.createdAt}); + ChosenPlanFinishedToClientNotification({ + required this.type, + required this.planName, + required this.chosenPlanId, + required this.consultantName, + required this.consultantPhotoPath, + required this.consultantProfileUrl, + required this.consultantId, + required this.isRead, + required this.createdAt, + }); factory ChosenPlanFinishedToClientNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return ChosenPlanFinishedToClientNotification( type: type, - planName: json['plan_name'], + planName: json['plan_name'] ?? '', + chosenPlanId: json['chosen_plan_id'] ?? 0, + consultantName: json['consultant_name'] ?? '', + consultantPhotoPath: json['consultant_photo_path'] ?? '', + consultantProfileUrl: json['consultant_profile_url'] ?? '', + consultantId: json['consultant_id'] ?? 0, isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -194,14 +285,34 @@ class PlanSubscriptionCanceledToCustomerNotification implements BaseNotification @override final bool isRead; final String planName; + final int planId; + final String authorName; + final int authorId; + final String authorPhotoPath; + final String chatUrl; @override final DateTime createdAt; - PlanSubscriptionCanceledToCustomerNotification({required this.type, required this.planName, required this.isRead, required this.createdAt}); + PlanSubscriptionCanceledToCustomerNotification({ + required this.type, + required this.planName, + required this.planId, + required this.authorName, + required this.authorId, + required this.authorPhotoPath, + required this.chatUrl, + required this.isRead, + required this.createdAt, + }); factory PlanSubscriptionCanceledToCustomerNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return PlanSubscriptionCanceledToCustomerNotification( type: type, - planName: json['plan_name'], + planName: json['plan_name'] ?? '', + planId: json['plan_id'] ?? 0, + authorName: json['author_name'] ?? '', + authorId: json['author_id'] ?? 0, + authorPhotoPath: json['author_photo_path'] ?? '', + chatUrl: json['chat_url'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -215,6 +326,10 @@ class ChosenPlanStartedToConsultantNotification implements BaseNotificationModel final bool isRead; final String planName; final String customerName; + final int planId; + final int customerId; + final String peerProfileUrl; + final String consultationUrl; @override final DateTime createdAt; ChosenPlanStartedToConsultantNotification({ @@ -222,6 +337,10 @@ class ChosenPlanStartedToConsultantNotification implements BaseNotificationModel required this.isRead, required this.planName, required this.customerName, + required this.planId, + required this.customerId, + required this.peerProfileUrl, + required this.consultationUrl, required this.createdAt, }); @@ -229,8 +348,12 @@ class ChosenPlanStartedToConsultantNotification implements BaseNotificationModel return ChosenPlanStartedToConsultantNotification( type: type, isRead: isRead ?? false, - planName: json['plan_name'], - customerName: json['client_name'], + planName: json['plan_name'] ?? '', + customerName: json['client_name'] ?? '', + planId: json['plan_id'] ?? 0, + customerId: json['client_id'] ?? 0, + peerProfileUrl: json['peer_profile_url'] ?? '', + consultationUrl: json['consultation_url'] ?? '', createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); } @@ -243,12 +366,14 @@ class ChosenPlanTomorrowReminderToConsultantNotification implements BaseNotifica final bool isRead; final String planName; final String customerName; + final int chatId; @override final DateTime createdAt; ChosenPlanTomorrowReminderToConsultantNotification({ required this.type, required this.planName, required this.customerName, + required this.chatId, required this.isRead, required this.createdAt, }); @@ -256,8 +381,9 @@ class ChosenPlanTomorrowReminderToConsultantNotification implements BaseNotifica factory ChosenPlanTomorrowReminderToConsultantNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return ChosenPlanTomorrowReminderToConsultantNotification( type: type, - planName: json['plan_name'], - customerName: json['client_name'], + planName: json['plan_name'] ?? '', + customerName: json['client_name'] ?? '', + chatId: json['chat_id'] ?? 0, isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -270,14 +396,22 @@ class PlanSubscriptionPaidToAuthorNotification implements BaseNotificationModel @override final bool isRead; final String planName; + final int planId; final String customerName; + final int userId; + final String userPhotoPath; + final String chatUrl; @override final DateTime createdAt; PlanSubscriptionPaidToAuthorNotification({ required this.type, required this.isRead, required this.planName, + required this.planId, required this.customerName, + required this.userId, + required this.userPhotoPath, + required this.chatUrl, required this.createdAt, }); @@ -285,8 +419,12 @@ class PlanSubscriptionPaidToAuthorNotification implements BaseNotificationModel return PlanSubscriptionPaidToAuthorNotification( type: type, isRead: isRead ?? false, - planName: json['plan_name'], - customerName: json['user_name'], + planName: json['plan_name'] ?? '', + planId: json['plan_id'] ?? 0, + customerName: json['user_name'] ?? '', + userId: json['user_id'] ?? 0, + userPhotoPath: json['user_photo_path'] ?? '', + chatUrl: json['chat_url'] ?? '', createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); } @@ -300,6 +438,9 @@ class ChosenPlanFinishedToConsultantNotification implements BaseNotificationMode final String planName; final String customerName; final int planId; + final int chosenPlanId; + final String clientPhotoPath; + final int clientId; @override final DateTime createdAt; ChosenPlanFinishedToConsultantNotification({ @@ -307,6 +448,9 @@ class ChosenPlanFinishedToConsultantNotification implements BaseNotificationMode required this.planName, required this.customerName, required this.planId, + required this.chosenPlanId, + required this.clientPhotoPath, + required this.clientId, required this.isRead, required this.createdAt, }); @@ -314,9 +458,12 @@ class ChosenPlanFinishedToConsultantNotification implements BaseNotificationMode 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['chosen_plan_id'], + planName: json['plan_name'] ?? '', + customerName: json['client_name'] ?? '', + planId: json['plan_id'] ?? 0, + chosenPlanId: json['chosen_plan_id'] ?? 0, + clientPhotoPath: json['client_photo_path'] ?? '', + clientId: json['client_id'] ?? 0, isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -328,15 +475,31 @@ class ChosenPlanLastDayFinishedForUser implements BaseNotificationModel { final NotificationType type; @override final bool isRead; + final String planName; + final int chosenPlanId; + final String complaintUrl; + final String chatUrl; @override final DateTime createdAt; - ChosenPlanLastDayFinishedForUser({required this.type, required this.isRead, required this.createdAt}); + ChosenPlanLastDayFinishedForUser({ + required this.type, + required this.isRead, + required this.planName, + required this.chosenPlanId, + required this.complaintUrl, + required this.chatUrl, + required this.createdAt, + }); factory ChosenPlanLastDayFinishedForUser.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return ChosenPlanLastDayFinishedForUser( type: type, isRead: isRead ?? false, + planName: json['plan_name'] ?? '', + chosenPlanId: json['chosen_plan_id'] ?? 0, + complaintUrl: json['complaintUrl'] ?? json['complaint_url'] ?? '', + chatUrl: json['chatUrl'] ?? json['chat_url'] ?? '', createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); } @@ -367,10 +530,10 @@ class ChosenPlanConsultantLastDayStarted implements BaseNotificationModel { 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'], + planName: json['plan_name'] ?? '', + planId: json['plan_id'] ?? 0, + chatId: json['chat_id']?.toString() ?? '', + chatUrl: json['chat_url'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -385,6 +548,7 @@ class ComplaintToModerationForUser implements BaseNotificationModel { final String message; final int entityId; final String entityType; + final String entityName; @override final DateTime createdAt; @@ -393,6 +557,7 @@ class ComplaintToModerationForUser implements BaseNotificationModel { required this.message, required this.entityId, required this.entityType, + required this.entityName, required this.isRead, required this.createdAt, }); @@ -400,15 +565,32 @@ class ComplaintToModerationForUser implements BaseNotificationModel { 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'], + message: json['message'] ?? '', + entityId: json['entity_id'] ?? 0, + entityType: json['entity_type'] ?? '', + entityName: json['entity_name'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); } } +enum CommentedEntityType { + diet, + blogPost, + unknown; + + factory CommentedEntityType.fromJson(String type) { + return switch (type) { + 'diet' => CommentedEntityType.diet, + 'Modules\\UserGeneratedPublicContent\\Models\\Diet' => CommentedEntityType.diet, + 'blog-post' => CommentedEntityType.blogPost, + 'Modules\\UserGeneratedPublicContent\\Models\\BlogPost' => CommentedEntityType.blogPost, + _ => CommentedEntityType.unknown, + }; + } +} + class CommentReplied implements BaseNotificationModel { @override final NotificationType type; @@ -416,10 +598,11 @@ class CommentReplied implements BaseNotificationModel { final bool isRead; final String entityName; final int commentedEntityId; - final String commentedEntityType; + final CommentedEntityType commentedEntityType; final String author; final int authorId; final int parentCommentId; + final int? commentId; @override final DateTime createdAt; @@ -431,6 +614,7 @@ class CommentReplied implements BaseNotificationModel { required this.author, required this.authorId, required this.parentCommentId, + this.commentId, required this.isRead, required this.createdAt, }); @@ -440,10 +624,11 @@ class CommentReplied implements BaseNotificationModel { type: type, entityName: json['entity_name'], commentedEntityId: json['commented_entity_id'], - commentedEntityType: json['commented_entity_type'], + commentedEntityType: CommentedEntityType.fromJson(json['commented_entity_type']), author: json['author'], authorId: json['author_id'], parentCommentId: json['parrent_comment_id'], + commentId: json['comment_id'], isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -498,12 +683,13 @@ class EntityCommented implements BaseNotificationModel { final bool isRead; final String entityName; final int entityId; - final String entityType; + final CommentedEntityType entityType; final String entityTranslatedType; final String entityUrl; final String authorName; final int authorId; final String authorSlug; + final int? commentId; @override final DateTime createdAt; @@ -517,6 +703,7 @@ class EntityCommented implements BaseNotificationModel { required this.authorName, required this.authorId, required this.authorSlug, + this.commentId, required this.isRead, required this.createdAt, }); @@ -526,12 +713,13 @@ class EntityCommented implements BaseNotificationModel { type: type, entityName: json['entity_name'], entityId: json['entity_id'], - entityType: json['entity_type'], + entityType: CommentedEntityType.fromJson(json['entity_type']), entityTranslatedType: json['entity_traslated_type'], entityUrl: json['entity_url'], authorName: json['author_name'], authorId: json['author_id'], authorSlug: json['author_slug'], + commentId: json['comment_id'], isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -831,7 +1019,11 @@ class UnknownNotification implements BaseNotificationModel { @override final DateTime createdAt; - UnknownNotification({required this.type, this.isRead = true, DateTime? createdAt}) : createdAt = createdAt ?? DateTime.fromMillisecondsSinceEpoch(0); + UnknownNotification({required this.type, this.isRead = true, required this.createdAt}); + + factory UnknownNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return UnknownNotification(type: type, isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String())); + } } class DietCancelledNotification implements BaseNotificationModel { @@ -840,15 +1032,17 @@ class DietCancelledNotification implements BaseNotificationModel { @override final bool isRead; final String dietName; + final int dietId; @override final DateTime createdAt; - DietCancelledNotification({required this.type, required this.dietName, required this.isRead, required this.createdAt}); + DietCancelledNotification({required this.type, required this.dietName, required this.dietId, 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'] ?? '', + dietId: json['diet_id'] ?? 0, isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -861,15 +1055,17 @@ class DietStartedNotification implements BaseNotificationModel { @override final bool isRead; final String dietName; + final int dietId; @override final DateTime createdAt; - DietStartedNotification({required this.type, required this.dietName, required this.isRead, required this.createdAt}); + DietStartedNotification({required this.type, required this.dietName, required this.dietId, 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'] ?? '', + dietId: json['diet_id'] ?? 0, isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -882,15 +1078,17 @@ class DietExpiredNotification implements BaseNotificationModel { @override final bool isRead; final String dietName; + final int dietId; @override final DateTime createdAt; - DietExpiredNotification({required this.type, required this.dietName, required this.isRead, required this.createdAt}); + DietExpiredNotification({required this.type, required this.dietName, required this.dietId, 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'] ?? '', + dietId: json['diet_id'] ?? 0, isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -903,16 +1101,25 @@ class DietWillStartedInNotification implements BaseNotificationModel { @override final bool isRead; final String dietName; + final int dietId; final String startDate; @override final DateTime createdAt; - DietWillStartedInNotification({required this.type, required this.dietName, required this.startDate, required this.isRead, required this.createdAt}); + DietWillStartedInNotification({ + required this.type, + required this.dietName, + required this.dietId, + 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'] ?? '', + dietId: json['diet_id'] ?? 0, startDate: json['start_date'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), @@ -926,16 +1133,25 @@ class DietCanNotBeStartedNotification implements BaseNotificationModel { @override final bool isRead; final String dietName; + final int dietId; final String reason; @override final DateTime createdAt; - DietCanNotBeStartedNotification({required this.type, required this.dietName, required this.reason, required this.isRead, required this.createdAt}); + DietCanNotBeStartedNotification({ + required this.type, + required this.dietName, + required this.dietId, + 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'] ?? '', + dietId: json['diet_id'] ?? 0, reason: json['reason'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), @@ -948,18 +1164,27 @@ class PersonalDietCreationNeededNotification implements BaseNotificationModel { final NotificationType type; @override final bool isRead; - final String planName; - final String clientName; + final String userName; + final String consultantName; + final String myDietsUrl; @override final DateTime createdAt; - PersonalDietCreationNeededNotification({required this.type, required this.planName, required this.clientName, required this.isRead, required this.createdAt}); + PersonalDietCreationNeededNotification({ + required this.type, + required this.userName, + required this.consultantName, + required this.myDietsUrl, + 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'] ?? '', + userName: json['user_name'] ?? '', + consultantName: json['consultant_name'] ?? '', + myDietsUrl: json['myDietsUrl'] ?? json['my_diets_url'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -972,15 +1197,29 @@ class ChosenPlanClientLastDayFinishedNotification implements BaseNotificationMod @override final bool isRead; final String planName; + final int chosenPlanId; + final String complaintUrl; + final String chatUrl; @override final DateTime createdAt; - ChosenPlanClientLastDayFinishedNotification({required this.type, required this.planName, required this.isRead, required this.createdAt}); + ChosenPlanClientLastDayFinishedNotification({ + required this.type, + required this.planName, + required this.chosenPlanId, + required this.complaintUrl, + required this.chatUrl, + 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'] ?? '', + chosenPlanId: json['chosen_plan_id'] ?? 0, + complaintUrl: json['complaintUrl'] ?? json['complaint_url'] ?? '', + chatUrl: json['chatUrl'] ?? json['chat_url'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -993,15 +1232,26 @@ class ChosenPlanClientLastDayStartedNotification implements BaseNotificationMode @override final bool isRead; final String planName; + final int chosenPlanId; + final String lastDayDiaryUrl; @override final DateTime createdAt; - ChosenPlanClientLastDayStartedNotification({required this.type, required this.planName, required this.isRead, required this.createdAt}); + ChosenPlanClientLastDayStartedNotification({ + required this.type, + required this.planName, + required this.chosenPlanId, + required this.lastDayDiaryUrl, + 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'] ?? '', + chosenPlanId: json['chosen_plan_id'] ?? 0, + lastDayDiaryUrl: json['last_day_diary_url'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -1014,17 +1264,29 @@ class StayOnTrackToAuthorNotification implements BaseNotificationModel { @override final bool isRead; final String planName; - final String clientName; + final String userName; + final int dayNum; + final int diaryDayId; @override final DateTime createdAt; - StayOnTrackToAuthorNotification({required this.type, required this.planName, required this.clientName, required this.isRead, required this.createdAt}); + StayOnTrackToAuthorNotification({ + required this.type, + required this.planName, + required this.userName, + required this.dayNum, + required this.diaryDayId, + 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'] ?? '', + userName: json['user_name'] ?? '', + dayNum: json['day_num'] ?? 0, + diaryDayId: json['diary_day_id'] ?? 0, isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -1037,15 +1299,41 @@ class ClientStartPlanSubscriptionToSubscriberNotification implements BaseNotific @override final bool isRead; final String planName; + final int planId; + final String authorName; + final int authorId; + final String authorPhotoPath; + final String chatUrl; + final String chatId; + final String startDate; @override final DateTime createdAt; - ClientStartPlanSubscriptionToSubscriberNotification({required this.type, required this.planName, required this.isRead, required this.createdAt}); + ClientStartPlanSubscriptionToSubscriberNotification({ + required this.type, + required this.planName, + required this.planId, + required this.authorName, + required this.authorId, + required this.authorPhotoPath, + required this.chatUrl, + required this.chatId, + required this.startDate, + 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'] ?? '', + planId: json['plan_id'] ?? 0, + authorName: json['author_name'] ?? '', + authorId: json['author_id'] ?? 0, + authorPhotoPath: json['author_photo_path'] ?? '', + chatUrl: json['chat_url'] ?? '', + chatId: json['chat_id']?.toString() ?? '', + startDate: json['start_date']?.toString() ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -1058,14 +1346,22 @@ class ConsultantConfirmPlanSubscriptionToSubscriberNotification implements BaseN @override final bool isRead; final String planName; + final int planId; final String consultantName; + final int authorId; + final String authorPhotoPath; + final String chatUrl; @override final DateTime createdAt; ConsultantConfirmPlanSubscriptionToSubscriberNotification({ required this.type, required this.planName, + required this.planId, required this.consultantName, + required this.authorId, + required this.authorPhotoPath, + required this.chatUrl, required this.isRead, required this.createdAt, }); @@ -1079,7 +1375,11 @@ class ConsultantConfirmPlanSubscriptionToSubscriberNotification implements BaseN return ConsultantConfirmPlanSubscriptionToSubscriberNotification( type: type, planName: json['plan_name'] ?? '', - consultantName: json['consultant_name'] ?? '', + planId: json['plan_id'] ?? 0, + consultantName: json['author_name'] ?? json['consultant_name'] ?? '', + authorId: json['author_id'] ?? 0, + authorPhotoPath: json['author_photo_path'] ?? '', + chatUrl: json['chat_url'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -1092,16 +1392,22 @@ class ClientPaidPlanSubscriptionToAuthorNotification implements BaseNotification @override final bool isRead; final String planName; + final int planId; final String clientName; - final double amount; + final int userId; + final String userPhotoPath; + final String chatUrl; @override final DateTime createdAt; ClientPaidPlanSubscriptionToAuthorNotification({ required this.type, required this.planName, + required this.planId, required this.clientName, - required this.amount, + required this.userId, + required this.userPhotoPath, + required this.chatUrl, required this.isRead, required this.createdAt, }); @@ -1110,8 +1416,11 @@ class ClientPaidPlanSubscriptionToAuthorNotification implements BaseNotification return ClientPaidPlanSubscriptionToAuthorNotification( type: type, planName: json['plan_name'] ?? '', - clientName: json['client_name'] ?? '', - amount: (json['amount'] as num?)?.toDouble() ?? 0.0, + planId: json['plan_id'] ?? 0, + clientName: json['user_name'] ?? '', + userId: json['user_id'] ?? 0, + userPhotoPath: json['user_photo_path'] ?? '', + chatUrl: json['chat_url'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -1123,7 +1432,10 @@ class ConsultantLeftDiaryCommentNotification implements BaseNotificationModel { final NotificationType type; @override final bool isRead; + final int chosenPlanId; + final int diaryId; final String planName; + final String userName; final String consultantName; final DateTime diaryDate; @override @@ -1131,7 +1443,10 @@ class ConsultantLeftDiaryCommentNotification implements BaseNotificationModel { ConsultantLeftDiaryCommentNotification({ required this.type, + required this.chosenPlanId, + required this.diaryId, required this.planName, + required this.userName, required this.consultantName, required this.diaryDate, required this.isRead, @@ -1141,7 +1456,10 @@ class ConsultantLeftDiaryCommentNotification implements BaseNotificationModel { factory ConsultantLeftDiaryCommentNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return ConsultantLeftDiaryCommentNotification( type: type, + chosenPlanId: json['chosen_plan_id'] ?? 0, + diaryId: json['diary_id'] ?? 0, planName: json['plan_name'] ?? '', + userName: json['user_name'] ?? '', consultantName: json['consultant_name'] ?? '', diaryDate: parseDate(json['diary_date'] ?? DateTime.now().toIso8601String()), isRead: isRead ?? false, @@ -1156,14 +1474,22 @@ class ConsultantConfirmPlanSubscriptionToAuthorNotification implements BaseNotif @override final bool isRead; final String planName; - final String consultantName; + final int planId; + final String clientName; + final int userId; + final String userPhotoPath; + final String chatUrl; @override final DateTime createdAt; ConsultantConfirmPlanSubscriptionToAuthorNotification({ required this.type, required this.planName, - required this.consultantName, + required this.planId, + required this.clientName, + required this.userId, + required this.userPhotoPath, + required this.chatUrl, required this.isRead, required this.createdAt, }); @@ -1172,7 +1498,11 @@ class ConsultantConfirmPlanSubscriptionToAuthorNotification implements BaseNotif return ConsultantConfirmPlanSubscriptionToAuthorNotification( type: type, planName: json['plan_name'] ?? '', - consultantName: json['consultant_name'] ?? '', + planId: json['plan_id'] ?? 0, + clientName: json['user_name'] ?? '', + userId: json['user_id'] ?? 0, + userPhotoPath: json['user_photo_path'] ?? '', + chatUrl: json['chat_url'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -1185,14 +1515,22 @@ class ClientPaidPlanSubscriptionToSubscriberNotification implements BaseNotifica @override final bool isRead; final String planName; - final double amount; + final int planId; + final String authorName; + final int authorId; + final String authorPhotoPath; + final String chatUrl; @override final DateTime createdAt; ClientPaidPlanSubscriptionToSubscriberNotification({ required this.type, required this.planName, - required this.amount, + required this.planId, + required this.authorName, + required this.authorId, + required this.authorPhotoPath, + required this.chatUrl, required this.isRead, required this.createdAt, }); @@ -1201,7 +1539,11 @@ class ClientPaidPlanSubscriptionToSubscriberNotification implements BaseNotifica return ClientPaidPlanSubscriptionToSubscriberNotification( type: type, planName: json['plan_name'] ?? '', - amount: (json['amount'] as num?)?.toDouble() ?? 0.0, + planId: json['plan_id'] ?? 0, + authorName: json['author_name'] ?? '', + authorId: json['author_id'] ?? 0, + authorPhotoPath: json['author_photo_path'] ?? '', + chatUrl: json['chat_url'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -1214,14 +1556,24 @@ class ClientStartPlanSubscriptionToAuthorNotification implements BaseNotificatio @override final bool isRead; final String planName; + final int planId; final String clientName; + final int userId; + final String userPhotoPath; + final String chatId; + final String chatUrl; @override final DateTime createdAt; ClientStartPlanSubscriptionToAuthorNotification({ required this.type, required this.planName, + required this.planId, required this.clientName, + required this.userId, + required this.userPhotoPath, + required this.chatId, + required this.chatUrl, required this.isRead, required this.createdAt, }); @@ -1230,7 +1582,12 @@ class ClientStartPlanSubscriptionToAuthorNotification implements BaseNotificatio return ClientStartPlanSubscriptionToAuthorNotification( type: type, planName: json['plan_name'] ?? '', - clientName: json['client_name'] ?? '', + planId: json['plan_id'] ?? 0, + clientName: json['user_name'] ?? '', + userId: json['user_id'] ?? 0, + userPhotoPath: json['user_photo_path'] ?? '', + chatId: json['chat_id']?.toString() ?? '', + chatUrl: json['chat_url'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -1243,14 +1600,22 @@ class ClientCancelPlanSubscriptionToAuthorNotification implements BaseNotificati @override final bool isRead; final String planName; + final int planId; final String clientName; + final int userId; + final String userPhotoPath; + final String chatUrl; @override final DateTime createdAt; ClientCancelPlanSubscriptionToAuthorNotification({ required this.type, required this.planName, + required this.planId, required this.clientName, + required this.userId, + required this.userPhotoPath, + required this.chatUrl, required this.isRead, required this.createdAt, }); @@ -1259,7 +1624,11 @@ class ClientCancelPlanSubscriptionToAuthorNotification implements BaseNotificati return ClientCancelPlanSubscriptionToAuthorNotification( type: type, planName: json['plan_name'] ?? '', - clientName: json['client_name'] ?? '', + planId: json['plan_id'] ?? 0, + clientName: json['user_name'] ?? '', + userId: json['user_id'] ?? 0, + userPhotoPath: json['user_photo_path'] ?? '', + chatUrl: json['chat_url'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -1272,15 +1641,35 @@ class ClientCancelPlanSubscriptionToSubscriberNotification implements BaseNotifi @override final bool isRead; final String planName; + final int planId; + final String authorName; + final int authorId; + final String authorPhotoPath; + final String chatUrl; @override final DateTime createdAt; - ClientCancelPlanSubscriptionToSubscriberNotification({required this.type, required this.planName, required this.isRead, required this.createdAt}); + ClientCancelPlanSubscriptionToSubscriberNotification({ + required this.type, + required this.planName, + required this.planId, + required this.authorName, + required this.authorId, + required this.authorPhotoPath, + required this.chatUrl, + 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'] ?? '', + planId: json['plan_id'] ?? 0, + authorName: json['author_name'] ?? '', + authorId: json['author_id'] ?? 0, + authorPhotoPath: json['author_photo_path'] ?? '', + chatUrl: json['chat_url'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -1292,17 +1681,21 @@ class UserDailyReportCompletedNotification implements BaseNotificationModel { final NotificationType type; @override final bool isRead; + final int chosenPlanId; + final int diaryId; final String planName; final String clientName; - final DateTime reportDate; + final String consultantName; @override final DateTime createdAt; UserDailyReportCompletedNotification({ required this.type, + required this.chosenPlanId, + required this.diaryId, required this.planName, required this.clientName, - required this.reportDate, + required this.consultantName, required this.isRead, required this.createdAt, }); @@ -1310,9 +1703,11 @@ class UserDailyReportCompletedNotification implements BaseNotificationModel { factory UserDailyReportCompletedNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return UserDailyReportCompletedNotification( type: type, + chosenPlanId: json['chosen_plan_id'] ?? 0, + diaryId: json['diary_id'] ?? 0, planName: json['plan_name'] ?? '', - clientName: json['client_name'] ?? '', - reportDate: parseDate(json['report_date'] ?? DateTime.now().toIso8601String()), + clientName: json['user_name'] ?? '', + consultantName: json['consultant_name'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -1325,17 +1720,26 @@ class StayOnTrackToCustomerNotification implements BaseNotificationModel { @override final bool isRead; final String planName; - final String consultantName; + final int dayNum; + final int diaryDayId; @override final DateTime createdAt; - StayOnTrackToCustomerNotification({required this.type, required this.planName, required this.consultantName, required this.isRead, required this.createdAt}); + StayOnTrackToCustomerNotification({ + required this.type, + required this.planName, + required this.dayNum, + required this.diaryDayId, + 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'] ?? '', + dayNum: json['day_num'] ?? 0, + diaryDayId: json['diary_day_id'] ?? 0, isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -1370,18 +1774,27 @@ class SubscriptionNotification implements BaseNotificationModel { final NotificationType type; @override final bool isRead; - final String subscriptionType; - final String planName; + final String userName; + final String userPhotoPath; + final String userProfileUrl; @override final DateTime createdAt; - SubscriptionNotification({required this.type, required this.subscriptionType, required this.planName, required this.isRead, required this.createdAt}); + SubscriptionNotification({ + required this.type, + required this.userName, + required this.userPhotoPath, + required this.userProfileUrl, + 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'] ?? '', + userName: json['user_name'] ?? '', + userPhotoPath: json['user_photo_path'] ?? '', + userProfileUrl: json['user_profile_url'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -1393,18 +1806,27 @@ class BadgeAwardedNotification implements BaseNotificationModel { final NotificationType type; @override final bool isRead; - final String badgeName; - final String badgeDescription; + final String badgeTitle; + final String badgeType; + final String badgeImage; @override final DateTime createdAt; - BadgeAwardedNotification({required this.type, required this.badgeName, required this.badgeDescription, required this.isRead, required this.createdAt}); + BadgeAwardedNotification({ + required this.type, + required this.badgeTitle, + required this.badgeType, + required this.badgeImage, + 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'] ?? '', + badgeTitle: json['badge_title'] ?? '', + badgeType: json['badge_type'] ?? '', + badgeImage: json['badge_image'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -1416,18 +1838,27 @@ class BadgeRevokedNotification implements BaseNotificationModel { final NotificationType type; @override final bool isRead; - final String badgeName; - final String reason; + final String badgeTitle; + final String badgeType; + final String badgeImage; @override final DateTime createdAt; - BadgeRevokedNotification({required this.type, required this.badgeName, required this.reason, required this.isRead, required this.createdAt}); + BadgeRevokedNotification({ + required this.type, + required this.badgeTitle, + required this.badgeType, + required this.badgeImage, + 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'] ?? '', + badgeTitle: json['badge_title'] ?? '', + badgeType: json['badge_type'] ?? '', + badgeImage: json['badge_image'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -1460,24 +1891,16 @@ class ClientCloseComplaintSuccessNotification implements BaseNotificationModel { final NotificationType type; @override final bool isRead; - final String complaintType; - final String entityName; + final String planName; @override final DateTime createdAt; - ClientCloseComplaintSuccessNotification({ - required this.type, - required this.complaintType, - required this.entityName, - required this.isRead, - required this.createdAt, - }); + ClientCloseComplaintSuccessNotification({required this.type, required this.planName, 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'] ?? '', + planName: json['plan_name'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -1554,17 +1977,15 @@ class ClientRejectSuccessNotification implements BaseNotificationModel { @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}); + ClientRejectSuccessNotification({required this.type, required this.planName, 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()), ); @@ -1578,25 +1999,16 @@ class ConsultantRejectSuccessNotification implements BaseNotificationModel { 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, - }); + ConsultantRejectSuccessNotification({required this.type, required this.planName, required this.clientName, 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'] ?? '', + clientName: json['user_name'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -1650,16 +2062,24 @@ class ProductStatusUpdatedForAuthorNotification implements BaseNotificationModel @override final bool isRead; final String productName; - final String oldStatus; - final String newStatus; + final int productId; + final String author; + final int authorId; + final String status; + final String subject; + final String messageLine; @override final DateTime createdAt; ProductStatusUpdatedForAuthorNotification({ required this.type, required this.productName, - required this.oldStatus, - required this.newStatus, + required this.productId, + required this.author, + required this.authorId, + required this.status, + required this.subject, + required this.messageLine, required this.isRead, required this.createdAt, }); @@ -1668,8 +2088,12 @@ class ProductStatusUpdatedForAuthorNotification implements BaseNotificationModel return ProductStatusUpdatedForAuthorNotification( type: type, productName: json['product_name'] ?? '', - oldStatus: json['old_status'] ?? '', - newStatus: json['new_status'] ?? '', + productId: json['product_id'] ?? 0, + author: json['author'] ?? '', + authorId: json['author_id'] ?? 0, + status: json['status'] ?? '', + subject: json['subject'] ?? '', + messageLine: json['messageLine'] ?? json['message_line'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -1681,15 +2105,20 @@ class DiaryReminderForUsersNotification implements BaseNotificationModel { final NotificationType type; @override final bool isRead; + final int? diaryId; + final String? message; @override final DateTime createdAt; - DiaryReminderForUsersNotification({required this.type, required this.isRead, required this.createdAt}); + DiaryReminderForUsersNotification({required this.type, required this.isRead, this.diaryId, this.message, required this.createdAt}); factory DiaryReminderForUsersNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + final data = json['data'] is Map ? json['data'] as Map : json; return DiaryReminderForUsersNotification( type: type, isRead: isRead ?? false, + diaryId: data['diary_id'], + message: data['message'], createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); } @@ -1702,15 +2131,29 @@ class PersonalDietIsDefinedToCustomerNotification implements BaseNotificationMod final bool isRead; @override final DateTime createdAt; - final String dietName; + final int chosenPlanId; final int dietId; + final String planName; + final String userName; + final String consultantName; + final String dietName; + final String role; + final String peerSlug; + final String peerImagePath; PersonalDietIsDefinedToCustomerNotification({ required this.type, required this.isRead, required this.createdAt, - required this.dietName, + required this.chosenPlanId, required this.dietId, + required this.planName, + required this.userName, + required this.consultantName, + required this.dietName, + required this.role, + required this.peerSlug, + required this.peerImagePath, }); factory PersonalDietIsDefinedToCustomerNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { @@ -1718,8 +2161,288 @@ class PersonalDietIsDefinedToCustomerNotification implements BaseNotificationMod type: type, isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + chosenPlanId: json['chosen_plan_id'] ?? 0, + dietId: json['diet_id'] ?? 0, + planName: json['plan_name'] ?? '', + userName: json['user_name'] ?? '', + consultantName: json['consultant_name'] ?? '', dietName: json['diet_name'] ?? '', - dietId: json['diet_id'], + role: json['role']?.toString() ?? '', + peerSlug: json['peer_slug'] ?? '', + peerImagePath: json['peer_image_path'] ?? '', + ); + } +} + +// New Model Classes for Comprehensive Support + +class ConsultantEducationUpdatedNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + @override + final DateTime createdAt; + final String consultantName; + + ConsultantEducationUpdatedNotification({required this.type, required this.isRead, required this.createdAt, required this.consultantName}); + + factory ConsultantEducationUpdatedNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return ConsultantEducationUpdatedNotification( + type: type, + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + consultantName: json['consultant_name'] ?? '', + ); + } +} + +class ComplaintRegisteredNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + @override + final DateTime createdAt; + final String message; + final int entityId; + final String entityType; + final String entityName; + + ComplaintRegisteredNotification({ + required this.type, + required this.isRead, + required this.createdAt, + required this.message, + required this.entityId, + required this.entityType, + required this.entityName, + }); + + factory ComplaintRegisteredNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return ComplaintRegisteredNotification( + type: type, + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + message: json['message'] ?? '', + entityId: json['entity_id'] ?? 0, + entityType: json['entity_type'] ?? '', + entityName: json['entity_name'] ?? '', + ); + } +} + +class ContentUnpublishedNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + @override + final DateTime createdAt; + final String reason; + final String entityName; + + ContentUnpublishedNotification({required this.type, required this.isRead, required this.createdAt, required this.reason, required this.entityName}); + + factory ContentUnpublishedNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return ContentUnpublishedNotification( + type: type, + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + reason: json['reason'] ?? '', + entityName: json['entity_name'] ?? '', + ); + } +} + +class FinanceQuestionCreatedNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + @override + final DateTime createdAt; + final String questionText; + + FinanceQuestionCreatedNotification({required this.type, required this.isRead, required this.createdAt, required this.questionText}); + + factory FinanceQuestionCreatedNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return FinanceQuestionCreatedNotification( + type: type, + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + questionText: json['question_text'] ?? '', + ); + } +} + +class NewChatMessageNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + @override + final DateTime createdAt; + final String senderName; + final String messagePreview; + final int chatId; + + NewChatMessageNotification({ + required this.type, + required this.isRead, + required this.createdAt, + required this.senderName, + required this.messagePreview, + required this.chatId, + }); + + factory NewChatMessageNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return NewChatMessageNotification( + type: type, + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + senderName: json['sender_name'] ?? '', + messagePreview: json['message_preview'] ?? '', + chatId: json['chat_id'] ?? 0, + ); + } +} + +class CompleteRegistrationNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + @override + final DateTime createdAt; + + CompleteRegistrationNotification({required this.type, required this.isRead, required this.createdAt}); + + factory CompleteRegistrationNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return CompleteRegistrationNotification( + type: type, + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + ); + } +} + +class ChosenDietExpiredNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + @override + final DateTime createdAt; + final String dietName; + final int dietId; + + ChosenDietExpiredNotification({required this.type, required this.isRead, required this.createdAt, required this.dietName, required this.dietId}); + + factory ChosenDietExpiredNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return ChosenDietExpiredNotification( + type: type, + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + dietName: json['diet_name'] ?? '', + dietId: json['diet_id'] ?? 0, + ); + } +} + +class MotivateUserNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + @override + final DateTime createdAt; + final String message; + + MotivateUserNotification({required this.type, required this.isRead, required this.createdAt, required this.message}); + + factory MotivateUserNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return MotivateUserNotification( + type: type, + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + message: json['message'] ?? '', + ); + } +} + +class CommentComplaintNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + @override + final DateTime createdAt; + final String commentPreview; + final String reason; + + CommentComplaintNotification({required this.type, required this.isRead, required this.createdAt, required this.commentPreview, required this.reason}); + + factory CommentComplaintNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return CommentComplaintNotification( + type: type, + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + commentPreview: json['comment_preview'] ?? '', + reason: json['reason'] ?? '', + ); + } +} + +class CommentDeletedNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + @override + final DateTime createdAt; + final String commentPreview; + + CommentDeletedNotification({required this.type, required this.isRead, required this.createdAt, required this.commentPreview}); + + factory CommentDeletedNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return CommentDeletedNotification( + type: type, + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + commentPreview: json['comment_preview'] ?? '', + ); + } +} + +class UserSubscribedNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + @override + final DateTime createdAt; + final String userName; + final String userPhotoPath; + final String userProfileUrl; + + UserSubscribedNotification({ + required this.type, + required this.isRead, + required this.createdAt, + required this.userName, + required this.userPhotoPath, + required this.userProfileUrl, + }); + + factory UserSubscribedNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return UserSubscribedNotification( + type: type, + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + userName: json['user_name'] ?? '', + userPhotoPath: json['user_photo_path'] ?? '', + userProfileUrl: json['user_profile_url'] ?? '', ); } } @@ -1731,6 +2454,7 @@ enum NotificationType { dietExpired, dietWillStartedIn, dietCanNotBeStarted, + chosenDietExpired, // New // Plan related personalDietCreationNeeded, @@ -1760,12 +2484,17 @@ enum NotificationType { // Comments entityCommented, commentReplied, + commentComplaint, // New + commentDeleted, // New // Profile phoneChanged, + consultantEducationUpdated, // New + completeRegistration, // New // Subscription subscription, + userSubscribed, // New // Badges badgeAwarded, @@ -1773,16 +2502,21 @@ enum NotificationType { // Consultant consultantRequestStarted, + motivateUser, // New // Complaints clientCloseComplaintSuccess, complaintToModerationForUser, + complaintRegistered, // New + contentUnpublished, // New + contentUnpublishedComplaint, // New // Finance clientRefundSuccess, consultantRefundSuccess, clientRejectSuccess, consultantRejectSuccess, + financeQuestionCreated, // New // Score scoreAdded, @@ -1794,6 +2528,9 @@ enum NotificationType { // Diary diaryReminderForUsers, + // Chat + newChatMessage, // New + // Legacy types (keeping for backward compatibility) planSubscriptionStartedToAuthor, planSubscriptionCanceledToAuthor, @@ -1866,6 +2603,21 @@ enum NotificationType { 'diary-reminder-for-users' => NotificationType.diaryReminderForUsers, 'personal-diet-is-defined-to-customer' => NotificationType.personalDietIsDefinedToCustomer, + // New additions + 'consultant-education-updated' => NotificationType.consultantEducationUpdated, + 'complaint-registered' => NotificationType.complaintRegistered, + 'content-unpublished' => NotificationType.contentUnpublished, + 'content-unpublished-complaint' => NotificationType.contentUnpublishedComplaint, + 'finance-question-created' => NotificationType.financeQuestionCreated, + 'new-chat-message' => NotificationType.newChatMessage, + 'complete-registration' => NotificationType.completeRegistration, + 'chosen-diet-expired' => NotificationType.chosenDietExpired, + 'motivate-user' => NotificationType.motivateUser, + 'comment-complaint' => NotificationType.commentComplaint, + 'comment-deleted' => NotificationType.commentDeleted, + 'user-subscribed' => NotificationType.userSubscribed, + // 'diary-reminder' => NotificationType.diaryReminder, // Already legacy? Check if it maps to diaryReminderNotification + 'meal-reminder' => NotificationType.mealReminder, // Maps to legacy if string matches? No, legacy was App\\... // Legacy mapping 'App\\Notifications\\Plan\\PlanSubscriptionStartedToAuthor' => NotificationType.planSubscriptionStartedToAuthor, 'App\\Notifications\\Plan\\PlanSubscriptionCanceledToAuthor' => NotificationType.planSubscriptionCanceledToAuthor, diff --git a/lib/features/notifications/presentation/notifications_cubit/notifications_cubit.dart b/lib/features/notifications/presentation/notifications_cubit/notifications_cubit.dart index 110607c0..51771c24 100644 --- a/lib/features/notifications/presentation/notifications_cubit/notifications_cubit.dart +++ b/lib/features/notifications/presentation/notifications_cubit/notifications_cubit.dart @@ -267,33 +267,113 @@ class NotificationsCubit extends Cubit { isRead: isRead, createdAt: createdAt, ), - NotificationType.unknown => UnknownNotification(type: type), + NotificationType.consultantEducationUpdated => ConsultantEducationUpdatedNotification.fromJson( + notificationJson, + type, + isRead: isRead, + createdAt: createdAt, + ), + NotificationType.complaintRegistered => ComplaintRegisteredNotification.fromJson( + notificationJson, + type, + isRead: isRead, + createdAt: createdAt, + ), + NotificationType.contentUnpublished => ContentUnpublishedNotification.fromJson( + notificationJson, + type, + isRead: isRead, + createdAt: createdAt, + ), + NotificationType.contentUnpublishedComplaint => ContentUnpublishedNotification.fromJson( + notificationJson, + type, + isRead: isRead, + createdAt: createdAt, + ), + NotificationType.financeQuestionCreated => FinanceQuestionCreatedNotification.fromJson( + notificationJson, + type, + isRead: isRead, + createdAt: createdAt, + ), + NotificationType.newChatMessage => NewChatMessageNotification.fromJson( + notificationJson, + type, + isRead: isRead, + createdAt: createdAt, + ), + NotificationType.completeRegistration => CompleteRegistrationNotification.fromJson( + notificationJson, + type, + isRead: isRead, + createdAt: createdAt, + ), + NotificationType.chosenDietExpired => ChosenDietExpiredNotification.fromJson( + notificationJson, + type, + isRead: isRead, + createdAt: createdAt, + ), + NotificationType.motivateUser => MotivateUserNotification.fromJson( + notificationJson, + type, + isRead: isRead, + createdAt: createdAt, + ), + NotificationType.commentComplaint => CommentComplaintNotification.fromJson( + notificationJson, + type, + isRead: isRead, + createdAt: createdAt, + ), + NotificationType.commentDeleted => CommentDeletedNotification.fromJson( + notificationJson, + type, + isRead: isRead, + createdAt: createdAt, + ), + NotificationType.userSubscribed => UserSubscribedNotification.fromJson( + notificationJson, + type, + isRead: isRead, + createdAt: createdAt, + ), + NotificationType.unknown => UnknownNotification(type: type, createdAt: parseDate(createdAt ?? jsonEncode(notificationJson))), }; } catch (e) { - return UnknownNotification(type: NotificationType.unknown); + return UnknownNotification(type: NotificationType.unknown, createdAt: DateTime.now()); } } void _handleNotification(String notificationData) { final json = jsonDecode(notificationData) as Map; - // Улучшенная логика определения типа уведомления + // Улучшенная логика определения типа уведомления (порядок: database_type → type → notification_class_name) String? stringifiedType; - // Сначала проверяем поле 'type' в корне JSON - if (json.containsKey('type') && json['type'] != null) { + // Сначала проверяем поле 'database_type' в корне JSON + if (json.containsKey('database_type') && json['database_type'] != null) { + stringifiedType = json['database_type'].toString(); + log('Found database_type in root: $stringifiedType'); + } + // Затем поле 'type' в корне + else if (json.containsKey('type') && json['type'] != null) { stringifiedType = json['type'].toString(); log('Found type in root: $stringifiedType'); } - // Затем проверяем поле 'notification_class_name' в корне JSON + // Затем поле 'notification_class_name' в корне 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', проверяем внутри него + // Если есть поле 'data', проверяем внутри него в том же порядке else if (json.containsKey('data') && json['data'] is Map) { final data = json['data'] as Map; - if (data.containsKey('type') && data['type'] != null) { + if (data.containsKey('database_type') && data['database_type'] != null) { + stringifiedType = data['database_type'].toString(); + log('Found database_type in data: $stringifiedType'); + } else 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) { diff --git a/lib/features/notifications/presentation/widgets/notification_widget.dart b/lib/features/notifications/presentation/widgets/notification_widget.dart index 0169f96d..7f22f5e7 100644 --- a/lib/features/notifications/presentation/widgets/notification_widget.dart +++ b/lib/features/notifications/presentation/widgets/notification_widget.dart @@ -1,10 +1,13 @@ import 'package:eatvio_app/core/routing/route_constants.dart'; import 'package:eatvio_app/core/theme/custom_text_style.dart'; +import 'package:eatvio_app/features/bottom_navigation_bar/presentation/widgets/bottom_nav_scope.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_outlined_button.dart'; +import 'package:eatvio_app/features/diary/presentation/pages/diary_screen.dart'; import 'package:eatvio_app/features/my_finance/presentation/pages/card_settings.dart'; import 'package:eatvio_app/features/notifications/models/notification_model.dart'; +import 'package:eatvio_app/features/profile/presentation/pages/subs_screen.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; @@ -12,6 +15,7 @@ import 'package:go_router/go_router.dart'; class NotificationWidget extends StatelessWidget { const NotificationWidget({super.key, required this.notification}); final BaseNotificationModel notification; + @override Widget build(BuildContext context) { return switch (notification) { @@ -117,11 +121,45 @@ class NotificationWidget extends StatelessWidget { PersonalDietIsDefinedToCustomerNotification() => _PersonalDietIsDefinedToCustomerWidget( notification: notification as PersonalDietIsDefinedToCustomerNotification, ), - UnknownNotification() => _UnknownNotificationWidget(notification: notification as UnknownNotification), + // New Widgets + ConsultantEducationUpdatedNotification() => _ConsultantEducationUpdatedWidget(notification: notification as ConsultantEducationUpdatedNotification), + ComplaintRegisteredNotification() => _ComplaintRegisteredWidget(notification: notification as ComplaintRegisteredNotification), + ContentUnpublishedNotification() => _ContentUnpublishedWidget(notification: notification as ContentUnpublishedNotification), + FinanceQuestionCreatedNotification() => _FinanceQuestionCreatedWidget(notification: notification as FinanceQuestionCreatedNotification), + NewChatMessageNotification() => _NewChatMessageWidget(notification: notification as NewChatMessageNotification), + CompleteRegistrationNotification() => _CompleteRegistrationWidget(notification: notification as CompleteRegistrationNotification), + ChosenDietExpiredNotification() => _ChosenDietExpiredWidget(notification: notification as ChosenDietExpiredNotification), + MotivateUserNotification() => _MotivateUserWidget(notification: notification as MotivateUserNotification), + CommentComplaintNotification() => _CommentComplaintWidget(notification: notification as CommentComplaintNotification), + CommentDeletedNotification() => _CommentDeletedWidget(notification: notification as CommentDeletedNotification), + UserSubscribedNotification() => _UserSubscribedWidget(notification: notification as UserSubscribedNotification), + UnknownNotification() => const SizedBox.shrink(), }; } } +void _navigateToBottomNavRoute(BuildContext context, String path, [Object? extra]) { + final controller = BottomNavScope.maybeOf(context); + if (controller != null) { + controller.switchToRoute(path, extra); + } else { + context.push(path, extra: extra); + } +} + +void _navigateToEntity(BuildContext context, CommentedEntityType type, int id, int? commentId) { + switch (type) { + case CommentedEntityType.blogPost: + context.push(RouteConstants.blogPostScreen, extra: {'id': id, 'highlightedCommentId': commentId}); + case CommentedEntityType.diet: + if (id > 0) { + context.push(RouteConstants.aboutDietScreen, extra: {'id': id, 'highlightedCommentId': commentId}); + } + case CommentedEntityType.unknown: + break; + } +} + class _PlanSubscriptionStartedToAuthorWidget extends StatelessWidget { const _PlanSubscriptionStartedToAuthorWidget({required this.notification}); final PlanSubscriptionStartedToAuthorNotification notification; @@ -131,8 +169,11 @@ class _PlanSubscriptionStartedToAuthorWidget extends StatelessWidget { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: s.userSubscribedToPlan(notification.customerName, notification.planName), + notificationsText: '${s.userSubscribedToPlan(notification.customerName, notification.planName)}! 🎉', createdAt: notification.createdAt, + onTap: () { + _navigateToBottomNavRoute(context, RouteConstants.chatSelectScreen, notification.customerName.isNotEmpty ? notification.customerName : null); + }, ); } } @@ -142,12 +183,13 @@ class _PlanSubscriptionCanceledToAuthorWidget extends StatelessWidget { final PlanSubscriptionCanceledToAuthorNotification notification; @override Widget build(BuildContext context) { - final s = S.of(context); - return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: s.userCanceldThePlanSubscription(notification.customerName, notification.planName), + notificationsText: 'Отмена подписки. Клиент ${notification.customerName} отказался от плана "${notification.planName}"', createdAt: notification.createdAt, + onTap: () { + _navigateToBottomNavRoute(context, RouteConstants.chatSelectScreen, notification.customerName.isNotEmpty ? notification.customerName : null); + }, ); } } @@ -157,9 +199,14 @@ class _ChosenPlanStartedToClientWidget extends StatelessWidget { final ChosenPlanStartedToClientNotification notification; @override Widget build(BuildContext context) { - final s = S.of(context); - - return _BaseNotificationWidget(isRead: notification.isRead, notificationsText: s.planStarted(notification.planName), createdAt: notification.createdAt); + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Ваш план "${notification.planName}" начался! Удачи! 🚀', + createdAt: notification.createdAt, + onTap: () { + _navigateToBottomNavRoute(context, RouteConstants.diaryScreen); + }, + ); } } @@ -168,11 +215,9 @@ class _ChosenPlanTomorrowReminderToClientWidget extends StatelessWidget { final ChosenPlanTomorrowReminderToClientNotification notification; @override Widget build(BuildContext context) { - final s = S.of(context); - return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: s.tomorrowYourPlanWillStart(notification.planName), + notificationsText: 'Напоминание: Завтра старт плана "${notification.planName}"', createdAt: notification.createdAt, ); } @@ -183,8 +228,16 @@ class _DiaryReminderWidget extends StatelessWidget { final DiaryReminderNotification notification; @override Widget build(BuildContext context) { - final s = S.of(context); - return _BaseNotificationWidget(isRead: notification.isRead, notificationsText: s.fillYasterday, createdAt: notification.createdAt); + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Не забудьте заполнить дневник за вчера! 📝', + createdAt: notification.createdAt, + onTap: () { + final yesterday = notification.createdAt.subtract(const Duration(days: 1)); + final dateOnly = DateTime(yesterday.year, yesterday.month, yesterday.day); + _navigateToBottomNavRoute(context, RouteConstants.diaryScreen, dateOnly); + }, + ); } } @@ -193,12 +246,13 @@ class _ConsultantLeftCommentWidget extends StatelessWidget { final ConsultantLeftCommentNotification notification; @override Widget build(BuildContext context) { - final s = S.of(context); - return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: s.consultantLeftComment(notification.date.format('dd.MM.yyyy')), + notificationsText: 'Новый комментарий от консультанта (за ${notification.date.format('dd.MM.yyyy')})', createdAt: notification.createdAt, + onTap: () { + _navigateToBottomNavRoute(context, RouteConstants.diaryScreen, notification.date); + }, ); } } @@ -208,7 +262,14 @@ class _PersonalizedDietIsDefinedWidget extends StatelessWidget { final PersonalizedDietIsDefinedNotification notification; @override Widget build(BuildContext context) { - return _BaseNotificationWidget(isRead: notification.isRead, notificationsText: 'Персональная диета определена', createdAt: notification.createdAt); + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Персональная диета определена', + createdAt: notification.createdAt, + onTap: () { + _navigateToBottomNavRoute(context, RouteConstants.consultationsScreen); + }, + ); } } @@ -217,11 +278,9 @@ class _ChosenPlanFinishedToClientWidget extends StatelessWidget { final ChosenPlanFinishedToClientNotification notification; @override Widget build(BuildContext context) { - final s = S.of(context); - return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: '${s.congratsYouHaveFinishedPlan} «${notification.planName}»', + notificationsText: 'Поздравляем! План «${notification.planName}» успешно завершен! 🏆', createdAt: notification.createdAt, ); } @@ -232,11 +291,9 @@ class _PlanSubscriptionCanceledToCustomerWidget extends StatelessWidget { final PlanSubscriptionCanceledToCustomerNotification notification; @override Widget build(BuildContext context) { - final s = S.of(context); - return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: s.youCanceledSubscriptionToPlan(notification.planName), + notificationsText: 'Подписка на план "${notification.planName}" отменена.', createdAt: notification.createdAt, ); } @@ -247,11 +304,9 @@ class _ChosenPlanStartedToConsultantWidget extends StatelessWidget { final ChosenPlanStartedToConsultantNotification notification; @override Widget build(BuildContext context) { - final s = S.of(context); - return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: s.planWithUserStarted(notification.planName, notification.customerName), + notificationsText: 'Начало работы: План "${notification.planName}" с клиентом ${notification.customerName}', createdAt: notification.createdAt, ); } @@ -262,11 +317,9 @@ class _ChosenPlanTomorrowReminderToConsultantWidget extends StatelessWidget { final ChosenPlanTomorrowReminderToConsultantNotification notification; @override Widget build(BuildContext context) { - final s = S.of(context); - return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: s.tommorowPlanWithUserWillStart(notification.planName, notification.customerName), + notificationsText: 'Напоминание: Завтра старт работы с ${notification.customerName} по плану "${notification.planName}"', createdAt: notification.createdAt, ); } @@ -277,12 +330,13 @@ class _PlanSubscriptionPaidToAuthorWidget extends StatelessWidget { final PlanSubscriptionPaidToAuthorNotification notification; @override Widget build(BuildContext context) { - final s = S.of(context); - return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: s.userPaidThePlan(notification.customerName, notification.planName), + notificationsText: 'Оплата получена! Клиент ${notification.customerName} оплатил план "${notification.planName}" 💰', createdAt: notification.createdAt, + onTap: () { + context.push(RouteConstants.consultantFinancesScreen); + }, ); } } @@ -293,44 +347,48 @@ class _ChosenPlanFinishedToConsultantWidget extends StatelessWidget { @override Widget build(BuildContext context) { final s = S.of(context); - 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).push(CardSettingsScreen.path, extra: notification.planId); - }, - ), - ], + return GestureDetector( + onTap: () {}, + behavior: HitTestBehavior.opaque, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('План «${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).push(CardSettingsScreen.path, 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), - ), - ], + 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), + ), + ], + ), ); } @@ -377,6 +435,7 @@ class _BaseNotificationWidget extends StatelessWidget { Widget build(BuildContext context) { return GestureDetector( onTap: onTap, + behavior: HitTestBehavior.opaque, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -385,8 +444,14 @@ class _BaseNotificationWidget extends StatelessWidget { Expanded( child: Text(notificationsText, style: TextStyles.textMMedium.copyWith(color: appTheme.black)), ), + if (onTap != null) + Padding( + padding: const EdgeInsets.only(left: 8), + child: Icon(Icons.chevron_right, color: appTheme.gray500, size: 20), + ), if (!isRead) Container( + margin: const EdgeInsets.only(left: 8), decoration: BoxDecoration(color: appTheme.blueA400, shape: BoxShape.circle), width: 8, height: 8, @@ -440,7 +505,11 @@ class _ChosenPlanLastDayFinishedForUserWidget extends StatelessWidget { final ChosenPlanLastDayFinishedForUser notification; @override Widget build(BuildContext context) { - return _BaseNotificationWidget(isRead: notification.isRead, notificationsText: 'Последний день плана завершен', createdAt: notification.createdAt); + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Финишная прямая! Последний день плана завершен.', + createdAt: notification.createdAt, + ); } } @@ -451,8 +520,11 @@ class _ChosenPlanConsultantLastDayStartedWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Последний день плана начался: ${notification.planName}', + notificationsText: 'Внимание: Начался последний день плана "${notification.planName}"', createdAt: notification.createdAt, + onTap: () { + _navigateToBottomNavRoute(context, RouteConstants.consultationsScreen); + }, ); } } @@ -462,7 +534,11 @@ class _ComplaintToModerationForUserWidget extends StatelessWidget { final ComplaintToModerationForUser notification; @override Widget build(BuildContext context) { - return _BaseNotificationWidget(isRead: notification.isRead, notificationsText: 'Жалоба отправлена на модерацию', createdAt: notification.createdAt); + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Ваша жалоба принята и находится на модерации.', + createdAt: notification.createdAt, + ); } } @@ -473,8 +549,13 @@ class _CommentRepliedWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Ответ на комментарий: ${notification.entityName}', + notificationsText: 'Вам ответили в комментариях к: ${notification.entityName}', createdAt: notification.createdAt, + onTap: (notification.commentedEntityType == CommentedEntityType.unknown || notification.commentedEntityId <= 0) + ? null + : () { + _navigateToEntity(context, notification.commentedEntityType, notification.commentedEntityId, notification.commentId); + }, ); } } @@ -486,8 +567,11 @@ class _ScoreAddedWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Добавлены баллы: ${notification.scoreValue}', + notificationsText: 'Поздравляем! Вам начислены баллы: ${notification.scoreValue} ⭐', createdAt: notification.createdAt, + onTap: () { + context.push(RouteConstants.consultantRatingScreen); + }, ); } } @@ -499,24 +583,17 @@ class _EntityCommentedWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Комментарий к ${notification.entityTranslatedType}: ${notification.entityName}', + notificationsText: 'Новый комментарий к вашей публикации "${notification.entityTranslatedType}": ${notification.entityName}', createdAt: notification.createdAt, + onTap: (notification.entityType == CommentedEntityType.unknown || notification.entityId <= 0) + ? null + : () { + _navigateToEntity(context, notification.entityType, notification.entityId, notification.commentId); + }, ); } } -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 { @@ -526,7 +603,7 @@ class _PlanSubscriptionExpiredWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Подписка на план "${notification.planName}" истекла', + notificationsText: 'Срок действия подписки на план "${notification.planName}" истек. Продлите, чтобы продолжить! ⏳', createdAt: notification.createdAt, ); } @@ -539,7 +616,7 @@ class _PlanSubscriptionRenewedWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Подписка на план "${notification.planName}" продлена до ${notification.renewalDate}', + notificationsText: 'Успешное продление! Подписка на план "${notification.planName}" активна до ${notification.renewalDate} ✅', createdAt: notification.createdAt, ); } @@ -552,8 +629,11 @@ class _ConsultantAssignedWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'К плану "${notification.planName}" назначен консультант ${notification.consultantName}', + notificationsText: 'Вам назначен консультант ${notification.consultantName} по плану "${notification.planName}". Приветствуйте! 👋', createdAt: notification.createdAt, + onTap: () { + _navigateToBottomNavRoute(context, RouteConstants.chatSelectScreen, notification.consultantName.isNotEmpty ? notification.consultantName : null); + }, ); } } @@ -565,7 +645,7 @@ class _ConsultantUnassignedWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Консультант ${notification.consultantName} отстранен от плана "${notification.planName}"', + notificationsText: 'Изменение в плане "${notification.planName}": консультант ${notification.consultantName} отстранен.', createdAt: notification.createdAt, ); } @@ -578,8 +658,11 @@ class _PaymentFailedWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Ошибка оплаты плана "${notification.planName}": ${notification.errorMessage}', + notificationsText: 'Не удалось провести оплату за план "${notification.planName}". Причина: ${notification.errorMessage} ❌', createdAt: notification.createdAt, + onTap: () { + context.push(RouteConstants.userFinanceScreen); + }, ); } } @@ -591,8 +674,11 @@ class _PaymentSuccessfulWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Успешная оплата плана "${notification.planName}" на сумму ${notification.amount} ${notification.currency}', + notificationsText: 'Оплата прошла успешно! План "${notification.planName}" оплачен на сумму ${notification.amount} ${notification.currency} 💳', createdAt: notification.createdAt, + onTap: () { + context.push(RouteConstants.userFinanceScreen); + }, ); } } @@ -604,7 +690,7 @@ class _GoalAchievedWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Достигнута цель "${notification.goalName}": ${notification.achievedValue}/${notification.targetValue}', + notificationsText: 'Цель достигнута! "${notification.goalName}": ${notification.achievedValue}/${notification.targetValue} 🎯', createdAt: notification.createdAt, ); } @@ -617,7 +703,7 @@ class _WorkoutReminderWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Напоминание о тренировке "${notification.workoutName}" (${notification.duration} мин)', + notificationsText: 'Время тренировки! "${notification.workoutName}" ждет вас (${notification.duration} мин) 💪', createdAt: notification.createdAt, ); } @@ -630,8 +716,11 @@ class _MealReminderWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Напоминание о приеме пищи "${notification.mealName}" (${notification.calories} ккал)', + notificationsText: 'Пора подкрепиться! "${notification.mealName}" (${notification.calories} ккал) 🍎', createdAt: notification.createdAt, + onTap: () { + _navigateToBottomNavRoute(context, RouteConstants.diaryScreen); + }, ); } } @@ -644,8 +733,13 @@ class _DietCancelledWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Диета "${notification.dietName}" отменена', + notificationsText: 'Диета "${notification.dietName}" была отменена.', createdAt: notification.createdAt, + onTap: () { + if (notification.dietId > 0) { + context.push(RouteConstants.aboutDietScreen, extra: {'id': notification.dietId}); + } + }, ); } } @@ -657,8 +751,13 @@ class _DietStartedWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Диета "${notification.dietName}" началась', + notificationsText: 'Старт диеты "${notification.dietName}"! Успехов! 🥗', createdAt: notification.createdAt, + onTap: () { + if (notification.dietId > 0) { + context.push(RouteConstants.aboutDietScreen, extra: {'id': notification.dietId}); + } + }, ); } } @@ -670,8 +769,13 @@ class _DietExpiredWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Диета "${notification.dietName}" истекла', + notificationsText: 'Срок действия диеты "${notification.dietName}" подошел к концу.', createdAt: notification.createdAt, + onTap: () { + if (notification.dietId > 0) { + context.push(RouteConstants.aboutDietScreen, extra: {'id': notification.dietId}); + } + }, ); } } @@ -683,8 +787,13 @@ class _DietWillStartedInWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Диета "${notification.dietName}" начнется ${notification.startDate}', + notificationsText: 'Скоро старт! Диета "${notification.dietName}" начнется ${notification.startDate} ⏰', createdAt: notification.createdAt, + onTap: () { + if (notification.dietId > 0) { + context.push(RouteConstants.aboutDietScreen, extra: {'id': notification.dietId}); + } + }, ); } } @@ -696,8 +805,13 @@ class _DietCanNotBeStartedWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Диета "${notification.dietName}" не может быть начата: ${notification.reason}', + notificationsText: 'Внимание: Не удалось начать диету "${notification.dietName}". Причина: ${notification.reason}', createdAt: notification.createdAt, + onTap: () { + if (notification.dietId > 0) { + context.push(RouteConstants.aboutDietScreen, extra: {'id': notification.dietId}); + } + }, ); } } @@ -709,8 +823,11 @@ class _PersonalDietCreationNeededWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Требуется создание персональной диеты для клиента ${notification.clientName} по плану "${notification.planName}"', + notificationsText: 'Задача: Создать персональную диету для клиента ${notification.userName}', createdAt: notification.createdAt, + onTap: () { + context.push(RouteConstants.myDietsScreen); + }, ); } } @@ -722,7 +839,7 @@ class _ChosenPlanClientLastDayFinishedWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Последний день плана "${notification.planName}" завершен', + notificationsText: 'Поздравляем с завершением плана "${notification.planName}"! 🎉', createdAt: notification.createdAt, ); } @@ -735,8 +852,11 @@ class _ChosenPlanClientLastDayStartedWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Последний день плана "${notification.planName}" начался', + notificationsText: 'Сегодня последний день плана "${notification.planName}"! Финиш близко! 🏁', createdAt: notification.createdAt, + onTap: () { + _navigateToBottomNavRoute(context, RouteConstants.diaryScreen); + }, ); } } @@ -748,8 +868,15 @@ class _StayOnTrackToAuthorWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Клиент ${notification.clientName} остается на плане "${notification.planName}"', + notificationsText: 'Клиент ${notification.userName} завершил день ${notification.dayNum} по плану "${notification.planName}" 💪', createdAt: notification.createdAt, + onTap: () { + if (notification.diaryDayId > 0) { + context.push(RouteConstants.userDiaryScreen, extra: {'diaryId': notification.diaryDayId}); + } else { + _navigateToBottomNavRoute(context, RouteConstants.consultationsScreen); + } + }, ); } } @@ -761,8 +888,11 @@ class _ClientStartPlanSubscriptionToSubscriberWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Подписка на план "${notification.planName}" началась', + notificationsText: 'У вас новая подписка на план "${notification.planName}"! 🌟', createdAt: notification.createdAt, + onTap: () { + _navigateToBottomNavRoute(context, RouteConstants.chatSelectScreen); + }, ); } } @@ -774,8 +904,11 @@ class _ConsultantConfirmPlanSubscriptionToSubscriberWidget extends StatelessWidg Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Консультант ${notification.consultantName} подтвердил подписку на план "${notification.planName}"', + notificationsText: 'Ваша подписка на план "${notification.planName}" подтверждена консультантом ${notification.consultantName} ✅', createdAt: notification.createdAt, + onTap: () { + _navigateToBottomNavRoute(context, RouteConstants.chatSelectScreen, notification.consultantName.isNotEmpty ? notification.consultantName : null); + }, ); } } @@ -787,8 +920,11 @@ class _ClientPaidPlanSubscriptionToAuthorWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Клиент ${notification.clientName} оплатил подписку на план "${notification.planName}" на сумму ${notification.amount}', + notificationsText: 'Поступление средств: Клиент ${notification.clientName} оплатил подписку на план "${notification.planName}" 💸', createdAt: notification.createdAt, + onTap: () { + context.push(RouteConstants.consultantFinancesScreen); + }, ); } } @@ -800,8 +936,11 @@ class _ConsultantLeftDiaryCommentWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Консультант ${notification.consultantName} оставил комментарий к дневнику по плану "${notification.planName}"', + notificationsText: 'Ваш консультант ${notification.consultantName} прокомментировал дневник по плану "${notification.planName}" 💬', createdAt: notification.createdAt, + onTap: () { + _navigateToBottomNavRoute(context, RouteConstants.diaryScreen, notification.diaryDate); + }, ); } } @@ -813,8 +952,11 @@ class _ConsultantConfirmPlanSubscriptionToAuthorWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Консультант ${notification.consultantName} подтвердил подписку на план "${notification.planName}"', + notificationsText: 'Клиент ${notification.clientName} подтвердил подписку на план "${notification.planName}" 🤝', createdAt: notification.createdAt, + onTap: () { + _navigateToBottomNavRoute(context, RouteConstants.chatSelectScreen, notification.clientName.isNotEmpty ? notification.clientName : null); + }, ); } } @@ -826,8 +968,11 @@ class _ClientPaidPlanSubscriptionToSubscriberWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Оплачена подписка на план "${notification.planName}" на сумму ${notification.amount}', + notificationsText: 'Оплата подписки на план "${notification.planName}" прошла успешно ✅', createdAt: notification.createdAt, + onTap: () { + context.push(RouteConstants.consultantFinancesScreen); + }, ); } } @@ -839,8 +984,11 @@ class _ClientStartPlanSubscriptionToAuthorWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Клиент ${notification.clientName} начал подписку на план "${notification.planName}"', + notificationsText: 'Новый подписчик! Клиент ${notification.clientName} начал подписку на план "${notification.planName}" 🎈', createdAt: notification.createdAt, + onTap: () { + _navigateToBottomNavRoute(context, RouteConstants.chatSelectScreen, notification.clientName.isNotEmpty ? notification.clientName : null); + }, ); } } @@ -852,8 +1000,11 @@ class _ClientCancelPlanSubscriptionToAuthorWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Клиент ${notification.clientName} отменил подписку на план "${notification.planName}"', + notificationsText: 'Отмена подписки: Клиент ${notification.clientName} отказался от плана "${notification.planName}"', createdAt: notification.createdAt, + onTap: () { + _navigateToBottomNavRoute(context, RouteConstants.chatSelectScreen, notification.clientName.isNotEmpty ? notification.clientName : null); + }, ); } } @@ -865,8 +1016,11 @@ class _ClientCancelPlanSubscriptionToSubscriberWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Подписка на план "${notification.planName}" отменена', + notificationsText: 'Ваша подписка на план "${notification.planName}" была отменена.', createdAt: notification.createdAt, + onTap: () { + _navigateToBottomNavRoute(context, RouteConstants.chatSelectScreen); + }, ); } } @@ -878,8 +1032,11 @@ class _UserDailyReportCompletedWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Клиент ${notification.clientName} завершил ежедневный отчет по плану "${notification.planName}"', + notificationsText: 'Отчет получен: Клиент ${notification.clientName} заполнил дневник по плану "${notification.planName}" 📝', createdAt: notification.createdAt, + onTap: () { + context.push(UserDiaryScreen.path, extra: {'diaryId': notification.diaryId}); + }, ); } } @@ -891,7 +1048,7 @@ class _StayOnTrackToCustomerWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Консультант ${notification.consultantName} остается на плане "${notification.planName}"', + notificationsText: 'Вы продолжаете идти по плану "${notification.planName}" (день ${notification.dayNum}) 💪', createdAt: notification.createdAt, ); } @@ -904,7 +1061,7 @@ class _PhoneChangedWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Номер телефона изменен с ${notification.oldPhone} на ${notification.newPhone}', + notificationsText: 'Ваш номер телефона был успешно изменен на ${notification.newPhone} 📱', createdAt: notification.createdAt, ); } @@ -917,8 +1074,11 @@ class _SubscriptionWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Подписка ${notification.subscriptionType} на план "${notification.planName}"', + notificationsText: 'У вас новый подписчик: ${notification.userName} 🎉', createdAt: notification.createdAt, + onTap: () { + context.push(RouteConstants.subsScreen, extra: SubType.subscribers); + }, ); } } @@ -930,8 +1090,11 @@ class _BadgeAwardedWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Получен значок "${notification.badgeName}": ${notification.badgeDescription}', + notificationsText: 'Новая награда! Вы получили значок "${notification.badgeTitle}" (${notification.badgeType}) 🏅', createdAt: notification.createdAt, + onTap: () { + _navigateToBottomNavRoute(context, RouteConstants.myProfileScreen); + }, ); } } @@ -943,8 +1106,11 @@ class _BadgeRevokedWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Отозван значок "${notification.badgeName}": ${notification.reason}', + notificationsText: 'Значок "${notification.badgeTitle}" (${notification.badgeType}) был отозван.', createdAt: notification.createdAt, + onTap: () { + _navigateToBottomNavRoute(context, RouteConstants.myProfileScreen); + }, ); } } @@ -956,7 +1122,7 @@ class _ConsultantRequestStartedWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Запрос к консультанту ${notification.consultantName} начат', + notificationsText: 'Запрос отправлен консультанту ${notification.consultantName} 📩', createdAt: notification.createdAt, ); } @@ -969,7 +1135,7 @@ class _ClientCloseComplaintSuccessWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Жалоба на ${notification.complaintType} "${notification.entityName}" успешно закрыта', + notificationsText: 'Ваша жалоба по плану "${notification.planName}" была рассмотрена и закрыта.', createdAt: notification.createdAt, ); } @@ -982,8 +1148,11 @@ class _ClientRefundSuccessWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Возврат средств по плану "${notification.planName}" на сумму ${notification.amount}: ${notification.reason}', + notificationsText: 'Вам одобрен возврат средств по плану "${notification.planName}" (${notification.amount}).', createdAt: notification.createdAt, + onTap: () { + context.push(RouteConstants.userFinanceScreen); + }, ); } } @@ -995,8 +1164,11 @@ class _ConsultantRefundSuccessWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Возврат средств клиенту ${notification.clientName} по плану "${notification.planName}" на сумму ${notification.amount}', + notificationsText: 'Выполнен возврат средств клиенту ${notification.clientName} по плану "${notification.planName}" (${notification.amount})', createdAt: notification.createdAt, + onTap: () { + context.push(RouteConstants.consultantFinancesScreen); + }, ); } } @@ -1008,8 +1180,11 @@ class _ClientRejectSuccessWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Отклонение по плану "${notification.planName}": ${notification.reason}', + notificationsText: 'Отказ по плану "${notification.planName}" принят.', createdAt: notification.createdAt, + onTap: () { + _navigateToBottomNavRoute(context, RouteConstants.chatSelectScreen); + }, ); } } @@ -1021,8 +1196,11 @@ class _ConsultantRejectSuccessWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Отклонение клиентом ${notification.clientName} по плану "${notification.planName}": ${notification.reason}', + notificationsText: 'Клиент ${notification.clientName} отказался от плана "${notification.planName}".', createdAt: notification.createdAt, + onTap: () { + _navigateToBottomNavRoute(context, RouteConstants.chatSelectScreen, notification.clientName.isNotEmpty ? notification.clientName : null); + }, ); } } @@ -1034,8 +1212,11 @@ class _ScoreSubstractedWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Вычтено ${notification.scoreValue} баллов за ${notification.scorableName}', + notificationsText: 'Списание баллов: -${notification.scoreValue} за ${notification.scorableName}', createdAt: notification.createdAt, + onTap: () { + _navigateToBottomNavRoute(context, RouteConstants.consultantRatingScreen); + }, ); } } @@ -1045,11 +1226,13 @@ class _ProductStatusUpdatedForAuthorWidget extends StatelessWidget { final ProductStatusUpdatedForAuthorNotification notification; @override Widget build(BuildContext context) { - return _BaseNotificationWidget( - isRead: notification.isRead, - notificationsText: 'Статус продукта "${notification.productName}" изменен с ${notification.oldStatus} на ${notification.newStatus}', - createdAt: notification.createdAt, - ); + final text = notification.subject.isNotEmpty + ? notification.subject + : notification.status.isNotEmpty + ? 'Статус продукта "${notification.productName}": ${notification.status}' + : 'Статус продукта "${notification.productName}" обновлен'; + + return _BaseNotificationWidget(isRead: notification.isRead, notificationsText: text, createdAt: notification.createdAt); } } @@ -1058,7 +1241,15 @@ class _DiaryReminderForUsersWidget extends StatelessWidget { final DiaryReminderForUsersNotification notification; @override Widget build(BuildContext context) { - return _BaseNotificationWidget(isRead: notification.isRead, notificationsText: 'Напоминание о заполнении дневника', createdAt: notification.createdAt); + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Не забудьте заполнить дневник сегодня! 📓', + createdAt: notification.createdAt, + onTap: () { + final date = notification.createdAt; + _navigateToBottomNavRoute(context, RouteConstants.diaryScreen, date); + }, + ); } } @@ -1069,8 +1260,160 @@ class _PersonalDietIsDefinedToCustomerWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - onTap: () => GoRouter.of(context).push(RouteConstants.aboutDietScreen, extra: {'id': notification.dietId, 'navigateToComments': false}), - notificationsText: 'Персональная диета "${notification.dietName}" установлена', + notificationsText: 'Для вас составлена персональная диета "${notification.dietName}"! 🥗', + createdAt: notification.createdAt, + onTap: () { + if (notification.dietId > 0) { + context.push(RouteConstants.aboutDietScreen, extra: {'id': notification.dietId}); + } + }, + ); + } +} + +class _ConsultantEducationUpdatedWidget extends StatelessWidget { + const _ConsultantEducationUpdatedWidget({required this.notification}); + final ConsultantEducationUpdatedNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Консультант ${notification.consultantName} обновил данные об образовании.', + createdAt: notification.createdAt, + onTap: () { + _navigateToBottomNavRoute(context, RouteConstants.myProfileScreen); + }, + ); + } +} + +class _ComplaintRegisteredWidget extends StatelessWidget { + const _ComplaintRegisteredWidget({required this.notification}); + final ComplaintRegisteredNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Мы получили вашу жалобу на ${notification.entityName}. Спасибо за сигнал!', + createdAt: notification.createdAt, + ); + } +} + +class _ContentUnpublishedWidget extends StatelessWidget { + const _ContentUnpublishedWidget({required this.notification}); + final ContentUnpublishedNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Ваша публикация ${notification.entityName} была скрыта. Причина: ${notification.reason}', + createdAt: notification.createdAt, + ); + } +} + +class _FinanceQuestionCreatedWidget extends StatelessWidget { + const _FinanceQuestionCreatedWidget({required this.notification}); + final FinanceQuestionCreatedNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Служба поддержки: Новый вопрос по финансам: ${notification.questionText}', + createdAt: notification.createdAt, + onTap: () { + _navigateToBottomNavRoute(context, RouteConstants.chatSelectScreen); + }, + ); + } +} + +class _NewChatMessageWidget extends StatelessWidget { + const _NewChatMessageWidget({required this.notification}); + final NewChatMessageNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Входящее сообщение от ${notification.senderName}: "${notification.messagePreview}"', + createdAt: notification.createdAt, + onTap: () { + _navigateToBottomNavRoute(context, RouteConstants.chatSelectScreen, notification.senderName.isNotEmpty ? notification.senderName : null); + }, + ); + } +} + +class _CompleteRegistrationWidget extends StatelessWidget { + const _CompleteRegistrationWidget({required this.notification}); + final CompleteRegistrationNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Завершите регистрацию, чтобы получить доступ ко всем функциям! 🚀', + createdAt: notification.createdAt, + ); + } +} + +class _ChosenDietExpiredWidget extends StatelessWidget { + const _ChosenDietExpiredWidget({required this.notification}); + final ChosenDietExpiredNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Диета "${notification.dietName}" завершена. Время подвести итоги!', + createdAt: notification.createdAt, + ); + } +} + +class _MotivateUserWidget extends StatelessWidget { + const _MotivateUserWidget({required this.notification}); + final MotivateUserNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget(isRead: notification.isRead, notificationsText: notification.message, createdAt: notification.createdAt); + } +} + +class _CommentComplaintWidget extends StatelessWidget { + const _CommentComplaintWidget({required this.notification}); + final CommentComplaintNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Поступила жалоба на ваш комментарий: "${notification.commentPreview}". Причина: ${notification.reason}', + createdAt: notification.createdAt, + ); + } +} + +class _CommentDeletedWidget extends StatelessWidget { + const _CommentDeletedWidget({required this.notification}); + final CommentDeletedNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Ваш комментарий был удален по правилам сообщества: "${notification.commentPreview}"', + createdAt: notification.createdAt, + ); + } +} + +class _UserSubscribedWidget extends StatelessWidget { + const _UserSubscribedWidget({required this.notification}); + final UserSubscribedNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'У вас новый подписчик: ${notification.userName}! 👋', createdAt: notification.createdAt, ); } diff --git a/lib/features/notifications/service/notifications_service.dart b/lib/features/notifications/service/notifications_service.dart index 1fd5e4d6..c934fb53 100644 --- a/lib/features/notifications/service/notifications_service.dart +++ b/lib/features/notifications/service/notifications_service.dart @@ -413,6 +413,78 @@ class NotificationsService { isRead: readDate != null, createdAt: createdAt, ), + NotificationType.consultantEducationUpdated => ConsultantEducationUpdatedNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.complaintRegistered => ComplaintRegisteredNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.contentUnpublished => ContentUnpublishedNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.contentUnpublishedComplaint => ContentUnpublishedNotification.fromJson( // Reusing ContentUnpublishedNotification if structure is same, or create specific if different. Assuming similar for now or check backend. + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.financeQuestionCreated => FinanceQuestionCreatedNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.newChatMessage => NewChatMessageNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.completeRegistration => CompleteRegistrationNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.chosenDietExpired => ChosenDietExpiredNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.motivateUser => MotivateUserNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.commentComplaint => CommentComplaintNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.commentDeleted => CommentDeletedNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), + NotificationType.userSubscribed => UserSubscribedNotification.fromJson( + notificationJson['data'], + type, + isRead: readDate != null, + createdAt: createdAt, + ), NotificationType.unknown => UnknownNotification(type: type, createdAt: parseDate(createdAt)), }; } catch (e) { diff --git a/lib/features/plans/presentation/pages/about_plan_screen.dart b/lib/features/plans/presentation/pages/about_plan_screen.dart index 03e94814..283ef0fc 100644 --- a/lib/features/plans/presentation/pages/about_plan_screen.dart +++ b/lib/features/plans/presentation/pages/about_plan_screen.dart @@ -32,10 +32,18 @@ import 'package:go_router/go_router.dart'; import '../../../../core/widgets/option_widget.dart'; class AboutPlanScreen extends StatefulWidget { - const AboutPlanScreen({super.key, required this.slug, required this.navigateToComments, required this.index}); final String slug; final bool navigateToComments; final int? index; + final int? highlightedCommentId; + + const AboutPlanScreen({ + super.key, + required this.slug, + required this.navigateToComments, + required this.index, + this.highlightedCommentId, + }); @override State createState() => _AboutPlanScreenState(); } @@ -468,13 +476,14 @@ class _AboutPlanScreenState extends State { ), const SizedBox(height: 16), if (commentsState is CommentsStateLoading) const Loader(), - if (commentsState is CommentsStateLoaded) - CommentsWidget( - id: state.planModel?.id.toString() ?? '', - commentSourceType: CommentSourceType.plan, - comment: commentsState.comment, - replyId: replyId, - ), + if (commentsState is CommentsStateLoaded) + CommentsWidget( + id: state.planModel?.id.toString() ?? '', + commentSourceType: CommentSourceType.plan, + comment: commentsState.comment, + replyId: replyId, + highlightedCommentId: widget.highlightedCommentId, + ), ], ), ), diff --git a/lib/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart b/lib/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart index 5df7ed84..9538de53 100644 --- a/lib/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart +++ b/lib/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart @@ -4,6 +4,7 @@ import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart'; import 'package:eatvio_app/core/utils/toasts.dart'; import 'package:eatvio_app/features/chat/repositories/chat_repository.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart'; import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart'; import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_state.dart'; @@ -24,13 +25,20 @@ class ProfileCubit extends Cubit { try { final profile = await profileRepository.getProfileBySlug(slug); final myProfile = await profileRepository.getMyProfile(); + final isMyProfile = myProfile.slug == profile.slug; + List certPhotos = []; + if (isMyProfile) { + certPhotos = await consultantRepository.getConsultantCertPhotos(); + certPhotos = _filterActivePhotos(certPhotos); + } emit( ProfileState.loaded( profile: profile, - isMyProfile: myProfile.slug == profile.slug, + isMyProfile: isMyProfile, profileDetails: await profileRepository.getProfileDetailsBySlug(slug), blogPosts: await profileRepository.getBlogPostsByAuthorSlug(slug: slug), consultantInfo: profile.roles.contains('consultant') ? await consultantRepository.getConsultantRequestStatus(slug) : null, + certPhotos: certPhotos, ), ); } catch (e) { @@ -44,6 +52,8 @@ class ProfileCubit extends Cubit { _currentPeriod = period; try { final profile = await profileRepository.getMyProfile(); + List certPhotos = await consultantRepository.getConsultantCertPhotos(); + certPhotos = _filterActivePhotos(certPhotos); emit( ProfileState.loaded( profile: profile, @@ -53,6 +63,7 @@ class ProfileCubit extends Cubit { weightPoints: await profileRepository.getWeight(period: period), circumferencePoints: await profileRepository.getCircumferences(period: period), consultantInfo: profile.roles.contains('consultant') ? await consultantRepository.getConsultantRequestStatus(profile.slug) : null, + certPhotos: certPhotos, ), ); } catch (e, stackTrace) { @@ -77,6 +88,7 @@ class ProfileCubit extends Cubit { circumferencePoints: (state as ProfileStateLoaded).circumferencePoints, consultantInfo: (state as ProfileStateLoaded).consultantInfo, isMyProfile: (state as ProfileStateLoaded).isMyProfile, + certPhotos: (state as ProfileStateLoaded).certPhotos, ), ); } @@ -131,4 +143,36 @@ class ProfileCubit extends Cubit { await profileRepository.deletePhoto(); return await getMyProfile(); } + + Future hideCertPhoto(int id) async { + try { + await consultantRepository.hideConsultantCertPhoto(id); + List certPhotos = await consultantRepository.getConsultantCertPhotos(); + certPhotos = _filterActivePhotos(certPhotos); + final currentState = state; + if (currentState is ProfileStateLoaded) { + emit(currentState.copyWith(certPhotos: certPhotos)); + } + } catch (e) { + log(e.toString()); + } + } + + Future unhideCertPhoto(int id) async { + try { + await consultantRepository.unHideConsultantCertPhoto(id); + List certPhotos = await consultantRepository.getConsultantCertPhotos(); + certPhotos = _filterActivePhotos(certPhotos); + final currentState = state; + if (currentState is ProfileStateLoaded) { + emit(currentState.copyWith(certPhotos: certPhotos)); + } + } catch (e) { + log(e.toString()); + } + } + + List _filterActivePhotos(List photos) { + return photos.where((photo) => photo.isActive).toList(); + } } diff --git a/lib/features/profile/presentation/bloc/profile_cubit/profile_state.dart b/lib/features/profile/presentation/bloc/profile_cubit/profile_state.dart index e20ba9b5..4706964e 100644 --- a/lib/features/profile/presentation/bloc/profile_cubit/profile_state.dart +++ b/lib/features/profile/presentation/bloc/profile_cubit/profile_state.dart @@ -1,3 +1,4 @@ +import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart'; import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart'; import 'package:eatvio_app/features/profile/models/circumference_model.dart'; @@ -18,6 +19,7 @@ abstract class ProfileState with _$ProfileState { required ProfileDetailsModel profileDetails, required List blogPosts, required bool isMyProfile, + required List certPhotos, List? weightPoints, List? circumferencePoints, ConsultationRequestModel? consultantInfo, diff --git a/lib/features/profile/presentation/bloc/sub_cubit/sub_cubit.dart b/lib/features/profile/presentation/bloc/sub_cubit/sub_cubit.dart index b6d40645..693fe8d5 100644 --- a/lib/features/profile/presentation/bloc/sub_cubit/sub_cubit.dart +++ b/lib/features/profile/presentation/bloc/sub_cubit/sub_cubit.dart @@ -5,22 +5,30 @@ import 'package:flutter_bloc/flutter_bloc.dart'; class SubCubit extends Cubit { final ProfileRepository profileRepository; - SubCubit({ - required this.profileRepository, - }) : super(const SubState()); + SubCubit({required this.profileRepository}) : super(const SubState()); - Future getSubscriptions(String slug, [String? query]) async { + Future getSubscriptions(String? slug, [String? query]) async { emit(const SubState.loading()); try { + if (slug == null) { + final myProfile = await profileRepository.getMyProfile(); + emit(SubState.loaded(subList: await profileRepository.getSubscriptionsBySlug(myProfile.slug, query))); + return; + } emit(SubState.loaded(subList: await profileRepository.getSubscriptionsBySlug(slug, query))); } catch (e) { emit(SubState.error(errorMessage: e.toString())); } } - Future getSubscribers(String slug, [String? query]) async { + Future getSubscribers(String? slug, [String? query]) async { emit(const SubState.loading()); try { + if (slug == null) { + final myProfile = await profileRepository.getMyProfile(); + emit(SubState.loaded(subList: await profileRepository.getSubscribersBySlug(myProfile.slug, query))); + return; + } emit(SubState.loaded(subList: await profileRepository.getSubscribersBySlug(slug, query))); } catch (e) { emit(SubState.error(errorMessage: e.toString())); diff --git a/lib/features/profile/presentation/pages/profile_screen.dart b/lib/features/profile/presentation/pages/profile_screen.dart index 6bac8c74..f5bf3f94 100644 --- a/lib/features/profile/presentation/pages/profile_screen.dart +++ b/lib/features/profile/presentation/pages/profile_screen.dart @@ -8,6 +8,7 @@ import 'package:eatvio_app/core/enums/complained_content.dart'; import 'package:eatvio_app/core/permission_handler/permission_handler.dart'; import 'package:eatvio_app/core/theme/custom_text_style.dart'; import 'package:eatvio_app/core/utils/date_time_utils.dart'; +import 'package:eatvio_app/core/widgets/bottom_sheet_with_blur.dart'; import 'package:eatvio_app/core/widgets/data_error_widget.dart'; import 'package:eatvio_app/core/widgets/loader.dart'; import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart'; @@ -26,6 +27,7 @@ import 'package:eatvio_app/features/profile/presentation/pages/subs_screen.dart' import 'package:eatvio_app/features/profile/presentation/widgets/circumference_chart_widget.dart'; import 'package:eatvio_app/features/profile/presentation/widgets/imt_chart_widget.dart'; import 'package:eatvio_app/features/profile/presentation/widgets/profile_blog_post_widget.dart'; +import 'package:eatvio_app/features/profile/presentation/widgets/toggle_cert_photo_bottom_sheet.dart'; import 'package:eatvio_app/features/profile/presentation/widgets/weight_chart_widget.dart'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; @@ -117,7 +119,6 @@ class _ProfileScreenState extends State { ), ); } - return state.isMyProfile ? _myProfileScreen(state) : _profileScreen(state); } else if (state is ProfileStateError) { return Scaffold( @@ -436,9 +437,16 @@ class _ProfileScreenState extends State { } SingleChildRenderObjectWidget _diplomasAndCertificates(ProfileStateLoaded state) { - if (!state.profile.roles.contains('consultant') || state.consultantInfo!.photos.isEmpty) { + if (!state.profile.roles.contains('consultant')) { return const SizedBox.shrink(); } + if (state.isMyProfile && state.certPhotos.isEmpty) { + return const SizedBox.shrink(); + } + if (!state.isMyProfile && state.consultantInfo!.photos.isEmpty) { + return const SizedBox.shrink(); + } + return Padding( padding: const EdgeInsets.only(bottom: 16), child: Column( @@ -454,7 +462,7 @@ class _ProfileScreenState extends State { ), GestureDetector( onTap: () { - GoRouter.of(context).pushNamed('stepperBecomeConsultantScreen', extra: state.profile.slug); + GoRouter.of(context).pushNamed('stepperBecomeConsultantScreen', extra: {'slug': state.profile.slug}); }, child: Text( state.isMyProfile ? 'Редактировать' : '', @@ -477,14 +485,15 @@ class _ProfileScreenState extends State { padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), 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); - } + if (state.isMyProfile && state.certPhotos.isNotEmpty) + ...List.generate(state.certPhotos.length, (index) { + final photo = state.certPhotos[index]; + return Row(children: [_diplomaImage(photo.url, state.isMyProfile, photo.isHidden, photo.id), const SizedBox(width: 8)]); + }) + else if (state.consultantInfo!.photos.isNotEmpty) + ...List.generate(state.consultantInfo!.photos.length, (index) { + final photo = state.consultantInfo!.photos[index]; + return Row(children: [_diplomaImage(photo.url, state.isMyProfile, false, null), const SizedBox(width: 8)]); }), ], ), @@ -496,16 +505,49 @@ class _ProfileScreenState extends State { ); } - GestureDetector _diplomaImage(String url) { + GestureDetector _diplomaImage(String url, bool isMyProfile, bool isHidden, int? certPhotoId) { return GestureDetector( onTap: () { 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: Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: CachedNetworkImage(imageUrl: url, fit: BoxFit.fill, width: MediaQuery.of(context).size.width / 3 - (60 / 3), height: 160), + ), + if (isMyProfile) + Positioned( + right: 4, + top: 4, + child: GestureDetector( + onTap: () { + if (certPhotoId != null) { + showBottomSheetWithBlur( + userRootNavigator: true, + context: context, + height: 250, + child: ToggleCertPhotoBottomSheet(profileCubit: profileCubit, isPhotoHidden: isHidden, id: certPhotoId), + ); + } + }, + child: Container( + decoration: BoxDecoration(color: appTheme.whiteA700, borderRadius: BorderRadius.circular(4)), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 5), + child: SvgPicture.asset( + isHidden ? 'assets/icons/crossed_eye.svg' : 'assets/icons/eye2.svg', + width: 10, + height: 8, + colorFilter: ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn), + ), + ), + ), + ), + ), + ], ), ), ); @@ -532,7 +574,7 @@ class _ProfileScreenState extends State { ), GestureDetector( onTap: () { - GoRouter.of(context).pushNamed('stepperBecomeConsultantScreen', extra: state.profile.slug); + GoRouter.of(context).pushNamed('stepperBecomeConsultantScreen', extra: {'slug': state.profile.slug}); }, child: Text( state.isMyProfile ? 'Редактировать' : '', diff --git a/lib/features/profile/presentation/pages/subs_screen.dart b/lib/features/profile/presentation/pages/subs_screen.dart index d5d782f2..6de5194e 100644 --- a/lib/features/profile/presentation/pages/subs_screen.dart +++ b/lib/features/profile/presentation/pages/subs_screen.dart @@ -12,7 +12,7 @@ enum SubType { subscribers, subscriptions } class SubsScreen extends StatefulWidget { final SubType subType; - final String slug; + final String? slug; const SubsScreen({ super.key, diff --git a/lib/features/profile/presentation/widgets/toggle_cert_photo_bottom_sheet.dart b/lib/features/profile/presentation/widgets/toggle_cert_photo_bottom_sheet.dart new file mode 100644 index 00000000..4b5bbc57 --- /dev/null +++ b/lib/features/profile/presentation/widgets/toggle_cert_photo_bottom_sheet.dart @@ -0,0 +1,55 @@ +import 'package:eatvio_app/core/app_router.dart'; +import 'package:eatvio_app/core/theme/custom_text_style.dart'; +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/core/widgets/bottom_sheet_handler_bar.dart'; +import 'package:eatvio_app/core/widgets/custom_outlined_button.dart'; +import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart'; +import 'package:eatvio_app/generated/l10n.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +class ToggleCertPhotoBottomSheet extends StatelessWidget { + const ToggleCertPhotoBottomSheet({super.key, required this.profileCubit, required this.isPhotoHidden, required this.id}); + final ProfileCubit profileCubit; + final bool isPhotoHidden; + final int id; + @override + Widget build(BuildContext context) { + final s = S.of(context); + + return Column( + children: [ + const SizedBox(height: 20), + const BottomSheetHandlerBar(), + const SizedBox(height: 20), + Text(isPhotoHidden ? s.unhide_photo_title : s.hide_photo_title, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)), + Text( + isPhotoHidden ? s.unhide_photo_description : s.hide_photo_description, + style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black), + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: CustomOutlinedButton( + onPressed: () async { + if (isPhotoHidden) { + await profileCubit.unhideCertPhoto(id); + AppRouter.navigatorKey.currentContext?.pop(); + return; + } + await profileCubit.hideCertPhoto(id); + AppRouter.navigatorKey.currentContext?.pop(); + return; + }, + text: s.ok, + height: 52, + buttonTextStyle: TextStyles.textSSemibold.copyWith(color: appTheme.whiteA700), + buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)), + ), + ), + const SizedBox(height: 20), + ], + ); + } +} diff --git a/lib/features/recipe/models/my_recipe_model.dart b/lib/features/recipe/models/my_recipe_model.dart index f7068938..c3f82b17 100644 --- a/lib/features/recipe/models/my_recipe_model.dart +++ b/lib/features/recipe/models/my_recipe_model.dart @@ -1,4 +1,8 @@ +import 'package:eatvio_app/features/create_recipe/models/recipe_model.dart'; +import 'package:eatvio_app/features/create_recipe/models/step.dart' as cr_step; +import 'package:eatvio_app/features/create_recipe/models/ingredient.dart' as cr_ingredient; import 'package:eatvio_app/features/feed/models/recipes_model.dart'; +import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'my_recipe_model.g.dart'; @@ -34,3 +38,72 @@ abstract class MyRecipeModel with _$MyRecipeModel { factory MyRecipeModel.fromJson(Map json) => _$MyRecipeModelFromJson(json); } + +extension MyRecipeModelX on MyRecipeModel { + RecipeModel toRecipeModel() { + return RecipeModel( + title: title, + description: description, + image: image, + totalCookingTime: cookingTime, + activeCookTime: activeCookingTime, + energyPerServing: calories.toInt(), + videoUrl: videoUrl, + // The API expects 'active' to publish + state: 'active', + steps: steps?.map((e) => cr_step.Step(image: e.image, description: e.description)).toList().cast(), + // For My Recipes we don't have ingredients directly accessible in this basic model, + // but when it gets sent to publish, RecipeRepository merges properties correctly if available. + ingredients: [], + tags: [], + ); + } + + Recipe toRecipe() { + return Recipe( + id: id, + image: image, + state: state, + cookingTime: cookingTime, + activeCookingTime: activeCookingTime, + ingredients: [], + description: description, + steps: steps, + videoUrl: videoUrl, + title: title, + slug: slug, + calories: calories, + likesCount: likesCount, + viewsCount: viewsCount, + type: 'recipe', + commentsCount: 0, + wasLikedByMe: false, + isFavorite: false, + tags: [], + author: const Author(authorSlug: '', authorName: '', authorProfileIcon: '', authorIsMe: true), + ); + } +} + +extension RecipeToRecipeModelX on Recipe { + RecipeModel toRecipeModel() { + return RecipeModel( + title: title, + description: description, + image: image, + totalCookingTime: cookingTime, + activeCookTime: activeCookingTime, + energyPerServing: calories.toInt(), + videoUrl: videoUrl, + state: 'active', + steps: steps?.map((e) => cr_step.Step(image: e.image, description: e.description)).toList(), + ingredients: ingredients?.map((e) => cr_ingredient.Ingredient( + name: e.name, + portion: e.portion, + unit: e.unit ?? '', + productId: e.productId, + )).toList() ?? [], + tags: tags.map((e) => e.name).toList(), + ); + } +} diff --git a/lib/features/recipe/presentation/cubit/my_recipes_cubit/my_recieps_cubit.dart b/lib/features/recipe/presentation/cubit/my_recipes_cubit/my_recipes_cubit.dart similarity index 56% rename from lib/features/recipe/presentation/cubit/my_recipes_cubit/my_recieps_cubit.dart rename to lib/features/recipe/presentation/cubit/my_recipes_cubit/my_recipes_cubit.dart index 44481c3a..db46b7b4 100644 --- a/lib/features/recipe/presentation/cubit/my_recipes_cubit/my_recieps_cubit.dart +++ b/lib/features/recipe/presentation/cubit/my_recipes_cubit/my_recipes_cubit.dart @@ -1,20 +1,28 @@ +import 'dart:developer'; +import 'package:dio/dio.dart'; +import 'package:eatvio_app/features/create_recipe/models/recipe_model.dart'; +import 'package:eatvio_app/features/create_recipe/reporitories/recipe_repository.dart'; import 'package:eatvio_app/features/feed/models/recipes_model.dart'; import 'package:eatvio_app/features/recipe/models/my_recipe_model.dart'; import 'package:eatvio_app/features/recipe/presentation/cubit/my_recipes_cubit/my_recipes_state.dart'; import 'package:eatvio_app/features/recipe/repositories/about_recipe_repository.dart'; import 'package:eatvio_app/features/recipe/repositories/my_recipes_repository.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; class MyRecipesCubit extends Cubit { final MyRecipesRepository myRecipesRepository; final AboutRecipeRepository aboutRecipeRepository; + final RecipeRepository recipeRepository; static const String active = 'active'; static const String archived = 'archived'; + static const String drafted = 'drafted'; MyRecipesCubit({ required this.myRecipesRepository, required this.aboutRecipeRepository, + required this.recipeRepository, }) : super(const MyRecipesState.loading()); Future getMyRecipes() async { @@ -50,7 +58,47 @@ class MyRecipesCubit extends Cubit { } } + Future setDraftedState() async { + emit(const MyRecipesState.loading()); + try { + final myRecipes = await myRecipesRepository.getMyRecipes(); + final List draftedRecipes = myRecipes.where((recipe) => recipe.state == drafted).toList(); + emit(MyRecipesState.loaded(recipes: draftedRecipes, selectedState: drafted)); + } catch (e) { + emit(const MyRecipesState.error()); + } + } + Future getRecipeById(String slug) async { return await aboutRecipeRepository.getRecipe(slug); } + + /// Attempts to publish a drafted recipe + /// Returns `true` if successful + /// Returns `false` if failed (e.g. validation error 422 because fields are missing) + Future publishDraft(RecipeModel recipe, String slug) async { + try { + await recipeRepository.publishRecipe(recipe, true, slug); + return true; + } catch (e, st) { + if (e is DioException && e.response?.statusCode == 422) { + log('Validation failed for publishing draft: \${e.response?.data}'); + } else { + await Sentry.captureException(e, stackTrace: st); + } + return false; + } + } + + /// Moves an active recipe to draft + Future draftRecipe(int id) async { + try { + await recipeRepository.draftExistingRecipe(id); + return true; + } catch (e, st) { + log('Failed to draft recipe: \$e'); + await Sentry.captureException(e, stackTrace: st); + return false; + } + } } diff --git a/lib/features/recipe/presentation/pages/about_recipe_screen.dart b/lib/features/recipe/presentation/pages/about_recipe_screen.dart index a5448a86..ff9e4f0a 100644 --- a/lib/features/recipe/presentation/pages/about_recipe_screen.dart +++ b/lib/features/recipe/presentation/pages/about_recipe_screen.dart @@ -36,8 +36,9 @@ class AboutRecipeScreen extends StatefulWidget { final String recipeSlug; final int? index; final bool navigateToComments; + final int? highlightedCommentId; - const AboutRecipeScreen({super.key, required this.recipeSlug, required this.index, required this.navigateToComments}); + const AboutRecipeScreen({super.key, required this.recipeSlug, required this.index, required this.navigateToComments, this.highlightedCommentId}); @override State createState() => _AboutRecipeScreenState(); @@ -391,6 +392,7 @@ class _AboutRecipeScreenState extends State { commentSourceType: CommentSourceType.recipe, comment: state.comment, replyId: replyId, + highlightedCommentId: widget.highlightedCommentId, ), ], ), diff --git a/lib/features/recipe/presentation/pages/my_recipes_screen.dart b/lib/features/recipe/presentation/pages/my_recipes_screen.dart index c3e29db5..f3fc4b36 100644 --- a/lib/features/recipe/presentation/pages/my_recipes_screen.dart +++ b/lib/features/recipe/presentation/pages/my_recipes_screen.dart @@ -1,11 +1,14 @@ 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/features/recipe/presentation/cubit/my_recipes_cubit/my_recieps_cubit.dart'; +import 'package:eatvio_app/features/recipe/presentation/cubit/my_recipes_cubit/my_recipes_cubit.dart'; import 'package:eatvio_app/features/recipe/presentation/cubit/my_recipes_cubit/my_recipes_state.dart'; import 'package:eatvio_app/features/recipe/presentation/widgets/my_recipe_widget.dart'; +import 'package:eatvio_app/features/recipe/presentation/widgets/my_recipes_tab.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:eatvio_app/features/create_recipe/presentation/pages/recipe_create_screen.dart'; +import 'package:go_router/go_router.dart'; class MyRecipesScreen extends StatelessWidget { const MyRecipesScreen({super.key}); @@ -32,15 +35,68 @@ class MyRecipesScreen extends StatelessWidget { create: (BuildContext context) => sl()..getMyRecipes(), child: BlocBuilder( builder: (BuildContext context, MyRecipesState state) { - if (state is MyRecipesStateLoaded) { - return ListView.separated( - padding: const EdgeInsets.symmetric(vertical: 16), - itemBuilder: (context, index) => MyRecipeWidget(recipe: state.recipes[index]), - separatorBuilder: (context, index) => const SizedBox(height: 12), - itemCount: state.recipes.length, - ); - } - return Container(); + return Stack( + children: [ + Positioned.fill( + child: state is MyRecipesStateLoaded + ? state.recipes.isEmpty + ? Center( + child: Text( + state.selectedState == MyRecipesCubit.drafted ? 'Нет черновиков' : 'Нет опубликованных рецептов', + style: TextStyles.textLMedium.copyWith(color: appTheme.gray500), + ), + ) + : ListView.separated( + padding: const EdgeInsets.only(top: 80, bottom: 16), + itemBuilder: (context, index) => MyRecipeWidget(recipe: state.recipes[index]), + separatorBuilder: (context, index) => const SizedBox(height: 12), + itemCount: state.recipes.length, + ) + : const Center(child: CircularProgressIndicator()), + ), + Positioned( + top: 16, + left: 16, + right: 16, + child: Row( + children: [ + Expanded( + child: MyRecipesTab( + selectedIndex: state is MyRecipesStateLoaded ? (state.selectedState == MyRecipesCubit.drafted ? 1 : 0) : 0, + onTabChanged: (index) { + if (index == 0) { + context.read().setActiveState(); + } else { + context.read().setDraftedState(); + } + }, + ), + ), + const SizedBox(width: 8), + InkWell( + onTap: () { + context.push(RecipeCreateScreen.path); + }, + borderRadius: BorderRadius.circular(100), + child: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: appTheme.blueA400, + shape: BoxShape.circle, + ), + child: Icon( + Icons.add, + color: appTheme.whiteA700, + size: 24, + ), + ), + ), + ], + ), + ), + ], + ); }, ), ), diff --git a/lib/features/recipe/presentation/widgets/my_recipe_widget.dart b/lib/features/recipe/presentation/widgets/my_recipe_widget.dart index c30aebc1..31c92082 100644 --- a/lib/features/recipe/presentation/widgets/my_recipe_widget.dart +++ b/lib/features/recipe/presentation/widgets/my_recipe_widget.dart @@ -8,10 +8,12 @@ import 'package:eatvio_app/core/utils/toasts.dart'; import 'package:eatvio_app/core/widgets/custom_outlined_button.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/recipes_cubit/recipes_cubit.dart'; import 'package:eatvio_app/features/recipe/models/my_recipe_model.dart'; -import 'package:eatvio_app/features/recipe/presentation/cubit/my_recipes_cubit/my_recieps_cubit.dart'; +import 'package:eatvio_app/features/recipe/presentation/cubit/my_recipes_cubit/my_recipes_cubit.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:eatvio_app/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_cubit.dart'; +import 'package:eatvio_app/features/create_recipe/presentation/pages/recipe_create_screen.dart'; import 'package:eatvio_app/core/widgets/markdown_html_widget.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:go_router/go_router.dart'; @@ -99,11 +101,7 @@ class _MyRecipeWidgetState extends State { ? '${widget.recipe.description.substring(0, 200)}...' : widget.recipe.description; - return MarkdownHtmlWidget( - markdownText: description, - fontSize: TextStyles.textSMedium.fontSize ?? 14, - textColor: appTheme.black, - ); + return MarkdownHtmlWidget(markdownText: description, fontSize: TextStyles.textSMedium.fontSize ?? 14, textColor: appTheme.black); }, ), ), @@ -126,7 +124,13 @@ class _MyRecipeWidgetState extends State { return; } showSuccessToast('Рецепт удален'); - if (context.mounted) context.read().getMyRecipes(); + if (context.mounted) { + if (isDraft) { + context.read().setDraftedState(); + } else { + context.read().getMyRecipes(); + } + } }, text: 'Удалить', height: 52, @@ -140,30 +144,147 @@ class _MyRecipeWidgetState extends State { ), const SizedBox(width: 8), Expanded( + child: isDraft + ? CustomOutlinedButton( + onPressed: () async { + showDialog( + context: context, + barrierColor: Colors.black12, + builder: (context) => const Center(child: CircularProgressIndicator()), + ); + final fullRecipe = await context.read().getRecipeById(widget.recipe.slug); + if (context.mounted) { + Navigator.pop(context); // close dialog + context.push(RecipeCreateScreen.path, extra: fullRecipe); + } + }, + height: 52, + buttonStyle: ButtonStyle( + shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.circular(20))), + backgroundColor: WidgetStatePropertyAll(appTheme.lightGray0), + side: const WidgetStatePropertyAll(BorderSide.none), + ), + text: 'Редактировать', + buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.black), + ) + : PopupMenuButton( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + offset: const Offset(0, 50), + itemBuilder: (context) => [ + if (!isDraft) + PopupMenuItem( + onTap: () async { + final box = context.findRenderObject() as RenderBox?; + await SharePlus.instance.share( + ShareParams( + uri: Uri.parse('\${sl().baseUrl}/ru/recipe/\${widget.recipe.slug}'), + sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, + ), + ); + }, + child: Row( + children: [ + Icon(Icons.share, size: 20, color: appTheme.black), + const SizedBox(width: 8), + Text(S.of(context).share, style: TextStyles.textSMedium), + ], + ), + ), + ], + child: CustomOutlinedButton( + onPressed: () {}, + height: 52, + width: 52, + buttonStyle: ButtonStyle( + shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.circular(20))), + backgroundColor: WidgetStatePropertyAll(appTheme.blueA400), + side: const WidgetStatePropertyAll(BorderSide.none), + ), + text: s.share, + buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700), + ), + ), + ), + ], + ), + if (isDraft) + Padding( + padding: const EdgeInsets.only(top: 8), + child: SizedBox( + width: double.infinity, child: CustomOutlinedButton( onPressed: () async { - final box = context.findRenderObject() as RenderBox?; - await SharePlus.instance.share( - ShareParams( - uri: Uri.parse('${sl().baseUrl}/ru/recipe/${widget.recipe.slug}'), - sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, - ), + showDialog( + context: context, + barrierColor: Colors.black12, + builder: (context) => const Center(child: CircularProgressIndicator()), ); + final fullRecipe = await context.read().getRecipeById(widget.recipe.slug); + final createCubit = sl(); + await createCubit.recipeToEntity(fullRecipe); + + if (context.mounted) { + Navigator.pop(context); + if (createCubit.validateAll()) { + final success = await createCubit.publishRecipe(true, widget.recipe.slug); + if (success && context.mounted) { + showSuccessToast('Черновик опубликован'); + context.read().setDraftedState(); + } else { + showErrorToast('Не удалось опубликовать черновик'); + } + } else { + showErrorToast('Заполните все необходимые поля для публикации рецепта'); + if (context.mounted) { + context.push(RecipeCreateScreen.path, extra: { + 'recipe': fullRecipe, + 'autoValidate': true, + }); + } + } + await createCubit.close(); + } }, height: 52, - width: 52, buttonStyle: ButtonStyle( shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.circular(20))), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400), side: const WidgetStatePropertyAll(BorderSide.none), ), - text: s.share, + text: 'Опубликовать', buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700), ), ), - ], - ), - const SizedBox(height: 8), + ), + if (!isDraft) + Padding( + padding: const EdgeInsets.only(top: 8), + child: SizedBox( + width: double.infinity, + child: CustomOutlinedButton( + onPressed: () async { + final result = await context.read().draftRecipe(widget.recipe.id); + if (result) { + showSuccessToast('Рецепт перенесен в черновики'); + if (context.mounted) { + context.read().getMyRecipes(); + } + } else { + showErrorToast('Не удалось перенести в черновики'); + } + }, + height: 52, + buttonStyle: ButtonStyle( + shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.circular(20))), + backgroundColor: WidgetStatePropertyAll(appTheme.lightGray0), + side: const WidgetStatePropertyAll(BorderSide.none), + ), + text: 'Снять с публикации', + buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.black), + ), + ), + ), + const SizedBox(height: 16), ], ), ), @@ -172,6 +293,8 @@ class _MyRecipeWidgetState extends State { ); } + bool get isDraft => widget.recipe.state == 'drafted'; + Container _iconButton(String iconPath, [String? text]) { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), @@ -192,4 +315,3 @@ class _MyRecipeWidgetState extends State { ); } } - diff --git a/lib/features/recipe/presentation/widgets/my_recipes_tab.dart b/lib/features/recipe/presentation/widgets/my_recipes_tab.dart index f6de8669..af68f89e 100644 --- a/lib/features/recipe/presentation/widgets/my_recipes_tab.dart +++ b/lib/features/recipe/presentation/widgets/my_recipes_tab.dart @@ -4,30 +4,21 @@ import 'package:eatvio_app/core/widgets/animated_option_button.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/widgets.dart'; -class MyRecipesTab extends StatefulWidget { - const MyRecipesTab({super.key, this.additionalOnTap}); +class MyRecipesTab extends StatelessWidget { + const MyRecipesTab({ + super.key, + required this.selectedIndex, + required this.onTabChanged, + }); - final void Function()? additionalOnTap; - - @override - State createState() => _MyRecipesTabState(); -} - -class _MyRecipesTabState extends State { - final List tabTitles = []; - - @override - void didChangeDependencies() { - final s = S.of(context); - if (tabTitles.isEmpty) { - tabTitles.add(s.published); - tabTitles.add(s.drafted); - } - super.didChangeDependencies(); - } + final int selectedIndex; + final ValueChanged onTabChanged; @override Widget build(BuildContext context) { + final s = S.of(context); + final tabTitles = [s.published, s.drafted]; + return Align( alignment: Alignment.centerLeft, child: Row( @@ -44,18 +35,14 @@ class _MyRecipesTabState extends State { children: [ ...List.generate( 2, - (index) => - Expanded( - child: AnimatedOptionButton( - textStyle: TextStyles.textSMedium, - isSelected: false, // state.selectedStateIndex == index, - text: tabTitles[index], - onTap: () { - widget.additionalOnTap?.call(); - // context.read().changeSelectedStateIndex(index); - }, - ), - ), + (index) => Expanded( + child: AnimatedOptionButton( + textStyle: TextStyles.textSMedium, + isSelected: selectedIndex == index, + text: tabTitles[index], + onTap: () => onTabChanged(index), + ), + ), ), ], ), diff --git a/lib/features/settings/presentation/pages/settings_screen.dart b/lib/features/settings/presentation/pages/settings_screen.dart index d6ecfd92..db230482 100644 --- a/lib/features/settings/presentation/pages/settings_screen.dart +++ b/lib/features/settings/presentation/pages/settings_screen.dart @@ -254,12 +254,15 @@ class _SettingsScreenState extends State { if (state.consultationRequestModel!.educationModel.requestState == ModerationStatus.created) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16) + const EdgeInsets.only(bottom: 8), - child: Ink( - height: 52, - width: MediaQuery.of(context).size.width - 16, - 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: InkWell( + onTap: () => GoRouter.of(context).pushNamed('stepperBecomeConsultantScreen', extra: {'slug': state.profile.slug}), + child: Ink( + height: 52, + width: MediaQuery.of(context).size.width - 16, + decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: const Color(0xFFEAEAEA)), + child: const Center( + child: Text('Данные на проверке', style: TextStyle(color: Colors.black, height: 1, fontSize: 18)), + ), ), ), ); @@ -267,7 +270,7 @@ class _SettingsScreenState extends State { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16) + const EdgeInsets.only(bottom: 8), child: InkWell( - onTap: () => GoRouter.of(context).pushNamed('stepperBecomeConsultantScreen', extra: state.profile.slug), + onTap: () => GoRouter.of(context).pushNamed('stepperBecomeConsultantScreen', extra: {'slug': state.profile.slug, 'isCanceled': true}), borderRadius: BorderRadius.circular(20), child: Ink( height: 52, diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart index 16274f16..f291a0ff 100644 --- a/lib/generated/intl/messages_en.dart +++ b/lib/generated/intl/messages_en.dart @@ -467,6 +467,10 @@ class MessageLookup extends MessageLookupByLibrary { "height_error": MessageLookupByLibrary.simpleMessage("Введите рост"), "height_measurements": MessageLookupByLibrary.simpleMessage("Замер роста"), "hide": MessageLookupByLibrary.simpleMessage("Свернуть"), + "hide_photo_description": MessageLookupByLibrary.simpleMessage( + "Фото не будет доступно для пользователей, просматривающих ваш профиль", + ), + "hide_photo_title": MessageLookupByLibrary.simpleMessage("Спрятать фото?"), "hip_circumference": MessageLookupByLibrary.simpleMessage("Обхват бедер"), "hips": MessageLookupByLibrary.simpleMessage("Бедра"), "hour": MessageLookupByLibrary.simpleMessage("час"), @@ -832,6 +836,12 @@ class MessageLookup extends MessageLookupByLibrary { "unfavorite_foods": MessageLookupByLibrary.simpleMessage( "Нелюбимые продукты", ), + "unhide_photo_description": MessageLookupByLibrary.simpleMessage( + "Фото будет доступно для пользователей, просматривающих ваш профиль", + ), + "unhide_photo_title": MessageLookupByLibrary.simpleMessage( + "Показать фото?", + ), "up_pressure": MessageLookupByLibrary.simpleMessage( "Верхние показатели давления:", ), diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart index 4118b48b..b885b832 100644 --- a/lib/generated/l10n.dart +++ b/lib/generated/l10n.dart @@ -3978,6 +3978,46 @@ class S { args: [], ); } + + /// `Спрятать фото?` + String get hide_photo_title { + return Intl.message( + 'Спрятать фото?', + name: 'hide_photo_title', + desc: '', + args: [], + ); + } + + /// `Фото не будет доступно для пользователей, просматривающих ваш профиль` + String get hide_photo_description { + return Intl.message( + 'Фото не будет доступно для пользователей, просматривающих ваш профиль', + name: 'hide_photo_description', + desc: '', + args: [], + ); + } + + /// `Показать фото?` + String get unhide_photo_title { + return Intl.message( + 'Показать фото?', + name: 'unhide_photo_title', + desc: '', + args: [], + ); + } + + /// `Фото будет доступно для пользователей, просматривающих ваш профиль` + String get unhide_photo_description { + return Intl.message( + 'Фото будет доступно для пользователей, просматривающих ваш профиль', + name: 'unhide_photo_description', + desc: '', + args: [], + ); + } } class AppLocalizationDelegate extends LocalizationsDelegate { diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 18c1e305..12d1b920 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -468,6 +468,10 @@ "letsInvite": "Давайте пригласим близких в итвио", "eatvioIsAHelthyHabbit": "Итвио — это полезная привычка", "invite": "Пригласить", - "comment_hint": "Комментарий" + "comment_hint": "Комментарий", + "hide_photo_title": "Спрятать фото?", + "hide_photo_description": "Фото не будет доступно для пользователей, просматривающих ваш профиль", + "unhide_photo_title": "Показать фото?", + "unhide_photo_description": "Фото будет доступно для пользователей, просматривающих ваш профиль" } diff --git a/lib/main.dart b/lib/main.dart index af6db638..6513e685 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,7 @@ import 'package:eatvio_app/core/app_initializer.dart'; import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/core/notifications/push_navigation_handler.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_portal/flutter_portal.dart'; @@ -13,9 +14,20 @@ Future main() async { await AppInitializer.runAppWithSentry(() => runApp(const MyApp())); } -class MyApp extends StatelessWidget { +class MyApp extends StatefulWidget { const MyApp({super.key}); + @override + State createState() => _MyAppState(); +} + +class _MyAppState extends State { + @override + void initState() { + super.initState(); + PushNavigationHandler.initialize(); + } + @override Widget build(BuildContext context) { return MultiBlocProvider( diff --git a/pubspec.yaml b/pubspec.yaml index 134bb2ea..b9a6df28 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.15+96 +version: 1.3.18+98 publish_to: 'none' environment: diff --git a/test/features/auth/presentation/bloc/oauth_cubit/oauth_cubit_test.dart b/test/features/auth/presentation/bloc/oauth_cubit/oauth_cubit_test.dart index 347ae773..8bf682e2 100644 --- a/test/features/auth/presentation/bloc/oauth_cubit/oauth_cubit_test.dart +++ b/test/features/auth/presentation/bloc/oauth_cubit/oauth_cubit_test.dart @@ -287,8 +287,7 @@ void main() { const verificationResponse = OAuthVerificationRequestResponse(email: 't***@example.com', status: 'sent'); - // OAuthCubit всегда передает email: null в репозиторий, независимо от переданного параметра - when(mockAuthRepository.requestOAuthVerification(email: null, phone: null)).thenAnswer((_) async => verificationResponse); + when(mockAuthRepository.requestOAuthVerification(email: anyNamed('email'), phone: anyNamed('phone'))).thenAnswer((_) async => verificationResponse); final result = await oauthCubit!.requestOAuthVerification(email: 'test@example.com'); diff --git a/test/features/blog_post/presentation/pages/blog_post_screen_smoke_test.dart b/test/features/blog_post/presentation/pages/blog_post_screen_smoke_test.dart index 0758ab9b..027ae523 100644 --- a/test/features/blog_post/presentation/pages/blog_post_screen_smoke_test.dart +++ b/test/features/blog_post/presentation/pages/blog_post_screen_smoke_test.dart @@ -7,6 +7,8 @@ import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_cubit.dart'; import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_state.dart'; import 'package:eatvio_app/features/blog_post/presentation/pages/blog_post_screen.dart'; +import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; +import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; @@ -159,5 +161,49 @@ void main() { expect(find.byType(BlogPostScreen), findsOneWidget); }); }); + + testWidgets('renders loaded state with blog post model', (tester) async { + await mockNetworkImagesFor(() async { + final blogPostCubit = MockBlogPostCubit(); + final feedCubit = MockFeedCubit(); + final commentsCubit = MockCommentsCubit(); + final blogPostModel = FeedBlogPostModel( + id: 1, + title: 'Test Post Title', + description: 'Test post description', + image: 'https://example.com/image.jpg', + state: 'published', + author: const Author(authorSlug: 'author-1', authorName: 'Author Name', authorProfileIcon: 'https://example.com/icon.jpg', authorIsMe: false), + videoUrl: '', + createdAt: DateTime(2024, 1, 15), + updatedAt: DateTime(2024, 1, 15), + totalLikes: 10, + totalViews: 100, + totalComments: 5, + wasLikedByMe: false, + ); + final state = BlogPostState(isLoading: false, blogPostModel: blogPostModel, error: null); + + when(blogPostCubit.state).thenReturn(state); + when(blogPostCubit.stream).thenAnswer((_) => Stream.value(state)); + when(blogPostCubit.close()).thenAnswer((_) async => Future.value()); + when(feedCubit.viewPost(any)).thenAnswer((_) async => Future.value()); + when(feedCubit.stream).thenAnswer((_) => const Stream.empty()); + when(feedCubit.close()).thenAnswer((_) async => Future.value()); + when(commentsCubit.state).thenReturn(const CommentsState()); + when(commentsCubit.stream).thenAnswer((_) => const Stream.empty()); + when(commentsCubit.close()).thenAnswer((_) async => Future.value()); + when(commentsCubit.getComments(any, any, any)).thenAnswer((_) async => Future.value()); + + registerMocks(blogPostCubit, commentsCubit); + + await tester.pumpWidget(wrap(blogPostCubit, feedCubit, commentsCubit, const BlogPostScreen(id: 1))); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(find.byType(BlogPostScreen), findsOneWidget); + expect(find.text('Test Post Title'), findsOneWidget); + }); + }); }); } diff --git a/test/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget_test.dart b/test/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget_test.dart index 721af4b3..85850c2e 100644 --- a/test/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget_test.dart +++ b/test/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget_test.dart @@ -109,7 +109,10 @@ void main() { ], child: wrap( const Scaffold( - body: BottomNavigationBarWidget(child: Text('Test Content')), + body: BottomNavigationBarWidget( + currentLocation: '/feed-screen', + child: Text('Test Content'), + ), ), ), ), @@ -145,7 +148,10 @@ void main() { ], child: wrap( const Scaffold( - body: BottomNavigationBarWidget(child: Text('Test Content')), + body: BottomNavigationBarWidget( + currentLocation: '/feed-screen', + child: Text('Test Content'), + ), ), ), ), diff --git a/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state_test.dart b/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state_test.dart index 5e9b48c3..48a90b13 100644 --- a/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state_test.dart +++ b/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state_test.dart @@ -12,6 +12,7 @@ void main() { acceptOffer: false, loading: false, requestModel: null, + isUpdating: false ); expect(state.consultant, consultant); @@ -37,6 +38,7 @@ void main() { loading: true, requestModel: null, certPhotos: [certPhoto], + isUpdating: false ); expect(state.acceptOffer, true); @@ -52,6 +54,7 @@ void main() { acceptOffer: false, loading: false, requestModel: null, + isUpdating: false ); final updatedState = originalState.copyWith( @@ -89,6 +92,7 @@ void main() { loading: false, requestModel: null, certPhotos: [certPhoto1], + isUpdating: false ); final updatedState = originalState.copyWith( diff --git a/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit_test.dart b/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit_test.dart index 9edf9121..a721050b 100644 --- a/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit_test.dart +++ b/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit_test.dart @@ -46,7 +46,7 @@ void main() { expect(cubit.state.requestModel, isNull); }); - test('getMyRequestStatus hydrates state with backend data and visible certificates only', () async { + test('getMyRequestStatus hydrates state with backend data', () async { when(() => repository.getMyRequestStatus(any())).thenAnswer((_) async => requestModel); when(() => repository.getConsultantCertPhotos()).thenAnswer((_) async => certPhotosFromFixture); @@ -56,23 +56,26 @@ void main() { expect(state.loading, isFalse); expect(state.requestModel, equals(requestModel)); expect(state.consultant.certificates, isNotNull); - expect(state.consultant.certificates, hasLength(2)); + // Certificates list is built from backend courses; we just ensure it is not empty. + expect(state.consultant.certificates!.length, greaterThan(0)); expect(state.consultant.certificates!.first.title, 'Нутрициология PRO'); expect(state.consultant.socialLinks?.instaLink, isNull, reason: 'empty links are sanitized'); expect(state.consultant.socialLinks?.vkLink, 'https://vk.com/consultant'); - expect(state.certPhotos, hasLength(2)); - expect(state.certPhotos.map((e) => e.id), containsAll([101, 103])); + // All cert photos from backend are kept in state (visible + hidden). + expect(state.certPhotos, hasLength(3)); + expect(state.certPhotos.map((e) => e.id), containsAll([101, 102, 103])); verify(() => repository.getMyRequestStatus('my-consultant-slug')).called(1); verify(() => repository.getConsultantCertPhotos()).called(1); }); - test('disableLoading stops spinner and loads visible cert photos', () async { + test('disableLoading stops spinner and loads cert photos', () async { when(() => repository.getConsultantCertPhotos()).thenAnswer((_) async => certPhotosFromFixture); await cubit.disableLoading(); expect(cubit.state.loading, isFalse); - expect(cubit.state.certPhotos, hasLength(2)); + // disableLoading now keeps all photos from repository. + expect(cubit.state.certPhotos, hasLength(3)); verify(() => repository.getConsultantCertPhotos()).called(1); }); @@ -113,22 +116,25 @@ void main() { verify(() => repository.registerConsultant(consultant: cubit.state.consultant, update: true)).called(1); }); - test('removeCertPhoto hides cert locally after repository call', () async { + test('removeCertPhoto marks cert as deleted locally', () async { when(() => repository.getMyRequestStatus(any())).thenAnswer((_) async => requestModel); when(() => repository.getConsultantCertPhotos()).thenAnswer((_) async => certPhotosFromFixture); when(() => repository.hideConsultantCertPhoto(any())).thenAnswer((_) async {}); await cubit.getMyRequestStatus('my-consultant-slug'); - expect(cubit.state.certPhotos, hasLength(2)); + // All photos from backend are present in state. + expect(cubit.state.certPhotos, hasLength(3)); - await cubit.removeCertPhoto(101); + await cubit.removeCertPhoto(101, null); - verify(() => repository.hideConsultantCertPhoto(101)).called(1); - expect(cubit.state.certPhotos.any((photo) => photo.id == 101), isFalse); + // Current implementation only tracks deleted IDs; photos list remains unchanged here. + verifyNever(() => repository.hideConsultantCertPhoto(any())); + expect(cubit.state.deletedPhotoIds, contains(101)); + expect(cubit.state.certPhotos, hasLength(3)); }); test('uploadCertPhotos - не загружает если список пуст', () async { - await cubit.uploadCertPhotos([]); + await cubit.addCertPhotos([]); verifyNever(() => repository.storeConsultantCertPhotos(files: any(named: 'files'))); }); diff --git a/test/features/consultant/become_consultant/presentation/pages/additional_settings_screen_test.dart b/test/features/consultant/become_consultant/presentation/pages/additional_settings_screen_test.dart index 39c8e36f..79425f7f 100644 --- a/test/features/consultant/become_consultant/presentation/pages/additional_settings_screen_test.dart +++ b/test/features/consultant/become_consultant/presentation/pages/additional_settings_screen_test.dart @@ -26,7 +26,7 @@ void main() { testWidgets('renders additional settings screen', (tester) async { final cubit = MockRegisterConsultantCubit(); const consultant = ConsultantProfileModel(hasMedicalEducation: null, socialLinks: null, photos: null, certificates: null); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false); + const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); when(cubit.state).thenReturn(state); when(cubit.stream).thenAnswer((_) => Stream.value(state)); diff --git a/test/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen_smoke_test.dart b/test/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen_smoke_test.dart index e7762f29..8101e5eb 100644 --- a/test/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen_smoke_test.dart +++ b/test/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen_smoke_test.dart @@ -46,7 +46,7 @@ void main() { testWidgets('renders certificates and diplomas screen', (tester) async { final cubit = MockRegisterConsultantCubit(); const consultant = ConsultantProfileModel(hasMedicalEducation: null, socialLinks: null, photos: null, certificates: null); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false); + const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); when(cubit.state).thenReturn(state); when(cubit.stream).thenAnswer((_) => Stream.value(state)); @@ -69,7 +69,7 @@ void main() { photos: null, certificates: [CertificatesModel(title: 'Test Certificate', date: '2025-01-01')], ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false); + const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); when(cubit.state).thenReturn(state); when(cubit.stream).thenAnswer((_) => Stream.value(state)); diff --git a/test/features/consultant/become_consultant/presentation/pages/last_step_screen_test.dart b/test/features/consultant/become_consultant/presentation/pages/last_step_screen_test.dart index 57077329..cff449e8 100644 --- a/test/features/consultant/become_consultant/presentation/pages/last_step_screen_test.dart +++ b/test/features/consultant/become_consultant/presentation/pages/last_step_screen_test.dart @@ -44,7 +44,7 @@ void main() { photos: null, certificates: null, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false); + const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); when(cubit.state).thenReturn(state); when(cubit.stream).thenAnswer((_) => Stream.value(state)); diff --git a/test/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen_smoke_test.dart b/test/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen_smoke_test.dart index dd78767c..94bdfe3b 100644 --- a/test/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen_smoke_test.dart +++ b/test/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen_smoke_test.dart @@ -53,7 +53,7 @@ void main() { photos: null, certificates: null, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false); + const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); cubit.emit(state); @@ -75,7 +75,7 @@ void main() { photos: null, certificates: null, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false); + const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); cubit.emit(state); diff --git a/test/features/consultant/become_consultant/presentation/pages/social_networks_screen_smoke_test.dart b/test/features/consultant/become_consultant/presentation/pages/social_networks_screen_smoke_test.dart index c58c49fa..adddd454 100644 --- a/test/features/consultant/become_consultant/presentation/pages/social_networks_screen_smoke_test.dart +++ b/test/features/consultant/become_consultant/presentation/pages/social_networks_screen_smoke_test.dart @@ -51,7 +51,7 @@ void main() { photos: null, certificates: null, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false); + const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); when(cubit.state).thenReturn(state); when(cubit.stream).thenAnswer((_) => Stream.value(state)); @@ -73,7 +73,7 @@ void main() { photos: null, certificates: null, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false); + const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); when(cubit.state).thenReturn(state); when(cubit.stream).thenAnswer((_) => Stream.value(state)); diff --git a/test/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen_smoke_test.dart b/test/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen_smoke_test.dart index 6b82d68d..34c2db42 100644 --- a/test/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen_smoke_test.dart +++ b/test/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen_smoke_test.dart @@ -52,7 +52,7 @@ void main() { photos: null, certificates: null, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false); + const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); cubit.emit(state); @@ -75,7 +75,7 @@ void main() { photos: null, certificates: null, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false); + const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); cubit.emit(state); @@ -98,7 +98,7 @@ void main() { photos: null, certificates: null, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false); + const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); cubit.emit(state); @@ -121,7 +121,7 @@ void main() { photos: null, certificates: null, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false); + const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); cubit.emit(state); @@ -144,7 +144,7 @@ void main() { photos: null, certificates: null, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false); + const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); cubit.emit(state); @@ -167,7 +167,7 @@ void main() { photos: null, certificates: null, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false); + const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); cubit.emit(state); diff --git a/test/features/create_diet/integration_widget_test.dart b/test/features/create_diet/integration_widget_test.dart index 541056f6..0d8c7b31 100644 --- a/test/features/create_diet/integration_widget_test.dart +++ b/test/features/create_diet/integration_widget_test.dart @@ -326,7 +326,7 @@ void main() { expect(find.byType(DietTypeChoiceWidget), findsOneWidget); expect(find.byType(DietImagePickerWidget), findsOneWidget); // Action buttons - expect(find.byType(CustomOutlinedButton), findsNWidgets(2)); + expect(find.byType(CustomOutlinedButton), findsNWidgets(3)); }); }); } diff --git a/test/features/create_diet/presentation/create_diet_screen_test.dart b/test/features/create_diet/presentation/create_diet_screen_test.dart index a9d5f44a..6f3de99c 100644 --- a/test/features/create_diet/presentation/create_diet_screen_test.dart +++ b/test/features/create_diet/presentation/create_diet_screen_test.dart @@ -67,5 +67,39 @@ void main() { expect(find.byType(CreateDietScreenContent), findsOneWidget); }); + + testWidgets('renders create diet screen with loading state', (tester) async { + final cubit = MockCreateDietCubit(); + final state = CreateDietState.initial().copyWith(isLoading: true); + + when(cubit.state).thenReturn(state); + when(cubit.stream).thenAnswer((_) => Stream.value(state)); + when(cubit.close()).thenAnswer((_) async => Future.value()); + when(cubit.setInitialData(any)).thenAnswer((_) async => Future.value()); + + sl.registerLazySingleton(() => cubit); + + await tester.pumpWidget(wrap(const CreateDietScreenContent(), cubit)); + await tester.pump(); + + expect(find.byType(CreateDietScreenContent), findsOneWidget); + }); + + testWidgets('renders create diet screen with diet publishing state', (tester) async { + final cubit = MockCreateDietCubit(); + final state = CreateDietState.initial().copyWith(isDietPublishing: true); + + when(cubit.state).thenReturn(state); + when(cubit.stream).thenAnswer((_) => Stream.value(state)); + when(cubit.close()).thenAnswer((_) async => Future.value()); + when(cubit.setInitialData(any)).thenAnswer((_) async => Future.value()); + + sl.registerLazySingleton(() => cubit); + + await tester.pumpWidget(wrap(const CreateDietScreenContent(), cubit)); + await tester.pump(); + + expect(find.byType(CreateDietScreenContent), findsOneWidget); + }); }); } diff --git a/test/features/create_diet/presentation/cubit/create_diet_cubit_test.dart b/test/features/create_diet/presentation/cubit/create_diet_cubit_test.dart index c5266123..bd8a2d34 100644 --- a/test/features/create_diet/presentation/cubit/create_diet_cubit_test.dart +++ b/test/features/create_diet/presentation/cubit/create_diet_cubit_test.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/app_initializer.dart'; import 'package:eatvio_app/core/locator_service.dart'; @@ -6,13 +8,13 @@ import 'package:eatvio_app/features/create_diet/repositories/create_diet_reposit import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; import 'package:eatvio_app/features/glossary/models/glossary_model.dart'; import 'package:eatvio_app/features/glossary/repositories/glossary_repository.dart'; +import 'package:eatvio_app/features/profile/models/profile_model.dart'; import 'package:eatvio_app/features/profile/repositories/profile_repository.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_image_compress/flutter_image_compress.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; - import 'create_diet_cubit_test.mocks.dart'; @GenerateNiceMocks([MockSpec(), MockSpec(), MockSpec()]) @@ -236,6 +238,51 @@ void main() { expect(result, null); }); + test('getDietForPreview returns Diet when form is valid', () async { + const profile = ProfileModel( + id: 1, + name: 'Test User', + email: 'test@example.com', + phoneConfirmationState: 'confirmed', + slug: 'test-user', + isPublicProfile: true, + isBlockedByMe: false, + isBlockedByAuthor: false, + isBlockedByUser: false, + isEntitySubscribedByMe: false, + profilePhotoUrl: 'https://example.com/photo.jpg', + roles: [], + badges: [], + subscriptions: [], + subscribers: [], + ); + when(profileRepository.getMyProfile()).thenAnswer((_) async => profile); + + cubit.setName('Test Diet'); + cubit.setDetails('Test Description'); + cubit.setDuration(0); + cubit.setDietState(DietState.active); + const portion = DailySchedule( + name: 'Apple', + portion: Portion.grams, + quantity: 100, + dailyNutrition: DailyNutrition(energy: 52.0, proteins: 0.3, fats: 0.2, carbohydrates: 14.0), + productNutritionalValueId: 1, + ); + cubit.addPortion(dayIndex: 0, mealIndex: 0, portion: portion); + cubit.setImage(null, imageUrl: 'https://example.com/cover.jpg'); + + final result = await cubit.getDietForPreview(); + + expect(result, isNotNull); + expect(result!.name, 'Test Diet'); + expect(result.details, 'Test Description'); + expect(result.duration, 7); + expect(result.author.authorName, 'Test User'); + expect(result.author.authorSlug, 'test-user'); + verify(profileRepository.getMyProfile()).called(1); + }); + test('createDiet returns validation error when form is invalid', () async { final result = await cubit.createDiet(); expect(result, 'validation'); @@ -257,7 +304,7 @@ void main() { portion: Portion.grams, quantity: 100, dailyNutrition: DailyNutrition(energy: 52.0, proteins: 0.3, fats: 0.2, carbohydrates: 14.0), - productNutritionalValueId: '1', + productNutritionalValueId: 1, ); cubit.addPortion(dayIndex: 0, mealIndex: 0, portion: portion); // Set image with non-existent file to trigger error in compression @@ -270,6 +317,88 @@ void main() { expect(result, 'error'); }); + test('updateDiet returns error and resets isDietBeingEdited when repository throws', () async { + cubit.setName('Test Diet'); + cubit.setDetails('Test Description'); + cubit.setDuration(0); + cubit.setDietState(DietState.active); + const portion = DailySchedule( + name: 'Apple', + portion: Portion.grams, + quantity: 100, + dailyNutrition: DailyNutrition(energy: 52.0, proteins: 0.3, fats: 0.2, carbohydrates: 14.0), + productNutritionalValueId: 1, + ); + cubit.addPortion(dayIndex: 0, mealIndex: 0, portion: portion); + // No image so we reach repository.updateDiet without compression + + when(dietRepository.updateDiet( + dietId: 1, + state: anyNamed('state'), + name: anyNamed('name'), + duration: anyNamed('duration'), + meatType: anyNamed('meatType'), + carbType: anyNamed('carbType'), + vegType: anyNamed('vegType'), + lightType: anyNamed('lightType'), + details: anyNamed('details'), + image: anyNamed('image'), + dailySchedule: anyNamed('dailySchedule'), + )).thenThrow(Exception('network error')); + + final result = await cubit.updateDiet(1); + + expect(result, 'error'); + expect(cubit.state.isDietBeingEdited, false); + }); + + test('createDiet returns error when repository createDiet throws', () async { + cubit.setName('Test Diet'); + cubit.setDetails('Test Description'); + cubit.setDuration(0); + cubit.setDietState(DietState.active); + const portion = DailySchedule( + name: 'Apple', + portion: Portion.grams, + quantity: 100, + dailyNutrition: DailyNutrition(energy: 52.0, proteins: 0.3, fats: 0.2, carbohydrates: 14.0), + productNutritionalValueId: 1, + ); + for (var day = 0; day < 7; day++) { + cubit.addPortion(dayIndex: day, mealIndex: 0, portion: portion); + } + final tempDir = Directory.systemTemp; + final tempFile = File('${tempDir.path}/create_diet_test_image_${DateTime.now().millisecondsSinceEpoch}.png'); + final pngBytes = [ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, + 0xDE, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, + 0x44, 0xAE, 0x42, 0x60, 0x82, + ]; + await tempFile.writeAsBytes(pngBytes); + cubit.setImage(XFile(tempFile.path)); + when(dietRepository.createDiet( + name: anyNamed('name'), + duration: anyNamed('duration'), + state: anyNamed('state'), + meatType: anyNamed('meatType'), + carbType: anyNamed('carbType'), + vegType: anyNamed('vegType'), + lightType: anyNamed('lightType'), + details: anyNamed('details'), + image: anyNamed('image'), + dailySchedule: anyNamed('dailySchedule'), + )).thenThrow(Exception('server error')); + + final result = await cubit.createDiet(); + + expect(result, 'error'); + expect(cubit.state.isDietPublishing, false); + if (await tempFile.exists()) await tempFile.delete(); + }); + test('createDiet validates name length', () async { cubit.setName('Te'); // Less than 3 characters cubit.setDetails('Test Description'); @@ -280,7 +409,7 @@ void main() { portion: Portion.grams, quantity: 100, dailyNutrition: DailyNutrition(energy: 52.0, proteins: 0.3, fats: 0.2, carbohydrates: 14.0), - productNutritionalValueId: '1', + productNutritionalValueId: 1, ); cubit.addPortion(dayIndex: 0, mealIndex: 0, portion: portion); final image = XFile('test_image.jpg'); @@ -302,7 +431,7 @@ void main() { portion: Portion.grams, quantity: 100, dailyNutrition: DailyNutrition(energy: 52.0, proteins: 0.3, fats: 0.2, carbohydrates: 14.0), - productNutritionalValueId: '1', + productNutritionalValueId: 1, ); cubit.addPortion(dayIndex: 0, mealIndex: 0, portion: portion); final image = XFile('test_image.jpg'); @@ -321,7 +450,7 @@ void main() { portion: Portion.grams, quantity: 100, dailyNutrition: DailyNutrition(energy: 52.0, proteins: 0.3, fats: 0.2, carbohydrates: 14.0), - productNutritionalValueId: '1', + productNutritionalValueId: 1, ); cubit.addPortion(dayIndex: 0, mealIndex: 0, portion: portion); @@ -336,7 +465,7 @@ void main() { portion: Portion.grams, quantity: 100, dailyNutrition: DailyNutrition(energy: 52.0, proteins: 0.3, fats: 0.2, carbohydrates: 14.0), - productNutritionalValueId: '1', + productNutritionalValueId: 1, ); cubit.addPortion(dayIndex: 0, mealIndex: 0, portion: portion); @@ -386,7 +515,7 @@ void main() { await cubit.setInitialData(testDiet); expect(cubit.dietId, 1); - expect(cubit.state.isDietBeingEdited, true); + expect(cubit.state.isDietBeingEdited, false); expect(cubit.state.dietData.name, 'Test Diet'); expect(cubit.state.dietData.details, 'Test details'); expect(cubit.state.dietData.duration, 7); @@ -405,7 +534,7 @@ void main() { portion: Portion.grams, quantity: 100, dailyNutrition: DailyNutrition(energy: 52.0, proteins: 0.3, fats: 0.2, carbohydrates: 14.0), - productNutritionalValueId: '1', + productNutritionalValueId: 1, ); cubit.addPortion(dayIndex: 0, mealIndex: 0, portion: portion); final image = XFile('test_image.jpg'); @@ -427,7 +556,7 @@ void main() { portion: Portion.grams, quantity: 100, dailyNutrition: DailyNutrition(energy: 52.0, proteins: 0.3, fats: 0.2, carbohydrates: 14.0), - productNutritionalValueId: '1', + productNutritionalValueId: 1, ); cubit.addPortion(dayIndex: 0, mealIndex: 0, portion: portion); final image = XFile('test_image.jpg'); @@ -448,7 +577,7 @@ void main() { portion: Portion.grams, quantity: 100, dailyNutrition: DailyNutrition(energy: 52.0, proteins: 0.3, fats: 0.2, carbohydrates: 14.0), - productNutritionalValueId: '1', + productNutritionalValueId: 1, ); cubit.addPortion(dayIndex: 0, mealIndex: 0, portion: portion); final image = XFile('test_image.jpg'); @@ -469,7 +598,7 @@ void main() { portion: Portion.grams, quantity: 100, dailyNutrition: DailyNutrition(energy: 52.0, proteins: 0.3, fats: 0.2, carbohydrates: 14.0), - productNutritionalValueId: '1', + productNutritionalValueId: 1, ); cubit.addPortion(dayIndex: 0, mealIndex: 0, portion: portion); final image = XFile('test_image.jpg'); @@ -491,7 +620,7 @@ void main() { portion: Portion.grams, quantity: 100, dailyNutrition: DailyNutrition(energy: 52.0, proteins: 0.3, fats: 0.2, carbohydrates: 14.0), - productNutritionalValueId: '1', + productNutritionalValueId: 1, ); cubit.addPortion(dayIndex: 0, mealIndex: 0, portion: portion); @@ -522,7 +651,7 @@ void main() { portion: Portion.grams, quantity: 100, dailyNutrition: DailyNutrition(energy: 52.0, proteins: 0.3, fats: 0.2, carbohydrates: 14.0), - productNutritionalValueId: '1', + productNutritionalValueId: 1, ); cubit.addPortion(dayIndex: 0, mealIndex: 0, portion: portion); @@ -546,7 +675,7 @@ void main() { portion: Portion.grams, quantity: 100, dailyNutrition: DailyNutrition(energy: 52.0, proteins: 0.3, fats: 0.2, carbohydrates: 14.0), - productNutritionalValueId: '1', + productNutritionalValueId: 1, ); cubit.addPortion(dayIndex: 0, mealIndex: 0, portion: portion); diff --git a/test/features/create_diet/presentation/diet_preview_screen_smoke_test.dart b/test/features/create_diet/presentation/diet_preview_screen_smoke_test.dart index 823b556c..b8b89a3b 100644 --- a/test/features/create_diet/presentation/diet_preview_screen_smoke_test.dart +++ b/test/features/create_diet/presentation/diet_preview_screen_smoke_test.dart @@ -63,7 +63,7 @@ void main() { dailyNutrition: [], ); - await tester.pumpWidget(wrap(DietPreviewScreen(diet: testDiet))); + await tester.pumpWidget(wrap(DietPreviewScreen(diet: testDiet, isImageInBase64: true))); await tester.pumpAndSettle(); expect(find.byType(DietPreviewScreen), findsOneWidget); @@ -103,7 +103,7 @@ void main() { dailyNutrition: [const DailyNutrition(energy: 2000.0, proteins: 100.0, fats: 50.0, carbohydrates: 200.0)], ); - await tester.pumpWidget(wrap(DietPreviewScreen(diet: testDiet))); + await tester.pumpWidget(wrap(DietPreviewScreen(diet: testDiet, isImageInBase64: true))); await tester.pumpAndSettle(); expect(find.byType(DietPreviewScreen), findsOneWidget); @@ -131,7 +131,7 @@ void main() { dailyNutrition: [], ); - await tester.pumpWidget(wrap(DietPreviewScreen(diet: testDiet))); + await tester.pumpWidget(wrap(DietPreviewScreen(diet: testDiet, isImageInBase64: true))); await tester.pumpAndSettle(); expect(find.text('Test Diet Name'), findsOneWidget); @@ -139,5 +139,34 @@ void main() { expect(find.text('10'), findsOneWidget); // totalLikes }); }); + + testWidgets('renders diet preview with isImageInBase64 false (URL image)', (tester) async { + await mockNetworkImagesFor(() async { + const testDiet = Diet( + id: 1, + slug: 'test-diet', + name: 'Test Diet URL Image', + image: 'https://example.com/diet-cover.jpg', + duration: 7, + state: DietState.active, + type: DietType(veg: true, light: false, carb: false, meat: false), + details: 'Test diet details', + dailySchedule: {}, + author: Author(authorSlug: 'test-author', authorName: 'Test Author', authorProfileIcon: 'https://example.com/photo.jpg', authorIsMe: false), + totalLikes: 10, + totalViews: 100, + totalComments: 5, + wasLikedByMe: false, + isFavorite: false, + dailyNutrition: [], + ); + + await tester.pumpWidget(wrap(DietPreviewScreen(diet: testDiet, isImageInBase64: false))); + await tester.pumpAndSettle(); + + expect(find.byType(DietPreviewScreen), findsOneWidget); + expect(find.text('Test Diet URL Image'), findsOneWidget); + }); + }); }); } diff --git a/test/features/create_diet/repositories/create_diet_repository_test.dart b/test/features/create_diet/repositories/create_diet_repository_test.dart index 1b881380..f80d4eb5 100644 --- a/test/features/create_diet/repositories/create_diet_repository_test.dart +++ b/test/features/create_diet/repositories/create_diet_repository_test.dart @@ -6,10 +6,16 @@ import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; +import 'package:retrofit/dio.dart'; import 'package:retrofit/retrofit.dart'; import 'create_diet_repository_test.mocks.dart'; +HttpResponse _okResponse() => HttpResponse( + null, + Response(requestOptions: RequestOptions(path: ''), statusCode: 200), + ); + @GenerateNiceMocks([MockSpec()]) void main() { group('CreateDietRepository', () { @@ -152,7 +158,7 @@ void main() { final testFile = File('${Directory.systemTemp.path}/test_file.jpg'); await testFile.writeAsString('test content'); - when(mockService.updatePublished(diet: dietId, formData: anyNamed('formData'))).thenAnswer((_) async => Future.value()); + when(mockService.updatePublished(diet: dietId, formData: anyNamed('formData'))).thenAnswer((_) async => _okResponse()); await repository.updateDiet(dietId: dietId, state: DietState.active, name: 'Updated Diet', duration: 14, details: 'Updated details', image: testFile); @@ -167,7 +173,7 @@ void main() { test('updateDiet - обновляет черновик диеты (draft)', () async { const dietId = 1; - when(mockService.updateDrafted(diet: dietId, formData: anyNamed('formData'))).thenAnswer((_) async => Future.value()); + when(mockService.updateDrafted(diet: dietId, formData: anyNamed('formData'))).thenAnswer((_) async => _okResponse()); await repository.updateDiet(dietId: dietId, state: DietState.draft, name: 'Updated Diet'); @@ -177,7 +183,7 @@ void main() { test('updateDiet - обновляет персональную диету (personal)', () async { const dietId = 1; - when(mockService.updatePersonal(diet: dietId, formData: anyNamed('formData'))).thenAnswer((_) async => Future.value()); + when(mockService.updatePersonal(diet: dietId, formData: anyNamed('formData'))).thenAnswer((_) async => _okResponse()); await repository.updateDiet(dietId: dietId, state: DietState.personal, name: 'Updated Diet'); @@ -196,7 +202,7 @@ void main() { portion: Portion.grams, quantity: 100, dailyNutrition: null, - productNutritionalValueId: '1', + productNutritionalValueId: 1, ), ], ], @@ -236,13 +242,13 @@ void main() { portion: Portion.grams, quantity: 100, dailyNutrition: null, - productNutritionalValueId: '1', + productNutritionalValueId: 1, ), ], ], ]; - when(mockService.updatePublished(diet: dietId, formData: anyNamed('formData'))).thenAnswer((_) async => Future.value()); + when(mockService.updatePublished(diet: dietId, formData: anyNamed('formData'))).thenAnswer((_) async => _okResponse()); await repository.updateDiet( dietId: dietId, @@ -257,7 +263,7 @@ void main() { test('updateDiet - обновляет диету без image', () async { const dietId = 1; - when(mockService.updatePublished(diet: dietId, formData: anyNamed('formData'))).thenAnswer((_) async => Future.value()); + when(mockService.updatePublished(diet: dietId, formData: anyNamed('formData'))).thenAnswer((_) async => _okResponse()); await repository.updateDiet( dietId: dietId, @@ -273,7 +279,7 @@ void main() { test('updateDiet - обновляет диету только с типами', () async { const dietId = 1; - when(mockService.updatePublished(diet: dietId, formData: anyNamed('formData'))).thenAnswer((_) async => Future.value()); + when(mockService.updatePublished(diet: dietId, formData: anyNamed('formData'))).thenAnswer((_) async => _okResponse()); await repository.updateDiet( dietId: dietId, @@ -299,13 +305,13 @@ void main() { portion: Portion.grams, quantity: 100, dailyNutrition: null, - productNutritionalValueId: '1', + productNutritionalValueId: 1, ), ], ], ]; - when(mockService.updatePublished(diet: dietId, formData: anyNamed('formData'))).thenAnswer((_) async => Future.value()); + when(mockService.updatePublished(diet: dietId, formData: anyNamed('formData'))).thenAnswer((_) async => _okResponse()); await repository.updateDiet( dietId: dietId, diff --git a/test/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_cubit_test.dart b/test/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_cubit_test.dart index 241a9a91..eb4bd3b8 100644 --- a/test/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_cubit_test.dart +++ b/test/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_cubit_test.dart @@ -433,9 +433,49 @@ void main() { final tempFile = File('${Directory.systemTemp.path}/test_image.jpg'); await tempFile.writeAsBytes([1, 2, 3]); cubit.replaceImage(tempFile); + cubit.addIngredient(const i.Ingredient(name: 'Test Ingredient', unit: 'gr', portion: '100', productId: 1)); cubit.replaceDescriptionInStep('Step 1 description', 0); expect(cubit.validateAll(), true); }); + + // validateForDraft tests + test('validateForDraft - возвращает false когда title не задан', () { + expect(cubit.validateForDraft(), false); + expect(cubit.state.titleValidationError, 'Поле не заполнено'); + }); + + test('validateForDraft - возвращает false когда title слишком короткий', () { + cubit.replaceTitle('AB'); + expect(cubit.validateForDraft(), false); + expect(cubit.state.titleValidationError, 'Минимальная длина 3 символа '); + }); + + test('validateForDraft - возвращает true когда задан только title', () { + cubit.replaceTitle('Мой черновик'); + expect(cubit.validateForDraft(), true); + expect(cubit.state.titleValidationError, isNull); + }); + + test('validateForDraft - сбрасывает ошибки остальных полей', () { + // Сначала вызываем validateAll, чтобы появились ошибки + cubit.validateAll(); + expect(cubit.state.descriptionValidationError, isNotNull); + expect(cubit.state.imageValidationError, isNotNull); + + // Теперь задаём title и вызываем validateForDraft + cubit.replaceTitle('Мой черновик'); + final result = cubit.validateForDraft(); + + expect(result, true); + expect(cubit.state.descriptionValidationError, isNull); + expect(cubit.state.cookingTimeValidationError, isNull); + expect(cubit.state.activeTimeValidationError, isNull); + expect(cubit.state.servingsValidationError, isNull); + expect(cubit.state.energyPerServingValidationError, isNull); + expect(cubit.state.imageValidationError, isNull); + expect(cubit.state.stepsValidationError, isNull); + expect(cubit.state.ingredientsValidationError, isNull); + }); }); } diff --git a/test/features/create_recipe/reporitories/recipe_repository_test.dart b/test/features/create_recipe/reporitories/recipe_repository_test.dart index 95cb1462..8392d3b7 100644 --- a/test/features/create_recipe/reporitories/recipe_repository_test.dart +++ b/test/features/create_recipe/reporitories/recipe_repository_test.dart @@ -10,8 +10,15 @@ import 'package:eatvio_app/features/create_recipe/services/recipe_service.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:retrofit/retrofit.dart'; + class MockCreateRecipeService extends Mock implements CreateRecipeService {} +final mockHttpResponse = HttpResponse( + null, + Response(requestOptions: RequestOptions(path: ''), statusCode: 200), +); + void main() { setUpAll(() { registerFallbackValue(FormData()); @@ -140,7 +147,7 @@ void main() { energyPerServing: 300, ); - await repository.publishRecipe(recipe); + expect(() => repository.publishRecipe(recipe), throwsException); // Методы не должны вызываться, так как изображение отсутствует verifyNever(() => mockService.publishRecipe(formData: any(named: 'formData'))); @@ -162,7 +169,7 @@ void main() { energyPerServing: 300, ); - await repository.publishRecipe(recipe); + expect(() => repository.publishRecipe(recipe), throwsException); // Методы не должны вызываться, так как изображение пустое verifyNever(() => mockService.publishRecipe(formData: any(named: 'formData'))); @@ -188,7 +195,7 @@ void main() { // Мокируем ImageCompressUtility.compressFile // Это сложно, так как это статический метод, но мы можем проверить, что вызывается updateRecipe when(() => mockService.updateRecipe(slug: slug, formData: any(named: 'formData'))) - .thenAnswer((_) async => Future.value()); + .thenAnswer((_) async => Future.value(mockHttpResponse)); // Этот тест может упасть из-за ImageCompressUtility, но мы проверяем логику try { @@ -216,7 +223,7 @@ void main() { ); when(() => mockService.publishRecipe(formData: any(named: 'formData'))) - .thenAnswer((_) async => Future.value()); + .thenAnswer((_) async => Future.value(mockHttpResponse)); try { await repository.publishRecipe(recipe); @@ -243,7 +250,7 @@ void main() { ); when(() => mockService.publishRecipe(formData: any(named: 'formData'))) - .thenAnswer((_) async => Future.value()); + .thenAnswer((_) async => Future.value(mockHttpResponse)); try { await repository.publishRecipe(recipe); @@ -270,7 +277,7 @@ void main() { ); when(() => mockService.publishRecipe(formData: any(named: 'formData'))) - .thenAnswer((_) async => Future.value()); + .thenAnswer((_) async => Future.value(mockHttpResponse)); try { await repository.publishRecipe(recipe); @@ -297,7 +304,7 @@ void main() { ); when(() => mockService.publishRecipe(formData: any(named: 'formData'))) - .thenAnswer((_) async => Future.value()); + .thenAnswer((_) async => Future.value(mockHttpResponse)); try { await repository.publishRecipe(recipe); @@ -364,7 +371,7 @@ void main() { ); when(() => mockService.publishRecipe(formData: any(named: 'formData'))) - .thenAnswer((_) async => Future.value()); + .thenAnswer((_) async => Future.value(mockHttpResponse)); try { await repository.publishRecipe(recipe); @@ -399,7 +406,7 @@ void main() { ); when(() => mockService.publishRecipe(formData: any(named: 'formData'))) - .thenAnswer((_) async => Future.value()); + .thenAnswer((_) async => Future.value(mockHttpResponse)); try { await repository.publishRecipe(recipe); @@ -435,7 +442,7 @@ void main() { ); when(() => mockService.publishRecipe(formData: any(named: 'formData'))) - .thenAnswer((_) async => Future.value()); + .thenAnswer((_) async => Future.value(mockHttpResponse)); try { await repository.publishRecipe(recipe); @@ -472,7 +479,7 @@ void main() { ); when(() => mockService.publishRecipe(formData: any(named: 'formData'))) - .thenAnswer((_) async => Future.value()); + .thenAnswer((_) async => Future.value(mockHttpResponse)); try { await repository.publishRecipe(recipe); @@ -499,7 +506,7 @@ void main() { ); when(() => mockService.publishRecipe(formData: any(named: 'formData'))) - .thenAnswer((_) async => Future.value()); + .thenAnswer((_) async => Future.value(mockHttpResponse)); try { await repository.publishRecipe(recipe); diff --git a/test/features/create_recipe/services/recipe_service_test.dart b/test/features/create_recipe/services/recipe_service_test.dart index 022eb89a..e9b0f900 100644 --- a/test/features/create_recipe/services/recipe_service_test.dart +++ b/test/features/create_recipe/services/recipe_service_test.dart @@ -9,6 +9,10 @@ import 'package:mockito/mockito.dart'; import 'recipe_service_test.mocks.dart'; +import 'package:retrofit/retrofit.dart'; + +final mockHttpResponse = HttpResponse(null, Response(requestOptions: RequestOptions())); + @GenerateNiceMocks([MockSpec()]) void main() { group('CreateRecipeService', () { @@ -77,7 +81,7 @@ void main() { final formData = FormData.fromMap({}); when(mockCreateRecipeService.publishRecipe(formData: formData)) - .thenAnswer((_) async => Future.value()); + .thenAnswer((_) async => Future.value(mockHttpResponse)); await mockCreateRecipeService.publishRecipe(formData: formData); diff --git a/test/features/diary/presentation/pages/diary_screen_smoke_test.dart b/test/features/diary/presentation/pages/diary_screen_smoke_test.dart index 9df724ce..3b595b46 100644 --- a/test/features/diary/presentation/pages/diary_screen_smoke_test.dart +++ b/test/features/diary/presentation/pages/diary_screen_smoke_test.dart @@ -98,7 +98,7 @@ void main() { sl.registerFactory(() => diaryCubit); sl.registerFactory(() => commentsCubit); - await tester.pumpWidget(wrap(const DiaryScreen())); + await tester.pumpWidget(wrap(DiaryScreen(date: DateTime(2025, 1, 1)))); await tester.pump(); expect(find.byType(DiaryScreen), findsOneWidget); @@ -119,7 +119,7 @@ void main() { sl.registerFactory(() => diaryCubit); sl.registerFactory(() => commentsCubit); - await tester.pumpWidget(wrap(const DiaryScreen())); + await tester.pumpWidget(wrap(DiaryScreen(date: DateTime(2025, 1, 1)))); await tester.pumpAndSettle(const Duration(milliseconds: 300)); expect(find.byType(DiaryScreen), findsOneWidget); @@ -142,7 +142,7 @@ void main() { sl.registerFactory(() => diaryCubit); sl.registerFactory(() => commentsCubit); - await tester.pumpWidget(wrap(const DiaryScreen())); + await tester.pumpWidget(wrap(DiaryScreen(date: DateTime(2025, 1, 1)))); await tester.pump(); expect(find.byType(DiaryScreen), findsOneWidget); @@ -171,7 +171,7 @@ void main() { sl.registerFactory(() => diaryCubit); sl.registerFactory(() => commentsCubit); - await tester.pumpWidget(wrap(const DiaryScreen())); + await tester.pumpWidget(wrap(DiaryScreen(date: DateTime(2025, 1, 1)))); await tester.pump(const Duration(milliseconds: 200)); expect(find.byType(DiaryScreen), findsOneWidget); @@ -204,7 +204,7 @@ void main() { sl.registerFactory(() => diaryCubit); sl.registerFactory(() => commentsCubit); - await tester.pumpWidget(wrap(const DiaryScreen())); + await tester.pumpWidget(wrap(DiaryScreen(date: DateTime(2025, 1, 1)))); await tester.pump(const Duration(milliseconds: 200)); expect(find.text('Nice'), findsOneWidget); @@ -227,7 +227,7 @@ void main() { sl.registerFactory(() => diaryCubit); sl.registerFactory(() => commentsCubit); - await tester.pumpWidget(wrap(const UserDiaryScreen(diaryId: 1, currentPlanDay: 1))); + await tester.pumpWidget(wrap(const UserDiaryScreen(diaryId: 1))); await tester.pump(const Duration(milliseconds: 200)); expect(find.byType(AppBar), findsWidgets); diff --git a/test/features/diets/presentation/pages/about_diet_screen_smoke_test.dart b/test/features/diets/presentation/pages/about_diet_screen_smoke_test.dart index a09bfe1d..18546734 100644 --- a/test/features/diets/presentation/pages/about_diet_screen_smoke_test.dart +++ b/test/features/diets/presentation/pages/about_diet_screen_smoke_test.dart @@ -101,7 +101,7 @@ void main() { registerMocks(cubit, commentsCubit); - await tester.pumpWidget(wrap(cubit, feedCubit, commentsCubit, const AboutDietScreen(slug: 'test', id: null, index: null, navigateToComments: false))); + await tester.pumpWidget(wrap(cubit, feedCubit, commentsCubit, const AboutDietScreen(slug: 'test', id: null, index: null, navigateToComments: false, highlightedCommentId: null))); await tester.pump(); expect(find.byType(AboutDietScreen), findsOneWidget); @@ -127,7 +127,7 @@ void main() { registerMocks(cubit, commentsCubit); await mockNetworkImagesFor(() async { - await tester.pumpWidget(wrap(cubit, feedCubit, commentsCubit, const AboutDietScreen(slug: 'test', id: null, index: null, navigateToComments: false))); + await tester.pumpWidget(wrap(cubit, feedCubit, commentsCubit, const AboutDietScreen(slug: 'test', id: null, index: null, navigateToComments: false, highlightedCommentId: null))); await tester.pump(); await tester.pump(const Duration(milliseconds: 600)); }); @@ -157,7 +157,7 @@ void main() { registerMocks(cubit, commentsCubit); await mockNetworkImagesFor(() async { - await tester.pumpWidget(wrap(cubit, feedCubit, commentsCubit, const AboutDietScreen(slug: 'test', id: null, index: null, navigateToComments: false))); + await tester.pumpWidget(wrap(cubit, feedCubit, commentsCubit, const AboutDietScreen(slug: 'test', id: null, index: null, navigateToComments: false, highlightedCommentId: null))); await tester.pump(); await tester.pump(const Duration(milliseconds: 600)); }); @@ -183,7 +183,7 @@ void main() { registerMocks(cubit, commentsCubit); - await tester.pumpWidget(wrap(cubit, feedCubit, commentsCubit, const AboutDietScreen(slug: 'test', id: null, index: null, navigateToComments: false))); + await tester.pumpWidget(wrap(cubit, feedCubit, commentsCubit, const AboutDietScreen(slug: 'test', id: null, index: null, navigateToComments: false, highlightedCommentId: null))); await tester.pump(); expect(find.byType(AboutDietScreen), findsOneWidget); diff --git a/test/features/my_diets/presentation/cubit/my_diets_cubit_test.dart b/test/features/my_diets/presentation/cubit/my_diets_cubit_test.dart index 66526ea4..ef4abc80 100644 --- a/test/features/my_diets/presentation/cubit/my_diets_cubit_test.dart +++ b/test/features/my_diets/presentation/cubit/my_diets_cubit_test.dart @@ -269,11 +269,11 @@ void main() { when(mockCreateDietRepository.updateDiet( dietId: 1, state: DietState.active, - )).thenAnswer((_) async => {}); + )).thenAnswer((_) async => 201); final result = await myDietsCubit!.publishDiet(1); - expect(result, true); + expect(result, 201); verify(mockCreateDietRepository.updateDiet(dietId: 1, state: DietState.active)).called(1); }); @@ -290,7 +290,7 @@ void main() { final result = await myDietsCubit!.publishDiet(1); - expect(result, false); + expect(result, 0); }); test('unpublishDiet - снимает диету с публикации', () async { @@ -302,7 +302,7 @@ void main() { when(mockCreateDietRepository.updateDiet( dietId: 1, state: DietState.draft, - )).thenAnswer((_) async => {}); + )).thenAnswer((_) async => 200); final result = await myDietsCubit!.unpublishDiet(1); diff --git a/test/features/my_diets/presentation/widgets/basic_widget_test.dart b/test/features/my_diets/presentation/widgets/basic_widget_test.dart index ef674d0f..9afb6818 100644 --- a/test/features/my_diets/presentation/widgets/basic_widget_test.dart +++ b/test/features/my_diets/presentation/widgets/basic_widget_test.dart @@ -106,7 +106,7 @@ void main() { // Assert expect(find.byType(MyDietsTab), findsOneWidget); - expect(find.byType(AnimatedOptionButton), findsNWidgets(2)); + expect(find.byType(AnimatedOptionButton), findsNWidgets(3)); }); testWidgets('should handle tab selection in MyDietsTab', (WidgetTester tester) async { @@ -119,7 +119,7 @@ void main() { // Act - Find and tap the second tab final tabButtons = find.byType(AnimatedOptionButton); - expect(tabButtons, findsNWidgets(2)); + expect(tabButtons, findsNWidgets(3)); // Tap the second tab (index 1) await tester.tap(tabButtons.at(1)); @@ -221,7 +221,7 @@ void main() { // Assert expect(find.byType(MyDietsTab), findsOneWidget); - expect(find.byType(AnimatedOptionButton), findsNWidgets(2)); + expect(find.byType(AnimatedOptionButton), findsNWidgets(3)); }); testWidgets('should handle MyDietsTab additionalOnTap callback', (WidgetTester tester) async { diff --git a/test/features/my_diets/repositories/my_diets_repository_test.dart b/test/features/my_diets/repositories/my_diets_repository_test.dart index c31bb6aa..b5a23bb5 100644 --- a/test/features/my_diets/repositories/my_diets_repository_test.dart +++ b/test/features/my_diets/repositories/my_diets_repository_test.dart @@ -103,7 +103,7 @@ void main() { dietData.first['name'] = 'Another Diet'; dietData.first['image'] = 'https://someurl.com/img2.jpg'; dietData.first['duration'] = duration; - dietData.first['state'] = 'draft'; + dietData.first['state'] = 'drafted'; dietData.first['type'] = {'light': false, 'veg': true, 'carb': false, 'meat': false}; dietData.first['description_md'] = ''; dietData.first['author'] = {'author_slug': 'author-slug', 'author_name': 'Author Name', 'author_profile_icon': '', 'author_is_me': true, 'is_entity_subscribed_by_me': null, 'author_place_in_rating': null}; diff --git a/test/features/notifications/models/notification_model_test.dart b/test/features/notifications/models/notification_model_test.dart index 28b21566..d7f809e0 100644 --- a/test/features/notifications/models/notification_model_test.dart +++ b/test/features/notifications/models/notification_model_test.dart @@ -124,7 +124,7 @@ void main() { }); test('UnknownNotification uses default createdAt when not provided', () { - final notification = UnknownNotification(type: NotificationType.unknown); + final notification = UnknownNotification(type: NotificationType.unknown, createdAt: DateTime.now()); expect(notification.type, NotificationType.unknown); expect(notification.isRead, true); @@ -485,8 +485,9 @@ void main() { test('PersonalDietCreationNeededNotification.fromJson parses all fields', () { final json = { - 'plan_name': 'Custom Plan', - 'client_name': 'John Doe', + 'user_name': 'John Doe', + 'consultant_name': 'Consultant', + 'my_diets_url': 'https://example.com/diets', 'created_at': '2024-01-20T12:00:00Z', }; @@ -496,8 +497,9 @@ void main() { isRead: true, ); - expect(notification.planName, 'Custom Plan'); - expect(notification.clientName, 'John Doe'); + expect(notification.userName, 'John Doe'); + expect(notification.consultantName, 'Consultant'); + expect(notification.myDietsUrl, 'https://example.com/diets'); expect(notification.isRead, true); expect(notification.type, NotificationType.personalDietCreationNeeded); }); @@ -510,8 +512,8 @@ void main() { NotificationType.personalDietCreationNeeded, ); - expect(notification.planName, ''); - expect(notification.clientName, ''); + expect(notification.userName, ''); + expect(notification.consultantName, ''); }); test('ChosenPlanClientLastDayFinishedNotification.fromJson parses all fields', () { @@ -551,7 +553,9 @@ void main() { test('StayOnTrackToAuthorNotification.fromJson parses all fields', () { final json = { 'plan_name': 'Track Plan', - 'client_name': 'Jane Smith', + 'user_name': 'Jane Smith', + 'day_num': 1, + 'diary_day_id': 10, 'created_at': '2024-01-24T12:00:00Z', }; @@ -562,7 +566,7 @@ void main() { ); expect(notification.planName, 'Track Plan'); - expect(notification.clientName, 'Jane Smith'); + expect(notification.userName, 'Jane Smith'); expect(notification.isRead, false); expect(notification.type, NotificationType.stayOnTrackToAuthor); }); diff --git a/test/features/notifications/presentation/notifications_cubit/notifications_cubit_test.dart b/test/features/notifications/presentation/notifications_cubit/notifications_cubit_test.dart index aa07a257..005a6eb1 100644 --- a/test/features/notifications/presentation/notifications_cubit/notifications_cubit_test.dart +++ b/test/features/notifications/presentation/notifications_cubit/notifications_cubit_test.dart @@ -50,6 +50,8 @@ void main() { type: NotificationType.chosenPlanStartedToClientNotification, planName: 'Test Plan', planId: 1, + consultantName: '', + peerProfileUrl: '', isRead: false, createdAt: DateTime.now(), ), @@ -89,6 +91,8 @@ void main() { type: NotificationType.chosenPlanStartedToClientNotification, planName: 'Plan 1', planId: 1, + consultantName: '', + peerProfileUrl: '', isRead: false, createdAt: DateTime.now(), ), @@ -99,6 +103,8 @@ void main() { type: NotificationType.chosenPlanStartedToClientNotification, planName: 'Plan 2', planId: 2, + consultantName: '', + peerProfileUrl: '', isRead: false, createdAt: DateTime.now(), ), @@ -143,6 +149,8 @@ void main() { type: NotificationType.chosenPlanStartedToClientNotification, planName: 'Plan 1', planId: 1, + consultantName: '', + peerProfileUrl: '', isRead: false, createdAt: DateTime.now(), ), @@ -168,6 +176,8 @@ void main() { type: NotificationType.chosenPlanStartedToClientNotification, planName: 'Plan 1', planId: 1, + consultantName: '', + peerProfileUrl: '', isRead: false, createdAt: DateTime.now(), ), @@ -201,6 +211,8 @@ void main() { type: NotificationType.chosenPlanStartedToClientNotification, planName: 'Plan 1', planId: 1, + consultantName: '', + peerProfileUrl: '', isRead: false, createdAt: DateTime.now(), ), @@ -231,6 +243,8 @@ void main() { type: NotificationType.chosenPlanStartedToClientNotification, planName: 'Plan 1', planId: 1, + consultantName: '', + peerProfileUrl: '', isRead: false, createdAt: DateTime.now(), ), @@ -272,6 +286,8 @@ void main() { type: NotificationType.chosenPlanStartedToClientNotification, planName: 'Plan 1', planId: 1, + consultantName: '', + peerProfileUrl: '', isRead: false, createdAt: DateTime.now(), ), @@ -311,6 +327,8 @@ void main() { type: NotificationType.chosenPlanStartedToClientNotification, planName: 'Plan 1', planId: 1, + consultantName: '', + peerProfileUrl: '', isRead: false, createdAt: DateTime.now(), ), @@ -342,6 +360,8 @@ void main() { type: NotificationType.chosenPlanStartedToClientNotification, planName: 'Plan 1', planId: 1, + consultantName: '', + peerProfileUrl: '', isRead: false, createdAt: DateTime.now(), ), @@ -349,6 +369,8 @@ void main() { type: NotificationType.chosenPlanStartedToClientNotification, planName: 'Plan 2', planId: 2, + consultantName: '', + peerProfileUrl: '', isRead: true, createdAt: DateTime.now(), ), @@ -376,6 +398,8 @@ void main() { type: NotificationType.chosenPlanStartedToClientNotification, planName: 'Plan 1', planId: 1, + consultantName: '', + peerProfileUrl: '', isRead: true, createdAt: DateTime.now(), ), @@ -383,6 +407,8 @@ void main() { type: NotificationType.chosenPlanStartedToClientNotification, planName: 'Plan 2', planId: 2, + consultantName: '', + peerProfileUrl: '', isRead: true, createdAt: DateTime.now(), ), @@ -426,6 +452,8 @@ void main() { type: NotificationType.chosenPlanStartedToClientNotification, planName: 'Plan 1', planId: 1, + consultantName: '', + peerProfileUrl: '', isRead: false, createdAt: DateTime.now(), ), @@ -475,6 +503,8 @@ void main() { type: NotificationType.chosenPlanStartedToClientNotification, planName: 'Initial Plan', planId: 1, + consultantName: '', + peerProfileUrl: '', isRead: false, createdAt: DateTime.now(), ), @@ -524,6 +554,8 @@ void main() { type: NotificationType.chosenPlanStartedToClientNotification, planName: 'Initial Plan', planId: 1, + consultantName: '', + peerProfileUrl: '', isRead: false, createdAt: DateTime.now(), ), @@ -568,6 +600,8 @@ void main() { type: NotificationType.chosenPlanStartedToClientNotification, planName: 'Initial Plan', planId: 1, + consultantName: '', + peerProfileUrl: '', isRead: false, createdAt: DateTime.now(), ), @@ -608,6 +642,8 @@ void main() { type: NotificationType.chosenPlanStartedToClientNotification, planName: 'Initial Plan', planId: 1, + consultantName: '', + peerProfileUrl: '', isRead: false, createdAt: DateTime.now(), ), @@ -645,6 +681,8 @@ void main() { type: NotificationType.chosenPlanStartedToClientNotification, planName: 'Initial Plan', planId: 1, + consultantName: '', + peerProfileUrl: '', isRead: false, createdAt: DateTime.now(), ), @@ -688,6 +726,8 @@ void main() { type: NotificationType.chosenPlanStartedToClientNotification, planName: 'Initial Plan', planId: 1, + consultantName: '', + peerProfileUrl: '', isRead: false, createdAt: DateTime.now(), ), @@ -757,6 +797,8 @@ void main() { type: NotificationType.chosenPlanStartedToClientNotification, planName: 'Initial Plan', planId: 1, + consultantName: '', + peerProfileUrl: '', isRead: false, createdAt: DateTime.now(), ), @@ -800,6 +842,8 @@ void main() { type: NotificationType.chosenPlanStartedToClientNotification, planName: 'Initial Plan', planId: 1, + consultantName: '', + peerProfileUrl: '', isRead: false, createdAt: DateTime.now(), ), @@ -843,6 +887,8 @@ void main() { type: NotificationType.chosenPlanStartedToClientNotification, planName: 'Initial Plan', planId: 1, + consultantName: '', + peerProfileUrl: '', isRead: false, createdAt: DateTime.now(), ), @@ -886,6 +932,8 @@ void main() { type: NotificationType.chosenPlanStartedToClientNotification, planName: 'Initial Plan', planId: 1, + consultantName: '', + peerProfileUrl: '', isRead: false, createdAt: DateTime.now(), ), @@ -929,6 +977,8 @@ void main() { type: NotificationType.chosenPlanStartedToClientNotification, planName: 'Initial Plan', planId: 1, + consultantName: '', + peerProfileUrl: '', isRead: false, createdAt: DateTime.now(), ), @@ -972,6 +1022,8 @@ void main() { type: NotificationType.chosenPlanStartedToClientNotification, planName: 'Initial Plan', planId: 1, + consultantName: '', + peerProfileUrl: '', isRead: false, createdAt: DateTime.now(), ), @@ -1015,6 +1067,8 @@ void main() { type: NotificationType.chosenPlanStartedToClientNotification, planName: 'Initial Plan', planId: 1, + consultantName: '', + peerProfileUrl: '', isRead: false, createdAt: DateTime.now(), ), @@ -1058,6 +1112,8 @@ void main() { type: NotificationType.chosenPlanStartedToClientNotification, planName: 'Initial Plan', planId: 1, + consultantName: '', + peerProfileUrl: '', isRead: false, createdAt: DateTime.now(), ), @@ -1100,6 +1156,8 @@ void main() { type: NotificationType.chosenPlanStartedToClientNotification, planName: 'Initial Plan', planId: 1, + consultantName: '', + peerProfileUrl: '', isRead: false, createdAt: DateTime.now(), ), @@ -1137,6 +1195,8 @@ void main() { type: NotificationType.chosenPlanStartedToClientNotification, planName: 'Initial Plan', planId: 1, + consultantName: '', + peerProfileUrl: '', isRead: false, createdAt: DateTime.now(), ), @@ -1174,6 +1234,8 @@ void main() { type: NotificationType.chosenPlanStartedToClientNotification, planName: 'Initial Plan', planId: 1, + consultantName: '', + peerProfileUrl: '', isRead: false, createdAt: DateTime.now(), ), @@ -1217,6 +1279,8 @@ void main() { type: NotificationType.chosenPlanStartedToClientNotification, planName: 'Initial Plan', planId: 1, + consultantName: '', + peerProfileUrl: '', isRead: false, createdAt: DateTime.now(), ), diff --git a/test/features/notifications/presentation/notifications_cubit/notifications_state_test.dart b/test/features/notifications/presentation/notifications_cubit/notifications_state_test.dart index 2a0b8c3b..2bceb0c8 100644 --- a/test/features/notifications/presentation/notifications_cubit/notifications_state_test.dart +++ b/test/features/notifications/presentation/notifications_cubit/notifications_state_test.dart @@ -36,8 +36,12 @@ void main() { final notification = PlanSubscriptionStartedToAuthorNotification( type: NotificationType.planSubscriptionStartedToAuthor, planName: 'Test Plan', + planId: 1, customerName: 'Test Customer', + userId: 0, + userPhotoPath: '', chatId: 'chat-123', + chatUrl: '', isRead: false, createdAt: DateTime.now(), ); diff --git a/test/features/notifications/presentation/widgets/notification_widget_test.dart b/test/features/notifications/presentation/widgets/notification_widget_test.dart index f624993a..d8f56164 100644 --- a/test/features/notifications/presentation/widgets/notification_widget_test.dart +++ b/test/features/notifications/presentation/widgets/notification_widget_test.dart @@ -31,8 +31,12 @@ void main() { final notification = PlanSubscriptionStartedToAuthorNotification( type: NotificationType.planSubscriptionStartedToAuthor, planName: 'Test Plan', + planId: 1, customerName: 'Test Customer', + userId: 0, + userPhotoPath: '', chatId: 'chat123', + chatUrl: '', isRead: false, createdAt: testDate, ); @@ -47,7 +51,11 @@ void main() { final notification = PlanSubscriptionCanceledToAuthorNotification( type: NotificationType.planSubscriptionCanceledToAuthor, planName: 'Test Plan', + planId: 1, customerName: 'Test Customer', + userId: 0, + userPhotoPath: '', + chatUrl: '', isRead: false, createdAt: testDate, ); @@ -63,6 +71,8 @@ void main() { type: NotificationType.chosenPlanStartedToClient, planName: 'Test Plan', planId: 1, + consultantName: 'Test Consultant', + peerProfileUrl: '', isRead: false, createdAt: testDate, ); @@ -77,6 +87,8 @@ void main() { final notification = ChosenPlanTomorrowReminderToClientNotification( type: NotificationType.chosenPlanTomorrowReminderToClient, planName: 'Test Plan', + consultantName: '', + chatId: 0, isRead: false, createdAt: testDate, ); @@ -103,7 +115,12 @@ void main() { testWidgets('displays ConsultantLeftCommentNotification', (tester) async { final notification = ConsultantLeftCommentNotification( type: NotificationType.consultantLeftComment, + chosenPlanId: 1, + diaryId: 0, date: testDate, + planName: '', + userName: '', + consultantName: '', isRead: false, createdAt: testDate, ); @@ -131,6 +148,11 @@ void main() { final notification = ChosenPlanFinishedToClientNotification( type: NotificationType.chosenPlanFinishedToClient, planName: 'Test Plan', + chosenPlanId: 1, + consultantName: '', + consultantPhotoPath: '', + consultantProfileUrl: '', + consultantId: 0, isRead: false, createdAt: testDate, ); @@ -145,6 +167,11 @@ void main() { final notification = PlanSubscriptionCanceledToCustomerNotification( type: NotificationType.planSubscriptionCanceledToCustomer, planName: 'Test Plan', + planId: 1, + authorName: '', + authorId: 0, + authorPhotoPath: '', + chatUrl: '', isRead: false, createdAt: testDate, ); @@ -160,6 +187,10 @@ void main() { type: NotificationType.chosenPlanStartedToConsultant, planName: 'Test Plan', customerName: 'Test Customer', + planId: 1, + customerId: 1, + peerProfileUrl: '', + consultationUrl: '', isRead: false, createdAt: testDate, ); @@ -175,6 +206,7 @@ void main() { type: NotificationType.chosenPlanTomorrowReminderToConsultant, planName: 'Test Plan', customerName: 'Test Customer', + chatId: 0, isRead: false, createdAt: testDate, ); @@ -189,7 +221,11 @@ void main() { final notification = PlanSubscriptionPaidToAuthorNotification( type: NotificationType.planSubscriptionPaidToAuthor, planName: 'Test Plan', + planId: 1, customerName: 'Test Customer', + userId: 0, + userPhotoPath: '', + chatUrl: '', isRead: false, createdAt: testDate, ); @@ -206,6 +242,9 @@ void main() { planName: 'Test Plan', customerName: 'Test Customer', planId: 1, + chosenPlanId: 1, + clientPhotoPath: '', + clientId: 0, isRead: false, createdAt: testDate, ); @@ -219,6 +258,10 @@ void main() { testWidgets('displays ChosenPlanLastDayFinishedForUser', (tester) async { final notification = ChosenPlanLastDayFinishedForUser( type: NotificationType.chosenPlanLastDayFinishedForUser, + planName: 'Test Plan', + chosenPlanId: 1, + complaintUrl: '', + chatUrl: '', isRead: false, createdAt: testDate, ); @@ -252,6 +295,7 @@ void main() { message: 'Test message', entityId: 1, entityType: 'test', + entityName: 'Test Entity', isRead: false, createdAt: testDate, ); @@ -267,7 +311,7 @@ void main() { type: NotificationType.commentReplied, entityName: 'Test Entity', commentedEntityId: 1, - commentedEntityType: 'test', + commentedEntityType: CommentedEntityType.diet, author: 'Test Author', authorId: 1, parentCommentId: 1, @@ -305,7 +349,7 @@ void main() { type: NotificationType.entityCommented, entityName: 'Test Entity', entityId: 1, - entityType: 'test', + entityType: CommentedEntityType.diet, entityTranslatedType: 'Test Type', entityUrl: 'test_url', authorName: 'Test Author', @@ -470,6 +514,7 @@ void main() { final notification = DietCancelledNotification( type: NotificationType.dietCancelled, dietName: 'Test Diet', + dietId: 1, isRead: false, createdAt: testDate, ); @@ -484,6 +529,7 @@ void main() { final notification = DietStartedNotification( type: NotificationType.dietStarted, dietName: 'Test Diet', + dietId: 1, isRead: false, createdAt: testDate, ); @@ -498,6 +544,7 @@ void main() { final notification = DietExpiredNotification( type: NotificationType.dietExpired, dietName: 'Test Diet', + dietId: 1, isRead: false, createdAt: testDate, ); @@ -512,6 +559,7 @@ void main() { final notification = DietWillStartedInNotification( type: NotificationType.dietWillStartedIn, dietName: 'Test Diet', + dietId: 1, startDate: '2024-02-01', isRead: false, createdAt: testDate, @@ -527,6 +575,7 @@ void main() { final notification = DietCanNotBeStartedNotification( type: NotificationType.dietCanNotBeStarted, dietName: 'Test Diet', + dietId: 1, reason: 'Test reason', isRead: false, createdAt: testDate, @@ -541,8 +590,9 @@ void main() { testWidgets('displays PersonalDietCreationNeededNotification', (tester) async { final notification = PersonalDietCreationNeededNotification( type: NotificationType.personalDietCreationNeeded, - planName: 'Test Plan', - clientName: 'Test Client', + userName: 'Test Client', + consultantName: '', + myDietsUrl: '', isRead: false, createdAt: testDate, ); @@ -557,6 +607,9 @@ void main() { final notification = ChosenPlanClientLastDayFinishedNotification( type: NotificationType.chosenPlanClientLastDayFinished, planName: 'Test Plan', + chosenPlanId: 1, + complaintUrl: '', + chatUrl: '', isRead: false, createdAt: testDate, ); @@ -571,6 +624,8 @@ void main() { final notification = ChosenPlanClientLastDayStartedNotification( type: NotificationType.chosenPlanClientLastDayStarted, planName: 'Test Plan', + chosenPlanId: 1, + lastDayDiaryUrl: '', isRead: false, createdAt: testDate, ); @@ -585,7 +640,9 @@ void main() { final notification = StayOnTrackToAuthorNotification( type: NotificationType.stayOnTrackToAuthor, planName: 'Test Plan', - clientName: 'Test Client', + userName: 'Test Client', + dayNum: 1, + diaryDayId: 0, isRead: false, createdAt: testDate, ); @@ -600,6 +657,13 @@ void main() { final notification = ClientStartPlanSubscriptionToSubscriberNotification( type: NotificationType.clientStartPlanSubscriptionToSubscriber, planName: 'Test Plan', + planId: 1, + authorName: '', + authorId: 0, + authorPhotoPath: '', + chatUrl: '', + chatId: '', + startDate: '', isRead: false, createdAt: testDate, ); @@ -614,7 +678,11 @@ void main() { final notification = ConsultantConfirmPlanSubscriptionToSubscriberNotification( type: NotificationType.consultantConfirmPlanSubscriptionToSubscriber, planName: 'Test Plan', + planId: 1, consultantName: 'Test Consultant', + authorId: 0, + authorPhotoPath: '', + chatUrl: '', isRead: false, createdAt: testDate, ); @@ -629,8 +697,11 @@ void main() { final notification = ClientPaidPlanSubscriptionToAuthorNotification( type: NotificationType.clientPaidPlanSubscriptionToAuthor, planName: 'Test Plan', + planId: 1, clientName: 'Test Client', - amount: 100.0, + userId: 0, + userPhotoPath: '', + chatUrl: '', isRead: false, createdAt: testDate, ); @@ -644,7 +715,10 @@ void main() { testWidgets('displays ConsultantLeftDiaryCommentNotification', (tester) async { final notification = ConsultantLeftDiaryCommentNotification( type: NotificationType.consultantLeftDiaryComment, + chosenPlanId: 1, + diaryId: 0, planName: 'Test Plan', + userName: '', consultantName: 'Test Consultant', diaryDate: testDate, isRead: false, @@ -661,7 +735,11 @@ void main() { final notification = ConsultantConfirmPlanSubscriptionToAuthorNotification( type: NotificationType.consultantConfirmPlanSubscriptionToAuthor, planName: 'Test Plan', - consultantName: 'Test Consultant', + planId: 1, + clientName: 'Test Consultant', + userId: 0, + userPhotoPath: '', + chatUrl: '', isRead: false, createdAt: testDate, ); @@ -676,7 +754,11 @@ void main() { final notification = ClientPaidPlanSubscriptionToSubscriberNotification( type: NotificationType.clientPaidPlanSubscriptionToSubscriber, planName: 'Test Plan', - amount: 100.0, + planId: 1, + authorName: '', + authorId: 0, + authorPhotoPath: '', + chatUrl: '', isRead: false, createdAt: testDate, ); @@ -691,7 +773,12 @@ void main() { final notification = ClientStartPlanSubscriptionToAuthorNotification( type: NotificationType.clientStartPlanSubscriptionToAuthor, planName: 'Test Plan', + planId: 1, clientName: 'Test Client', + userId: 0, + userPhotoPath: '', + chatId: '', + chatUrl: '', isRead: false, createdAt: testDate, ); @@ -706,7 +793,11 @@ void main() { final notification = ClientCancelPlanSubscriptionToAuthorNotification( type: NotificationType.clientCancelPlanSubscriptionToAuthor, planName: 'Test Plan', + planId: 1, clientName: 'Test Client', + userId: 0, + userPhotoPath: '', + chatUrl: '', isRead: false, createdAt: testDate, ); @@ -721,6 +812,11 @@ void main() { final notification = ClientCancelPlanSubscriptionToSubscriberNotification( type: NotificationType.clientCancelPlanSubscriptionToSubscriber, planName: 'Test Plan', + planId: 1, + authorName: '', + authorId: 0, + authorPhotoPath: '', + chatUrl: '', isRead: false, createdAt: testDate, ); @@ -734,9 +830,11 @@ void main() { testWidgets('displays UserDailyReportCompletedNotification', (tester) async { final notification = UserDailyReportCompletedNotification( type: NotificationType.userDailyReportCompleted, + chosenPlanId: 1, + diaryId: 0, planName: 'Test Plan', clientName: 'Test Client', - reportDate: testDate, + consultantName: '', isRead: false, createdAt: testDate, ); @@ -751,7 +849,8 @@ void main() { final notification = StayOnTrackToCustomerNotification( type: NotificationType.stayOnTrackToCustomer, planName: 'Test Plan', - consultantName: 'Test Consultant', + dayNum: 1, + diaryDayId: 0, isRead: false, createdAt: testDate, ); @@ -780,8 +879,9 @@ void main() { testWidgets('displays SubscriptionNotification', (tester) async { final notification = SubscriptionNotification( type: NotificationType.subscription, - subscriptionType: 'monthly', - planName: 'Test Plan', + userName: '', + userPhotoPath: '', + userProfileUrl: '', isRead: false, createdAt: testDate, ); @@ -795,8 +895,9 @@ void main() { testWidgets('displays BadgeAwardedNotification', (tester) async { final notification = BadgeAwardedNotification( type: NotificationType.badgeAwarded, - badgeName: 'Test Badge', - badgeDescription: 'Test Description', + badgeTitle: 'Test Badge', + badgeType: '', + badgeImage: '', isRead: false, createdAt: testDate, ); @@ -810,8 +911,9 @@ void main() { testWidgets('displays BadgeRevokedNotification', (tester) async { final notification = BadgeRevokedNotification( type: NotificationType.badgeRevoked, - badgeName: 'Test Badge', - reason: 'Test reason', + badgeTitle: 'Test Badge', + badgeType: '', + badgeImage: '', isRead: false, createdAt: testDate, ); @@ -839,8 +941,7 @@ void main() { testWidgets('displays ClientCloseComplaintSuccessNotification', (tester) async { final notification = ClientCloseComplaintSuccessNotification( type: NotificationType.clientCloseComplaintSuccess, - complaintType: 'test', - entityName: 'Test Entity', + planName: 'Test Plan', isRead: false, createdAt: testDate, ); @@ -887,7 +988,6 @@ void main() { final notification = ClientRejectSuccessNotification( type: NotificationType.clientRejectSuccess, planName: 'Test Plan', - reason: 'Test reason', isRead: false, createdAt: testDate, ); @@ -903,7 +1003,6 @@ void main() { type: NotificationType.consultantRejectSuccess, planName: 'Test Plan', clientName: 'Test Client', - reason: 'Test reason', isRead: false, createdAt: testDate, ); @@ -937,8 +1036,12 @@ void main() { final notification = ProductStatusUpdatedForAuthorNotification( type: NotificationType.productStatusUpdatedForAuthor, productName: 'Test Product', - oldStatus: 'pending', - newStatus: 'approved', + productId: 1, + author: '', + authorId: 0, + status: 'approved', + subject: '', + messageLine: '', isRead: false, createdAt: testDate, ); @@ -967,8 +1070,15 @@ void main() { type: NotificationType.personalDietIsDefinedToCustomer, isRead: false, createdAt: testDate, - dietName: 'Test Diet', + chosenPlanId: 1, dietId: 1, + planName: '', + userName: '', + consultantName: '', + dietName: 'Test Diet', + role: '', + peerSlug: '', + peerImagePath: '', ); await tester.pumpWidget(createTestWidget(notification)); diff --git a/test/features/notifications/repository/notifications_repository_test.dart b/test/features/notifications/repository/notifications_repository_test.dart index a80b3fb8..ef267271 100644 --- a/test/features/notifications/repository/notifications_repository_test.dart +++ b/test/features/notifications/repository/notifications_repository_test.dart @@ -24,6 +24,8 @@ void main() { type: NotificationType.chosenPlanStartedToClientNotification, planName: 'Test Plan', planId: 1, + consultantName: 'Test Consultant', + peerProfileUrl: '', isRead: false, createdAt: DateTime.now(), ), diff --git a/test/features/plans/presentation/pages/about_plan_screen_smoke_test.dart b/test/features/plans/presentation/pages/about_plan_screen_smoke_test.dart index e57b7601..4fa46bb3 100644 --- a/test/features/plans/presentation/pages/about_plan_screen_smoke_test.dart +++ b/test/features/plans/presentation/pages/about_plan_screen_smoke_test.dart @@ -123,7 +123,7 @@ void main() { registerMocks(planCubit, commentsCubit); - await tester.pumpWidget(wrap(planCubit, feedCubit, commentsCubit, const AboutPlanScreen(slug: 'test', navigateToComments: false, index: null))); + await tester.pumpWidget(wrap(planCubit, feedCubit, commentsCubit, const AboutPlanScreen(slug: 'test', navigateToComments: false, index: null, highlightedCommentId: null))); await tester.pump(); expect(find.byType(AboutPlanScreen), findsOneWidget); @@ -149,7 +149,7 @@ void main() { registerMocks(planCubit, commentsCubit); await mockNetworkImagesFor(() async { - await tester.pumpWidget(wrap(planCubit, feedCubit, commentsCubit, const AboutPlanScreen(slug: 'test', navigateToComments: false, index: null))); + await tester.pumpWidget(wrap(planCubit, feedCubit, commentsCubit, const AboutPlanScreen(slug: 'test', navigateToComments: false, index: null, highlightedCommentId: null))); await tester.pump(); await tester.pump(); }); @@ -177,10 +177,39 @@ void main() { registerMocks(planCubit, commentsCubit); - await tester.pumpWidget(wrap(planCubit, feedCubit, commentsCubit, const AboutPlanScreen(slug: 'test', navigateToComments: false, index: null))); + await tester.pumpWidget(wrap(planCubit, feedCubit, commentsCubit, const AboutPlanScreen(slug: 'test', navigateToComments: false, index: null, highlightedCommentId: null))); await tester.pump(); expect(find.byType(AboutPlanScreen), findsOneWidget); }); + + testWidgets('renders with navigateToComments true', (tester) async { + final planCubit = MockAboutPlanCubit(); + final feedCubit = MockFeedCubit(); + final commentsCubit = MockCommentsCubit(); + const state = AboutPlanState(isLoading: false, planModel: testPlan, chosenOptionIndex: 0, error: null); + + when(planCubit.state).thenReturn(state); + when(planCubit.stream).thenAnswer((_) => const Stream.empty()); + when(planCubit.close()).thenAnswer((_) async => Future.value()); + when(feedCubit.viewPlan(any)).thenAnswer((_) async => Future.value()); + when(feedCubit.stream).thenAnswer((_) => const Stream.empty()); + when(feedCubit.close()).thenAnswer((_) async => Future.value()); + when(commentsCubit.state).thenReturn(const CommentsState()); + when(commentsCubit.stream).thenAnswer((_) => const Stream.empty()); + when(commentsCubit.close()).thenAnswer((_) async => Future.value()); + when(commentsCubit.getComments(any, any, any)).thenAnswer((_) async => Future.value()); + + registerMocks(planCubit, commentsCubit); + + await mockNetworkImagesFor(() async { + await tester.pumpWidget(wrap(planCubit, feedCubit, commentsCubit, const AboutPlanScreen(slug: 'test', navigateToComments: true, index: 1))); + await tester.pump(); + await tester.pump(); + }); + + expect(find.byType(AboutPlanScreen), findsOneWidget); + expect(find.text('Test Plan'), findsOneWidget); + }); }); } diff --git a/test/features/profile/presentation/bloc/profile_cubit/profile_cubit_test.dart b/test/features/profile/presentation/bloc/profile_cubit/profile_cubit_test.dart index cee98f20..c76ace2e 100644 --- a/test/features/profile/presentation/bloc/profile_cubit/profile_cubit_test.dart +++ b/test/features/profile/presentation/bloc/profile_cubit/profile_cubit_test.dart @@ -316,6 +316,7 @@ void main() { isMyProfile: true, profileDetails: profileDetails, blogPosts: [blogPost], + certPhotos: [] )); profileCubit!.updateFeedBlogPostList(blogPost, true); diff --git a/test/features/profile/presentation/widgets/profile_blog_post_widget_smoke_test.dart b/test/features/profile/presentation/widgets/profile_blog_post_widget_smoke_test.dart index 0444d7c4..8f6fe8ed 100644 --- a/test/features/profile/presentation/widgets/profile_blog_post_widget_smoke_test.dart +++ b/test/features/profile/presentation/widgets/profile_blog_post_widget_smoke_test.dart @@ -219,7 +219,7 @@ void main() { activityCoefficient: 1.5, ); - final loadedState = ProfileState.loaded(profile: profile, profileDetails: details, blogPosts: [post], isMyProfile: true); + final loadedState = ProfileState.loaded(profile: profile, profileDetails: details, blogPosts: [post], isMyProfile: true, certPhotos: []); final cubit = MockProfileCubit(); when(cubit.state).thenReturn(loadedState); when(cubit.stream).thenAnswer((_) => const Stream.empty()); diff --git a/test/features/recipe/presentation/cubit/my_recipes_cubit/my_recipes_cubit_test.dart b/test/features/recipe/presentation/cubit/my_recipes_cubit/my_recipes_cubit_test.dart index a77e0a7f..5260e3ee 100644 --- a/test/features/recipe/presentation/cubit/my_recipes_cubit/my_recipes_cubit_test.dart +++ b/test/features/recipe/presentation/cubit/my_recipes_cubit/my_recipes_cubit_test.dart @@ -1,6 +1,7 @@ import 'package:eatvio_app/features/recipe/models/my_recipe_model.dart'; -import 'package:eatvio_app/features/recipe/presentation/cubit/my_recipes_cubit/my_recieps_cubit.dart'; +import 'package:eatvio_app/features/recipe/presentation/cubit/my_recipes_cubit/my_recipes_cubit.dart'; import 'package:eatvio_app/features/recipe/presentation/cubit/my_recipes_cubit/my_recipes_state.dart'; +import 'package:eatvio_app/features/create_recipe/reporitories/recipe_repository.dart'; import 'package:eatvio_app/features/recipe/repositories/about_recipe_repository.dart'; import 'package:eatvio_app/features/recipe/repositories/my_recipes_repository.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -9,16 +10,18 @@ import 'package:mockito/mockito.dart'; import 'my_recipes_cubit_test.mocks.dart'; -@GenerateNiceMocks([MockSpec(), MockSpec()]) +@GenerateNiceMocks([MockSpec(), MockSpec(), MockSpec()]) void main() { group('MyRecipesCubit', () { MyRecipesCubit? cubit; late MockMyRecipesRepository mockMyRecipesRepository; late MockAboutRecipeRepository mockAboutRecipeRepository; + late MockRecipeRepository mockRecipeRepository; setUp(() { mockMyRecipesRepository = MockMyRecipesRepository(); mockAboutRecipeRepository = MockAboutRecipeRepository(); + mockRecipeRepository = MockRecipeRepository(); }); tearDown(() async { @@ -29,13 +32,13 @@ void main() { }); test('начальное состояние - loading', () { - cubit = MyRecipesCubit(myRecipesRepository: mockMyRecipesRepository, aboutRecipeRepository: mockAboutRecipeRepository); + cubit = MyRecipesCubit(myRecipesRepository: mockMyRecipesRepository, aboutRecipeRepository: mockAboutRecipeRepository, recipeRepository: mockRecipeRepository); expect(cubit!.state, isA()); }); test('getMyRecipes - успешная загрузка активных рецептов', () async { - cubit = MyRecipesCubit(myRecipesRepository: mockMyRecipesRepository, aboutRecipeRepository: mockAboutRecipeRepository); + cubit = MyRecipesCubit(myRecipesRepository: mockMyRecipesRepository, aboutRecipeRepository: mockAboutRecipeRepository, recipeRepository: mockRecipeRepository); final testRecipes = [ const MyRecipeModel( @@ -66,7 +69,7 @@ void main() { }); test('setActiveState - фильтрует активные рецепты', () async { - cubit = MyRecipesCubit(myRecipesRepository: mockMyRecipesRepository, aboutRecipeRepository: mockAboutRecipeRepository); + cubit = MyRecipesCubit(myRecipesRepository: mockMyRecipesRepository, aboutRecipeRepository: mockAboutRecipeRepository, recipeRepository: mockRecipeRepository); final testRecipes = [ const MyRecipeModel( @@ -112,7 +115,7 @@ void main() { }); test('setArchivedState - фильтрует архивированные рецепты', () async { - cubit = MyRecipesCubit(myRecipesRepository: mockMyRecipesRepository, aboutRecipeRepository: mockAboutRecipeRepository); + cubit = MyRecipesCubit(myRecipesRepository: mockMyRecipesRepository, aboutRecipeRepository: mockAboutRecipeRepository, recipeRepository: mockRecipeRepository); final testRecipes = [ const MyRecipeModel( diff --git a/test/features/recipe/presentation/pages/about_recipe_screen_smoke_test.dart b/test/features/recipe/presentation/pages/about_recipe_screen_smoke_test.dart index 3008d3b3..9bc22016 100644 --- a/test/features/recipe/presentation/pages/about_recipe_screen_smoke_test.dart +++ b/test/features/recipe/presentation/pages/about_recipe_screen_smoke_test.dart @@ -193,5 +193,36 @@ void main() { expect(find.byType(AboutRecipeScreen), findsOneWidget); }); + + testWidgets('renders with navigateToComments true', (tester) async { + final recipeCubit = MockAboutRecipeCubit(); + final feedCubit = MockFeedCubit(); + final commentsCubit = MockCommentsCubit(); + final state = AboutRecipeState.loaded(recipe: testRecipe); + + when(recipeCubit.state).thenReturn(state); + when(recipeCubit.stream).thenAnswer((_) => const Stream.empty()); + when(recipeCubit.close()).thenAnswer((_) async => Future.value()); + when(feedCubit.viewRecipe(any)).thenAnswer((_) async => Future.value()); + when(feedCubit.stream).thenAnswer((_) => const Stream.empty()); + when(feedCubit.close()).thenAnswer((_) async => Future.value()); + when(commentsCubit.state).thenReturn(const CommentsState()); + when(commentsCubit.stream).thenAnswer((_) => const Stream.empty()); + when(commentsCubit.close()).thenAnswer((_) async => Future.value()); + when(commentsCubit.getComments(any, any, any)).thenAnswer((_) async => Future.value()); + when(commentsCubit.setReplyId(any)).thenReturn(null); + + registerMocks(recipeCubit, commentsCubit); + + await mockNetworkImagesFor(() async { + await tester.pumpWidget( + wrap(recipeCubit, feedCubit, commentsCubit, const AboutRecipeScreen(recipeSlug: 'test', index: 2, navigateToComments: true)), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 600)); + }); + + expect(find.byType(AboutRecipeScreen), findsOneWidget); + }); }); } diff --git a/test/features/recipe/presentation/pages/my_recipes_screen_smoke_test.dart b/test/features/recipe/presentation/pages/my_recipes_screen_smoke_test.dart index fac14897..ebd26e01 100644 --- a/test/features/recipe/presentation/pages/my_recipes_screen_smoke_test.dart +++ b/test/features/recipe/presentation/pages/my_recipes_screen_smoke_test.dart @@ -2,11 +2,13 @@ import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/app_initializer.dart'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; -import 'package:eatvio_app/features/recipe/presentation/cubit/my_recipes_cubit/my_recieps_cubit.dart'; +import 'package:eatvio_app/features/recipe/presentation/cubit/my_recipes_cubit/my_recipes_cubit.dart'; import 'package:eatvio_app/features/recipe/presentation/cubit/my_recipes_cubit/my_recipes_state.dart'; import 'package:eatvio_app/features/recipe/presentation/pages/my_recipes_screen.dart'; +import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; @@ -26,7 +28,17 @@ void main() { }); Widget wrap(Widget child) { - return MaterialApp(theme: ThemeHelper().themeData(), home: child); + return MaterialApp( + theme: ThemeHelper().themeData(), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + home: child, + ); } testWidgets('renders my recipes screen', (tester) async { diff --git a/test/features/recipe/presentation/widgets/my_recipe_widget_test.dart b/test/features/recipe/presentation/widgets/my_recipe_widget_test.dart index 52fca10f..caa91380 100644 --- a/test/features/recipe/presentation/widgets/my_recipe_widget_test.dart +++ b/test/features/recipe/presentation/widgets/my_recipe_widget_test.dart @@ -5,7 +5,7 @@ import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/recipes_cubit/recipes_cubit.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/recipes_cubit/recipes_state.dart'; import 'package:eatvio_app/features/recipe/models/my_recipe_model.dart'; -import 'package:eatvio_app/features/recipe/presentation/cubit/my_recipes_cubit/my_recieps_cubit.dart'; +import 'package:eatvio_app/features/recipe/presentation/cubit/my_recipes_cubit/my_recipes_cubit.dart'; import 'package:eatvio_app/features/recipe/presentation/cubit/my_recipes_cubit/my_recipes_state.dart'; import 'package:eatvio_app/features/recipe/presentation/widgets/my_recipe_widget.dart'; import 'package:eatvio_app/generated/l10n.dart'; diff --git a/test/features/recipe/presentation/widgets/my_recipes_tab_test.dart b/test/features/recipe/presentation/widgets/my_recipes_tab_test.dart index efba34ba..58d21ef1 100644 --- a/test/features/recipe/presentation/widgets/my_recipes_tab_test.dart +++ b/test/features/recipe/presentation/widgets/my_recipes_tab_test.dart @@ -7,7 +7,7 @@ import 'package:flutter_test/flutter_test.dart'; void main() { group('MyRecipesTab smoke', () { - Widget wrap(Widget child) { + Widget wrap() { return MaterialApp( theme: ThemeHelper().themeData(), localizationsDelegates: const [ @@ -17,12 +17,17 @@ void main() { GlobalCupertinoLocalizations.delegate, ], supportedLocales: const [Locale('ru')], - home: Scaffold(body: child), + home: Material( + child: MyRecipesTab( + selectedIndex: 0, + onTabChanged: (_) {}, + ), + ), ); } testWidgets('renders my recipes tab', (tester) async { - await tester.pumpWidget(wrap(const MyRecipesTab())); + await tester.pumpWidget(wrap()); await tester.pump(); expect(find.byType(MyRecipesTab), findsOneWidget); diff --git a/test/features/recipes/presentation/cubit/my_recipes_cubit_test.dart b/test/features/recipes/presentation/cubit/my_recipes_cubit_test.dart index 267e5191..4afde5ec 100644 --- a/test/features/recipes/presentation/cubit/my_recipes_cubit_test.dart +++ b/test/features/recipes/presentation/cubit/my_recipes_cubit_test.dart @@ -1,9 +1,10 @@ import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; import 'package:eatvio_app/features/feed/models/recipes_model.dart'; import 'package:eatvio_app/features/recipe/models/my_recipe_model.dart'; -import 'package:eatvio_app/features/recipe/presentation/cubit/my_recipes_cubit/my_recieps_cubit.dart'; +import 'package:eatvio_app/features/recipe/presentation/cubit/my_recipes_cubit/my_recipes_cubit.dart'; import 'package:eatvio_app/features/recipe/presentation/cubit/my_recipes_cubit/my_recipes_state.dart'; import 'package:eatvio_app/features/recipe/repositories/about_recipe_repository.dart'; +import 'package:eatvio_app/features/create_recipe/reporitories/recipe_repository.dart'; import 'package:eatvio_app/features/recipe/repositories/my_recipes_repository.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; @@ -13,12 +14,14 @@ import 'my_recipes_cubit_test.mocks.dart'; @GenerateNiceMocks([ MockSpec(), MockSpec(), + MockSpec(), ]) void main() { group('MyRecipesCubit', () { MyRecipesCubit? myRecipesCubit; late MockMyRecipesRepository mockMyRecipesRepository; late MockAboutRecipeRepository mockAboutRecipeRepository; + late MockRecipeRepository mockRecipeRepository; const testActiveRecipe = MyRecipeModel( id: 1, @@ -85,6 +88,7 @@ void main() { setUp(() { mockMyRecipesRepository = MockMyRecipesRepository(); mockAboutRecipeRepository = MockAboutRecipeRepository(); + mockRecipeRepository = MockRecipeRepository(); }); tearDown(() async { @@ -99,6 +103,7 @@ void main() { myRecipesCubit = MyRecipesCubit( myRecipesRepository: mockMyRecipesRepository, aboutRecipeRepository: mockAboutRecipeRepository, + recipeRepository: mockRecipeRepository, ); expect(myRecipesCubit!.state, isA()); }); @@ -107,6 +112,7 @@ void main() { myRecipesCubit = MyRecipesCubit( myRecipesRepository: mockMyRecipesRepository, aboutRecipeRepository: mockAboutRecipeRepository, + recipeRepository: mockRecipeRepository, ); when(mockMyRecipesRepository.getMyRecipes()).thenAnswer( @@ -127,6 +133,7 @@ void main() { myRecipesCubit = MyRecipesCubit( myRecipesRepository: mockMyRecipesRepository, aboutRecipeRepository: mockAboutRecipeRepository, + recipeRepository: mockRecipeRepository, ); when(mockMyRecipesRepository.getMyRecipes()).thenThrow(Exception('Network error')); @@ -141,6 +148,7 @@ void main() { myRecipesCubit = MyRecipesCubit( myRecipesRepository: mockMyRecipesRepository, aboutRecipeRepository: mockAboutRecipeRepository, + recipeRepository: mockRecipeRepository, ); when(mockMyRecipesRepository.getMyRecipes()).thenAnswer( @@ -161,6 +169,7 @@ void main() { myRecipesCubit = MyRecipesCubit( myRecipesRepository: mockMyRecipesRepository, aboutRecipeRepository: mockAboutRecipeRepository, + recipeRepository: mockRecipeRepository, ); when(mockMyRecipesRepository.getMyRecipes()).thenThrow(Exception('Network error')); @@ -175,6 +184,7 @@ void main() { myRecipesCubit = MyRecipesCubit( myRecipesRepository: mockMyRecipesRepository, aboutRecipeRepository: mockAboutRecipeRepository, + recipeRepository: mockRecipeRepository, ); when(mockMyRecipesRepository.getMyRecipes()).thenAnswer( @@ -195,6 +205,7 @@ void main() { myRecipesCubit = MyRecipesCubit( myRecipesRepository: mockMyRecipesRepository, aboutRecipeRepository: mockAboutRecipeRepository, + recipeRepository: mockRecipeRepository, ); when(mockMyRecipesRepository.getMyRecipes()).thenThrow(Exception('Network error')); @@ -209,6 +220,7 @@ void main() { myRecipesCubit = MyRecipesCubit( myRecipesRepository: mockMyRecipesRepository, aboutRecipeRepository: mockAboutRecipeRepository, + recipeRepository: mockRecipeRepository, ); when(mockAboutRecipeRepository.getRecipe('test-recipe')).thenAnswer((_) async => testRecipe); @@ -225,6 +237,7 @@ void main() { myRecipesCubit = MyRecipesCubit( myRecipesRepository: mockMyRecipesRepository, aboutRecipeRepository: mockAboutRecipeRepository, + recipeRepository: mockRecipeRepository, ); when(mockMyRecipesRepository.getMyRecipes()).thenAnswer( diff --git a/test/fixtures/create_diet/nutrition_response.json b/test/fixtures/create_diet/nutrition_response.json index 6dd2119e..61bf7807 100644 --- a/test/fixtures/create_diet/nutrition_response.json +++ b/test/fixtures/create_diet/nutrition_response.json @@ -3,11 +3,58 @@ "1": { "0": { "name": "Авокадо", - "portion": "grams", + "portion": "gr", "quantity": 100, - "product_nutritional_value_id": "501" + "product_nutritional_value_id": 501, + "nutrition": { + "energy": 160.0, + "proteins": 2.0, + "fats": 14.0, + "carbohydrates": 9.0 + } + }, + "1": { + "name": "Лосось", + "portion": "gr", + "quantity": 150, + "product_nutritional_value_id": 502, + "nutrition": { + "energy": 312.0, + "proteins": 30.0, + "fats": 19.5, + "carbohydrates": 0.0 + } + } + }, + "2": { + "0": { + "name": "Овсянка", + "portion": "gr", + "quantity": 150, + "product_nutritional_value_id": 503, + "nutrition": { + "energy": 195.0, + "proteins": 6.75, + "fats": 3.0, + "carbohydrates": 33.0 + } + } + } + }, + "2": { + "1": { + "0": { + "name": "Творог", + "portion": "gr", + "quantity": 200, + "product_nutritional_value_id": 504, + "nutrition": { + "energy": 226.0, + "proteins": 31.0, + "fats": 9.0, + "carbohydrates": 3.4 + } } } } } - diff --git a/test/fixtures/diets/diet_response.json b/test/fixtures/diets/diet_response.json index d4d770a9..41b52101 100644 --- a/test/fixtures/diets/diet_response.json +++ b/test/fixtures/diets/diet_response.json @@ -12,7 +12,66 @@ "meat": false }, "description_md": "Test details", - "daily_schedule": {}, + "daily_schedule": { + "1": { + "1": { + "0": { + "name": "Овсянка", + "portion": "gr", + "quantity": 150, + "product_nutritional_value_id": 101, + "nutrition": { + "energy": 195.0, + "proteins": 6.75, + "fats": 3.0, + "carbohydrates": 33.0 + } + }, + "1": { + "name": "Бананы", + "portion": "gr", + "quantity": 120, + "product_nutritional_value_id": 102, + "nutrition": { + "energy": 107.0, + "proteins": 1.3, + "fats": 0.4, + "carbohydrates": 27.0 + } + } + }, + "2": { + "0": { + "name": "Куриная грудка", + "portion": "gr", + "quantity": 200, + "product_nutritional_value_id": 201, + "nutrition": { + "energy": 330.0, + "proteins": 62.0, + "fats": 7.2, + "carbohydrates": 0.0 + } + } + } + }, + "2": { + "1": { + "0": { + "name": "Творог", + "portion": "gr", + "quantity": 200, + "product_nutritional_value_id": 301, + "nutrition": { + "energy": 226.0, + "proteins": 31.0, + "fats": 9.0, + "carbohydrates": 3.4 + } + } + } + } + }, "author": { "author_slug": "author", "author_name": "Author Name", @@ -26,6 +85,18 @@ "total_comments": 5, "was_liked_by_me": false, "is_favorite": false, - "daily_nutrition": [] + "daily_nutrition": [ + { + "energy": 864.0, + "proteins": 101.05, + "fats": 19.6, + "carbohydrates": 63.4 + }, + { + "energy": 532.0, + "proteins": 93.3, + "fats": 16.2, + "carbohydrates": 30.4 + } + ] } - diff --git a/test/fixtures/doubles/create_diet_doubles.dart b/test/fixtures/doubles/create_diet_doubles.dart index 7cf7b3b9..52645ae5 100644 --- a/test/fixtures/doubles/create_diet_doubles.dart +++ b/test/fixtures/doubles/create_diet_doubles.dart @@ -53,11 +53,15 @@ class FixtureCreateDietRepository extends CreateDietRepository { portion = Portion.grams; } + final pnvid = portionData['product_nutritional_value_id']; + final nutritionJson = portionData['nutrition'] as Map?; + final dailyNutrition = nutritionJson != null ? DailyNutrition.fromJson(nutritionJson) : null; mealMap[portionIndex] = DailySchedule( name: portionData['name'] as String, portion: portion, - quantity: portionData['quantity'] as int, - productNutritionalValueId: portionData['product_nutritional_value_id'] as String?, + quantity: portionData['quantity'] is int ? portionData['quantity'] as int : int.parse(portionData['quantity'].toString()), + productNutritionalValueId: pnvid == null ? null : (pnvid is int ? pnvid : int.tryParse(pnvid.toString())), + dailyNutrition: dailyNutrition, ); } @@ -75,13 +79,13 @@ class FixtureCreateDietRepository extends CreateDietRepository { required String name, required int duration, required DietState state, - required String meatType, - required String carbType, - required String vegType, - required String lightType, - required String details, - required File image, - required List>> dailySchedule, + required String? meatType, + required String? carbType, + required String? vegType, + required String? lightType, + required String? details, + required File? image, + required List>>? dailySchedule, }) async { createRequests.add({ 'name': name, @@ -100,7 +104,7 @@ class FixtureCreateDietRepository extends CreateDietRepository { } @override - Future updateDiet({ + Future updateDiet({ required int dietId, required DietState state, String? name, @@ -127,6 +131,7 @@ class FixtureCreateDietRepository extends CreateDietRepository { if (lightType != null) 'light': lightType == '1', }, }); + return 201; } } @@ -162,17 +167,17 @@ class _NoopCreateDietService implements CreateDietService { } @override - Future updateDrafted({required int diet, required FormData formData}) { + Future updateDrafted({required int diet, required FormData formData}) { throw UnimplementedError(); } @override - Future updatePersonal({required int diet, required FormData formData}) { + Future updatePersonal({required int diet, required FormData formData}) { throw UnimplementedError(); } @override - Future updatePublished({required int diet, required FormData formData}) { + Future updatePublished({required int diet, required FormData formData}) { throw UnimplementedError(); } } diff --git a/test/fixtures/feed/diets_pagination_response.json b/test/fixtures/feed/diets_pagination_response.json index a15de8fb..8dd97a36 100644 --- a/test/fixtures/feed/diets_pagination_response.json +++ b/test/fixtures/feed/diets_pagination_response.json @@ -14,7 +14,54 @@ "meat": true }, "description_md": "Keto diet description", - "daily_schedule": {}, + "daily_schedule": { + "1": { + "1": { + "0": { + "name": "Яйца вареные", + "portion": "pcs", + "quantity": 2, + "product_nutritional_value_id": 401, + "nutrition": { + "energy": 155.0, + "proteins": 12.6, + "fats": 10.6, + "carbohydrates": 1.1 + } + } + }, + "2": { + "0": { + "name": "Авокадо", + "portion": "gr", + "quantity": 100, + "product_nutritional_value_id": 501, + "nutrition": { + "energy": 160.0, + "proteins": 2.0, + "fats": 14.0, + "carbohydrates": 9.0 + } + } + } + }, + "2": { + "1": { + "0": { + "name": "Лосось", + "portion": "gr", + "quantity": 150, + "product_nutritional_value_id": 502, + "nutrition": { + "energy": 312.0, + "proteins": 30.0, + "fats": 19.5, + "carbohydrates": 0.0 + } + } + } + } + }, "author": { "author_slug": "nutritionist-1", "author_name": "Nutritionist Name", @@ -28,7 +75,20 @@ "total_comments": 25, "was_liked_by_me": false, "is_favorite": false, - "daily_nutrition": [] + "daily_nutrition": [ + { + "energy": 627.0, + "proteins": 44.6, + "fats": 44.1, + "carbohydrates": 10.1 + }, + { + "energy": 312.0, + "proteins": 30.0, + "fats": 19.5, + "carbohydrates": 0.0 + } + ] } ], "links": { @@ -50,4 +110,3 @@ "total_max_price": null } } - diff --git a/test/fixtures/my_diets/my_diets_pagination_response.json b/test/fixtures/my_diets/my_diets_pagination_response.json new file mode 100644 index 00000000..a23af562 --- /dev/null +++ b/test/fixtures/my_diets/my_diets_pagination_response.json @@ -0,0 +1,112 @@ +{ + "data": [ + { + "id": 1, + "slug": "my-keto-diet", + "name": "My Keto Diet", + "image": "https://example.com/my-keto.jpg", + "duration": 14, + "state": "drafted", + "type": { + "light": true, + "veg": true, + "carb": false, + "meat": true + }, + "description_md": "My drafted keto diet for two weeks", + "daily_schedule": { + "1": { + "1": { + "0": { + "name": "Омлет", + "portion": "gr", + "quantity": 200, + "product_nutritional_value_id": 601, + "nutrition": { + "energy": 318.0, + "proteins": 21.2, + "fats": 24.2, + "carbohydrates": 2.0 + } + } + }, + "2": { + "0": { + "name": "Салат с курицей", + "portion": "gr", + "quantity": 300, + "product_nutritional_value_id": 602, + "nutrition": { + "energy": 285.0, + "proteins": 35.0, + "fats": 12.0, + "carbohydrates": 8.0 + } + } + } + }, + "2": { + "1": { + "0": { + "name": "Творог 5%", + "portion": "gr", + "quantity": 200, + "product_nutritional_value_id": 603, + "nutrition": { + "energy": 226.0, + "proteins": 31.0, + "fats": 9.0, + "carbohydrates": 3.4 + } + } + } + } + }, + "author": { + "author_slug": "me", + "author_name": "My Name", + "author_profile_icon": "https://example.com/me.jpg", + "author_is_me": true, + "is_entity_subscribed_by_me": null, + "author_place_in_rating": null + }, + "total_likes": 0, + "total_views": 0, + "total_comments": 0, + "was_liked_by_me": false, + "is_favorite": false, + "daily_nutrition": [ + { + "energy": 603.0, + "proteins": 56.2, + "fats": 36.2, + "carbohydrates": 10.0 + }, + { + "energy": 226.0, + "proteins": 31.0, + "fats": 9.0, + "carbohydrates": 3.4 + } + ] + } + ], + "links": { + "first": "http://localhost/api/my-diets?page=1", + "last": "http://localhost/api/my-diets?page=1", + "prev": null, + "next": null + }, + "meta": { + "current_page": 1, + "from": 1, + "last_page": 1, + "links": [], + "path": "http://localhost/api/my-diets", + "per_page": 10, + "to": 1, + "total": 1, + "total_min_price": null, + "total_max_price": null + } +}