Merge pull request 'BUILD 99' (#935) from main into test-flight

Reviewed-on: eatvio/eatvio_app#935
This commit is contained in:
mso7nueh
2026-03-12 13:53:44 +00:00
139 changed files with 5328 additions and 1481 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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

View File

@@ -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 <<EOF >/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 }}

6
.gitignore vendored
View File

@@ -20,4 +20,8 @@ env/.env
.env
android/key.properties
app/upload-keystore.jks
coverage/
coverage/
backend/
.fvm/
.fvmrc
/eatvio

View File

@@ -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"}}]}
{
"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"
}
} ]
}

View File

@@ -21,6 +21,12 @@
<meta-data
android:name="push_kit_auto_init_enabled"
android:value="true" />
<meta-data
android:name="com.huawei.hms.client.appid"
android:value="appid=113238569" />
<meta-data
android:name="com.huawei.hms.client.cpid"
android:value="cpid=20007000036693451" />
<activity
android:name=".MainActivity"
android:exported="true"

View File

@@ -1,6 +1,10 @@
package com.eatvioapp.app
import android.app.PendingIntent
import android.content.Intent
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.huawei.hms.push.HmsMessageService
import com.huawei.hms.push.RemoteMessage
@@ -13,6 +17,45 @@ class HmsPushService : HmsMessageService() {
override fun onMessageReceived(message: RemoteMessage?) {
super.onMessageReceived(message)
Log.i("HMS", "onMessageReceived: ${message?.data}")
if (message == null) return
// Берём action_url из data-поля, если оно есть.
val actionUrl = message.dataOfMap["action_url"]
// Строим системное уведомление, по тапу по которому откроется MainActivity с action_url.
showNotification(message, actionUrl)
}
private fun showNotification(message: RemoteMessage, actionUrl: String?) {
val context = applicationContext
// Интент для запуска Flutter-активити.
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
if (!actionUrl.isNullOrBlank()) {
putExtra("action_url", actionUrl)
}
}
val pendingIntent = PendingIntent.getActivity(
context,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val channelId = "hms_push_channel"
val notification = NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.mipmap.ic_launcher) // используем иконку приложения
.setContentTitle(message.notification?.title ?: context.getString(R.string.app_name))
.setContentText(message.notification?.body ?: "")
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.build()
NotificationManagerCompat.from(context).notify(message.messageId.hashCode(), notification)
}
}

View File

@@ -1,8 +1,8 @@
package com.eatvioapp.app
import android.content.Intent
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import android.os.Bundle
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
@@ -14,12 +14,13 @@ import kotlinx.coroutines.launch
class MainActivity: FlutterActivity() {
private val channelName = "com.eatvioapp/hms_push"
private var methodChannel: MethodChannel? = null
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName)
.setMethodCallHandler { call, result ->
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)
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,3 @@
<svg width="10" height="9" viewBox="0 0 10 9" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.53669 8.89177L6.8122 7.15382C6.52317 7.26749 6.22614 7.34957 5.92111 7.40005C5.61615 7.45053 5.3063 7.47578 4.99155 7.47578C3.95998 7.47578 3.01527 7.19683 2.15742 6.63893C1.29964 6.08096 0.619788 5.34562 0.117849 4.43293C0.0770142 4.36144 0.047164 4.28862 0.0282984 4.21447C0.00943278 4.14041 0 4.06506 0 3.98842C0 3.91178 0.0102087 3.8356 0.030626 3.75987C0.0510433 3.68423 0.0816693 3.61224 0.122504 3.5439C0.317857 3.21368 0.525501 2.89417 0.745437 2.58538C0.96529 2.2765 1.22096 2.00328 1.51243 1.76572L0.406101 0.633204C0.338315 0.564287 0.303606 0.478889 0.301972 0.377008C0.300421 0.275127 0.337662 0.185536 0.413696 0.108233C0.484667 0.0360776 0.570746 0 0.671934 0C0.773041 0 0.85908 0.0360776 0.93005 0.108233L9.05304 8.3668C9.12091 8.43579 9.15689 8.52119 9.16097 8.62299C9.16505 8.72487 9.12908 8.81446 9.05304 8.89177C8.98207 8.96392 8.89599 9 8.79481 9C8.6937 9 8.60766 8.96392 8.53669 8.89177ZM4.99155 6.01955C5.10025 6.01955 5.20405 6.01299 5.30295 5.99988C5.40194 5.98676 5.49855 5.95815 5.5928 5.91406L3.09751 3.37713C3.05725 3.47295 3.02989 3.57118 3.01544 3.67181C3.00098 3.77237 2.99375 3.8779 2.99375 3.98842C2.99375 4.5537 3.18772 5.03355 3.57565 5.42795C3.96358 5.82235 4.43554 6.01955 4.99155 6.01955ZM4.99155 0.501058C6.02622 0.501058 6.97529 0.781293 7.83878 1.34176C8.70227 1.90223 9.38344 2.64201 9.88227 3.56109C9.91992 3.62436 9.94896 3.69224 9.96937 3.76473C9.98979 3.83721 10 3.91178 10 3.98842C10 4.06506 9.99106 4.13962 9.97317 4.21211C9.95529 4.28459 9.92748 4.35247 9.88975 4.41574C9.74086 4.70378 9.57442 4.97521 9.39042 5.23004C9.20634 5.48487 9.00057 5.72471 8.77312 5.94956C8.67136 6.05302 8.55074 6.10059 8.41125 6.09229C8.27184 6.08399 8.15121 6.02811 8.04937 5.92465L7.0223 4.88517C6.97142 4.83344 6.93781 4.77369 6.92147 4.70594C6.90514 4.63827 6.90608 4.56769 6.92429 4.49421C6.94749 4.40868 6.96411 4.32565 6.97415 4.24511C6.98428 4.16465 6.98934 4.07909 6.98934 3.98842C6.98934 3.42313 6.79538 2.94329 6.40745 2.54888C6.01952 2.15448 5.54755 1.95728 4.99155 1.95728C4.90236 1.95728 4.81898 1.96322 4.74139 1.97509C4.66381 1.98688 4.58447 2.00461 4.50337 2.02827C4.43117 2.04994 4.35894 2.05248 4.28666 2.03587C4.21446 2.01926 4.15293 1.9851 4.10205 1.93337L3.64584 1.46967C3.50643 1.32785 3.46012 1.16274 3.50692 0.974343C3.55372 0.785942 3.67136 0.665878 3.85985 0.614149C4.04516 0.575788 4.23174 0.547349 4.41958 0.528832C4.60742 0.510316 4.79807 0.501058 4.99155 0.501058ZM5.94046 3.04758C6.03977 3.15876 6.1216 3.28347 6.18596 3.42172C6.25032 3.55997 6.29066 3.70449 6.30699 3.85527C6.31516 3.91531 6.29303 3.95699 6.2406 3.98032C6.18817 4.00357 6.139 3.9919 6.0931 3.94532L5.04153 2.88093C4.99563 2.83427 4.98542 2.78428 5.0109 2.73097C5.0363 2.67767 5.07852 2.65101 5.13757 2.65101C5.29593 2.66438 5.44199 2.70478 5.57577 2.7722C5.70962 2.83954 5.83119 2.93133 5.94046 3.04758Z" fill="#2F80ED"/>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@@ -14,7 +14,7 @@ import 'package:go_router/go_router.dart';
class AppRouter {
static GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
// Домены для deep links
// Домены для deep links (в т.ч. ссылки из пушей dev2.eatvio.com)
static const List<String> _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}) {

View File

@@ -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>(() => MyDietsCubit(myDietsRepository: sl(), createDietRepository: sl()));
sl.registerFactory<MyPlansCubit>(() => MyPlansCubit(myPlansRepository: sl()));
sl.registerFactory<MyRecipesCubit>(() => MyRecipesCubit(myRecipesRepository: sl(), aboutRecipeRepository: sl()));
sl.registerFactory<MyRecipesCubit>(() => MyRecipesCubit(myRecipesRepository: sl(), aboutRecipeRepository: sl(), recipeRepository: sl()));
// Other feature cubits
sl.registerFactory<CalendarCubit>(() => CalendarCubit(calendarRepository: sl()));

View File

@@ -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<Dio>().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(),
},

View File

@@ -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<void> 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);
}
}

View File

@@ -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<RouteBase> 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()),

View File

@@ -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<String, dynamic>;
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<String, dynamic>;
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<String, dynamic>;
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<String, dynamic>?;
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<String, dynamic>;
return BlogPostScreen(id: data['id'] as int, highlightedCommentId: data['commentId'] as int?);
},
),
];

View File

@@ -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,
),
);
}
}

View File

@@ -71,16 +71,16 @@ class OtherRoutes {
try {
final profileRepository = sl<ProfileRepository>();
final profile = await profileRepository.getMyProfile();
final firebaseApi = sl<FirebaseApi>();
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<String, dynamic>) {
final data = state.extra as Map<String, dynamic>;
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<String, dynamic>?;
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<String, dynamic>;
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<String, dynamic>;
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<String, int>;
final diaryId = data['diaryId'] as int;
final currentPlanDay = data['currentDiaryDay'] as int;
return UserDiaryScreen(diaryId: diaryId, currentPlanDay: currentPlanDay);
return UserDiaryScreen(diaryId: diaryId);
},
),
GoRoute(

View File

@@ -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<void> _connectCubitsToStreams() async {
try {
await sl<ChatsCubit>().connectToWebSocket();
await sl<NotificationsCubit>().getNotifications();
await sl<NotificationsCubit>().connectToWebSocket();
} catch (e, st) {
log('WsManager._connectCubitsToStreams: $e', stackTrace: st);
await Sentry.captureException(e, stackTrace: st);
}
}
Future<void> _initPrivateNotificationsChannel() async {
final token = await TokenStorage.getTokenFromStorage();
_privateNotificationChannel = client?.privateChannel(

View File

@@ -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)],
),
],
),

View File

@@ -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),
],
);
}

View File

@@ -36,9 +36,29 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
final emailErrors = errors?['email'] as List<dynamic>?;
final phoneErrors = errors?['phone'] as List<dynamic>?;
if ((emailErrors != null && emailErrors.isNotEmpty) || (phoneErrors != null && phoneErrors.isNotEmpty)) {
// Это ошибка валидации - нужно показать экран подтверждения
emit(const OAuthState.error(errorMessage: 'validation_error'));
final errorsList = <RegistrationValidationErrors>[];
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<OAuthState> 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<OAuthState> with RegistrationDateCacheMixin {
}
}
enum RegistrationValidationErrors { incorrectEmailFormat, emailExist, phoneExist, incorrectDateOfBirthFormat }

View File

@@ -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>? registrationValidationErrors,
}) = OAuthStateError;
}
enum RegistrationValidationErrors { incorrectEmailFormat, emailExist, phoneExist, incorrectDateOfBirthFormat }

View File

@@ -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<AuthScreen> {
try {
await sl<ProfileAvatarCubit>().getMyProfile();
await sl<ChatsCubit>().getChatRoomsWithLoading();
await sl<ChatsCubit>().connectToWebSocket();
await sl<NotificationsCubit>().getNotifications();
await sl<NotificationsCubit>().connectToWebSocket();
await sl<FirebaseApi>().registerPushToken();
showSuccessToast('Успешный вход');
} catch (e) {
@@ -213,9 +209,6 @@ class _AuthScreenState extends State<AuthScreen> {
await sl<WsManager>().initialize();
await sl<ProfileAvatarCubit>().getMyProfile();
await sl<ChatsCubit>().getChatRoomsWithLoading();
await sl<ChatsCubit>().connectToWebSocket();
await sl<NotificationsCubit>().getNotifications();
await sl<NotificationsCubit>().connectToWebSocket();
await sl<FirebaseApi>().registerPushToken();
showSuccessToast('Успешный вход');
} catch (e) {

View File

@@ -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<RegisterViaSocialScreen> {
try {
await sl<ProfileAvatarCubit>().getMyProfile();
await sl<ChatsCubit>().getChatRoomsWithLoading();
await sl<ChatsCubit>().connectToWebSocket();
await sl<NotificationsCubit>().getNotifications();
await sl<NotificationsCubit>().connectToWebSocket();
await sl<FirebaseApi>().registerPushToken();
showSuccessToast('Успешная регистрация');
} catch (e) {

View File

@@ -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<ScanQRCodeScreen> {
try {
await sl<ProfileAvatarCubit>().getMyProfile();
await sl<ChatsCubit>().getChatRoomsWithLoading();
await sl<ChatsCubit>().connectToWebSocket();
await sl<NotificationsCubit>().getNotifications();
await sl<NotificationsCubit>().connectToWebSocket();
await sl<FirebaseApi>().registerPushToken();
} catch (e) {
rethrow;

View File

@@ -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<BlogPostScreen> createState() => _BlogPostScreenState();
@@ -303,7 +304,13 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
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,
),
],
),
),

View File

@@ -568,6 +568,7 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
CustomTextFormField(key: AppKeys.blogPostCreateScreenVideoLinkTextField, controller: videoUrlController),
const SizedBox(height: 80),
_createBlogPostButton(),
const SizedBox(height: 80),
],
),
),

View File

@@ -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<BottomNavScope>()?.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;
}

View File

@@ -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<BottomNavigationBarWidget> createState() => _BottomNavigationBarWidgetState();
}
@@ -35,24 +40,9 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
List<String> _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<BottomNavigationBarWidget> {
}
}
/// 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<AppConfig>().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<BottomNavigationBarWidget> {
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<BottomNavigationBarWidget> {
selectedFontSize: 10,
unselectedFontSize: 10,
selectedItemColor: appTheme.blueA400,
currentIndex: currentBottomNavBarIndex,
currentIndex: effectiveIndex,
items: [
BottomNavigationBarItem(
label: '',
@@ -117,52 +184,26 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
),
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<BottomNavigationBarWidget> {
'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<ChatsCubit, ChatsState>(
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<ChatsCubit, ChatsState>(
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<NotificationsCubit, NotificationsState>(
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<NotificationsCubit, NotificationsState>(
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);
},
);
}

View File

@@ -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<ChatsScreen> createState() => _ChatsScreenState();
@@ -23,12 +27,17 @@ class _ChatsScreenState extends State<ChatsScreen> {
@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<ChatsCubit>();
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);
}
});
}

View File

@@ -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(

View File

@@ -29,7 +29,7 @@ class _ChosePlanDateWidget extends State<ChosePlanDateWidget> {
if (widget.hasPersonalDiet) {
startDate = [DateTime.now().add(const Duration(days: 2))];
} else {
startDate = [DateTime.now()];
startDate = [DateTime.now().add(const Duration(days: 1))];
}
}

View File

@@ -36,7 +36,7 @@ class _NotificationBannerState extends State<NotificationBanner> {
final s = S.of(context);
return Container(
decoration: BoxDecoration(
color: appTheme.gray50,
color: const Color(0xFFFFFDD8),
borderRadius: BorderRadius.circular(20),
),
child: Column(

View File

@@ -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;
}

View File

@@ -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<RegisterConsultantState> {
consultant: ConsultantProfileModel(socialLinks: SocialLinksModel(), hasMedicalEducation: true),
loading: true,
acceptOffer: false,
isUpdating: false,
),
);
Future<void> getMyRequestStatus(String slug) async {
_update = true;
final myRequestStatus = await consultantRepository.getMyRequestStatus(slug);
final certPhotos = await _fetchVisibleCertPhotos();
final certPhotos = await consultantRepository.getConsultantCertPhotos();
List<CertificatesModel>? certificates = myRequestStatus.educationModel.completedCourses?.map((courseMap) {
final course = courseMap.values.first;
@@ -51,12 +51,13 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
loading: false,
requestModel: myRequestStatus,
certPhotos: certPhotos,
isUpdating: _update
),
);
}
Future<void> 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<RegisterConsultantState> {
}
Future<void> 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<void> uploadCertPhotos(List<File> files) async {
Future<void> addCertPhotos(List<File> 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<void> removeCertPhoto(int id) async {
await consultantRepository.hideConsultantCertPhoto(id);
emit(state.copyWith(certPhotos: state.certPhotos.where((photo) => photo.id != id).toList()));
}
Future<List<ConsultantCertPhotoModel>> _fetchVisibleCertPhotos() async {
try {
final photos = await consultantRepository.getConsultantCertPhotos();
return _onlyVisible(photos);
} catch (e) {
return [];
Future<void> 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<ConsultantCertPhotoModel> _onlyVisible(List<ConsultantCertPhotoModel> photos) {
return photos.where((photo) => !photo.isHidden).toList();
}
}

View File

@@ -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(<ConsultantCertPhotoModel>[]) List<ConsultantCertPhotoModel> certPhotos,
@Default(<File>[]) List<File> newAddedPhotos,
@Default(<int>[]) List<int> deletedPhotoIds,
}) = _RegisterConsultantState;
}

View File

@@ -33,7 +33,9 @@ class _PhotoOrScansScreenState extends State<PhotoOrScansScreen> {
const SizedBox(height: 16),
BlocBuilder<RegisterConsultantCubit, RegisterConsultantState>(
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<PhotoOrScansScreen> {
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<PhotoOrScansScreen> {
),
);
} 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<PhotoOrScansScreen> {
});
try {
// ignore: use_build_context_synchronously
await context.read<RegisterConsultantCubit>().uploadCertPhotos(newImages.map((file) => File(file.path)).toList());
await context.read<RegisterConsultantCubit>().addCertPhotos(newImages.map((file) => File(file.path)).toList());
} finally {
if (mounted) {
setState(() {
@@ -92,8 +109,8 @@ class _PhotoOrScansScreenState extends State<PhotoOrScansScreen> {
}
}
Future<void> _removePhoto(int id) async {
await context.read<RegisterConsultantCubit>().removeCertPhoto(id);
Future<void> _removePhoto({int? id, String? path}) async {
await context.read<RegisterConsultantCubit>().removeCertPhoto(id, path);
}
}
@@ -147,3 +164,61 @@ class _CertPhotoCard extends StatelessWidget {
);
}
}
class _LocalCertPhotoCard extends StatelessWidget {
final File photo;
final List<File> 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),
),
),
),
],
),
),
),
);
}
}

View File

@@ -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<StepperBecomeConsultantScreen> createState() => _StepperBecomeConsultantScreenState();
@@ -63,25 +62,17 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
highlightElevation: 0,
focusElevation: 0,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
backgroundColor: const Color(0xFFEAF2FD),
onPressed: () async {
setState(() {
pageController.previousPage(
duration: const Duration(milliseconds: 350),
curve: Curves.easeInOut,
);
pageController.previousPage(duration: const Duration(milliseconds: 350), curve: Curves.easeInOut);
});
},
label: Icon(
Icons.arrow_back_ios_new_rounded,
color: appTheme.blueA400,
),
label: Icon(Icons.arrow_back_ios_new_rounded, color: appTheme.blueA400),
),
),
if (state.requestModel?.educationModel.requestState == ModerationStatus.canceled)
if (widget.isCanceled == true)
InkWell(
onTap: () {
showDialog(
@@ -94,15 +85,10 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
children: [
for (int i = 0; i < state.requestModel!.canceledModeration!.additional!.length; i++) ...[
Text('• ${state.requestModel!.canceledModeration!.additional![i]}'),
]
],
],
),
actions: [
TextButton(
onPressed: () => 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<StepperBecomeConsultantS
child: Ink(
height: 48,
width: MediaQuery.of(context).size.width * 0.6,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: const Color(0x33FFCC00),
),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: const Color(0x33FFCC00)),
child: const Center(
child: Text(
'Комментарий',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Color(0xFF323F4B),
height: 1,
),
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF323F4B), height: 1),
),
),
),
),
Container(
height: 48,
width: state.requestModel?.educationModel.requestState == ModerationStatus.canceled
width: widget.isCanceled == true
? MediaQuery.of(context).size.width * 0.2
: MediaQuery.of(context).size.width * 0.8,
padding: const EdgeInsets.only(left: 4, right: 16),
@@ -138,18 +116,19 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
highlightElevation: 0,
elevation: 0,
focusElevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
backgroundColor: isLoading
? Colors.grey
: state.requestModel?.educationModel.requestState == ModerationStatus.canceled
? const Color(0xFFEAF2FD)
: appTheme.blueA400,
: widget.isCanceled == true
? const Color(0xFFEAF2FD)
: appTheme.blueA400,
onPressed: isLoading
? null
: () async {
if (step == 6) {
if (widget.isCanceled == true) {
return;
}
if (registrationConsultantCubit.state.acceptOffer) {
setState(() {
isLoading = true;
@@ -170,27 +149,15 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
const Text(
'Спасибо!\nВаши данные отправлены на модерацию',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 20,
height: 1,
fontWeight: FontWeight.w600,
),
style: TextStyle(fontSize: 20, height: 1, fontWeight: FontWeight.w600),
),
const SizedBox(height: 16),
Lottie.asset(
'assets/lottie_animations/success.json',
width: 160,
height: 160,
),
Lottie.asset('assets/lottie_animations/success.json', width: 160, height: 160),
const SizedBox(height: 16),
const Text(
'В течении трех рабочих дней Вам придет уведомление о смене статуса',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
height: 1,
fontWeight: FontWeight.w500,
),
style: TextStyle(fontSize: 16, height: 1, fontWeight: FontWeight.w500),
),
const SizedBox(height: 16),
InkWell(
@@ -201,18 +168,11 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
child: Ink(
width: MediaQuery.of(context).size.width - 32,
height: 52,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: appTheme.blueA400,
),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
child: const Center(
child: Text(
'Закрыть',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w600,
),
style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600),
),
),
),
@@ -263,24 +223,15 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
} else {
FocusManager.instance.primaryFocus?.unfocus();
setState(() {
pageController.nextPage(
duration: const Duration(milliseconds: 350),
curve: Curves.easeInOut,
);
pageController.nextPage(duration: const Duration(milliseconds: 350), curve: Curves.easeInOut);
});
}
},
label: isLoading
? const CircularProgressIndicator()
: state.requestModel?.educationModel.requestState == ModerationStatus.canceled
? Icon(
Icons.arrow_forward_ios_outlined,
color: appTheme.blueA400,
)
: Text(
'Продолжить',
style: theme.textTheme.titleSmall!.copyWith(color: Colors.white),
),
: widget.isCanceled == true
? Icon(Icons.arrow_forward_ios_outlined, color: appTheme.blueA400)
: Text('Продолжить', style: theme.textTheme.titleSmall!.copyWith(color: Colors.white)),
),
),
],
@@ -288,23 +239,11 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
appBar: AppBar(
automaticallyImplyLeading: false,
leading: Builder(
builder: (context) => 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<StepperBecomeConsultantS
child: TweenAnimationBuilder<double>(
duration: const Duration(milliseconds: 350),
curve: Curves.easeInOut,
tween: Tween<double>(
begin: 1 / 6,
end: progressValue,
),
tween: Tween<double>(begin: 1 / 6, end: progressValue),
builder: (context, value, _) => LinearProgressIndicator(
value: value,
borderRadius: BorderRadius.circular(16),
@@ -347,9 +283,7 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
),
);
} else {
return const Scaffold(
body: Loader(),
);
return const Scaffold(body: Loader());
}
},
),

View File

@@ -90,4 +90,13 @@ class ConsultantRepository {
rethrow;
}
}
Future<void> deletePhoto(int id) async {
try {
await consultantService.deletePhoto(id: id);
} catch (e, s) {
await Sentry.captureException(e, stackTrace: s);
rethrow;
}
}
}

View File

@@ -40,4 +40,7 @@ abstract class ConsultantService {
@POST('/api/v2/consultant_request/cert_photos/un_hide')
Future<void> unHideConsultantCertPhoto({@Body() required Map<String, dynamic> payload});
@POST('/api/v2/consultant_request/cert_photos/{id}/queue-for-deletion')
Future<void> deletePhoto({@Path() required int id});
}

View File

@@ -172,8 +172,8 @@ class _ConsultationCardWidgetState extends State<ConsultationCardWidget> {
);
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<ConsultationCardWidget> {
.where((e) => e.$2.state == DiaryStateEnum.finished || e.$2.state == DiaryStateEnum.lastDayFinished)
.lastOrNull;
if (lastActiveDay != null) {
GoRouter.of(context).push(UserDiaryScreen.path, extra: {'diaryId': lastActiveDay.$2.id, 'currentDiaryDay': lastActiveDay.$1});
GoRouter.of(context).push(UserDiaryScreen.path, extra: {'diaryId': lastActiveDay.$2.id,});
return;
}
showErrorDialog(

View File

@@ -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<CreateDietCubit>()..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<CreateDietScreenContent> createState() => _CreateDietScreenContentState();
}
@@ -58,6 +58,34 @@ class _CreateDietScreenContentState extends State<CreateDietScreenContent> {
final GlobalKey<MarkdownEditorState> _mdKey = GlobalKey<MarkdownEditorState>();
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<CreateDietScreenContent> {
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<CreateDietScreenContent> {
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<CreateDietScreenContent> {
),
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<CreateDietScreenContent> {
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<CreateDietCubit>().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<CreateDietCubit>().setDietState(DietState.active);
} else {
context.read<CreateDietCubit>().setDietState(DietState.draft);
}
final result = await context.read<CreateDietCubit>().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<CreateDietCubit>().setDietState(DietState.draft);
}
final result = await context.read<CreateDietCubit>().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<CreateDietCubit>().getDietForPreview();
if (diet == null) {
if (!context.mounted) return;
_scrollToFirstError(context.read<CreateDietCubit>().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<CreateDietCubit>().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<CreateDietCubit>().createDiet();
if (result == 'validation') {
if (!context.mounted) return;
_scrollToFirstError(context.read<CreateDietCubit>().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<CreateDietCubit>().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),
],

View File

@@ -32,7 +32,7 @@ class CreateDietCubit extends Cubit<CreateDietState> {
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<List<List<DailySchedule>>> dailySchedule = List.generate(countOfDays, (index) => [[], [], [], [], []]);
for (final dayEntry in dailyScheduleWithNutrition.entries) {
@@ -49,6 +49,12 @@ class CreateDietCubit extends Cubit<CreateDietState> {
type['carb'] = diet.type.carb ?? false;
type['light'] = diet.type.light ?? false;
final appConfig = sl<AppConfig>();
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<CreateDietState> {
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<CreateDietState> {
}
}
static const int _maxImageBytesForBase64 = 5 * 1024 * 1024;
Future<Diet?> getDietForPreview() async {
if (!_isFromValid()) return null;
if (!_isFormValidForDraft()) return null;
final authorProfile = await profileRepository.getMyProfile();
final imageBytes = (await state.dietData.image?.readAsBytes()) ?? <int>[];
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<double>(0, (prev, element) {
return prev +
@@ -259,7 +272,7 @@ class CreateDietCubit extends Cubit<CreateDietState> {
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<CreateDietState> {
Future<String?> 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<CreateDietState> {
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<CreateDietState> {
}
Future<String?> 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<CreateDietState> {
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<CreateDietState> {
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;
}

View File

@@ -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)),
);
}

View File

@@ -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<int> 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<DietPreviewScreen> {
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<DietPreviewScreen> {
),
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<DietPreviewScreen> {
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<AppConfig>().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<DietPreviewScreen> {
},
itemCount: widget.diet.dailySchedule.length,
),
const MainBackgroundBouble(
padding: EdgeInsets.symmetric(vertical: 16),
child: Column(
children: [
SizedBox(height: 16),
],
),
),
],
),
),

View File

@@ -185,7 +185,7 @@ class _AddMealWidgetState extends State<AddMealWidget> {
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<AddMealWidget> {
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) {

View File

@@ -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<List<List<DailySchedule>>> dailySchedule,
required String? meatType,
required String? carbType,
required String? vegType,
required String? lightType,
required String? details,
required File? image,
required List<List<List<DailySchedule>>>? dailySchedule,
}) async {
final dietMap = <String, dynamic>{};
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<String, dynamic>).map((key, value) {
return MapEntry(int.parse(key), DailySchedule.fromJson(value as Map<String, dynamic>));
final portionMap = Map<String, dynamic>.from(value as Map<String, dynamic>);
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<void> updateDiet({
Future<int> 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;

View File

@@ -28,21 +28,21 @@ abstract class CreateDietService {
@POST('/api/diets/update/published/{diet}')
@MultiPart()
Future<void> updatePublished({
Future<HttpResponse> updatePublished({
@Path() required int diet,
@Body() required FormData formData,
});
@POST('/api/diets/update/drafted/{diet}')
@MultiPart()
Future<void> updateDrafted({
Future<HttpResponse> updateDrafted({
@Path() required int diet,
@Body() required FormData formData,
});
@POST('/api/diets/update/personalized/{diet}')
@MultiPart()
Future<void> updatePersonal({
Future<HttpResponse> updatePersonal({
@Path() required int diet,
@Body() required FormData formData,
});

View File

@@ -172,6 +172,38 @@ class CreateRecipeCubit extends Cubit<CreateRecipeState> {
}
}
Future<bool> 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<File> downloadImageAsFile(String imageUrl) async {
final response = await http.get(Uri.parse(imageUrl));
@@ -239,6 +271,7 @@ class CreateRecipeCubit extends Cubit<CreateRecipeState> {
final isActiveCookTimeValid = validateActiveCookTime();
final isEnergyPerServingsValid = validateEnergyPerServing();
final isIngredientsValid = validateIngredients();
final isStepsValid = validateSteps();
return isTitleValid &&
@@ -248,8 +281,19 @@ class CreateRecipeCubit extends Cubit<CreateRecipeState> {
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<CreateRecipeState> {
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;

View File

@@ -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;
}

View File

@@ -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<RecipeCreateScreen> {
createRecipeCubit = sl<CreateRecipeCubit>();
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<RecipeCreateScreen> {
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<CreateRecipeCubit>().validateAll()) {
final isSuccess = await context.read<CreateRecipeCubit>().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<CreateRecipeCubit>().validateAll()) {
final isSuccess = await context.read<CreateRecipeCubit>().publishRecipe(
widget.recipe == null ? false : true,
widget.recipe?.slug,
);
if (context.read<CreateRecipeCubit>().validateForDraft()) {
final isSuccess = await context.read<CreateRecipeCubit>().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),
),
),
),
);
),
],
);
},
);
},

View File

@@ -182,7 +182,7 @@ class _AddPortionWidgetState extends State<AddPortionWidget> {
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<AddPortionWidget> {
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) {

View File

@@ -85,6 +85,14 @@ class _IngredientsWidgetState extends State<IngredientsWidget> {
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),
),
),
],
),
),

View File

@@ -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<void> 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<List> _buildStepsMap(List<Step?>? 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<FormData> _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<Map<String, dynamic>>()
.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<FormData> _buildDraftFormData(RecipeModel recipe) async {
final ingredientsMap = recipe.ingredients
?.map((i) => i?.toJson())
.whereType<Map<String, dynamic>>()
.toList() ??
[];
final stepsMap = await _buildStepsMap(recipe.steps);
final formMap = <String, dynamic>{
'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<void> 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<void> 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<void> draftExistingRecipe(int id) async {
try {
await recipeService.draftRecipe(id: id);
} catch (e, s) {
await Sentry.captureException(e, stackTrace: s);
rethrow;
}
}
Future<RecipeModel> getRecipe(String recipeID) async {
try {
return await recipeService.getRecipe(recipeID);

View File

@@ -33,7 +33,7 @@ abstract class CreateRecipeService {
@POST('/api/recipes/create')
@MultiPart()
Future<void> publishRecipe({
Future<HttpResponse<dynamic>> publishRecipe({
@Body() required FormData formData,
});
@@ -47,8 +47,19 @@ abstract class CreateRecipeService {
@POST('/api/recipes/update/{slug}')
@MultiPart()
Future<void> updateRecipe({
Future<HttpResponse<dynamic>> updateRecipe({
@Path() required String slug,
@Body() required FormData formData,
});
@POST('/api/recipes/draft/{id}')
Future<void> draftRecipe({
@Path() required int id,
});
@POST('/api/recipes/create/drafted')
@MultiPart()
Future<HttpResponse<dynamic>> createDraftedRecipe({
@Body() required FormData formData,
});
}

View File

@@ -47,6 +47,12 @@ class DiaryCubit extends Cubit<DiaryState> {
}
String comment = '';
bool commentNeedsToBeFilled = false;
void setInitialDate(DateTime? date) {
if (date == null) return;
selectedDay = date;
}
Future<bool> setSelectedDay(DateTime day) async {
final prevDay = selectedDay;
@@ -77,6 +83,7 @@ class DiaryCubit extends Cubit<DiaryState> {
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<DiaryState> {
}
}
Future<void> 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<bool> 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<NutritionDataModel> getNutrition(int index) async {
@@ -416,6 +429,7 @@ class DiaryCubit extends Cubit<DiaryState> {
void fillComment(String comment) {
this.comment = comment;
commentNeedsToBeFilled = comment.isNotEmpty && comment.length < 3;
}
}

View File

@@ -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<DiaryScreenContent> createState() => _DiaryScreenContentState();
}
@@ -61,7 +60,11 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
final s = S.of(context);
return MultiBlocProvider(
providers: [
BlocProvider<DiaryCubit>(create: (BuildContext context) => sl<DiaryCubit>()..fetchDiaryV2(needLoading: true, diaryId: widget.diaryId)),
BlocProvider<DiaryCubit>(
create: (BuildContext context) => sl<DiaryCubit>()
..setInitialDate(widget.date)
..fetchDiaryV2(needLoading: true, diaryId: widget.diaryId),
),
BlocProvider(create: (context) => sl<ConsultantCommentsCubit>()),
],
child: BlocBuilder<DiaryCubit, DiaryState>(
@@ -91,7 +94,7 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
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<DiaryScreenContent> {
setState(() {
_isDiaryFinishing = true;
});
await context.read<DiaryCubit>().finishDiaryDay(
final isFinished = await context.read<DiaryCubit>().finishDiaryDay(
diaryId: state.diary.id,
wakeUp: state.diary.wakeup.toString(),
retire: state.diary.retire.toString(),
);
if (!isFinished && context.mounted) {
showErrorDialog(context);
}
setState(() {
_isDiaryFinishing = false;
});

View File

@@ -132,7 +132,12 @@ class _AddProductModalState extends State<AddProductModal> {
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<AddProductModal> {
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];
}

View File

@@ -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<CalendarWidget> createState() => _CalendarWidgetState();
@@ -18,6 +19,23 @@ class _CalendarWidgetState extends State<CalendarWidget> {
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) {

View File

@@ -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<CommentForConsultantWidget> {
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<CommentForConsultantWidget>
CustomTextFormField(
controller: controller,
onChanged: (value) {
EasyDebounce.debounce('client-typing', const Duration(milliseconds: 500), () => context.read<DiaryCubit>().fillComment(value));
EasyDebounce.debounce(
'client-typing',
const Duration(milliseconds: 500),
() => context.read<DiaryCubit>().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 символа'),
],
],
),
),

View File

@@ -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<void> Function() onPressed;
@@ -18,6 +20,14 @@ class FinishDayButtonWidget extends StatefulWidget {
class _FinishDayButtonWidgetState extends State<FinishDayButtonWidget> {
void _onTap() {
if (context.read<DiaryCubit>().commentNeedsToBeFilled) {
showErrorDialog(
context,
title: 'Комментарий слишком короткий',
description: 'Минимум 3 символа в комментарии для консультанта.',
);
return;
}
if (!widget.hasProducts) {
showErrorDialog(
context,

View File

@@ -22,6 +22,11 @@ class PortionCommentWidget extends StatefulWidget {
class _PortionCommentWidgetState extends State<PortionCommentWidget> {
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<PortionCommentWidget> {
hintText: s.typePortionComment,
hasError: hasError,
onChanged: (value) {
final cubit = context.read<ConsultantCommentsCubit>();
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<ConsultantCommentsCubit>().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<ConsultantCommentsCubit>().addPortionComment(PortionCommentModel(id: widget.portionId, comment: value));
cubit.addPortionComment(PortionCommentModel(id: widget.portionId, comment: value));
},
);
},

View File

@@ -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<AboutDietScreen> createState() => _AboutDietScreenState();
@@ -433,6 +434,7 @@ class _AboutDietScreenState extends State<AboutDietScreen> {
commentSourceType: CommentSourceType.diet,
comment: state.comment,
replyId: replyId,
highlightedCommentId: widget.highlightedCommentId,
),
],
),

View File

@@ -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<int, Map<int, Map<int, DailySchedule>>> dailySchedule,
@JsonKey(name: "type", fromJson: parseDietTypeFromJson) required DietType type,
@JsonKey(name: "description_md", defaultValue: '') required String details,
@JsonKey(name: "daily_schedule", defaultValue: {}) required Map<int, Map<int, Map<int, DailySchedule>>> 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<String, dynamic> json) => _$DietFromJson(json);
}
DietType parseDietTypeFromJson(Map<String, dynamic>? 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<String, dynamic> 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<String, dynamic> 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

View File

@@ -248,7 +248,7 @@ class _FeedScreenContentState extends State<FeedScreenContent> {
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,
),

View File

@@ -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<CommentsWidget> createState() => _CommentsWidgetState();
}
class _CommentsWidgetState extends State<CommentsWidget> {
final Map<int, GlobalKey> 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<CommentsWidget> {
}
Widget _comment(S s, Comment comment, [double radius = 18, bool isChild = false]) {
final key = commentKeys.putIfAbsent(comment.id, () => GlobalKey());
final isMe = (sl<ProfileAvatarCubit>().state as ProfileAvatarStateLoaded).profile.slug == comment.authorSlug;
return GestureDetector(
key: key,
onTap: () {
showCommentsActionsBottomSheet(
context,
@@ -83,7 +131,7 @@ class _CommentsWidgetState extends State<CommentsWidget> {
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(

View File

@@ -291,16 +291,16 @@ class MyDietsCubit extends Cubit<MyDietsState> {
}
}
Future<bool> publishDiet(int dietId) async {
Future<int> 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;
}
}

View File

@@ -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<MyDietsCubit>().loadCurrentDiets(query: query == '' ? null : query);
},
);
EasyDebounce.debounce('searchDiets', const Duration(milliseconds: 500), () {
context.read<MyDietsCubit>().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<MyDietsCubit, MyDietsState>(
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<MyDietsCubit>().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<MyDietsCubit>().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<MyDietsCubit>(),
),
),
child: FilterBadge(filterBottomSheet: DietFilterBottomSheet(myDietsCubit: context.read<MyDietsCubit>())),
),
],
);

View File

@@ -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<MyDietWidget> {
Future<void> _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<MyDietsCubit>().loadAllDiets();
}
void _suggestDiet(Diet diet) {
GoRouter.of(context).pushNamed(PersonalDietSuggestionScreen.name, extra: diet.id);
}
Future<void> _shareDiet(int dietId) async {
await SharePlus.instance.share(ShareParams(uri: Uri.parse('${sl<AppConfig>().baseUrl}/ru/diet/$dietId')));
}
Future<void> _publishDiet(Diet diet, BuildContext context) async {
final statusCode = await context.read<MyDietsCubit>().publishDiet(widget.diet.id);
switch (statusCode) {
case 201:
if (!context.mounted) return;
showBottomSheetWithBlur(
context: context,
child: SuccessPublishedDietOrPlanBottomSheet(title: widget.diet.name),
);
await context.read<MyDietsCubit>().loadAllDiets();
break;
case 422:
showErrorToast('Пожалуйста, заполните все обязательные поля для публикации');
break;
default:
showErrorToast('Произошла ошибка. Попробуйте ещё раз через некоторое время');
}
}
void _unpublishDiet(Diet diet, BuildContext context) async {
final result = await context.read<MyDietsCubit>().unpublishDiet(widget.diet.id);
if (result) {
if (!context.mounted) return;
showBottomSheetWithBlur(
context: context,
height: 250,
child: DietDraftedBottomSheet(title: widget.diet.name),
);
await context.read<MyDietsCubit>().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<MyDietsCubit>().deleteDiet(widget.diet.id);
if (result) {
if (!context.mounted) return;
showBottomSheetWithBlur(
context: context,
child: DietDeletedBottomSheet(title: widget.diet.name),
);
await context.read<MyDietsCubit>().loadAllDiets();
}
}
@override
Widget build(BuildContext context) {
final s = S.of(context);
@@ -42,28 +112,34 @@ class _MyDietWidgetState extends State<MyDietWidget> {
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<AppConfig>().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<AppConfig>().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<MyDietWidget> {
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<MyDietWidget> {
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
children: [
// Верхний ряд: Удалить + Редактировать (или + Поделиться для опубликованных)
Row(
children: [
// Кнопка удаления (слева)
CustomOutlinedButton(
onPressed: () async {
final result = await context.read<MyDietsCubit>().deleteDiet(widget.diet.id);
if (result) {
if (!context.mounted) return;
showBottomSheetWithBlur(
context: context,
child: DietDeletedBottomSheet(title: widget.diet.name),
);
await context.read<MyDietsCubit>().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<AppConfig>().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<MyDietWidget> {
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<MyDietWidget> {
);
}
}

View File

@@ -21,6 +21,7 @@ class _MyDietsTabState extends State<MyDietsTab> {
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<MyDietsTab> {
Widget build(BuildContext context) {
return BlocBuilder<MyDietsCubit, MyDietsState>(
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<MyDietsCubit>().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<MyDietsCubit>().changeSelectedStateIndex(index);
},
),
),
),
],
),
),
],

File diff suppressed because it is too large Load Diff

View File

@@ -267,33 +267,113 @@ class NotificationsCubit extends Cubit<NotificationsState> {
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<String, dynamic>;
// Улучшенная логика определения типа уведомления
// Улучшенная логика определения типа уведомления (порядок: 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<String, dynamic>) {
final data = json['data'] as Map<String, dynamic>;
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) {

View File

@@ -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) {

View File

@@ -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<AboutPlanScreen> createState() => _AboutPlanScreenState();
}
@@ -468,13 +476,14 @@ class _AboutPlanScreenState extends State<AboutPlanScreen> {
),
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,
),
],
),
),

View File

@@ -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<ProfileState> {
try {
final profile = await profileRepository.getProfileBySlug(slug);
final myProfile = await profileRepository.getMyProfile();
final isMyProfile = myProfile.slug == profile.slug;
List<ConsultantCertPhotoModel> 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<ProfileState> {
_currentPeriod = period;
try {
final profile = await profileRepository.getMyProfile();
List<ConsultantCertPhotoModel> certPhotos = await consultantRepository.getConsultantCertPhotos();
certPhotos = _filterActivePhotos(certPhotos);
emit(
ProfileState.loaded(
profile: profile,
@@ -53,6 +63,7 @@ class ProfileCubit extends Cubit<ProfileState> {
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<ProfileState> {
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<ProfileState> {
await profileRepository.deletePhoto();
return await getMyProfile();
}
Future<void> hideCertPhoto(int id) async {
try {
await consultantRepository.hideConsultantCertPhoto(id);
List<ConsultantCertPhotoModel> 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<void> unhideCertPhoto(int id) async {
try {
await consultantRepository.unHideConsultantCertPhoto(id);
List<ConsultantCertPhotoModel> 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<ConsultantCertPhotoModel> _filterActivePhotos(List<ConsultantCertPhotoModel> photos) {
return photos.where((photo) => photo.isActive).toList();
}
}

View File

@@ -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<FeedBlogPostModel> blogPosts,
required bool isMyProfile,
required List<ConsultantCertPhotoModel> certPhotos,
List<WeightModel>? weightPoints,
List<CircumferenceModel>? circumferencePoints,
ConsultationRequestModel? consultantInfo,

View File

@@ -5,22 +5,30 @@ import 'package:flutter_bloc/flutter_bloc.dart';
class SubCubit extends Cubit<SubState> {
final ProfileRepository profileRepository;
SubCubit({
required this.profileRepository,
}) : super(const SubState());
SubCubit({required this.profileRepository}) : super(const SubState());
Future<void> getSubscriptions(String slug, [String? query]) async {
Future<void> 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<void> getSubscribers(String slug, [String? query]) async {
Future<void> 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()));

View File

@@ -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<ProfileScreen> {
),
);
}
return state.isMyProfile ? _myProfileScreen(state) : _profileScreen(state);
} else if (state is ProfileStateError) {
return Scaffold(
@@ -436,9 +437,16 @@ class _ProfileScreenState extends State<ProfileScreen> {
}
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<ProfileScreen> {
),
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<ProfileScreen> {
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<ProfileScreen> {
);
}
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<ProfileScreen> {
),
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 ? 'Редактировать' : '',

View File

@@ -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,

View File

@@ -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),
],
);
}
}

View File

@@ -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<String, Object?> 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<cr_step.Step?>(),
// 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(),
);
}
}

View File

@@ -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<MyRecipesState> {
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<void> getMyRecipes() async {
@@ -50,7 +58,47 @@ class MyRecipesCubit extends Cubit<MyRecipesState> {
}
}
Future<void> setDraftedState() async {
emit(const MyRecipesState.loading());
try {
final myRecipes = await myRecipesRepository.getMyRecipes();
final List<MyRecipeModel> draftedRecipes = myRecipes.where((recipe) => recipe.state == drafted).toList();
emit(MyRecipesState.loaded(recipes: draftedRecipes, selectedState: drafted));
} catch (e) {
emit(const MyRecipesState.error());
}
}
Future<Recipe> 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<bool> 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<bool> 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;
}
}
}

View File

@@ -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<AboutRecipeScreen> createState() => _AboutRecipeScreenState();
@@ -391,6 +392,7 @@ class _AboutRecipeScreenState extends State<AboutRecipeScreen> {
commentSourceType: CommentSourceType.recipe,
comment: state.comment,
replyId: replyId,
highlightedCommentId: widget.highlightedCommentId,
),
],
),

View File

@@ -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<MyRecipesCubit>()..getMyRecipes(),
child: BlocBuilder<MyRecipesCubit, MyRecipesState>(
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<MyRecipesCubit>().setActiveState();
} else {
context.read<MyRecipesCubit>().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,
),
),
),
],
),
),
],
);
},
),
),

View File

@@ -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<MyRecipeWidget> {
? '${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<MyRecipeWidget> {
return;
}
showSuccessToast('Рецепт удален');
if (context.mounted) context.read<MyRecipesCubit>().getMyRecipes();
if (context.mounted) {
if (isDraft) {
context.read<MyRecipesCubit>().setDraftedState();
} else {
context.read<MyRecipesCubit>().getMyRecipes();
}
}
},
text: 'Удалить',
height: 52,
@@ -140,30 +144,147 @@ class _MyRecipeWidgetState extends State<MyRecipeWidget> {
),
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<MyRecipesCubit>().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<int>(
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<AppConfig>().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<AppConfig>().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<MyRecipesCubit>().getRecipeById(widget.recipe.slug);
final createCubit = sl<CreateRecipeCubit>();
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<MyRecipesCubit>().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<MyRecipesCubit>().draftRecipe(widget.recipe.id);
if (result) {
showSuccessToast('Рецепт перенесен в черновики');
if (context.mounted) {
context.read<MyRecipesCubit>().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<MyRecipeWidget> {
);
}
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<MyRecipeWidget> {
);
}
}

View File

@@ -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<MyRecipesTab> createState() => _MyRecipesTabState();
}
class _MyRecipesTabState extends State<MyRecipesTab> {
final List<String> 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<int> 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<MyRecipesTab> {
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<MyPlansCubit>().changeSelectedStateIndex(index);
},
),
),
(index) => Expanded(
child: AnimatedOptionButton(
textStyle: TextStyles.textSMedium,
isSelected: selectedIndex == index,
text: tabTitles[index],
onTap: () => onTabChanged(index),
),
),
),
],
),

View File

@@ -254,12 +254,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
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<SettingsScreen> {
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,

View File

@@ -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(
"Верхние показатели давления:",
),

View File

@@ -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<S> {

View File

@@ -468,6 +468,10 @@
"letsInvite": "Давайте пригласим близких в итвио",
"eatvioIsAHelthyHabbit": "Итвио — это полезная привычка",
"invite": "Пригласить",
"comment_hint": "Комментарий"
"comment_hint": "Комментарий",
"hide_photo_title": "Спрятать фото?",
"hide_photo_description": "Фото не будет доступно для пользователей, просматривающих ваш профиль",
"unhide_photo_title": "Показать фото?",
"unhide_photo_description": "Фото будет доступно для пользователей, просматривающих ваш профиль"
}

View File

@@ -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<void> main() async {
await AppInitializer.runAppWithSentry(() => runApp(const MyApp()));
}
class MyApp extends StatelessWidget {
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
@override
void initState() {
super.initState();
PushNavigationHandler.initialize();
}
@override
Widget build(BuildContext context) {
return MultiBlocProvider(

View File

@@ -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:

View File

@@ -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');

View File

@@ -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<void>.value());
when(feedCubit.viewPost(any)).thenAnswer((_) async => Future<void>.value());
when(feedCubit.stream).thenAnswer((_) => const Stream.empty());
when(feedCubit.close()).thenAnswer((_) async => Future<void>.value());
when(commentsCubit.state).thenReturn(const CommentsState());
when(commentsCubit.stream).thenAnswer((_) => const Stream<CommentsState>.empty());
when(commentsCubit.close()).thenAnswer((_) async => Future<void>.value());
when(commentsCubit.getComments(any, any, any)).thenAnswer((_) async => Future<void>.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);
});
});
});
}

View File

@@ -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'),
),
),
),
),

View File

@@ -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(

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