forked from eatvio/eatvio_app
Compare commits
126 Commits
personaliz
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ca1caa4adb | |||
| 388607190f | |||
| 1c54063a35 | |||
| efae7fc958 | |||
|
|
90ac4e750d | ||
|
|
6d7419c283 | ||
| caf45a9050 | |||
|
|
0a5e7d95c4 | ||
|
|
85482173ad | ||
| 65f7992d40 | |||
|
|
52a01bee12 | ||
| 179fc0ad99 | |||
|
|
8494e61eec | ||
|
|
5c2458ecad | ||
| b63bab8bf5 | |||
| 7790014726 | |||
|
|
0d588bcd57 | ||
| b679e90ba2 | |||
|
|
6b5442eb87 | ||
| 2474c5bea3 | |||
|
|
478a53674a | ||
| 7f278d0f59 | |||
|
|
53426c858c | ||
| 88f0cde88f | |||
|
|
a5fcf62524 | ||
| a2e41a2ff5 | |||
|
|
5b1beafbab | ||
| 4eadecd6f2 | |||
|
|
6292db07a1 | ||
| ab29b6b329 | |||
|
|
af5d182a79 | ||
|
|
04de956769 | ||
| 191bb74aa4 | |||
|
|
3210f38d4a | ||
| 27a98a496d | |||
| d8ecc7f3b1 | |||
| 063da03d7e | |||
| 6b5927a99e | |||
| afab72f7bc | |||
| 2770ff0e80 | |||
| 1200021bb6 | |||
|
|
9e8b8b89fc | ||
| a24d052193 | |||
|
|
9de0efa28e | ||
| 37504aa292 | |||
| c8588a2e1f | |||
|
|
e0cab89126 | ||
|
|
954d4955da | ||
| e5086d7fbc | |||
| 0d93768dc3 | |||
|
|
8c5fc8d827 | ||
|
|
2d6b042d45 | ||
| d56aa45c92 | |||
|
|
b3d0a26667 | ||
| 8835d65944 | |||
|
|
cc9aa6bffb | ||
| a15e8b78a5 | |||
|
|
0667ab81a8 | ||
|
|
1dc60eec64 | ||
|
|
69f0f1e919 | ||
|
|
a6fe6afb80 | ||
|
|
c8c8ade083 | ||
|
|
037f153273 | ||
| 6a17bf3a95 | |||
|
|
2cccc9e253 | ||
| dcf7b25989 | |||
| 163f56ff61 | |||
|
|
5dc1f48fd1 | ||
|
|
5ec877ef72 | ||
|
|
dbaf639ac6 | ||
|
|
2a07ed596b | ||
|
|
219908684f | ||
|
|
9ce7b035ff | ||
|
|
c8ad600d97 | ||
|
|
f493e07f9f | ||
|
|
c0c7e2e658 | ||
|
|
f913ae60fa | ||
|
|
2100e5ca02 | ||
|
|
10b36117a6 | ||
|
|
bde4eea7d1 | ||
|
|
59f42c9b1e | ||
|
|
c5bf5acfe7 | ||
|
|
8ff826fa3e | ||
|
|
1e29cda65c | ||
|
|
ed72ed9799 | ||
|
|
8920fc280f | ||
|
|
7493870a3d | ||
|
|
e3c22f8ac8 | ||
|
|
ea5fced456 | ||
|
|
e3dd8162fa | ||
|
|
90e5c7e58d | ||
|
|
65d529b0af | ||
|
|
95c1d40c79 | ||
|
|
895443e747 | ||
|
|
367753d409 | ||
|
|
e82866f936 | ||
|
|
b13ff74220 | ||
|
|
e0d697e8d0 | ||
|
|
705538ac85 | ||
|
|
03f037518e | ||
|
|
a07565fca9 | ||
|
|
b9cb4eeb84 | ||
|
|
4d531036fa | ||
|
|
1cafb4b05a | ||
|
|
5daa187045 | ||
|
|
f4f884484d | ||
|
|
4dd32e1a42 | ||
|
|
3e89bb5ca9 | ||
|
|
885c6f66f9 | ||
|
|
9dc568ceab | ||
|
|
6fb151ef32 | ||
|
|
5fa024a7a8 | ||
|
|
de795c5274 | ||
|
|
27fe4afd81 | ||
|
|
8e2b7147d5 | ||
|
|
f51a68324b | ||
| 8a0d51add9 | |||
|
|
634d83fd61 | ||
|
|
9e9f2a9f45 | ||
|
|
a495f98f30 | ||
|
|
9f8f9a7aaa | ||
|
|
af1980930b | ||
|
|
1c262da81a | ||
|
|
8a617509de | ||
|
|
73cec1a01e | ||
|
|
1acb158f36 |
@@ -15,11 +15,12 @@ jobs:
|
|||||||
uses: https://github.com/actions/checkout@v2
|
uses: https://github.com/actions/checkout@v2
|
||||||
|
|
||||||
- name: Checkout env repository
|
- name: Checkout env repository
|
||||||
uses: https://github.com/actions/checkout@v2
|
uses: https://github.com/actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
repository: mso7nueh/eatvio_app_env
|
repository: eatvio/eatvio_app_env
|
||||||
path: env_repo
|
path: env_repo
|
||||||
token: ${{ vars.MSO7NUEH_TOKEN }}
|
# Здесь может быть токен любого юзера с доступом в gitea
|
||||||
|
token: ${{ vars.READ_USER_GITEA_TOKEN }}
|
||||||
ref: main
|
ref: main
|
||||||
|
|
||||||
- name: Copy .env file
|
- name: Copy .env file
|
||||||
@@ -57,23 +58,24 @@ jobs:
|
|||||||
|
|
||||||
- name: Flutter build
|
- name: Flutter build
|
||||||
run: |
|
run: |
|
||||||
flutter upgrade
|
flutter --version
|
||||||
|
flutter config --no-enable-swift-package-manager
|
||||||
|
rm -rf ios/Podfile.lock
|
||||||
flutter clean
|
flutter clean
|
||||||
flutter pub get
|
flutter pub get
|
||||||
flutter pub upgrade
|
|
||||||
flutter pub run build_runner build --delete-conflicting-outputs
|
flutter pub run build_runner build --delete-conflicting-outputs
|
||||||
# Configure Flutter for release mode
|
|
||||||
pod repo update
|
|
||||||
flutter build ios --config-only --release
|
|
||||||
cd ios
|
cd ios
|
||||||
pod update
|
pod install --repo-update
|
||||||
|
cd ..
|
||||||
|
flutter build ios --config-only --release
|
||||||
|
cd ios
|
||||||
fastlane beta
|
fastlane beta
|
||||||
env:
|
env:
|
||||||
LANG: en_US.UTF-8
|
LANG: en_US.UTF-8
|
||||||
LC_ALL: en_US.UTF-8
|
LC_ALL: en_US.UTF-8
|
||||||
MATCH_PASSWORD: ${{ vars.IOS_RUNNER_CI_PASSWORD }}
|
MATCH_PASSWORD: ${{ vars.IOS_RUNNER_CI_PASSWORD }}
|
||||||
FASTLANE_PASSWORD: ${{ vars.FASTLANE_PASSWORD }}
|
FASTLANE_PASSWORD: ${{ vars.FASTLANE_PASSWORD }}
|
||||||
MATCH_GIT_URL: http://gitea.office.local:3000/mso7nueh/fastlane
|
MATCH_GIT_URL: http://eatvio:${{ vars.READ_USER_GITEA_TOKEN }}@gitea.office.local:3000/eatvio/fastlane.git
|
||||||
FASTLANE_USER: eatvio2023@icloud.com
|
FASTLANE_USER: eatvio2023@icloud.com
|
||||||
FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD: ${{ vars.FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD }}
|
FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD: ${{ vars.FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD }}
|
||||||
API_APPSTORE_KEY: ${{ vars.API_APPSTORE_KEY }}
|
API_APPSTORE_KEY: ${{ vars.API_APPSTORE_KEY }}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
name: Eatvio App Android Test
|
name: Eatvio App Android Test
|
||||||
|
|
||||||
on: [pull_request]
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
env:
|
env:
|
||||||
flutter_version: '3.41.2'
|
flutter_version: '3.41.2'
|
||||||
@@ -163,66 +166,66 @@ jobs:
|
|||||||
- name: Test
|
- name: Test
|
||||||
run: flutter test --coverage
|
run: flutter test --coverage
|
||||||
|
|
||||||
- name: Test coverage
|
# - name: Test coverage
|
||||||
run: |
|
# run: |
|
||||||
echo "📊 Analyzing test coverage..."
|
# echo "📊 Analyzing test coverage..."
|
||||||
echo ""
|
# echo ""
|
||||||
awk -F: '
|
# awk -F: '
|
||||||
/^SF:/ {
|
# /^SF:/ {
|
||||||
file = $0
|
# file = $0
|
||||||
sub(/^SF:/, "", file)
|
# sub(/^SF:/, "", file)
|
||||||
sub(/^[[:space:]]+/, "", file)
|
# sub(/^[[:space:]]+/, "", file)
|
||||||
is_generated = 0
|
# is_generated = 0
|
||||||
if (file ~ /\.(g|freezed|gr|mocks)\.dart$/) {
|
# if (file ~ /\.(g|freezed|gr|mocks)\.dart$/) {
|
||||||
is_generated = 1
|
# is_generated = 1
|
||||||
generated_files++
|
# generated_files++
|
||||||
} else if (file ~ /\/(build|\.dart_tool)\//) {
|
# } else if (file ~ /\/(build|\.dart_tool)\//) {
|
||||||
is_generated = 1
|
# is_generated = 1
|
||||||
generated_files++
|
# generated_files++
|
||||||
} else {
|
# } else {
|
||||||
source_files++
|
# source_files++
|
||||||
}
|
# }
|
||||||
}
|
# }
|
||||||
/^LF:/ {
|
# /^LF:/ {
|
||||||
if (!is_generated) {
|
# if (!is_generated) {
|
||||||
total += $2
|
# total += $2
|
||||||
} else {
|
# } else {
|
||||||
generated_lines += $2
|
# generated_lines += $2
|
||||||
}
|
# }
|
||||||
}
|
# }
|
||||||
/^LH:/ {
|
# /^LH:/ {
|
||||||
if (!is_generated) {
|
# if (!is_generated) {
|
||||||
covered += $2
|
# covered += $2
|
||||||
} else {
|
# } else {
|
||||||
generated_covered += $2
|
# generated_covered += $2
|
||||||
}
|
# }
|
||||||
}
|
# }
|
||||||
END {
|
# END {
|
||||||
print "📁 Files:"
|
# print "📁 Files:"
|
||||||
printf " Source files: %d\n", source_files
|
# printf " Source files: %d\n", source_files
|
||||||
printf " Generated files: %d (excluded)\n", generated_files
|
# printf " Generated files: %d (excluded)\n", generated_files
|
||||||
print ""
|
# print ""
|
||||||
print "📝 Lines:"
|
# print "📝 Lines:"
|
||||||
printf " Total lines: %d\n", total
|
# printf " Total lines: %d\n", total
|
||||||
printf " Covered lines: %d\n", covered
|
# printf " Covered lines: %d\n", covered
|
||||||
printf " Uncovered lines: %d\n", total - covered
|
# printf " Uncovered lines: %d\n", total - covered
|
||||||
print ""
|
# print ""
|
||||||
if (total == 0) {
|
# if (total == 0) {
|
||||||
print "⚠️ Coverage: 0%"
|
# print "⚠️ Coverage: 0%"
|
||||||
exit 1
|
# exit 1
|
||||||
} else {
|
# } else {
|
||||||
percentage = (covered / total) * 100
|
# percentage = (covered / total) * 100
|
||||||
printf "✅ Coverage: %.2f%%\n", percentage
|
# printf "✅ Coverage: %.2f%%\n", percentage
|
||||||
print ""
|
# print ""
|
||||||
if (percentage < 70) {
|
# if (percentage < 70) {
|
||||||
printf "❌ FAIL: Coverage is below 70%%\n"
|
# printf "❌ FAIL: Coverage is below 70%%\n"
|
||||||
exit 1
|
# exit 1
|
||||||
} else {
|
# } else {
|
||||||
printf "💚 Excellent: Coverage is above 70%%\n"
|
# printf "💚 Excellent: Coverage is above 70%%\n"
|
||||||
}
|
# }
|
||||||
}
|
# }
|
||||||
}
|
# }
|
||||||
' coverage/lcov.info
|
# ' coverage/lcov.info
|
||||||
|
|
||||||
- name: Build apk
|
- name: Build apk
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -24,4 +24,5 @@ coverage/
|
|||||||
backend/
|
backend/
|
||||||
.fvm/
|
.fvm/
|
||||||
.fvmrc
|
.fvmrc
|
||||||
/eatvio
|
/eatvio
|
||||||
|
*.code-workspace
|
||||||
@@ -69,6 +69,8 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
implementation platform('com.google.firebase:firebase-bom:34.4.0')
|
||||||
|
implementation 'com.google.firebase:firebase-messaging'
|
||||||
implementation 'com.huawei.agconnect:agconnect-core:1.9.5.300'
|
implementation 'com.huawei.agconnect:agconnect-core:1.9.5.300'
|
||||||
implementation 'com.huawei.hms:push:6.13.0.301'
|
implementation 'com.huawei.hms:push:6.13.0.301'
|
||||||
// Huawei Analytics dependency to satisfy com.huawei.hianalytics.* references during R8
|
// Huawei Analytics dependency to satisfy com.huawei.hianalytics.* references during R8
|
||||||
|
|||||||
6
android/app/proguard-rules.pro
vendored
6
android/app/proguard-rules.pro
vendored
@@ -4,6 +4,12 @@
|
|||||||
-keep class org.bouncycastle.** { *; }
|
-keep class org.bouncycastle.** { *; }
|
||||||
-dontwarn org.bouncycastle.**
|
-dontwarn org.bouncycastle.**
|
||||||
|
|
||||||
|
-keep class com.hiennv.flutter_callkit_incoming.** { *; }
|
||||||
|
-dontwarn com.hiennv.flutter_callkit_incoming.**
|
||||||
|
|
||||||
|
-keep class com.pravera.flutter_foreground_task.** { *; }
|
||||||
|
-dontwarn com.pravera.flutter_foreground_task.**
|
||||||
|
|
||||||
-keep class io.flutter.** { *; }
|
-keep class io.flutter.** { *; }
|
||||||
-dontwarn io.flutter.**
|
-dontwarn io.flutter.**
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
|
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
|
||||||
@@ -13,6 +17,16 @@
|
|||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher">
|
||||||
|
<service
|
||||||
|
android:name=".EatvioFcmMessagingService"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
<service
|
||||||
|
android:name="io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingService"
|
||||||
|
tools:node="remove" />
|
||||||
<service
|
<service
|
||||||
android:name=".HmsPushService"
|
android:name=".HmsPushService"
|
||||||
android:exported="false">
|
android:exported="false">
|
||||||
@@ -33,7 +47,7 @@
|
|||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleTop"
|
||||||
android:taskAffinity=""
|
android:supportsPictureInPicture="true"
|
||||||
android:theme="@style/LaunchTheme"
|
android:theme="@style/LaunchTheme"
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
android:hardwareAccelerated="true"
|
android:hardwareAccelerated="true"
|
||||||
@@ -77,6 +91,68 @@
|
|||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
|
|
||||||
|
<!-- Tap on notification body opens CallkitIncomingActivity via PendingIntent.
|
||||||
|
Replace it with a bridge that routes to the same accept flow as Answer. -->
|
||||||
|
<activity
|
||||||
|
android:name="com.hiennv.flutter_callkit_incoming.CallkitIncomingActivity"
|
||||||
|
tools:node="remove" />
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".EatvioCallkitIncomingBridgeActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:theme="@android:style/Theme.Translucent.NoTitleBar"
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:noHistory="true"
|
||||||
|
android:turnScreenOn="true"
|
||||||
|
android:launchMode="singleTop"
|
||||||
|
android:configChanges="orientation" />
|
||||||
|
|
||||||
|
<activity-alias
|
||||||
|
android:name="com.hiennv.flutter_callkit_incoming.CallkitIncomingActivity"
|
||||||
|
android:targetActivity=".EatvioCallkitIncomingBridgeActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:noHistory="true"
|
||||||
|
android:turnScreenOn="true"
|
||||||
|
android:launchMode="singleTop"
|
||||||
|
android:configChanges="orientation"
|
||||||
|
android:permission="${applicationId}.PERMISSION_CALL">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="${applicationId}.com.hiennv.flutter_callkit_incoming.ACTION_CALL_INCOMING" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity-alias>
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name="com.hiennv.flutter_callkit_incoming.TransparentActivity"
|
||||||
|
tools:node="remove" />
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".EatvioCallkitTransparentBridgeActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:theme="@style/TranslucentTheme"
|
||||||
|
android:showWhenLocked="true"
|
||||||
|
android:turnScreenOn="true"
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:noHistory="true"
|
||||||
|
android:launchMode="singleTop" />
|
||||||
|
|
||||||
|
<activity-alias
|
||||||
|
android:name="com.hiennv.flutter_callkit_incoming.TransparentActivity"
|
||||||
|
android:targetActivity=".EatvioCallkitTransparentBridgeActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:theme="@style/TranslucentTheme"
|
||||||
|
android:showWhenLocked="true"
|
||||||
|
android:turnScreenOn="true"
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:noHistory="true"
|
||||||
|
android:launchMode="singleTop" />
|
||||||
|
<service
|
||||||
|
android:name="com.pravera.flutter_foreground_task.service.ForegroundService"
|
||||||
|
android:foregroundServiceType="microphone"
|
||||||
|
android:exported="false"
|
||||||
|
android:stopWithTask="true" />
|
||||||
</application>
|
</application>
|
||||||
<!-- Required to query activities that can process text, see:
|
<!-- Required to query activities that can process text, see:
|
||||||
https://developer.android.com/training/package-visibility and
|
https://developer.android.com/training/package-visibility and
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package com.eatvioapp.app
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import com.hiennv.flutter_callkit_incoming.CallkitConstants
|
||||||
|
import com.hiennv.flutter_callkit_incoming.FlutterCallkitIncomingPlugin
|
||||||
|
|
||||||
|
object CallKitAcceptIntentMapper {
|
||||||
|
fun payloadFromBundle(data: Bundle, action: String): Map<String, Any?>? {
|
||||||
|
if (!action.contains(CallkitConstants.ACTION_CALL_ACCEPT) &&
|
||||||
|
!action.contains(CallkitConstants.ACTION_CALL_CALLBACK)
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return hashMapOf(
|
||||||
|
"action" to action,
|
||||||
|
"id" to data.getString(CallkitConstants.EXTRA_CALLKIT_ID, ""),
|
||||||
|
"handle" to data.getString(CallkitConstants.EXTRA_CALLKIT_HANDLE, ""),
|
||||||
|
"nameCaller" to data.getString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, ""),
|
||||||
|
"avatar" to data.getString(CallkitConstants.EXTRA_CALLKIT_AVATAR, ""),
|
||||||
|
"extra" to data.getSerializable(CallkitConstants.EXTRA_CALLKIT_EXTRA),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun payloadFromIntent(intent: Intent?): Map<String, Any?>? {
|
||||||
|
if (intent == null) return null
|
||||||
|
|
||||||
|
val data = intent.getBundleExtra(FlutterCallkitIncomingPlugin.EXTRA_CALLKIT_CALL_DATA)
|
||||||
|
?: return null
|
||||||
|
val action = intent.action ?: return null
|
||||||
|
if (!action.contains(CallkitConstants.ACTION_CALL_ACCEPT) &&
|
||||||
|
!action.contains(CallkitConstants.ACTION_CALL_CALLBACK)
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return hashMapOf(
|
||||||
|
"action" to action,
|
||||||
|
"id" to data.getString(CallkitConstants.EXTRA_CALLKIT_ID, ""),
|
||||||
|
"handle" to data.getString(CallkitConstants.EXTRA_CALLKIT_HANDLE, ""),
|
||||||
|
"nameCaller" to data.getString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, ""),
|
||||||
|
"avatar" to data.getString(CallkitConstants.EXTRA_CALLKIT_AVATAR, ""),
|
||||||
|
"extra" to data.getSerializable(CallkitConstants.EXTRA_CALLKIT_EXTRA),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearCallKitLaunchIntent(intent: Intent?) {
|
||||||
|
if (intent == null) return
|
||||||
|
intent.removeExtra(FlutterCallkitIncomingPlugin.EXTRA_CALLKIT_CALL_DATA)
|
||||||
|
intent.action = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.eatvioapp.app
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import com.hiennv.flutter_callkit_incoming.AppUtils
|
||||||
|
import com.hiennv.flutter_callkit_incoming.CallkitIncomingBroadcastReceiver
|
||||||
|
|
||||||
|
object CallKitIntentBridge {
|
||||||
|
fun dispatchCallKitAction(context: Context, action: String, data: Bundle?) {
|
||||||
|
if (data != null) {
|
||||||
|
CallKitAcceptIntentMapper.payloadFromBundle(data, action)?.let { payload ->
|
||||||
|
PendingCallKitAcceptBridge.store(payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val broadcastIntent = CallkitIncomingBroadcastReceiver.getIntent(context, action, data)
|
||||||
|
broadcastIntent.addFlags(Intent.FLAG_RECEIVER_FOREGROUND)
|
||||||
|
context.sendBroadcast(broadcastIntent)
|
||||||
|
|
||||||
|
val shouldLaunchMainActivity =
|
||||||
|
action == com.hiennv.flutter_callkit_incoming.CallkitConstants.ACTION_CALL_ACCEPT ||
|
||||||
|
action == com.hiennv.flutter_callkit_incoming.CallkitConstants.ACTION_CALL_CALLBACK ||
|
||||||
|
!MainActivity.isInForeground
|
||||||
|
|
||||||
|
if (!shouldLaunchMainActivity) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val activityIntent = AppUtils.getAppIntent(context, action, data) ?: return
|
||||||
|
activityIntent.addFlags(
|
||||||
|
Intent.FLAG_ACTIVITY_NEW_TASK or
|
||||||
|
Intent.FLAG_ACTIVITY_SINGLE_TOP or
|
||||||
|
Intent.FLAG_ACTIVITY_REORDER_TO_FRONT,
|
||||||
|
)
|
||||||
|
context.startActivity(activityIntent)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.eatvioapp.app
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.os.Bundle
|
||||||
|
import com.hiennv.flutter_callkit_incoming.CallkitConstants
|
||||||
|
|
||||||
|
/// Redirects notification body / full-screen intent taps to the same accept path as
|
||||||
|
/// the Answer button, instead of showing the native Callkit incoming UI.
|
||||||
|
class EatvioCallkitIncomingBridgeActivity : Activity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
val data = intent.extras?.getBundle(CallkitConstants.EXTRA_CALLKIT_INCOMING_DATA)
|
||||||
|
if (data == null) {
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
CallKitIntentBridge.dispatchCallKitAction(
|
||||||
|
this,
|
||||||
|
CallkitConstants.ACTION_CALL_ACCEPT,
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
|
||||||
|
finish()
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
overridePendingTransition(0, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.eatvioapp.app
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.os.Bundle
|
||||||
|
|
||||||
|
/// Replaces plugin TransparentActivity: forwards CallKit actions without relaunching
|
||||||
|
/// MainActivity when the Flutter app is already in the foreground.
|
||||||
|
class EatvioCallkitTransparentBridgeActivity : Activity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
val action = intent.action
|
||||||
|
if (action.isNullOrBlank()) {
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val data = intent.getBundleExtra("data")
|
||||||
|
CallKitIntentBridge.dispatchCallKitAction(this, action, data)
|
||||||
|
|
||||||
|
finish()
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
overridePendingTransition(0, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.eatvioapp.app
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.google.firebase.messaging.FirebaseMessagingService
|
||||||
|
import com.google.firebase.messaging.RemoteMessage
|
||||||
|
import io.flutter.plugins.firebase.messaging.FlutterFirebaseTokenLiveData
|
||||||
|
|
||||||
|
class EatvioFcmMessagingService : FirebaseMessagingService() {
|
||||||
|
override fun onNewToken(token: String) {
|
||||||
|
super.onNewToken(token)
|
||||||
|
FlutterFirebaseTokenLiveData.getInstance().postToken(token)
|
||||||
|
Log.i(TAG, "onNewToken")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMessageReceived(message: RemoteMessage) {
|
||||||
|
Log.i(TAG, "onMessageReceived: ${message.data}")
|
||||||
|
|
||||||
|
if (VideoCallPushBridge.isEndedVideoCall(message.data)) {
|
||||||
|
VideoCallPushBridge.dismissAllIncomingCalls(this)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!VideoCallPushBridge.isIncomingVideoCall(message.data)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (MainActivity.isInForeground) {
|
||||||
|
Log.i(TAG, "App in foreground, deferring incoming call to Flutter handler")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
VideoCallPushBridge.showIncomingCall(this, message.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "EatvioFCM"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,17 +20,23 @@ class HmsPushService : HmsMessageService() {
|
|||||||
|
|
||||||
if (message == null) return
|
if (message == null) return
|
||||||
|
|
||||||
// Берём action_url из data-поля, если оно есть.
|
if (VideoCallPushBridge.isEndedVideoCall(message.dataOfMap)) {
|
||||||
val actionUrl = message.dataOfMap["action_url"]
|
VideoCallPushBridge.dismissAllIncomingCalls(this)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Строим системное уведомление, по тапу по которому откроется MainActivity с action_url.
|
if (VideoCallPushBridge.isIncomingVideoCall(message.dataOfMap)) {
|
||||||
|
VideoCallPushBridge.showIncomingCall(this, message.dataOfMap)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val actionUrl = message.dataOfMap["action_url"]
|
||||||
showNotification(message, actionUrl)
|
showNotification(message, actionUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showNotification(message: RemoteMessage, actionUrl: String?) {
|
private fun showNotification(message: RemoteMessage, actionUrl: String?) {
|
||||||
val context = applicationContext
|
val context = applicationContext
|
||||||
|
|
||||||
// Интент для запуска Flutter-активити.
|
|
||||||
val intent = Intent(context, MainActivity::class.java).apply {
|
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
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||||
if (!actionUrl.isNullOrBlank()) {
|
if (!actionUrl.isNullOrBlank()) {
|
||||||
@@ -48,7 +54,7 @@ class HmsPushService : HmsMessageService() {
|
|||||||
val channelId = "hms_push_channel"
|
val channelId = "hms_push_channel"
|
||||||
|
|
||||||
val notification = NotificationCompat.Builder(context, channelId)
|
val notification = NotificationCompat.Builder(context, channelId)
|
||||||
.setSmallIcon(R.mipmap.ic_launcher) // используем иконку приложения
|
.setSmallIcon(R.mipmap.ic_launcher)
|
||||||
.setContentTitle(message.notification?.title ?: context.getString(R.string.app_name))
|
.setContentTitle(message.notification?.title ?: context.getString(R.string.app_name))
|
||||||
.setContentText(message.notification?.body ?: "")
|
.setContentText(message.notification?.body ?: "")
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
@@ -58,5 +64,3 @@ class HmsPushService : HmsMessageService() {
|
|||||||
NotificationManagerCompat.from(context).notify(message.messageId.hashCode(), notification)
|
NotificationManagerCompat.from(context).notify(message.messageId.hashCode(), notification)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
package com.eatvioapp.app
|
package com.eatvioapp.app
|
||||||
|
|
||||||
|
import android.app.PictureInPictureParams
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.ApplicationInfo
|
import android.content.pm.ApplicationInfo
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Rational
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
import com.hiennv.flutter_callkit_incoming.FlutterCallkitIncomingPlugin
|
||||||
import com.huawei.agconnect.config.AGConnectServicesConfig
|
import com.huawei.agconnect.config.AGConnectServicesConfig
|
||||||
import com.huawei.hms.aaid.HmsInstanceId
|
import com.huawei.hms.aaid.HmsInstanceId
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -13,13 +18,68 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class MainActivity: FlutterActivity() {
|
class MainActivity: FlutterActivity() {
|
||||||
|
companion object {
|
||||||
|
@JvmStatic
|
||||||
|
var isInForeground: Boolean = false
|
||||||
|
private set
|
||||||
|
}
|
||||||
|
|
||||||
private val channelName = "com.eatvioapp/hms_push"
|
private val channelName = "com.eatvioapp/hms_push"
|
||||||
|
private val videoCallPipChannelName = "com.eatvioapp/video_call_pip"
|
||||||
|
private val videoCallPushChannelName = "com.eatvioapp/video_call_push"
|
||||||
private var methodChannel: MethodChannel? = null
|
private var methodChannel: MethodChannel? = null
|
||||||
|
private var videoCallPipChannel: MethodChannel? = null
|
||||||
|
private var videoCallPushChannel: MethodChannel? = null
|
||||||
|
private var videoCallPipEnabled: Boolean = false
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
isInForeground = true
|
||||||
|
captureCallKitLaunchIntent(intent)
|
||||||
|
if (PendingCallKitAcceptBridge.peek() != null) {
|
||||||
|
scheduleDeliverPendingCallKitAccept()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
isInForeground = false
|
||||||
|
super.onPause()
|
||||||
|
}
|
||||||
|
|
||||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
super.configureFlutterEngine(flutterEngine)
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
|
||||||
methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName)
|
methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName)
|
||||||
|
videoCallPipChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, videoCallPipChannelName)
|
||||||
|
videoCallPushChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, videoCallPushChannelName)
|
||||||
|
videoCallPushChannel?.setMethodCallHandler { call, result ->
|
||||||
|
when (call.method) {
|
||||||
|
"dismissIncomingCalls" -> {
|
||||||
|
try {
|
||||||
|
VideoCallPushBridge.dismissAllIncomingCalls(applicationContext)
|
||||||
|
result.success(null)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
result.error("DISMISS_INCOMING_CALLS_FAILED", e.message, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"drainPendingCallKitAccept" -> {
|
||||||
|
result.success(PendingCallKitAcceptBridge.drain())
|
||||||
|
}
|
||||||
|
"peekPendingCallKitAccept" -> {
|
||||||
|
result.success(PendingCallKitAcceptBridge.peekPending())
|
||||||
|
}
|
||||||
|
else -> result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
videoCallPipChannel?.setMethodCallHandler { call, result ->
|
||||||
|
when (call.method) {
|
||||||
|
"setPipEnabled" -> {
|
||||||
|
videoCallPipEnabled = call.arguments as? Boolean ?: false
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
else -> result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
methodChannel?.setMethodCallHandler { call, result ->
|
methodChannel?.setMethodCallHandler { call, result ->
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
"getHuaweiToken" -> {
|
"getHuaweiToken" -> {
|
||||||
@@ -75,17 +135,67 @@ class MainActivity: FlutterActivity() {
|
|||||||
|
|
||||||
// Обрабатываем возможный action_url, с которым была открыта Activity (например, из HMS уведомления).
|
// Обрабатываем возможный action_url, с которым была открыта Activity (например, из HMS уведомления).
|
||||||
handleIntentForActionUrl(intent)
|
handleIntentForActionUrl(intent)
|
||||||
|
captureCallKitLaunchIntent(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onUserLeaveHint() {
|
||||||
|
super.onUserLeaveHint()
|
||||||
|
enterVideoCallPictureInPictureIfNeeded()
|
||||||
|
videoCallPipChannel?.invokeMethod("onUserLeaveHint", null)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
|
setIntent(intent)
|
||||||
|
captureCallKitLaunchIntent(intent)
|
||||||
// Обработка нового интента, когда Activity уже запущена (тап по уведомлению).
|
// Обработка нового интента, когда Activity уже запущена (тап по уведомлению).
|
||||||
handleIntentForActionUrl(intent)
|
handleIntentForActionUrl(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun captureCallKitLaunchIntent(intent: Intent?) {
|
||||||
|
val payload = CallKitAcceptIntentMapper.payloadFromIntent(intent) ?: return
|
||||||
|
|
||||||
|
android.util.Log.i(
|
||||||
|
"VideoCallPush",
|
||||||
|
"captureCallKitLaunchIntent: action=${payload["action"]} id=${payload["id"]}",
|
||||||
|
)
|
||||||
|
|
||||||
|
PendingCallKitAcceptBridge.store(payload)
|
||||||
|
CallKitAcceptIntentMapper.clearCallKitLaunchIntent(intent)
|
||||||
|
scheduleDeliverPendingCallKitAccept()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleDeliverPendingCallKitAccept() {
|
||||||
|
PendingCallKitAcceptBridge.scheduleDeliverToFlutter(
|
||||||
|
deliver = { deliverPendingCallKitAcceptToFlutter() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deliverPendingCallKitAcceptToFlutter() {
|
||||||
|
val payload = PendingCallKitAcceptBridge.peek() ?: return
|
||||||
|
val channel = videoCallPushChannel
|
||||||
|
if (channel == null) {
|
||||||
|
android.util.Log.w(
|
||||||
|
"VideoCallPush",
|
||||||
|
"deliverPendingCallKitAcceptToFlutter: channel not ready",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
android.util.Log.i(
|
||||||
|
"VideoCallPush",
|
||||||
|
"deliverPendingCallKitAcceptToFlutter: id=${payload["id"]}",
|
||||||
|
)
|
||||||
|
channel.invokeMethod("onCallKitLaunchIntent", payload)
|
||||||
|
}
|
||||||
|
|
||||||
private fun handleIntentForActionUrl(intent: Intent?) {
|
private fun handleIntentForActionUrl(intent: Intent?) {
|
||||||
if (intent == null) return
|
if (intent == null) return
|
||||||
|
|
||||||
|
if (intent.hasExtra(FlutterCallkitIncomingPlugin.EXTRA_CALLKIT_CALL_DATA)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Вариант 1: action_url лежит в extras.
|
// Вариант 1: action_url лежит в extras.
|
||||||
val fromExtras = intent.extras?.getString("action_url")
|
val fromExtras = intent.extras?.getString("action_url")
|
||||||
|
|
||||||
@@ -101,4 +211,19 @@ class MainActivity: FlutterActivity() {
|
|||||||
// Проксируем URL во Flutter, где уже есть общая логика навигации по диплинкам.
|
// Проксируем URL во Flutter, где уже есть общая логика навигации по диплинкам.
|
||||||
methodChannel?.invokeMethod("openActionUrl", actionUrl)
|
methodChannel?.invokeMethod("openActionUrl", actionUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun enterVideoCallPictureInPictureIfNeeded() {
|
||||||
|
if (!videoCallPipEnabled) return
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
|
if (isInPictureInPictureMode) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
val params = PictureInPictureParams.Builder()
|
||||||
|
.setAspectRatio(Rational(2, 3))
|
||||||
|
.build()
|
||||||
|
enterPictureInPictureMode(params)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.w("VideoCallPiP", "Unable to enter PiP from onUserLeaveHint", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package com.eatvioapp.app
|
||||||
|
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
/// Stores accepted call payloads until the Flutter engine is ready to drain them.
|
||||||
|
object PendingCallKitAcceptBridge {
|
||||||
|
private const val TAG = "PendingCallKitAccept"
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var pendingPayload: Map<String, Any?>? = null
|
||||||
|
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
|
fun store(payload: Map<String, Any?>) {
|
||||||
|
pendingPayload = payload
|
||||||
|
Log.i(TAG, "stored pending accept id=${payload["id"]}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun peek(): Map<String, Any?>? = pendingPayload
|
||||||
|
|
||||||
|
fun drain(): Map<String, Any?>? {
|
||||||
|
val payload = pendingPayload
|
||||||
|
pendingPayload = null
|
||||||
|
if (payload != null) {
|
||||||
|
Log.i(TAG, "drained pending accept id=${payload["id"]}")
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
fun peekPending(): Map<String, Any?>? = pendingPayload
|
||||||
|
|
||||||
|
fun scheduleDeliverToFlutter(
|
||||||
|
deliver: () -> Unit,
|
||||||
|
delaysMs: LongArray = longArrayOf(0L, 250L, 700L, 1500L, 2800L, 4200L, 6000L),
|
||||||
|
) {
|
||||||
|
for (delayMs in delaysMs) {
|
||||||
|
mainHandler.postDelayed({ deliver() }, delayMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
package com.eatvioapp.app
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import com.hiennv.flutter_callkit_incoming.CallkitConstants
|
||||||
|
import com.hiennv.flutter_callkit_incoming.CallkitNotificationManager
|
||||||
|
import com.hiennv.flutter_callkit_incoming.CallkitNotificationService
|
||||||
|
import com.hiennv.flutter_callkit_incoming.CallkitSoundPlayerManager
|
||||||
|
import com.hiennv.flutter_callkit_incoming.Data
|
||||||
|
import com.hiennv.flutter_callkit_incoming.FlutterCallkitIncomingPlugin
|
||||||
|
import com.hiennv.flutter_callkit_incoming.addCall
|
||||||
|
import com.hiennv.flutter_callkit_incoming.getDataActiveCalls
|
||||||
|
import com.hiennv.flutter_callkit_incoming.removeAllCalls
|
||||||
|
import java.util.HashMap
|
||||||
|
|
||||||
|
object VideoCallPushBridge {
|
||||||
|
private const val TAG = "VideoCallPushBridge"
|
||||||
|
private const val ANDROID_ACCENT_COLOR = "#2787F5"
|
||||||
|
private const val MISSED_CALL_CLEANUP_DELAY_MS = 250L
|
||||||
|
|
||||||
|
private var activeSoundManager: CallkitSoundPlayerManager? = null
|
||||||
|
private var activeNotificationManager: CallkitNotificationManager? = null
|
||||||
|
|
||||||
|
fun isIncomingVideoCall(data: Map<String, String>): Boolean {
|
||||||
|
val rawType = data["type"]
|
||||||
|
?: data["notification_type"]
|
||||||
|
?: data["event"]
|
||||||
|
?: data["action"]
|
||||||
|
?: data["push_type"]
|
||||||
|
?: data["call_type"]
|
||||||
|
|
||||||
|
if (rawType != null) {
|
||||||
|
val normalized = rawType.lowercase().replace("-", "_")
|
||||||
|
if (normalized.contains("video_call")) {
|
||||||
|
return !normalized.contains("end") && !normalized.contains("ended")
|
||||||
|
}
|
||||||
|
if (normalized == "incoming_call" ||
|
||||||
|
normalized == "video_call_incoming" ||
|
||||||
|
normalized == "incoming_video_call"
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTruthy(data["is_video_call"]) ||
|
||||||
|
isTruthy(data["incoming_call"]) ||
|
||||||
|
isTruthy(data["is_incoming_call"])
|
||||||
|
) {
|
||||||
|
return conversationUuid(data) != null
|
||||||
|
}
|
||||||
|
|
||||||
|
val actionUrl = data["action_url"]
|
||||||
|
if (!actionUrl.isNullOrBlank() &&
|
||||||
|
(actionUrl.contains("video-call") || actionUrl.contains("videoCall"))
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isEndedVideoCall(data: Map<String, String>): Boolean {
|
||||||
|
val rawType = data["type"]
|
||||||
|
?: data["notification_type"]
|
||||||
|
?: data["event"]
|
||||||
|
?: data["action"]
|
||||||
|
?: data["push_type"]
|
||||||
|
?: data["call_type"]
|
||||||
|
|
||||||
|
if (rawType != null) {
|
||||||
|
val normalized = rawType.lowercase().replace("-", "_")
|
||||||
|
if (normalized == "video_call_ended" || normalized == "video_call_end") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (normalized.contains("video_call") &&
|
||||||
|
(normalized.contains("ended") || normalized.endsWith("_end"))
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showIncomingCall(context: Context, data: Map<String, String>) {
|
||||||
|
val appContext = context.applicationContext
|
||||||
|
val callData = buildCallDataBundle(data)
|
||||||
|
if (callData == null) {
|
||||||
|
Log.w(TAG, "showIncomingCall: missing conversation uuid, data=$data")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.i(TAG, "showIncomingCall: conversation=${callData.getString(CallkitConstants.EXTRA_CALLKIT_ID)}")
|
||||||
|
|
||||||
|
val soundManager = CallkitSoundPlayerManager(appContext)
|
||||||
|
val notificationManager = CallkitNotificationManager(appContext, soundManager)
|
||||||
|
activeSoundManager = soundManager
|
||||||
|
activeNotificationManager = notificationManager
|
||||||
|
notificationManager.createNotificationChanel(callData)
|
||||||
|
notificationManager.showIncomingNotification(callData)
|
||||||
|
|
||||||
|
try {
|
||||||
|
addCall(appContext, Data.fromBundle(callData))
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(TAG, "showIncomingCall: failed to persist active call", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissAllIncomingCalls(context: Context) {
|
||||||
|
val appContext = context.applicationContext
|
||||||
|
val calls = getDataActiveCalls(appContext)
|
||||||
|
Log.i(TAG, "dismissAllIncomingCalls: active calls=${calls.size}")
|
||||||
|
|
||||||
|
stopIncomingRingtone(appContext)
|
||||||
|
|
||||||
|
val notificationManager = resolveNotificationManager(appContext)
|
||||||
|
val bundles = calls.map { it.toBundle() }
|
||||||
|
|
||||||
|
for (bundle in bundles) {
|
||||||
|
try {
|
||||||
|
dismissIncomingCallNotification(appContext, notificationManager, bundle)
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(
|
||||||
|
TAG,
|
||||||
|
"dismissAllIncomingCalls: failed for call ${bundle.getString(CallkitConstants.EXTRA_CALLKIT_ID)}",
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
CallkitNotificationService.stopService(appContext)
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(TAG, "dismissAllIncomingCalls: failed to stop callkit service", error)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
removeAllCalls(appContext)
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(TAG, "dismissAllIncomingCalls: failed to remove persisted calls", error)
|
||||||
|
}
|
||||||
|
|
||||||
|
stopIncomingRingtone(appContext)
|
||||||
|
scheduleMissedCallCleanup(notificationManager, bundles)
|
||||||
|
activeSoundManager = null
|
||||||
|
activeNotificationManager = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveNotificationManager(context: Context): CallkitNotificationManager {
|
||||||
|
activeNotificationManager?.let { return it }
|
||||||
|
FlutterCallkitIncomingPlugin.getInstance()
|
||||||
|
?.getCallkitNotificationManager()
|
||||||
|
?.let { return it }
|
||||||
|
|
||||||
|
val soundManager = CallkitSoundPlayerManager(context)
|
||||||
|
return CallkitNotificationManager(context, soundManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopIncomingRingtone(context: Context) {
|
||||||
|
activeSoundManager?.stop()
|
||||||
|
FlutterCallkitIncomingPlugin.getInstance()
|
||||||
|
?.getCallkitSoundPlayerManager()
|
||||||
|
?.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces the incoming notification before cancel so Android does not fire the
|
||||||
|
* timeout delete intent (which would show a missed-call notification).
|
||||||
|
*/
|
||||||
|
private fun dismissIncomingCallNotification(
|
||||||
|
context: Context,
|
||||||
|
notificationManager: CallkitNotificationManager,
|
||||||
|
bundle: Bundle,
|
||||||
|
) {
|
||||||
|
val notificationId = incomingNotificationId(bundle)
|
||||||
|
|
||||||
|
val silentNotification = NotificationCompat.Builder(
|
||||||
|
context,
|
||||||
|
CallkitNotificationManager.NOTIFICATION_CHANNEL_ID_INCOMING,
|
||||||
|
)
|
||||||
|
.setSmallIcon(context.applicationInfo.icon)
|
||||||
|
.setContentTitle("")
|
||||||
|
.setSilent(true)
|
||||||
|
.setOngoing(false)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val notificationManagerCompat = NotificationManagerCompat.from(context)
|
||||||
|
notificationManagerCompat.notify(notificationId, silentNotification)
|
||||||
|
notificationManagerCompat.cancel(notificationId)
|
||||||
|
|
||||||
|
bundle.putBoolean(CallkitConstants.EXTRA_CALLKIT_MISSED_CALL_SHOW, false)
|
||||||
|
notificationManager.clearIncomingNotification(bundle, isAccepted = true)
|
||||||
|
notificationManager.clearMissCallNotification(bundle)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleMissedCallCleanup(
|
||||||
|
notificationManager: CallkitNotificationManager,
|
||||||
|
bundles: List<Bundle>,
|
||||||
|
) {
|
||||||
|
if (bundles.isEmpty()) return
|
||||||
|
|
||||||
|
Handler(Looper.getMainLooper()).postDelayed({
|
||||||
|
for (bundle in bundles) {
|
||||||
|
try {
|
||||||
|
notificationManager.clearMissCallNotification(bundle)
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(TAG, "dismissAllIncomingCalls: delayed missed-call cleanup failed", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, MISSED_CALL_CLEANUP_DELAY_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun incomingNotificationId(bundle: Bundle): Int {
|
||||||
|
return bundle.getString(CallkitConstants.EXTRA_CALLKIT_ID, "callkit_incoming").hashCode()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildCallDataBundle(data: Map<String, String>): Bundle? {
|
||||||
|
val conversationUuid = conversationUuid(data) ?: return null
|
||||||
|
val callerName = data["counterpartName"]
|
||||||
|
?: data["counterpart_name"]
|
||||||
|
?: data["caller_name"]
|
||||||
|
?: data["callerName"]
|
||||||
|
?: data["nameCaller"]
|
||||||
|
?: data["caller"]
|
||||||
|
?: "Итвио"
|
||||||
|
val avatarUrl = data["counterpartAvatarUrl"]
|
||||||
|
?: data["counterpart_avatar_url"]
|
||||||
|
?: data["avatar"]
|
||||||
|
?: data["avatarUrl"]
|
||||||
|
?: data["avatar_url"]
|
||||||
|
?: data["caller_avatar"]
|
||||||
|
?: ""
|
||||||
|
val localParticipantName = data["localParticipantName"]
|
||||||
|
?: data["local_participant_name"]
|
||||||
|
val counterpartIsConsultant = isTruthy(
|
||||||
|
data["counterpartIsConsultant"]
|
||||||
|
?: data["counterpart_is_consultant"]
|
||||||
|
?: data["is_consultant"]
|
||||||
|
?: data["isConsultant"]
|
||||||
|
)
|
||||||
|
|
||||||
|
val extra = HashMap<String, Any?>()
|
||||||
|
extra["conversationUuid"] = conversationUuid
|
||||||
|
extra["counterpartName"] = callerName
|
||||||
|
if (avatarUrl.isNotBlank()) {
|
||||||
|
extra["counterpartAvatarUrl"] = avatarUrl
|
||||||
|
}
|
||||||
|
if (!localParticipantName.isNullOrBlank()) {
|
||||||
|
extra["localParticipantName"] = localParticipantName
|
||||||
|
}
|
||||||
|
if (counterpartIsConsultant) {
|
||||||
|
extra["counterpartIsConsultant"] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return Bundle().apply {
|
||||||
|
putString(CallkitConstants.EXTRA_CALLKIT_ID, conversationUuid)
|
||||||
|
putString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, callerName)
|
||||||
|
putString(CallkitConstants.EXTRA_CALLKIT_APP_NAME, "Итвио")
|
||||||
|
putString(CallkitConstants.EXTRA_CALLKIT_HANDLE, conversationUuid)
|
||||||
|
putString(CallkitConstants.EXTRA_CALLKIT_AVATAR, avatarUrl)
|
||||||
|
putInt(CallkitConstants.EXTRA_CALLKIT_TYPE, 0)
|
||||||
|
putLong(CallkitConstants.EXTRA_CALLKIT_DURATION, 45000L)
|
||||||
|
putString(CallkitConstants.EXTRA_CALLKIT_TEXT_ACCEPT, "Answer")
|
||||||
|
putString(CallkitConstants.EXTRA_CALLKIT_TEXT_DECLINE, "Decline")
|
||||||
|
putSerializable(CallkitConstants.EXTRA_CALLKIT_EXTRA, extra)
|
||||||
|
putSerializable(CallkitConstants.EXTRA_CALLKIT_HEADERS, HashMap<String, Any?>())
|
||||||
|
putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_CUSTOM_NOTIFICATION, true)
|
||||||
|
putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_CUSTOM_SMALL_EX_NOTIFICATION, false)
|
||||||
|
putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_SHOW_LOGO, false)
|
||||||
|
putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_SHOW_CALL_ID, false)
|
||||||
|
putString(CallkitConstants.EXTRA_CALLKIT_RINGTONE_PATH, "system_ringtone_default")
|
||||||
|
putString(CallkitConstants.EXTRA_CALLKIT_BACKGROUND_COLOR, ANDROID_ACCENT_COLOR)
|
||||||
|
putString(CallkitConstants.EXTRA_CALLKIT_ACTION_COLOR, ANDROID_ACCENT_COLOR)
|
||||||
|
putString(CallkitConstants.EXTRA_CALLKIT_TEXT_COLOR, "#ffffff")
|
||||||
|
putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_SHOW_FULL_LOCKED_SCREEN, false)
|
||||||
|
putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_FULL_SCREEN, false)
|
||||||
|
putBoolean(CallkitConstants.EXTRA_CALLKIT_CALLING_SHOW, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun conversationUuid(data: Map<String, String>): String? {
|
||||||
|
val fromData = data["conversationUuid"]
|
||||||
|
?: data["conversation_uuid"]
|
||||||
|
?: data["conversation_id"]
|
||||||
|
?: data["chat_uuid"]
|
||||||
|
?: data["uuid"]
|
||||||
|
if (!fromData.isNullOrBlank()) {
|
||||||
|
return fromData
|
||||||
|
}
|
||||||
|
|
||||||
|
val actionUrl = data["action_url"] ?: return null
|
||||||
|
val uuidFromQuery = Regex("""[?&](?:uuid|conversationUuid|conversation_uuid)=([^&]+)""")
|
||||||
|
.find(actionUrl)
|
||||||
|
?.groupValues
|
||||||
|
?.getOrNull(1)
|
||||||
|
?.trim()
|
||||||
|
if (!uuidFromQuery.isNullOrEmpty()) {
|
||||||
|
return uuidFromQuery
|
||||||
|
}
|
||||||
|
|
||||||
|
val pathMatch = Regex("""/video-call/([^/?#]+)""").find(actionUrl)
|
||||||
|
return pathMatch?.groupValues?.getOrNull(1)?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isTruthy(value: String?): Boolean {
|
||||||
|
if (value.isNullOrBlank()) return false
|
||||||
|
return when (value.trim().lowercase()) {
|
||||||
|
"1", "true", "yes" -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
3
assets/icons/video_call/vc_control_enter.svg
Normal file
3
assets/icons/video_call/vc_control_enter.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M14.3438 5.34375C14.9405 5.34375 15.5128 5.5808 15.9347 6.00276C16.3567 6.42472 16.5938 6.99701 16.5938 7.59375M14.3438 0.84375C16.134 0.84375 17.8509 1.55491 19.1167 2.82078C20.3826 4.08665 21.0938 5.80354 21.0938 7.59375M3.09375 1.96875H7.59375L9.84375 7.59375L7.03125 9.28125C8.23608 11.7242 10.2133 13.7014 12.6562 14.9062L14.3438 12.0938L19.9688 14.3438V18.8438C19.9688 19.4405 19.7317 20.0128 19.3097 20.4347C18.8878 20.8567 18.3155 21.0938 17.7188 21.0938C13.3304 20.8271 9.19142 18.9636 6.08268 15.8548C2.97394 12.7461 1.11043 8.60708 0.84375 4.21875C0.84375 3.62201 1.0808 3.04972 1.50276 2.62776C1.92472 2.2058 2.49701 1.96875 3.09375 1.96875Z" stroke="white" stroke-width="1.6875" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 853 B |
@@ -47,8 +47,6 @@ post_install do |installer|
|
|||||||
## dart: PermissionGroup.photos
|
## dart: PermissionGroup.photos
|
||||||
'PERMISSION_PHOTOS=1',
|
'PERMISSION_PHOTOS=1',
|
||||||
'PERMISSION_NOTIFICATIONS=1',
|
'PERMISSION_NOTIFICATIONS=1',
|
||||||
## dart: PermissionGroup.mediaLibrary
|
|
||||||
'PERMISSION_MEDIA_LIBRARY=1',
|
|
||||||
]
|
]
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -397,7 +397,7 @@
|
|||||||
);
|
);
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Eatvio;
|
INFOPLIST_KEY_CFBundleDisplayName = Eatvio;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -548,7 +548,7 @@
|
|||||||
);
|
);
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Eatvio;
|
INFOPLIST_KEY_CFBundleDisplayName = Eatvio;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -587,7 +587,7 @@
|
|||||||
);
|
);
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Eatvio;
|
INFOPLIST_KEY_CFBundleDisplayName = Eatvio;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
|
|||||||
@@ -1,30 +1,517 @@
|
|||||||
|
import AVFAudio
|
||||||
|
import CallKit
|
||||||
import Flutter
|
import Flutter
|
||||||
|
import PushKit
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import UserNotifications
|
||||||
import YandexLoginSDK
|
import YandexLoginSDK
|
||||||
|
import flutter_callkit_incoming
|
||||||
|
import flutter_foreground_task
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@objc class AppDelegate: FlutterAppDelegate {
|
@objc class AppDelegate: FlutterAppDelegate, PKPushRegistryDelegate, CallkitIncomingAppDelegate {
|
||||||
|
private var voipRegistry: PKPushRegistry?
|
||||||
|
private var voipDiagnosticsChannel: FlutterMethodChannel?
|
||||||
|
private let voipDiagnosticsStorageKey = "EatvioVoipDiagnostics"
|
||||||
|
private let pendingAcceptedVoipCallKey = "EatvioPendingAcceptedVoipCall"
|
||||||
|
private let voipDiagnosticsChannelName = "com.eatvioapp/voip_diagnostics"
|
||||||
|
private let appUrlScheme = "eatvio"
|
||||||
|
|
||||||
override func application(
|
override func application(
|
||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
GeneratedPluginRegistrant.register(with: self)
|
GeneratedPluginRegistrant.register(with: self)
|
||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
SwiftFlutterForegroundTaskPlugin.setPluginRegistrantCallback { registry in
|
||||||
|
GeneratedPluginRegistrant.register(with: registry)
|
||||||
|
}
|
||||||
|
|
||||||
|
let mainQueue = DispatchQueue.main
|
||||||
|
voipRegistry = PKPushRegistry(queue: mainQueue)
|
||||||
|
voipRegistry?.delegate = self
|
||||||
|
voipRegistry?.desiredPushTypes = [PKPushType.voIP]
|
||||||
|
|
||||||
|
if #available(iOS 10.0, *) {
|
||||||
|
UNUserNotificationCenter.current().delegate = self
|
||||||
|
}
|
||||||
|
|
||||||
|
let launched = super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
|
|
||||||
|
if let controller = window?.rootViewController as? FlutterViewController {
|
||||||
|
let channel = FlutterMethodChannel(
|
||||||
|
name: voipDiagnosticsChannelName,
|
||||||
|
binaryMessenger: controller.binaryMessenger
|
||||||
|
)
|
||||||
|
voipDiagnosticsChannel = channel
|
||||||
|
channel.setMethodCallHandler { [weak self] call, result in
|
||||||
|
guard let self else {
|
||||||
|
result(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch call.method {
|
||||||
|
case "drainVoipDiagnostics":
|
||||||
|
result(self.drainVoipDiagnostics())
|
||||||
|
case "drainPendingAcceptedVoipCall":
|
||||||
|
result(self.drainPendingAcceptedVoipCall())
|
||||||
|
default:
|
||||||
|
result(FlutterMethodNotImplemented)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return launched
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(iOS 8.0, *)
|
@available(iOS 8.0, *)
|
||||||
func application(
|
override func application(
|
||||||
_ application: UIApplication, continue userActivity: NSUserActivity,
|
_ application: UIApplication,
|
||||||
restorationHandler: @escaping ([Any]?) -> Void
|
continue userActivity: NSUserActivity,
|
||||||
|
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
YXLSdk.shared.processUserActivity(userActivity)
|
if YXLSdk.shared.processUserActivity(userActivity) {
|
||||||
return true
|
return true
|
||||||
|
}
|
||||||
|
return super.application(
|
||||||
|
application,
|
||||||
|
continue: userActivity,
|
||||||
|
restorationHandler: restorationHandler
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override public func application(
|
override public func application(
|
||||||
_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]
|
_ app: UIApplication,
|
||||||
|
open url: URL,
|
||||||
|
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
|
if url.scheme == appUrlScheme {
|
||||||
|
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||||
|
let queryItems = components?.queryItems ?? []
|
||||||
|
let conversationUuid = queryItems.first { $0.name == "conversationUuid" }?.value?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
let callKitId = queryItems.first { $0.name == "callKitId" }?.value?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
recordVoipDiagnostic(
|
||||||
|
"accepted_call_url_opened",
|
||||||
|
data: [
|
||||||
|
"callKitId": callKitId ?? "",
|
||||||
|
"hasCallKitId": callKitId != nil,
|
||||||
|
"conversationUuid": conversationUuid,
|
||||||
|
"url": url.absoluteString,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if let callKitId, !callKitId.isEmpty, !conversationUuid.isEmpty {
|
||||||
|
voipDiagnosticsChannel?.invokeMethod(
|
||||||
|
"acceptedVoipCall",
|
||||||
|
arguments: [
|
||||||
|
"id": callKitId,
|
||||||
|
"handle": conversationUuid,
|
||||||
|
"extra": [
|
||||||
|
"conversationUuid": conversationUuid
|
||||||
|
],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
return YXLSdk.shared.handleOpen(
|
return YXLSdk.shared.handleOpen(
|
||||||
url, sourceApplication: options[UIApplication.OpenURLOptionsKey.sourceApplication] as? String)
|
url,
|
||||||
|
sourceApplication: options[UIApplication.OpenURLOptionsKey.sourceApplication] as? String
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func conversationUuid(fromActionUrl actionUrl: String?) -> String? {
|
||||||
|
guard
|
||||||
|
let actionUrl = actionUrl,
|
||||||
|
let components = URLComponents(string: actionUrl)
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let queryItems = components.queryItems ?? []
|
||||||
|
for key: String in ["uuid", "conversationUuid", "conversation_uuid"] {
|
||||||
|
if let value = queryItems.first(where: { $0.name == key })?.value,
|
||||||
|
!value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
{
|
||||||
|
return value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let segments = components.path.split(separator: "/").map(String.init)
|
||||||
|
for index in segments.indices where segments[index] == "video-call" {
|
||||||
|
let nextIndex = segments.index(after: index)
|
||||||
|
if segments.indices.contains(nextIndex), !segments[nextIndex].isEmpty {
|
||||||
|
return segments[nextIndex]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func conversationUuid(fromCall call: Call) -> String? {
|
||||||
|
let extra = call.data.extra as? [String: Any] ?? [:]
|
||||||
|
for key in ["conversationUuid", "conversation_uuid", "conversation_id", "chat_uuid", "uuid"] {
|
||||||
|
if let value = extra[key] as? String {
|
||||||
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !trimmed.isEmpty {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let handle = call.data.handle.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !handle.isEmpty {
|
||||||
|
return handle
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func storePendingAcceptedCall(_ call: Call) {
|
||||||
|
let payload = sanitizedDiagnosticValue(call.data.toJSON()) as? [String: Any] ?? [:]
|
||||||
|
UserDefaults.standard.set(payload, forKey: pendingAcceptedVoipCallKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func drainPendingAcceptedVoipCall() -> [String: Any]? {
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
guard let payload = defaults.dictionary(forKey: pendingAcceptedVoipCallKey) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defaults.removeObject(forKey: pendingAcceptedVoipCallKey)
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bringAppToForeground() {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
for scene in UIApplication.shared.connectedScenes {
|
||||||
|
guard let windowScene = scene as? UIWindowScene else { continue }
|
||||||
|
for window in windowScene.windows {
|
||||||
|
window.makeKeyAndVisible()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.window?.makeKeyAndVisible()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openAppForAcceptedCall(_ call: Call) {
|
||||||
|
guard let conversationUuid = conversationUuid(fromCall: call), !conversationUuid.isEmpty else {
|
||||||
|
recordVoipDiagnostic(
|
||||||
|
"accepted_call_missing_conversation_uuid",
|
||||||
|
data: ["callKitId": call.data.uuid]
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var components = URLComponents()
|
||||||
|
components.scheme = appUrlScheme
|
||||||
|
components.host = "video-call"
|
||||||
|
components.queryItems = [
|
||||||
|
URLQueryItem(name: "callKitId", value: call.data.uuid),
|
||||||
|
URLQueryItem(name: "conversationUuid", value: conversationUuid)
|
||||||
|
]
|
||||||
|
|
||||||
|
guard let url = components.url else {
|
||||||
|
recordVoipDiagnostic(
|
||||||
|
"accepted_call_url_build_failed",
|
||||||
|
data: [
|
||||||
|
"callKitId": call.data.uuid,
|
||||||
|
"conversationUuid": conversationUuid,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
recordVoipDiagnostic(
|
||||||
|
"accepted_call_open_app_requested",
|
||||||
|
data: [
|
||||||
|
"callKitId": call.data.uuid,
|
||||||
|
"conversationUuid": conversationUuid,
|
||||||
|
"url": url.absoluteString,
|
||||||
|
"appState": UIApplication.shared.applicationState.rawValue,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
bringAppToForeground()
|
||||||
|
|
||||||
|
UIApplication.shared.open(url, options: [:]) { [weak self] success in
|
||||||
|
self?.recordVoipDiagnostic(
|
||||||
|
success ? "accepted_call_open_app_succeeded" : "accepted_call_open_app_failed",
|
||||||
|
data: [
|
||||||
|
"callKitId": call.data.uuid,
|
||||||
|
"conversationUuid": conversationUuid,
|
||||||
|
"url": url.absoluteString,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sanitizedDiagnosticValue(_ value: Any?) -> Any {
|
||||||
|
guard let value = value else {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if let string = value as? String {
|
||||||
|
return string
|
||||||
|
}
|
||||||
|
if let number = value as? NSNumber {
|
||||||
|
return number
|
||||||
|
}
|
||||||
|
if let bool = value as? Bool {
|
||||||
|
return bool
|
||||||
|
}
|
||||||
|
if let dictionary = value as? [AnyHashable: Any] {
|
||||||
|
return dictionary.reduce(into: [String: Any]()) { result, entry in
|
||||||
|
result[String(describing: entry.key)] = sanitizedDiagnosticValue(entry.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let array = value as? [Any] {
|
||||||
|
return array.map { sanitizedDiagnosticValue($0) }
|
||||||
|
}
|
||||||
|
return String(describing: value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func recordVoipDiagnostic(_ name: String, data: [String: Any]) {
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
var events = defaults.array(forKey: voipDiagnosticsStorageKey) as? [[String: Any]] ?? []
|
||||||
|
var event: [String: Any] = [
|
||||||
|
"name": name,
|
||||||
|
"timestamp": ISO8601DateFormatter().string(from: Date()),
|
||||||
|
"appState": UIApplication.shared.applicationState.rawValue,
|
||||||
|
]
|
||||||
|
event["data"] = sanitizedDiagnosticValue(data)
|
||||||
|
events.append(event)
|
||||||
|
if events.count > 20 {
|
||||||
|
events = Array(events.suffix(20))
|
||||||
|
}
|
||||||
|
defaults.set(events, forKey: voipDiagnosticsStorageKey)
|
||||||
|
voipDiagnosticsChannel?.invokeMethod("voipDiagnosticsUpdated", arguments: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func drainVoipDiagnostics() -> [[String: Any]] {
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
let events = defaults.array(forKey: voipDiagnosticsStorageKey) as? [[String: Any]] ?? []
|
||||||
|
defaults.removeObject(forKey: voipDiagnosticsStorageKey)
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func pushRegistry(
|
||||||
|
_ registry: PKPushRegistry,
|
||||||
|
didUpdate credentials: PKPushCredentials,
|
||||||
|
for type: PKPushType
|
||||||
|
) {
|
||||||
|
let deviceToken = credentials.token.map { String(format: "%02x", $0) }.joined()
|
||||||
|
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP(deviceToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pushRegistry(_ registry: PKPushRegistry, didInvalidatePushTokenFor type: PKPushType) {
|
||||||
|
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP("")
|
||||||
|
}
|
||||||
|
|
||||||
|
func pushRegistry(
|
||||||
|
_ registry: PKPushRegistry,
|
||||||
|
didReceiveIncomingPushWith payload: PKPushPayload,
|
||||||
|
for type: PKPushType,
|
||||||
|
completion: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
guard type == .voIP else {
|
||||||
|
completion()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let dict = payload.dictionaryPayload
|
||||||
|
let pushMarkers = [
|
||||||
|
dict["type"] as? String,
|
||||||
|
dict["notification_type"] as? String,
|
||||||
|
dict["event"] as? String,
|
||||||
|
dict["action"] as? String,
|
||||||
|
dict["push_type"] as? String,
|
||||||
|
dict["call_type"] as? String,
|
||||||
|
]
|
||||||
|
.compactMap { $0?.lowercased().replacingOccurrences(of: "-", with: "_") }
|
||||||
|
|
||||||
|
if pushMarkers.contains(where: { $0.contains("video_call") })
|
||||||
|
&& pushMarkers.contains(where: { $0.contains("end") || $0.contains("cancel") }) {
|
||||||
|
recordVoipDiagnostic(
|
||||||
|
"voip_end_or_cancel_received",
|
||||||
|
data: [
|
||||||
|
"markers": pushMarkers,
|
||||||
|
"payload": dict,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.endAllCalls()
|
||||||
|
completion()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let nameCaller = (dict["nameCaller"] as? String)
|
||||||
|
?? (dict["counterpartName"] as? String)
|
||||||
|
?? (dict["counterpart_name"] as? String)
|
||||||
|
?? (dict["caller_name"] as? String)
|
||||||
|
?? (dict["callerName"] as? String)
|
||||||
|
?? (dict["caller"] as? String)
|
||||||
|
?? ""
|
||||||
|
let conversationUuid = (dict["conversation_uuid"] as? String)
|
||||||
|
?? (dict["conversationUuid"] as? String)
|
||||||
|
?? (dict["conversation_id"] as? String)
|
||||||
|
?? (dict["chat_uuid"] as? String)
|
||||||
|
?? (dict["uuid"] as? String)
|
||||||
|
?? conversationUuid(fromActionUrl: dict["action_url"] as? String)
|
||||||
|
?? (dict["handle"] as? String)
|
||||||
|
?? ""
|
||||||
|
let callKitIdSource = [
|
||||||
|
(key: "call_id", value: dict["call_id"] as? String),
|
||||||
|
(key: "id", value: dict["id"] as? String),
|
||||||
|
(key: "message_id", value: dict["message_id"] as? String),
|
||||||
|
]
|
||||||
|
.first { item in
|
||||||
|
guard let trimmed = item.value?.trimmingCharacters(in: .whitespacesAndNewlines) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !trimmed.isEmpty && UUID(uuidString: trimmed) != nil
|
||||||
|
}
|
||||||
|
let id = callKitIdSource?.value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? UUID().uuidString
|
||||||
|
let callKitIdSourceName = callKitIdSource?.key ?? "generated"
|
||||||
|
let isVideo = (dict["isVideo"] as? Bool)
|
||||||
|
?? (dict["is_video"] as? Bool)
|
||||||
|
?? true
|
||||||
|
|
||||||
|
var extra: [String: Any] = [:]
|
||||||
|
if !conversationUuid.isEmpty {
|
||||||
|
extra["conversationUuid"] = conversationUuid
|
||||||
|
}
|
||||||
|
if let avatar = dict["caller_avatar"] as? String ?? dict["avatar"] as? String
|
||||||
|
?? dict["avatarUrl"] as? String
|
||||||
|
?? dict["avatar_url"] as? String
|
||||||
|
?? dict["counterpartAvatarUrl"] as? String
|
||||||
|
?? dict["counterpart_avatar_url"] as? String
|
||||||
|
{
|
||||||
|
extra["counterpartAvatarUrl"] = avatar
|
||||||
|
}
|
||||||
|
if let messageId = dict["message_id"] as? String {
|
||||||
|
extra["messageId"] = messageId
|
||||||
|
}
|
||||||
|
if let roomName = dict["room_name"] as? String {
|
||||||
|
extra["roomName"] = roomName
|
||||||
|
}
|
||||||
|
if let localName = dict["localParticipantName"] as? String
|
||||||
|
?? dict["local_participant_name"] as? String
|
||||||
|
{
|
||||||
|
extra["localParticipantName"] = localName
|
||||||
|
}
|
||||||
|
if let counterpartName = dict["counterpartName"] as? String {
|
||||||
|
extra["counterpartName"] = counterpartName
|
||||||
|
} else if let counterpartName = dict["counterpart_name"] as? String {
|
||||||
|
extra["counterpartName"] = counterpartName
|
||||||
|
} else if !nameCaller.isEmpty {
|
||||||
|
extra["counterpartName"] = nameCaller
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = flutter_callkit_incoming.Data(
|
||||||
|
id: id,
|
||||||
|
nameCaller: nameCaller,
|
||||||
|
handle: conversationUuid.isEmpty ? nameCaller : conversationUuid,
|
||||||
|
type: isVideo ? 1 : 0
|
||||||
|
)
|
||||||
|
data.extra = extra as NSDictionary
|
||||||
|
|
||||||
|
NSLog(
|
||||||
|
"[EatvioVoIP] report incoming call id=\(id) conversation=\(conversationUuid) state=\(UIApplication.shared.applicationState.rawValue) markers=\(pushMarkers.joined(separator: ",")) payload=\(dict)"
|
||||||
|
)
|
||||||
|
recordVoipDiagnostic(
|
||||||
|
"voip_incoming_received",
|
||||||
|
data: [
|
||||||
|
"callKitId": id,
|
||||||
|
"callKitIdSource": callKitIdSourceName,
|
||||||
|
"conversationUuid": conversationUuid,
|
||||||
|
"nameCaller": nameCaller,
|
||||||
|
"isVideo": isVideo,
|
||||||
|
"markers": pushMarkers,
|
||||||
|
"payload": dict,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
guard let callkitPlugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance else {
|
||||||
|
NSLog("[EatvioVoIP] CallKit plugin missing id=\(id)")
|
||||||
|
recordVoipDiagnostic(
|
||||||
|
"callkit_plugin_missing",
|
||||||
|
data: [
|
||||||
|
"callKitId": id,
|
||||||
|
"conversationUuid": conversationUuid,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
completion()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
callkitPlugin.showCallkitIncoming(
|
||||||
|
data,
|
||||||
|
fromPushKit: true
|
||||||
|
) {
|
||||||
|
let activeCalls = callkitPlugin.activeCalls()
|
||||||
|
let activeIds = activeCalls.compactMap { $0["id"] as? String }.joined(separator: ",")
|
||||||
|
NSLog(
|
||||||
|
"[EatvioVoIP] report incoming completed id=\(id) activeCallIds=\(activeIds)"
|
||||||
|
)
|
||||||
|
self.recordVoipDiagnostic(
|
||||||
|
"callkit_report_completed",
|
||||||
|
data: [
|
||||||
|
"callKitId": id,
|
||||||
|
"callKitIdSource": callKitIdSourceName,
|
||||||
|
"conversationUuid": conversationUuid,
|
||||||
|
"activeCallIds": activeIds,
|
||||||
|
"activeCallsCount": activeCalls.count,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||||
|
let delayedActiveCalls = callkitPlugin.activeCalls()
|
||||||
|
let delayedActiveIds = delayedActiveCalls.compactMap { $0["id"] as? String }.joined(separator: ",")
|
||||||
|
self.recordVoipDiagnostic(
|
||||||
|
"callkit_active_calls_delayed",
|
||||||
|
data: [
|
||||||
|
"callKitId": id,
|
||||||
|
"callKitIdSource": callKitIdSourceName,
|
||||||
|
"conversationUuid": conversationUuid,
|
||||||
|
"activeCallIds": delayedActiveIds,
|
||||||
|
"activeCallsCount": delayedActiveCalls.count,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
completion()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func onAccept(_ call: Call, _ action: CXAnswerCallAction) {
|
||||||
|
storePendingAcceptedCall(call)
|
||||||
|
recordVoipDiagnostic(
|
||||||
|
"callkit_accept_native",
|
||||||
|
data: [
|
||||||
|
"callKitId": call.data.uuid,
|
||||||
|
"conversationUuid": conversationUuid(fromCall: call) ?? "",
|
||||||
|
"data": call.data.toJSON(),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
voipDiagnosticsChannel?.invokeMethod("acceptedVoipCall", arguments: call.data.toJSON())
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.bringAppToForeground()
|
||||||
|
self?.openAppForAcceptedCall(call)
|
||||||
|
}
|
||||||
|
action.fulfill()
|
||||||
|
}
|
||||||
|
|
||||||
|
func onDecline(_ call: Call, _ action: CXEndCallAction) {
|
||||||
|
action.fulfill()
|
||||||
|
}
|
||||||
|
|
||||||
|
func onEnd(_ call: Call, _ action: CXEndCallAction) {
|
||||||
|
action.fulfill()
|
||||||
|
}
|
||||||
|
|
||||||
|
func onTimeOut(_ call: Call) {}
|
||||||
|
|
||||||
|
func didActivateAudioSession(_ audioSession: AVAudioSession) {}
|
||||||
|
|
||||||
|
func didDeactivateAudioSession(_ audioSession: AVAudioSession) {}
|
||||||
|
|
||||||
|
func providerDidReset() {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,20 +31,22 @@
|
|||||||
<key>NSAllowsArbitraryLoads</key>
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
<key>NSAppleMusicUsageDescription</key>
|
|
||||||
<string>This permission is not needed by the app, but it is required by an underlying API. If you see this dialog, contact us.</string>
|
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>Предоставление доступа позволит Вам делать фотографии и добавлять к записям</string>
|
<string>Предоставление доступа позволит Вам делать фотографии и добавлять к записям</string>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
<string>Нужен для звука в видеозвонках с консультантом</string>
|
<string>Нужен для звука в видеозвонках с консультантом</string>
|
||||||
|
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||||
|
<string>Может использоваться для стабильного поддержания связи во время звонка.</string>
|
||||||
<key>NSLocationWhenInUseUsageDescription</key>
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
<string></string>
|
<string>Может использоваться для стабильного поддержания связи во время звонка.</string>
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
<string>Предоставление доступа позволит Вам добавлять фото к записям</string>
|
<string>Предоставление доступа позволит Вам добавлять фото к записям</string>
|
||||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
|
<string>audio</string>
|
||||||
|
<string>voip</string>
|
||||||
<string>fetch</string>
|
<string>fetch</string>
|
||||||
<string>remote-notification</string>
|
<string>remote-notification</string>
|
||||||
</array>
|
</array>
|
||||||
@@ -138,6 +140,14 @@
|
|||||||
<string>yx5399b03636104c6d920b87c1ad329254</string>
|
<string>yx5399b03636104c6d920b87c1ad329254</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleURLName</key>
|
||||||
|
<string>Eatvio</string>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>eatvio</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -75,16 +75,24 @@ platform :ios do
|
|||||||
xcodeproj: "Runner.xcodeproj",
|
xcodeproj: "Runner.xcodeproj",
|
||||||
build_number: flutter_build_number
|
build_number: flutter_build_number
|
||||||
)
|
)
|
||||||
|
|
||||||
|
update_code_signing_settings(
|
||||||
|
use_automatic_signing: false,
|
||||||
|
path: "Runner.xcodeproj",
|
||||||
|
team_id: "VL68C36HYS",
|
||||||
|
targets: ["Runner"],
|
||||||
|
code_sign_identity: "Apple Distribution",
|
||||||
|
profile_name: "match AppStore com.eatvioapp.app 1781098260"
|
||||||
|
)
|
||||||
|
|
||||||
build_app(
|
build_app(
|
||||||
workspace: "Runner.xcworkspace",
|
workspace: "Runner.xcworkspace",
|
||||||
scheme: "Runner",
|
scheme: "Runner",
|
||||||
export_method: "app-store",
|
export_method: "app-store",
|
||||||
codesigning_identity: "Apple Distribution",
|
# codesigning_identity: "Apple Distribution",
|
||||||
|
export_team_id: "VL68C36HYS"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
app_store_connect_api_key(
|
app_store_connect_api_key(
|
||||||
key_id: "325JS9JCZH",
|
key_id: "325JS9JCZH",
|
||||||
issuer_id: "dfa27d1d-2bb8-4d4d-847e-0b32f9b880b3",
|
issuer_id: "dfa27d1d-2bb8-4d4d-847e-0b32f9b880b3",
|
||||||
@@ -92,6 +100,7 @@ platform :ios do
|
|||||||
duration: 1200,
|
duration: 1200,
|
||||||
in_house: false
|
in_house: false
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
begin
|
begin
|
||||||
upload_to_testflight(
|
upload_to_testflight(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/new_
|
|||||||
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
|
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
|
||||||
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
|
import 'package:eatvio_app/features/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/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
|
||||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
||||||
import 'package:eatvio_app/generated/l10n.dart';
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -35,6 +36,7 @@ class AppConfig {
|
|||||||
/// Список глобальных BlocProvider'ов
|
/// Список глобальных BlocProvider'ов
|
||||||
/// Используются lazy: true для отложенной инициализации, чтобы не блокировать старт приложения
|
/// Используются lazy: true для отложенной инициализации, чтобы не блокировать старт приложения
|
||||||
List<BlocProvider> get globalBlocProviders => [
|
List<BlocProvider> get globalBlocProviders => [
|
||||||
|
BlocProvider<VideoCallSessionCubit>.value(value: sl<VideoCallSessionCubit>()),
|
||||||
BlocProvider<NewMessagesCubit>(lazy: true, create: (context) => sl<NewMessagesCubit>()),
|
BlocProvider<NewMessagesCubit>(lazy: true, create: (context) => sl<NewMessagesCubit>()),
|
||||||
BlocProvider<NotificationsCubit>(lazy: true, create: (context) => sl<NotificationsCubit>()),
|
BlocProvider<NotificationsCubit>(lazy: true, create: (context) => sl<NotificationsCubit>()),
|
||||||
BlocProvider<FeedCubit>(lazy: true, create: (context) => sl<FeedCubit>()),
|
BlocProvider<FeedCubit>(lazy: true, create: (context) => sl<FeedCubit>()),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:eatvio_app/core/env_config.dart';
|
|||||||
import 'package:eatvio_app/core/error_reporting/error_report_policy.dart';
|
import 'package:eatvio_app/core/error_reporting/error_report_policy.dart';
|
||||||
import 'package:eatvio_app/core/locator_service.dart' as di;
|
import 'package:eatvio_app/core/locator_service.dart' as di;
|
||||||
import 'package:eatvio_app/core/locator_service.dart';
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/services/video_call_fcm_handler.dart';
|
||||||
import 'package:eatvio_app/firebase_options.dart';
|
import 'package:eatvio_app/firebase_options.dart';
|
||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
@@ -47,6 +48,7 @@ class AppInitializer {
|
|||||||
throw TimeoutException('Firebase initialization timeout');
|
throw TimeoutException('Firebase initialization timeout');
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
VideoCallFcmHandler.registerBackgroundHandler();
|
||||||
if (sl<AppConfig>().buildType == AppBuildType.prod) {
|
if (sl<AppConfig>().buildType == AppBuildType.prod) {
|
||||||
try {
|
try {
|
||||||
await FirebaseMessaging.instance.requestPermission().timeout(const Duration(seconds: 5));
|
await FirebaseMessaging.instance.requestPermission().timeout(const Duration(seconds: 5));
|
||||||
|
|||||||
@@ -9,4 +9,5 @@ const List<String> planTypeValues = ['fit', 'slim', 'wellness', 'behavior'];
|
|||||||
const newMessagesChannelName = 'private-new-chat-messages';
|
const newMessagesChannelName = 'private-new-chat-messages';
|
||||||
const newNotificationsChannelName = 'private-message-events';
|
const newNotificationsChannelName = 'private-message-events';
|
||||||
const directChatChannelName = 'private-direct-chat';
|
const directChatChannelName = 'private-direct-chat';
|
||||||
|
const presenceDirectChatChannelName = 'presence-direct-chat';
|
||||||
const thresholdForPagination = 150;
|
const thresholdForPagination = 150;
|
||||||
|
|||||||
16
lib/core/device/device_hw_id_provider.dart
Normal file
16
lib/core/device/device_hw_id_provider.dart
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
|
|
||||||
|
abstract final class DeviceHwIdProvider {
|
||||||
|
static Future<String?> current() async {
|
||||||
|
final deviceInfo = DeviceInfoPlugin();
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
return (await deviceInfo.iosInfo).identifierForVendor;
|
||||||
|
}
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
return (await deviceInfo.androidInfo).id;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,6 +52,8 @@ import 'package:eatvio_app/features/registration/presentation/bloc/verify_email_
|
|||||||
import 'package:eatvio_app/features/registration_v2/cubit/registration_cubit.dart';
|
import 'package:eatvio_app/features/registration_v2/cubit/registration_cubit.dart';
|
||||||
import 'package:eatvio_app/features/settings/presentation/bloc/settings_cubit/settings_cubit.dart';
|
import 'package:eatvio_app/features/settings/presentation/bloc/settings_cubit/settings_cubit.dart';
|
||||||
import 'package:eatvio_app/features/user_details/presentation/cubit/user_details_cubit.dart';
|
import 'package:eatvio_app/features/user_details/presentation/cubit/user_details_cubit.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
|
||||||
import 'package:eatvio_app/core/comments/comments_cubit/comments_cubit.dart';
|
import 'package:eatvio_app/core/comments/comments_cubit/comments_cubit.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
|
|
||||||
@@ -72,6 +74,18 @@ class CubitRegistrations {
|
|||||||
sl.registerLazySingleton<ProfileAvatarCubit>(() => ProfileAvatarCubit(profileRepository: sl()));
|
sl.registerLazySingleton<ProfileAvatarCubit>(() => ProfileAvatarCubit(profileRepository: sl()));
|
||||||
sl.registerLazySingleton<ChatsCubit>(() => ChatsCubit(chatRepository: sl(), wsManager: sl(), profileRepository: sl()));
|
sl.registerLazySingleton<ChatsCubit>(() => ChatsCubit(chatRepository: sl(), wsManager: sl(), profileRepository: sl()));
|
||||||
sl.registerLazySingleton<InternetConnectionCubit>(() => InternetConnectionCubit());
|
sl.registerLazySingleton<InternetConnectionCubit>(() => InternetConnectionCubit());
|
||||||
|
sl.registerLazySingleton<VideoCallManager>(
|
||||||
|
() => VideoCallManager(
|
||||||
|
videoCallRepository: sl(),
|
||||||
|
profileRepository: sl(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
sl.registerLazySingleton<VideoCallSessionCubit>(
|
||||||
|
() => VideoCallSessionCubit(
|
||||||
|
videoCallManager: sl(),
|
||||||
|
systemPipCoordinator: sl(),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Регистрация Cubit'ов как фабрик
|
/// Регистрация Cubit'ов как фабрик
|
||||||
@@ -93,7 +107,9 @@ class CubitRegistrations {
|
|||||||
sl.registerFactory<FeedProfileCubit>(() => FeedProfileCubit(profileRepository: sl()));
|
sl.registerFactory<FeedProfileCubit>(() => FeedProfileCubit(profileRepository: sl()));
|
||||||
|
|
||||||
// Chat related cubits
|
// Chat related cubits
|
||||||
sl.registerFactory<ChatRoomCubit>(() => ChatRoomCubit(cardsRepository: sl(), chatRepository: sl(), wsManager: sl()));
|
sl.registerFactory<ChatRoomCubit>(
|
||||||
|
() => ChatRoomCubit(cardsRepository: sl(), chatRepository: sl(), wsManager: sl(), consultantRepository: sl()),
|
||||||
|
);
|
||||||
|
|
||||||
// Profile related cubits
|
// Profile related cubits
|
||||||
sl.registerFactory<ProfileCubit>(() => ProfileCubit(profileRepository: sl(), chatRepository: sl(), consultantRepository: sl()));
|
sl.registerFactory<ProfileCubit>(() => ProfileCubit(profileRepository: sl(), chatRepository: sl(), consultantRepository: sl()));
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ import 'package:eatvio_app/features/recipe/services/my_recipes_service.dart';
|
|||||||
import 'package:eatvio_app/features/registration/services/registration_service.dart';
|
import 'package:eatvio_app/features/registration/services/registration_service.dart';
|
||||||
import 'package:eatvio_app/features/registration_v2/services/registration_service_v2.dart';
|
import 'package:eatvio_app/features/registration_v2/services/registration_service_v2.dart';
|
||||||
import 'package:eatvio_app/features/settings/services/settings_service.dart';
|
import 'package:eatvio_app/features/settings/services/settings_service.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/services/video_call_callkit_coordinator.dart';
|
||||||
import 'package:eatvio_app/features/video_calls/services/video_call_service.dart';
|
import 'package:eatvio_app/features/video_calls/services/video_call_service.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/services/video_call_system_pip_coordinator.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
|
|
||||||
/// Регистрация всех Service в системе внедрения зависимостей
|
/// Регистрация всех Service в системе внедрения зависимостей
|
||||||
@@ -76,6 +78,12 @@ class ServiceRegistrations {
|
|||||||
sl.registerLazySingleton<CreatePlanService>(() => CreatePlanService(sl()));
|
sl.registerLazySingleton<CreatePlanService>(() => CreatePlanService(sl()));
|
||||||
sl.registerLazySingleton<NotificationsService>(() => NotificationsService(sl()));
|
sl.registerLazySingleton<NotificationsService>(() => NotificationsService(sl()));
|
||||||
sl.registerLazySingleton<FirebaseApi>(() => FirebaseApi(errorReporter: sl()));
|
sl.registerLazySingleton<FirebaseApi>(() => FirebaseApi(errorReporter: sl()));
|
||||||
|
sl.registerLazySingleton<VideoCallCallKitCoordinator>(
|
||||||
|
() => VideoCallCallKitCoordinator(firebaseApi: sl()),
|
||||||
|
);
|
||||||
|
sl.registerLazySingleton<VideoCallSystemPipCoordinator>(
|
||||||
|
() => VideoCallSystemPipCoordinator(),
|
||||||
|
);
|
||||||
sl.registerLazySingleton<ResetPasswordService>(() => ResetPasswordService(sl()));
|
sl.registerLazySingleton<ResetPasswordService>(() => ResetPasswordService(sl()));
|
||||||
sl.registerLazySingleton<BlockedUserService>(() => BlockedUserService(sl()));
|
sl.registerLazySingleton<BlockedUserService>(() => BlockedUserService(sl()));
|
||||||
sl.registerLazySingleton<CommentsService>(() => CommentsService(sl()));
|
sl.registerLazySingleton<CommentsService>(() => CommentsService(sl()));
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ class ErrorReportPolicy {
|
|||||||
case DioExceptionType.connectionTimeout:
|
case DioExceptionType.connectionTimeout:
|
||||||
case DioExceptionType.sendTimeout:
|
case DioExceptionType.sendTimeout:
|
||||||
case DioExceptionType.receiveTimeout:
|
case DioExceptionType.receiveTimeout:
|
||||||
case DioExceptionType.connectionError:
|
|
||||||
case DioExceptionType.transformTimeout:
|
case DioExceptionType.transformTimeout:
|
||||||
|
case DioExceptionType.connectionError:
|
||||||
return true;
|
return true;
|
||||||
case DioExceptionType.unknown:
|
case DioExceptionType.unknown:
|
||||||
case DioExceptionType.badResponse:
|
case DioExceptionType.badResponse:
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import 'package:eatvio_app/core/token_storage.dart';
|
|||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
import 'package:flutter/services.dart' show MethodChannel, PlatformException;
|
import 'package:flutter/services.dart' show MethodChannel, PlatformException;
|
||||||
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
|
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
|
||||||
|
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
|
||||||
|
|
||||||
class FirebaseApi {
|
class FirebaseApi {
|
||||||
final ErrorReporter _errorReporter;
|
final ErrorReporter _errorReporter;
|
||||||
@@ -87,6 +88,9 @@ class FirebaseApi {
|
|||||||
throw TimeoutException('registerPushToken timeout');
|
throw TimeoutException('registerPushToken timeout');
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
await _registerIosVoipPushToken();
|
||||||
|
}
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
log(e.toString(), stackTrace: st);
|
log(e.toString(), stackTrace: st);
|
||||||
await _errorReporter.report(e, st, origin: 'FirebaseApi');
|
await _errorReporter.report(e, st, origin: 'FirebaseApi');
|
||||||
@@ -95,6 +99,88 @@ class FirebaseApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Future<void> unregisterPushTokens() async {
|
||||||
|
if (!await TokenStorage.haveTokenInStorage()) return;
|
||||||
|
try {
|
||||||
|
await unregisterPushToken();
|
||||||
|
} catch (_) {}
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
try {
|
||||||
|
await _unregisterIosVoipPushToken();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> unregisterPushToken() async {
|
||||||
|
if (!await TokenStorage.haveTokenInStorage()) return;
|
||||||
|
try {
|
||||||
|
final isHuawei = Platform.isAndroid ? await _isHuaweiDevice() : false;
|
||||||
|
final platform = Platform.isIOS ? 'ios' : (isHuawei ? 'huawei' : 'android');
|
||||||
|
final token = (await getToken()) ?? '';
|
||||||
|
await sl<Dio>().post(
|
||||||
|
'/api/profile/notification/token/unregister',
|
||||||
|
data: {
|
||||||
|
'platform': platform,
|
||||||
|
'token': token,
|
||||||
|
},
|
||||||
|
).timeout(
|
||||||
|
const Duration(seconds: 10),
|
||||||
|
onTimeout: () {
|
||||||
|
throw TimeoutException('unregisterPushToken timeout');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (e, st) {
|
||||||
|
log(e.toString(), stackTrace: st);
|
||||||
|
await _errorReporter.report(e, st, origin: 'FirebaseApi');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _registerIosVoipPushToken() async {
|
||||||
|
try {
|
||||||
|
final token = (await FlutterCallkitIncoming.getDevicePushTokenVoIP())?.trim();
|
||||||
|
if (token == null || token.isEmpty) return;
|
||||||
|
|
||||||
|
final deviceInfo = DeviceInfoPlugin();
|
||||||
|
await sl<Dio>().post(
|
||||||
|
'/api/profile/notification/token/voip/register',
|
||||||
|
data: {
|
||||||
|
'platform': 'ios',
|
||||||
|
'hw_id': (await deviceInfo.iosInfo).identifierForVendor,
|
||||||
|
'token': token,
|
||||||
|
},
|
||||||
|
).timeout(
|
||||||
|
const Duration(seconds: 10),
|
||||||
|
onTimeout: () {
|
||||||
|
throw TimeoutException('registerPushToken voip timeout');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (e, st) {
|
||||||
|
log(e.toString(), stackTrace: st);
|
||||||
|
await _errorReporter.report(e, st, origin: 'FirebaseApi');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _unregisterIosVoipPushToken() async {
|
||||||
|
try {
|
||||||
|
final token = (await FlutterCallkitIncoming.getDevicePushTokenVoIP())?.trim() ?? '';
|
||||||
|
await sl<Dio>().post(
|
||||||
|
'/api/profile/notification/token/voip/unregister',
|
||||||
|
data: {
|
||||||
|
'token': token,
|
||||||
|
},
|
||||||
|
).timeout(
|
||||||
|
const Duration(seconds: 10),
|
||||||
|
onTimeout: () {
|
||||||
|
throw TimeoutException('unregisterPushToken voip timeout');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (e, st) {
|
||||||
|
log(e.toString(), stackTrace: st);
|
||||||
|
await _errorReporter.report(e, st, origin: 'FirebaseApi');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<bool> _isHuaweiDevice() async {
|
Future<bool> _isHuaweiDevice() async {
|
||||||
if (!Platform.isAndroid) return false;
|
if (!Platform.isAndroid) return false;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import 'dart:developer' as developer;
|
import 'dart:developer' as developer;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:eatvio_app/core/app_router.dart';
|
import 'package:eatvio_app/core/app_router.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/services/video_call_ended_push_handler.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/services/video_call_ended_push_payload.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/services/video_call_notification_navigator.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart';
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
@@ -50,7 +57,37 @@ class PushNavigationHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static void _handleMessageInternal(RemoteMessage message, {required String source}) {
|
static void _handleMessageInternal(RemoteMessage message, {required String source}) {
|
||||||
final data = message.data;
|
final data = Map<String, dynamic>.from(message.data);
|
||||||
|
|
||||||
|
final endedPayload = VideoCallEndedPushPayload.tryParse(data);
|
||||||
|
if (endedPayload != null) {
|
||||||
|
developer.log(
|
||||||
|
'PushNavigationHandler: dismiss incoming call UI, source=$source',
|
||||||
|
name: 'PushNavigationHandler',
|
||||||
|
);
|
||||||
|
unawaited(VideoCallEndedPushHandler.dismissIncomingCallUi());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final videoCallPayload = VideoCallPushPayload.tryParse(data);
|
||||||
|
if (videoCallPayload != null) {
|
||||||
|
developer.log(
|
||||||
|
'PushNavigationHandler: opening incoming video call, source=$source, conversation=${videoCallPayload.conversationUuid}',
|
||||||
|
name: 'PushNavigationHandler',
|
||||||
|
);
|
||||||
|
unawaited(
|
||||||
|
VideoCallNotificationNavigator.openIncomingCall(
|
||||||
|
conversationUuid: videoCallPayload.conversationUuid,
|
||||||
|
counterpartName: videoCallPayload.counterpartName,
|
||||||
|
counterpartAvatarUrl: videoCallPayload.counterpartAvatarUrl,
|
||||||
|
localParticipantName: videoCallPayload.localParticipantName,
|
||||||
|
counterpartIsConsultant: videoCallPayload.counterpartIsConsultant,
|
||||||
|
entryMode: VideoCallEntryMode.incomingAccepted,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final actionUrl = data['action_url'] as String?;
|
final actionUrl = data['action_url'] as String?;
|
||||||
if (actionUrl == null || actionUrl.isEmpty) {
|
if (actionUrl == null || actionUrl.isEmpty) {
|
||||||
developer.log('PushNavigationHandler: no action_url in message data, source=$source, data=$data', name: 'PushNavigationHandler');
|
developer.log('PushNavigationHandler: no action_url in message data, source=$source, data=$data', name: 'PushNavigationHandler');
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import 'package:eatvio_app/features/chat/presentation/pages/empty_chat_screen.da
|
|||||||
import 'package:eatvio_app/features/chat/presentation/pages/payment_screen.dart';
|
import 'package:eatvio_app/features/chat/presentation/pages/payment_screen.dart';
|
||||||
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart';
|
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart';
|
||||||
import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart';
|
import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/become_consultant_screen.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/become_consultant_screen.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart';
|
||||||
import 'package:eatvio_app/features/consultant_rating/presentation/consultant_rating_screen.dart';
|
import 'package:eatvio_app/features/consultant_rating/presentation/consultant_rating_screen.dart';
|
||||||
@@ -244,7 +245,13 @@ class OtherRoutes {
|
|||||||
return VideoCallScreen(
|
return VideoCallScreen(
|
||||||
conversationUuid: extra['conversationUuid'] as String,
|
conversationUuid: extra['conversationUuid'] as String,
|
||||||
counterpartName: extra['counterpartName'] as String?,
|
counterpartName: extra['counterpartName'] as String?,
|
||||||
|
counterpartAvatarUrl: extra['counterpartAvatarUrl'] as String?,
|
||||||
|
counterpartUserId: extra['counterpartUserId'] as int?,
|
||||||
|
counterpartIsOnline: extra['counterpartIsOnline'] as bool?,
|
||||||
|
counterpartLastSeenText: extra['counterpartLastSeenText'] as String?,
|
||||||
|
counterpartIsConsultant: extra['counterpartIsConsultant'] as bool?,
|
||||||
localParticipantName: extra['localParticipantName'] as String?,
|
localParticipantName: extra['localParticipantName'] as String?,
|
||||||
|
entryMode: _videoCallEntryModeFromRouteExtra(extra['entryMode']),
|
||||||
onBackToChat: extra['onBackToChat'] as void Function()?,
|
onBackToChat: extra['onBackToChat'] as void Function()?,
|
||||||
onHangUp: extra['onHangUp'] as void Function()?,
|
onHangUp: extra['onHangUp'] as void Function()?,
|
||||||
);
|
);
|
||||||
@@ -258,7 +265,15 @@ class OtherRoutes {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Consultant routes
|
// Consultant routes
|
||||||
GoRoute(path: RouteConstants.becomeConsultantScreen, name: 'becomeConsultantScreen', builder: (context, state) => const BecomeConsultantScreen()),
|
GoRoute(
|
||||||
|
path: RouteConstants.becomeConsultantScreen,
|
||||||
|
name: 'becomeConsultantScreen',
|
||||||
|
builder: (context, state) {
|
||||||
|
final data = state.extra as Map<String, dynamic>?;
|
||||||
|
final slug = data?['slug'] as String?;
|
||||||
|
return BecomeConsultantScreen(slug: slug);
|
||||||
|
},
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: RouteConstants.stepperBecomeConsultantScreen,
|
path: RouteConstants.stepperBecomeConsultantScreen,
|
||||||
name: 'stepperBecomeConsultantScreen',
|
name: 'stepperBecomeConsultantScreen',
|
||||||
@@ -266,7 +281,12 @@ class OtherRoutes {
|
|||||||
final data = state.extra as Map<String, dynamic>;
|
final data = state.extra as Map<String, dynamic>;
|
||||||
final slug = data['slug'] as String?;
|
final slug = data['slug'] as String?;
|
||||||
final isCanceled = data['isCanceled'] as bool?;
|
final isCanceled = data['isCanceled'] as bool?;
|
||||||
return StepperBecomeConsultantScreen(slug: slug, isCanceled: isCanceled);
|
final isNewRegistration = data['isNewRegistration'] as bool? ?? false;
|
||||||
|
return StepperBecomeConsultantScreen(
|
||||||
|
slug: slug,
|
||||||
|
isCanceled: isCanceled,
|
||||||
|
isNewRegistration: isNewRegistration,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
GoRoute(path: RouteConstants.calendarScreen, name: 'calendarScreen', builder: (context, state) => const CalendarScreen()),
|
GoRoute(path: RouteConstants.calendarScreen, name: 'calendarScreen', builder: (context, state) => const CalendarScreen()),
|
||||||
@@ -339,3 +359,11 @@ class OtherRoutes {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
VideoCallEntryMode _videoCallEntryModeFromRouteExtra(Object? raw) {
|
||||||
|
if (raw is VideoCallEntryMode) return raw;
|
||||||
|
if (raw == VideoCallEntryMode.incomingAccepted.name) {
|
||||||
|
return VideoCallEntryMode.incomingAccepted;
|
||||||
|
}
|
||||||
|
return VideoCallEntryMode.outgoing;
|
||||||
|
}
|
||||||
|
|||||||
@@ -80,6 +80,48 @@ class CustomTextStyles {
|
|||||||
static TextStyle get titleSmallSecondaryContainer_1 => theme.textTheme.titleSmall!.copyWith(color: theme.colorScheme.secondaryContainer);
|
static TextStyle get titleSmallSecondaryContainer_1 => theme.textTheme.titleSmall!.copyWith(color: theme.colorScheme.secondaryContainer);
|
||||||
|
|
||||||
static TextStyle get titleSmallSegoeUIOnPrimary => theme.textTheme.titleSmall!.segoeUI.copyWith(color: theme.colorScheme.onPrimary);
|
static TextStyle get titleSmallSegoeUIOnPrimary => theme.textTheme.titleSmall!.segoeUI.copyWith(color: theme.colorScheme.onPrimary);
|
||||||
|
|
||||||
|
static TextStyle get videoCallFlowPlaceholderTitle => theme.textTheme.headlineSmall!.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
height: 32 / 24,
|
||||||
|
color: appTheme.black900,
|
||||||
|
);
|
||||||
|
|
||||||
|
static TextStyle get videoCallFlowPlaceholderConnectionLostBody => theme.textTheme.bodyLarge!.copyWith(
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
fontSize: 16,
|
||||||
|
height: 24 / 16,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
color: appTheme.black900,
|
||||||
|
);
|
||||||
|
|
||||||
|
static TextStyle get videoCallFlowPlaceholderConnectingSubtitle => theme.textTheme.bodyMedium!.copyWith(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
height: 24 / 13,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
color: appTheme.black900,
|
||||||
|
);
|
||||||
|
|
||||||
|
static TextStyle get videoCallFlowPlaceholderReconnectingSubtitle => theme.textTheme.bodyMedium!.copyWith(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
height: 24 / 14,
|
||||||
|
color: appTheme.gray600,
|
||||||
|
);
|
||||||
|
|
||||||
|
static TextStyle get videoCallFlowPlaceholderConnectionLostDetail => theme.textTheme.bodyMedium!.copyWith(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
height: 1.35,
|
||||||
|
color: appTheme.red,
|
||||||
|
);
|
||||||
|
|
||||||
|
static TextStyle get videoCallFlowPlaceholderFilledButtonLabel => theme.textTheme.titleMedium!.copyWith(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: appTheme.whiteA700,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
extension on TextStyle {
|
extension on TextStyle {
|
||||||
|
|||||||
@@ -26,18 +26,28 @@ class WsManager {
|
|||||||
PrivateChannel? _privateNotificationChannel;
|
PrivateChannel? _privateNotificationChannel;
|
||||||
PrivateChannel? _privateMessagesChannel;
|
PrivateChannel? _privateMessagesChannel;
|
||||||
PrivateChannel? _privateDirectChatChannel;
|
PrivateChannel? _privateDirectChatChannel;
|
||||||
|
PresenceChannel? _presenceDirectChatChannel;
|
||||||
|
|
||||||
|
StreamSubscription? _presenceMemberAddedSubscription;
|
||||||
|
StreamSubscription? _presenceMemberRemovedSubscription;
|
||||||
|
StreamSubscription? _presenceSubscriptionSucceededSubscription;
|
||||||
|
final List<_PresenceDirectChatListener> _presenceDirectChatListeners = [];
|
||||||
|
bool _presenceChannelEventsBound = false;
|
||||||
|
|
||||||
StreamSubscription? _lifecycleSubscription;
|
StreamSubscription? _lifecycleSubscription;
|
||||||
StreamSubscription? _eventSubscription;
|
StreamSubscription? _eventSubscription;
|
||||||
StreamSubscription? _connectionEstablishedSubscription;
|
StreamSubscription? _connectionEstablishedSubscription;
|
||||||
|
|
||||||
|
Stream<ChannelReadEvent>? _directChatStream;
|
||||||
|
Stream<ChannelReadEvent>? _clientTypingStream;
|
||||||
|
|
||||||
Stream<ChannelReadEvent>? get newMessagesStream => _privateMessagesChannel?.bind('App\\Events\\ChatMessageUpdated');
|
Stream<ChannelReadEvent>? get newMessagesStream => _privateMessagesChannel?.bind('App\\Events\\ChatMessageUpdated');
|
||||||
|
|
||||||
Stream<ChannelReadEvent>? get newNotificationsStream => _privateNotificationChannel?.bind('Illuminate\\Notifications\\Events\\BroadcastNotificationCreated');
|
Stream<ChannelReadEvent>? get newNotificationsStream => _privateNotificationChannel?.bind('Illuminate\\Notifications\\Events\\BroadcastNotificationCreated');
|
||||||
|
|
||||||
Stream<ChannelReadEvent>? get directChatStream => _privateDirectChatChannel?.bind('App\\Events\\ChatMessageUpdated');
|
Stream<ChannelReadEvent>? get directChatStream => _directChatStream;
|
||||||
|
|
||||||
Stream<ChannelReadEvent>? get clientTypingStream => _privateDirectChatChannel?.bind('client-typing');
|
Stream<ChannelReadEvent>? get clientTypingStream => _clientTypingStream;
|
||||||
|
|
||||||
Future<void> initialize() async {
|
Future<void> initialize() async {
|
||||||
if (client == null) {
|
if (client == null) {
|
||||||
@@ -100,6 +110,8 @@ class WsManager {
|
|||||||
if (data == PusherChannelsClientLifeCycleState.establishedConnection) {
|
if (data == PusherChannelsClientLifeCycleState.establishedConnection) {
|
||||||
_unsubscribe();
|
_unsubscribe();
|
||||||
_subscribe();
|
_subscribe();
|
||||||
|
_privateDirectChatChannel?.subscribe();
|
||||||
|
_presenceDirectChatChannel?.subscribe();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
_eventSubscription = client?.eventStream.listen((event) {
|
_eventSubscription = client?.eventStream.listen((event) {
|
||||||
@@ -163,6 +175,7 @@ class WsManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> initPrivateDirectChatChannel(String chatId) async {
|
Future<void> initPrivateDirectChatChannel(String chatId) async {
|
||||||
|
_privateDirectChatChannel?.unsubscribe();
|
||||||
final token = await TokenStorage.getTokenFromStorage();
|
final token = await TokenStorage.getTokenFromStorage();
|
||||||
_privateDirectChatChannel = client?.privateChannel(
|
_privateDirectChatChannel = client?.privateChannel(
|
||||||
'$directChatChannelName.$chatId',
|
'$directChatChannelName.$chatId',
|
||||||
@@ -173,6 +186,109 @@ class WsManager {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
_directChatStream = _privateDirectChatChannel?.bind('App\\Events\\ChatMessageUpdated');
|
||||||
|
_clientTypingStream = _privateDirectChatChannel?.bind('client-typing');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> initPresenceDirectChatChannel(String chatId) async {
|
||||||
|
final token = await TokenStorage.getTokenFromStorage();
|
||||||
|
_presenceDirectChatChannel = client?.presenceChannel(
|
||||||
|
'$presenceDirectChatChannelName.$chatId',
|
||||||
|
authorizationDelegate: EndpointAuthorizableChannelTokenAuthorizationDelegate.forPresenceChannel(
|
||||||
|
authorizationEndpoint: Uri.parse('${sl<AppConfig>().baseUrl}/broadcasting/auth'),
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer $token',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void addPresenceDirectChatListener({
|
||||||
|
required int counterpartUserId,
|
||||||
|
required void Function(bool isOnline) onCounterpartOnlineChanged,
|
||||||
|
}) {
|
||||||
|
final channel = _presenceDirectChatChannel;
|
||||||
|
if (channel == null) return;
|
||||||
|
|
||||||
|
_presenceDirectChatListeners.add(
|
||||||
|
_PresenceDirectChatListener(
|
||||||
|
counterpartUserId: counterpartUserId,
|
||||||
|
onCounterpartOnlineChanged: onCounterpartOnlineChanged,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
_bindPresenceChannelEventsIfNeeded();
|
||||||
|
channel.subscribe();
|
||||||
|
_emitCounterpartPresence(counterpartUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
void removePresenceDirectChatListener(
|
||||||
|
void Function(bool isOnline) onCounterpartOnlineChanged,
|
||||||
|
) {
|
||||||
|
_presenceDirectChatListeners.removeWhere(
|
||||||
|
(listener) => listener.onCounterpartOnlineChanged == onCounterpartOnlineChanged,
|
||||||
|
);
|
||||||
|
if (_presenceDirectChatListeners.isEmpty) {
|
||||||
|
unsubscribePresenceDirectChatChannel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _bindPresenceChannelEventsIfNeeded() {
|
||||||
|
if (_presenceChannelEventsBound) return;
|
||||||
|
final channel = _presenceDirectChatChannel;
|
||||||
|
if (channel == null) return;
|
||||||
|
|
||||||
|
_presenceChannelEventsBound = true;
|
||||||
|
|
||||||
|
_presenceSubscriptionSucceededSubscription =
|
||||||
|
channel.whenSubscriptionSucceeded().listen((_) {
|
||||||
|
final counterpartIds = _presenceDirectChatListeners
|
||||||
|
.map((listener) => listener.counterpartUserId)
|
||||||
|
.toSet();
|
||||||
|
for (final counterpartUserId in counterpartIds) {
|
||||||
|
_emitCounterpartPresence(counterpartUserId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_presenceMemberAddedSubscription = channel.whenMemberAdded().listen((event) {
|
||||||
|
final memberUserId = _parsePresenceMemberUserId(event.data);
|
||||||
|
if (memberUserId == null) return;
|
||||||
|
_notifyPresenceListeners(memberUserId, isOnline: true);
|
||||||
|
});
|
||||||
|
_presenceMemberRemovedSubscription = channel.whenMemberRemoved().listen((event) {
|
||||||
|
final memberUserId = _parsePresenceMemberUserId(event.data);
|
||||||
|
if (memberUserId == null) return;
|
||||||
|
_notifyPresenceListeners(memberUserId, isOnline: false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _emitCounterpartPresence(int counterpartUserId) {
|
||||||
|
final channel = _presenceDirectChatChannel;
|
||||||
|
if (channel == null) return;
|
||||||
|
final members = channel.state?.members;
|
||||||
|
if (members == null) return;
|
||||||
|
final isOnline = members.getMemberInfo(counterpartUserId.toString()) != null;
|
||||||
|
_notifyPresenceListeners(counterpartUserId, isOnline: isOnline);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _notifyPresenceListeners(int counterpartUserId, {required bool isOnline}) {
|
||||||
|
for (final listener in _presenceDirectChatListeners) {
|
||||||
|
if (listener.counterpartUserId == counterpartUserId) {
|
||||||
|
listener.onCounterpartOnlineChanged(isOnline);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int? _parsePresenceMemberUserId(String? data) {
|
||||||
|
if (data == null || data.isEmpty) return null;
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(data);
|
||||||
|
if (decoded is! Map) return null;
|
||||||
|
final rawId = decoded['user_id'] ?? decoded['id'];
|
||||||
|
if (rawId == null) return null;
|
||||||
|
return int.tryParse(rawId.toString());
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _subscribe() {
|
void _subscribe() {
|
||||||
@@ -191,6 +307,21 @@ class WsManager {
|
|||||||
|
|
||||||
void unsubscribeDirectChatChannel() {
|
void unsubscribeDirectChatChannel() {
|
||||||
_privateDirectChatChannel?.unsubscribe();
|
_privateDirectChatChannel?.unsubscribe();
|
||||||
|
_directChatStream = null;
|
||||||
|
_clientTypingStream = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void unsubscribePresenceDirectChatChannel() {
|
||||||
|
_presenceSubscriptionSucceededSubscription?.cancel();
|
||||||
|
_presenceMemberAddedSubscription?.cancel();
|
||||||
|
_presenceMemberRemovedSubscription?.cancel();
|
||||||
|
_presenceSubscriptionSucceededSubscription = null;
|
||||||
|
_presenceMemberAddedSubscription = null;
|
||||||
|
_presenceMemberRemovedSubscription = null;
|
||||||
|
_presenceDirectChatListeners.clear();
|
||||||
|
_presenceChannelEventsBound = false;
|
||||||
|
_presenceDirectChatChannel?.unsubscribe();
|
||||||
|
_presenceDirectChatChannel = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> clear() async {
|
Future<void> clear() async {
|
||||||
@@ -204,10 +335,23 @@ class WsManager {
|
|||||||
client = null;
|
client = null;
|
||||||
userId = null;
|
userId = null;
|
||||||
_privateDirectChatChannel?.unsubscribe();
|
_privateDirectChatChannel?.unsubscribe();
|
||||||
|
unsubscribePresenceDirectChatChannel();
|
||||||
_privateMessagesChannel?.unsubscribe();
|
_privateMessagesChannel?.unsubscribe();
|
||||||
_privateNotificationChannel?.unsubscribe();
|
_privateNotificationChannel?.unsubscribe();
|
||||||
_privateDirectChatChannel = null;
|
_privateDirectChatChannel = null;
|
||||||
_privateMessagesChannel = null;
|
_privateMessagesChannel = null;
|
||||||
_privateNotificationChannel = null;
|
_privateNotificationChannel = null;
|
||||||
|
_directChatStream = null;
|
||||||
|
_clientTypingStream = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _PresenceDirectChatListener {
|
||||||
|
const _PresenceDirectChatListener({
|
||||||
|
required this.counterpartUserId,
|
||||||
|
required this.onCounterpartOnlineChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int counterpartUserId;
|
||||||
|
final void Function(bool isOnline) onCounterpartOnlineChanged;
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:eatvio_app/core/utils/toasts.dart';
|
|||||||
import 'package:eatvio_app/core/widgets/show_error_dialog.dart';
|
import 'package:eatvio_app/core/widgets/show_error_dialog.dart';
|
||||||
import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_cubit.dart';
|
import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_cubit.dart';
|
||||||
import 'package:eatvio_app/features/complaining/presentation/complaining_bottom_sheet.dart';
|
import 'package:eatvio_app/features/complaining/presentation/complaining_bottom_sheet.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
||||||
import 'package:eatvio_app/features/feed/models/diets_pagination_model.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/models/feed_blog_post_model.dart';
|
||||||
import 'package:eatvio_app/features/feed/models/recipes_model.dart';
|
import 'package:eatvio_app/features/feed/models/recipes_model.dart';
|
||||||
@@ -13,9 +14,11 @@ import 'package:eatvio_app/features/feed/presentation/bloc/diets_cubit/diets_cub
|
|||||||
import 'package:eatvio_app/features/feed/presentation/bloc/recipes_cubit/recipes_cubit.dart';
|
import 'package:eatvio_app/features/feed/presentation/bloc/recipes_cubit/recipes_cubit.dart';
|
||||||
import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_cubit.dart';
|
import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_cubit.dart';
|
||||||
import 'package:eatvio_app/features/my_plans/presentation/cubit/my_plans_cubit.dart';
|
import 'package:eatvio_app/features/my_plans/presentation/cubit/my_plans_cubit.dart';
|
||||||
|
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
|
||||||
import 'package:eatvio_app/generated/l10n.dart';
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:share_plus/share_plus.dart';
|
import 'package:share_plus/share_plus.dart';
|
||||||
@@ -30,6 +33,7 @@ class CustomPopup extends StatelessWidget {
|
|||||||
final Recipe? recipe;
|
final Recipe? recipe;
|
||||||
final Diet? diet;
|
final Diet? diet;
|
||||||
final String userSlug;
|
final String userSlug;
|
||||||
|
final VoidCallback? onPostsChanged;
|
||||||
|
|
||||||
const CustomPopup({
|
const CustomPopup({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -41,7 +45,8 @@ class CustomPopup extends StatelessWidget {
|
|||||||
this.feedBlogPostModel,
|
this.feedBlogPostModel,
|
||||||
this.recipe,
|
this.recipe,
|
||||||
this.diet,
|
this.diet,
|
||||||
required this.userSlug
|
required this.userSlug,
|
||||||
|
this.onPostsChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -103,7 +108,8 @@ class CustomPopup extends StatelessWidget {
|
|||||||
if (complainedContentType == ComplainedContent.posts) {
|
if (complainedContentType == ComplainedContent.posts) {
|
||||||
addItem('assets/icons/edit_paint.svg', 'Редактировать', () async {
|
addItem('assets/icons/edit_paint.svg', 'Редактировать', () async {
|
||||||
if (complainedContentType == ComplainedContent.posts) {
|
if (complainedContentType == ComplainedContent.posts) {
|
||||||
GoRouter.of(context).pushNamed('blogPostCreateScreen', extra: feedBlogPostModel);
|
await GoRouter.of(context).pushNamed('blogPostCreateScreen', extra: feedBlogPostModel);
|
||||||
|
onPostsChanged?.call();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -114,7 +120,14 @@ class CustomPopup extends StatelessWidget {
|
|||||||
showErrorDialog(context, title: S.current.could_not_delete_blog_post);
|
showErrorDialog(context, title: S.current.could_not_delete_blog_post);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
showSuccessToast('Пост удален, обновите ленту');
|
onPostsChanged?.call();
|
||||||
|
ProfileCubit.myProfileCubit?.reloadBlogPosts();
|
||||||
|
if (context.mounted) {
|
||||||
|
try {
|
||||||
|
context.read<FeedCubit>().loadFeedBlogPostList();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
showSuccessToast('Пост удален');
|
||||||
} else if (complainedContentType == ComplainedContent.recipe) {
|
} else if (complainedContentType == ComplainedContent.recipe) {
|
||||||
final result = await sl<RecipesCubit>().deleteRecipe(slug!);
|
final result = await sl<RecipesCubit>().deleteRecipe(slug!);
|
||||||
if (!result && context.mounted) {
|
if (!result && context.mounted) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:dismissible_page/dismissible_page.dart';
|
||||||
import 'package:eatvio_app/core/app_config.dart';
|
import 'package:eatvio_app/core/app_config.dart';
|
||||||
import 'package:eatvio_app/core/enums/complained_content.dart';
|
import 'package:eatvio_app/core/enums/complained_content.dart';
|
||||||
import 'package:eatvio_app/core/utils/date_time_utils.dart';
|
import 'package:eatvio_app/core/utils/date_time_utils.dart';
|
||||||
@@ -9,7 +9,10 @@ import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_cubit
|
|||||||
import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_state.dart';
|
import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_state.dart';
|
||||||
import 'package:eatvio_app/core/comments/comments_cubit/comments_cubit.dart';
|
import 'package:eatvio_app/core/comments/comments_cubit/comments_cubit.dart';
|
||||||
import 'package:eatvio_app/core/comments/comments_cubit/comments_state.dart';
|
import 'package:eatvio_app/core/comments/comments_cubit/comments_state.dart';
|
||||||
|
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart';
|
||||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/presentation/widgets/blog_post_media_content.dart';
|
||||||
import 'package:eatvio_app/features/feed/presentation/widgets/comments_widget.dart';
|
import 'package:eatvio_app/features/feed/presentation/widgets/comments_widget.dart';
|
||||||
import 'package:eatvio_app/core/locator_service.dart';
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||||
@@ -33,6 +36,7 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
|||||||
final CommentsCubit commentsCubit = sl<CommentsCubit>();
|
final CommentsCubit commentsCubit = sl<CommentsCubit>();
|
||||||
final textEditingController = TextEditingController();
|
final textEditingController = TextEditingController();
|
||||||
int? replyId;
|
int? replyId;
|
||||||
|
int currentGalleryIndex = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -40,6 +44,10 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
|||||||
super.initState();
|
super.initState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _openGallery(List<String> imageUrls, int index, {Object? heroTag}) {
|
||||||
|
context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: imageUrls, index: index, heroTag: heroTag));
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocProvider(
|
return BlocProvider(
|
||||||
@@ -242,8 +250,30 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
|||||||
child: MarkdownHtmlWidget(markdownText: blogPostState.blogPostModel!.description),
|
child: MarkdownHtmlWidget(markdownText: blogPostState.blogPostModel!.description),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
if (blogPostState.blogPostModel?.image.isNotEmpty ?? false)
|
Builder(
|
||||||
CachedNetworkImage(imageUrl: '${sl<AppConfig>().baseUrl}/storage/${blogPostState.blogPostModel?.image ?? ''}'),
|
builder: (context) {
|
||||||
|
final post = blogPostState.blogPostModel;
|
||||||
|
if (post == null) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
final imageUrl = PostFeedMapper.resolveMediaUrl(post.image);
|
||||||
|
final embedUrl = post.videoEmbedUrl?.trim();
|
||||||
|
return BlogPostMediaContent(
|
||||||
|
galleryImages: post.images,
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null,
|
||||||
|
videoUrlThumb: post.videoUrlThumb != null ? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb) : null,
|
||||||
|
currentGalleryIndex: currentGalleryIndex,
|
||||||
|
heroTag: 'blog-post-screen-${post.id}-cover',
|
||||||
|
onOpenGallery: (urls, index) => _openGallery(urls, index, heroTag: urls.length == 1 ? 'blog-post-screen-${post.id}-cover' : null),
|
||||||
|
onGalleryChanged: (index) {
|
||||||
|
setState(() {
|
||||||
|
currentGalleryIndex = index;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
|||||||
@@ -3,38 +3,184 @@ import 'dart:developer';
|
|||||||
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||||
import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_state.dart';
|
import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_state.dart';
|
||||||
import 'package:eatvio_app/features/blog_post_create/repositories/blog_post_repository.dart';
|
import 'package:eatvio_app/features/blog_post_create/repositories/blog_post_repository.dart';
|
||||||
|
import 'package:eatvio_app/features/blog_post_create/validation/blog_post_form_validator.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||||
|
|
||||||
class BlogPostCreateCubit extends Cubit<BlogPostCreateState> {
|
class BlogPostCreateCubit extends Cubit<BlogPostCreateState> {
|
||||||
final BlogPostRepository blogPostRepository;
|
final BlogPostRepository blogPostRepository;
|
||||||
|
|
||||||
BlogPostCreateCubit({required this.blogPostRepository}) : super(const BlogPostCreateState());
|
BlogPostCreateCubit({required this.blogPostRepository})
|
||||||
|
: super(const BlogPostCreateState());
|
||||||
|
|
||||||
|
void clearTitleError() {
|
||||||
|
if (state.titleError != null || state.formError != null) {
|
||||||
|
emit(state.copyWith(titleError: null, formError: null));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearDescriptionError() {
|
||||||
|
if (state.descriptionError != null || state.formError != null) {
|
||||||
|
emit(state.copyWith(descriptionError: null, formError: null));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearVideoUrlError() {
|
||||||
|
if (state.videoUrlError != null || state.formError != null) {
|
||||||
|
emit(state.copyWith(videoUrlError: null, formError: null));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearImagesError() {
|
||||||
|
if (state.imagesError != null || state.formError != null) {
|
||||||
|
emit(state.copyWith(imagesError: null, formError: null));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void resetFormErrors() {
|
||||||
|
if (!state.hasValidationErrors &&
|
||||||
|
!state.isSuccess &&
|
||||||
|
state.errorMessage == null &&
|
||||||
|
!state.isLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit(const BlogPostCreateState());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates form fields and emits error messages on [BlogPostCreateState].
|
||||||
|
/// Returns `true` when the form is valid.
|
||||||
|
bool validateForm({
|
||||||
|
required String title,
|
||||||
|
required String description,
|
||||||
|
required String videoUrl,
|
||||||
|
required int imageCount,
|
||||||
|
Iterable<String> imagePaths = const [],
|
||||||
|
}) {
|
||||||
|
final titleError = BlogPostFormValidator.title(title);
|
||||||
|
final descriptionError = BlogPostFormValidator.description(description);
|
||||||
|
final videoUrlError = BlogPostFormValidator.videoUrl(videoUrl);
|
||||||
|
final imagesError =
|
||||||
|
BlogPostFormValidator.galleryCount(imageCount) ??
|
||||||
|
BlogPostFormValidator.imagePaths(imagePaths);
|
||||||
|
final formError = BlogPostFormValidator.contentRequiredMessage(
|
||||||
|
title: title,
|
||||||
|
description: description,
|
||||||
|
videoUrl: videoUrl,
|
||||||
|
imageCount: imageCount,
|
||||||
|
);
|
||||||
|
|
||||||
|
final hasFieldErrors =
|
||||||
|
titleError != null ||
|
||||||
|
descriptionError != null ||
|
||||||
|
videoUrlError != null ||
|
||||||
|
imagesError != null;
|
||||||
|
final resolvedFormError = hasFieldErrors ? null : formError;
|
||||||
|
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
titleError: titleError,
|
||||||
|
descriptionError: descriptionError,
|
||||||
|
videoUrlError: videoUrlError,
|
||||||
|
imagesError: imagesError,
|
||||||
|
// Form-level message only when individual fields are fine but content is empty.
|
||||||
|
formError: resolvedFormError,
|
||||||
|
isSuccess: false,
|
||||||
|
errorMessage: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return titleError == null &&
|
||||||
|
descriptionError == null &&
|
||||||
|
videoUrlError == null &&
|
||||||
|
imagesError == null &&
|
||||||
|
resolvedFormError == null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> createBlogPost({
|
||||||
|
String? title,
|
||||||
|
String? videoUrl,
|
||||||
|
String? description,
|
||||||
|
List<XFile> images = const [],
|
||||||
|
}) async {
|
||||||
|
final trimmedTitle = title?.trim() ?? '';
|
||||||
|
final trimmedDescription = description?.trim() ?? '';
|
||||||
|
final trimmedVideoUrl = videoUrl?.trim() ?? '';
|
||||||
|
|
||||||
|
final isValid = validateForm(
|
||||||
|
title: trimmedTitle,
|
||||||
|
description: trimmedDescription,
|
||||||
|
videoUrl: trimmedVideoUrl,
|
||||||
|
imageCount: images.length,
|
||||||
|
imagePaths: images.map((image) => image.path),
|
||||||
|
);
|
||||||
|
if (!isValid) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
Future<bool> createBlogPost({String? title, String? videoUrl, String? description, XFile? image}) async {
|
|
||||||
try {
|
try {
|
||||||
emit(const BlogPostCreateState.loading());
|
emit(
|
||||||
await blogPostRepository.createBlogPost(title, videoUrl, description, image);
|
state.copyWith(isLoading: true, isSuccess: false, errorMessage: null),
|
||||||
emit(const BlogPostCreateState.loaded());
|
);
|
||||||
|
await blogPostRepository.createBlogPost(
|
||||||
|
title: trimmedTitle.isEmpty ? null : trimmedTitle,
|
||||||
|
videoUrl: trimmedVideoUrl.isEmpty ? null : trimmedVideoUrl,
|
||||||
|
description: trimmedDescription.isEmpty ? null : trimmedDescription,
|
||||||
|
images: images,
|
||||||
|
);
|
||||||
|
emit(const BlogPostCreateState(isSuccess: true));
|
||||||
return true;
|
return true;
|
||||||
} catch (error, stackTrace) {
|
} catch (error, stackTrace) {
|
||||||
log('BlogPostCreateCubit: $error', stackTrace: stackTrace);
|
log('BlogPostCreateCubit: $error', stackTrace: stackTrace);
|
||||||
await reportFrom(error, stackTrace, this);
|
await reportFrom(error, stackTrace, this);
|
||||||
emit(BlogPostCreateState.error(errorMessage: error.toString()));
|
emit(BlogPostCreateState(errorMessage: error.toString()));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> updateBlogPost({required int id, String? title, String? videoUrl, String? description, XFile? image, String? oldImageUrl}) async {
|
Future<bool> updateBlogPost({
|
||||||
|
required int id,
|
||||||
|
String? title,
|
||||||
|
String? videoUrl,
|
||||||
|
String? description,
|
||||||
|
List<int> existingMediaIds = const [],
|
||||||
|
List<XFile> newImages = const [],
|
||||||
|
List<int> mediaIdsToDelete = const [],
|
||||||
|
}) async {
|
||||||
|
final trimmedTitle = title?.trim() ?? '';
|
||||||
|
final trimmedDescription = description?.trim() ?? '';
|
||||||
|
final trimmedVideoUrl = videoUrl?.trim() ?? '';
|
||||||
|
final imageCount = existingMediaIds.length + newImages.length;
|
||||||
|
|
||||||
|
final isValid = validateForm(
|
||||||
|
title: trimmedTitle,
|
||||||
|
description: trimmedDescription,
|
||||||
|
videoUrl: trimmedVideoUrl,
|
||||||
|
imageCount: imageCount,
|
||||||
|
imagePaths: newImages.map((image) => image.path),
|
||||||
|
);
|
||||||
|
if (!isValid) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
emit(const BlogPostCreateState.loading());
|
emit(
|
||||||
await blogPostRepository.updateBlogPost(id, title, videoUrl, description, image, oldImageUrl);
|
state.copyWith(isLoading: true, isSuccess: false, errorMessage: null),
|
||||||
emit(const BlogPostCreateState.loaded());
|
);
|
||||||
|
await blogPostRepository.updateBlogPost(
|
||||||
|
id: id,
|
||||||
|
title: trimmedTitle,
|
||||||
|
videoUrl: trimmedVideoUrl,
|
||||||
|
description: trimmedDescription,
|
||||||
|
existingMediaIds: existingMediaIds,
|
||||||
|
newImages: newImages,
|
||||||
|
mediaIdsToDelete: mediaIdsToDelete,
|
||||||
|
);
|
||||||
|
emit(const BlogPostCreateState(isSuccess: true));
|
||||||
return true;
|
return true;
|
||||||
} catch (error, stackTrace) {
|
} catch (error, stackTrace) {
|
||||||
log('BlogPostCreateCubit: $error', stackTrace: stackTrace);
|
log('BlogPostCreateCubit: $error', stackTrace: stackTrace);
|
||||||
await reportFrom(error, stackTrace, this);
|
await reportFrom(error, stackTrace, this);
|
||||||
emit(BlogPostCreateState.error(errorMessage: error.toString()));
|
emit(BlogPostCreateState(errorMessage: error.toString()));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,23 @@ part 'blog_post_create_state.freezed.dart';
|
|||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
abstract class BlogPostCreateState with _$BlogPostCreateState {
|
abstract class BlogPostCreateState with _$BlogPostCreateState {
|
||||||
const factory BlogPostCreateState() = _BlogPostCreateState;
|
const BlogPostCreateState._();
|
||||||
|
|
||||||
const factory BlogPostCreateState.loading() = BlogPostCreateStateLoading;
|
const factory BlogPostCreateState({
|
||||||
|
@Default(false) bool isLoading,
|
||||||
|
@Default(false) bool isSuccess,
|
||||||
|
String? errorMessage,
|
||||||
|
String? titleError,
|
||||||
|
String? descriptionError,
|
||||||
|
String? videoUrlError,
|
||||||
|
String? imagesError,
|
||||||
|
String? formError,
|
||||||
|
}) = _BlogPostCreateState;
|
||||||
|
|
||||||
const factory BlogPostCreateState.loaded() = BlogPostCreateStateLoaded;
|
bool get hasValidationErrors =>
|
||||||
|
titleError != null ||
|
||||||
const factory BlogPostCreateState.error({
|
descriptionError != null ||
|
||||||
required String errorMessage,
|
videoUrlError != null ||
|
||||||
}) = BlogPostCreateStateError;
|
imagesError != null ||
|
||||||
|
formError != null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:eatvio_app/app_keys.dart';
|
import 'package:eatvio_app/app_keys.dart';
|
||||||
import 'package:eatvio_app/core/app_config.dart';
|
|
||||||
import 'package:eatvio_app/core/app_router.dart';
|
import 'package:eatvio_app/core/app_router.dart';
|
||||||
import 'package:eatvio_app/core/permission_handler/permission_handler.dart';
|
import 'package:eatvio_app/core/permission_handler/permission_handler.dart';
|
||||||
import 'package:eatvio_app/core/theme/app_decoration.dart';
|
import 'package:eatvio_app/core/theme/app_decoration.dart';
|
||||||
@@ -12,8 +12,13 @@ import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit
|
|||||||
import 'package:eatvio_app/core/locator_service.dart';
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||||
import 'package:eatvio_app/core/widgets/custom_text_form_field.dart';
|
import 'package:eatvio_app/core/widgets/custom_text_form_field.dart';
|
||||||
|
import 'package:eatvio_app/features/blog_post_create/repositories/blog_post_repository.dart';
|
||||||
|
import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart';
|
||||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/models/post_feed_pagination_model.dart';
|
||||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
||||||
|
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
|
||||||
import 'package:eatvio_app/generated/l10n.dart';
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -36,20 +41,31 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
|||||||
final blogPostCubit = sl<BlogPostCreateCubit>();
|
final blogPostCubit = sl<BlogPostCreateCubit>();
|
||||||
bool shrinkImage = false;
|
bool shrinkImage = false;
|
||||||
late StreamSubscription<bool> keyboardSubscription;
|
late StreamSubscription<bool> keyboardSubscription;
|
||||||
XFile? image;
|
final List<FeedGalleryImage> savedImages = [];
|
||||||
|
final List<XFile> pendingImages = [];
|
||||||
|
final List<int> mediaIdsToDelete = [];
|
||||||
final titleController = TextEditingController();
|
final titleController = TextEditingController();
|
||||||
final descriptionController = TextEditingController();
|
final descriptionController = TextEditingController();
|
||||||
final videoUrlController = TextEditingController();
|
final videoUrlController = TextEditingController();
|
||||||
bool isLoading = false;
|
|
||||||
bool enableMdPanel = false;
|
bool enableMdPanel = false;
|
||||||
bool isPreviewMode = false;
|
bool isPreviewMode = false;
|
||||||
final FocusNode descriptionFocusNode = FocusNode();
|
final FocusNode descriptionFocusNode = FocusNode();
|
||||||
|
|
||||||
|
bool get _isEditMode => widget.feedBlogPostModel != null;
|
||||||
|
|
||||||
|
int get _galleryCount => savedImages.length + pendingImages.length;
|
||||||
|
|
||||||
|
int get _remainingSlots =>
|
||||||
|
BlogPostRepository.maxGalleryImages - _galleryCount;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
blogPostCubit.resetFormErrors();
|
||||||
var keyboardVisibilityController = KeyboardVisibilityController();
|
var keyboardVisibilityController = KeyboardVisibilityController();
|
||||||
keyboardSubscription = keyboardVisibilityController.onChange.listen((bool visible) {
|
keyboardSubscription = keyboardVisibilityController.onChange.listen((
|
||||||
|
bool visible,
|
||||||
|
) {
|
||||||
setState(() {
|
setState(() {
|
||||||
shrinkImage = visible;
|
shrinkImage = visible;
|
||||||
enableMdPanel = visible;
|
enableMdPanel = visible;
|
||||||
@@ -59,6 +75,9 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
|||||||
titleController.text = widget.feedBlogPostModel!.title;
|
titleController.text = widget.feedBlogPostModel!.title;
|
||||||
descriptionController.text = widget.feedBlogPostModel!.description;
|
descriptionController.text = widget.feedBlogPostModel!.description;
|
||||||
videoUrlController.text = widget.feedBlogPostModel!.videoUrl;
|
videoUrlController.text = widget.feedBlogPostModel!.videoUrl;
|
||||||
|
savedImages.addAll(
|
||||||
|
widget.feedBlogPostModel!.images.where((image) => image.id != null),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +85,9 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
keyboardSubscription.cancel();
|
keyboardSubscription.cancel();
|
||||||
descriptionFocusNode.dispose();
|
descriptionFocusNode.dispose();
|
||||||
|
titleController.dispose();
|
||||||
|
descriptionController.dispose();
|
||||||
|
videoUrlController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,9 +100,16 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
|||||||
return selectedText.startsWith(prefix) && selectedText.endsWith(suffix);
|
return selectedText.startsWith(prefix) && selectedText.endsWith(suffix);
|
||||||
} else {
|
} else {
|
||||||
final cursorPosition = selection.baseOffset;
|
final cursorPosition = selection.baseOffset;
|
||||||
if (cursorPosition >= prefix.length && text.length >= cursorPosition + suffix.length) {
|
if (cursorPosition >= prefix.length &&
|
||||||
final beforeCursor = text.substring(cursorPosition - prefix.length, cursorPosition);
|
text.length >= cursorPosition + suffix.length) {
|
||||||
final afterCursor = text.substring(cursorPosition, cursorPosition + suffix.length);
|
final beforeCursor = text.substring(
|
||||||
|
cursorPosition - prefix.length,
|
||||||
|
cursorPosition,
|
||||||
|
);
|
||||||
|
final afterCursor = text.substring(
|
||||||
|
cursorPosition,
|
||||||
|
cursorPosition + suffix.length,
|
||||||
|
);
|
||||||
return beforeCursor == prefix && afterCursor == suffix;
|
return beforeCursor == prefix && afterCursor == suffix;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,23 +122,36 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
|||||||
|
|
||||||
if (selection.isValid && !selection.isCollapsed) {
|
if (selection.isValid && !selection.isCollapsed) {
|
||||||
final selectedText = text.substring(selection.start, selection.end);
|
final selectedText = text.substring(selection.start, selection.end);
|
||||||
final unformattedText = selectedText.substring(prefix.length, selectedText.length - suffix.length);
|
final unformattedText = selectedText.substring(
|
||||||
|
prefix.length,
|
||||||
|
selectedText.length - suffix.length,
|
||||||
|
);
|
||||||
|
|
||||||
final newText = text.replaceRange(selection.start, selection.end, unformattedText);
|
final newText = text.replaceRange(
|
||||||
|
selection.start,
|
||||||
|
selection.end,
|
||||||
|
unformattedText,
|
||||||
|
);
|
||||||
descriptionController.text = newText;
|
descriptionController.text = newText;
|
||||||
|
|
||||||
final newSelection = TextSelection(baseOffset: selection.start, extentOffset: selection.start + unformattedText.length);
|
final newSelection = TextSelection(
|
||||||
|
baseOffset: selection.start,
|
||||||
|
extentOffset: selection.start + unformattedText.length,
|
||||||
|
);
|
||||||
descriptionController.selection = newSelection;
|
descriptionController.selection = newSelection;
|
||||||
} else {
|
} else {
|
||||||
final cursorPosition = selection.baseOffset;
|
final cursorPosition = selection.baseOffset;
|
||||||
if (cursorPosition >= prefix.length && text.length >= cursorPosition + suffix.length) {
|
if (cursorPosition >= prefix.length &&
|
||||||
|
text.length >= cursorPosition + suffix.length) {
|
||||||
final beforeCursor = text.substring(0, cursorPosition - prefix.length);
|
final beforeCursor = text.substring(0, cursorPosition - prefix.length);
|
||||||
final afterCursor = text.substring(cursorPosition + suffix.length);
|
final afterCursor = text.substring(cursorPosition + suffix.length);
|
||||||
final newText = beforeCursor + afterCursor;
|
final newText = beforeCursor + afterCursor;
|
||||||
descriptionController.text = newText;
|
descriptionController.text = newText;
|
||||||
|
|
||||||
final newCursorPosition = cursorPosition - prefix.length;
|
final newCursorPosition = cursorPosition - prefix.length;
|
||||||
descriptionController.selection = TextSelection.collapsed(offset: newCursorPosition);
|
descriptionController.selection = TextSelection.collapsed(
|
||||||
|
offset: newCursorPosition,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -122,18 +164,28 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
|||||||
final selectedText = text.substring(selection.start, selection.end);
|
final selectedText = text.substring(selection.start, selection.end);
|
||||||
final formattedText = '$prefix$selectedText$suffix';
|
final formattedText = '$prefix$selectedText$suffix';
|
||||||
|
|
||||||
final newText = text.replaceRange(selection.start, selection.end, formattedText);
|
final newText = text.replaceRange(
|
||||||
|
selection.start,
|
||||||
|
selection.end,
|
||||||
|
formattedText,
|
||||||
|
);
|
||||||
descriptionController.text = newText;
|
descriptionController.text = newText;
|
||||||
|
|
||||||
final newSelection = TextSelection(baseOffset: selection.start, extentOffset: selection.start + formattedText.length);
|
final newSelection = TextSelection(
|
||||||
|
baseOffset: selection.start,
|
||||||
|
extentOffset: selection.start + formattedText.length,
|
||||||
|
);
|
||||||
descriptionController.selection = newSelection;
|
descriptionController.selection = newSelection;
|
||||||
} else {
|
} else {
|
||||||
final cursorPosition = selection.baseOffset;
|
final cursorPosition = selection.baseOffset;
|
||||||
final newText = '${text.substring(0, cursorPosition)}$prefix$suffix${text.substring(cursorPosition)}';
|
final newText =
|
||||||
|
'${text.substring(0, cursorPosition)}$prefix$suffix${text.substring(cursorPosition)}';
|
||||||
descriptionController.text = newText;
|
descriptionController.text = newText;
|
||||||
|
|
||||||
final newCursorPosition = cursorPosition + prefix.length;
|
final newCursorPosition = cursorPosition + prefix.length;
|
||||||
descriptionController.selection = TextSelection.collapsed(offset: newCursorPosition);
|
descriptionController.selection = TextSelection.collapsed(
|
||||||
|
offset: newCursorPosition,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,17 +240,31 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
|||||||
if (linkRegex.hasMatch(selectedText)) {
|
if (linkRegex.hasMatch(selectedText)) {
|
||||||
final match = linkRegex.firstMatch(selectedText);
|
final match = linkRegex.firstMatch(selectedText);
|
||||||
final linkText = match?.group(1) ?? selectedText;
|
final linkText = match?.group(1) ?? selectedText;
|
||||||
final newText = text.replaceRange(selection.start, selection.end, linkText);
|
final newText = text.replaceRange(
|
||||||
|
selection.start,
|
||||||
|
selection.end,
|
||||||
|
linkText,
|
||||||
|
);
|
||||||
descriptionController.text = newText;
|
descriptionController.text = newText;
|
||||||
|
|
||||||
final newSelection = TextSelection(baseOffset: selection.start, extentOffset: selection.start + linkText.length);
|
final newSelection = TextSelection(
|
||||||
|
baseOffset: selection.start,
|
||||||
|
extentOffset: selection.start + linkText.length,
|
||||||
|
);
|
||||||
descriptionController.selection = newSelection;
|
descriptionController.selection = newSelection;
|
||||||
} else {
|
} else {
|
||||||
final formattedText = '[$selectedText](url)';
|
final formattedText = '[$selectedText](url)';
|
||||||
final newText = text.replaceRange(selection.start, selection.end, formattedText);
|
final newText = text.replaceRange(
|
||||||
|
selection.start,
|
||||||
|
selection.end,
|
||||||
|
formattedText,
|
||||||
|
);
|
||||||
descriptionController.text = newText;
|
descriptionController.text = newText;
|
||||||
|
|
||||||
final newSelection = TextSelection(baseOffset: selection.start + selectedText.length + 2, extentOffset: selection.start + selectedText.length + 5);
|
final newSelection = TextSelection(
|
||||||
|
baseOffset: selection.start + selectedText.length + 2,
|
||||||
|
extentOffset: selection.start + selectedText.length + 5,
|
||||||
|
);
|
||||||
descriptionController.selection = newSelection;
|
descriptionController.selection = newSelection;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -209,11 +275,14 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
|||||||
void _applyHorizontalRule() {
|
void _applyHorizontalRule() {
|
||||||
final cursorPosition = descriptionController.selection.baseOffset;
|
final cursorPosition = descriptionController.selection.baseOffset;
|
||||||
final text = descriptionController.text;
|
final text = descriptionController.text;
|
||||||
final newText = '${text.substring(0, cursorPosition)}\n---\n${text.substring(cursorPosition)}';
|
final newText =
|
||||||
|
'${text.substring(0, cursorPosition)}\n---\n${text.substring(cursorPosition)}';
|
||||||
descriptionController.text = newText;
|
descriptionController.text = newText;
|
||||||
|
|
||||||
final newCursorPosition = cursorPosition + 5;
|
final newCursorPosition = cursorPosition + 5;
|
||||||
descriptionController.selection = TextSelection.collapsed(offset: newCursorPosition);
|
descriptionController.selection = TextSelection.collapsed(
|
||||||
|
offset: newCursorPosition,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _togglePreview() {
|
void _togglePreview() {
|
||||||
@@ -240,7 +309,9 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
|||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
border: Border.all(color: const Color(0xFFE0E0E0)),
|
border: Border.all(color: const Color(0xFFE0E0E0)),
|
||||||
),
|
),
|
||||||
child: SingleChildScrollView(child: _parseMarkdown(descriptionController.text)),
|
child: SingleChildScrollView(
|
||||||
|
child: _parseMarkdown(descriptionController.text),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,7 +319,11 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
|||||||
if (text.isEmpty) {
|
if (text.isEmpty) {
|
||||||
return const Text(
|
return const Text(
|
||||||
'Описание',
|
'Описание',
|
||||||
style: TextStyle(fontSize: 16, color: Color(0xFF323F4B), fontWeight: FontWeight.w500),
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
color: Color(0xFF323F4B),
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,21 +342,33 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
|||||||
widgets.add(
|
widgets.add(
|
||||||
Text(
|
Text(
|
||||||
line.substring(2),
|
line.substring(2),
|
||||||
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.black),
|
style: const TextStyle(
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.black,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else if (line.startsWith('## ')) {
|
} else if (line.startsWith('## ')) {
|
||||||
widgets.add(
|
widgets.add(
|
||||||
Text(
|
Text(
|
||||||
line.substring(3),
|
line.substring(3),
|
||||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.black),
|
style: const TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.black,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else if (line.startsWith('### ')) {
|
} else if (line.startsWith('### ')) {
|
||||||
widgets.add(
|
widgets.add(
|
||||||
Text(
|
Text(
|
||||||
line.substring(4),
|
line.substring(4),
|
||||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.black),
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.black,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else if (line.startsWith('- ') || line.startsWith('* ')) {
|
} else if (line.startsWith('- ') || line.startsWith('* ')) {
|
||||||
@@ -307,7 +394,11 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text('$number. ', style: const TextStyle(fontSize: 16)),
|
Text('$number. ', style: const TextStyle(fontSize: 16)),
|
||||||
Expanded(child: _parseInlineMarkdown(line.substring(number.length + 2))),
|
Expanded(
|
||||||
|
child: _parseInlineMarkdown(
|
||||||
|
line.substring(number.length + 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -323,7 +414,10 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: widgets);
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: widgets,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _parseInlineMarkdown(String text) {
|
Widget _parseInlineMarkdown(String text) {
|
||||||
@@ -347,14 +441,22 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
|||||||
spans.add(
|
spans.add(
|
||||||
TextSpan(
|
TextSpan(
|
||||||
text: matchedText.substring(2, matchedText.length - 2),
|
text: matchedText.substring(2, matchedText.length - 2),
|
||||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.black),
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.black,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else if (matchedText.startsWith('*') && matchedText.endsWith('*')) {
|
} else if (matchedText.startsWith('*') && matchedText.endsWith('*')) {
|
||||||
spans.add(
|
spans.add(
|
||||||
TextSpan(
|
TextSpan(
|
||||||
text: matchedText.substring(1, matchedText.length - 1),
|
text: matchedText.substring(1, matchedText.length - 1),
|
||||||
style: const TextStyle(fontSize: 16, fontStyle: FontStyle.italic, color: Colors.black),
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
color: Colors.black,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else if (matchedText.startsWith('[') && matchedText.contains('](')) {
|
} else if (matchedText.startsWith('[') && matchedText.contains('](')) {
|
||||||
@@ -364,7 +466,11 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
|||||||
spans.add(
|
spans.add(
|
||||||
TextSpan(
|
TextSpan(
|
||||||
text: linkText,
|
text: linkText,
|
||||||
style: const TextStyle(fontSize: 16, color: Colors.blue, decoration: TextDecoration.underline),
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
color: Colors.blue,
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -390,9 +496,11 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
|||||||
return BlocProvider<BlogPostCreateCubit>(
|
return BlocProvider<BlogPostCreateCubit>(
|
||||||
create: (BuildContext context) => blogPostCubit,
|
create: (BuildContext context) => blogPostCubit,
|
||||||
child: BlocListener<BlogPostCreateCubit, BlogPostCreateState>(
|
child: BlocListener<BlogPostCreateCubit, BlogPostCreateState>(
|
||||||
|
bloc: blogPostCubit,
|
||||||
listener: (BuildContext context, BlogPostCreateState state) {
|
listener: (BuildContext context, BlogPostCreateState state) {
|
||||||
if (state is BlogPostCreateStateLoaded) {
|
if (state.isSuccess) {
|
||||||
context.read<FeedCubit>().loadFeedBlogPostList();
|
context.read<FeedCubit>().loadFeedBlogPostList();
|
||||||
|
ProfileCubit.myProfileCubit?.reloadBlogPosts();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: _buildBlogPostCreate(context),
|
child: _buildBlogPostCreate(context),
|
||||||
@@ -401,6 +509,10 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _showImagePicker() {
|
void _showImagePicker() {
|
||||||
|
if (_remainingSlots <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
showCupertinoModalPopup<void>(
|
showCupertinoModalPopup<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
@@ -415,14 +527,27 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
|||||||
description: s.allowAccessToCameraDescription,
|
description: s.allowAccessToCameraDescription,
|
||||||
onGranted: () async {
|
onGranted: () async {
|
||||||
final picker = ImagePicker();
|
final picker = ImagePicker();
|
||||||
image = await picker.pickImage(source: ImageSource.camera);
|
final image = await picker.pickImage(
|
||||||
setState(() {});
|
source: ImageSource.camera,
|
||||||
|
);
|
||||||
|
if (image != null && _remainingSlots > 0) {
|
||||||
|
setState(() {
|
||||||
|
pendingImages.add(image);
|
||||||
|
});
|
||||||
|
blogPostCubit.clearImagesError();
|
||||||
|
}
|
||||||
AppRouter.navigatorKey.currentContext?.pop();
|
AppRouter.navigatorKey.currentContext?.pop();
|
||||||
},
|
},
|
||||||
context: context,
|
context: context,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: const Row(children: [Icon(Icons.photo_camera), SizedBox(width: 8), Text('Фото')]),
|
child: const Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.photo_camera),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Фото'),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
CupertinoActionSheetAction(
|
CupertinoActionSheetAction(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
@@ -432,30 +557,88 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
|||||||
description: s.allowAcessToGaleryDescription,
|
description: s.allowAcessToGaleryDescription,
|
||||||
onGranted: () async {
|
onGranted: () async {
|
||||||
final picker = ImagePicker();
|
final picker = ImagePicker();
|
||||||
image = await picker.pickImage(source: ImageSource.gallery);
|
final images = await picker.pickMultiImage(
|
||||||
setState(() {});
|
limit: _remainingSlots,
|
||||||
|
);
|
||||||
|
if (images.isNotEmpty) {
|
||||||
|
setState(() {
|
||||||
|
pendingImages.addAll(images.take(_remainingSlots));
|
||||||
|
});
|
||||||
|
blogPostCubit.clearImagesError();
|
||||||
|
}
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
AppRouter.navigatorKey.currentContext?.pop();
|
AppRouter.navigatorKey.currentContext?.pop();
|
||||||
},
|
},
|
||||||
context: context,
|
context: context,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: const Row(children: [Icon(Icons.photo), SizedBox(width: 8), Text('Галерея')]),
|
child: const Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.photo),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Галерея'),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
cancelButton: CupertinoActionSheetAction(onPressed: () => Navigator.of(context).pop(), isDestructiveAction: true, child: const Text('Отмена')),
|
cancelButton: CupertinoActionSheetAction(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
isDestructiveAction: true,
|
||||||
|
child: const Text('Отмена'),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _removeSavedImage(int index) {
|
||||||
|
final image = savedImages[index];
|
||||||
|
final mediaId = image.id;
|
||||||
|
setState(() {
|
||||||
|
savedImages.removeAt(index);
|
||||||
|
if (mediaId != null) {
|
||||||
|
mediaIdsToDelete.add(mediaId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _removePendingImage(int index) {
|
||||||
|
setState(() {
|
||||||
|
pendingImages.removeAt(index);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onReorderGallery(int oldIndex, int newIndex) {
|
||||||
|
setState(() {
|
||||||
|
if (newIndex > oldIndex) {
|
||||||
|
newIndex -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldIndex < savedImages.length && newIndex < savedImages.length) {
|
||||||
|
final item = savedImages.removeAt(oldIndex);
|
||||||
|
savedImages.insert(newIndex, item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final pendingOld = oldIndex - savedImages.length;
|
||||||
|
final pendingNew = newIndex - savedImages.length;
|
||||||
|
if (pendingOld >= 0 && pendingNew >= 0) {
|
||||||
|
final item = pendingImages.removeAt(pendingOld);
|
||||||
|
pendingImages.insert(pendingNew, item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Widget _mdButton(IconData icon, Function() onTap) {
|
Widget _mdButton(IconData icon, Function() onTap) {
|
||||||
return Expanded(
|
return Expanded(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Container(
|
child: Container(
|
||||||
height: 36,
|
height: 36,
|
||||||
decoration: BoxDecoration(color: const Color(0xFFF1F2F6), borderRadius: BorderRadius.circular(10)),
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFF1F2F6),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
child: Icon(icon),
|
child: Icon(icon),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -468,11 +651,18 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
|||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Container(
|
child: Container(
|
||||||
height: 36,
|
height: 36,
|
||||||
decoration: BoxDecoration(color: const Color(0xFFF1F2F6), borderRadius: BorderRadius.circular(10)),
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFF1F2F6),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
text,
|
text,
|
||||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w900, color: Colors.black87),
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -496,7 +686,12 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
|||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(color: const Color(0x00000000).withValues(alpha: 0.2), spreadRadius: 0, blurRadius: 32, offset: const Offset(0, 0)),
|
BoxShadow(
|
||||||
|
color: const Color(0x00000000).withValues(alpha: 0.2),
|
||||||
|
spreadRadius: 0,
|
||||||
|
blurRadius: 32,
|
||||||
|
offset: const Offset(0, 0),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -508,9 +703,15 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
|||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
_mdButton(Icons.format_italic, _applyItalic),
|
_mdButton(Icons.format_italic, _applyItalic),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
_mdButton(Icons.format_list_numbered, _applyNumberedList),
|
_mdButton(
|
||||||
|
Icons.format_list_numbered,
|
||||||
|
_applyNumberedList,
|
||||||
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
_mdButton(Icons.format_list_bulleted, _applyBulletedList),
|
_mdButton(
|
||||||
|
Icons.format_list_bulleted,
|
||||||
|
_applyBulletedList,
|
||||||
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
_mdButton(Icons.link, _applyLink),
|
_mdButton(Icons.link, _applyLink),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
@@ -528,157 +729,297 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
boxShadow: [BoxShadow(color: const Color(0x00000000).withValues(alpha: 0.2), spreadRadius: 0, blurRadius: 32, offset: const Offset(0, 0))],
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: const Color(0x00000000).withValues(alpha: 0.2),
|
||||||
|
spreadRadius: 0,
|
||||||
|
blurRadius: 32,
|
||||||
|
offset: const Offset(0, 0),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_mdButton(
|
||||||
|
isPreviewMode ? Icons.edit : Icons.visibility,
|
||||||
|
_togglePreview,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
child: Row(children: [_mdButton(isPreviewMode ? Icons.edit : Icons.visibility, _togglePreview)]),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
appBar: AppBar(),
|
appBar: AppBar(),
|
||||||
body: SingleChildScrollView(
|
body: BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
|
||||||
padding: const EdgeInsets.all(16),
|
bloc: blogPostCubit,
|
||||||
|
builder: (context, state) {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text('Заголовок'),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
CustomTextFormField(
|
||||||
|
key: AppKeys.blogPostCreateScreenTitleTextField,
|
||||||
|
controller: titleController,
|
||||||
|
hasError: state.titleError != null,
|
||||||
|
onChanged: (_) => blogPostCubit.clearTitleError(),
|
||||||
|
),
|
||||||
|
if (state.titleError != null) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
ValidationErrorWidget(error: state.titleError!),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text('Описание'),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
isPreviewMode
|
||||||
|
? _buildMarkdownPreview()
|
||||||
|
: CustomTextFormField(
|
||||||
|
key: AppKeys.blogPostCreateScreenContentTextField,
|
||||||
|
controller: descriptionController,
|
||||||
|
focusNode: descriptionFocusNode,
|
||||||
|
maxLines: 6,
|
||||||
|
minLines: 4,
|
||||||
|
hasError: state.descriptionError != null,
|
||||||
|
onChanged: (_) => blogPostCubit.clearDescriptionError(),
|
||||||
|
),
|
||||||
|
if (state.descriptionError != null) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
ValidationErrorWidget(error: state.descriptionError!),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Изображения ($_galleryCount/${BlogPostRepository.maxGalleryImages})',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildGallery(),
|
||||||
|
if (state.imagesError != null) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
ValidationErrorWidget(error: state.imagesError!),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text('Ссылка на видео'),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Rutube, VK или OK',
|
||||||
|
style: TextStyle(fontSize: 13, color: appTheme.blueGray300),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
CustomTextFormField(
|
||||||
|
key: AppKeys.blogPostCreateScreenVideoLinkTextField,
|
||||||
|
controller: videoUrlController,
|
||||||
|
hasError: state.videoUrlError != null,
|
||||||
|
keyboardType: TextInputType.url,
|
||||||
|
onChanged: (_) => blogPostCubit.clearVideoUrlError(),
|
||||||
|
),
|
||||||
|
if (state.videoUrlError != null) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
ValidationErrorWidget(error: state.videoUrlError!),
|
||||||
|
],
|
||||||
|
if (state.formError != null) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ValidationErrorWidget(error: state.formError!),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 80),
|
||||||
|
_createBlogPostButton(isSubmitting: state.isLoading),
|
||||||
|
const SizedBox(height: 80),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submitBlogPost() async {
|
||||||
|
if (blogPostCubit.state.isLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final title = titleController.text.trim();
|
||||||
|
final description = descriptionController.text.trim();
|
||||||
|
final videoUrl = videoUrlController.text.trim();
|
||||||
|
final existingMediaIds = savedImages.map((image) => image.id!).toList();
|
||||||
|
|
||||||
|
final bool result;
|
||||||
|
if (_isEditMode) {
|
||||||
|
result = await blogPostCubit.updateBlogPost(
|
||||||
|
id: widget.feedBlogPostModel!.id,
|
||||||
|
title: title,
|
||||||
|
description: description,
|
||||||
|
videoUrl: videoUrl,
|
||||||
|
existingMediaIds: existingMediaIds,
|
||||||
|
newImages: pendingImages,
|
||||||
|
mediaIdsToDelete: List<int>.from(mediaIdsToDelete),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
result = await blogPostCubit.createBlogPost(
|
||||||
|
title: title,
|
||||||
|
description: description,
|
||||||
|
videoUrl: videoUrl,
|
||||||
|
images: pendingImages,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
if (!mounted) return;
|
||||||
|
if (blogPostCubit.state.hasValidationErrors) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showErrorDialog(
|
||||||
|
context,
|
||||||
|
title: _isEditMode
|
||||||
|
? S.current.could_not_update_blog_post
|
||||||
|
: S.current.could_not_publish_blog_post,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
if (!mounted) return;
|
||||||
|
if (context.canPop()) {
|
||||||
|
context.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _createBlogPostButton({required bool isSubmitting}) {
|
||||||
|
return InkWell(
|
||||||
|
key: AppKeys.blogPostCreateScreenCreateButton,
|
||||||
|
onTap: isSubmitting ? null : _submitBlogPost,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: isSubmitting
|
||||||
|
? const Center(child: Loader())
|
||||||
|
: Ink(
|
||||||
|
width: MediaQuery.of(context).size.width - 32,
|
||||||
|
height: 52,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
color: appTheme.blueA400,
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
_isEditMode ? 'Сохранить' : 'Создать',
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildGallery() {
|
||||||
|
final itemHeight = shrinkImage ? 72.0 : 112.0;
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
height: itemHeight,
|
||||||
|
child: ReorderableListView.builder(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
buildDefaultDragHandles: false,
|
||||||
|
onReorder: _onReorderGallery,
|
||||||
|
itemCount: _galleryCount + (_remainingSlots > 0 ? 1 : 0),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
if (index == _galleryCount) {
|
||||||
|
return KeyedSubtree(
|
||||||
|
key: const ValueKey('add-photo'),
|
||||||
|
child: _buildAddPhotoTile(itemHeight),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index < savedImages.length) {
|
||||||
|
final image = savedImages[index];
|
||||||
|
final url = PostFeedMapper.resolveMediaUrl(image.url);
|
||||||
|
return KeyedSubtree(
|
||||||
|
key: ValueKey('saved-${image.id}'),
|
||||||
|
child: _buildGalleryTile(
|
||||||
|
index: index,
|
||||||
|
height: itemHeight,
|
||||||
|
imageProvider: NetworkImage(url),
|
||||||
|
onRemove: () => _removeSavedImage(index),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final pendingIndex = index - savedImages.length;
|
||||||
|
final pending = pendingImages[pendingIndex];
|
||||||
|
return KeyedSubtree(
|
||||||
|
key: ValueKey('pending-${pending.path}'),
|
||||||
|
child: _buildGalleryTile(
|
||||||
|
index: index,
|
||||||
|
height: itemHeight,
|
||||||
|
imageProvider: FileImage(File(pending.path)),
|
||||||
|
onRemove: () => _removePendingImage(pendingIndex),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAddPhotoTile(double height) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: _showImagePicker,
|
||||||
|
child: Container(
|
||||||
|
width: height,
|
||||||
|
height: height,
|
||||||
|
margin: const EdgeInsets.only(right: 8),
|
||||||
|
decoration: AppDecoration.fillGray.copyWith(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 16),
|
Icon(Icons.photo_library, color: appTheme.blueGray300),
|
||||||
const Text('Заголовок'),
|
const SizedBox(height: 4),
|
||||||
const SizedBox(height: 8),
|
Text(
|
||||||
CustomTextFormField(key: AppKeys.blogPostCreateScreenTitleTextField, controller: titleController),
|
'Добавить',
|
||||||
const SizedBox(height: 16),
|
style: TextStyle(fontSize: 12, color: appTheme.blueGray300),
|
||||||
const Text('Описание'),
|
),
|
||||||
const SizedBox(height: 8),
|
|
||||||
isPreviewMode
|
|
||||||
? _buildMarkdownPreview()
|
|
||||||
: CustomTextFormField(
|
|
||||||
key: AppKeys.blogPostCreateScreenContentTextField,
|
|
||||||
controller: descriptionController,
|
|
||||||
focusNode: descriptionFocusNode,
|
|
||||||
maxLines: 6,
|
|
||||||
minLines: 4,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
if (videoUrlController.text.isEmpty) const Text('Изображение поста'),
|
|
||||||
if (videoUrlController.text.isEmpty) const SizedBox(height: 8),
|
|
||||||
if (videoUrlController.text.isEmpty) _buildAddPhoto(),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
if (image == null && widget.feedBlogPostModel?.image == null) const Text('Ссылка на видео'),
|
|
||||||
if (image == null && widget.feedBlogPostModel?.image == null) const SizedBox(height: 8),
|
|
||||||
if (image == null && widget.feedBlogPostModel?.image == null)
|
|
||||||
CustomTextFormField(key: AppKeys.blogPostCreateScreenVideoLinkTextField, controller: videoUrlController),
|
|
||||||
const SizedBox(height: 80),
|
|
||||||
_createBlogPostButton(),
|
|
||||||
const SizedBox(height: 80),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _createBlogPostButton() {
|
Widget _buildGalleryTile({
|
||||||
return InkWell(
|
required int index,
|
||||||
onTap: isLoading
|
required double height,
|
||||||
? null
|
required ImageProvider imageProvider,
|
||||||
: () async {
|
required VoidCallback onRemove,
|
||||||
setState(() {
|
}) {
|
||||||
isLoading = true;
|
return ReorderableDelayedDragStartListener(
|
||||||
});
|
index: index,
|
||||||
if (widget.feedBlogPostModel != null) {
|
child: Container(
|
||||||
final result = await blogPostCubit.updateBlogPost(
|
width: height,
|
||||||
id: widget.feedBlogPostModel!.id,
|
height: height,
|
||||||
title: titleController.text,
|
margin: const EdgeInsets.only(right: 8),
|
||||||
description: descriptionController.text,
|
child: Stack(
|
||||||
videoUrl: videoUrlController.text,
|
clipBehavior: Clip.none,
|
||||||
image: image,
|
children: [
|
||||||
oldImageUrl: widget.feedBlogPostModel!.image,
|
Positioned.fill(
|
||||||
);
|
|
||||||
setState(() {
|
|
||||||
isLoading = false;
|
|
||||||
});
|
|
||||||
if (!result) {
|
|
||||||
if (!mounted) return;
|
|
||||||
showErrorDialog(context, title: S.current.could_not_update_blog_post);
|
|
||||||
} else {
|
|
||||||
if (!mounted) return;
|
|
||||||
if (context.canPop()) {
|
|
||||||
context.pop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
final result = await blogPostCubit.createBlogPost(
|
|
||||||
title: titleController.text,
|
|
||||||
description: descriptionController.text,
|
|
||||||
videoUrl: videoUrlController.text,
|
|
||||||
image: image,
|
|
||||||
);
|
|
||||||
setState(() {
|
|
||||||
isLoading = false;
|
|
||||||
});
|
|
||||||
if (!result) {
|
|
||||||
if (!mounted) return;
|
|
||||||
showErrorDialog(context, title: S.current.could_not_publish_blog_post);
|
|
||||||
} else {
|
|
||||||
if (!mounted) return;
|
|
||||||
if (context.canPop()) {
|
|
||||||
context.pop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
child: isLoading
|
|
||||||
? const Center(child: Loader())
|
|
||||||
: Ink(
|
|
||||||
width: MediaQuery.of(context).size.width - 32,
|
|
||||||
height: 52,
|
|
||||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(16), color: appTheme.blueA400),
|
|
||||||
child: const Center(
|
|
||||||
child: Text('Создать', style: TextStyle(color: Colors.white)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
GestureDetector _buildAddPhoto() {
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: () {
|
|
||||||
_showImagePicker();
|
|
||||||
},
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
|
||||||
if (image == null && widget.feedBlogPostModel?.image == null)
|
|
||||||
AnimatedContainer(
|
|
||||||
height: shrinkImage ? 52 : 128,
|
|
||||||
curve: Curves.easeIn,
|
|
||||||
duration: const Duration(milliseconds: 150),
|
|
||||||
decoration: AppDecoration.fillGray.copyWith(borderRadius: BorderRadius.circular(16)),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.photo_library, color: appTheme.blueGray300),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text("Добавить фото", selectionColor: appTheme.blueA400),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (image != null)
|
|
||||||
SizedBox(
|
|
||||||
width: shrinkImage ? 52 : MediaQuery.of(context).size.width - 32,
|
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
child: Image(image: FileImage(File(image!.path))),
|
child: Image(image: imageProvider, fit: BoxFit.cover),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (widget.feedBlogPostModel?.image != null && image == null)
|
Positioned(
|
||||||
SizedBox(
|
top: -6,
|
||||||
width: shrinkImage ? 52 : MediaQuery.of(context).size.width - 32,
|
right: -6,
|
||||||
child: ClipRRect(
|
child: Material(
|
||||||
borderRadius: BorderRadius.circular(16),
|
color: Colors.white,
|
||||||
child: Image(image: NetworkImage('${sl<AppConfig>().baseUrl}/storage/${widget.feedBlogPostModel!.image}')),
|
shape: const CircleBorder(),
|
||||||
|
elevation: 1,
|
||||||
|
child: InkWell(
|
||||||
|
customBorder: const CircleBorder(),
|
||||||
|
onTap: onRemove,
|
||||||
|
child: const Padding(
|
||||||
|
padding: EdgeInsets.all(4),
|
||||||
|
child: Icon(Icons.close, size: 16),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,79 +1,122 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:eatvio_app/core/app_config.dart';
|
|
||||||
import 'package:eatvio_app/core/locator_service.dart';
|
|
||||||
import 'package:http/http.dart' as http;
|
|
||||||
import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart';
|
import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart';
|
||||||
import 'package:eatvio_app/features/blog_post_create/services/blog_post_service.dart';
|
import 'package:eatvio_app/features/blog_post_create/services/blog_post_service.dart';
|
||||||
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
|
||||||
|
|
||||||
class BlogPostRepository {
|
class BlogPostRepository {
|
||||||
|
static const maxGalleryImages = 10;
|
||||||
|
|
||||||
final BlogPostService blogPostService;
|
final BlogPostService blogPostService;
|
||||||
|
|
||||||
BlogPostRepository({
|
BlogPostRepository({required this.blogPostService});
|
||||||
required this.blogPostService,
|
|
||||||
});
|
|
||||||
|
|
||||||
Future<void> createBlogPost(
|
Future<void> createBlogPost({
|
||||||
String? title,
|
String? title,
|
||||||
String? videoUrl,
|
String? videoUrl,
|
||||||
String? description,
|
String? description,
|
||||||
XFile? image,
|
List<XFile> images = const [],
|
||||||
) async {
|
}) async {
|
||||||
final formData = FormData.fromMap({
|
final formData = await _buildFormData(
|
||||||
if (title != null && title.isNotEmpty) 'title': title,
|
title: title,
|
||||||
if (description != null && description.isNotEmpty) 'description_md': description,
|
videoUrl: videoUrl,
|
||||||
if (videoUrl != null && videoUrl.isNotEmpty) 'video_url': videoUrl,
|
description: description,
|
||||||
if (image != null) 'image': await MultipartFile.fromFile((await ImageCompressUtility.compressFile(File(image.path))).path, filename: image.name),
|
newImages: images,
|
||||||
});
|
|
||||||
|
|
||||||
return blogPostService.createBlogPost(
|
|
||||||
formData: formData,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return blogPostService.createBlogPost(formData: formData);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<XFile?> downloadImage(String imageUrl) async {
|
Future<void> updateBlogPost({
|
||||||
final response = await http.get(Uri.parse('${sl<AppConfig>().baseUrl}/storage/$imageUrl'));
|
required int id,
|
||||||
|
String? title,
|
||||||
if (response.statusCode == 200) {
|
String? videoUrl,
|
||||||
final directory = await getTemporaryDirectory();
|
String? description,
|
||||||
final filePath = '${directory.path}/image.jpg';
|
List<int> existingMediaIds = const [],
|
||||||
final file = File(filePath);
|
List<XFile> newImages = const [],
|
||||||
await file.writeAsBytes(response.bodyBytes);
|
List<int> mediaIdsToDelete = const [],
|
||||||
|
}) async {
|
||||||
return XFile(file.path);
|
if (mediaIdsToDelete.isNotEmpty) {
|
||||||
|
await Future.wait(
|
||||||
|
mediaIdsToDelete.map(
|
||||||
|
(mediaId) =>
|
||||||
|
blogPostService.deleteBlogPostMedia(postId: id, mediaId: mediaId),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
|
final formData = await _buildFormData(
|
||||||
|
title: title ?? '',
|
||||||
|
videoUrl: videoUrl ?? '',
|
||||||
|
description: description ?? '',
|
||||||
|
existingMediaIds: existingMediaIds,
|
||||||
|
newImages: newImages,
|
||||||
|
includeExistingMediaIds: true,
|
||||||
|
includeEmptyTextFields: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
return blogPostService.updateBlogPost(id: id, formData: formData);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateBlogPost(
|
Future<void> deleteBlogPostMedia({
|
||||||
int id,
|
required int postId,
|
||||||
String? title,
|
required int mediaId,
|
||||||
String? videoUrl,
|
}) async {
|
||||||
String? description,
|
await blogPostService.deleteBlogPostMedia(postId: postId, mediaId: mediaId);
|
||||||
XFile? image,
|
|
||||||
String? oldImageUrl,
|
|
||||||
) async {
|
|
||||||
final formData = FormData.fromMap({
|
|
||||||
'title': title,
|
|
||||||
'description_md': description,
|
|
||||||
'video_url': videoUrl,
|
|
||||||
'image': image != null
|
|
||||||
? await MultipartFile.fromFile((await ImageCompressUtility.compressFile(File(image.path))).path, filename: image.name)
|
|
||||||
: oldImageUrl != '' && oldImageUrl != null
|
|
||||||
? await MultipartFile.fromFile((await downloadImage(oldImageUrl))?.path ?? '', filename: oldImageUrl.split('/').last)
|
|
||||||
: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
return blogPostService.updateBlogPost(
|
|
||||||
id: id,
|
|
||||||
formData: formData,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> deleteBlogPost(int id) async {
|
Future<void> deleteBlogPost(int id) async {
|
||||||
await blogPostService.deleteBlogPost(id: id);
|
await blogPostService.deleteBlogPost(id: id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<FormData> _buildFormData({
|
||||||
|
String? title,
|
||||||
|
String? videoUrl,
|
||||||
|
String? description,
|
||||||
|
List<int> existingMediaIds = const [],
|
||||||
|
List<XFile> newImages = const [],
|
||||||
|
bool includeExistingMediaIds = false,
|
||||||
|
bool includeEmptyTextFields = false,
|
||||||
|
}) async {
|
||||||
|
final formData = FormData();
|
||||||
|
|
||||||
|
if (includeEmptyTextFields) {
|
||||||
|
formData.fields.add(MapEntry('title', title ?? ''));
|
||||||
|
formData.fields.add(MapEntry('description_md', description ?? ''));
|
||||||
|
formData.fields.add(MapEntry('video_url', videoUrl ?? ''));
|
||||||
|
} else {
|
||||||
|
if (title != null && title.isNotEmpty) {
|
||||||
|
formData.fields.add(MapEntry('title', title));
|
||||||
|
}
|
||||||
|
if (description != null && description.isNotEmpty) {
|
||||||
|
formData.fields.add(MapEntry('description_md', description));
|
||||||
|
}
|
||||||
|
if (videoUrl != null && videoUrl.isNotEmpty) {
|
||||||
|
formData.fields.add(MapEntry('video_url', videoUrl));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (includeExistingMediaIds) {
|
||||||
|
for (final mediaId in existingMediaIds) {
|
||||||
|
formData.fields.add(
|
||||||
|
MapEntry('existing_media_ids[]', mediaId.toString()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final image in newImages) {
|
||||||
|
final compressed = await ImageCompressUtility.compressFile(
|
||||||
|
File(image.path),
|
||||||
|
);
|
||||||
|
formData.files.add(
|
||||||
|
MapEntry(
|
||||||
|
'images[]',
|
||||||
|
await MultipartFile.fromFile(compressed.path, filename: image.name),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return formData;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,21 +7,23 @@ part 'blog_post_service.g.dart';
|
|||||||
abstract class BlogPostService {
|
abstract class BlogPostService {
|
||||||
factory BlogPostService(Dio dio, {String baseUrl}) = _BlogPostService;
|
factory BlogPostService(Dio dio, {String baseUrl}) = _BlogPostService;
|
||||||
|
|
||||||
@POST('/api/blogPosts/create/published')
|
@POST('/api/v2/blogPosts/create/published')
|
||||||
@MultiPart()
|
@MultiPart()
|
||||||
Future<void> createBlogPost({
|
Future<void> createBlogPost({@Body() required FormData formData});
|
||||||
@Body() required FormData formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
@POST('/api/blogPosts/update/published/{id}')
|
@PUT('/api/v2/blogPosts/update/published/{id}')
|
||||||
@MultiPart()
|
@MultiPart()
|
||||||
Future<void> updateBlogPost({
|
Future<void> updateBlogPost({
|
||||||
@Path('id') required int id,
|
@Path('id') required int id,
|
||||||
@Body() required FormData formData,
|
@Body() required FormData formData,
|
||||||
});
|
});
|
||||||
|
|
||||||
@DELETE('/api/blogPosts/delete/{id}')
|
@DELETE('/api/v2/blogPosts/{postId}/media/{mediaId}')
|
||||||
Future<void> deleteBlogPost({
|
Future<void> deleteBlogPostMedia({
|
||||||
@Path('id') required int id,
|
@Path('postId') required int postId,
|
||||||
|
@Path('mediaId') required int mediaId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@DELETE('/api/blogPosts/delete/{id}')
|
||||||
|
Future<void> deleteBlogPost({@Path('id') required int id});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
/// Client-side validation aligned with backend BlogPost V2 create/update rules.
|
||||||
|
abstract final class BlogPostFormValidator {
|
||||||
|
static const minTextLength = 3;
|
||||||
|
static const maxVideoUrlLength = 2048;
|
||||||
|
static const maxGalleryImages = 10;
|
||||||
|
static const allowedImageExtensions = {'jpg', 'jpeg', 'png'};
|
||||||
|
|
||||||
|
static const titleTooShort = 'Заголовок должен содержать минимум 3 символа';
|
||||||
|
static const descriptionTooShort =
|
||||||
|
'Описание должно содержать минимум 3 символа';
|
||||||
|
static const contentRequired =
|
||||||
|
'Добавьте заголовок, описание, изображение или ссылку на видео';
|
||||||
|
static const galleryTooLarge = 'Можно загрузить не более 10 изображений';
|
||||||
|
static const imageTypeInvalid = 'Допустимы только изображения JPG или PNG';
|
||||||
|
static const videoUrlTooLong = 'Ссылка на видео слишком длинная';
|
||||||
|
static const videoUrlInvalid = 'Укажите корректную ссылку на видео';
|
||||||
|
static const videoUrlUnsupportedHost =
|
||||||
|
'Поддерживаются только Rutube, VK и OK';
|
||||||
|
static const videoUrlRutubeFormat = 'Неверный формат ссылки Rutube';
|
||||||
|
static const videoUrlVkFormat = 'Неверный формат ссылки VK';
|
||||||
|
static const videoUrlOkFormat = 'Неверный формат ссылки OK';
|
||||||
|
|
||||||
|
/// Empty is allowed; non-empty must be at least [minTextLength] chars (after trim).
|
||||||
|
static String? title(String? value) {
|
||||||
|
final text = value?.trim() ?? '';
|
||||||
|
if (text.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (text.length < minTextLength) {
|
||||||
|
return titleTooShort;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Empty is allowed; non-empty must be at least [minTextLength] chars (after trim).
|
||||||
|
static String? description(String? value) {
|
||||||
|
final text = value?.trim() ?? '';
|
||||||
|
if (text.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (text.length < minTextLength) {
|
||||||
|
return descriptionTooShort;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Empty is allowed; non-empty must match allowed video hosts/paths.
|
||||||
|
static String? videoUrl(String? value) {
|
||||||
|
final url = value?.trim() ?? '';
|
||||||
|
if (url.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (url.length > maxVideoUrlLength) {
|
||||||
|
return videoUrlTooLong;
|
||||||
|
}
|
||||||
|
|
||||||
|
final uri = Uri.tryParse(url);
|
||||||
|
if (uri == null || !uri.hasScheme || uri.host.isEmpty) {
|
||||||
|
return videoUrlInvalid;
|
||||||
|
}
|
||||||
|
if (uri.scheme != 'http' && uri.scheme != 'https') {
|
||||||
|
return videoUrlInvalid;
|
||||||
|
}
|
||||||
|
|
||||||
|
final host = uri.host.toLowerCase();
|
||||||
|
if (!_hostMatchesRootAllowlist(host, const [
|
||||||
|
'vk.com',
|
||||||
|
'vkvideo.ru',
|
||||||
|
'ok.ru',
|
||||||
|
'rutube.ru',
|
||||||
|
])) {
|
||||||
|
return videoUrlUnsupportedHost;
|
||||||
|
}
|
||||||
|
|
||||||
|
final path = uri.path;
|
||||||
|
if (_hostMatchesRootAllowlist(host, const ['rutube.ru'])) {
|
||||||
|
if (!_rutubePath.hasMatch(path)) {
|
||||||
|
return videoUrlRutubeFormat;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (_hostMatchesRootAllowlist(host, const ['vk.com', 'vkvideo.ru'])) {
|
||||||
|
if (!_vkPath.hasMatch(path)) {
|
||||||
|
return videoUrlVkFormat;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (_hostMatchesRootAllowlist(host, const ['ok.ru'])) {
|
||||||
|
if (!_okPath.hasMatch(path)) {
|
||||||
|
return videoUrlOkFormat;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// At least one of title / description / images / video must be meaningfully filled.
|
||||||
|
static String? contentRequiredMessage({
|
||||||
|
required String title,
|
||||||
|
required String description,
|
||||||
|
required String videoUrl,
|
||||||
|
required int imageCount,
|
||||||
|
}) {
|
||||||
|
final hasTitle = title.trim().length >= minTextLength;
|
||||||
|
final hasDescription = description.trim().length >= minTextLength;
|
||||||
|
final trimmedVideo = videoUrl.trim();
|
||||||
|
final hasVideo =
|
||||||
|
trimmedVideo.isNotEmpty &&
|
||||||
|
BlogPostFormValidator.videoUrl(trimmedVideo) == null;
|
||||||
|
final hasImages = imageCount > 0;
|
||||||
|
|
||||||
|
if (hasTitle || hasDescription || hasVideo || hasImages) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return contentRequired;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? galleryCount(int count) {
|
||||||
|
if (count > maxGalleryImages) {
|
||||||
|
return galleryTooLarge;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns error if any path has a non-allowed extension. Empty path is ignored.
|
||||||
|
static String? imagePaths(Iterable<String> paths) {
|
||||||
|
for (final path in paths) {
|
||||||
|
final name = path.split(RegExp(r'[\\/]')).last;
|
||||||
|
final dot = name.lastIndexOf('.');
|
||||||
|
if (dot < 0 || dot == name.length - 1) {
|
||||||
|
return imageTypeInvalid;
|
||||||
|
}
|
||||||
|
final ext = name.substring(dot + 1).toLowerCase();
|
||||||
|
if (!allowedImageExtensions.contains(ext)) {
|
||||||
|
return imageTypeInvalid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static final _rutubePath = RegExp(
|
||||||
|
r'^/(video|shorts)/[a-z0-9]+/?$',
|
||||||
|
caseSensitive: false,
|
||||||
|
);
|
||||||
|
static final _vkPath = RegExp(r'^/video-?\d+_\d+/?$', caseSensitive: false);
|
||||||
|
static final _okPath = RegExp(r'^/video/\d+/?$');
|
||||||
|
|
||||||
|
static bool _hostMatchesRootAllowlist(
|
||||||
|
String host,
|
||||||
|
List<String> allowedRootHosts,
|
||||||
|
) {
|
||||||
|
for (final allowed in allowedRootHosts) {
|
||||||
|
if (host == allowed || host.endsWith('.$allowed')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,7 +33,7 @@ abstract class ChatMessageModel with _$ChatMessageModel {
|
|||||||
@JsonKey(name: 'read_by_to_user', defaultValue: false) required bool isRead,
|
@JsonKey(name: 'read_by_to_user', defaultValue: false) required bool isRead,
|
||||||
@JsonKey(name: 'is_author_message_auth_user') required bool? isCurrentUserAuthor,
|
@JsonKey(name: 'is_author_message_auth_user') required bool? isCurrentUserAuthor,
|
||||||
@JsonKey(name: 'room_id') required String? roomId,
|
@JsonKey(name: 'room_id') required String? roomId,
|
||||||
@JsonKey(name: 'type') String? type,
|
@JsonKey(name: 'type', unknownEnumValue: ChatMessageType.unknown) ChatMessageType? type,
|
||||||
@Default(MessageState.sent) @JsonKey(includeToJson: false, includeFromJson: false) MessageState messageState,
|
@Default(MessageState.sent) @JsonKey(includeToJson: false, includeFromJson: false) MessageState messageState,
|
||||||
@Default(null) @JsonKey(includeToJson: false, includeFromJson: false) String? uuid,
|
@Default(null) @JsonKey(includeToJson: false, includeFromJson: false) String? uuid,
|
||||||
}) = _ChatMessageModel;
|
}) = _ChatMessageModel;
|
||||||
@@ -42,3 +42,22 @@ abstract class ChatMessageModel with _$ChatMessageModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum MessageState { loading, sent, error }
|
enum MessageState { loading, sent, error }
|
||||||
|
|
||||||
|
enum ChatMessageType {
|
||||||
|
@JsonValue('text')
|
||||||
|
text,
|
||||||
|
@JsonValue('system')
|
||||||
|
system,
|
||||||
|
@JsonValue('attachment')
|
||||||
|
attachment,
|
||||||
|
@JsonValue('image')
|
||||||
|
image,
|
||||||
|
@JsonValue('plan_order')
|
||||||
|
planOrder,
|
||||||
|
@JsonValue('video_call_started')
|
||||||
|
videoCallStarted,
|
||||||
|
@JsonValue('video_call_ended')
|
||||||
|
videoCallEnded,
|
||||||
|
@JsonValue('unknown')
|
||||||
|
unknown;
|
||||||
|
}
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ abstract class ChatUser with _$ChatUser {
|
|||||||
@JsonKey(name: "name") required String name,
|
@JsonKey(name: "name") required String name,
|
||||||
@JsonKey(name: "profile_photo_path") required String? profilePhotoPath,
|
@JsonKey(name: "profile_photo_path") required String? profilePhotoPath,
|
||||||
@JsonKey(name: "profile_photo_url") required String profilePhotoUrl,
|
@JsonKey(name: "profile_photo_url") required String profilePhotoUrl,
|
||||||
|
@Default(false) @JsonKey(name: 'is_online') bool isOnline,
|
||||||
|
@Default('') @JsonKey(name: 'last_seen_text') String lastSeenText,
|
||||||
}) = _ChatUser;
|
}) = _ChatUser;
|
||||||
|
|
||||||
factory ChatUser.fromJson(Map<String, dynamic> json) => _$ChatUserFromJson(json);
|
factory ChatUser.fromJson(Map<String, dynamic> json) => _$ChatUserFromJson(json);
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ abstract class ChatUserModel with _$ChatUserModel {
|
|||||||
required String name,
|
required String name,
|
||||||
required String slug,
|
required String slug,
|
||||||
@JsonKey(name: 'profile_photo_url') String? profilePhotoUrl,
|
@JsonKey(name: 'profile_photo_url') String? profilePhotoUrl,
|
||||||
|
@Default(false) @JsonKey(name: 'is_online') bool isOnline,
|
||||||
|
@Default('') @JsonKey(name: 'last_seen_text') String lastSeenText,
|
||||||
}) = _ChatUserModel;
|
}) = _ChatUserModel;
|
||||||
|
|
||||||
factory ChatUserModel.fromJson(Map<String, Object?> json) => _$ChatUserModelFromJson(json);
|
factory ChatUserModel.fromJson(Map<String, Object?> json) => _$ChatUserModelFromJson(json);
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import 'package:eatvio_app/features/chat/models/plan_complain_model.dart';
|
|||||||
import 'package:eatvio_app/features/chat/presentation/bloc/chat_room_cubit/chat_room_state.dart';
|
import 'package:eatvio_app/features/chat/presentation/bloc/chat_room_cubit/chat_room_state.dart';
|
||||||
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
|
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
|
||||||
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
|
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
|
||||||
|
import 'package:eatvio_app/features/chat/utils/chat_counterpart_utils.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart';
|
||||||
import 'package:eatvio_app/features/my_finance/models/card_model.dart';
|
import 'package:eatvio_app/features/my_finance/models/card_model.dart';
|
||||||
import 'package:eatvio_app/features/my_finance/repositories/cards_repository.dart';
|
import 'package:eatvio_app/features/my_finance/repositories/cards_repository.dart';
|
||||||
import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart';
|
import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart';
|
||||||
@@ -26,6 +28,7 @@ import 'package:uuid/uuid.dart';
|
|||||||
class ChatRoomCubit extends Cubit<ChatRoomState> {
|
class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||||
final ChatRepository chatRepository;
|
final ChatRepository chatRepository;
|
||||||
final CardsRepository cardsRepository;
|
final CardsRepository cardsRepository;
|
||||||
|
final ConsultantRepository consultantRepository;
|
||||||
final WsManager wsManager;
|
final WsManager wsManager;
|
||||||
|
|
||||||
StreamSubscription<ChannelReadEvent>? typingSubscription;
|
StreamSubscription<ChannelReadEvent>? typingSubscription;
|
||||||
@@ -34,9 +37,16 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
late int lastPage;
|
late int lastPage;
|
||||||
bool isLoading = false;
|
bool isLoading = false;
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
|
String? _activeChatUuid;
|
||||||
|
void Function(bool isOnline)? _presenceOnlineListener;
|
||||||
var uuid = const Uuid();
|
var uuid = const Uuid();
|
||||||
|
|
||||||
ChatRoomCubit({required this.chatRepository, required this.wsManager, required this.cardsRepository}) : super(const ChatRoomState.loading());
|
ChatRoomCubit({
|
||||||
|
required this.chatRepository,
|
||||||
|
required this.wsManager,
|
||||||
|
required this.cardsRepository,
|
||||||
|
required this.consultantRepository,
|
||||||
|
}) : super(const ChatRoomState.loading());
|
||||||
|
|
||||||
void setTimer() {
|
void setTimer() {
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
@@ -51,16 +61,16 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
if (currentPage < lastPage && !isLoading) {
|
if (currentPage < lastPage && !isLoading) {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
|
|
||||||
emit((state as ChatRoomStateLoaded).copyWith(isLoading: isLoading));
|
_emitLoaded((current) => current.copyWith(isLoading: isLoading));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final chatMessages = await chatRepository.getChatMessages(uuid, currentPage + 1);
|
final chatMessages = await chatRepository.getChatMessages(uuid, currentPage + 1);
|
||||||
|
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
emit(
|
_emitLoaded(
|
||||||
(state as ChatRoomStateLoaded).copyWith(
|
(current) => current.copyWith(
|
||||||
isLoading: isLoading,
|
isLoading: isLoading,
|
||||||
chatMessages: [...(state as ChatRoomStateLoaded).chatMessages, ...chatMessages.messagesList],
|
chatMessages: [...current.chatMessages, ...chatMessages.messagesList],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -83,11 +93,20 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _setCard({required List<CardModel> cards}) {
|
void _setCard({required List<CardModel> cards}) {
|
||||||
final currentState = state;
|
if (cards.where((card) => card.isDefault).isNotEmpty) {
|
||||||
if (currentState is ChatRoomStateLoaded) {
|
_emitLoaded((current) => current.copyWith(hasCard: true));
|
||||||
if (cards.where((card) => card.isDefault).isNotEmpty) {
|
}
|
||||||
emit(currentState.copyWith(hasCard: true));
|
}
|
||||||
|
|
||||||
|
Future<void> checkAlfaScoringStatus(String slug) async {
|
||||||
|
try {
|
||||||
|
final status = await consultantRepository.getMyRequestStatus(slug);
|
||||||
|
if (status.legalEntity?.alfaScoringStatus.isComplete == true) {
|
||||||
|
_emitLoaded((current) => current.copyWith(isAlfaScoringComplete: true));
|
||||||
}
|
}
|
||||||
|
} catch (e, st) {
|
||||||
|
await reportFrom(e, st, this);
|
||||||
|
log(e.toString(), stackTrace: st);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,6 +133,9 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
throw ArgumentError('initChat requires uuid or orderId');
|
throw ArgumentError('initChat requires uuid or orderId');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
currentPage = 1;
|
||||||
|
isLoading = false;
|
||||||
|
|
||||||
final chatRoom = await chatRepository.getChatRoom(resolvedUuid);
|
final chatRoom = await chatRepository.getChatRoom(resolvedUuid);
|
||||||
ChosenPlanModel? chosenPlan;
|
ChosenPlanModel? chosenPlan;
|
||||||
final planId = resolvedChosenPlanId ?? chatRoom.chosenPlanId;
|
final planId = resolvedChosenPlanId ?? chatRoom.chosenPlanId;
|
||||||
@@ -132,9 +154,14 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
hasCard: false,
|
hasCard: false,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
await _subscribeToPresenceChannel(chatId: chatRoom.id);
|
||||||
|
|
||||||
if (chosenPlan?.consultantId == chatRoom.users.sender.id) {
|
if (chosenPlan?.consultantId == wsManager.userId) {
|
||||||
|
final mySlug = chatRoom.users.sender.id == wsManager.userId
|
||||||
|
? chatRoom.users.sender.slug
|
||||||
|
: chatRoom.users.receiver.slug;
|
||||||
await checkCards();
|
await checkCards();
|
||||||
|
await checkAlfaScoringStatus(mySlug);
|
||||||
}
|
}
|
||||||
return resolvedUuid;
|
return resolvedUuid;
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
@@ -151,13 +178,15 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
Future<void> updateChosenPlanState() async {
|
Future<void> updateChosenPlanState() async {
|
||||||
try {
|
try {
|
||||||
final currentState = state;
|
final currentState = state;
|
||||||
if (currentState is ChatRoomStateLoaded) {
|
if (currentState is! ChatRoomStateLoaded) {
|
||||||
final chosenPlanId = currentState.chatRoom.chosenPlanId;
|
return;
|
||||||
if (chosenPlanId != null) {
|
|
||||||
final chosenPlan = await chatRepository.getChosenPlan(id: chosenPlanId);
|
|
||||||
emit(currentState.copyWith.chatRoom(chosenPlan: chosenPlan));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
final chosenPlanId = currentState.chatRoom.chosenPlanId;
|
||||||
|
if (chosenPlanId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final chosenPlan = await chatRepository.getChosenPlan(id: chosenPlanId);
|
||||||
|
_emitLoaded((latest) => latest.copyWith.chatRoom(chosenPlan: chosenPlan));
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
}
|
}
|
||||||
@@ -190,93 +219,240 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _emitLoaded(ChatRoomStateLoaded Function(ChatRoomStateLoaded current) update) {
|
||||||
|
if (isClosed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final currentState = state;
|
||||||
|
if (currentState is! ChatRoomStateLoaded) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
emit(update(currentState));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _subscribeToChatChannel({required String uuid, required String chatId}) async {
|
Future<void> _subscribeToChatChannel({required String uuid, required String chatId}) async {
|
||||||
final client = wsManager.client;
|
final client = wsManager.client;
|
||||||
if (client == null) return;
|
if (client == null) return;
|
||||||
|
_activeChatUuid = uuid;
|
||||||
|
await messagesSubscription?.cancel();
|
||||||
|
await typingSubscription?.cancel();
|
||||||
|
messagesSubscription = null;
|
||||||
|
typingSubscription = null;
|
||||||
await wsManager.initPrivateDirectChatChannel(chatId);
|
await wsManager.initPrivateDirectChatChannel(chatId);
|
||||||
wsManager.subscribeDirectChatChannel();
|
wsManager.subscribeDirectChatChannel();
|
||||||
messagesSubscription = wsManager.directChatStream?.listen((event) {
|
messagesSubscription = wsManager.directChatStream?.listen(
|
||||||
AudioPlayer().play((AssetSource('audio/recive_message.wav')));
|
_onDirectChatEvent,
|
||||||
final message = ChatMessageModel.fromJson(jsonDecode(event.data));
|
onError: (Object e, StackTrace st) {
|
||||||
final currentState = state as ChatRoomStateLoaded;
|
unawaited(reportFrom(e, st, this));
|
||||||
|
},
|
||||||
// Сообщения от текущего пользователя не приходят через WebSocket,
|
);
|
||||||
// они обрабатываются через ответ API в sendMessage
|
|
||||||
// Здесь обрабатываем только входящие сообщения от других пользователей
|
|
||||||
List<ChatMessageModel> messagesList = [message, ...currentState.chatMessages];
|
|
||||||
|
|
||||||
chatRepository.emitNewMessageToChats(message);
|
|
||||||
emit(currentState.copyWith(chatMessages: messagesList, isLoading: isLoading));
|
|
||||||
EasyDebounce.debounce('searchChatRoom', const Duration(milliseconds: 300), () => sl<ChatsCubit>().getChatRooms());
|
|
||||||
});
|
|
||||||
|
|
||||||
typingSubscription = wsManager.clientTypingStream?.listen((event) {
|
typingSubscription = wsManager.clientTypingStream?.listen((event) {
|
||||||
emit((state as ChatRoomStateLoaded).copyWith(isTyping: true, isLoading: isLoading));
|
_emitLoaded((current) => current.copyWith(isTyping: true, isLoading: isLoading));
|
||||||
EasyDebounce.debounce(
|
EasyDebounce.debounce(
|
||||||
'client-typing',
|
'client-typing',
|
||||||
const Duration(seconds: 3),
|
const Duration(seconds: 3),
|
||||||
() => emit((state as ChatRoomStateLoaded).copyWith(isTyping: false, isLoading: isLoading)),
|
() => _emitLoaded((current) => current.copyWith(isTyping: false, isLoading: isLoading)),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onDirectChatEvent(ChannelReadEvent event) {
|
||||||
|
try {
|
||||||
|
if (addIncomingMessage(ChatMessageModel.fromJson(jsonDecode(event.data) as Map<String, dynamic>))) {
|
||||||
|
unawaited(_playIncomingSound());
|
||||||
|
}
|
||||||
|
} catch (e, st) {
|
||||||
|
unawaited(reportFrom(e, st, this));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _playIncomingSound() async {
|
||||||
|
try {
|
||||||
|
await AudioPlayer().play(AssetSource('audio/recive_message.wav'));
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Добавляет входящее сообщение в текущий список, не затирая более новый state.
|
||||||
|
bool addIncomingMessage(ChatMessageModel message) {
|
||||||
|
final currentUserId = wsManager.userId;
|
||||||
|
if (currentUserId != null && message.authorId == currentUserId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final currentState = state;
|
||||||
|
if (currentState is! ChatRoomStateLoaded) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (currentState.chatMessages.any((existing) => existing.id == message.id && message.id != 0)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final added = _emitLoaded((current) {
|
||||||
|
if (current.chatMessages.any((existing) => existing.id == message.id && message.id != 0)) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
return current.copyWith(
|
||||||
|
chatMessages: [message, ...current.chatMessages],
|
||||||
|
isLoading: isLoading,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (!added) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
chatRepository.emitNewMessageToChats(message);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _subscribeToPresenceChannel({required String chatId}) async {
|
||||||
|
final currentState = state;
|
||||||
|
if (currentState is! ChatRoomStateLoaded) return;
|
||||||
|
|
||||||
|
final counterpart = resolveChatCounterpart(
|
||||||
|
users: currentState.chatRoom.users,
|
||||||
|
currentUserId: wsManager.userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
await wsManager.initPresenceDirectChatChannel(chatId);
|
||||||
|
_presenceOnlineListener = (isOnline) {
|
||||||
|
unawaited(_handleCounterpartOnlineChanged(
|
||||||
|
counterpartField: counterpart.id == currentState.chatRoom.users.receiver.id
|
||||||
|
? _ChatCounterpartField.receiver
|
||||||
|
: _ChatCounterpartField.sender,
|
||||||
|
isOnline: isOnline,
|
||||||
|
));
|
||||||
|
};
|
||||||
|
wsManager.addPresenceDirectChatListener(
|
||||||
|
counterpartUserId: counterpart.id,
|
||||||
|
onCounterpartOnlineChanged: _presenceOnlineListener!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleCounterpartOnlineChanged({
|
||||||
|
required _ChatCounterpartField counterpartField,
|
||||||
|
required bool isOnline,
|
||||||
|
}) async {
|
||||||
|
final currentState = state;
|
||||||
|
if (currentState is! ChatRoomStateLoaded) return;
|
||||||
|
|
||||||
|
final counterpart = counterpartField == _ChatCounterpartField.receiver
|
||||||
|
? currentState.chatRoom.users.receiver
|
||||||
|
: currentState.chatRoom.users.sender;
|
||||||
|
|
||||||
|
if (counterpart.isOnline == isOnline && (isOnline || counterpart.lastSeenText.isNotEmpty)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isOnline) {
|
||||||
|
await _refreshCounterpartOnlineStatus(counterpartField: counterpartField);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_emitCounterpartOnlineStatus(
|
||||||
|
counterpartField: counterpartField,
|
||||||
|
isOnline: true,
|
||||||
|
lastSeenText: '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _refreshCounterpartOnlineStatus({
|
||||||
|
required _ChatCounterpartField counterpartField,
|
||||||
|
}) async {
|
||||||
|
final chatUuid = _activeChatUuid;
|
||||||
|
if (chatUuid == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final chatRoom = await chatRepository.getChatRoom(chatUuid);
|
||||||
|
final currentState = state;
|
||||||
|
if (currentState is! ChatRoomStateLoaded) return;
|
||||||
|
|
||||||
|
final counterpart = resolveChatCounterpart(
|
||||||
|
users: chatRoom.users,
|
||||||
|
currentUserId: wsManager.userId,
|
||||||
|
);
|
||||||
|
final field = counterpart.id == currentState.chatRoom.users.receiver.id
|
||||||
|
? _ChatCounterpartField.receiver
|
||||||
|
: _ChatCounterpartField.sender;
|
||||||
|
|
||||||
|
if (field != counterpartField) return;
|
||||||
|
|
||||||
|
_emitCounterpartOnlineStatus(
|
||||||
|
counterpartField: counterpartField,
|
||||||
|
isOnline: counterpart.isOnline,
|
||||||
|
lastSeenText: counterpart.lastSeenText,
|
||||||
|
);
|
||||||
|
} catch (e, st) {
|
||||||
|
await reportFrom(e, st, this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _emitCounterpartOnlineStatus({
|
||||||
|
required _ChatCounterpartField counterpartField,
|
||||||
|
required bool isOnline,
|
||||||
|
required String lastSeenText,
|
||||||
|
}) {
|
||||||
|
final currentState = state;
|
||||||
|
if (currentState is! ChatRoomStateLoaded) return;
|
||||||
|
|
||||||
|
final users = currentState.chatRoom.users;
|
||||||
|
final updatedUsers = counterpartField == _ChatCounterpartField.receiver
|
||||||
|
? users.copyWith(
|
||||||
|
receiver: users.receiver.copyWith(
|
||||||
|
isOnline: isOnline,
|
||||||
|
lastSeenText: lastSeenText,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: users.copyWith(
|
||||||
|
sender: users.sender.copyWith(
|
||||||
|
isOnline: isOnline,
|
||||||
|
lastSeenText: lastSeenText,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
emit(
|
||||||
|
currentState.copyWith(
|
||||||
|
chatRoom: currentState.chatRoom.copyWith(users: updatedUsers),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<bool> sendMessage({String? data, List<File>? files, required String userSlug}) async {
|
Future<bool> sendMessage({String? data, List<File>? files, required String userSlug}) async {
|
||||||
try {
|
try {
|
||||||
final messageUuid = uuid.v4();
|
final messageUuid = uuid.v4();
|
||||||
final compressedFiles = files != null ? await ImageCompressUtility.compressFilesList(List<File>.from(files)) : null;
|
final compressedFiles = files != null ? await ImageCompressUtility.compressFilesList(List<File>.from(files)) : null;
|
||||||
AudioPlayer().play((AssetSource('audio/send_message.wav')));
|
try {
|
||||||
|
unawaited(AudioPlayer().play(AssetSource('audio/send_message.wav')));
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
// Создаем временное сообщение с состоянием loading
|
_emitLoaded(
|
||||||
emit(
|
(current) => current.copyWith(
|
||||||
(state as ChatRoomStateLoaded).copyWith(
|
|
||||||
chatMessages: [
|
chatMessages: [
|
||||||
ChatMessageModel(
|
ChatMessageModel(
|
||||||
id: 1,
|
id: -messageUuid.hashCode.abs(),
|
||||||
data: data ?? '',
|
data: data ?? '',
|
||||||
mediaData: compressedFiles,
|
mediaData: compressedFiles,
|
||||||
authorId: 1,
|
authorId: wsManager.userId ?? 0,
|
||||||
updatedAt: DateTime.now(),
|
updatedAt: DateTime.now(),
|
||||||
isRead: true,
|
isRead: true,
|
||||||
isCurrentUserAuthor: true,
|
isCurrentUserAuthor: true,
|
||||||
roomId: '',
|
roomId: current.chatRoom.id,
|
||||||
messageState: MessageState.loading,
|
messageState: MessageState.loading,
|
||||||
uuid: messageUuid,
|
uuid: messageUuid,
|
||||||
),
|
),
|
||||||
...(state as ChatRoomStateLoaded).chatMessages,
|
...current.chatMessages,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Отправляем сообщение и получаем ответ от сервера с правильными данными
|
|
||||||
final serverMessage = await chatRepository.sendMessage(data: data, files: compressedFiles, slug: userSlug);
|
final serverMessage = await chatRepository.sendMessage(data: data, files: compressedFiles, slug: userSlug);
|
||||||
|
_replaceOptimisticMessage(messageUuid: messageUuid, serverMessage: serverMessage);
|
||||||
// Заменяем локальное сообщение данными с сервера (с правильными URL файлов)
|
|
||||||
emit(
|
|
||||||
(state as ChatRoomStateLoaded).copyWith(
|
|
||||||
chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) {
|
|
||||||
if (elem.uuid == messageUuid) {
|
|
||||||
// Сохраняем UUID, состояние и обновляем данные из ответа сервера
|
|
||||||
// Используем значения из локального сообщения для полей, которые могут отсутствовать в ответе
|
|
||||||
return serverMessage.copyWith(
|
|
||||||
uuid: messageUuid,
|
|
||||||
messageState: MessageState.sent,
|
|
||||||
isCurrentUserAuthor: true,
|
|
||||||
isRead: elem.isRead,
|
|
||||||
roomId: elem.roomId ?? serverMessage.roomId,
|
|
||||||
authorId: serverMessage.authorId,
|
|
||||||
updatedAt: serverMessage.updatedAt,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return elem;
|
|
||||||
}).toList(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
emit(
|
_emitLoaded(
|
||||||
(state as ChatRoomStateLoaded).copyWith(
|
(current) => current.copyWith(
|
||||||
chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) {
|
chatMessages: current.chatMessages.map((elem) {
|
||||||
if (elem.uuid == messageUuid) {
|
if (elem.uuid == messageUuid) {
|
||||||
return elem.copyWith(messageState: MessageState.error);
|
return elem.copyWith(messageState: MessageState.error);
|
||||||
}
|
}
|
||||||
@@ -294,21 +470,21 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> markMessagesAsRead(String chatId, List<({int id, int index})> messagesIds) async {
|
Future<bool> markMessagesAsRead(String chatId, List<int> messageIds) async {
|
||||||
try {
|
try {
|
||||||
if (state is ChatRoomStateLoaded) {
|
if (state is! ChatRoomStateLoaded) {
|
||||||
final ids = messagesIds.map((e) => e.id).toList();
|
return false;
|
||||||
await chatRepository.markMessagesAsRead(chatId, ids);
|
|
||||||
final currentMessages = [...(state as ChatRoomStateLoaded).chatMessages];
|
|
||||||
for (var elem in messagesIds) {
|
|
||||||
currentMessages[elem.index] = currentMessages[elem.index].copyWith(isRead: true);
|
|
||||||
}
|
|
||||||
if (!isClosed) {
|
|
||||||
emit((state as ChatRoomStateLoaded).copyWith(chatMessages: currentMessages));
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
return false;
|
await chatRepository.markMessagesAsRead(chatId, messageIds);
|
||||||
|
final idSet = messageIds.toSet();
|
||||||
|
_emitLoaded(
|
||||||
|
(current) => current.copyWith(
|
||||||
|
chatMessages: current.chatMessages
|
||||||
|
.map((message) => idSet.contains(message.id) ? message.copyWith(isRead: true) : message)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
log(e.toString(), stackTrace: st);
|
log(e.toString(), stackTrace: st);
|
||||||
@@ -316,6 +492,35 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _replaceOptimisticMessage({
|
||||||
|
required String messageUuid,
|
||||||
|
required ChatMessageModel serverMessage,
|
||||||
|
}) {
|
||||||
|
_emitLoaded((current) {
|
||||||
|
final sentMessage = serverMessage.copyWith(
|
||||||
|
uuid: messageUuid,
|
||||||
|
messageState: MessageState.sent,
|
||||||
|
isCurrentUserAuthor: true,
|
||||||
|
);
|
||||||
|
final withoutDuplicate = current.chatMessages.where((message) => message.id != serverMessage.id || message.uuid == messageUuid);
|
||||||
|
final hasOptimistic = withoutDuplicate.any((message) => message.uuid == messageUuid);
|
||||||
|
if (!hasOptimistic) {
|
||||||
|
return current.copyWith(chatMessages: [sentMessage, ...withoutDuplicate]);
|
||||||
|
}
|
||||||
|
return current.copyWith(
|
||||||
|
chatMessages: withoutDuplicate.map((elem) {
|
||||||
|
if (elem.uuid == messageUuid) {
|
||||||
|
return sentMessage.copyWith(
|
||||||
|
isRead: elem.isRead,
|
||||||
|
roomId: elem.roomId?.isNotEmpty == true ? elem.roomId : serverMessage.roomId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return elem;
|
||||||
|
}).toList(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<String?> getPaymentLink() async {
|
Future<String?> getPaymentLink() async {
|
||||||
try {
|
try {
|
||||||
final planId = (state as ChatRoomStateLoaded).chatRoom.chosenPlan?.id;
|
final planId = (state as ChatRoomStateLoaded).chatRoom.chosenPlan?.id;
|
||||||
@@ -360,10 +565,16 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> close() async {
|
Future<void> close() async {
|
||||||
|
EasyDebounce.cancel('client-typing');
|
||||||
|
EasyDebounce.cancel('searchChatRoom');
|
||||||
|
if (_presenceOnlineListener != null) {
|
||||||
|
wsManager.removePresenceDirectChatListener(_presenceOnlineListener!);
|
||||||
|
_presenceOnlineListener = null;
|
||||||
|
}
|
||||||
wsManager.unsubscribeDirectChatChannel();
|
wsManager.unsubscribeDirectChatChannel();
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
messagesSubscription?.cancel();
|
await messagesSubscription?.cancel();
|
||||||
typingSubscription?.cancel();
|
await typingSubscription?.cancel();
|
||||||
super.close();
|
super.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,67 +591,46 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
final message = currentState.chatMessages.firstWhereOrNull((message) => message.uuid == uuid);
|
final message = currentState.chatMessages.firstWhereOrNull((message) => message.uuid == uuid);
|
||||||
if (message != null) {
|
if (message != null) {
|
||||||
try {
|
try {
|
||||||
AudioPlayer().play((AssetSource('audio/send_message.wav')));
|
try {
|
||||||
|
unawaited(AudioPlayer().play(AssetSource('audio/send_message.wav')));
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
// Определяем, есть ли файлы для повторной отправки
|
|
||||||
List<File>? filesToResend;
|
List<File>? filesToResend;
|
||||||
if (message.mediaData != null && message.mediaData!.isNotEmpty) {
|
if (message.mediaData != null && message.mediaData!.isNotEmpty) {
|
||||||
// Если mediaData содержит File объекты, используем их
|
|
||||||
// Если это строки (URL), то файлы уже на сервере и повторная отправка не нужна
|
|
||||||
final firstItem = message.mediaData!.first;
|
final firstItem = message.mediaData!.first;
|
||||||
if (firstItem is File) {
|
if (firstItem is File) {
|
||||||
filesToResend = message.mediaData!.cast<File>();
|
filesToResend = message.mediaData!.cast<File>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
emit(
|
_emitLoaded(
|
||||||
(state as ChatRoomStateLoaded).copyWith(
|
(current) => current.copyWith(
|
||||||
chatMessages: [
|
chatMessages: [
|
||||||
ChatMessageModel(
|
ChatMessageModel(
|
||||||
id: 1,
|
id: -uuid.hashCode.abs(),
|
||||||
data: message.data,
|
data: message.data,
|
||||||
mediaData: message.mediaData,
|
mediaData: message.mediaData,
|
||||||
authorId: 1,
|
authorId: wsManager.userId ?? 0,
|
||||||
updatedAt: DateTime.now(),
|
updatedAt: DateTime.now(),
|
||||||
isRead: true,
|
isRead: true,
|
||||||
isCurrentUserAuthor: true,
|
isCurrentUserAuthor: true,
|
||||||
roomId: '',
|
roomId: current.chatRoom.id,
|
||||||
messageState: MessageState.loading,
|
messageState: MessageState.loading,
|
||||||
uuid: uuid,
|
uuid: uuid,
|
||||||
),
|
),
|
||||||
...(state as ChatRoomStateLoaded).chatMessages.where((message) => message.uuid != uuid),
|
...current.chatMessages.where((item) => item.uuid != uuid),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Отправляем сообщение и получаем ответ от сервера
|
|
||||||
final serverMessage = await chatRepository.sendMessage(data: message.data, files: filesToResend, slug: userSlug);
|
final serverMessage = await chatRepository.sendMessage(data: message.data, files: filesToResend, slug: userSlug);
|
||||||
|
_replaceOptimisticMessage(messageUuid: uuid, serverMessage: serverMessage);
|
||||||
// Заменяем локальное сообщение данными с сервера
|
|
||||||
emit(
|
|
||||||
(state as ChatRoomStateLoaded).copyWith(
|
|
||||||
chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) {
|
|
||||||
if (elem.uuid == uuid) {
|
|
||||||
return serverMessage.copyWith(
|
|
||||||
uuid: uuid,
|
|
||||||
messageState: MessageState.sent,
|
|
||||||
isCurrentUserAuthor: true,
|
|
||||||
isRead: elem.isRead,
|
|
||||||
roomId: elem.roomId ?? serverMessage.roomId,
|
|
||||||
authorId: serverMessage.authorId,
|
|
||||||
updatedAt: serverMessage.updatedAt,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return elem;
|
|
||||||
}).toList(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
emit(
|
_emitLoaded(
|
||||||
(state as ChatRoomStateLoaded).copyWith(
|
(current) => current.copyWith(
|
||||||
chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) {
|
chatMessages: current.chatMessages.map((elem) {
|
||||||
if (elem.uuid == uuid) {
|
if (elem.uuid == uuid) {
|
||||||
return elem.copyWith(messageState: MessageState.error);
|
return elem.copyWith(messageState: MessageState.error);
|
||||||
}
|
}
|
||||||
@@ -458,3 +648,5 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum _ChatCounterpartField { sender, receiver }
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ abstract class ChatRoomState with _$ChatRoomState {
|
|||||||
required ChatRoomModel chatRoom,
|
required ChatRoomModel chatRoom,
|
||||||
required List<ChatMessageModel> chatMessages,
|
required List<ChatMessageModel> chatMessages,
|
||||||
required bool hasCard,
|
required bool hasCard,
|
||||||
|
@Default(false) bool isAlfaScoringComplete,
|
||||||
required bool isTyping,
|
required bool isTyping,
|
||||||
required bool isLoading,
|
required bool isLoading,
|
||||||
}) = ChatRoomStateLoaded;
|
}) = ChatRoomStateLoaded;
|
||||||
|
|||||||
@@ -141,8 +141,8 @@ class ChatsCubit extends Cubit<ChatsState> {
|
|||||||
|
|
||||||
Future<void> _handleNewMessageViaSocket(dynamic data) async {
|
Future<void> _handleNewMessageViaSocket(dynamic data) async {
|
||||||
try {
|
try {
|
||||||
final json = jsonDecode(data);
|
final json = jsonDecode(data) as Map<String, dynamic>;
|
||||||
final message = ChatMessageModel.fromJson(json['message']);
|
final message = ChatMessageModel.fromJson(json);
|
||||||
|
|
||||||
final currentChatRooms = [...state.chatRooms];
|
final currentChatRooms = [...state.chatRooms];
|
||||||
|
|
||||||
|
|||||||
@@ -13,13 +13,19 @@ import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cub
|
|||||||
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart';
|
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart';
|
||||||
import 'package:eatvio_app/features/chat/presentation/widgets/about_order_button.dart';
|
import 'package:eatvio_app/features/chat/presentation/widgets/about_order_button.dart';
|
||||||
import 'package:eatvio_app/features/chat/presentation/widgets/chat_bubble_widget.dart';
|
import 'package:eatvio_app/features/chat/presentation/widgets/chat_bubble_widget.dart';
|
||||||
|
import 'package:eatvio_app/features/chat/models/chat_message_model.dart';
|
||||||
|
import 'package:eatvio_app/features/chat/models/chat_user_model.dart';
|
||||||
import 'package:eatvio_app/features/chat/presentation/widgets/plan_order_message_widget.dart';
|
import 'package:eatvio_app/features/chat/presentation/widgets/plan_order_message_widget.dart';
|
||||||
|
import 'package:eatvio_app/features/chat/presentation/widgets/video_call_chat_message_widget.dart';
|
||||||
import 'package:eatvio_app/core/locator_service.dart';
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
|
import 'package:eatvio_app/core/websockets/ws_manager.dart';
|
||||||
import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_cubit.dart';
|
import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_cubit.dart';
|
||||||
import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_state.dart';
|
import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_state.dart';
|
||||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||||
|
import 'package:eatvio_app/features/chat/presentation/widgets/chat_user_online_status_line.dart';
|
||||||
import 'package:eatvio_app/features/chat/presentation/widgets/notification_banner.dart';
|
import 'package:eatvio_app/features/chat/presentation/widgets/notification_banner.dart';
|
||||||
|
import 'package:eatvio_app/features/chat/utils/chat_counterpart_utils.dart';
|
||||||
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||||
import 'package:eatvio_app/features/order_info/presentation/info_by_order_screen.dart';
|
import 'package:eatvio_app/features/order_info/presentation/info_by_order_screen.dart';
|
||||||
import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart';
|
import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart';
|
||||||
@@ -50,8 +56,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
final scrollController = ScrollController();
|
final scrollController = ScrollController();
|
||||||
final TextEditingController textEditingController = TextEditingController();
|
final TextEditingController textEditingController = TextEditingController();
|
||||||
final ChatRoomCubit chatRoomCubit = sl<ChatRoomCubit>();
|
final ChatRoomCubit chatRoomCubit = sl<ChatRoomCubit>();
|
||||||
final List<({int id, int index})> unreadMessagesIds = [];
|
final List<int> unreadMessageIds = [];
|
||||||
final List<({int id, int index})> unreadMessagesIdsCopy = [];
|
|
||||||
final showableChatBadgeStates = ChosenPlanStateEnum.getShowableChatBadgeStates();
|
final showableChatBadgeStates = ChosenPlanStateEnum.getShowableChatBadgeStates();
|
||||||
late AppLifecycleListener _listener;
|
late AppLifecycleListener _listener;
|
||||||
List<File> pickedFiles = [];
|
List<File> pickedFiles = [];
|
||||||
@@ -59,7 +64,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
/// Conversation uuid после успешного init (в т.ч. после resolve по orderId).
|
/// Conversation uuid после успешного init (в т.ч. после resolve по orderId).
|
||||||
String? _effectiveUuid;
|
String? _effectiveUuid;
|
||||||
|
|
||||||
String? get _conversationUuid => _effectiveUuid ?? widget.uuid;
|
String? get conversationUuid => _effectiveUuid ?? widget.uuid;
|
||||||
|
|
||||||
void _onHide() {
|
void _onHide() {
|
||||||
chatRoomCubit.cancelTimer();
|
chatRoomCubit.cancelTimer();
|
||||||
@@ -72,7 +77,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
|
|
||||||
Future<void> _initChatRoom() async {
|
Future<void> _initChatRoom() async {
|
||||||
final uuid = await chatRoomCubit.initChat(
|
final uuid = await chatRoomCubit.initChat(
|
||||||
_conversationUuid,
|
conversationUuid,
|
||||||
orderId: _effectiveUuid == null ? widget.orderId : null,
|
orderId: _effectiveUuid == null ? widget.orderId : null,
|
||||||
sendInitialMessage: widget.sendInitialMessage ?? false,
|
sendInitialMessage: widget.sendInitialMessage ?? false,
|
||||||
);
|
);
|
||||||
@@ -101,7 +106,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onScroll() {
|
void _onScroll() {
|
||||||
final uuid = _conversationUuid;
|
final uuid = conversationUuid;
|
||||||
if (uuid == null) return;
|
if (uuid == null) return;
|
||||||
if (scrollController.offset >= scrollController.position.maxScrollExtent && !scrollController.position.outOfRange) {
|
if (scrollController.offset >= scrollController.position.maxScrollExtent && !scrollController.position.outOfRange) {
|
||||||
chatRoomCubit.loadNextPage(uuid);
|
chatRoomCubit.loadNextPage(uuid);
|
||||||
@@ -110,6 +115,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
EasyDebounce.cancel('mark_as_read');
|
||||||
scrollController.dispose();
|
scrollController.dispose();
|
||||||
_listener.dispose();
|
_listener.dispose();
|
||||||
chatRoomCubit.close();
|
chatRoomCubit.close();
|
||||||
@@ -162,6 +168,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Scaffold _buildChat(BuildContext context, ChatRoomStateLoaded state) {
|
Scaffold _buildChat(BuildContext context, ChatRoomStateLoaded state) {
|
||||||
|
final chatCounterpart = resolveChatCounterpart(users: state.chatRoom.users, currentUserId: sl<WsManager>().userId);
|
||||||
final chosenPlan = state.chatRoom.chosenPlan;
|
final chosenPlan = state.chatRoom.chosenPlan;
|
||||||
final amIConsultant = chosenPlan?.consultantId == state.chatRoom.users.sender.id;
|
final amIConsultant = chosenPlan?.consultantId == state.chatRoom.users.sender.id;
|
||||||
final showUserDetailsButton = [
|
final showUserDetailsButton = [
|
||||||
@@ -183,7 +190,10 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
BannerType? bannerType;
|
BannerType? bannerType;
|
||||||
if (chosenPlan == null || chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateCanceledByTheInitiator) {
|
if (chosenPlan == null || chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateCanceledByTheInitiator) {
|
||||||
bannerType = null;
|
bannerType = null;
|
||||||
} else if (amIConsultant && !state.hasCard && chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateInCoordination) {
|
} else if (amIConsultant &&
|
||||||
|
!state.hasCard &&
|
||||||
|
!state.isAlfaScoringComplete &&
|
||||||
|
chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateInCoordination) {
|
||||||
bannerType = BannerType.card;
|
bannerType = BannerType.card;
|
||||||
} else if (amIConsultant && chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateInCoordination) {
|
} else if (amIConsultant && chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateInCoordination) {
|
||||||
bannerType = BannerType.date;
|
bannerType = BannerType.date;
|
||||||
@@ -238,19 +248,31 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
Column(
|
Expanded(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: Column(
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
Text(state.chatRoom.users.receiver.name, style: CustomTextStyles.titleSmallBold.copyWith(color: PrimaryColors().black)),
|
children: [
|
||||||
AnimatedCrossFade(
|
Text(
|
||||||
firstChild: Text('Печатает...', style: CustomTextStyles.bodySmallMulishPrimary),
|
state.chatRoom.users.receiver.name,
|
||||||
firstCurve: Curves.easeIn,
|
maxLines: 1,
|
||||||
secondCurve: Curves.easeOut,
|
overflow: TextOverflow.ellipsis,
|
||||||
secondChild: const SizedBox.shrink(),
|
style: CustomTextStyles.titleSmallBold.copyWith(color: PrimaryColors().black),
|
||||||
crossFadeState: state.isTyping ? CrossFadeState.showFirst : CrossFadeState.showSecond,
|
),
|
||||||
duration: const Duration(milliseconds: 150),
|
AnimatedCrossFade(
|
||||||
),
|
firstChild: Text('Печатает...', maxLines: 1, overflow: TextOverflow.ellipsis, style: CustomTextStyles.bodySmallMulishPrimary),
|
||||||
],
|
firstCurve: Curves.easeIn,
|
||||||
|
secondCurve: Curves.easeOut,
|
||||||
|
secondChild: ChatUserOnlineStatusLine(
|
||||||
|
isOnline: chatCounterpart.isOnline,
|
||||||
|
lastSeenText: chatCounterpart.lastSeenText,
|
||||||
|
style: CustomTextStyles.bodySmallMulishPrimary,
|
||||||
|
showOnlineDot: false,
|
||||||
|
),
|
||||||
|
crossFadeState: state.isTyping ? CrossFadeState.showFirst : CrossFadeState.showSecond,
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -258,18 +280,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
actions: [
|
actions: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsetsDirectional.only(end: 8),
|
padding: const EdgeInsetsDirectional.only(end: 8),
|
||||||
child: _ChatHeaderVideoCallButton(
|
child: _ChatHeaderVideoCallButton(onPressed: () => _openVideoCall(context, state, chatCounterpart)),
|
||||||
onPressed: () {
|
|
||||||
GoRouter.of(context).pushNamed(
|
|
||||||
VideoCallScreen.name,
|
|
||||||
extra: <String, dynamic>{
|
|
||||||
'conversationUuid': _conversationUuid,
|
|
||||||
'counterpartName': state.chatRoom.users.receiver.name,
|
|
||||||
'localParticipantName': state.chatRoom.users.sender.name,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -304,7 +315,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
index + 1 == state.chatMessages.length) {
|
index + 1 == state.chatMessages.length) {
|
||||||
final isCurrentUserAuthor = state.chatRoom.users.receiver.id != state.chatMessages[index].authorId;
|
final isCurrentUserAuthor = state.chatRoom.users.receiver.id != state.chatMessages[index].authorId;
|
||||||
final message = state.chatMessages[index];
|
final message = state.chatMessages[index];
|
||||||
final isPlanOrderMessage = message.type == 'plan_order';
|
final isPlanOrderMessage = message.type == ChatMessageType.planOrder;
|
||||||
|
|
||||||
PacketOptions? messageOption;
|
PacketOptions? messageOption;
|
||||||
if (isPlanOrderMessage && chosenPlan != null) {
|
if (isPlanOrderMessage && chosenPlan != null) {
|
||||||
@@ -329,34 +340,23 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
isCurrentUser: isCurrentUserAuthor,
|
isCurrentUser: isCurrentUserAuthor,
|
||||||
),
|
),
|
||||||
VisibilityDetector(
|
VisibilityDetector(
|
||||||
key: Key(message.id.toString()),
|
key: ValueKey('msg-${message.uuid ?? message.id}'),
|
||||||
onVisibilityChanged: (VisibilityInfo info) {
|
onVisibilityChanged: (VisibilityInfo info) {
|
||||||
if (!message.isRead && !isCurrentUserAuthor && info.visibleFraction > 0) {
|
_queueMarkAsRead(
|
||||||
unreadMessagesIdsCopy.add((id: message.id, index: index));
|
context: context,
|
||||||
EasyDebounce.debounce('mark_as_read', const Duration(milliseconds: 500), () async {
|
chatId: state.chatRoom.id,
|
||||||
final result = await context.read<ChatRoomCubit>().markMessagesAsRead(state.chatRoom.id, unreadMessagesIdsCopy);
|
message: message,
|
||||||
if (!result) return;
|
isCurrentUserAuthor: isCurrentUserAuthor,
|
||||||
if (!context.mounted) return;
|
visibleFraction: info.visibleFraction,
|
||||||
context.read<ChatsCubit>().reduceUnreadMessagesCount(
|
);
|
||||||
chatId: state.chatRoom.id,
|
|
||||||
count: unreadMessagesIdsCopy.length,
|
|
||||||
);
|
|
||||||
unreadMessagesIdsCopy.clear();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
child: ChatBubbleWidget(
|
child: _buildMessageBubble(
|
||||||
isRead: message.isRead,
|
context: context,
|
||||||
sendTime: message.updatedAt,
|
state: state,
|
||||||
text: message.data.trim(),
|
chatCounterpart: chatCounterpart,
|
||||||
isCurrentUser: isCurrentUserAuthor,
|
isCurrentUserAuthor: isCurrentUserAuthor,
|
||||||
mediaData: message.mediaData ?? [],
|
message: message,
|
||||||
messageState: message.messageState,
|
amIConsultant: amIConsultant,
|
||||||
messageUuid: message.uuid ?? '',
|
|
||||||
userSlug: state.chatRoom.users.receiver.slug,
|
|
||||||
messageType: message.type,
|
|
||||||
isConsultant: amIConsultant,
|
|
||||||
urlsMediaInfo: message.urlsMediaInfo,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -364,7 +364,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
}
|
}
|
||||||
final isCurrentUserAuthor = state.chatRoom.users.receiver.id != state.chatMessages[index].authorId;
|
final isCurrentUserAuthor = state.chatRoom.users.receiver.id != state.chatMessages[index].authorId;
|
||||||
final message = state.chatMessages[index];
|
final message = state.chatMessages[index];
|
||||||
final isPlanOrderMessage = message.type == 'plan_order';
|
final isPlanOrderMessage = message.type == ChatMessageType.planOrder;
|
||||||
|
|
||||||
PacketOptions? messageOption;
|
PacketOptions? messageOption;
|
||||||
if (isPlanOrderMessage && chosenPlan != null) {
|
if (isPlanOrderMessage && chosenPlan != null) {
|
||||||
@@ -388,31 +388,23 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
isCurrentUser: isCurrentUserAuthor,
|
isCurrentUser: isCurrentUserAuthor,
|
||||||
),
|
),
|
||||||
VisibilityDetector(
|
VisibilityDetector(
|
||||||
key: Key(message.id.toString()),
|
key: ValueKey('msg-${message.uuid ?? message.id}'),
|
||||||
onVisibilityChanged: (VisibilityInfo info) {
|
onVisibilityChanged: (VisibilityInfo info) {
|
||||||
if (!message.isRead && !isCurrentUserAuthor && info.visibleFraction > 0) {
|
_queueMarkAsRead(
|
||||||
unreadMessagesIds.add((id: message.id, index: index));
|
context: context,
|
||||||
EasyDebounce.debounce('mark_as_read', const Duration(milliseconds: 500), () async {
|
chatId: state.chatRoom.id,
|
||||||
final result = await context.read<ChatRoomCubit>().markMessagesAsRead(state.chatRoom.id, unreadMessagesIds);
|
message: message,
|
||||||
if (!result) return;
|
isCurrentUserAuthor: isCurrentUserAuthor,
|
||||||
if (!context.mounted) return;
|
visibleFraction: info.visibleFraction,
|
||||||
context.read<ChatsCubit>().reduceUnreadMessagesCount(chatId: state.chatRoom.id, count: unreadMessagesIds.length);
|
);
|
||||||
unreadMessagesIds.clear();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
child: ChatBubbleWidget(
|
child: _buildMessageBubble(
|
||||||
isRead: message.isRead,
|
context: context,
|
||||||
sendTime: message.updatedAt,
|
state: state,
|
||||||
text: message.data.trim(),
|
chatCounterpart: chatCounterpart,
|
||||||
isCurrentUser: isCurrentUserAuthor,
|
isCurrentUserAuthor: isCurrentUserAuthor,
|
||||||
mediaData: message.mediaData ?? [],
|
message: message,
|
||||||
messageState: message.messageState,
|
amIConsultant: amIConsultant,
|
||||||
messageUuid: message.uuid ?? '',
|
|
||||||
userSlug: state.chatRoom.users.receiver.slug,
|
|
||||||
messageType: message.type,
|
|
||||||
isConsultant: amIConsultant,
|
|
||||||
urlsMediaInfo: message.urlsMediaInfo,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -779,6 +771,79 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _openVideoCall(BuildContext context, ChatRoomStateLoaded state, ChatUserModel chatCounterpart) {
|
||||||
|
GoRouter.of(context).pushNamed(
|
||||||
|
VideoCallScreen.name,
|
||||||
|
extra: <String, dynamic>{
|
||||||
|
'conversationUuid': conversationUuid,
|
||||||
|
'counterpartName': chatCounterpart.name,
|
||||||
|
'counterpartAvatarUrl': chatCounterpart.profilePhotoUrl,
|
||||||
|
'counterpartUserId': chatCounterpart.id,
|
||||||
|
'counterpartIsOnline': chatCounterpart.isOnline,
|
||||||
|
'counterpartLastSeenText': chatCounterpart.lastSeenText,
|
||||||
|
'localParticipantName': state.chatRoom.users.sender.name,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _queueMarkAsRead({
|
||||||
|
required BuildContext context,
|
||||||
|
required String chatId,
|
||||||
|
required ChatMessageModel message,
|
||||||
|
required bool isCurrentUserAuthor,
|
||||||
|
required double visibleFraction,
|
||||||
|
}) {
|
||||||
|
if (message.isRead || isCurrentUserAuthor || visibleFraction <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!unreadMessageIds.contains(message.id)) {
|
||||||
|
unreadMessageIds.add(message.id);
|
||||||
|
}
|
||||||
|
EasyDebounce.debounce('mark_as_read', const Duration(milliseconds: 500), () async {
|
||||||
|
final ids = List<int>.from(unreadMessageIds);
|
||||||
|
if (ids.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unreadMessageIds.clear();
|
||||||
|
final result = await context.read<ChatRoomCubit>().markMessagesAsRead(chatId, ids);
|
||||||
|
if (!result || !context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.read<ChatsCubit>().reduceUnreadMessagesCount(chatId: chatId, count: ids.length);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMessageBubble({
|
||||||
|
required BuildContext context,
|
||||||
|
required ChatRoomStateLoaded state,
|
||||||
|
required ChatUserModel chatCounterpart,
|
||||||
|
required bool isCurrentUserAuthor,
|
||||||
|
required ChatMessageModel message,
|
||||||
|
required bool? amIConsultant,
|
||||||
|
}) {
|
||||||
|
if (message.type == ChatMessageType.videoCallStarted && !isCurrentUserAuthor) {
|
||||||
|
return VideoCallChatMessageWidget(
|
||||||
|
text: message.data.trim(),
|
||||||
|
sendTime: message.updatedAt,
|
||||||
|
onVideoCallPressed: () => _openVideoCall(context, state, chatCounterpart),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ChatBubbleWidget(
|
||||||
|
isRead: message.isRead,
|
||||||
|
sendTime: message.updatedAt,
|
||||||
|
text: message.data.trim(),
|
||||||
|
isCurrentUser: isCurrentUserAuthor,
|
||||||
|
mediaData: message.mediaData ?? [],
|
||||||
|
messageState: message.messageState,
|
||||||
|
messageUuid: message.uuid ?? '',
|
||||||
|
userSlug: state.chatRoom.users.receiver.slug,
|
||||||
|
messageType: message.type,
|
||||||
|
isConsultant: amIConsultant,
|
||||||
|
urlsMediaInfo: message.urlsMediaInfo,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ChatHeaderVideoCallButton extends StatelessWidget {
|
class _ChatHeaderVideoCallButton extends StatelessWidget {
|
||||||
@@ -800,9 +865,7 @@ class _ChatHeaderVideoCallButton extends StatelessWidget {
|
|||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
child: Center(
|
child: Center(child: Icon(Icons.videocam_rounded, color: appTheme.blueA400, size: 18)),
|
||||||
child: Icon(Icons.videocam_rounded, color: appTheme.blueA400, size: 18),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,11 +14,15 @@ class PhotoViewScreen extends StatefulWidget {
|
|||||||
final int index;
|
final int index;
|
||||||
final bool isLocal;
|
final bool isLocal;
|
||||||
|
|
||||||
|
/// When set, used instead of the image URL so source/destination tags stay unique.
|
||||||
|
final Object? heroTag;
|
||||||
|
|
||||||
const PhotoViewScreen({
|
const PhotoViewScreen({
|
||||||
super.key,
|
super.key,
|
||||||
required this.imageUrls,
|
required this.imageUrls,
|
||||||
required this.index,
|
required this.index,
|
||||||
this.isLocal = false,
|
this.isLocal = false,
|
||||||
|
this.heroTag,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -36,6 +40,23 @@ class _PhotoViewScreenState extends State<PhotoViewScreen> {
|
|||||||
super.initState();
|
super.initState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PhotoViewHeroAttributes? _heroAttributesFor(int index) {
|
||||||
|
if (widget.heroTag != null) {
|
||||||
|
if (index != widget.index) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return PhotoViewHeroAttributes(
|
||||||
|
tag: widget.heroTag!,
|
||||||
|
transitionOnUserGestures: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return PhotoViewHeroAttributes(
|
||||||
|
tag: widget.imageUrls[index],
|
||||||
|
transitionOnUserGestures: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return DismissiblePage(
|
return DismissiblePage(
|
||||||
@@ -44,23 +65,19 @@ class _PhotoViewScreenState extends State<PhotoViewScreen> {
|
|||||||
direction: DismissiblePageDismissDirection.vertical,
|
direction: DismissiblePageDismissDirection.vertical,
|
||||||
isFullScreen: true,
|
isFullScreen: true,
|
||||||
child: PhotoViewGallery.builder(
|
child: PhotoViewGallery.builder(
|
||||||
backgroundDecoration: const BoxDecoration(
|
backgroundDecoration: const BoxDecoration(color: Colors.transparent),
|
||||||
color: Colors.transparent,
|
|
||||||
),
|
|
||||||
scrollPhysics: const BouncingScrollPhysics(),
|
scrollPhysics: const BouncingScrollPhysics(),
|
||||||
builder: (BuildContext context, int index) {
|
builder: (BuildContext context, int index) {
|
||||||
final imageProvider = widget.isLocal
|
final imageProvider = widget.isLocal
|
||||||
? FileImage(widget.imageUrls[index]) as ImageProvider<Object>
|
? FileImage(widget.imageUrls[index]) as ImageProvider<Object>
|
||||||
: CachedNetworkImageProvider(widget.imageUrls[index]) as ImageProvider<Object>;
|
: CachedNetworkImageProvider(widget.imageUrls[index])
|
||||||
|
as ImageProvider<Object>;
|
||||||
|
|
||||||
return PhotoViewGalleryPageOptions(
|
return PhotoViewGalleryPageOptions(
|
||||||
imageProvider: imageProvider,
|
imageProvider: imageProvider,
|
||||||
initialScale: PhotoViewComputedScale.contained,
|
initialScale: PhotoViewComputedScale.contained,
|
||||||
minScale: PhotoViewComputedScale.contained,
|
minScale: PhotoViewComputedScale.contained,
|
||||||
heroAttributes: PhotoViewHeroAttributes(
|
heroAttributes: _heroAttributesFor(index),
|
||||||
tag: widget.imageUrls[index],
|
|
||||||
transitionOnUserGestures: true,
|
|
||||||
),
|
|
||||||
errorBuilder: (context, object, stackTrace) {
|
errorBuilder: (context, object, stackTrace) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.black,
|
backgroundColor: Colors.black,
|
||||||
@@ -76,8 +93,12 @@ class _PhotoViewScreenState extends State<PhotoViewScreen> {
|
|||||||
),
|
),
|
||||||
contentPadding: const EdgeInsets.all(16),
|
contentPadding: const EdgeInsets.all(16),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
if (!await launchUrl(Uri.parse(widget.imageUrls[index]))) {
|
if (!await launchUrl(
|
||||||
throw Exception('Could not launch ${widget.imageUrls[index]}');
|
Uri.parse(widget.imageUrls[index]),
|
||||||
|
)) {
|
||||||
|
throw Exception(
|
||||||
|
'Could not launch ${widget.imageUrls[index]}',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
leading: const Icon(
|
leading: const Icon(
|
||||||
@@ -89,7 +110,10 @@ class _PhotoViewScreenState extends State<PhotoViewScreen> {
|
|||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
widget.imageUrls[index].replaceAll('${sl<AppConfig>().baseUrl}/storage/chat_files/', ''),
|
widget.imageUrls[index].replaceAll(
|
||||||
|
'${sl<AppConfig>().baseUrl}/storage/chat_files/',
|
||||||
|
'',
|
||||||
|
),
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
@@ -109,7 +133,9 @@ class _PhotoViewScreenState extends State<PhotoViewScreen> {
|
|||||||
width: 20.0,
|
width: 20.0,
|
||||||
height: 20.0,
|
height: 20.0,
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
value: event == null ? 0 : event.cumulativeBytesLoaded / event.expectedTotalBytes!,
|
value: event == null
|
||||||
|
? 0
|
||||||
|
: event.cumulativeBytesLoaded / event.expectedTotalBytes!,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ class ChatBubbleWidget extends StatelessWidget {
|
|||||||
final MessageState messageState;
|
final MessageState messageState;
|
||||||
final String messageUuid;
|
final String messageUuid;
|
||||||
final String userSlug;
|
final String userSlug;
|
||||||
final String? messageType;
|
final ChatMessageType? messageType;
|
||||||
final bool? isConsultant;
|
final bool? isConsultant;
|
||||||
final List<Map<String, dynamic>>? urlsMediaInfo;
|
final List<Map<String, dynamic>>? urlsMediaInfo;
|
||||||
@override
|
@override
|
||||||
@@ -182,33 +182,36 @@ class ChatBubbleWidget extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ListTile(
|
return Material(
|
||||||
contentPadding: const EdgeInsets.all(4),
|
color: Colors.transparent,
|
||||||
horizontalTitleGap: 8,
|
child: ListTile(
|
||||||
onTap: () async {
|
contentPadding: const EdgeInsets.all(4),
|
||||||
if (mediaItem is File) {
|
horizontalTitleGap: 8,
|
||||||
// Для локальных файлов используем file:// URI
|
onTap: () async {
|
||||||
final fileUri = Uri.file(mediaItem.path);
|
if (mediaItem is File) {
|
||||||
if (await canLaunchUrl(fileUri)) {
|
// Для локальных файлов используем file:// URI
|
||||||
await launchUrl(fileUri, mode: LaunchMode.externalApplication);
|
final fileUri = Uri.file(mediaItem.path);
|
||||||
|
if (await canLaunchUrl(fileUri)) {
|
||||||
|
await launchUrl(fileUri, mode: LaunchMode.externalApplication);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Для URL файлов используем обычный launchUrl
|
||||||
|
final urlString = mediaItem.toString();
|
||||||
|
final uri = urlString.startsWith('http://') || urlString.startsWith('https://')
|
||||||
|
? Uri.parse(urlString)
|
||||||
|
: Uri.parse('https://$urlString');
|
||||||
|
if (await canLaunchUrl(uri)) {
|
||||||
|
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
},
|
||||||
// Для URL файлов используем обычный launchUrl
|
leading: const Icon(Icons.file_copy_outlined, color: Colors.black),
|
||||||
final urlString = mediaItem.toString();
|
trailing: const Icon(Icons.download_outlined, color: Colors.black),
|
||||||
final uri = urlString.startsWith('http://') || urlString.startsWith('https://')
|
title: Text(
|
||||||
? Uri.parse(urlString)
|
fileName,
|
||||||
: Uri.parse('https://$urlString');
|
maxLines: 2,
|
||||||
if (await canLaunchUrl(uri)) {
|
style: const TextStyle(color: Colors.black, fontSize: 12, overflow: TextOverflow.ellipsis),
|
||||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
),
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
leading: const Icon(Icons.file_copy_outlined, color: Colors.black),
|
|
||||||
trailing: const Icon(Icons.download_outlined, color: Colors.black),
|
|
||||||
title: Text(
|
|
||||||
fileName,
|
|
||||||
maxLines: 2,
|
|
||||||
style: const TextStyle(color: Colors.black, fontSize: 12, overflow: TextOverflow.ellipsis),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -255,7 +258,7 @@ class ChatBubbleWidget extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _getDisplayText() {
|
String _getDisplayText() {
|
||||||
if (messageType == 'plan_order') {
|
if (messageType == ChatMessageType.planOrder) {
|
||||||
if (isConsultant == true) {
|
if (isConsultant == true) {
|
||||||
// Текст для консультанта
|
// Текст для консультанта
|
||||||
return '1. Уточните у подопечного его цели и пожелания\n'
|
return '1. Уточните у подопечного его цели и пожелания\n'
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||||
|
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||||
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class ChatUserOnlineStatusLine extends StatelessWidget {
|
||||||
|
const ChatUserOnlineStatusLine({
|
||||||
|
super.key,
|
||||||
|
required this.isOnline,
|
||||||
|
required this.lastSeenText,
|
||||||
|
this.style,
|
||||||
|
this.showOnlineDot = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool isOnline;
|
||||||
|
final String lastSeenText;
|
||||||
|
final TextStyle? style;
|
||||||
|
final bool showOnlineDot;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final textStyle = style ??
|
||||||
|
CustomTextStyles.bodySmallMulishBlack900.copyWith(height: 14 / 12);
|
||||||
|
|
||||||
|
if (isOnline) {
|
||||||
|
if (!showOnlineDot) {
|
||||||
|
return Text(
|
||||||
|
S.of(context).video_call_status_online,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: textStyle,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: appTheme.greenA700,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
S.of(context).video_call_status_online,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: textStyle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final trimmedLastSeen = lastSeenText.trim();
|
||||||
|
if (trimmedLastSeen.isEmpty) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Text(
|
||||||
|
trimmedLastSeen,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: textStyle,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||||
|
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||||
|
import 'package:eatvio_app/core/utils/date_time_utils.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
|
|
||||||
|
class VideoCallChatMessageWidget extends StatelessWidget {
|
||||||
|
const VideoCallChatMessageWidget({
|
||||||
|
super.key,
|
||||||
|
required this.text,
|
||||||
|
required this.sendTime,
|
||||||
|
required this.onVideoCallPressed,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String text;
|
||||||
|
final DateTime sendTime;
|
||||||
|
final VoidCallback onVideoCallPressed;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final size = MediaQuery.sizeOf(context);
|
||||||
|
final textStyle = TextStyles.textFS13FW400.copyWith(color: appTheme.black, fontSize: 16);
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
width: size.width,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 10, 64, 4),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
constraints: BoxConstraints(maxWidth: size.width * 0.7),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: appTheme.gray60004,
|
||||||
|
borderRadius: const BorderRadius.horizontal(
|
||||||
|
left: Radius.circular(16),
|
||||||
|
right: Radius.circular(24),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(8, 8, 16, 8),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
if (text.isNotEmpty)
|
||||||
|
Flexible(
|
||||||
|
child: Text(text, style: textStyle),
|
||||||
|
),
|
||||||
|
if (text.isNotEmpty) const SizedBox(width: 8),
|
||||||
|
_VideoCallBubbleActionButton(onPressed: onVideoCallPressed),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 4, bottom: 4),
|
||||||
|
child: Text(
|
||||||
|
sendTime.format('HH:mm', 'ru'),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Color(0xFF7B8794),
|
||||||
|
height: 1,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VideoCallBubbleActionButton extends StatelessWidget {
|
||||||
|
const _VideoCallBubbleActionButton({required this.onPressed});
|
||||||
|
|
||||||
|
final VoidCallback onPressed;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Material(
|
||||||
|
color: appTheme.greenA700,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onPressed,
|
||||||
|
child: SizedBox(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
child: Center(
|
||||||
|
child: SvgPicture.asset(
|
||||||
|
'assets/icons/video_call/vc_control_enter.svg',
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,10 @@ import 'package:eatvio_app/features/chat/models/chat_room_pagination_model.dart'
|
|||||||
import 'package:eatvio_app/features/chat/models/order_id_to_conversation_model.dart';
|
import 'package:eatvio_app/features/chat/models/order_id_to_conversation_model.dart';
|
||||||
import 'package:eatvio_app/features/chat/models/payment_model.dart';
|
import 'package:eatvio_app/features/chat/models/payment_model.dart';
|
||||||
import 'package:eatvio_app/features/chat/models/plan_complain_model.dart';
|
import 'package:eatvio_app/features/chat/models/plan_complain_model.dart';
|
||||||
|
import 'package:eatvio_app/features/payment/models/promo_applied_list_response.dart';
|
||||||
|
import 'package:eatvio_app/features/payment/models/promo_apply_response.dart';
|
||||||
|
import 'package:eatvio_app/features/payment/models/promo_remove_response.dart';
|
||||||
|
import 'package:eatvio_app/features/payment/models/promo_validate_response.dart';
|
||||||
import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart';
|
import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart';
|
||||||
import 'package:retrofit/retrofit.dart';
|
import 'package:retrofit/retrofit.dart';
|
||||||
|
|
||||||
@@ -69,6 +73,26 @@ abstract class ChatService {
|
|||||||
@Body() required Map<String, String> body,
|
@Body() required Map<String, String> body,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@GET('/api/chosen_plans/applied_promos')
|
||||||
|
Future<PromoAppliedListResponse> listAppliedPromoCodes();
|
||||||
|
|
||||||
|
@POST('/api/chosen_plans/{id}/validate_promo')
|
||||||
|
Future<PromoValidateResponse> validatePromoCode({
|
||||||
|
@Path() required int id,
|
||||||
|
@Body() required Map<String, String> body,
|
||||||
|
});
|
||||||
|
|
||||||
|
@POST('/api/chosen_plans/{id}/apply_promo')
|
||||||
|
Future<PromoApplyResponse> applyPromoCode({
|
||||||
|
@Path() required int id,
|
||||||
|
@Body() required Map<String, String> body,
|
||||||
|
});
|
||||||
|
|
||||||
|
@DELETE('/api/chosen_plans/{id}/remove_promo_code')
|
||||||
|
Future<PromoRemoveResponse> removePromoCode({
|
||||||
|
@Path() required int id,
|
||||||
|
});
|
||||||
|
|
||||||
@GET('/api/consultation/resolve/order_id_to_conversation/{orderId}')
|
@GET('/api/consultation/resolve/order_id_to_conversation/{orderId}')
|
||||||
Future<OrderIdToConversationModel> resolveOrderIdToConversation({
|
Future<OrderIdToConversationModel> resolveOrderIdToConversation({
|
||||||
@Path() required String orderId,
|
@Path() required String orderId,
|
||||||
|
|||||||
15
lib/features/chat/utils/chat_counterpart_utils.dart
Normal file
15
lib/features/chat/utils/chat_counterpart_utils.dart
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import 'package:eatvio_app/features/chat/models/chat_room_model.dart';
|
||||||
|
import 'package:eatvio_app/features/chat/models/chat_user_model.dart';
|
||||||
|
|
||||||
|
ChatUserModel resolveChatCounterpart({
|
||||||
|
required Users users,
|
||||||
|
required int? currentUserId,
|
||||||
|
}) {
|
||||||
|
if (currentUserId != null && users.sender.id == currentUserId) {
|
||||||
|
return users.receiver;
|
||||||
|
}
|
||||||
|
if (currentUserId != null && users.receiver.id == currentUserId) {
|
||||||
|
return users.sender;
|
||||||
|
}
|
||||||
|
return users.receiver;
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
|
enum AlfaScoringStatus {
|
||||||
|
@JsonValue('none')
|
||||||
|
none,
|
||||||
|
@JsonValue('beneficiary_creating')
|
||||||
|
beneficiaryCreating,
|
||||||
|
@JsonValue('beneficiary_failed')
|
||||||
|
beneficiaryFailed,
|
||||||
|
@JsonValue('scoring_pending')
|
||||||
|
scoringPending,
|
||||||
|
@JsonValue('scoring_complete')
|
||||||
|
scoringComplete,
|
||||||
|
@JsonValue('scoring_failed')
|
||||||
|
scoringFailed;
|
||||||
|
|
||||||
|
static AlfaScoringStatus fromString(String? value) {
|
||||||
|
switch (value) {
|
||||||
|
case 'beneficiary_creating':
|
||||||
|
return AlfaScoringStatus.beneficiaryCreating;
|
||||||
|
case 'beneficiary_failed':
|
||||||
|
return AlfaScoringStatus.beneficiaryFailed;
|
||||||
|
case 'scoring_pending':
|
||||||
|
return AlfaScoringStatus.scoringPending;
|
||||||
|
case 'scoring_complete':
|
||||||
|
return AlfaScoringStatus.scoringComplete;
|
||||||
|
case 'scoring_failed':
|
||||||
|
return AlfaScoringStatus.scoringFailed;
|
||||||
|
case 'none':
|
||||||
|
case null:
|
||||||
|
case '':
|
||||||
|
return AlfaScoringStatus.none;
|
||||||
|
default:
|
||||||
|
return AlfaScoringStatus.none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get isProcessing =>
|
||||||
|
this == AlfaScoringStatus.beneficiaryCreating ||
|
||||||
|
this == AlfaScoringStatus.scoringPending;
|
||||||
|
|
||||||
|
bool get isFailed =>
|
||||||
|
this == AlfaScoringStatus.beneficiaryFailed ||
|
||||||
|
this == AlfaScoringStatus.scoringFailed;
|
||||||
|
|
||||||
|
bool get canRetry => isFailed;
|
||||||
|
|
||||||
|
bool get isComplete => this == AlfaScoringStatus.scoringComplete;
|
||||||
|
|
||||||
|
bool get isTerminal => isComplete || isFailed;
|
||||||
|
}
|
||||||
@@ -18,7 +18,8 @@ abstract class ConsultantCertPhotoModel with _$ConsultantCertPhotoModel {
|
|||||||
|
|
||||||
const ConsultantCertPhotoModel._();
|
const ConsultantCertPhotoModel._();
|
||||||
|
|
||||||
factory ConsultantCertPhotoModel.fromJson(Map<String, dynamic> json) => _$ConsultantCertPhotoModelFromJson(json);
|
factory ConsultantCertPhotoModel.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$ConsultantCertPhotoModelFromJson(json);
|
||||||
|
|
||||||
static const String visibleGroup = 'user_certs';
|
static const String visibleGroup = 'user_certs';
|
||||||
static const String hiddenGroup = 'hidden_user_certs';
|
static const String hiddenGroup = 'hidden_user_certs';
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/utils/passport_data_validator.dart';
|
||||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
part 'consultant_profile_model.freezed.dart';
|
part 'consultant_profile_model.freezed.dart';
|
||||||
@@ -6,27 +7,70 @@ part 'consultant_profile_model.g.dart';
|
|||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
abstract class ConsultantProfileModel with _$ConsultantProfileModel {
|
abstract class ConsultantProfileModel with _$ConsultantProfileModel {
|
||||||
|
const ConsultantProfileModel._();
|
||||||
|
|
||||||
const factory ConsultantProfileModel({
|
const factory ConsultantProfileModel({
|
||||||
@JsonKey(name: 'has_medical_education', includeIfNull: false) bool? hasMedicalEducation,
|
@JsonKey(name: 'has_medical_education', includeIfNull: false)
|
||||||
@JsonKey(name: 'social_links', includeIfNull: false) SocialLinksModel? socialLinks,
|
bool? hasMedicalEducation,
|
||||||
@JsonKey(name: 'cert_photos', includeIfNull: false) List<String>? photos,
|
@JsonKey(name: 'social_links', includeIfNull: false)
|
||||||
@JsonKey(name: 'completed_courses', includeIfNull: false) List<CertificatesModel>? certificates,
|
SocialLinksModel? socialLinks,
|
||||||
|
@JsonKey(name: 'cert_photos', includeIfNull: false) List<String>? photos,
|
||||||
|
@JsonKey(name: 'completed_courses', includeIfNull: false)
|
||||||
|
List<CertificatesModel>? certificates,
|
||||||
|
@JsonKey(name: 'legal_type', includeIfNull: false) String? legalType,
|
||||||
|
@JsonKey(name: 'last_name', includeIfNull: false) String? lastName,
|
||||||
|
@JsonKey(name: 'first_name', includeIfNull: false) String? firstName,
|
||||||
|
@JsonKey(name: 'middle_name', includeIfNull: false) String? middleName,
|
||||||
|
@JsonKey(name: 'inn', includeIfNull: false) String? inn,
|
||||||
|
@JsonKey(name: 'snils', includeIfNull: false) String? snils,
|
||||||
|
@JsonKey(name: 'passport_series', includeIfNull: false)
|
||||||
|
String? passportSeries,
|
||||||
|
@JsonKey(name: 'passport_number', includeIfNull: false)
|
||||||
|
String? passportNumber,
|
||||||
|
@JsonKey(name: 'passport_issue_date', includeIfNull: false)
|
||||||
|
String? passportIssueDate,
|
||||||
|
@JsonKey(name: 'passport_issuer', includeIfNull: false)
|
||||||
|
String? passportIssuer,
|
||||||
|
@JsonKey(name: 'passport_issuer_code', includeIfNull: false)
|
||||||
|
String? passportIssuerCode,
|
||||||
|
@JsonKey(name: 'birth_date', includeIfNull: false) String? birthDate,
|
||||||
|
@JsonKey(name: 'birth_place', includeIfNull: false) String? birthPlace,
|
||||||
|
@JsonKey(name: 'registration_address', includeIfNull: false)
|
||||||
|
String? registrationAddress,
|
||||||
|
@JsonKey(name: 'residence_address', includeIfNull: false)
|
||||||
|
String? residenceAddress,
|
||||||
|
@JsonKey(name: 'citizenship', includeIfNull: false) String? citizenship,
|
||||||
|
@JsonKey(name: 'tax_resident_us', includeIfNull: false) bool? taxResidentUs,
|
||||||
|
@JsonKey(name: 'foreign_tax_resident', includeIfNull: false)
|
||||||
|
bool? foreignTaxResident,
|
||||||
|
@JsonKey(name: 'sbp_phone', includeIfNull: false) String? sbpPhone,
|
||||||
|
@JsonKey(name: 'sbp_bank_member_id', includeIfNull: false)
|
||||||
|
String? sbpBankMemberId,
|
||||||
|
@JsonKey(name: 'agreement_accepted', includeIfNull: false)
|
||||||
|
bool? agreementAccepted,
|
||||||
|
@JsonKey(name: 'frdo_agreement_accepted', includeIfNull: false)
|
||||||
|
bool? frdoAgreementAccepted,
|
||||||
}) = _ConsultantProfileModel;
|
}) = _ConsultantProfileModel;
|
||||||
|
|
||||||
factory ConsultantProfileModel.fromJson(Map<String, Object?> json) => _$ConsultantProfileModelFromJson(json);
|
factory ConsultantProfileModel.fromJson(Map<String, Object?> json) =>
|
||||||
|
_$ConsultantProfileModelFromJson(json);
|
||||||
|
|
||||||
|
bool get hasValidPassportData => PassportDataValidator.validate(this).isEmpty;
|
||||||
}
|
}
|
||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
abstract class SocialLinksModel with _$SocialLinksModel {
|
abstract class SocialLinksModel with _$SocialLinksModel {
|
||||||
const factory SocialLinksModel({
|
const factory SocialLinksModel({
|
||||||
@JsonKey(name: 'vk_link', includeIfNull: false) String? vkLink,
|
@JsonKey(name: 'vk_link', includeIfNull: false) String? vkLink,
|
||||||
@JsonKey(name: 'tg_link', includeIfNull: false) String? tgLink,
|
@JsonKey(name: 'tg_link', includeIfNull: false) String? tgLink,
|
||||||
@JsonKey(name: 'fb_link', includeIfNull: false) String? fbLink,
|
@JsonKey(name: 'fb_link', includeIfNull: false) String? fbLink,
|
||||||
@JsonKey(name: 'insta_link', includeIfNull: false) String? instaLink,
|
@JsonKey(name: 'insta_link', includeIfNull: false) String? instaLink,
|
||||||
@JsonKey(name: 'personal_site_link', includeIfNull: false) String? personalSiteLink,
|
@JsonKey(name: 'personal_site_link', includeIfNull: false)
|
||||||
|
String? personalSiteLink,
|
||||||
}) = _SocialLinksModel;
|
}) = _SocialLinksModel;
|
||||||
|
|
||||||
factory SocialLinksModel.fromJson(Map<String, Object?> json) => _$SocialLinksModelFromJson(json);
|
factory SocialLinksModel.fromJson(Map<String, Object?> json) =>
|
||||||
|
_$SocialLinksModelFromJson(json);
|
||||||
}
|
}
|
||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
@@ -36,5 +80,6 @@ abstract class CertificatesModel with _$CertificatesModel {
|
|||||||
required String date,
|
required String date,
|
||||||
}) = _CertificatesModel;
|
}) = _CertificatesModel;
|
||||||
|
|
||||||
factory CertificatesModel.fromJson(Map<String, Object?> json) => _$CertificatesModelFromJson(json);
|
factory CertificatesModel.fromJson(Map<String, Object?> json) =>
|
||||||
|
_$CertificatesModelFromJson(json);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart';
|
||||||
import 'package:eatvio_app/features/profile/models/profile_model.dart';
|
import 'package:eatvio_app/features/profile/models/profile_model.dart';
|
||||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
@@ -11,26 +12,81 @@ abstract class ConsultationRequestModel with _$ConsultationRequestModel {
|
|||||||
@JsonKey(name: 'rating_place') int? ratingPlace,
|
@JsonKey(name: 'rating_place') int? ratingPlace,
|
||||||
@JsonKey(name: 'education_resource') required EducationModel educationModel,
|
@JsonKey(name: 'education_resource') required EducationModel educationModel,
|
||||||
@JsonKey(name: 'cert_photos') required List<CertPhotosModel> photos,
|
@JsonKey(name: 'cert_photos') required List<CertPhotosModel> photos,
|
||||||
@JsonKey(name: 'canceled_moderation') CanceledModerationModel? canceledModeration,
|
@JsonKey(name: 'canceled_moderation')
|
||||||
|
CanceledModerationModel? canceledModeration,
|
||||||
|
@JsonKey(name: 'legal_entity_resource') LegalEntityModel? legalEntity,
|
||||||
}) = _ConsultationRequestModel;
|
}) = _ConsultationRequestModel;
|
||||||
|
|
||||||
factory ConsultationRequestModel.fromJson(Map<String, dynamic> json) => _$ConsultationRequestModelFromJson(json);
|
factory ConsultationRequestModel.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$ConsultationRequestModelFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class LegalEntityModel with _$LegalEntityModel {
|
||||||
|
const LegalEntityModel._();
|
||||||
|
|
||||||
|
const factory LegalEntityModel({
|
||||||
|
required int id,
|
||||||
|
required String type,
|
||||||
|
String? inn,
|
||||||
|
String? snils,
|
||||||
|
String? state,
|
||||||
|
@JsonKey(
|
||||||
|
name: 'alfa_scoring_status',
|
||||||
|
unknownEnumValue: AlfaScoringStatus.none,
|
||||||
|
defaultValue: AlfaScoringStatus.none,
|
||||||
|
)
|
||||||
|
@Default(AlfaScoringStatus.none)
|
||||||
|
AlfaScoringStatus alfaScoringStatus,
|
||||||
|
@JsonKey(name: 'alfa_scoring_request_id') String? alfaScoringRequestId,
|
||||||
|
@JsonKey(name: 'last_name') String? lastName,
|
||||||
|
@JsonKey(name: 'first_name') String? firstName,
|
||||||
|
@JsonKey(name: 'middle_name') String? middleName,
|
||||||
|
@JsonKey(name: 'passport_series') String? passportSeries,
|
||||||
|
@JsonKey(name: 'passport_number') String? passportNumber,
|
||||||
|
@JsonKey(name: 'passport_issue_date') String? passportIssueDate,
|
||||||
|
@JsonKey(name: 'passport_issuer') String? passportIssuer,
|
||||||
|
@JsonKey(name: 'passport_issuer_code') String? passportIssuerCode,
|
||||||
|
@JsonKey(name: 'birth_date') String? birthDate,
|
||||||
|
@JsonKey(name: 'birth_place') String? birthPlace,
|
||||||
|
@JsonKey(name: 'registration_address') String? registrationAddress,
|
||||||
|
@JsonKey(name: 'residence_address') String? residenceAddress,
|
||||||
|
String? citizenship,
|
||||||
|
@JsonKey(name: 'tax_resident_us') bool? taxResidentUs,
|
||||||
|
@JsonKey(name: 'foreign_tax_resident') bool? foreignTaxResident,
|
||||||
|
@JsonKey(name: 'sbp_phone') String? sbpPhone,
|
||||||
|
@JsonKey(name: 'sbp_bank_member_id') String? sbpBankMemberId,
|
||||||
|
}) = _LegalEntityModel;
|
||||||
|
|
||||||
|
factory LegalEntityModel.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$LegalEntityModelFromJson(json);
|
||||||
|
|
||||||
|
/// Normalizes API date (`Y-m-d` or ISO datetime) to `Y-m-d`.
|
||||||
|
static String? normalizeApiDate(String? value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (value.length >= 10 && RegExp(r'^\d{4}-\d{2}-\d{2}').hasMatch(value)) {
|
||||||
|
return value.substring(0, 10);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? get normalizedPassportIssueDate =>
|
||||||
|
normalizeApiDate(passportIssueDate);
|
||||||
|
|
||||||
|
String? get normalizedBirthDate => normalizeApiDate(birthDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
abstract class CertPhotosModel with _$CertPhotosModel {
|
abstract class CertPhotosModel with _$CertPhotosModel {
|
||||||
const factory CertPhotosModel({
|
const factory CertPhotosModel({required String url}) = _CertPhotosModel;
|
||||||
required String url,
|
|
||||||
}) = _CertPhotosModel;
|
|
||||||
|
|
||||||
factory CertPhotosModel.fromJson(Map<String, dynamic> json) => _$CertPhotosModelFromJson(json);
|
factory CertPhotosModel.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$CertPhotosModelFromJson(json);
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ModerationStatus {
|
enum ModerationStatus { approved, created, canceled }
|
||||||
approved,
|
|
||||||
created,
|
|
||||||
canceled
|
|
||||||
}
|
|
||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
abstract class CanceledModerationModel with _$CanceledModerationModel {
|
abstract class CanceledModerationModel with _$CanceledModerationModel {
|
||||||
@@ -40,5 +96,6 @@ abstract class CanceledModerationModel with _$CanceledModerationModel {
|
|||||||
required List<String>? additional,
|
required List<String>? additional,
|
||||||
}) = _CanceledModerationModel;
|
}) = _CanceledModerationModel;
|
||||||
|
|
||||||
factory CanceledModerationModel.fromJson(Map<String, dynamic> json) => _$CanceledModerationModelFromJson(json);
|
factory CanceledModerationModel.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$CanceledModerationModelFromJson(json);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
|
part 'sbp_bank_model.freezed.dart';
|
||||||
|
part 'sbp_bank_model.g.dart';
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class SbpBankModel with _$SbpBankModel {
|
||||||
|
const factory SbpBankModel({
|
||||||
|
@JsonKey(name: 'bank_member_id') required String bankMemberId,
|
||||||
|
@JsonKey(name: 'bank_name') required String bankName,
|
||||||
|
@JsonKey(name: 'short_name') String? shortName,
|
||||||
|
@JsonKey(name: 'logo_url') String? logoUrl,
|
||||||
|
String? bic,
|
||||||
|
}) = _SbpBankModel;
|
||||||
|
|
||||||
|
factory SbpBankModel.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$SbpBankModelFromJson(json);
|
||||||
|
}
|
||||||
@@ -1,42 +1,118 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||||
import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart';
|
import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_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';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_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/presentation/bloc/register_consultant_cubit/register_consultant_state.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/utils/laravel_validation_errors.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/utils/passport_data_validator.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
|
||||||
|
|
||||||
class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
||||||
final ConsultantRepository consultantRepository;
|
final ConsultantRepository consultantRepository;
|
||||||
|
final Duration scoringPollInterval;
|
||||||
bool _update = false;
|
bool _update = false;
|
||||||
|
String? _userSlug;
|
||||||
|
Timer? _scoringPollTimer;
|
||||||
|
|
||||||
RegisterConsultantCubit({required this.consultantRepository})
|
static const String individualPersonLegalType = 'individualPerson';
|
||||||
: super(
|
|
||||||
const RegisterConsultantState(
|
RegisterConsultantCubit({
|
||||||
consultant: ConsultantProfileModel(socialLinks: SocialLinksModel(), hasMedicalEducation: true),
|
required this.consultantRepository,
|
||||||
loading: true,
|
this.scoringPollInterval = const Duration(seconds: 3),
|
||||||
acceptOffer: false,
|
}) : super(
|
||||||
isUpdating: false,
|
const RegisterConsultantState(
|
||||||
),
|
consultant: ConsultantProfileModel(
|
||||||
);
|
socialLinks: SocialLinksModel(),
|
||||||
|
hasMedicalEducation: true,
|
||||||
|
legalType: individualPersonLegalType,
|
||||||
|
),
|
||||||
|
loading: true,
|
||||||
|
acceptOffer: false,
|
||||||
|
acceptFrdoAgreement: false,
|
||||||
|
isUpdating: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
String? get userSlug => _userSlug;
|
||||||
|
|
||||||
|
void setUserSlug(String? slug) {
|
||||||
|
if (slug != null && slug.isNotEmpty) {
|
||||||
|
_userSlug = slug;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<SbpBankModel>> _loadSbpBanks() async {
|
||||||
|
try {
|
||||||
|
return await consultantRepository.getSbpBanks();
|
||||||
|
} catch (e, st) {
|
||||||
|
await reportFrom(e, st, this);
|
||||||
|
log(e.toString());
|
||||||
|
return const <SbpBankModel>[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<SbpBankModel> searchSbpBanks(String query) {
|
||||||
|
final normalized = query.trim().toLowerCase();
|
||||||
|
if (normalized.isEmpty) {
|
||||||
|
return state.sbpBanks;
|
||||||
|
}
|
||||||
|
return state.sbpBanks.where((bank) {
|
||||||
|
final name = bank.bankName.toLowerCase();
|
||||||
|
final shortName = (bank.shortName ?? '').toLowerCase();
|
||||||
|
final bic = (bank.bic ?? '').toLowerCase();
|
||||||
|
return name.contains(normalized) ||
|
||||||
|
shortName.contains(normalized) ||
|
||||||
|
bic.contains(normalized);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
SbpBankModel? findSbpBankById(String? bankMemberId) {
|
||||||
|
if (bankMemberId == null || bankMemberId.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (final bank in state.sbpBanks) {
|
||||||
|
if (bank.bankMemberId == bankMemberId) {
|
||||||
|
return bank;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> getMyRequestStatus(String slug) async {
|
Future<void> getMyRequestStatus(String slug) async {
|
||||||
try {
|
try {
|
||||||
_update = true;
|
_update = true;
|
||||||
final myRequestStatus = await consultantRepository.getMyRequestStatus(slug);
|
_userSlug = slug;
|
||||||
|
final myRequestStatus = await consultantRepository.getMyRequestStatus(
|
||||||
|
slug,
|
||||||
|
);
|
||||||
final certPhotos = await consultantRepository.getConsultantCertPhotos();
|
final certPhotos = await consultantRepository.getConsultantCertPhotos();
|
||||||
|
final sbpBanks = await _loadSbpBanks();
|
||||||
|
|
||||||
List<CertificatesModel>? certificates = myRequestStatus.educationModel.completedCourses?.map((courseMap) {
|
List<CertificatesModel>? certificates = myRequestStatus
|
||||||
final course = courseMap.values.first;
|
.educationModel
|
||||||
|
.completedCourses
|
||||||
|
?.map((courseMap) {
|
||||||
|
final course = courseMap.values.first;
|
||||||
|
|
||||||
return CertificatesModel(title: course.title, date: course.date);
|
return CertificatesModel(title: course.title, date: course.date);
|
||||||
}).toList();
|
})
|
||||||
|
.toList();
|
||||||
|
|
||||||
String? sanitizeLink(String? link) {
|
String? sanitizeLink(String? link) {
|
||||||
return (link != null && link.isNotEmpty) ? link : null;
|
return (link != null && link.isNotEmpty) ? link : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final legal = myRequestStatus.legalEntity;
|
||||||
|
final scoringStatus = legal?.alfaScoringStatus ?? AlfaScoringStatus.none;
|
||||||
|
|
||||||
emit(
|
emit(
|
||||||
RegisterConsultantState(
|
RegisterConsultantState(
|
||||||
consultant: ConsultantProfileModel(
|
consultant: ConsultantProfileModel(
|
||||||
@@ -46,33 +122,107 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
|||||||
tgLink: sanitizeLink(myRequestStatus.educationModel.tgLink),
|
tgLink: sanitizeLink(myRequestStatus.educationModel.tgLink),
|
||||||
fbLink: sanitizeLink(myRequestStatus.educationModel.fbLink),
|
fbLink: sanitizeLink(myRequestStatus.educationModel.fbLink),
|
||||||
instaLink: sanitizeLink(myRequestStatus.educationModel.instaLink),
|
instaLink: sanitizeLink(myRequestStatus.educationModel.instaLink),
|
||||||
personalSiteLink: sanitizeLink(myRequestStatus.educationModel.personalSiteLink),
|
personalSiteLink: sanitizeLink(
|
||||||
|
myRequestStatus.educationModel.personalSiteLink,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
hasMedicalEducation: myRequestStatus.educationModel.hasMedicalEducation == 1,
|
hasMedicalEducation:
|
||||||
|
myRequestStatus.educationModel.hasMedicalEducation == 1,
|
||||||
|
legalType: legal?.type ?? individualPersonLegalType,
|
||||||
|
lastName: _emptyToNull(legal?.lastName),
|
||||||
|
firstName: _emptyToNull(legal?.firstName),
|
||||||
|
middleName: _emptyToNull(legal?.middleName),
|
||||||
|
inn: _emptyToNull(legal?.inn),
|
||||||
|
snils: _emptyToNull(legal?.snils),
|
||||||
|
passportSeries: _emptyToNull(legal?.passportSeries),
|
||||||
|
passportNumber: _emptyToNull(legal?.passportNumber),
|
||||||
|
passportIssueDate: legal?.normalizedPassportIssueDate,
|
||||||
|
passportIssuer: _emptyToNull(legal?.passportIssuer),
|
||||||
|
passportIssuerCode: _emptyToNull(legal?.passportIssuerCode),
|
||||||
|
birthDate: legal?.normalizedBirthDate,
|
||||||
|
birthPlace: _emptyToNull(legal?.birthPlace),
|
||||||
|
registrationAddress: _emptyToNull(legal?.registrationAddress),
|
||||||
|
residenceAddress: _emptyToNull(legal?.residenceAddress),
|
||||||
|
citizenship: _emptyToNull(legal?.citizenship),
|
||||||
|
taxResidentUs: legal?.taxResidentUs ?? false,
|
||||||
|
foreignTaxResident: legal?.foreignTaxResident ?? false,
|
||||||
|
sbpPhone: _emptyToNull(legal?.sbpPhone),
|
||||||
|
sbpBankMemberId: _emptyToNull(legal?.sbpBankMemberId),
|
||||||
),
|
),
|
||||||
acceptOffer: false,
|
acceptOffer: false,
|
||||||
|
acceptFrdoAgreement: false,
|
||||||
loading: false,
|
loading: false,
|
||||||
requestModel: myRequestStatus,
|
requestModel: myRequestStatus,
|
||||||
certPhotos: certPhotos,
|
certPhotos: certPhotos,
|
||||||
|
sbpBanks: sbpBanks,
|
||||||
isUpdating: _update,
|
isUpdating: _update,
|
||||||
|
alfaScoringStatus: scoringStatus,
|
||||||
|
isScoringPolling: scoringStatus.isProcessing,
|
||||||
|
scoringRetryInProgress: false,
|
||||||
|
awaitingScoringOutcome: false,
|
||||||
|
fieldErrors: const <String, String>{},
|
||||||
error: null,
|
error: null,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (scoringStatus.isProcessing) {
|
||||||
|
startScoringPoll();
|
||||||
|
}
|
||||||
|
} catch (e, st) {
|
||||||
|
await reportFrom(e, st, this);
|
||||||
|
log(e.toString());
|
||||||
|
final parsed = parseLaravelValidationErrors(e);
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
loading: false,
|
||||||
|
error: parsed.summary,
|
||||||
|
fieldErrors: parsed.fieldErrors,
|
||||||
|
awaitingScoringOutcome: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _emptyToNull(String? value) {
|
||||||
|
if (value == null || value.trim().isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> disableLoading({String? slug}) async {
|
||||||
|
try {
|
||||||
|
setUserSlug(slug);
|
||||||
|
final certPhotos = await consultantRepository.getConsultantCertPhotos();
|
||||||
|
final sbpBanks = await _loadSbpBanks();
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
loading: false,
|
||||||
|
certPhotos: certPhotos,
|
||||||
|
sbpBanks: sbpBanks,
|
||||||
|
error: null,
|
||||||
|
fieldErrors: const <String, String>{},
|
||||||
|
awaitingScoringOutcome: false,
|
||||||
|
consultant: state.consultant.copyWith(
|
||||||
|
legalType: individualPersonLegalType,
|
||||||
|
citizenship: state.consultant.citizenship,
|
||||||
|
taxResidentUs: state.consultant.taxResidentUs ?? false,
|
||||||
|
foreignTaxResident: state.consultant.foreignTaxResident ?? false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
log(e.toString());
|
log(e.toString());
|
||||||
emit(state.copyWith(loading: false, error: e.toString()));
|
final parsed = parseLaravelValidationErrors(e);
|
||||||
}
|
emit(
|
||||||
}
|
state.copyWith(
|
||||||
|
loading: false,
|
||||||
Future<void> disableLoading() async {
|
error: parsed.summary,
|
||||||
try {
|
fieldErrors: parsed.fieldErrors,
|
||||||
final certPhotos = await consultantRepository.getConsultantCertPhotos();
|
awaitingScoringOutcome: false,
|
||||||
emit(state.copyWith(loading: false, certPhotos: certPhotos, error: null));
|
),
|
||||||
} catch (e, st) {
|
);
|
||||||
await reportFrom(e, st, this);
|
|
||||||
log(e.toString());
|
|
||||||
emit(state.copyWith(loading: false, error: e.toString()));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,41 +232,326 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
|||||||
if (slug != null) {
|
if (slug != null) {
|
||||||
await getMyRequestStatus(slug);
|
await getMyRequestStatus(slug);
|
||||||
} else {
|
} else {
|
||||||
await disableLoading();
|
await disableLoading(slug: _userSlug);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateConsultantProfileModel(ConsultantProfileModel consultant, [bool acceptOffer = false]) {
|
void updateConsultantProfileModel(
|
||||||
emit(state.copyWith(consultant: consultant, acceptOffer: acceptOffer, loading: false, error: null));
|
ConsultantProfileModel consultant, [
|
||||||
|
bool? acceptOffer,
|
||||||
|
bool? acceptFrdoAgreement,
|
||||||
|
]) {
|
||||||
|
final nextFieldErrors = state.fieldErrors.isEmpty
|
||||||
|
? const <String, String>{}
|
||||||
|
: PassportDataValidator.validate(consultant);
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
consultant: consultant,
|
||||||
|
acceptOffer: acceptOffer ?? state.acceptOffer,
|
||||||
|
acceptFrdoAgreement: acceptFrdoAgreement ?? state.acceptFrdoAgreement,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
fieldErrors: nextFieldErrors,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String> validatePassportFields() {
|
||||||
|
final errors = PassportDataValidator.validate(state.consultant);
|
||||||
|
emit(state.copyWith(fieldErrors: errors, error: null));
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateAgreements({bool? acceptOffer, bool? acceptFrdoAgreement}) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
acceptOffer: acceptOffer ?? state.acceptOffer,
|
||||||
|
acceptFrdoAgreement: acceptFrdoAgreement ?? state.acceptFrdoAgreement,
|
||||||
|
error: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get hasAcceptedRequiredAgreements {
|
||||||
|
if (_update) {
|
||||||
|
return state.acceptOffer;
|
||||||
|
}
|
||||||
|
return state.acceptOffer && state.acceptFrdoAgreement;
|
||||||
|
}
|
||||||
|
|
||||||
|
ConsultantProfileModel buildSubmitPayload() {
|
||||||
|
final withLegalType = state.consultant.copyWith(
|
||||||
|
legalType: individualPersonLegalType,
|
||||||
|
citizenship: state.consultant.citizenship,
|
||||||
|
taxResidentUs: state.consultant.taxResidentUs ?? false,
|
||||||
|
foreignTaxResident: state.consultant.foreignTaxResident ?? false,
|
||||||
|
certificates: state.consultant.certificates
|
||||||
|
?.map(
|
||||||
|
(course) => CertificatesModel(
|
||||||
|
title: course.title,
|
||||||
|
date:
|
||||||
|
LegalEntityModel.normalizeApiDate(course.date) ?? course.date,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (_update) {
|
||||||
|
return ConsultantProfileModel(
|
||||||
|
hasMedicalEducation: withLegalType.hasMedicalEducation,
|
||||||
|
socialLinks: withLegalType.socialLinks,
|
||||||
|
photos: withLegalType.photos,
|
||||||
|
certificates: withLegalType.certificates,
|
||||||
|
legalType: withLegalType.legalType,
|
||||||
|
lastName: withLegalType.lastName,
|
||||||
|
firstName: withLegalType.firstName,
|
||||||
|
middleName: withLegalType.middleName,
|
||||||
|
inn: withLegalType.inn,
|
||||||
|
snils: withLegalType.snils,
|
||||||
|
passportSeries: withLegalType.passportSeries,
|
||||||
|
passportNumber: withLegalType.passportNumber,
|
||||||
|
passportIssueDate: withLegalType.passportIssueDate,
|
||||||
|
passportIssuer: withLegalType.passportIssuer,
|
||||||
|
passportIssuerCode: withLegalType.passportIssuerCode,
|
||||||
|
birthDate: withLegalType.birthDate,
|
||||||
|
birthPlace: withLegalType.birthPlace,
|
||||||
|
registrationAddress: withLegalType.registrationAddress,
|
||||||
|
residenceAddress: withLegalType.residenceAddress,
|
||||||
|
citizenship: withLegalType.citizenship,
|
||||||
|
taxResidentUs: withLegalType.taxResidentUs,
|
||||||
|
foreignTaxResident: withLegalType.foreignTaxResident,
|
||||||
|
sbpPhone: withLegalType.sbpPhone,
|
||||||
|
sbpBankMemberId: withLegalType.sbpBankMemberId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return withLegalType.copyWith(
|
||||||
|
agreementAccepted: state.acceptOffer,
|
||||||
|
frdoAgreementAccepted: state.acceptFrdoAgreement,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> registerConsultant() async {
|
Future<void> registerConsultant() async {
|
||||||
emit(state.copyWith(error: null));
|
final passportErrors = validatePassportFields();
|
||||||
|
if (passportErrors.isNotEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show scoring widget immediately while request / job are in progress.
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
error: null,
|
||||||
|
fieldErrors: const <String, String>{},
|
||||||
|
alfaScoringStatus: AlfaScoringStatus.beneficiaryCreating,
|
||||||
|
awaitingScoringOutcome: true,
|
||||||
|
scoringRetryInProgress: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (state.newAddedPhotos.isNotEmpty) {
|
if (state.newAddedPhotos.isNotEmpty) {
|
||||||
await consultantRepository.storeConsultantCertPhotos(files: state.newAddedPhotos);
|
await consultantRepository.storeConsultantCertPhotos(
|
||||||
|
files: state.newAddedPhotos,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (state.deletedPhotoIds.isNotEmpty) {
|
if (state.deletedPhotoIds.isNotEmpty) {
|
||||||
for (final int id in state.deletedPhotoIds) {
|
for (final int id in state.deletedPhotoIds) {
|
||||||
await consultantRepository.deletePhoto(id);
|
await consultantRepository.deletePhoto(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await consultantRepository.registerConsultant(consultant: state.consultant, update: _update);
|
await consultantRepository.registerConsultant(
|
||||||
|
consultant: buildSubmitPayload(),
|
||||||
|
update: _update,
|
||||||
|
);
|
||||||
|
_update = true;
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
isUpdating: true,
|
||||||
|
alfaScoringStatus: AlfaScoringStatus.beneficiaryCreating,
|
||||||
|
isScoringPolling: true,
|
||||||
|
scoringRetryInProgress: false,
|
||||||
|
awaitingScoringOutcome: true,
|
||||||
|
newAddedPhotos: const <File>[],
|
||||||
|
deletedPhotoIds: const <int>[],
|
||||||
|
fieldErrors: const <String, String>{},
|
||||||
|
error: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
startScoringPoll();
|
||||||
|
} on DioException catch (e, st) {
|
||||||
|
await reportFrom(e, st, this);
|
||||||
|
log(e.toString());
|
||||||
|
final parsed = parseLaravelValidationErrors(e);
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
error: parsed.summary,
|
||||||
|
fieldErrors: parsed.fieldErrors,
|
||||||
|
scoringRetryInProgress: false,
|
||||||
|
awaitingScoringOutcome: false,
|
||||||
|
isScoringPolling: false,
|
||||||
|
alfaScoringStatus: AlfaScoringStatus.none,
|
||||||
|
),
|
||||||
|
);
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
log(e.toString());
|
log(e.toString());
|
||||||
emit(state.copyWith(error: e.toString()));
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
error: e.toString(),
|
||||||
|
scoringRetryInProgress: false,
|
||||||
|
awaitingScoringOutcome: false,
|
||||||
|
isScoringPolling: false,
|
||||||
|
alfaScoringStatus: AlfaScoringStatus.none,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> retryScoring() async {
|
||||||
|
if (!state.alfaScoringStatus.canRetry || state.scoringRetryInProgress) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final passportErrors = validatePassportFields();
|
||||||
|
if (passportErrors.isNotEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
scoringRetryInProgress: true,
|
||||||
|
error: null,
|
||||||
|
fieldErrors: const <String, String>{},
|
||||||
|
alfaScoringStatus: AlfaScoringStatus.beneficiaryCreating,
|
||||||
|
isScoringPolling: true,
|
||||||
|
awaitingScoringOutcome: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_update = true;
|
||||||
|
try {
|
||||||
|
await consultantRepository.registerConsultant(
|
||||||
|
consultant: buildSubmitPayload(),
|
||||||
|
update: true,
|
||||||
|
);
|
||||||
|
startScoringPoll();
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
scoringRetryInProgress: false,
|
||||||
|
isUpdating: true,
|
||||||
|
awaitingScoringOutcome: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} on DioException catch (e, st) {
|
||||||
|
await reportFrom(e, st, this);
|
||||||
|
log(e.toString());
|
||||||
|
final parsed = parseLaravelValidationErrors(e);
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
scoringRetryInProgress: false,
|
||||||
|
isScoringPolling: false,
|
||||||
|
awaitingScoringOutcome: false,
|
||||||
|
alfaScoringStatus: AlfaScoringStatus.scoringFailed,
|
||||||
|
error: parsed.summary,
|
||||||
|
fieldErrors: parsed.fieldErrors,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e, st) {
|
||||||
|
await reportFrom(e, st, this);
|
||||||
|
log(e.toString());
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
scoringRetryInProgress: false,
|
||||||
|
isScoringPolling: false,
|
||||||
|
awaitingScoringOutcome: false,
|
||||||
|
alfaScoringStatus: AlfaScoringStatus.scoringFailed,
|
||||||
|
error: e.toString(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void startScoringPoll() {
|
||||||
|
final slug = _userSlug;
|
||||||
|
if (slug == null || slug.isEmpty) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(isScoringPolling: false, awaitingScoringOutcome: false),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_stopScoringPoll();
|
||||||
|
emit(state.copyWith(isScoringPolling: true));
|
||||||
|
_scoringPollTimer = Timer.periodic(
|
||||||
|
scoringPollInterval,
|
||||||
|
(_) => _pollScoringStatus(slug),
|
||||||
|
);
|
||||||
|
unawaited(_pollScoringStatus(slug));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pollScoringStatus(String slug) async {
|
||||||
|
if (isClosed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final status = await consultantRepository.getMyRequestStatus(slug);
|
||||||
|
if (isClosed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final scoringStatus =
|
||||||
|
status.legalEntity?.alfaScoringStatus ?? AlfaScoringStatus.none;
|
||||||
|
// API stays on `none` until ProcessConsultantScoringJob starts
|
||||||
|
// (ScoringStarted → beneficiary_creating). Keep optimistic processing
|
||||||
|
// UI and continue polling until a real terminal/processing status arrives.
|
||||||
|
if (scoringStatus == AlfaScoringStatus.none) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
requestModel: status,
|
||||||
|
alfaScoringStatus: AlfaScoringStatus.beneficiaryCreating,
|
||||||
|
isScoringPolling: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final isTerminal = scoringStatus.isTerminal;
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
requestModel: status,
|
||||||
|
alfaScoringStatus: scoringStatus,
|
||||||
|
isScoringPolling: scoringStatus.isProcessing,
|
||||||
|
// Clear session flag on terminal so success sheet can fire once
|
||||||
|
// (listenWhen: previous.awaiting && !current.awaiting && complete).
|
||||||
|
awaitingScoringOutcome: isTerminal
|
||||||
|
? false
|
||||||
|
: state.awaitingScoringOutcome,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!scoringStatus.isProcessing) {
|
||||||
|
_stopScoringPoll();
|
||||||
|
}
|
||||||
|
} catch (e, st) {
|
||||||
|
await reportFrom(e, st, this);
|
||||||
|
log(e.toString());
|
||||||
|
// Keep polling on transient errors while scoring is in progress.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _stopScoringPoll() {
|
||||||
|
_scoringPollTimer?.cancel();
|
||||||
|
_scoringPollTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> addCertPhotos(List<File> files) async {
|
Future<void> addCertPhotos(List<File> files) async {
|
||||||
if (files.isEmpty) {
|
if (files.isEmpty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
final compressedFiles = await ImageCompressUtility.compressFilesList(files);
|
final compressedFiles = await ImageCompressUtility.compressFilesList(
|
||||||
|
files,
|
||||||
|
);
|
||||||
|
|
||||||
emit(state.copyWith(newAddedPhotos: [...state.newAddedPhotos, ...compressedFiles], error: null));
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
newAddedPhotos: [...state.newAddedPhotos, ...compressedFiles],
|
||||||
|
error: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
log(e.toString());
|
log(e.toString());
|
||||||
@@ -130,7 +565,19 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (path != null) {
|
if (path != null) {
|
||||||
emit(state.copyWith(newAddedPhotos: state.newAddedPhotos.where((file) => file.uri.path != path).toList()));
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
newAddedPhotos: state.newAddedPhotos
|
||||||
|
.where((file) => file.uri.path != path)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> close() {
|
||||||
|
_stopScoringPoll();
|
||||||
|
return super.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.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_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/consultant_profile_model.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart';
|
||||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
part 'register_consultant_state.freezed.dart';
|
part 'register_consultant_state.freezed.dart';
|
||||||
@@ -12,12 +14,22 @@ abstract class RegisterConsultantState with _$RegisterConsultantState {
|
|||||||
const factory RegisterConsultantState({
|
const factory RegisterConsultantState({
|
||||||
required ConsultantProfileModel consultant,
|
required ConsultantProfileModel consultant,
|
||||||
required bool acceptOffer,
|
required bool acceptOffer,
|
||||||
|
@Default(false) bool acceptFrdoAgreement,
|
||||||
required bool loading,
|
required bool loading,
|
||||||
required bool isUpdating,
|
required bool isUpdating,
|
||||||
ConsultationRequestModel? requestModel,
|
ConsultationRequestModel? requestModel,
|
||||||
@Default(<ConsultantCertPhotoModel>[]) List<ConsultantCertPhotoModel> certPhotos,
|
@Default(<ConsultantCertPhotoModel>[])
|
||||||
|
List<ConsultantCertPhotoModel> certPhotos,
|
||||||
|
@Default(<SbpBankModel>[]) List<SbpBankModel> sbpBanks,
|
||||||
@Default(<File>[]) List<File> newAddedPhotos,
|
@Default(<File>[]) List<File> newAddedPhotos,
|
||||||
@Default(<int>[]) List<int> deletedPhotoIds,
|
@Default(<int>[]) List<int> deletedPhotoIds,
|
||||||
|
@Default(AlfaScoringStatus.none) AlfaScoringStatus alfaScoringStatus,
|
||||||
|
@Default(false) bool isScoringPolling,
|
||||||
|
@Default(false) bool scoringRetryInProgress,
|
||||||
|
|
||||||
|
/// True only after successful register/retry in this session until scoring is terminal.
|
||||||
|
@Default(false) bool awaitingScoringOutcome,
|
||||||
|
@Default(<String, String>{}) Map<String, String> fieldErrors,
|
||||||
String? error,
|
String? error,
|
||||||
}) = _RegisterConsultantState;
|
}) = _RegisterConsultantState;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ class AdditionalSettingsScreen extends StatefulWidget {
|
|||||||
const AdditionalSettingsScreen({super.key});
|
const AdditionalSettingsScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AdditionalSettingsScreen> createState() => _AdditionalSettingsScreenState();
|
State<AdditionalSettingsScreen> createState() =>
|
||||||
|
_AdditionalSettingsScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AdditionalSettingsScreenState extends State<AdditionalSettingsScreen> {
|
class _AdditionalSettingsScreenState extends State<AdditionalSettingsScreen> {
|
||||||
@@ -17,7 +18,12 @@ class _AdditionalSettingsScreenState extends State<AdditionalSettingsScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
if (context.read<RegisterConsultantCubit>().state.consultant.hasMedicalEducation != null) {
|
if (context
|
||||||
|
.read<RegisterConsultantCubit>()
|
||||||
|
.state
|
||||||
|
.consultant
|
||||||
|
.hasMedicalEducation !=
|
||||||
|
null) {
|
||||||
haveMedicalDegree = context
|
haveMedicalDegree = context
|
||||||
.read<RegisterConsultantCubit>()
|
.read<RegisterConsultantCubit>()
|
||||||
.state
|
.state
|
||||||
@@ -52,7 +58,8 @@ class _AdditionalSettingsScreenState extends State<AdditionalSettingsScreen> {
|
|||||||
value: publishDiplomasAndCertificatesInProfile,
|
value: publishDiplomasAndCertificatesInProfile,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
setState(() {
|
setState(() {
|
||||||
publishDiplomasAndCertificatesInProfile = !publishDiplomasAndCertificatesInProfile;
|
publishDiplomasAndCertificatesInProfile =
|
||||||
|
!publishDiplomasAndCertificatesInProfile;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -64,11 +71,15 @@ class _AdditionalSettingsScreenState extends State<AdditionalSettingsScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
haveMedicalDegree = !haveMedicalDegree;
|
haveMedicalDegree = !haveMedicalDegree;
|
||||||
});
|
});
|
||||||
context.read<RegisterConsultantCubit>().updateConsultantProfileModel(
|
context
|
||||||
context.read<RegisterConsultantCubit>().state.consultant.copyWith(
|
.read<RegisterConsultantCubit>()
|
||||||
hasMedicalEducation: haveMedicalDegree,
|
.updateConsultantProfileModel(
|
||||||
),
|
context
|
||||||
);
|
.read<RegisterConsultantCubit>()
|
||||||
|
.state
|
||||||
|
.consultant
|
||||||
|
.copyWith(hasMedicalEducation: haveMedicalDegree),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
class BecomeConsultantScreen extends StatefulWidget {
|
class BecomeConsultantScreen extends StatefulWidget {
|
||||||
const BecomeConsultantScreen({super.key});
|
final String? slug;
|
||||||
|
|
||||||
|
const BecomeConsultantScreen({super.key, this.slug});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<BecomeConsultantScreen> createState() => _BecomeConsultantScreenState();
|
State<BecomeConsultantScreen> createState() => _BecomeConsultantScreenState();
|
||||||
@@ -17,7 +19,10 @@ class _BecomeConsultantScreenState extends State<BecomeConsultantScreen> {
|
|||||||
title: const Text('Стать консультантом'),
|
title: const Text('Стать консультантом'),
|
||||||
automaticallyImplyLeading: false,
|
automaticallyImplyLeading: false,
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
leading: IconButton(onPressed: () => GoRouter.of(context).pop(), icon: const Icon(Icons.close)),
|
leading: IconButton(
|
||||||
|
onPressed: () => GoRouter.of(context).pop(),
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
body: const Padding(
|
body: const Padding(
|
||||||
padding: EdgeInsets.all(16),
|
padding: EdgeInsets.all(16),
|
||||||
@@ -32,16 +37,27 @@ class _BecomeConsultantScreenState extends State<BecomeConsultantScreen> {
|
|||||||
|
|
||||||
InkWell _startRegistrationButton() {
|
InkWell _startRegistrationButton() {
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: () => GoRouter.of(context).pushReplacementNamed('stepperBecomeConsultantScreen', extra: {'slug': null}),
|
onTap: () => GoRouter.of(context).pushReplacementNamed(
|
||||||
|
'stepperBecomeConsultantScreen',
|
||||||
|
extra: {'slug': widget.slug, 'isNewRegistration': true},
|
||||||
|
),
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
child: Ink(
|
child: Ink(
|
||||||
height: 52,
|
height: 52,
|
||||||
width: MediaQuery.of(context).size.width - 32,
|
width: MediaQuery.of(context).size.width - 32,
|
||||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
color: appTheme.blueA400,
|
||||||
|
),
|
||||||
child: const Center(
|
child: const Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Начать регистрацию',
|
'Начать регистрацию',
|
||||||
style: TextStyle(color: Colors.white, height: 1, fontWeight: FontWeight.w600, fontSize: 18),
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
height: 1,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 18,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -7,15 +7,18 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:eatvio_app/core/utils/date_time_utils.dart';
|
import 'package:eatvio_app/core/utils/date_time_utils.dart';
|
||||||
import 'package:flutter/scheduler.dart';
|
import 'package:flutter/scheduler.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
class CertificatesAndDiplomasScreen extends StatefulWidget {
|
class CertificatesAndDiplomasScreen extends StatefulWidget {
|
||||||
const CertificatesAndDiplomasScreen({super.key});
|
const CertificatesAndDiplomasScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<CertificatesAndDiplomasScreen> createState() => _CertificatesAndDiplomasScreenState();
|
State<CertificatesAndDiplomasScreen> createState() =>
|
||||||
|
_CertificatesAndDiplomasScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasScreen> {
|
class _CertificatesAndDiplomasScreenState
|
||||||
|
extends State<CertificatesAndDiplomasScreen> {
|
||||||
final scrollController = ScrollController();
|
final scrollController = ScrollController();
|
||||||
|
|
||||||
List<DiplomaEntity> diplomasList = [
|
List<DiplomaEntity> diplomasList = [
|
||||||
@@ -29,9 +32,15 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
if (context.read<RegisterConsultantCubit>().state.consultant.certificates != null) {
|
if (context.read<RegisterConsultantCubit>().state.consultant.certificates !=
|
||||||
|
null) {
|
||||||
diplomasList = [
|
diplomasList = [
|
||||||
...context.read<RegisterConsultantCubit>().state.consultant.certificates!.map(
|
...context
|
||||||
|
.read<RegisterConsultantCubit>()
|
||||||
|
.state
|
||||||
|
.consultant
|
||||||
|
.certificates!
|
||||||
|
.map(
|
||||||
(e) => DiplomaEntity(
|
(e) => DiplomaEntity(
|
||||||
date: DateTime.tryParse(e.date),
|
date: DateTime.tryParse(e.date),
|
||||||
textEditingController: TextEditingController(text: e.title),
|
textEditingController: TextEditingController(text: e.title),
|
||||||
@@ -70,7 +79,8 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
|
|||||||
ListView.separated(
|
ListView.separated(
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
itemBuilder: (context, index) => _diplomaInfo(diploma: diplomasList[index]),
|
itemBuilder: (context, index) =>
|
||||||
|
_diplomaInfo(diploma: diplomasList[index]),
|
||||||
separatorBuilder: (context, index) => Padding(
|
separatorBuilder: (context, index) => Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
child: _divider(context),
|
child: _divider(context),
|
||||||
@@ -93,9 +103,7 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Column _diplomaInfo({
|
Column _diplomaInfo({required DiplomaEntity diploma}) {
|
||||||
required DiplomaEntity diploma,
|
|
||||||
}) {
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
CustomTextFormField(
|
CustomTextFormField(
|
||||||
@@ -111,7 +119,9 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: MediaQuery.of(context).size.width - (diploma.enableDeleteButton ? 92 : 32),
|
width:
|
||||||
|
MediaQuery.of(context).size.width -
|
||||||
|
(diploma.enableDeleteButton ? 92 : 32),
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
onTap: onTapCalendar(context, diploma),
|
onTap: onTapCalendar(context, diploma),
|
||||||
trailing: Icon(
|
trailing: Icon(
|
||||||
@@ -122,7 +132,9 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
|
|||||||
title: Text(
|
title: Text(
|
||||||
diploma.date?.format('dd MMMM yyyy', 'ru') ?? 'Дата выдачи',
|
diploma.date?.format('dd MMMM yyyy', 'ru') ?? 'Дата выдачи',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: diploma.date == null ? Colors.grey.shade400 : Colors.black,
|
color: diploma.date == null
|
||||||
|
? Colors.grey.shade400
|
||||||
|
: Colors.black,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
tileColor: const Color(0xFFF6F6F6),
|
tileColor: const Color(0xFFF6F6F6),
|
||||||
@@ -148,9 +160,7 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
|
|||||||
color: const Color(0xFFF6F6F6),
|
color: const Color(0xFFF6F6F6),
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: const Icon(Icons.delete_outline_rounded),
|
||||||
Icons.delete_outline_rounded,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -190,16 +200,14 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
|
|||||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||||
),
|
),
|
||||||
color: CupertinoColors.systemBackground.resolveFrom(context),
|
color: CupertinoColors.systemBackground.resolveFrom(context),
|
||||||
child: SafeArea(
|
child: SafeArea(top: false, child: child),
|
||||||
top: false,
|
|
||||||
child: child,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void checkLast() {
|
void checkLast() {
|
||||||
if (diplomasList.last.textEditingController.text.isNotEmpty || diplomasList.last.date != null) {
|
if (diplomasList.last.textEditingController.text.isNotEmpty ||
|
||||||
|
diplomasList.last.date != null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
diplomasList.add(
|
diplomasList.add(
|
||||||
DiplomaEntity(
|
DiplomaEntity(
|
||||||
@@ -230,12 +238,19 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
|
|||||||
|
|
||||||
void _saveState() {
|
void _saveState() {
|
||||||
context.read<RegisterConsultantCubit>().updateConsultantProfileModel(
|
context.read<RegisterConsultantCubit>().updateConsultantProfileModel(
|
||||||
context.read<RegisterConsultantCubit>().state.consultant.copyWith(
|
context.read<RegisterConsultantCubit>().state.consultant.copyWith(
|
||||||
certificates: diplomasList.map((e) => CertificatesModel(title: e.textEditingController.text, date: e.date.toString())).toList().sublist(
|
certificates: diplomasList
|
||||||
0,
|
.map(
|
||||||
diplomasList.length - 1,
|
(e) => CertificatesModel(
|
||||||
),
|
title: e.textEditingController.text,
|
||||||
|
date: e.date == null
|
||||||
|
? ''
|
||||||
|
: DateFormat('yyyy-MM-dd').format(e.date!),
|
||||||
),
|
),
|
||||||
);
|
)
|
||||||
|
.toList()
|
||||||
|
.sublist(0, diplomasList.length - 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import 'package:eatvio_app/core/app_config.dart';
|
import 'package:eatvio_app/core/app_config.dart';
|
||||||
import 'package:eatvio_app/core/const.dart';
|
import 'package:eatvio_app/core/const.dart';
|
||||||
import 'package:eatvio_app/core/locator_service.dart';
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
|
|
||||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.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/widgets/scoring_status_widget.dart';
|
||||||
|
import 'package:flutter/gestures.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
@@ -15,10 +18,29 @@ class LastStepScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _LastStepScreenState extends State<LastStepScreen> {
|
class _LastStepScreenState extends State<LastStepScreen> {
|
||||||
bool checkboxState = false;
|
static const _frdoUrl =
|
||||||
|
'https://obrnadzor.gov.ru/gosudarstvennye-uslugi-i-funkczii/7701537808-gosfunction/formirovanie-i-vedenie-federalnogo-reestra-svedenij-o-dokumentah-ob-obrazovanii-i-ili-o-kvalifikaczii-dokumentah-ob-obuchenii/';
|
||||||
|
|
||||||
|
late final TapGestureRecognizer _frdoTapRecognizer;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_frdoTapRecognizer = TapGestureRecognizer()
|
||||||
|
..onTap = () async => await launchUrl(Uri.parse(_frdoUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_frdoTapRecognizer.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final cubit = context.watch<RegisterConsultantCubit>();
|
||||||
|
final state = cubit.state;
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
padding: const EdgeInsets.only(bottom: 80, top: 16),
|
padding: const EdgeInsets.only(bottom: 80, top: 16),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -27,34 +49,138 @@ class _LastStepScreenState extends State<LastStepScreen> {
|
|||||||
children: [
|
children: [
|
||||||
const Padding(
|
const Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Text('Последний шаг', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, height: 1.1)),
|
child: Text(
|
||||||
|
'Последний шаг',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
height: 1.1,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
if (state.awaitingScoringOutcome ||
|
||||||
|
state.isScoringPolling ||
|
||||||
|
state.alfaScoringStatus !=
|
||||||
|
AlfaScoringStatus.none) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const ScoringStatusWidget(),
|
||||||
|
],
|
||||||
|
if (state.requestModel?.canceledModeration != null) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0x33FFCC00),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
state.requestModel!.canceledModeration!.title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
height: 1.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (state.requestModel!.canceledModeration!.additional !=
|
||||||
|
null) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
for (final item
|
||||||
|
in state
|
||||||
|
.requestModel!
|
||||||
|
.canceledModeration!
|
||||||
|
.additional!) ...[
|
||||||
|
Text(
|
||||||
|
'• $item',
|
||||||
|
style: const TextStyle(fontSize: 14, height: 1.3),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
CheckboxListTile(
|
CheckboxListTile(
|
||||||
value: checkboxState,
|
value: state.acceptOffer,
|
||||||
title: Text(
|
title: Text(
|
||||||
hidePlans
|
hidePlans
|
||||||
? 'Я ознакомлен и принимаю политику конфиденциальности и защиту персональных данных'
|
? 'Я ознакомлен и принимаю политику конфиденциальности и защиту персональных данных'
|
||||||
: 'Я ознакомлен и принимаю договор публичной офёрты',
|
: 'Я ознакомлен и принимаю публичную оферту и политику конфиденциальности',
|
||||||
style: const TextStyle(fontSize: 18, height: 1),
|
style: const TextStyle(fontSize: 18, height: 1.2),
|
||||||
),
|
),
|
||||||
onChanged: (_) {
|
onChanged: (value) {
|
||||||
setState(() {
|
cubit.updateAgreements(acceptOffer: value ?? false);
|
||||||
checkboxState = !checkboxState;
|
|
||||||
});
|
|
||||||
context.read<RegisterConsultantCubit>().updateConsultantProfileModel(context.read<RegisterConsultantCubit>().state.consultant, checkboxState);
|
|
||||||
},
|
},
|
||||||
activeColor: appTheme.blueA400,
|
activeColor: appTheme.blueA400,
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
|
),
|
||||||
|
if (!state.isUpdating) ...[
|
||||||
|
CheckboxListTile(
|
||||||
|
value: state.acceptFrdoAgreement,
|
||||||
|
title: Text.rich(
|
||||||
|
TextSpan(
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
height: 1.2,
|
||||||
|
color: Color(0xFF323F4B),
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
const TextSpan(
|
||||||
|
text:
|
||||||
|
'Обязательное требование: Один из моих документов об образовании есть в реестре ',
|
||||||
|
),
|
||||||
|
TextSpan(
|
||||||
|
text: 'ФРДО',
|
||||||
|
style: TextStyle(
|
||||||
|
color: appTheme.blueA400,
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
),
|
||||||
|
recognizer: _frdoTapRecognizer,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onChanged: (value) {
|
||||||
|
cubit.updateAgreements(acceptFrdoAgreement: value ?? false);
|
||||||
|
},
|
||||||
|
activeColor: appTheme.blueA400,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||||
|
child: _divider(context),
|
||||||
),
|
),
|
||||||
Padding(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), child: _divider(context)),
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
child: TextButton(
|
child: TextButton(
|
||||||
onPressed: () async => await launchUrl(Uri.parse(hidePlans ? '${sl<AppConfig>().baseUrl}/docs/privacy.pdf' : '${sl<AppConfig>().baseUrl}/oferta_bs.pdf')),
|
onPressed: () async => await launchUrl(
|
||||||
|
Uri.parse(
|
||||||
|
hidePlans
|
||||||
|
? '${sl<AppConfig>().baseUrl}/docs/privacy.pdf'
|
||||||
|
: '${sl<AppConfig>().baseUrl}/oferta_bs.pdf',
|
||||||
|
),
|
||||||
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
hidePlans ? 'Политика конфиденциальности и пользовательское соглашение' : 'Договор офёрты',
|
hidePlans
|
||||||
style: const TextStyle(height: 1, fontSize: 16, color: Color(0xFF323F4B), decoration: TextDecoration.underline),
|
? 'Политика конфиденциальности и пользовательское соглашение'
|
||||||
|
: 'Договор оферты',
|
||||||
|
style: const TextStyle(
|
||||||
|
height: 1,
|
||||||
|
fontSize: 16,
|
||||||
|
color: Color(0xFF323F4B),
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -67,7 +193,10 @@ class _LastStepScreenState extends State<LastStepScreen> {
|
|||||||
return Container(
|
return Container(
|
||||||
height: 0.4,
|
height: 0.4,
|
||||||
width: MediaQuery.of(context).size.width - 32,
|
width: MediaQuery.of(context).size.width - 32,
|
||||||
decoration: BoxDecoration(color: Colors.black26, borderRadius: BorderRadius.circular(16)),
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black26,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,608 @@
|
|||||||
|
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/consultant/become_consultant/models/sbp_bank_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/widgets/passport_field_with_error.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/widgets/passport_section_title.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flutter_typeahead/flutter_typeahead.dart';
|
||||||
|
import 'package:mask_text_input_formatter/mask_text_input_formatter.dart';
|
||||||
|
|
||||||
|
class PassportDataScreen extends StatefulWidget {
|
||||||
|
const PassportDataScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PassportDataScreen> createState() => _PassportDataScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PassportDataScreenState extends State<PassportDataScreen> {
|
||||||
|
late final TextEditingController lastNameController;
|
||||||
|
late final TextEditingController firstNameController;
|
||||||
|
late final TextEditingController middleNameController;
|
||||||
|
late final TextEditingController innController;
|
||||||
|
late final TextEditingController snilsController;
|
||||||
|
late final TextEditingController seriesController;
|
||||||
|
late final TextEditingController numberController;
|
||||||
|
late final TextEditingController issueDateController;
|
||||||
|
late final TextEditingController issuerController;
|
||||||
|
late final TextEditingController issuerCodeController;
|
||||||
|
late final TextEditingController birthDateController;
|
||||||
|
late final TextEditingController birthPlaceController;
|
||||||
|
late final TextEditingController citizenshipController;
|
||||||
|
late final TextEditingController registrationAddressController;
|
||||||
|
late final TextEditingController residenceAddressController;
|
||||||
|
late final TextEditingController sbpPhoneController;
|
||||||
|
late final TextEditingController sbpBankController;
|
||||||
|
final sbpBankSuggestionsController = SuggestionsController<SbpBankModel>();
|
||||||
|
|
||||||
|
bool taxResidentUs = false;
|
||||||
|
bool foreignTaxResident = false;
|
||||||
|
String? selectedSbpBankMemberId;
|
||||||
|
String? selectedSbpBankName;
|
||||||
|
|
||||||
|
final snilsMask = MaskTextInputFormatter(
|
||||||
|
mask: '###-###-### ##',
|
||||||
|
filter: {'#': RegExp(r'[0-9]')},
|
||||||
|
type: MaskAutoCompletionType.lazy,
|
||||||
|
);
|
||||||
|
final dateMaskIssue = MaskTextInputFormatter(
|
||||||
|
mask: '##.##.####',
|
||||||
|
filter: {'#': RegExp(r'[0-9]')},
|
||||||
|
type: MaskAutoCompletionType.lazy,
|
||||||
|
);
|
||||||
|
final dateMaskBirth = MaskTextInputFormatter(
|
||||||
|
mask: '##.##.####',
|
||||||
|
filter: {'#': RegExp(r'[0-9]')},
|
||||||
|
type: MaskAutoCompletionType.lazy,
|
||||||
|
);
|
||||||
|
final issuerCodeMask = MaskTextInputFormatter(
|
||||||
|
mask: '###-###',
|
||||||
|
filter: {'#': RegExp(r'[0-9]')},
|
||||||
|
type: MaskAutoCompletionType.lazy,
|
||||||
|
);
|
||||||
|
final phoneMask = MaskTextInputFormatter(
|
||||||
|
mask: '+7 (###) ### ## ##',
|
||||||
|
filter: {'#': RegExp(r'[0-9]')},
|
||||||
|
type: MaskAutoCompletionType.lazy,
|
||||||
|
);
|
||||||
|
final _alfaPersonNameFormatter = FilteringTextInputFormatter.allow(
|
||||||
|
RegExp(r'[a-zA-Zа-яА-ЯёЁ ,.-]'),
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final consultant = context.read<RegisterConsultantCubit>().state.consultant;
|
||||||
|
lastNameController = TextEditingController(text: consultant.lastName ?? '');
|
||||||
|
firstNameController = TextEditingController(
|
||||||
|
text: consultant.firstName ?? '',
|
||||||
|
);
|
||||||
|
middleNameController = TextEditingController(
|
||||||
|
text: consultant.middleName ?? '',
|
||||||
|
);
|
||||||
|
innController = TextEditingController(text: consultant.inn ?? '');
|
||||||
|
snilsController = TextEditingController(text: consultant.snils ?? '');
|
||||||
|
seriesController = TextEditingController(
|
||||||
|
text: consultant.passportSeries ?? '',
|
||||||
|
);
|
||||||
|
numberController = TextEditingController(
|
||||||
|
text: consultant.passportNumber ?? '',
|
||||||
|
);
|
||||||
|
issueDateController = TextEditingController(
|
||||||
|
text: _apiDateToUi(consultant.passportIssueDate),
|
||||||
|
);
|
||||||
|
issuerController = TextEditingController(
|
||||||
|
text: consultant.passportIssuer ?? '',
|
||||||
|
);
|
||||||
|
issuerCodeController = TextEditingController(
|
||||||
|
text: consultant.passportIssuerCode ?? '',
|
||||||
|
);
|
||||||
|
birthDateController = TextEditingController(
|
||||||
|
text: _apiDateToUi(consultant.birthDate),
|
||||||
|
);
|
||||||
|
birthPlaceController = TextEditingController(
|
||||||
|
text: consultant.birthPlace ?? '',
|
||||||
|
);
|
||||||
|
citizenshipController = TextEditingController(text: consultant.citizenship);
|
||||||
|
registrationAddressController = TextEditingController(
|
||||||
|
text: consultant.registrationAddress ?? '',
|
||||||
|
);
|
||||||
|
residenceAddressController = TextEditingController(
|
||||||
|
text: consultant.residenceAddress ?? '',
|
||||||
|
);
|
||||||
|
sbpPhoneController = TextEditingController(text: consultant.sbpPhone ?? '');
|
||||||
|
selectedSbpBankMemberId = consultant.sbpBankMemberId;
|
||||||
|
final selectedBank = context
|
||||||
|
.read<RegisterConsultantCubit>()
|
||||||
|
.findSbpBankById(consultant.sbpBankMemberId);
|
||||||
|
selectedSbpBankName = selectedBank?.bankName;
|
||||||
|
sbpBankController = TextEditingController(text: selectedSbpBankName ?? '');
|
||||||
|
taxResidentUs = consultant.taxResidentUs ?? false;
|
||||||
|
foreignTaxResident = consultant.foreignTaxResident ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _hydrateSbpBankNameIfNeeded() {
|
||||||
|
if (selectedSbpBankMemberId == null || selectedSbpBankMemberId!.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sbpBankController.text.trim().isNotEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final selectedBank = context
|
||||||
|
.read<RegisterConsultantCubit>()
|
||||||
|
.findSbpBankById(selectedSbpBankMemberId);
|
||||||
|
if (selectedBank == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectedSbpBankName = selectedBank.bankName;
|
||||||
|
sbpBankController.text = selectedBank.bankName;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
lastNameController.dispose();
|
||||||
|
firstNameController.dispose();
|
||||||
|
middleNameController.dispose();
|
||||||
|
innController.dispose();
|
||||||
|
snilsController.dispose();
|
||||||
|
seriesController.dispose();
|
||||||
|
numberController.dispose();
|
||||||
|
issueDateController.dispose();
|
||||||
|
issuerController.dispose();
|
||||||
|
issuerCodeController.dispose();
|
||||||
|
birthDateController.dispose();
|
||||||
|
birthPlaceController.dispose();
|
||||||
|
citizenshipController.dispose();
|
||||||
|
registrationAddressController.dispose();
|
||||||
|
residenceAddressController.dispose();
|
||||||
|
sbpPhoneController.dispose();
|
||||||
|
sbpBankController.dispose();
|
||||||
|
sbpBankSuggestionsController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _apiDateToUi(String? apiDate) {
|
||||||
|
if (apiDate == null || apiDate.isEmpty) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
final parts = apiDate.split('-');
|
||||||
|
if (parts.length != 3) {
|
||||||
|
return apiDate;
|
||||||
|
}
|
||||||
|
return '${parts[2]}.${parts[1]}.${parts[0]}';
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _uiDateToApi(String uiDate) {
|
||||||
|
final digits = uiDate.replaceAll(RegExp(r'\D'), '');
|
||||||
|
if (digits.length != 8) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return '${digits.substring(4, 8)}-${digits.substring(2, 4)}-${digits.substring(0, 2)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _uiPhoneToApi(String uiPhone) {
|
||||||
|
final digits = uiPhone.replaceAll(RegExp(r'\D'), '');
|
||||||
|
if (digits.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (digits.length == 11 && digits.startsWith('7')) {
|
||||||
|
return '+$digits';
|
||||||
|
}
|
||||||
|
if (digits.length == 10) {
|
||||||
|
return '+7$digits';
|
||||||
|
}
|
||||||
|
return uiPhone.trim().isEmpty ? null : uiPhone.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _syncToCubit() {
|
||||||
|
final cubit = context.read<RegisterConsultantCubit>();
|
||||||
|
final current = cubit.state.consultant;
|
||||||
|
cubit.updateConsultantProfileModel(
|
||||||
|
current.copyWith(
|
||||||
|
legalType: RegisterConsultantCubit.individualPersonLegalType,
|
||||||
|
lastName: lastNameController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: lastNameController.text.trim(),
|
||||||
|
firstName: firstNameController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: firstNameController.text.trim(),
|
||||||
|
middleName: middleNameController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: middleNameController.text.trim(),
|
||||||
|
inn: innController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: innController.text.trim(),
|
||||||
|
snils: snilsController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: snilsController.text.trim(),
|
||||||
|
passportSeries: seriesController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: seriesController.text.trim(),
|
||||||
|
passportNumber: numberController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: numberController.text.trim(),
|
||||||
|
passportIssueDate: _uiDateToApi(issueDateController.text),
|
||||||
|
passportIssuer: issuerController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: issuerController.text.trim(),
|
||||||
|
passportIssuerCode: issuerCodeController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: issuerCodeController.text.trim(),
|
||||||
|
birthDate: _uiDateToApi(birthDateController.text),
|
||||||
|
birthPlace: birthPlaceController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: birthPlaceController.text.trim(),
|
||||||
|
citizenship: citizenshipController.text.trim().isEmpty
|
||||||
|
? 'RU'
|
||||||
|
: citizenshipController.text.trim().toUpperCase(),
|
||||||
|
registrationAddress: registrationAddressController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: registrationAddressController.text.trim(),
|
||||||
|
residenceAddress: residenceAddressController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: residenceAddressController.text.trim(),
|
||||||
|
taxResidentUs: taxResidentUs,
|
||||||
|
foreignTaxResident: foreignTaxResident,
|
||||||
|
sbpPhone: _uiPhoneToApi(sbpPhoneController.text),
|
||||||
|
sbpBankMemberId: selectedSbpBankMemberId,
|
||||||
|
),
|
||||||
|
cubit.state.acceptOffer,
|
||||||
|
cubit.state.acceptFrdoAgreement,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _selectSbpBank(SbpBankModel bank) {
|
||||||
|
setState(() {
|
||||||
|
selectedSbpBankMemberId = bank.bankMemberId;
|
||||||
|
selectedSbpBankName = bank.bankName;
|
||||||
|
sbpBankController.text = bank.bankName;
|
||||||
|
});
|
||||||
|
_syncToCubit();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearSbpBankIfNeeded(String value) {
|
||||||
|
if (selectedSbpBankMemberId != null && value != selectedSbpBankName) {
|
||||||
|
setState(() {
|
||||||
|
selectedSbpBankMemberId = null;
|
||||||
|
selectedSbpBankName = null;
|
||||||
|
});
|
||||||
|
_syncToCubit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return BlocListener<RegisterConsultantCubit, RegisterConsultantState>(
|
||||||
|
listenWhen: (previous, current) => previous.sbpBanks != current.sbpBanks,
|
||||||
|
listener: (context, state) => _hydrateSbpBankNameIfNeeded(),
|
||||||
|
child: BlocBuilder<RegisterConsultantCubit, RegisterConsultantState>(
|
||||||
|
buildWhen: (previous, current) =>
|
||||||
|
previous.fieldErrors != current.fieldErrors,
|
||||||
|
builder: (context, state) {
|
||||||
|
final errors = state.fieldErrors;
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
bottom: 80,
|
||||||
|
right: 16,
|
||||||
|
left: 16,
|
||||||
|
top: 16,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const PassportScreenTitle(title: 'Паспортные данные'),
|
||||||
|
const PassportSectionTitle(
|
||||||
|
title: 'Персональные данные (как в паспорте)',
|
||||||
|
),
|
||||||
|
PassportFieldWithError(
|
||||||
|
error: errors['last_name'],
|
||||||
|
child: CustomTextFormField(
|
||||||
|
hintText: 'Фамилия',
|
||||||
|
controller: lastNameController,
|
||||||
|
maxLength: 50,
|
||||||
|
inputFormatters: [_alfaPersonNameFormatter],
|
||||||
|
hasError: errors.containsKey('last_name'),
|
||||||
|
onChanged: (_) => _syncToCubit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
PassportFieldWithError(
|
||||||
|
error: errors['first_name'],
|
||||||
|
child: CustomTextFormField(
|
||||||
|
hintText: 'Имя',
|
||||||
|
controller: firstNameController,
|
||||||
|
maxLength: 50,
|
||||||
|
inputFormatters: [_alfaPersonNameFormatter],
|
||||||
|
hasError: errors.containsKey('first_name'),
|
||||||
|
onChanged: (_) => _syncToCubit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
PassportFieldWithError(
|
||||||
|
error: errors['middle_name'],
|
||||||
|
child: CustomTextFormField(
|
||||||
|
hintText: 'Отчество',
|
||||||
|
controller: middleNameController,
|
||||||
|
maxLength: 50,
|
||||||
|
inputFormatters: [_alfaPersonNameFormatter],
|
||||||
|
hasError: errors.containsKey('middle_name'),
|
||||||
|
onChanged: (_) => _syncToCubit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PassportSectionTitle(title: 'Идентификация'),
|
||||||
|
PassportFieldWithError(
|
||||||
|
error: errors['inn'],
|
||||||
|
child: CustomTextFormField(
|
||||||
|
hintText: 'ИНН (12 цифр)',
|
||||||
|
controller: innController,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
maxLength: 12,
|
||||||
|
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||||
|
hasError: errors.containsKey('inn'),
|
||||||
|
onChanged: (_) => _syncToCubit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
PassportFieldWithError(
|
||||||
|
error: errors['snils'],
|
||||||
|
child: CustomTextFormField(
|
||||||
|
hintText: 'СНИЛС',
|
||||||
|
controller: snilsController,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
inputFormatters: [snilsMask],
|
||||||
|
hasError: errors.containsKey('snils'),
|
||||||
|
onChanged: (_) => _syncToCubit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
PassportFieldWithError(
|
||||||
|
error: errors['birth_date'],
|
||||||
|
child: CustomTextFormField(
|
||||||
|
hintText: 'Дата рождения (ДД.ММ.ГГГГ)',
|
||||||
|
controller: birthDateController,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
inputFormatters: [dateMaskBirth],
|
||||||
|
hasError: errors.containsKey('birth_date'),
|
||||||
|
onChanged: (_) => _syncToCubit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
PassportFieldWithError(
|
||||||
|
error: errors['birth_place'],
|
||||||
|
child: CustomTextFormField(
|
||||||
|
hintText: 'Место рождения',
|
||||||
|
controller: birthPlaceController,
|
||||||
|
hasError: errors.containsKey('birth_place'),
|
||||||
|
onChanged: (_) => _syncToCubit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
PassportFieldWithError(
|
||||||
|
error: errors['citizenship'],
|
||||||
|
child: CustomTextFormField(
|
||||||
|
hintText: 'Гражданство (ISO, например RU)',
|
||||||
|
controller: citizenshipController,
|
||||||
|
maxLength: 2,
|
||||||
|
hasError: errors.containsKey('citizenship'),
|
||||||
|
onChanged: (_) => _syncToCubit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
PassportFieldWithError(
|
||||||
|
error: errors['registration_address'],
|
||||||
|
child: CustomTextFormField(
|
||||||
|
hintText: 'Адрес регистрации',
|
||||||
|
controller: registrationAddressController,
|
||||||
|
hasError: errors.containsKey('registration_address'),
|
||||||
|
onChanged: (_) => _syncToCubit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
PassportFieldWithError(
|
||||||
|
error: errors['residence_address'],
|
||||||
|
child: CustomTextFormField(
|
||||||
|
hintText: 'Адрес проживания',
|
||||||
|
controller: residenceAddressController,
|
||||||
|
hasError: errors.containsKey('residence_address'),
|
||||||
|
onChanged: (_) => _syncToCubit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
CheckboxListTile(
|
||||||
|
value: taxResidentUs,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
|
activeColor: appTheme.blueA400,
|
||||||
|
title: Text(
|
||||||
|
'Я являюсь налоговым резидентом США',
|
||||||
|
style: TextStyles.textMMediumAuto.copyWith(
|
||||||
|
color: appTheme.black,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() => taxResidentUs = value ?? false);
|
||||||
|
_syncToCubit();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
CheckboxListTile(
|
||||||
|
value: foreignTaxResident,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
|
activeColor: appTheme.blueA400,
|
||||||
|
title: Text(
|
||||||
|
'Я являюсь иностранным налоговым резидентом',
|
||||||
|
style: TextStyles.textMMediumAuto.copyWith(
|
||||||
|
color: appTheme.black,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() => foreignTaxResident = value ?? false);
|
||||||
|
_syncToCubit();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const PassportSectionTitle(title: 'Паспорт'),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: PassportFieldWithError(
|
||||||
|
error: errors['passport_series'],
|
||||||
|
child: CustomTextFormField(
|
||||||
|
hintText: 'Серия',
|
||||||
|
controller: seriesController,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
maxLength: 4,
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
|
],
|
||||||
|
hasError: errors.containsKey('passport_series'),
|
||||||
|
onChanged: (_) => _syncToCubit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: PassportFieldWithError(
|
||||||
|
error: errors['passport_number'],
|
||||||
|
child: CustomTextFormField(
|
||||||
|
hintText: 'Номер',
|
||||||
|
controller: numberController,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
maxLength: 6,
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
|
],
|
||||||
|
hasError: errors.containsKey('passport_number'),
|
||||||
|
onChanged: (_) => _syncToCubit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
PassportFieldWithError(
|
||||||
|
error: errors['passport_issue_date'],
|
||||||
|
child: CustomTextFormField(
|
||||||
|
hintText: 'Дата выдачи (ДД.ММ.ГГГГ)',
|
||||||
|
controller: issueDateController,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
inputFormatters: [dateMaskIssue],
|
||||||
|
hasError: errors.containsKey('passport_issue_date'),
|
||||||
|
onChanged: (_) => _syncToCubit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
PassportFieldWithError(
|
||||||
|
error: errors['passport_issuer_code'],
|
||||||
|
child: CustomTextFormField(
|
||||||
|
hintText: 'Код подразделения',
|
||||||
|
controller: issuerCodeController,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
inputFormatters: [issuerCodeMask],
|
||||||
|
hasError: errors.containsKey('passport_issuer_code'),
|
||||||
|
onChanged: (_) => _syncToCubit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
PassportFieldWithError(
|
||||||
|
error: errors['passport_issuer'],
|
||||||
|
child: CustomTextFormField(
|
||||||
|
hintText: 'Кем выдан',
|
||||||
|
controller: issuerController,
|
||||||
|
hasError: errors.containsKey('passport_issuer'),
|
||||||
|
onChanged: (_) => _syncToCubit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PassportSectionTitle(title: 'Реквизиты СБП'),
|
||||||
|
PassportFieldWithError(
|
||||||
|
error: errors['sbp_phone'],
|
||||||
|
child: CustomTextFormField(
|
||||||
|
hintText: 'Телефон для выплат через СБП',
|
||||||
|
controller: sbpPhoneController,
|
||||||
|
keyboardType: TextInputType.phone,
|
||||||
|
inputFormatters: [phoneMask],
|
||||||
|
hasError: errors.containsKey('sbp_phone'),
|
||||||
|
onChanged: (_) => _syncToCubit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
PassportFieldWithError(
|
||||||
|
error: errors['sbp_bank_member_id'],
|
||||||
|
child: TypeAheadField<SbpBankModel>(
|
||||||
|
debounceDuration: const Duration(milliseconds: 250),
|
||||||
|
controller: sbpBankController,
|
||||||
|
suggestionsController: sbpBankSuggestionsController,
|
||||||
|
builder: (context, controller, focusNode) =>
|
||||||
|
CustomTextFormField(
|
||||||
|
controller: controller,
|
||||||
|
focusNode: focusNode,
|
||||||
|
hintText: 'Банк получатель (поиск)',
|
||||||
|
hasError: errors.containsKey('sbp_bank_member_id'),
|
||||||
|
onChanged: _clearSbpBankIfNeeded,
|
||||||
|
),
|
||||||
|
suggestionsCallback: (query) async {
|
||||||
|
return context
|
||||||
|
.read<RegisterConsultantCubit>()
|
||||||
|
.searchSbpBanks(query);
|
||||||
|
},
|
||||||
|
itemBuilder: (context, bank) {
|
||||||
|
return ListTile(
|
||||||
|
dense: true,
|
||||||
|
title: Text(
|
||||||
|
bank.bankName,
|
||||||
|
style: TextStyles.textMMedium.copyWith(
|
||||||
|
color: appTheme.black900,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle:
|
||||||
|
bank.shortName == null || bank.shortName!.isEmpty
|
||||||
|
? null
|
||||||
|
: Text(
|
||||||
|
bank.shortName!,
|
||||||
|
style: TextStyles.textSMedium.copyWith(
|
||||||
|
color: appTheme.grey7B,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
decorationBuilder: (context, child) => Material(
|
||||||
|
type: MaterialType.card,
|
||||||
|
elevation: 4,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
color: appTheme.whiteA700,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
emptyBuilder: (context) => Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Text(
|
||||||
|
'Ничего не найдено',
|
||||||
|
style: TextStyles.textSMedium.copyWith(
|
||||||
|
color: appTheme.grey7B,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onSelected: _selectSbpBank,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Divider(height: 0.4, thickness: 0.4, color: appTheme.greyD9),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Данные используются для проверки и скоринга. Заполните поля в соответствии с паспортом РФ.',
|
||||||
|
style: TextStyles.textSMedium.copyWith(
|
||||||
|
color: appTheme.blueA400,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,32 +29,48 @@ class _PhotoOrScansScreenState extends State<PhotoOrScansScreen> {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Text('Загрузите фото или сканы дипломов и сертификатов', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, height: 1.1)),
|
const Text(
|
||||||
|
'Загрузите фото или сканы дипломов и сертификатов',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
height: 1.1,
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
BlocBuilder<RegisterConsultantCubit, RegisterConsultantState>(
|
BlocBuilder<RegisterConsultantCubit, RegisterConsultantState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final photos = state.certPhotos.where((photo) => !state.deletedPhotoIds.contains(photo.id)).toList();
|
final photos = state.certPhotos
|
||||||
|
.where((photo) => !state.deletedPhotoIds.contains(photo.id))
|
||||||
|
.toList();
|
||||||
final addedPhotos = state.newAddedPhotos;
|
final addedPhotos = state.newAddedPhotos;
|
||||||
final totalPhotosCount = photos.length + addedPhotos.length;
|
final totalPhotosCount = photos.length + addedPhotos.length;
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
if (isUploading) ...[const LinearProgressIndicator(), const SizedBox(height: 16)],
|
if (isUploading) ...[
|
||||||
|
const LinearProgressIndicator(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
GridView.builder(
|
GridView.builder(
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate:
|
||||||
crossAxisCount: 3,
|
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
childAspectRatio: 3 / 4,
|
crossAxisCount: 3,
|
||||||
crossAxisSpacing: 8,
|
childAspectRatio: 3 / 4,
|
||||||
mainAxisSpacing: 8,
|
crossAxisSpacing: 8,
|
||||||
),
|
mainAxisSpacing: 8,
|
||||||
|
),
|
||||||
itemBuilder: (BuildContext context, int index) {
|
itemBuilder: (BuildContext context, int index) {
|
||||||
if (index == totalPhotosCount) {
|
if (index == totalPhotosCount) {
|
||||||
return InkWell(
|
return InkWell(
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
onTap: () => _pickAndUploadPhotos(),
|
onTap: () => _pickAndUploadPhotos(),
|
||||||
child: Ink(
|
child: Ink(
|
||||||
decoration: BoxDecoration(color: const Color(0xFFF6F6F6), borderRadius: BorderRadius.circular(20)),
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFF6F6F6),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
child: Icon(Icons.add, color: appTheme.blueA400),
|
child: Icon(Icons.add, color: appTheme.blueA400),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -72,7 +88,8 @@ class _PhotoOrScansScreenState extends State<PhotoOrScansScreen> {
|
|||||||
return _LocalCertPhotoCard(
|
return _LocalCertPhotoCard(
|
||||||
photo: localPhoto,
|
photo: localPhoto,
|
||||||
allPhotos: addedPhotos,
|
allPhotos: addedPhotos,
|
||||||
onRemove: () => _removePhoto(path: localPhoto.uri.path),
|
onRemove: () =>
|
||||||
|
_removePhoto(path: localPhoto.uri.path),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,7 +116,9 @@ class _PhotoOrScansScreenState extends State<PhotoOrScansScreen> {
|
|||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
// ignore: use_build_context_synchronously
|
// ignore: use_build_context_synchronously
|
||||||
await context.read<RegisterConsultantCubit>().addCertPhotos(newImages.map((file) => File(file.path)).toList());
|
await context.read<RegisterConsultantCubit>().addCertPhotos(
|
||||||
|
newImages.map((file) => File(file.path)).toList(),
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -119,7 +138,11 @@ class _CertPhotoCard extends StatelessWidget {
|
|||||||
final List<ConsultantCertPhotoModel> allPhotos;
|
final List<ConsultantCertPhotoModel> allPhotos;
|
||||||
final VoidCallback onRemove;
|
final VoidCallback onRemove;
|
||||||
|
|
||||||
const _CertPhotoCard({required this.photo, required this.allPhotos, required this.onRemove});
|
const _CertPhotoCard({
|
||||||
|
required this.photo,
|
||||||
|
required this.allPhotos,
|
||||||
|
required this.onRemove,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -131,7 +154,11 @@ class _CertPhotoCard extends StatelessWidget {
|
|||||||
onTap: () {
|
onTap: () {
|
||||||
context.pushTransparentRoute(
|
context.pushTransparentRoute(
|
||||||
rootNavigator: true,
|
rootNavigator: true,
|
||||||
PhotoViewScreen(imageUrls: allPhotos.map((e) => e.url).toList(), isLocal: false, index: allPhotos.indexOf(photo)),
|
PhotoViewScreen(
|
||||||
|
imageUrls: allPhotos.map((e) => e.url).toList(),
|
||||||
|
isLocal: false,
|
||||||
|
index: allPhotos.indexOf(photo),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
@@ -141,7 +168,10 @@ class _CertPhotoCard extends StatelessWidget {
|
|||||||
Ink(
|
Ink(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(20)),
|
borderRadius: const BorderRadius.all(Radius.circular(20)),
|
||||||
image: DecorationImage(fit: BoxFit.cover, image: NetworkImage(photo.url)),
|
image: DecorationImage(
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
image: NetworkImage(photo.url),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
@@ -152,8 +182,15 @@ class _CertPhotoCard extends StatelessWidget {
|
|||||||
customBorder: const CircleBorder(),
|
customBorder: const CircleBorder(),
|
||||||
child: Ink(
|
child: Ink(
|
||||||
padding: const EdgeInsets.all(4),
|
padding: const EdgeInsets.all(4),
|
||||||
decoration: const BoxDecoration(color: Color(0xFFF1F2F6), shape: BoxShape.circle),
|
decoration: const BoxDecoration(
|
||||||
child: const Icon(size: 20, Icons.close, color: Colors.black),
|
color: Color(0xFFF1F2F6),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
size: 20,
|
||||||
|
Icons.close,
|
||||||
|
color: Colors.black,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -170,7 +207,11 @@ class _LocalCertPhotoCard extends StatelessWidget {
|
|||||||
final List<File> allPhotos;
|
final List<File> allPhotos;
|
||||||
final VoidCallback onRemove;
|
final VoidCallback onRemove;
|
||||||
|
|
||||||
const _LocalCertPhotoCard({required this.photo, required this.allPhotos, required this.onRemove});
|
const _LocalCertPhotoCard({
|
||||||
|
required this.photo,
|
||||||
|
required this.allPhotos,
|
||||||
|
required this.onRemove,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -210,8 +251,15 @@ class _LocalCertPhotoCard extends StatelessWidget {
|
|||||||
customBorder: const CircleBorder(),
|
customBorder: const CircleBorder(),
|
||||||
child: Ink(
|
child: Ink(
|
||||||
padding: const EdgeInsets.all(4),
|
padding: const EdgeInsets.all(4),
|
||||||
decoration: const BoxDecoration(color: Color(0xFFF1F2F6), shape: BoxShape.circle),
|
decoration: const BoxDecoration(
|
||||||
child: const Icon(size: 20, Icons.close, color: Colors.black),
|
color: Color(0xFFF1F2F6),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
size: 20,
|
||||||
|
Icons.close,
|
||||||
|
color: Colors.black,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
class RegistrationAsScreen extends StatefulWidget {
|
class RegistrationAsScreen extends StatefulWidget {
|
||||||
const RegistrationAsScreen({super.key});
|
const RegistrationAsScreen({super.key});
|
||||||
@@ -11,7 +13,26 @@ class RegistrationAsScreen extends StatefulWidget {
|
|||||||
enum RegistrationAsType { physical, selfBusy, iP, ooo }
|
enum RegistrationAsType { physical, selfBusy, iP, ooo }
|
||||||
|
|
||||||
class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
|
class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
|
||||||
RegistrationAsType registrationAsType = RegistrationAsType.physical;
|
static const RegistrationAsType registrationAsType =
|
||||||
|
RegistrationAsType.physical;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final cubit = context.read<RegisterConsultantCubit>();
|
||||||
|
cubit.updateConsultantProfileModel(
|
||||||
|
cubit.state.consultant.copyWith(
|
||||||
|
legalType: RegisterConsultantCubit.individualPersonLegalType,
|
||||||
|
),
|
||||||
|
cubit.state.acceptOffer,
|
||||||
|
cubit.state.acceptFrdoAgreement,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -23,7 +44,14 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
|
|||||||
children: [
|
children: [
|
||||||
const Padding(
|
const Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Text('Регистрация в качестве', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, height: 1.1)),
|
child: Text(
|
||||||
|
'Регистрация в качестве',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
height: 1.1,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Column(
|
Column(
|
||||||
@@ -36,16 +64,15 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
|
|||||||
groupValue: registrationAsType,
|
groupValue: registrationAsType,
|
||||||
activeColor: appTheme.blueA400,
|
activeColor: appTheme.blueA400,
|
||||||
// ignore: deprecated_member_use
|
// ignore: deprecated_member_use
|
||||||
onChanged: (value) {
|
onChanged: (_) {},
|
||||||
setState(() {
|
|
||||||
registrationAsType = value!;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
const Expanded(
|
const Expanded(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||||
child: Text('Физического лица', style: TextStyle(fontSize: 18, height: 1)),
|
child: Text(
|
||||||
|
'Физического лица',
|
||||||
|
style: TextStyle(fontSize: 18, height: 1),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -58,16 +85,19 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
|
|||||||
groupValue: registrationAsType,
|
groupValue: registrationAsType,
|
||||||
activeColor: appTheme.blueA400,
|
activeColor: appTheme.blueA400,
|
||||||
// ignore: deprecated_member_use
|
// ignore: deprecated_member_use
|
||||||
onChanged: (value) {
|
onChanged: null,
|
||||||
setState(() {
|
|
||||||
registrationAsType = value!;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
const Expanded(
|
const Expanded(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||||
child: Text('Самозанятого', style: TextStyle(fontSize: 18, height: 1)),
|
child: Text(
|
||||||
|
'Самозанятого',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
height: 1,
|
||||||
|
color: Colors.black38,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -80,16 +110,19 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
|
|||||||
groupValue: registrationAsType,
|
groupValue: registrationAsType,
|
||||||
activeColor: appTheme.blueA400,
|
activeColor: appTheme.blueA400,
|
||||||
// ignore: deprecated_member_use
|
// ignore: deprecated_member_use
|
||||||
onChanged: (value) {
|
onChanged: null,
|
||||||
setState(() {
|
|
||||||
registrationAsType = value!;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
const Expanded(
|
const Expanded(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||||
child: Text('Индивидуального предпринимателя', style: TextStyle(fontSize: 18, height: 1)),
|
child: Text(
|
||||||
|
'Индивидуального предпринимателя',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
height: 1,
|
||||||
|
color: Colors.black38,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -102,26 +135,34 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
|
|||||||
groupValue: registrationAsType,
|
groupValue: registrationAsType,
|
||||||
activeColor: appTheme.blueA400,
|
activeColor: appTheme.blueA400,
|
||||||
// ignore: deprecated_member_use
|
// ignore: deprecated_member_use
|
||||||
onChanged: (value) {
|
onChanged: null,
|
||||||
setState(() {
|
|
||||||
registrationAsType = value!;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
const Expanded(
|
const Expanded(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||||
child: Text('Общества с ограниченной ответственностью', style: TextStyle(fontSize: 18, height: 1)),
|
child: Text(
|
||||||
|
'Общества с ограниченной ответственностью',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
height: 1,
|
||||||
|
color: Colors.black38,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Padding(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), child: _divider(context)),
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||||
|
child: _divider(context),
|
||||||
|
),
|
||||||
const Padding(
|
const Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Text('* На данный момент регистрация доступна только в качестве физического лица. Остальные варианты скоро появятся'),
|
child: Text(
|
||||||
|
'* На данный момент регистрация доступна только в качестве физического лица. Остальные варианты скоро появятся',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -132,7 +173,10 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
|
|||||||
return Container(
|
return Container(
|
||||||
height: 0.4,
|
height: 0.4,
|
||||||
width: MediaQuery.of(context).size.width - 32,
|
width: MediaQuery.of(context).size.width - 32,
|
||||||
decoration: BoxDecoration(color: Colors.black26, borderRadius: BorderRadius.circular(16)),
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black26,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,32 +19,47 @@ class _SocialNetworksScreenState extends State<SocialNetworksScreen> {
|
|||||||
|
|
||||||
var maskFormatter = MaskTextInputFormatter(
|
var maskFormatter = MaskTextInputFormatter(
|
||||||
mask: 'https://########################################',
|
mask: 'https://########################################',
|
||||||
filter: {
|
filter: {'#': RegExp(r"[a-zA-Z. ]+")},
|
||||||
'#': RegExp(r"[a-zA-Z. ]+"),
|
|
||||||
},
|
|
||||||
type: MaskAutoCompletionType.lazy,
|
type: MaskAutoCompletionType.lazy,
|
||||||
);
|
);
|
||||||
var maskTgFormatter = MaskTextInputFormatter(
|
var maskTgFormatter = MaskTextInputFormatter(
|
||||||
mask: 'https://tg.me/########################################',
|
mask: 'https://tg.me/########################################',
|
||||||
filter: {
|
filter: {'#': RegExp(r"[a-zA-Z. ]+")},
|
||||||
'#': RegExp(r"[a-zA-Z. ]+"),
|
|
||||||
},
|
|
||||||
type: MaskAutoCompletionType.lazy,
|
type: MaskAutoCompletionType.lazy,
|
||||||
);
|
);
|
||||||
var maskVkFormatter = MaskTextInputFormatter(
|
var maskVkFormatter = MaskTextInputFormatter(
|
||||||
mask: 'https://vk.com/########################################',
|
mask: 'https://vk.com/########################################',
|
||||||
filter: {
|
filter: {'#': RegExp(r"[a-zA-Z. ]+")},
|
||||||
'#': RegExp(r"[a-zA-Z. ]+"),
|
|
||||||
},
|
|
||||||
type: MaskAutoCompletionType.lazy,
|
type: MaskAutoCompletionType.lazy,
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
vkController.text = context.read<RegisterConsultantCubit>().state.consultant.socialLinks!.vkLink ?? '';
|
vkController.text =
|
||||||
telegramController.text = context.read<RegisterConsultantCubit>().state.consultant.socialLinks!.tgLink ?? '';
|
context
|
||||||
webSiteController.text = context.read<RegisterConsultantCubit>().state.consultant.socialLinks!.personalSiteLink ?? '';
|
.read<RegisterConsultantCubit>()
|
||||||
|
.state
|
||||||
|
.consultant
|
||||||
|
.socialLinks!
|
||||||
|
.vkLink ??
|
||||||
|
'';
|
||||||
|
telegramController.text =
|
||||||
|
context
|
||||||
|
.read<RegisterConsultantCubit>()
|
||||||
|
.state
|
||||||
|
.consultant
|
||||||
|
.socialLinks!
|
||||||
|
.tgLink ??
|
||||||
|
'';
|
||||||
|
webSiteController.text =
|
||||||
|
context
|
||||||
|
.read<RegisterConsultantCubit>()
|
||||||
|
.state
|
||||||
|
.consultant
|
||||||
|
.socialLinks!
|
||||||
|
.personalSiteLink ??
|
||||||
|
'';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -75,9 +90,7 @@ class _SocialNetworksScreenState extends State<SocialNetworksScreen> {
|
|||||||
color: const Color(0xFFF6F6F6),
|
color: const Color(0xFFF6F6F6),
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
child: SvgPicture.asset(
|
child: SvgPicture.asset('assets/icons/vk.svg'),
|
||||||
'assets/icons/vk.svg',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
@@ -105,9 +118,7 @@ class _SocialNetworksScreenState extends State<SocialNetworksScreen> {
|
|||||||
color: const Color(0xFFF6F6F6),
|
color: const Color(0xFFF6F6F6),
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
child: SvgPicture.asset(
|
child: SvgPicture.asset('assets/icons/telegram.svg'),
|
||||||
'assets/icons/telegram.svg',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
@@ -135,9 +146,7 @@ class _SocialNetworksScreenState extends State<SocialNetworksScreen> {
|
|||||||
color: const Color(0xFFF6F6F6),
|
color: const Color(0xFFF6F6F6),
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
child: SvgPicture.asset(
|
child: SvgPicture.asset('assets/icons/web.svg'),
|
||||||
'assets/icons/web.svg',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
@@ -164,25 +173,45 @@ class _SocialNetworksScreenState extends State<SocialNetworksScreen> {
|
|||||||
String? personalSiteLink,
|
String? personalSiteLink,
|
||||||
}) {
|
}) {
|
||||||
context.read<RegisterConsultantCubit>().updateConsultantProfileModel(
|
context.read<RegisterConsultantCubit>().updateConsultantProfileModel(
|
||||||
context.read<RegisterConsultantCubit>().state.consultant.copyWith(
|
context.read<RegisterConsultantCubit>().state.consultant.copyWith(
|
||||||
socialLinks: context.read<RegisterConsultantCubit>().state.consultant.socialLinks?.copyWith(
|
socialLinks: context
|
||||||
tgLink: tgLink == null
|
.read<RegisterConsultantCubit>()
|
||||||
? context.read<RegisterConsultantCubit>().state.consultant.socialLinks?.tgLink
|
.state
|
||||||
: tgLink.isEmpty
|
.consultant
|
||||||
? null
|
.socialLinks
|
||||||
: tgLink,
|
?.copyWith(
|
||||||
vkLink: vkLink == null
|
tgLink: tgLink == null
|
||||||
? context.read<RegisterConsultantCubit>().state.consultant.socialLinks?.vkLink
|
? context
|
||||||
: vkLink.isEmpty
|
.read<RegisterConsultantCubit>()
|
||||||
? null
|
.state
|
||||||
: vkLink,
|
.consultant
|
||||||
personalSiteLink: personalSiteLink == null
|
.socialLinks
|
||||||
? context.read<RegisterConsultantCubit>().state.consultant.socialLinks?.personalSiteLink
|
?.tgLink
|
||||||
: personalSiteLink.isEmpty
|
: tgLink.isEmpty
|
||||||
? null
|
? null
|
||||||
: personalSiteLink,
|
: tgLink,
|
||||||
),
|
vkLink: vkLink == null
|
||||||
),
|
? context
|
||||||
);
|
.read<RegisterConsultantCubit>()
|
||||||
|
.state
|
||||||
|
.consultant
|
||||||
|
.socialLinks
|
||||||
|
?.vkLink
|
||||||
|
: vkLink.isEmpty
|
||||||
|
? null
|
||||||
|
: vkLink,
|
||||||
|
personalSiteLink: personalSiteLink == null
|
||||||
|
? context
|
||||||
|
.read<RegisterConsultantCubit>()
|
||||||
|
.state
|
||||||
|
.consultant
|
||||||
|
.socialLinks
|
||||||
|
?.personalSiteLink
|
||||||
|
: personalSiteLink.isEmpty
|
||||||
|
? null
|
||||||
|
: personalSiteLink,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import 'package:eatvio_app/core/utils/image_constant.dart';
|
import 'package:eatvio_app/core/utils/image_constant.dart';
|
||||||
import 'package:eatvio_app/core/widgets/data_error_widget.dart';
|
import 'package:eatvio_app/core/widgets/data_error_widget.dart';
|
||||||
import 'package:eatvio_app/core/widgets/loader.dart';
|
import 'package:eatvio_app/core/widgets/loader.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.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_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/bloc/register_consultant_cubit/register_consultant_state.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/additional_settings_screen.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/additional_settings_screen.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/last_step_screen.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/last_step_screen.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/social_networks_screen.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/social_networks_screen.dart';
|
||||||
@@ -21,67 +23,196 @@ import 'package:lottie/lottie.dart';
|
|||||||
class StepperBecomeConsultantScreen extends StatefulWidget {
|
class StepperBecomeConsultantScreen extends StatefulWidget {
|
||||||
final String? slug;
|
final String? slug;
|
||||||
final bool? isCanceled;
|
final bool? isCanceled;
|
||||||
const StepperBecomeConsultantScreen({super.key, this.slug, this.isCanceled});
|
final bool isNewRegistration;
|
||||||
|
const StepperBecomeConsultantScreen({
|
||||||
|
super.key,
|
||||||
|
this.slug,
|
||||||
|
this.isCanceled,
|
||||||
|
this.isNewRegistration = false,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<StepperBecomeConsultantScreen> createState() => _StepperBecomeConsultantScreenState();
|
State<StepperBecomeConsultantScreen> createState() =>
|
||||||
|
_StepperBecomeConsultantScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantScreen> {
|
class _StepperBecomeConsultantScreenState
|
||||||
double progressValue = 1 / 6;
|
extends State<StepperBecomeConsultantScreen> {
|
||||||
|
double progressValue = 0;
|
||||||
int step = 1;
|
int step = 1;
|
||||||
final pageController = PageController();
|
final pageController = PageController();
|
||||||
late final RegisterConsultantCubit registrationConsultantCubit;
|
late final RegisterConsultantCubit registrationConsultantCubit;
|
||||||
bool isLoading = false;
|
bool isLoading = false;
|
||||||
|
bool _successSheetShown = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
if (widget.slug == null) {
|
if (widget.isNewRegistration || widget.slug == null) {
|
||||||
registrationConsultantCubit = sl<RegisterConsultantCubit>()..disableLoading();
|
registrationConsultantCubit = sl<RegisterConsultantCubit>()
|
||||||
|
..disableLoading(slug: widget.slug);
|
||||||
} else {
|
} else {
|
||||||
registrationConsultantCubit = sl<RegisterConsultantCubit>()..getMyRequestStatus(widget.slug!);
|
registrationConsultantCubit = sl<RegisterConsultantCubit>()
|
||||||
|
..getMyRequestStatus(widget.slug!);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static const int totalSteps = 7;
|
||||||
|
|
||||||
|
List<Widget> _pages() {
|
||||||
|
return const [
|
||||||
|
CertificatesAndDiplomasScreen(),
|
||||||
|
PhotoOrScansScreen(),
|
||||||
|
AdditionalSettingsScreen(),
|
||||||
|
SocialNetworksScreen(),
|
||||||
|
RegistrationAsScreen(),
|
||||||
|
PassportDataScreen(),
|
||||||
|
LastStepScreen(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showSuccessSheet(BuildContext context) async {
|
||||||
|
if (_successSheetShown) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_successSheetShown = true;
|
||||||
|
await showModalBottomSheet(
|
||||||
|
useRootNavigator: true,
|
||||||
|
showDragHandle: true,
|
||||||
|
context: context,
|
||||||
|
builder: (context) => Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
width: MediaQuery.of(context).size.width,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Спасибо!\nВаши данные отправлены на модерацию',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
height: 1,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
InkWell(
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
GoRouter.of(context).pop();
|
||||||
|
},
|
||||||
|
child: Ink(
|
||||||
|
width: MediaQuery.of(context).size.width - 32,
|
||||||
|
height: 52,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
color: appTheme.blueA400,
|
||||||
|
),
|
||||||
|
child: const Center(
|
||||||
|
child: Text(
|
||||||
|
'Закрыть',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocProvider<RegisterConsultantCubit>(
|
return BlocProvider<RegisterConsultantCubit>(
|
||||||
create: (context) => registrationConsultantCubit,
|
create: (context) => registrationConsultantCubit,
|
||||||
child: BlocBuilder<RegisterConsultantCubit, RegisterConsultantState>(
|
child: BlocListener<RegisterConsultantCubit, RegisterConsultantState>(
|
||||||
builder: (context, state) {
|
listenWhen: (previous, current) =>
|
||||||
if (state.loading) {
|
previous.fieldErrors.isEmpty &&
|
||||||
return const Scaffold(body: Loader());
|
current.fieldErrors.isNotEmpty &&
|
||||||
}
|
current.error == null,
|
||||||
if (state.error != null) {
|
listener: (context, state) {
|
||||||
|
pageController.jumpToPage(5);
|
||||||
|
},
|
||||||
|
child: BlocConsumer<RegisterConsultantCubit, RegisterConsultantState>(
|
||||||
|
listenWhen: (previous, current) =>
|
||||||
|
previous.awaitingScoringOutcome &&
|
||||||
|
!current.awaitingScoringOutcome &&
|
||||||
|
current.alfaScoringStatus.isComplete,
|
||||||
|
listener: (context, state) {
|
||||||
|
_showSuccessSheet(context);
|
||||||
|
},
|
||||||
|
builder: (context, state) {
|
||||||
|
if (state.loading) {
|
||||||
|
return const Scaffold(body: Loader());
|
||||||
|
}
|
||||||
|
if (state.error != null &&
|
||||||
|
state.requestModel == null &&
|
||||||
|
widget.slug != null &&
|
||||||
|
state.alfaScoringStatus == AlfaScoringStatus.none) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
centerTitle: true,
|
||||||
|
leading: BackButton(
|
||||||
|
onPressed: () {
|
||||||
|
if (context.canPop()) {
|
||||||
|
GoRouter.of(context).pop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
GoRouter.of(context).go('/feed-screen');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
'',
|
||||||
|
maxLines: 2,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
softWrap: true,
|
||||||
|
style: TextStyles.headingXLSemibold.copyWith(
|
||||||
|
color: appTheme.black,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: DataErrorWidget(
|
||||||
|
onReload: () => context
|
||||||
|
.read<RegisterConsultantCubit>()
|
||||||
|
.reloadData(widget.slug),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progressValue == 0) {
|
||||||
|
progressValue = 1 / totalSteps;
|
||||||
|
}
|
||||||
|
|
||||||
|
final submitDisabled =
|
||||||
|
isLoading ||
|
||||||
|
state.isScoringPolling ||
|
||||||
|
state.scoringRetryInProgress;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
floatingActionButtonLocation:
|
||||||
centerTitle: true,
|
FloatingActionButtonLocation.centerFloat,
|
||||||
leading: BackButton(
|
|
||||||
onPressed: () {
|
|
||||||
if (context.canPop()) {
|
|
||||||
GoRouter.of(context).pop();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
GoRouter.of(context).go('/feed-screen');
|
|
||||||
},
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
'',
|
|
||||||
maxLines: 2,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
softWrap: true,
|
|
||||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
body: DataErrorWidget(
|
|
||||||
onReload: () => context.read<RegisterConsultantCubit>().reloadData(widget.slug),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return Scaffold(
|
|
||||||
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
|
|
||||||
floatingActionButton: Row(
|
floatingActionButton: Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
@@ -93,33 +224,51 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
|
|||||||
highlightElevation: 0,
|
highlightElevation: 0,
|
||||||
focusElevation: 0,
|
focusElevation: 0,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
backgroundColor: const Color(0xFFEAF2FD),
|
backgroundColor: const Color(0xFFEAF2FD),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
setState(() {
|
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 (widget.isCanceled == true)
|
if (widget.isCanceled == true && step != totalSteps)
|
||||||
InkWell(
|
InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
final canceledModeration =
|
||||||
|
state.requestModel?.canceledModeration;
|
||||||
|
if (canceledModeration == null) return;
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: Text(state.requestModel!.canceledModeration!.title),
|
title: Text(canceledModeration.title),
|
||||||
content: Column(
|
content: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
for (int i = 0; i < state.requestModel!.canceledModeration!.additional!.length; i++) ...[
|
if (canceledModeration.additional != null)
|
||||||
Text('• ${state.requestModel!.canceledModeration!.additional![i]}'),
|
for (final item
|
||||||
],
|
in canceledModeration.additional!) ...[
|
||||||
|
Text('• $item'),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))],
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('OK'),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -127,18 +276,26 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
|
|||||||
child: Ink(
|
child: Ink(
|
||||||
height: 48,
|
height: 48,
|
||||||
width: MediaQuery.of(context).size.width * 0.6,
|
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: const Center(
|
||||||
child: Text(
|
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(
|
Container(
|
||||||
height: 48,
|
height: 48,
|
||||||
width: widget.isCanceled == true
|
width: widget.isCanceled == true && step != totalSteps
|
||||||
? MediaQuery.of(context).size.width * 0.2
|
? MediaQuery.of(context).size.width * 0.2
|
||||||
: MediaQuery.of(context).size.width * 0.8,
|
: MediaQuery.of(context).size.width * 0.8,
|
||||||
padding: const EdgeInsets.only(left: 4, right: 16),
|
padding: const EdgeInsets.only(left: 4, right: 16),
|
||||||
@@ -147,85 +304,77 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
|
|||||||
highlightElevation: 0,
|
highlightElevation: 0,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
focusElevation: 0,
|
focusElevation: 0,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
shape: RoundedRectangleBorder(
|
||||||
backgroundColor: isLoading
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
backgroundColor: submitDisabled
|
||||||
? Colors.grey
|
? Colors.grey
|
||||||
: widget.isCanceled == true
|
: widget.isCanceled == true && step != totalSteps
|
||||||
? const Color(0xFFEAF2FD)
|
? const Color(0xFFEAF2FD)
|
||||||
: appTheme.blueA400,
|
: appTheme.blueA400,
|
||||||
onPressed: isLoading
|
onPressed: submitDisabled
|
||||||
? null
|
? null
|
||||||
: () async {
|
: () async {
|
||||||
if (step == 6) {
|
if (step == totalSteps) {
|
||||||
if (widget.isCanceled == true) {
|
final passportErrors =
|
||||||
|
registrationConsultantCubit
|
||||||
|
.validatePassportFields();
|
||||||
|
if (passportErrors.isNotEmpty) {
|
||||||
|
pageController.jumpToPage(5);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (registrationConsultantCubit.state.acceptOffer) {
|
if (registrationConsultantCubit
|
||||||
|
.hasAcceptedRequiredAgreements) {
|
||||||
setState(() {
|
setState(() {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await registrationConsultantCubit.registerConsultant();
|
await registrationConsultantCubit
|
||||||
|
.registerConsultant();
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
if (registrationConsultantCubit.state.error != null) {
|
if (registrationConsultantCubit
|
||||||
return;
|
.state
|
||||||
}
|
.error !=
|
||||||
await showModalBottomSheet(
|
null) {
|
||||||
useRootNavigator: true,
|
showDialog(
|
||||||
showDragHandle: true,
|
context: context,
|
||||||
context: context,
|
builder: (context) => AlertDialog(
|
||||||
builder: (context) => Container(
|
actionsPadding:
|
||||||
padding: const EdgeInsets.all(16),
|
const EdgeInsets.symmetric(
|
||||||
width: MediaQuery.of(context).size.width,
|
horizontal: 4,
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'Спасибо!\nВаши данные отправлены на модерацию',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: TextStyle(fontSize: 20, height: 1, fontWeight: FontWeight.w600),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
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),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
InkWell(
|
|
||||||
onTap: () {
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
GoRouter.of(context).pop();
|
|
||||||
},
|
|
||||||
child: Ink(
|
|
||||||
width: MediaQuery.of(context).size.width - 32,
|
|
||||||
height: 52,
|
|
||||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
|
|
||||||
child: const Center(
|
|
||||||
child: Text(
|
|
||||||
'Закрыть',
|
|
||||||
style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
content: Text(
|
||||||
|
registrationConsultantCubit
|
||||||
|
.state
|
||||||
|
.error!,
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
child: const Text('Ок'),
|
||||||
|
onPressed: () =>
|
||||||
|
Navigator.of(context).pop(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
return;
|
||||||
|
}
|
||||||
|
// Success sheet is shown by BlocListener when scoring completes.
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
showDialog<void>(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
actionsPadding: const EdgeInsets.symmetric(horizontal: 4),
|
actionsPadding:
|
||||||
|
const EdgeInsets.symmetric(
|
||||||
|
horizontal: 4,
|
||||||
|
),
|
||||||
content: Text(e.toString()),
|
content: Text(e.toString()),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
child: const Text('Ок'),
|
child: const Text('Ок'),
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
onPressed: () =>
|
||||||
|
Navigator.of(context).pop(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -239,8 +388,15 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
|
|||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
actionsPadding: const EdgeInsets.symmetric(horizontal: 4),
|
actionsPadding:
|
||||||
content: const Text('Примите договор публичной офёрты'),
|
const EdgeInsets.symmetric(
|
||||||
|
horizontal: 4,
|
||||||
|
),
|
||||||
|
content: Text(
|
||||||
|
state.isUpdating
|
||||||
|
? 'Примите договор публичной оферты'
|
||||||
|
: 'Примите оферту и согласие ФРДО',
|
||||||
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
child: const Text('Ок'),
|
child: const Text('Ок'),
|
||||||
@@ -255,15 +411,26 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
|
|||||||
} else {
|
} else {
|
||||||
FocusManager.instance.primaryFocus?.unfocus();
|
FocusManager.instance.primaryFocus?.unfocus();
|
||||||
setState(() {
|
setState(() {
|
||||||
pageController.nextPage(duration: const Duration(milliseconds: 350), curve: Curves.easeInOut);
|
pageController.nextPage(
|
||||||
|
duration: const Duration(milliseconds: 350),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
label: isLoading
|
label: submitDisabled && step == totalSteps
|
||||||
? const CircularProgressIndicator()
|
? const CircularProgressIndicator()
|
||||||
: widget.isCanceled == true
|
: widget.isCanceled == true && step != totalSteps
|
||||||
? Icon(Icons.arrow_forward_ios_outlined, color: appTheme.blueA400)
|
? Icon(
|
||||||
: Text('Продолжить', style: theme.textTheme.titleSmall!.copyWith(color: Colors.white)),
|
Icons.arrow_forward_ios_outlined,
|
||||||
|
color: appTheme.blueA400,
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
step == totalSteps ? 'Отправить' : 'Продолжить',
|
||||||
|
style: theme.textTheme.titleSmall!.copyWith(
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -271,11 +438,23 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
|
|||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
automaticallyImplyLeading: false,
|
automaticallyImplyLeading: false,
|
||||||
leading: Builder(
|
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,
|
centerTitle: true,
|
||||||
title: SvgPicture.asset(ImageConstant.imgLogoEatvio, height: 48, fit: BoxFit.contain),
|
title: SvgPicture.asset(
|
||||||
actions: [Padding(padding: const EdgeInsets.only(right: 16), child: Text('шаг $step из 6'))],
|
ImageConstant.imgLogoEatvio,
|
||||||
|
height: 48,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 16),
|
||||||
|
child: Text('шаг $step из $totalSteps'),
|
||||||
|
),
|
||||||
|
],
|
||||||
bottom: PreferredSize(
|
bottom: PreferredSize(
|
||||||
preferredSize: Size(MediaQuery.of(context).size.width, 32),
|
preferredSize: Size(MediaQuery.of(context).size.width, 32),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -283,13 +462,18 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
|
|||||||
child: TweenAnimationBuilder<double>(
|
child: TweenAnimationBuilder<double>(
|
||||||
duration: const Duration(milliseconds: 350),
|
duration: const Duration(milliseconds: 350),
|
||||||
curve: Curves.easeInOut,
|
curve: Curves.easeInOut,
|
||||||
tween: Tween<double>(begin: 1 / 6, end: progressValue),
|
tween: Tween<double>(
|
||||||
|
begin: 1 / totalSteps,
|
||||||
|
end: progressValue,
|
||||||
|
),
|
||||||
builder: (context, value, _) => LinearProgressIndicator(
|
builder: (context, value, _) => LinearProgressIndicator(
|
||||||
value: value,
|
value: value,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
minHeight: 8,
|
minHeight: 8,
|
||||||
backgroundColor: Colors.grey.shade100,
|
backgroundColor: Colors.grey.shade100,
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(appTheme.blueA400),
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
|
appTheme.blueA400,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -301,20 +485,14 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
|
|||||||
onPageChanged: (index) {
|
onPageChanged: (index) {
|
||||||
setState(() {
|
setState(() {
|
||||||
step = index + 1;
|
step = index + 1;
|
||||||
progressValue = step / 6;
|
progressValue = step / totalSteps;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
children: const [
|
children: _pages(),
|
||||||
CertificatesAndDiplomasScreen(),
|
|
||||||
PhotoOrScansScreen(),
|
|
||||||
AdditionalSettingsScreen(),
|
|
||||||
SocialNetworksScreen(),
|
|
||||||
RegistrationAsScreen(),
|
|
||||||
LastStepScreen(),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Wraps a form field with [ValidationErrorWidget] when [error] is present.
|
||||||
|
/// Pass [hasError] into [CustomTextFormField] from the parent.
|
||||||
|
class PassportFieldWithError extends StatelessWidget {
|
||||||
|
const PassportFieldWithError({required this.child, this.error, super.key});
|
||||||
|
|
||||||
|
final Widget child;
|
||||||
|
final String? error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final message = error?.trim();
|
||||||
|
final showError = message != null && message.isNotEmpty;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
child,
|
||||||
|
if (showError) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
ValidationErrorWidget(error: message),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||||
|
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class PassportSectionTitle extends StatelessWidget {
|
||||||
|
const PassportSectionTitle({required this.title, super.key});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8, bottom: 12),
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PassportScreenTitle extends StatelessWidget {
|
||||||
|
const PassportScreenTitle({required this.title, super.key});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Text(
|
||||||
|
title,
|
||||||
|
style: TextStyles.textFS24FW700.copyWith(color: appTheme.black),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||||
|
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.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/create_diet/presentation/widgets/error_widget.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
|
class ScoringStatusWidget extends StatelessWidget {
|
||||||
|
const ScoringStatusWidget({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return BlocBuilder<RegisterConsultantCubit, RegisterConsultantState>(
|
||||||
|
buildWhen: (previous, current) =>
|
||||||
|
previous.alfaScoringStatus != current.alfaScoringStatus ||
|
||||||
|
previous.isScoringPolling != current.isScoringPolling ||
|
||||||
|
previous.scoringRetryInProgress != current.scoringRetryInProgress ||
|
||||||
|
previous.awaitingScoringOutcome != current.awaitingScoringOutcome,
|
||||||
|
builder: (context, state) {
|
||||||
|
final isWaiting =
|
||||||
|
state.awaitingScoringOutcome || state.isScoringPolling;
|
||||||
|
final status = state.alfaScoringStatus;
|
||||||
|
final showProcessing = status.isProcessing ||
|
||||||
|
(isWaiting && !status.isTerminal);
|
||||||
|
|
||||||
|
if (status == AlfaScoringStatus.none && !isWaiting) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
final displayStatus = showProcessing && !status.isProcessing
|
||||||
|
? AlfaScoringStatus.beneficiaryCreating
|
||||||
|
: status;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: appTheme.greyD9),
|
||||||
|
color: appTheme.whiteA700,
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ScoringStatusHeader(status: displayStatus),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (showProcessing) const ScoringProcessingBody(),
|
||||||
|
if (status.isComplete) const ScoringCompleteBody(),
|
||||||
|
if (status.isFailed)
|
||||||
|
ScoringFailedBody(
|
||||||
|
retryInProgress: state.scoringRetryInProgress,
|
||||||
|
onRetry: () => context
|
||||||
|
.read<RegisterConsultantCubit>()
|
||||||
|
.retryScoring(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ScoringStatusHeader extends StatelessWidget {
|
||||||
|
const ScoringStatusHeader({required this.status, super.key});
|
||||||
|
|
||||||
|
final AlfaScoringStatus status;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Проверка данных',
|
||||||
|
style: TextStyles.textMSemibold.copyWith(color: appTheme.black),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
ScoringStatusLabels.hintFor(status),
|
||||||
|
style: TextStyles.textSMedium.copyWith(
|
||||||
|
color: ScoringStatusLabels.colorFor(status),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ScoringProcessingBody extends StatelessWidget {
|
||||||
|
const ScoringProcessingBody({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: appTheme.blueA400,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Идёт проверка…',
|
||||||
|
style: TextStyles.textMMediumAuto.copyWith(color: appTheme.black),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ScoringCompleteBody extends StatelessWidget {
|
||||||
|
const ScoringCompleteBody({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.check_circle, color: appTheme.green, size: 22),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Проверка успешно завершена',
|
||||||
|
style: TextStyles.textMMediumAuto.copyWith(color: appTheme.black),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ScoringFailedBody extends StatelessWidget {
|
||||||
|
const ScoringFailedBody({
|
||||||
|
required this.retryInProgress,
|
||||||
|
required this.onRetry,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool retryInProgress;
|
||||||
|
final VoidCallback onRetry;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const ValidationErrorWidget(error: 'Не удалось пройти проверку'),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 44,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: retryInProgress ? null : onRetry,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: appTheme.blueA400,
|
||||||
|
disabledBackgroundColor: appTheme.greyD9,
|
||||||
|
foregroundColor: appTheme.whiteA700,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
child: retryInProgress
|
||||||
|
? SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: appTheme.whiteA700,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
'Повторить',
|
||||||
|
style: TextStyles.textMSemibold.copyWith(
|
||||||
|
color: appTheme.whiteA700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract final class ScoringStatusLabels {
|
||||||
|
static String hintFor(AlfaScoringStatus status) {
|
||||||
|
return switch (status) {
|
||||||
|
AlfaScoringStatus.beneficiaryCreating => 'Создание профиля',
|
||||||
|
AlfaScoringStatus.scoringPending => 'Ожидание результата',
|
||||||
|
AlfaScoringStatus.scoringComplete => 'Успешно',
|
||||||
|
AlfaScoringStatus.beneficiaryFailed => 'Ошибка',
|
||||||
|
AlfaScoringStatus.scoringFailed => 'Ошибка',
|
||||||
|
AlfaScoringStatus.none => '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static Color colorFor(AlfaScoringStatus status) {
|
||||||
|
return switch (status) {
|
||||||
|
AlfaScoringStatus.scoringComplete => appTheme.green,
|
||||||
|
AlfaScoringStatus.beneficiaryFailed ||
|
||||||
|
AlfaScoringStatus.scoringFailed => appTheme.red,
|
||||||
|
AlfaScoringStatus.beneficiaryCreating ||
|
||||||
|
AlfaScoringStatus.scoringPending => appTheme.blueA400,
|
||||||
|
AlfaScoringStatus.none => appTheme.grey7B,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import 'package:dio/dio.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_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/consultant_profile_model.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/services/consultant_service.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/services/consultant_service.dart';
|
||||||
import 'package:http_parser/http_parser.dart';
|
import 'package:http_parser/http_parser.dart';
|
||||||
|
|
||||||
@@ -11,36 +12,53 @@ class ConsultantRepository {
|
|||||||
|
|
||||||
const ConsultantRepository({required this.consultantService});
|
const ConsultantRepository({required this.consultantService});
|
||||||
|
|
||||||
Future<void> registerConsultant({required ConsultantProfileModel consultant, bool update = false}) async {
|
Future<void> registerConsultant({
|
||||||
|
required ConsultantProfileModel consultant,
|
||||||
|
bool update = false,
|
||||||
|
}) async {
|
||||||
if (update) {
|
if (update) {
|
||||||
return await consultantService.consultantUpdate(consultantModel: consultant);
|
return await consultantService.consultantUpdate(
|
||||||
|
consultantModel: consultant,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
return await consultantService.consultantRegistration(consultantModel: consultant);
|
return await consultantService.consultantRegistration(
|
||||||
|
consultantModel: consultant,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ConsultationRequestModel> getConsultantRequestStatus(String slug) async {
|
/// Профиль консультанта (свой или чужой) — публичные данные без паспорта.
|
||||||
|
Future<ConsultationRequestModel> getConsultantRequestStatus(
|
||||||
|
String slug,
|
||||||
|
) async {
|
||||||
return await consultantService.getConsultantRequestStatus(slug: slug);
|
return await consultantService.getConsultantRequestStatus(slug: slug);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Своя заявка — education + legal/passport для регистрации и update.
|
||||||
Future<ConsultationRequestModel> getMyRequestStatus(String slug) async {
|
Future<ConsultationRequestModel> getMyRequestStatus(String slug) async {
|
||||||
return await consultantService.getMyRequestStatus(slug: slug);
|
return await consultantService.getMyRequestStatus(slug: slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<SbpBankModel>> getSbpBanks() async {
|
||||||
|
return await consultantService.getSbpBanks();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<ConsultantCertPhotoModel>> getConsultantCertPhotos() async {
|
Future<List<ConsultantCertPhotoModel>> getConsultantCertPhotos() async {
|
||||||
return await consultantService.getConsultantCertPhotos();
|
return await consultantService.getConsultantCertPhotos();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<ConsultantCertPhotoModel>> storeConsultantCertPhotos({required List<File> files}) async {
|
Future<List<ConsultantCertPhotoModel>> storeConsultantCertPhotos({
|
||||||
|
required List<File> files,
|
||||||
|
}) async {
|
||||||
final multipartFiles = <MultipartFile>[];
|
final multipartFiles = <MultipartFile>[];
|
||||||
|
|
||||||
for (final file in files) {
|
for (final file in files) {
|
||||||
final fileBytes = await file.readAsBytes();
|
final fileBytes = await file.readAsBytes();
|
||||||
final multipartFile = MultipartFile.fromBytes(fileBytes, filename: file.path.split('/').last, contentType: MediaType('application', 'octet-stream'));
|
final multipartFile = MultipartFile.fromBytes(
|
||||||
|
fileBytes,
|
||||||
|
filename: file.path.split('/').last,
|
||||||
|
contentType: MediaType('application', 'octet-stream'),
|
||||||
|
);
|
||||||
multipartFiles.add(multipartFile);
|
multipartFiles.add(multipartFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,22 +66,24 @@ class ConsultantRepository {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return await consultantService.storeConsultantCertPhotos(photos: multipartFiles);
|
return await consultantService.storeConsultantCertPhotos(
|
||||||
|
photos: multipartFiles,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> hideConsultantCertPhoto(int id) async {
|
Future<void> hideConsultantCertPhoto(int id) async {
|
||||||
await consultantService.hideConsultantCertPhoto(payload: {'cert_photo_id': id});
|
await consultantService.hideConsultantCertPhoto(
|
||||||
|
payload: {'cert_photo_id': id},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> unHideConsultantCertPhoto(int id) async {
|
Future<void> unHideConsultantCertPhoto(int id) async {
|
||||||
await consultantService.unHideConsultantCertPhoto(payload: {'cert_photo_id': id});
|
await consultantService.unHideConsultantCertPhoto(
|
||||||
|
payload: {'cert_photo_id': id},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> deletePhoto(int id) async {
|
Future<void> deletePhoto(int id) async {
|
||||||
await consultantService.deletePhoto(id: id);
|
await consultantService.deletePhoto(id: id);
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:dio/dio.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_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/consultant_profile_model.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart';
|
||||||
import 'package:retrofit/retrofit.dart';
|
import 'package:retrofit/retrofit.dart';
|
||||||
|
|
||||||
part 'consultant_service.g.dart';
|
part 'consultant_service.g.dart';
|
||||||
@@ -10,21 +11,39 @@ part 'consultant_service.g.dart';
|
|||||||
abstract class ConsultantService {
|
abstract class ConsultantService {
|
||||||
factory ConsultantService(Dio dio, {String baseUrl}) = _ConsultantService;
|
factory ConsultantService(Dio dio, {String baseUrl}) = _ConsultantService;
|
||||||
|
|
||||||
@POST('/api/consultant_request/registration')
|
@POST('/api/v2/consultant_request/registration')
|
||||||
Future<void> consultantRegistration({@Body() required ConsultantProfileModel consultantModel});
|
Future<void> consultantRegistration({
|
||||||
|
@Body() required ConsultantProfileModel consultantModel,
|
||||||
|
});
|
||||||
|
|
||||||
@POST('/api/consultant_request/photos/upload')
|
@POST('/api/consultant_request/photos/upload')
|
||||||
@MultiPart()
|
@MultiPart()
|
||||||
Future<List<String>> uploadPhotos({@Part(name: 'photos[]', contentType: 'multipart/form-data') List<MultipartFile>? photos});
|
Future<List<String>> uploadPhotos({
|
||||||
|
@Part(name: 'photos[]', contentType: 'multipart/form-data')
|
||||||
|
List<MultipartFile>? photos,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Публичный статус консультанта для просмотра профиля.
|
||||||
|
/// Без паспортных данных: `/api/profile/consultant/{slug}/status`.
|
||||||
@GET('/api/profile/consultant/{slug}/status')
|
@GET('/api/profile/consultant/{slug}/status')
|
||||||
Future<ConsultationRequestModel> getConsultantRequestStatus({@Path() required String slug});
|
Future<ConsultationRequestModel> getConsultantRequestStatus({
|
||||||
|
@Path() required String slug,
|
||||||
|
});
|
||||||
|
|
||||||
@GET('/api/consultant_request/{slug}/status')
|
/// Свой статус заявки для редактирования/модерации.
|
||||||
Future<ConsultationRequestModel> getMyRequestStatus({@Path() required String slug});
|
/// С паспортными полями в `legal_entity_resource`: `/api/v2/consultant_request/{slug}/status`.
|
||||||
|
@GET('/api/v2/consultant_request/{slug}/status')
|
||||||
|
Future<ConsultationRequestModel> getMyRequestStatus({
|
||||||
|
@Path() required String slug,
|
||||||
|
});
|
||||||
|
|
||||||
@PUT('/api/consultant_request/update')
|
@PUT('/api/v2/consultant_request/update')
|
||||||
Future<void> consultantUpdate({@Body() required ConsultantProfileModel consultantModel});
|
Future<void> consultantUpdate({
|
||||||
|
@Body() required ConsultantProfileModel consultantModel,
|
||||||
|
});
|
||||||
|
|
||||||
|
@GET('/api/finances/sbp_banks')
|
||||||
|
Future<List<SbpBankModel>> getSbpBanks();
|
||||||
|
|
||||||
@GET('/api/v2/consultant_request/cert_photos')
|
@GET('/api/v2/consultant_request/cert_photos')
|
||||||
Future<List<ConsultantCertPhotoModel>> getConsultantCertPhotos();
|
Future<List<ConsultantCertPhotoModel>> getConsultantCertPhotos();
|
||||||
@@ -32,14 +51,19 @@ abstract class ConsultantService {
|
|||||||
@POST('/api/v2/consultant_request/cert_photos/store')
|
@POST('/api/v2/consultant_request/cert_photos/store')
|
||||||
@MultiPart()
|
@MultiPart()
|
||||||
Future<List<ConsultantCertPhotoModel>> storeConsultantCertPhotos({
|
Future<List<ConsultantCertPhotoModel>> storeConsultantCertPhotos({
|
||||||
@Part(name: 'photos[]', contentType: 'multipart/form-data') required List<MultipartFile> photos,
|
@Part(name: 'photos[]', contentType: 'multipart/form-data')
|
||||||
|
required List<MultipartFile> photos,
|
||||||
});
|
});
|
||||||
|
|
||||||
@POST('/api/v2/consultant_request/cert_photos/hide')
|
@POST('/api/v2/consultant_request/cert_photos/hide')
|
||||||
Future<void> hideConsultantCertPhoto({@Body() required Map<String, dynamic> payload});
|
Future<void> hideConsultantCertPhoto({
|
||||||
|
@Body() required Map<String, dynamic> payload,
|
||||||
|
});
|
||||||
|
|
||||||
@POST('/api/v2/consultant_request/cert_photos/un_hide')
|
@POST('/api/v2/consultant_request/cert_photos/un_hide')
|
||||||
Future<void> unHideConsultantCertPhoto({@Body() required Map<String, dynamic> payload});
|
Future<void> unHideConsultantCertPhoto({
|
||||||
|
@Body() required Map<String, dynamic> payload,
|
||||||
|
});
|
||||||
|
|
||||||
@POST('/api/v2/consultant_request/cert_photos/{id}/queue-for-deletion')
|
@POST('/api/v2/consultant_request/cert_photos/{id}/queue-for-deletion')
|
||||||
Future<void> deletePhoto({@Path() required int id});
|
Future<void> deletePhoto({@Path() required int id});
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
/// Parsed Laravel 422 validation payload for consultant registration UI.
|
||||||
|
class LaravelValidationErrors {
|
||||||
|
const LaravelValidationErrors({
|
||||||
|
required this.summary,
|
||||||
|
this.fieldErrors = const <String, String>{},
|
||||||
|
});
|
||||||
|
|
||||||
|
final String summary;
|
||||||
|
final Map<String, String> fieldErrors;
|
||||||
|
|
||||||
|
static const LaravelValidationErrors empty = LaravelValidationErrors(
|
||||||
|
summary: '',
|
||||||
|
);
|
||||||
|
|
||||||
|
bool get hasFieldErrors => fieldErrors.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _genericValidationSummary = 'Проверьте введённые данные';
|
||||||
|
const _genericNetworkSummary =
|
||||||
|
'Не удалось отправить данные. Попробуйте ещё раз';
|
||||||
|
|
||||||
|
/// Parses Laravel `errors` map and maps them to user-facing Russian texts.
|
||||||
|
/// Raw backend messages are never shown in the UI.
|
||||||
|
LaravelValidationErrors parseLaravelValidationErrors(Object error) {
|
||||||
|
if (error is! DioException) {
|
||||||
|
return const LaravelValidationErrors(summary: _genericNetworkSummary);
|
||||||
|
}
|
||||||
|
|
||||||
|
final data = error.response?.data;
|
||||||
|
if (error.response?.statusCode == 422 && data is Map) {
|
||||||
|
final fieldErrors = <String, String>{};
|
||||||
|
final errors = data['errors'];
|
||||||
|
|
||||||
|
if (errors is Map) {
|
||||||
|
errors.forEach((key, value) {
|
||||||
|
final fieldKey = key.toString();
|
||||||
|
final raw = _rawErrorText(value);
|
||||||
|
fieldErrors[fieldKey] = mapConsultantValidationMessage(
|
||||||
|
fieldKey: fieldKey,
|
||||||
|
rawMessage: raw,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldErrors.isNotEmpty) {
|
||||||
|
final uniqueMessages = fieldErrors.values.toSet().toList();
|
||||||
|
return LaravelValidationErrors(
|
||||||
|
summary: uniqueMessages.join('\n'),
|
||||||
|
fieldErrors: fieldErrors,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return const LaravelValidationErrors(summary: _genericValidationSummary);
|
||||||
|
}
|
||||||
|
|
||||||
|
return const LaravelValidationErrors(summary: _genericNetworkSummary);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _rawErrorText(Object? value) {
|
||||||
|
if (value is List && value.isNotEmpty) {
|
||||||
|
return value.map((e) => e.toString()).join(' ');
|
||||||
|
}
|
||||||
|
if (value is String) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps a backend validation message for [fieldKey] to a short Russian UI string.
|
||||||
|
String mapConsultantValidationMessage({
|
||||||
|
required String fieldKey,
|
||||||
|
required String rawMessage,
|
||||||
|
}) {
|
||||||
|
final field = fieldKey.split('.').last.toLowerCase();
|
||||||
|
final raw = rawMessage.toLowerCase();
|
||||||
|
|
||||||
|
final isUnique =
|
||||||
|
raw.contains('taken') ||
|
||||||
|
raw.contains('unique') ||
|
||||||
|
raw.contains('already') ||
|
||||||
|
raw.contains('уже существ') ||
|
||||||
|
raw.contains('уже занят');
|
||||||
|
final isRequired =
|
||||||
|
raw.contains('required') ||
|
||||||
|
raw.contains('обязательн') ||
|
||||||
|
raw.contains('required_if');
|
||||||
|
final isAccepted = raw.contains('accepted') || field.contains('agreement');
|
||||||
|
final isUrl = raw.contains('url') || field.contains('link');
|
||||||
|
final isFormat =
|
||||||
|
raw.contains('format') ||
|
||||||
|
raw.contains('invalid') ||
|
||||||
|
raw.contains('regex') ||
|
||||||
|
raw.contains('digits') ||
|
||||||
|
raw.contains('size') ||
|
||||||
|
raw.contains('date') ||
|
||||||
|
raw.contains('before');
|
||||||
|
|
||||||
|
if (isAccepted) {
|
||||||
|
if (field.contains('frdo')) {
|
||||||
|
return 'Подтвердите согласие ФРДО';
|
||||||
|
}
|
||||||
|
return 'Примите условия оферты';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUnique) {
|
||||||
|
return switch (field) {
|
||||||
|
'inn' => 'Такой ИНН существует',
|
||||||
|
'snils' => 'Такой СНИЛС существует',
|
||||||
|
'passport_number' || 'passport_series' => 'Такой паспорт существует',
|
||||||
|
'sbp_phone' => 'Такой телефон СБП существует',
|
||||||
|
_ => 'Такие данные уже существуют',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRequired) {
|
||||||
|
return switch (field) {
|
||||||
|
'inn' => 'Укажите ИНН',
|
||||||
|
'snils' => 'Укажите СНИЛС',
|
||||||
|
'last_name' => 'Укажите фамилию',
|
||||||
|
'first_name' => 'Укажите имя',
|
||||||
|
'middle_name' => 'Укажите отчество',
|
||||||
|
'passport_series' => 'Укажите серию паспорта',
|
||||||
|
'passport_number' => 'Укажите номер паспорта',
|
||||||
|
'passport_issue_date' => 'Укажите дату выдачи паспорта',
|
||||||
|
'passport_issuer' => 'Укажите, кем выдан паспорт',
|
||||||
|
'passport_issuer_code' => 'Укажите код подразделения',
|
||||||
|
'birth_date' => 'Укажите дату рождения',
|
||||||
|
'birth_place' => 'Укажите место рождения',
|
||||||
|
'registration_address' => 'Укажите адрес регистрации',
|
||||||
|
'residence_address' => 'Укажите адрес проживания',
|
||||||
|
'citizenship' => 'Укажите гражданство',
|
||||||
|
'sbp_phone' => 'Укажите телефон СБП',
|
||||||
|
'sbp_bank_member_id' => 'Выберите банк СБП',
|
||||||
|
'completed_courses' => 'Добавьте хотя бы один курс',
|
||||||
|
'title' => 'Укажите название курса',
|
||||||
|
'date' => 'Укажите дату курса',
|
||||||
|
'agreement_accepted' => 'Примите условия оферты',
|
||||||
|
'frdo_agreement_accepted' => 'Подтвердите согласие ФРДО',
|
||||||
|
_ => 'Заполните поле',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUrl) {
|
||||||
|
return 'Некорректная ссылка';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFormat || raw.isNotEmpty) {
|
||||||
|
return switch (field) {
|
||||||
|
'inn' => 'Некорректный ИНН',
|
||||||
|
'snils' => 'Некорректный СНИЛС',
|
||||||
|
'passport_series' => 'Некорректная серия паспорта',
|
||||||
|
'passport_number' => 'Некорректный номер паспорта',
|
||||||
|
'passport_issue_date' => 'Некорректная дата выдачи',
|
||||||
|
'passport_issuer_code' => 'Некорректный код подразделения',
|
||||||
|
'last_name' => 'Некорректная фамилия',
|
||||||
|
'first_name' => 'Некорректное имя',
|
||||||
|
'middle_name' => 'Некорректное отчество',
|
||||||
|
'birth_date' => 'Некорректная дата рождения',
|
||||||
|
'citizenship' => 'Некорректный код гражданства',
|
||||||
|
'sbp_phone' => 'Некорректный телефон СБП',
|
||||||
|
'title' => 'Слишком короткое название курса',
|
||||||
|
'date' => 'Некорректная дата курса',
|
||||||
|
_ => 'Некорректное значение',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Проверьте поле';
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
|
||||||
|
|
||||||
|
/// Client-side validation for individual-person passport fields.
|
||||||
|
///
|
||||||
|
/// Messages match [mapConsultantValidationMessage] so 422 and local errors
|
||||||
|
/// look the same in the UI. Dates also reject non-calendar values and
|
||||||
|
/// dates after today.
|
||||||
|
abstract final class PassportDataValidator {
|
||||||
|
static const futureDateMessage = 'Дата не может быть в будущем';
|
||||||
|
|
||||||
|
/// Alfa Bank `personalInformation.{last,first,middle}Name` pattern.
|
||||||
|
static final alfaPersonNamePattern = RegExp(r'^[a-zA-Zа-яА-ЯёЁ ,.-]{1,50}$');
|
||||||
|
|
||||||
|
static Map<String, String> validate(
|
||||||
|
ConsultantProfileModel consultant, {
|
||||||
|
DateTime? now,
|
||||||
|
}) {
|
||||||
|
final today = now ?? DateTime.now();
|
||||||
|
final errors = <String, String>{};
|
||||||
|
|
||||||
|
_requirePattern(
|
||||||
|
errors,
|
||||||
|
key: 'last_name',
|
||||||
|
value: consultant.lastName,
|
||||||
|
pattern: alfaPersonNamePattern,
|
||||||
|
emptyMessage: 'Укажите фамилию',
|
||||||
|
invalidMessage: 'Некорректная фамилия',
|
||||||
|
);
|
||||||
|
_requirePattern(
|
||||||
|
errors,
|
||||||
|
key: 'first_name',
|
||||||
|
value: consultant.firstName,
|
||||||
|
pattern: alfaPersonNamePattern,
|
||||||
|
emptyMessage: 'Укажите имя',
|
||||||
|
invalidMessage: 'Некорректное имя',
|
||||||
|
);
|
||||||
|
_optionalPattern(
|
||||||
|
errors,
|
||||||
|
key: 'middle_name',
|
||||||
|
value: consultant.middleName,
|
||||||
|
pattern: alfaPersonNamePattern,
|
||||||
|
invalidMessage: 'Некорректное отчество',
|
||||||
|
);
|
||||||
|
_requirePattern(
|
||||||
|
errors,
|
||||||
|
key: 'inn',
|
||||||
|
value: consultant.inn,
|
||||||
|
pattern: RegExp(r'^\d{12}$'),
|
||||||
|
emptyMessage: 'Укажите ИНН',
|
||||||
|
invalidMessage: 'Некорректный ИНН',
|
||||||
|
);
|
||||||
|
_requirePattern(
|
||||||
|
errors,
|
||||||
|
key: 'snils',
|
||||||
|
value: consultant.snils,
|
||||||
|
pattern: RegExp(r'^\d{3}-\d{3}-\d{3} \d{2}$'),
|
||||||
|
emptyMessage: 'Укажите СНИЛС',
|
||||||
|
invalidMessage: 'Некорректный СНИЛС',
|
||||||
|
);
|
||||||
|
_requirePattern(
|
||||||
|
errors,
|
||||||
|
key: 'passport_series',
|
||||||
|
value: consultant.passportSeries,
|
||||||
|
pattern: RegExp(r'^\d{4}$'),
|
||||||
|
emptyMessage: 'Укажите серию паспорта',
|
||||||
|
invalidMessage: 'Некорректная серия паспорта',
|
||||||
|
);
|
||||||
|
_requirePattern(
|
||||||
|
errors,
|
||||||
|
key: 'passport_number',
|
||||||
|
value: consultant.passportNumber,
|
||||||
|
pattern: RegExp(r'^\d{6}$'),
|
||||||
|
emptyMessage: 'Укажите номер паспорта',
|
||||||
|
invalidMessage: 'Некорректный номер паспорта',
|
||||||
|
);
|
||||||
|
_requireText(
|
||||||
|
errors,
|
||||||
|
key: 'passport_issuer',
|
||||||
|
value: consultant.passportIssuer,
|
||||||
|
emptyMessage: 'Укажите, кем выдан паспорт',
|
||||||
|
);
|
||||||
|
_requirePattern(
|
||||||
|
errors,
|
||||||
|
key: 'passport_issuer_code',
|
||||||
|
value: consultant.passportIssuerCode,
|
||||||
|
pattern: RegExp(r'^\d{3}-\d{3}$'),
|
||||||
|
emptyMessage: 'Укажите код подразделения',
|
||||||
|
invalidMessage: 'Некорректный код подразделения',
|
||||||
|
);
|
||||||
|
_requireDate(
|
||||||
|
errors,
|
||||||
|
key: 'passport_issue_date',
|
||||||
|
value: consultant.passportIssueDate,
|
||||||
|
emptyMessage: 'Укажите дату выдачи паспорта',
|
||||||
|
invalidMessage: 'Некорректная дата выдачи',
|
||||||
|
today: today,
|
||||||
|
);
|
||||||
|
_requireDate(
|
||||||
|
errors,
|
||||||
|
key: 'birth_date',
|
||||||
|
value: consultant.birthDate,
|
||||||
|
emptyMessage: 'Укажите дату рождения',
|
||||||
|
invalidMessage: 'Некорректная дата рождения',
|
||||||
|
today: today,
|
||||||
|
);
|
||||||
|
_requireText(
|
||||||
|
errors,
|
||||||
|
key: 'birth_place',
|
||||||
|
value: consultant.birthPlace,
|
||||||
|
emptyMessage: 'Укажите место рождения',
|
||||||
|
);
|
||||||
|
_requireText(
|
||||||
|
errors,
|
||||||
|
key: 'registration_address',
|
||||||
|
value: consultant.registrationAddress,
|
||||||
|
emptyMessage: 'Укажите адрес регистрации',
|
||||||
|
);
|
||||||
|
_requireText(
|
||||||
|
errors,
|
||||||
|
key: 'residence_address',
|
||||||
|
value: consultant.residenceAddress,
|
||||||
|
emptyMessage: 'Укажите адрес проживания',
|
||||||
|
);
|
||||||
|
_requirePattern(
|
||||||
|
errors,
|
||||||
|
key: 'citizenship',
|
||||||
|
value: consultant.citizenship,
|
||||||
|
pattern: RegExp(r'^[A-Za-z]{2}$'),
|
||||||
|
emptyMessage: 'Укажите гражданство',
|
||||||
|
invalidMessage: 'Некорректный код гражданства',
|
||||||
|
);
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _requireText(
|
||||||
|
Map<String, String> errors, {
|
||||||
|
required String key,
|
||||||
|
required String? value,
|
||||||
|
required String emptyMessage,
|
||||||
|
}) {
|
||||||
|
if (value == null || value.trim().isEmpty) {
|
||||||
|
errors[key] = emptyMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _requirePattern(
|
||||||
|
Map<String, String> errors, {
|
||||||
|
required String key,
|
||||||
|
required String? value,
|
||||||
|
required RegExp pattern,
|
||||||
|
required String emptyMessage,
|
||||||
|
required String invalidMessage,
|
||||||
|
}) {
|
||||||
|
final raw = value?.trim() ?? '';
|
||||||
|
if (raw.isEmpty) {
|
||||||
|
errors[key] = emptyMessage;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!pattern.hasMatch(raw)) {
|
||||||
|
errors[key] = invalidMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _optionalPattern(
|
||||||
|
Map<String, String> errors, {
|
||||||
|
required String key,
|
||||||
|
required String? value,
|
||||||
|
required RegExp pattern,
|
||||||
|
required String invalidMessage,
|
||||||
|
}) {
|
||||||
|
final raw = value?.trim() ?? '';
|
||||||
|
if (raw.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!pattern.hasMatch(raw)) {
|
||||||
|
errors[key] = invalidMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _requireDate(
|
||||||
|
Map<String, String> errors, {
|
||||||
|
required String key,
|
||||||
|
required String? value,
|
||||||
|
required String emptyMessage,
|
||||||
|
required String invalidMessage,
|
||||||
|
required DateTime today,
|
||||||
|
}) {
|
||||||
|
final raw = value?.trim() ?? '';
|
||||||
|
if (raw.isEmpty) {
|
||||||
|
errors[key] = emptyMessage;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final match = RegExp(r'^(\d{4})-(\d{2})-(\d{2})$').firstMatch(raw);
|
||||||
|
if (match == null) {
|
||||||
|
errors[key] = invalidMessage;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final year = int.parse(match.group(1)!);
|
||||||
|
final month = int.parse(match.group(2)!);
|
||||||
|
final day = int.parse(match.group(3)!);
|
||||||
|
final parsed = DateTime(year, month, day);
|
||||||
|
if (parsed.year != year || parsed.month != month || parsed.day != day) {
|
||||||
|
errors[key] = invalidMessage;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final todayDate = DateTime(today.year, today.month, today.day);
|
||||||
|
if (parsed.isAfter(todayDate)) {
|
||||||
|
errors[key] = futureDateMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,43 +59,47 @@ class _AddMealWidgetState extends State<AddMealWidget> {
|
|||||||
Row(
|
Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
Expanded(
|
||||||
width: MediaQuery.of(context).size.width * 0.8 - 32,
|
flex: 2,
|
||||||
child: CustomTextFormField(
|
child: SizedBox(
|
||||||
controller: productNameController,
|
child: CustomTextFormField(
|
||||||
autofocus: true,
|
controller: productNameController,
|
||||||
hintText: 'Введите название продукта/блюда',
|
autofocus: true,
|
||||||
onChanged: (query) {
|
hintText: 'Введите название продукта/блюда',
|
||||||
EasyDebounce.debounce('glossary-search', const Duration(milliseconds: 250), () async {
|
onChanged: (query) {
|
||||||
selectedGlossary = null;
|
EasyDebounce.debounce('glossary-search', const Duration(milliseconds: 250), () async {
|
||||||
if (query.isEmpty) {
|
|
||||||
glossariesList = [];
|
|
||||||
selectedGlossary = null;
|
selectedGlossary = null;
|
||||||
|
if (query.isEmpty) {
|
||||||
|
glossariesList = [];
|
||||||
|
selectedGlossary = null;
|
||||||
|
setState(() {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
glossariesList = await widget.createDietCubit.getGlossaryListByQuery(query);
|
||||||
setState(() {});
|
setState(() {});
|
||||||
return;
|
});
|
||||||
}
|
},
|
||||||
glossariesList = await widget.createDietCubit.getGlossaryListByQuery(query);
|
),
|
||||||
setState(() {});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
SizedBox(
|
Expanded(
|
||||||
width: MediaQuery.of(context).size.width * 0.2 - 4,
|
flex: 1,
|
||||||
child: CustomTextFormField(
|
child: SizedBox(
|
||||||
controller: quantityController,
|
child: CustomTextFormField(
|
||||||
keyboardType: TextInputType.number,
|
controller: quantityController,
|
||||||
hintText: selectedGlossary == null
|
keyboardType: TextInputType.number,
|
||||||
? '100'
|
hintText: selectedGlossary == null
|
||||||
: selectedGlossary!.grInPortion == 0
|
? '100'
|
||||||
? '100'
|
: selectedGlossary!.grInPortion == 0
|
||||||
: '1',
|
? '100'
|
||||||
suffixText: selectedGlossary == null
|
: '1',
|
||||||
? 'гр'
|
suffixText: selectedGlossary == null
|
||||||
: selectedGlossary?.grInPortion == 0
|
? 'гр'
|
||||||
? 'гр'
|
: selectedGlossary?.grInPortion == 0
|
||||||
: 'шт',
|
? 'гр'
|
||||||
|
: 'шт',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -149,17 +153,19 @@ class _AddMealWidgetState extends State<AddMealWidget> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (widget.portionIndex != null) const SizedBox(width: 8),
|
if (widget.portionIndex != null) const SizedBox(width: 8),
|
||||||
InkWell(
|
Expanded(
|
||||||
onTap: addPortion,
|
child: InkWell(
|
||||||
borderRadius: BorderRadius.circular(20),
|
onTap: addPortion,
|
||||||
child: Ink(
|
borderRadius: BorderRadius.circular(20),
|
||||||
width: MediaQuery.of(context).size.width - (widget.portionIndex == null ? 32 : 92),
|
child: Ink(
|
||||||
height: 52,
|
width: MediaQuery.of(context).size.width - (widget.portionIndex == null ? 32 : 92),
|
||||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
|
height: 52,
|
||||||
child: Center(
|
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
|
||||||
child: Text(
|
child: Center(
|
||||||
widget.portionIndex == null ? 'Добавить' : 'Сохранить',
|
child: Text(
|
||||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white),
|
widget.portionIndex == null ? 'Добавить' : 'Сохранить',
|
||||||
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -70,42 +70,46 @@ class _AddProductModalState extends State<AddProductModal> {
|
|||||||
Row(
|
Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
Expanded(
|
||||||
width: MediaQuery.of(context).size.width * 0.76 - 32,
|
flex: 2,
|
||||||
child: CustomTextFormField(
|
child: SizedBox(
|
||||||
controller: productNameController,
|
child: CustomTextFormField(
|
||||||
autofocus: true,
|
controller: productNameController,
|
||||||
hintText: 'Введите название продукта/блюда',
|
autofocus: true,
|
||||||
onChanged: (query) {
|
hintText: 'Введите название продукта/блюда',
|
||||||
EasyDebounce.debounce('glossary-search', const Duration(milliseconds: 250), () async {
|
onChanged: (query) {
|
||||||
selectedGlossary = null;
|
EasyDebounce.debounce('glossary-search', const Duration(milliseconds: 250), () async {
|
||||||
if (query.isEmpty) {
|
selectedGlossary = null;
|
||||||
glossariesList = [];
|
if (query.isEmpty) {
|
||||||
|
glossariesList = [];
|
||||||
|
setState(() {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
glossariesList = await widget.diaryCubit.getGlossaryListByQuery(query);
|
||||||
setState(() {});
|
setState(() {});
|
||||||
return;
|
});
|
||||||
}
|
},
|
||||||
glossariesList = await widget.diaryCubit.getGlossaryListByQuery(query);
|
),
|
||||||
setState(() {});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
SizedBox(
|
Expanded(
|
||||||
width: MediaQuery.of(context).size.width * 0.24 - 4,
|
flex: 1,
|
||||||
child: CustomTextFormField(
|
child: SizedBox(
|
||||||
controller: quantityController,
|
child: CustomTextFormField(
|
||||||
keyboardType: TextInputType.number,
|
controller: quantityController,
|
||||||
hintText: selectedGlossary == null
|
keyboardType: TextInputType.number,
|
||||||
? '100'
|
hintText: selectedGlossary == null
|
||||||
: selectedGlossary!.grInPortion == 0
|
? '100'
|
||||||
? '100'
|
: selectedGlossary!.grInPortion == 0
|
||||||
: '1',
|
? '100'
|
||||||
suffixText: selectedGlossary == null
|
: '1',
|
||||||
? 'гр'
|
suffixText: selectedGlossary == null
|
||||||
: selectedGlossary?.grInPortion == 0
|
? 'гр'
|
||||||
? 'гр'
|
: selectedGlossary?.grInPortion == 0
|
||||||
: 'шт',
|
? 'гр'
|
||||||
|
: 'шт',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -168,66 +172,68 @@ class _AddProductModalState extends State<AddProductModal> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (widget.portion != null) const SizedBox(width: 8),
|
if (widget.portion != null) const SizedBox(width: 8),
|
||||||
InkWell(
|
Expanded(
|
||||||
onTap: () {
|
child: InkWell(
|
||||||
// Автоматически выбираем первый элемент, если текст точно совпадает
|
onTap: () {
|
||||||
if (selectedGlossary == null &&
|
// Автоматически выбираем первый элемент, если текст точно совпадает
|
||||||
glossariesList.isNotEmpty &&
|
if (selectedGlossary == null &&
|
||||||
productNameController.text.trim().toLowerCase() == glossariesList[0].name.toLowerCase()) {
|
glossariesList.isNotEmpty &&
|
||||||
selectedGlossary = glossariesList[0];
|
productNameController.text.trim().toLowerCase() == glossariesList[0].name.toLowerCase()) {
|
||||||
}
|
selectedGlossary = glossariesList[0];
|
||||||
|
}
|
||||||
|
|
||||||
if (widget.portion == null) {
|
if (widget.portion == null) {
|
||||||
widget.diaryCubit.addProductToMeal(
|
widget.diaryCubit.addProductToMeal(
|
||||||
diaryId: widget.state.diary.id,
|
diaryId: widget.state.diary.id,
|
||||||
mealId: widget.state.selectedMeal,
|
mealId: widget.state.selectedMeal,
|
||||||
productName: productNameController.text,
|
productName: productNameController.text,
|
||||||
portion: selectedGlossary == null
|
portion: selectedGlossary == null
|
||||||
? 'gr'
|
? 'gr'
|
||||||
: selectedGlossary!.grInPortion == 0
|
: selectedGlossary!.grInPortion == 0
|
||||||
? 'gr'
|
? 'gr'
|
||||||
: 'pcs',
|
: 'pcs',
|
||||||
quantity: int.parse(quantityController.text),
|
quantity: int.parse(quantityController.text),
|
||||||
productNutritionalValueId: selectedGlossary?.id,
|
productNutritionalValueId: selectedGlossary?.id,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
widget.diaryCubit.updateProductInMeal(
|
widget.diaryCubit.updateProductInMeal(
|
||||||
diaryId: widget.state.diary.id,
|
diaryId: widget.state.diary.id,
|
||||||
mealId: widget.state.selectedMeal,
|
mealId: widget.state.selectedMeal,
|
||||||
portionId: widget.portion!.id,
|
portionId: widget.portion!.id,
|
||||||
productName: productNameController.text,
|
productName: productNameController.text,
|
||||||
portion: selectedGlossary == null
|
portion: selectedGlossary == null
|
||||||
? 'gr'
|
? 'gr'
|
||||||
: selectedGlossary!.grInPortion == 0
|
: selectedGlossary!.grInPortion == 0
|
||||||
? 'gr'
|
? 'gr'
|
||||||
: 'pcs',
|
: 'pcs',
|
||||||
quantity: int.parse(quantityController.text),
|
quantity: int.parse(quantityController.text),
|
||||||
productNutritionalValueId: selectedGlossary?.id,
|
productNutritionalValueId: selectedGlossary?.id,
|
||||||
portionState: widget.portion!.portionState,
|
portionState: widget.portion!.portionState,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
if (selectedGlossary == null && _previousProductName != productNameController.text) {
|
if (selectedGlossary == null && _previousProductName != productNameController.text) {
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
useRootNavigator: true,
|
useRootNavigator: true,
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => Padding(
|
builder: (context) => Padding(
|
||||||
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||||
child: GlossaryModerationRequestModalWidget(productName: productNameController.text, diaryCubit: widget.diaryCubit),
|
child: GlossaryModerationRequestModalWidget(productName: productNameController.text, diaryCubit: widget.diaryCubit),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
child: Ink(
|
||||||
|
width: MediaQuery.of(context).size.width - (widget.portion == null ? 32 : 92),
|
||||||
|
height: 52,
|
||||||
|
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
widget.portion == null ? 'Добавить' : 'Сохранить',
|
||||||
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white),
|
||||||
),
|
),
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
child: Ink(
|
|
||||||
width: MediaQuery.of(context).size.width - (widget.portion == null ? 32 : 92),
|
|
||||||
height: 52,
|
|
||||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
|
|
||||||
child: Center(
|
|
||||||
child: Text(
|
|
||||||
widget.portion == null ? 'Добавить' : 'Сохранить',
|
|
||||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ abstract class FeedBlogPostModel with _$FeedBlogPostModel {
|
|||||||
@JsonKey(name: "total_comments") required int totalComments,
|
@JsonKey(name: "total_comments") required int totalComments,
|
||||||
@JsonKey(name: "was_liked_by_me") required bool wasLikedByMe,
|
@JsonKey(name: "was_liked_by_me") required bool wasLikedByMe,
|
||||||
@Default([]) List<FeedGalleryImage> images,
|
@Default([]) List<FeedGalleryImage> images,
|
||||||
@JsonKey(name: "video_embed_url") String? videoEmbedUrl,
|
@JsonKey(name: "video_url_embed") String? videoEmbedUrl,
|
||||||
@JsonKey(name: "video_url_thumb") String? videoUrlThumb,
|
@JsonKey(name: "video_url_thumb") String? videoUrlThumb,
|
||||||
}) = _FeedBlogPostModel;
|
}) = _FeedBlogPostModel;
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,9 @@ abstract class PostFeedItemModel with _$PostFeedItemModel {
|
|||||||
abstract class FeedGalleryImage with _$FeedGalleryImage {
|
abstract class FeedGalleryImage with _$FeedGalleryImage {
|
||||||
const factory FeedGalleryImage({
|
const factory FeedGalleryImage({
|
||||||
required String url,
|
required String url,
|
||||||
required String orientation,
|
@Default('horizontal') String orientation,
|
||||||
|
int? id,
|
||||||
|
int? position,
|
||||||
}) = _FeedGalleryImage;
|
}) = _FeedGalleryImage;
|
||||||
|
|
||||||
factory FeedGalleryImage.fromJson(Map<String, Object?> json) => _$FeedGalleryImageFromJson(json);
|
factory FeedGalleryImage.fromJson(Map<String, Object?> json) => _$FeedGalleryImageFromJson(json);
|
||||||
|
|||||||
@@ -0,0 +1,287 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:carousel_slider_plus/carousel_slider_plus.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/models/post_feed_pagination_model.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_player_widget.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Gallery + optional embed video for feed card and blog post detail.
|
||||||
|
class BlogPostMediaContent extends StatefulWidget {
|
||||||
|
const BlogPostMediaContent({
|
||||||
|
super.key,
|
||||||
|
required this.galleryImages,
|
||||||
|
required this.imageUrl,
|
||||||
|
required this.videoEmbedUrl,
|
||||||
|
required this.currentGalleryIndex,
|
||||||
|
required this.onOpenGallery,
|
||||||
|
required this.onGalleryChanged,
|
||||||
|
required this.videoUrlThumb,
|
||||||
|
this.heroTag,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<FeedGalleryImage> galleryImages;
|
||||||
|
final String imageUrl;
|
||||||
|
final String? videoEmbedUrl;
|
||||||
|
final String? videoUrlThumb;
|
||||||
|
final int currentGalleryIndex;
|
||||||
|
final void Function(List<String> urls, int index) onOpenGallery;
|
||||||
|
final ValueChanged<int> onGalleryChanged;
|
||||||
|
|
||||||
|
/// Unique per post. Required when several cards with the same [imageUrl] share a route.
|
||||||
|
final Object? heroTag;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<BlogPostMediaContent> createState() => _BlogPostMediaContentState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
|
||||||
|
static const _counterDisplayDuration = Duration(seconds: 1);
|
||||||
|
|
||||||
|
/// Landscape / video slide aspect (width / height).
|
||||||
|
static const _horizontalAspectRatio = 16 / 9;
|
||||||
|
|
||||||
|
/// Portrait slide aspect (width / height).
|
||||||
|
static const _verticalAspectRatio = 3 / 4;
|
||||||
|
|
||||||
|
bool _isCounterVisible = false;
|
||||||
|
Timer? _counterHideTimer;
|
||||||
|
|
||||||
|
bool get _hasVideo =>
|
||||||
|
widget.videoEmbedUrl != null && widget.videoEmbedUrl!.trim().isNotEmpty;
|
||||||
|
|
||||||
|
List<FeedGalleryImage> get _gallery {
|
||||||
|
return [
|
||||||
|
for (final image in widget.galleryImages)
|
||||||
|
if (PostFeedMapper.resolveMediaUrl(image.url) case final url
|
||||||
|
when url.isNotEmpty)
|
||||||
|
FeedGalleryImage(
|
||||||
|
url: url,
|
||||||
|
orientation: image.orientation,
|
||||||
|
id: image.id,
|
||||||
|
position: image.position,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> get _galleryUrls => _gallery.map((e) => e.url).toList();
|
||||||
|
|
||||||
|
int get _slideCount {
|
||||||
|
if (_gallery.isNotEmpty) {
|
||||||
|
return _gallery.length + (_hasVideo ? 1 : 0);
|
||||||
|
}
|
||||||
|
if (_hasVideo) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _isCurrentSlideVideo =>
|
||||||
|
_hasVideo && widget.currentGalleryIndex == _gallery.length;
|
||||||
|
|
||||||
|
bool get _shouldShowCounter => !_isCurrentSlideVideo || _isCounterVisible;
|
||||||
|
|
||||||
|
/// Height is fixed for the whole post from the first gallery image (web-like).
|
||||||
|
String get _postOrientation {
|
||||||
|
if (_gallery.isNotEmpty) {
|
||||||
|
return _gallery.first.orientation;
|
||||||
|
}
|
||||||
|
return 'horizontal';
|
||||||
|
}
|
||||||
|
|
||||||
|
double _heightForOrientation(String orientation, double width) {
|
||||||
|
final aspectRatio = orientation == 'vertical'
|
||||||
|
? _verticalAspectRatio
|
||||||
|
: _horizontalAspectRatio;
|
||||||
|
final maxHeight = MediaQuery.sizeOf(context).height * 0.7;
|
||||||
|
return (width / aspectRatio).clamp(180.0, maxHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_counterHideTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant BlogPostMediaContent oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.currentGalleryIndex != widget.currentGalleryIndex) {
|
||||||
|
_scheduleCounterHideOnVideo();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scheduleCounterHideOnVideo() {
|
||||||
|
_counterHideTimer?.cancel();
|
||||||
|
if (!_isCurrentSlideVideo) return;
|
||||||
|
|
||||||
|
_counterHideTimer = Timer(_counterDisplayDuration, () {
|
||||||
|
if (mounted && _isCurrentSlideVideo) {
|
||||||
|
setState(() => _isCounterVisible = false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _handleCarouselScroll(ScrollNotification notification) {
|
||||||
|
if (notification is ScrollStartNotification ||
|
||||||
|
notification is ScrollUpdateNotification) {
|
||||||
|
_counterHideTimer?.cancel();
|
||||||
|
if (!_isCounterVisible) {
|
||||||
|
setState(() => _isCounterVisible = true);
|
||||||
|
}
|
||||||
|
} else if (notification is ScrollEndNotification &&
|
||||||
|
_isCounterVisible &&
|
||||||
|
_isCurrentSlideVideo) {
|
||||||
|
_scheduleCounterHideOnVideo();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _imageSlide(String itemUrl, int itemIndex, double height) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => widget.onOpenGallery(_galleryUrls, itemIndex),
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
width: MediaQuery.sizeOf(context).width,
|
||||||
|
height: height,
|
||||||
|
imageUrl: itemUrl,
|
||||||
|
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _videoSlide() {
|
||||||
|
return FeedVideoPlayer(
|
||||||
|
embedUrl: widget.videoEmbedUrl!,
|
||||||
|
videoUrlThumb: widget.videoUrlThumb,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _carousel({
|
||||||
|
required int itemCount,
|
||||||
|
required NullableIndexedWidgetBuilder itemBuilder,
|
||||||
|
required double height,
|
||||||
|
}) {
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
NotificationListener<ScrollNotification>(
|
||||||
|
onNotification: _handleCarouselScroll,
|
||||||
|
child: CarouselSlider.builder(
|
||||||
|
itemCount: itemCount,
|
||||||
|
itemBuilder: (context, itemIndex, _) =>
|
||||||
|
itemBuilder(context, itemIndex) ?? const SizedBox.shrink(),
|
||||||
|
options: CarouselOptions(
|
||||||
|
viewportFraction: 1,
|
||||||
|
height: height,
|
||||||
|
enableInfiniteScroll: false,
|
||||||
|
onPageChanged: (index, _) => widget.onGalleryChanged(index),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (itemCount > 1)
|
||||||
|
Positioned(
|
||||||
|
top: 12,
|
||||||
|
right: 12,
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
opacity: _shouldShowCounter ? 1 : 0,
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
child: _GalleryPhotoCounter(
|
||||||
|
currentIndex: widget.currentGalleryIndex,
|
||||||
|
total: itemCount,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final gallery = _gallery;
|
||||||
|
final width = MediaQuery.sizeOf(context).width;
|
||||||
|
final mediaHeight = _heightForOrientation(_postOrientation, width);
|
||||||
|
|
||||||
|
if (gallery.length > 1 || (gallery.length == 1 && _hasVideo)) {
|
||||||
|
final itemCount = _slideCount;
|
||||||
|
return _carousel(
|
||||||
|
itemCount: itemCount,
|
||||||
|
height: mediaHeight,
|
||||||
|
itemBuilder: (context, itemIndex) {
|
||||||
|
if (itemIndex < gallery.length) {
|
||||||
|
return _imageSlide(gallery[itemIndex].url, itemIndex, mediaHeight);
|
||||||
|
}
|
||||||
|
if (_hasVideo) {
|
||||||
|
return _videoSlide();
|
||||||
|
}
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gallery.length == 1) {
|
||||||
|
return _imageSlide(gallery.first.url, 0, mediaHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_hasVideo) {
|
||||||
|
return SizedBox(height: mediaHeight, width: width, child: _videoSlide());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (widget.imageUrl.isNotEmpty) {
|
||||||
|
final image = CachedNetworkImage(
|
||||||
|
fit: BoxFit.fitWidth,
|
||||||
|
width: width,
|
||||||
|
imageUrl: widget.imageUrl,
|
||||||
|
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
||||||
|
);
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => widget.onOpenGallery([widget.imageUrl], 0),
|
||||||
|
child: widget.heroTag == null
|
||||||
|
? image
|
||||||
|
: Hero(tag: widget.heroTag!, child: image),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GalleryPhotoCounter extends StatelessWidget {
|
||||||
|
const _GalleryPhotoCounter({required this.currentIndex, required this.total});
|
||||||
|
|
||||||
|
final int currentIndex;
|
||||||
|
final int total;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.5),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.photo_library_outlined,
|
||||||
|
size: 14,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'${currentIndex + 1} / $total',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
height: 1.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,3 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
|
||||||
import 'package:carousel_slider_plus/carousel_slider_plus.dart';
|
|
||||||
import 'package:dismissible_page/dismissible_page.dart';
|
import 'package:dismissible_page/dismissible_page.dart';
|
||||||
import 'package:eatvio_app/core/app_config.dart';
|
import 'package:eatvio_app/core/app_config.dart';
|
||||||
import 'package:eatvio_app/core/enums/complained_content.dart';
|
import 'package:eatvio_app/core/enums/complained_content.dart';
|
||||||
@@ -15,7 +11,7 @@ import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.da
|
|||||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
||||||
import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart';
|
import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart';
|
||||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
||||||
import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_player_widget.dart';
|
import 'package:eatvio_app/features/feed/presentation/widgets/blog_post_media_content.dart';
|
||||||
import 'package:eatvio_app/features/feed/presentation/widgets/subscribe_button_widget.dart';
|
import 'package:eatvio_app/features/feed/presentation/widgets/subscribe_button_widget.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
@@ -55,14 +51,18 @@ class _BlogPostWidgetState extends State<BlogPostWidget> {
|
|||||||
totalLikes = post.totalLikes;
|
totalLikes = post.totalLikes;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (oldWidget.post.wasLikedByMe != post.wasLikedByMe || oldWidget.post.totalLikes != post.totalLikes) {
|
if (oldWidget.post.wasLikedByMe != post.wasLikedByMe ||
|
||||||
|
oldWidget.post.totalLikes != post.totalLikes) {
|
||||||
isLiked = post.wasLikedByMe;
|
isLiked = post.wasLikedByMe;
|
||||||
totalLikes = post.totalLikes;
|
totalLikes = post.totalLikes;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _openGallery(List<String> imageUrls, int index) {
|
void _openGallery(List<String> imageUrls, int index, {Object? heroTag}) {
|
||||||
context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: imageUrls, index: index));
|
context.pushTransparentRoute(
|
||||||
|
rootNavigator: true,
|
||||||
|
PhotoViewScreen(imageUrls: imageUrls, index: index, heroTag: heroTag),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _toggleLike() async {
|
Future<void> _toggleLike() async {
|
||||||
@@ -82,12 +82,12 @@ class _BlogPostWidgetState extends State<BlogPostWidget> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final imageUrl = PostFeedMapper.resolveMediaUrl(post.image);
|
final imageUrl = PostFeedMapper.resolveMediaUrl(post.image);
|
||||||
final galleryUrls = post.images.map((e) => PostFeedMapper.resolveMediaUrl(e.url)).where((e) => e.isNotEmpty).toList();
|
|
||||||
final embedUrl = post.videoEmbedUrl?.trim();
|
final embedUrl = post.videoEmbedUrl?.trim();
|
||||||
|
|
||||||
return InkWell(
|
return InkWell(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id),
|
onTap: () =>
|
||||||
|
GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id),
|
||||||
child: Material(
|
child: Material(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
@@ -98,13 +98,22 @@ class _BlogPostWidgetState extends State<BlogPostWidget> {
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_BlogPostHeader(post: post),
|
_BlogPostHeader(post: post),
|
||||||
_BlogPostTextContent(post: post),
|
_BlogPostTextContent(post: post),
|
||||||
_BlogPostMediaContent(
|
BlogPostMediaContent(
|
||||||
galleryUrls: galleryUrls,
|
galleryImages: post.images,
|
||||||
imageUrl: imageUrl,
|
imageUrl: imageUrl,
|
||||||
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null,
|
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty
|
||||||
|
? embedUrl
|
||||||
|
: null,
|
||||||
videoUrlThumb: post.videoUrlThumb,
|
videoUrlThumb: post.videoUrlThumb,
|
||||||
currentGalleryIndex: currentGalleryIndex,
|
currentGalleryIndex: currentGalleryIndex,
|
||||||
onOpenGallery: _openGallery,
|
heroTag: 'feed-blog-post-${post.id}-cover',
|
||||||
|
onOpenGallery: (urls, index) => _openGallery(
|
||||||
|
urls,
|
||||||
|
index,
|
||||||
|
heroTag: urls.length == 1
|
||||||
|
? 'feed-blog-post-${post.id}-cover'
|
||||||
|
: null,
|
||||||
|
),
|
||||||
onGalleryChanged: (index) {
|
onGalleryChanged: (index) {
|
||||||
setState(() {
|
setState(() {
|
||||||
currentGalleryIndex = index;
|
currentGalleryIndex = index;
|
||||||
@@ -112,7 +121,12 @@ class _BlogPostWidgetState extends State<BlogPostWidget> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_BlogPostActionsRow(post: post, isLiked: isLiked, totalLikes: totalLikes, onToggleLike: _toggleLike),
|
_BlogPostActionsRow(
|
||||||
|
post: post,
|
||||||
|
isLiked: isLiked,
|
||||||
|
totalLikes: totalLikes,
|
||||||
|
onToggleLike: _toggleLike,
|
||||||
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -134,11 +148,19 @@ class _BlogPostHeader extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () => GoRouter.of(context).pushNamed('profileScreen', extra: post.author.authorSlug),
|
onTap: () => GoRouter.of(
|
||||||
|
context,
|
||||||
|
).pushNamed('profileScreen', extra: post.author.authorSlug),
|
||||||
child: CircleAvatar(
|
child: CircleAvatar(
|
||||||
radius: 20,
|
radius: 20,
|
||||||
backgroundColor: Colors.black12,
|
backgroundColor: Colors.black12,
|
||||||
foregroundImage: post.author.authorProfileIcon.isNotEmpty ? NetworkImage(PostFeedMapper.resolveMediaUrl(post.author.authorProfileIcon)) : null,
|
foregroundImage: post.author.authorProfileIcon.isNotEmpty
|
||||||
|
? NetworkImage(
|
||||||
|
PostFeedMapper.resolveMediaUrl(
|
||||||
|
post.author.authorProfileIcon,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -150,17 +172,27 @@ class _BlogPostHeader extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
post.author.authorName,
|
post.author.authorName,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(height: 1, color: Colors.black87, fontWeight: FontWeight.w500, fontSize: 16),
|
style: const TextStyle(
|
||||||
|
height: 1,
|
||||||
|
color: Colors.black87,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'${calculateDifference(post.createdAt)} в ${post.createdAt.hour.toString().padLeft(2, '0')}:${post.createdAt.minute.toString().padLeft(2, '0')}',
|
'${calculateDifference(post.createdAt)} в ${post.createdAt.hour.toString().padLeft(2, '0')}:${post.createdAt.minute.toString().padLeft(2, '0')}',
|
||||||
style: const TextStyle(height: 1, color: Colors.black45, fontSize: 14),
|
style: const TextStyle(
|
||||||
|
height: 1,
|
||||||
|
color: Colors.black45,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (!(post.author.isEntitySubscribedByMe ?? false) && !post.author.authorIsMe)
|
if (!(post.author.isEntitySubscribedByMe ?? false) &&
|
||||||
|
!post.author.authorIsMe)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(right: 8),
|
padding: const EdgeInsets.only(right: 8),
|
||||||
child: SubscribeButtonWidget(slug: post.author.authorSlug),
|
child: SubscribeButtonWidget(slug: post.author.authorSlug),
|
||||||
@@ -195,213 +227,51 @@ class _BlogPostTextContent extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
child: Text(
|
child: Text(
|
||||||
post.title,
|
post.title,
|
||||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.black87),
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (post.title.isEmpty) const SizedBox(height: 8),
|
if (post.title.isEmpty) const SizedBox(height: 8),
|
||||||
if (post.title.isNotEmpty && post.description.isNotEmpty) const SizedBox(height: 8),
|
if (post.title.isNotEmpty && post.description.isNotEmpty)
|
||||||
|
const SizedBox(height: 8),
|
||||||
if (post.description.isNotEmpty)
|
if (post.description.isNotEmpty)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
child: MarkdownHtmlWidget(markdownText: _truncateDescription(post.description)),
|
child: MarkdownHtmlWidget(
|
||||||
|
markdownText: _truncateDescription(post.description),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (post.description.length > 200)
|
if (post.description.length > 200)
|
||||||
const Padding(
|
const Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 12),
|
padding: EdgeInsets.symmetric(horizontal: 12),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Показать ещё',
|
'Показать ещё',
|
||||||
style: TextStyle(height: 1, color: Color(0xFF8B8B8B), fontSize: 14, fontWeight: FontWeight.w600),
|
style: TextStyle(
|
||||||
|
height: 1,
|
||||||
|
color: Color(0xFF8B8B8B),
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (post.title.isNotEmpty || post.description.isNotEmpty) const SizedBox(height: 8),
|
if (post.title.isNotEmpty || post.description.isNotEmpty)
|
||||||
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _BlogPostMediaContent extends StatefulWidget {
|
|
||||||
const _BlogPostMediaContent({
|
|
||||||
required this.galleryUrls,
|
|
||||||
required this.imageUrl,
|
|
||||||
required this.videoEmbedUrl,
|
|
||||||
required this.currentGalleryIndex,
|
|
||||||
required this.onOpenGallery,
|
|
||||||
required this.onGalleryChanged,
|
|
||||||
required this.videoUrlThumb,
|
|
||||||
});
|
|
||||||
|
|
||||||
final List<String> galleryUrls;
|
|
||||||
final String imageUrl;
|
|
||||||
final String? videoEmbedUrl;
|
|
||||||
final String? videoUrlThumb;
|
|
||||||
final int currentGalleryIndex;
|
|
||||||
final void Function(List<String> urls, int index) onOpenGallery;
|
|
||||||
final ValueChanged<int> onGalleryChanged;
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<_BlogPostMediaContent> createState() => _BlogPostMediaContentState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _BlogPostMediaContentState extends State<_BlogPostMediaContent> {
|
|
||||||
static const _counterDisplayDuration = Duration(seconds: 1);
|
|
||||||
|
|
||||||
bool _isCounterVisible = false;
|
|
||||||
Timer? _counterHideTimer;
|
|
||||||
|
|
||||||
bool get _isCurrentSlideVideo =>
|
|
||||||
widget.videoEmbedUrl != null && widget.currentGalleryIndex == widget.galleryUrls.length;
|
|
||||||
|
|
||||||
bool get _shouldShowCounter => !_isCurrentSlideVideo || _isCounterVisible;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_counterHideTimer?.cancel();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void didUpdateWidget(covariant _BlogPostMediaContent oldWidget) {
|
|
||||||
super.didUpdateWidget(oldWidget);
|
|
||||||
if (oldWidget.currentGalleryIndex != widget.currentGalleryIndex) {
|
|
||||||
_scheduleCounterHideOnVideo();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _scheduleCounterHideOnVideo() {
|
|
||||||
_counterHideTimer?.cancel();
|
|
||||||
if (!_isCurrentSlideVideo) return;
|
|
||||||
|
|
||||||
_counterHideTimer = Timer(_counterDisplayDuration, () {
|
|
||||||
if (mounted && _isCurrentSlideVideo) {
|
|
||||||
setState(() => _isCounterVisible = false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _handleCarouselScroll(ScrollNotification notification) {
|
|
||||||
if (notification is ScrollStartNotification || notification is ScrollUpdateNotification) {
|
|
||||||
_counterHideTimer?.cancel();
|
|
||||||
if (!_isCounterVisible) {
|
|
||||||
setState(() => _isCounterVisible = true);
|
|
||||||
}
|
|
||||||
} else if (notification is ScrollEndNotification && _isCounterVisible && _isCurrentSlideVideo) {
|
|
||||||
_scheduleCounterHideOnVideo();
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final hasVideo = widget.videoEmbedUrl != null;
|
|
||||||
final mediaContentCount = hasVideo ? widget.galleryUrls.length + 1 : widget.galleryUrls.length;
|
|
||||||
final showCarousel = mediaContentCount > 1;
|
|
||||||
return Column(
|
|
||||||
children: [
|
|
||||||
if (showCarousel) ...[
|
|
||||||
Stack(
|
|
||||||
children: [
|
|
||||||
NotificationListener<ScrollNotification>(
|
|
||||||
onNotification: _handleCarouselScroll,
|
|
||||||
child: CarouselSlider.builder(
|
|
||||||
itemCount: mediaContentCount,
|
|
||||||
itemBuilder: (context, itemIndex, _) {
|
|
||||||
if (itemIndex == widget.galleryUrls.length) {
|
|
||||||
if (widget.videoEmbedUrl != null) {
|
|
||||||
return FeedVideoPlayer(embedUrl: widget.videoEmbedUrl!, videoUrlThumb: widget.videoUrlThumb);
|
|
||||||
}
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
final itemUrl = widget.galleryUrls[itemIndex];
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: () => widget.onOpenGallery(widget.galleryUrls, itemIndex),
|
|
||||||
child: CachedNetworkImage(
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
width: MediaQuery.of(context).size.width,
|
|
||||||
imageUrl: itemUrl,
|
|
||||||
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
options: CarouselOptions(
|
|
||||||
viewportFraction: 1,
|
|
||||||
height: 260,
|
|
||||||
enableInfiniteScroll: false,
|
|
||||||
onPageChanged: (index, _) => widget.onGalleryChanged(index),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Positioned(
|
|
||||||
top: 12,
|
|
||||||
right: 12,
|
|
||||||
child: IgnorePointer(
|
|
||||||
child: AnimatedOpacity(
|
|
||||||
opacity: _shouldShowCounter ? 1 : 0,
|
|
||||||
duration: const Duration(milliseconds: 150),
|
|
||||||
child: _GalleryPhotoCounter(currentIndex: widget.currentGalleryIndex, total: mediaContentCount),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
] else if (widget.galleryUrls.length == 1) ...[
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () => widget.onOpenGallery(widget.galleryUrls, 0),
|
|
||||||
child: CachedNetworkImage(
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
width: MediaQuery.of(context).size.width,
|
|
||||||
height: 260,
|
|
||||||
imageUrl: widget.galleryUrls.first,
|
|
||||||
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
] else if (widget.imageUrl.isNotEmpty) ...[
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () => widget.onOpenGallery([widget.imageUrl], 0),
|
|
||||||
child: Hero(
|
|
||||||
tag: widget.imageUrl,
|
|
||||||
child: CachedNetworkImage(
|
|
||||||
fit: BoxFit.fitWidth,
|
|
||||||
width: MediaQuery.of(context).size.width,
|
|
||||||
imageUrl: widget.imageUrl,
|
|
||||||
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _GalleryPhotoCounter extends StatelessWidget {
|
|
||||||
const _GalleryPhotoCounter({required this.currentIndex, required this.total});
|
|
||||||
|
|
||||||
final int currentIndex;
|
|
||||||
final int total;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
||||||
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(999)),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.photo_library_outlined, size: 14, color: Colors.white),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
'${currentIndex + 1} / $total',
|
|
||||||
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500, height: 1.2),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _BlogPostActionsRow extends StatelessWidget {
|
class _BlogPostActionsRow extends StatelessWidget {
|
||||||
const _BlogPostActionsRow({required this.post, required this.isLiked, required this.totalLikes, required this.onToggleLike});
|
const _BlogPostActionsRow({
|
||||||
|
required this.post,
|
||||||
|
required this.isLiked,
|
||||||
|
required this.totalLikes,
|
||||||
|
required this.onToggleLike,
|
||||||
|
});
|
||||||
|
|
||||||
final FeedBlogPostModel post;
|
final FeedBlogPostModel post;
|
||||||
final bool isLiked;
|
final bool isLiked;
|
||||||
@@ -415,7 +285,9 @@ class _BlogPostActionsRow extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
FeedIconButton(
|
FeedIconButton(
|
||||||
icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
icon: isLiked
|
||||||
|
? Icons.favorite_rounded
|
||||||
|
: Icons.favorite_border_rounded,
|
||||||
text: totalLikes.toString(),
|
text: totalLikes.toString(),
|
||||||
iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
|
iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
|
||||||
backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null,
|
backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null,
|
||||||
@@ -424,19 +296,34 @@ class _BlogPostActionsRow extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
FeedIconButton(
|
FeedIconButton(
|
||||||
svgIcon: SvgPicture.asset('assets/icons/comment.svg', height: 20, width: 20, colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn)),
|
svgIcon: SvgPicture.asset(
|
||||||
|
'assets/icons/comment.svg',
|
||||||
|
height: 20,
|
||||||
|
width: 20,
|
||||||
|
colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn),
|
||||||
|
),
|
||||||
text: post.totalComments.toString(),
|
text: post.totalComments.toString(),
|
||||||
onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id),
|
onTap: () => GoRouter.of(
|
||||||
|
context,
|
||||||
|
).pushNamed('blogPostScreen', extra: post.id),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
FeedIconButton(
|
FeedIconButton(
|
||||||
svgIcon: SvgPicture.asset(height: 15, width: 12, 'assets/icons/repost.svg', colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn)),
|
svgIcon: SvgPicture.asset(
|
||||||
|
height: 15,
|
||||||
|
width: 12,
|
||||||
|
'assets/icons/repost.svg',
|
||||||
|
colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn),
|
||||||
|
),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
final box = context.findRenderObject() as RenderBox?;
|
final box = context.findRenderObject() as RenderBox?;
|
||||||
SharePlus.instance.share(
|
SharePlus.instance.share(
|
||||||
ShareParams(
|
ShareParams(
|
||||||
uri: Uri.parse('${sl<AppConfig>().baseUrl}/ru/blog_post/${post.id}'),
|
uri: Uri.parse(
|
||||||
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
|
'${sl<AppConfig>().baseUrl}/ru/blog_post/${post.id}',
|
||||||
|
),
|
||||||
|
sharePositionOrigin:
|
||||||
|
box!.localToGlobal(Offset.zero) & box.size,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -446,7 +333,11 @@ class _BlogPostActionsRow extends StatelessWidget {
|
|||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
post.totalViews.toString(),
|
post.totalViews.toString(),
|
||||||
style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400),
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: appTheme.gray500,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -169,9 +169,8 @@ class _FeedVideoPlayerState extends State<FeedVideoPlayer> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final player = SizedBox(
|
final player = ColoredBox(
|
||||||
height: 220,
|
color: Colors.black,
|
||||||
width: MediaQuery.sizeOf(context).width,
|
|
||||||
child: Stack(
|
child: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -22,10 +22,13 @@ class FeedVideoPreviewPlaceholder extends StatelessWidget {
|
|||||||
return Stack(
|
return Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
|
const ColoredBox(color: Colors.black),
|
||||||
if (previewUrl != null && previewUrl!.isNotEmpty)
|
if (previewUrl != null && previewUrl!.isNotEmpty)
|
||||||
CachedNetworkImage(
|
CachedNetworkImage(
|
||||||
imageUrl: previewUrl!,
|
imageUrl: previewUrl!,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.contain,
|
||||||
|
width: double.infinity,
|
||||||
|
height: double.infinity,
|
||||||
errorWidget: (_, _, _) => const ColoredBox(color: Color(0xFF2C2C2C)),
|
errorWidget: (_, _, _) => const ColoredBox(color: Color(0xFF2C2C2C)),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -116,9 +116,7 @@ class _PlanWidgetState extends State<PlanWidget> {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: MarkdownHtmlWidget(
|
child: MarkdownHtmlWidget(
|
||||||
markdownText: widget.plan.details.length > 200
|
markdownText: widget.plan.details.length > 200 ? '${widget.plan.details.substring(0, 200)}...' : widget.plan.details,
|
||||||
? '${widget.plan.details.substring(0, 200)}...'
|
|
||||||
: widget.plan.details,
|
|
||||||
textColor: appTheme.black,
|
textColor: appTheme.black,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ abstract class FeedService {
|
|||||||
@Query('cursor') String? cursor,
|
@Query('cursor') String? cursor,
|
||||||
});
|
});
|
||||||
|
|
||||||
@GET('/api/blogPosts/show/{postId}')
|
@GET('/api/v2/blogPosts/show/{postId}')
|
||||||
Future<FeedBlogPostModel> getBlogPost({@Path('postId') required int postId});
|
Future<FeedBlogPostModel> getBlogPost({@Path('postId') required int postId});
|
||||||
|
|
||||||
@GET('/api/recipes/index')
|
@GET('/api/recipes/index')
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
|
|||||||
import 'package:eatvio_app/core/app_config.dart';
|
import 'package:eatvio_app/core/app_config.dart';
|
||||||
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
|
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
|
||||||
import 'package:eatvio_app/core/const.dart';
|
import 'package:eatvio_app/core/const.dart';
|
||||||
|
import 'package:eatvio_app/core/firebase_api.dart';
|
||||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||||
import 'package:eatvio_app/core/token_storage.dart';
|
import 'package:eatvio_app/core/token_storage.dart';
|
||||||
import 'package:eatvio_app/core/utils/image_constant.dart';
|
import 'package:eatvio_app/core/utils/image_constant.dart';
|
||||||
@@ -15,6 +16,7 @@ import 'package:eatvio_app/features/consultant_rating/presentation/consultant_ra
|
|||||||
import 'package:eatvio_app/features/finances_and_consultations_archive/presentation/consultant_finances_screen.dart';
|
import 'package:eatvio_app/features/finances_and_consultations_archive/presentation/consultant_finances_screen.dart';
|
||||||
import 'package:eatvio_app/features/finances_and_consultations_archive/presentation/user_purchases_screen.dart';
|
import 'package:eatvio_app/features/finances_and_consultations_archive/presentation/user_purchases_screen.dart';
|
||||||
import 'package:eatvio_app/features/menu/presentation/bloc/menu_cubit/menu_cubit.dart';
|
import 'package:eatvio_app/features/menu/presentation/bloc/menu_cubit/menu_cubit.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
|
||||||
import 'package:eatvio_app/features/menu/presentation/bloc/menu_cubit/menu_state.dart';
|
import 'package:eatvio_app/features/menu/presentation/bloc/menu_cubit/menu_state.dart';
|
||||||
import 'package:eatvio_app/features/my_diets/presentation/my_diets_screen.dart';
|
import 'package:eatvio_app/features/my_diets/presentation/my_diets_screen.dart';
|
||||||
import 'package:eatvio_app/features/my_plans/presentation/my_plans_screen.dart';
|
import 'package:eatvio_app/features/my_plans/presentation/my_plans_screen.dart';
|
||||||
@@ -165,6 +167,13 @@ class MenuScreen extends StatelessWidget {
|
|||||||
} catch (e, s) {
|
} catch (e, s) {
|
||||||
await sl<ErrorReporter>().report(e, s, origin: 'MenuScreen');
|
await sl<ErrorReporter>().report(e, s, origin: 'MenuScreen');
|
||||||
} finally {
|
} finally {
|
||||||
|
try {
|
||||||
|
// Needs Authorization header → must run before removing token.
|
||||||
|
await sl<FirebaseApi>().unregisterPushTokens();
|
||||||
|
} catch (e, s) {
|
||||||
|
await sl<ErrorReporter>().report(e, s, origin: 'MenuScreen');
|
||||||
|
}
|
||||||
|
await sl<VideoCallSessionCubit>().endCall();
|
||||||
await sl<WsManager>().clear();
|
await sl<WsManager>().clear();
|
||||||
await TokenStorage.removeTokenInStorage();
|
await TokenStorage.removeTokenInStorage();
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
|
|||||||
15
lib/features/payment/models/promo_applied_list_response.dart
Normal file
15
lib/features/payment/models/promo_applied_list_response.dart
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import 'package:eatvio_app/features/payment/models/promo_code_model.dart';
|
||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
|
part 'promo_applied_list_response.freezed.dart';
|
||||||
|
part 'promo_applied_list_response.g.dart';
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class PromoAppliedListResponse with _$PromoAppliedListResponse {
|
||||||
|
const factory PromoAppliedListResponse({
|
||||||
|
@JsonKey(name: 'promo_codes') required List<PromoCodeModel> promoCodes,
|
||||||
|
}) = _PromoAppliedListResponse;
|
||||||
|
|
||||||
|
factory PromoAppliedListResponse.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$PromoAppliedListResponseFromJson(json);
|
||||||
|
}
|
||||||
21
lib/features/payment/models/promo_apply_response.dart
Normal file
21
lib/features/payment/models/promo_apply_response.dart
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import 'package:eatvio_app/features/payment/models/promo_code_model.dart';
|
||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
|
part 'promo_apply_response.freezed.dart';
|
||||||
|
part 'promo_apply_response.g.dart';
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class PromoApplyResponse with _$PromoApplyResponse {
|
||||||
|
const factory PromoApplyResponse({
|
||||||
|
required bool success,
|
||||||
|
String? error,
|
||||||
|
@JsonKey(name: 'promo_code') PromoCodeModel? promoCode,
|
||||||
|
@JsonKey(name: 'discount_percent') int? discountPercent,
|
||||||
|
@JsonKey(name: 'discount_amount_rub') double? discountAmountRub,
|
||||||
|
@JsonKey(name: 'original_amount_rub') double? originalAmountRub,
|
||||||
|
@JsonKey(name: 'final_amount_rub') double? finalAmountRub,
|
||||||
|
}) = _PromoApplyResponse;
|
||||||
|
|
||||||
|
factory PromoApplyResponse.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$PromoApplyResponseFromJson(json);
|
||||||
|
}
|
||||||
22
lib/features/payment/models/promo_code_model.dart
Normal file
22
lib/features/payment/models/promo_code_model.dart
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
|
part 'promo_code_model.freezed.dart';
|
||||||
|
part 'promo_code_model.g.dart';
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class PromoCodeModel with _$PromoCodeModel {
|
||||||
|
const factory PromoCodeModel({
|
||||||
|
required int id,
|
||||||
|
@JsonKey(name: 'chosen_plan_id') required int chosenPlanId,
|
||||||
|
@JsonKey(name: 'promo_code') required String promoCode,
|
||||||
|
@JsonKey(name: 'discount_percent') required int discountPercent,
|
||||||
|
@JsonKey(name: 'discount_amount_rub') required double discountAmountRub,
|
||||||
|
required String state,
|
||||||
|
@JsonKey(name: 'applied_at') String? appliedAt,
|
||||||
|
@JsonKey(name: 'confirmed_at') String? confirmedAt,
|
||||||
|
@JsonKey(name: 'cancelled_at') String? cancelledAt,
|
||||||
|
}) = _PromoCodeModel;
|
||||||
|
|
||||||
|
factory PromoCodeModel.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$PromoCodeModelFromJson(json);
|
||||||
|
}
|
||||||
15
lib/features/payment/models/promo_remove_response.dart
Normal file
15
lib/features/payment/models/promo_remove_response.dart
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
|
part 'promo_remove_response.freezed.dart';
|
||||||
|
part 'promo_remove_response.g.dart';
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class PromoRemoveResponse with _$PromoRemoveResponse {
|
||||||
|
const factory PromoRemoveResponse({
|
||||||
|
required bool success,
|
||||||
|
String? error,
|
||||||
|
}) = _PromoRemoveResponse;
|
||||||
|
|
||||||
|
factory PromoRemoveResponse.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$PromoRemoveResponseFromJson(json);
|
||||||
|
}
|
||||||
20
lib/features/payment/models/promo_validate_response.dart
Normal file
20
lib/features/payment/models/promo_validate_response.dart
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
|
part 'promo_validate_response.freezed.dart';
|
||||||
|
part 'promo_validate_response.g.dart';
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class PromoValidateResponse with _$PromoValidateResponse {
|
||||||
|
const factory PromoValidateResponse({
|
||||||
|
required bool valid,
|
||||||
|
String? error,
|
||||||
|
@JsonKey(name: 'promo_code') String? promoCode,
|
||||||
|
@JsonKey(name: 'discount_percent') int? discountPercent,
|
||||||
|
@JsonKey(name: 'discount_amount_rub') double? discountAmountRub,
|
||||||
|
@JsonKey(name: 'original_amount_rub') double? originalAmountRub,
|
||||||
|
@JsonKey(name: 'final_amount_rub') double? finalAmountRub,
|
||||||
|
}) = _PromoValidateResponse;
|
||||||
|
|
||||||
|
factory PromoValidateResponse.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$PromoValidateResponseFromJson(json);
|
||||||
|
}
|
||||||
@@ -1,22 +1,73 @@
|
|||||||
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
|
import 'package:eatvio_app/core/utils/toasts.dart';
|
||||||
|
import 'package:eatvio_app/features/payment/models/promo_apply_response.dart';
|
||||||
|
import 'package:eatvio_app/features/payment/models/promo_code_model.dart';
|
||||||
|
import 'package:eatvio_app/features/payment/models/promo_validate_response.dart';
|
||||||
import 'package:eatvio_app/features/payment/presentation/widgets/promo_code_gate_bottom_sheet.dart';
|
import 'package:eatvio_app/features/payment/presentation/widgets/promo_code_gate_bottom_sheet.dart';
|
||||||
import 'package:eatvio_app/features/payment/presentation/widgets/promo_code_modal.dart';
|
import 'package:eatvio_app/features/payment/presentation/widgets/promo_code_modal.dart';
|
||||||
import 'package:eatvio_app/features/payment/presentation/widgets/sbp_banks_bottom_sheet.dart';
|
import 'package:eatvio_app/features/payment/presentation/widgets/sbp_banks_bottom_sheet.dart';
|
||||||
import 'package:eatvio_app/generated/l10n.dart';
|
import 'package:eatvio_app/features/payment/repositories/payment_repository.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// Оркестрирует флоу оплаты консультации:
|
/// Оркестрирует флоу оплаты консультации:
|
||||||
/// гейт «У вас есть промокод?» → (Да → форма промокода) → SbpBanks.
|
/// applied rehydrate → (или гейт → форма) → apply при необходимости → SbpBanks.
|
||||||
Future<void> showConsultationPaymentFlow(
|
Future<void> showConsultationPaymentFlow(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required int planId,
|
required int planId,
|
||||||
Future<bool?> Function(BuildContext)? showGate,
|
Future<bool?> Function(BuildContext)? showGate,
|
||||||
Future<PromoCodeResult?> Function(BuildContext, {Future<PromoValidation> Function(String)? onValidate})? showForm,
|
Future<PromoCodeResult?> Function(
|
||||||
|
BuildContext, {
|
||||||
|
Future<PromoValidation> Function(String)? onValidate,
|
||||||
|
Future<bool> Function()? onRemove,
|
||||||
|
PromoCodeModel? initialPromo,
|
||||||
|
})? showForm,
|
||||||
Future<void> Function(BuildContext, {required int planId})? showSbp,
|
Future<void> Function(BuildContext, {required int planId})? showSbp,
|
||||||
|
Future<PromoValidateResponse> Function({
|
||||||
|
required int planId,
|
||||||
|
required String promoCode,
|
||||||
|
})? validatePromo,
|
||||||
|
Future<PromoApplyResponse> Function({
|
||||||
|
required int planId,
|
||||||
|
required String promoCode,
|
||||||
|
})? applyPromo,
|
||||||
|
Future<PromoCodeModel?> Function(int planId)? listApplied,
|
||||||
|
Future<bool> Function(int planId)? removePromo,
|
||||||
}) async {
|
}) async {
|
||||||
final gate = showGate ?? _showGate;
|
final gate = showGate ?? _showGate;
|
||||||
final form = showForm ?? _showForm;
|
final form = showForm ?? _showForm;
|
||||||
final sbp = showSbp ?? _showSbp;
|
final sbp = showSbp ?? _showSbp;
|
||||||
final s = S.of(context);
|
final validate = validatePromo ?? _defaultValidatePromo;
|
||||||
|
final apply = applyPromo ?? _defaultApplyPromo;
|
||||||
|
final findApplied = listApplied ?? _defaultFindApplied;
|
||||||
|
final remove = removePromo ?? _defaultRemovePromo;
|
||||||
|
|
||||||
|
final existing = await findApplied(planId);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
if (existing != null) {
|
||||||
|
final formResult = await form(
|
||||||
|
context,
|
||||||
|
initialPromo: existing,
|
||||||
|
onRemove: () => remove(planId),
|
||||||
|
onValidate: (code) => _mapValidateResponse(
|
||||||
|
validate(planId: planId, promoCode: code),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (formResult == null || !context.mounted) return;
|
||||||
|
|
||||||
|
if (formResult.hasPromoCode && !formResult.alreadyApplied) {
|
||||||
|
final applied = await _tryApplyPromo(
|
||||||
|
apply: apply,
|
||||||
|
planId: planId,
|
||||||
|
promoCode: formResult.code!,
|
||||||
|
);
|
||||||
|
if (!applied || !context.mounted) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
await sbp(context, planId: planId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final gateChoice = await gate(context);
|
final gateChoice = await gate(context);
|
||||||
if (gateChoice == null || !context.mounted) return;
|
if (gateChoice == null || !context.mounted) return;
|
||||||
@@ -24,9 +75,20 @@ Future<void> showConsultationPaymentFlow(
|
|||||||
if (gateChoice) {
|
if (gateChoice) {
|
||||||
final formResult = await form(
|
final formResult = await form(
|
||||||
context,
|
context,
|
||||||
onValidate: (_) async => PromoValidation.failure(s.promo_invalid),
|
onValidate: (code) => _mapValidateResponse(
|
||||||
|
validate(planId: planId, promoCode: code),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
if (formResult == null || !context.mounted) return;
|
if (formResult == null || !context.mounted) return;
|
||||||
|
|
||||||
|
if (formResult.hasPromoCode && !formResult.alreadyApplied) {
|
||||||
|
final applied = await _tryApplyPromo(
|
||||||
|
apply: apply,
|
||||||
|
planId: planId,
|
||||||
|
promoCode: formResult.code!,
|
||||||
|
);
|
||||||
|
if (!applied || !context.mounted) return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
@@ -40,10 +102,96 @@ Future<bool?> _showGate(BuildContext context) {
|
|||||||
Future<PromoCodeResult?> _showForm(
|
Future<PromoCodeResult?> _showForm(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
Future<PromoValidation> Function(String)? onValidate,
|
Future<PromoValidation> Function(String)? onValidate,
|
||||||
|
Future<bool> Function()? onRemove,
|
||||||
|
PromoCodeModel? initialPromo,
|
||||||
}) {
|
}) {
|
||||||
return ItvioPromoCodeModal.show(context, onValidate: onValidate);
|
return ItvioPromoCodeModal.show(
|
||||||
|
context,
|
||||||
|
onValidate: onValidate,
|
||||||
|
onRemove: onRemove,
|
||||||
|
initialPromo: initialPromo,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _showSbp(BuildContext context, {required int planId}) {
|
Future<void> _showSbp(BuildContext context, {required int planId}) {
|
||||||
return showSbpBanksBottomSheet(context, planId: planId);
|
return showSbpBanksBottomSheet(context, planId: planId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<PromoValidateResponse> _defaultValidatePromo({
|
||||||
|
required int planId,
|
||||||
|
required String promoCode,
|
||||||
|
}) {
|
||||||
|
return sl<PaymentRepository>().validatePromoCode(
|
||||||
|
planId: planId,
|
||||||
|
promoCode: promoCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<PromoApplyResponse> _defaultApplyPromo({
|
||||||
|
required int planId,
|
||||||
|
required String promoCode,
|
||||||
|
}) {
|
||||||
|
return sl<PaymentRepository>().applyPromoCode(
|
||||||
|
planId: planId,
|
||||||
|
promoCode: promoCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<PromoCodeModel?> _defaultFindApplied(int planId) {
|
||||||
|
return sl<PaymentRepository>().findAppliedPromoForPlan(planId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _defaultRemovePromo(int planId) async {
|
||||||
|
try {
|
||||||
|
final response = await sl<PaymentRepository>().removePromoCode(planId: planId);
|
||||||
|
if (response.success) return true;
|
||||||
|
showErrorToast(response.error ?? 'Не удалось убрать промокод.');
|
||||||
|
return false;
|
||||||
|
} catch (_) {
|
||||||
|
showErrorToast('Не удалось убрать промокод. Попробуйте позже');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<PromoValidation> _mapValidateResponse(
|
||||||
|
Future<PromoValidateResponse> request,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
final response = await request;
|
||||||
|
if (!response.valid) {
|
||||||
|
return PromoValidation.failure(
|
||||||
|
response.error ?? 'Промокод невалиден.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return PromoValidation.success(
|
||||||
|
discountPercent: response.discountPercent,
|
||||||
|
discountAmountRub: response.discountAmountRub,
|
||||||
|
originalAmountRub: response.originalAmountRub,
|
||||||
|
finalAmountRub: response.finalAmountRub,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
return const PromoValidation.failure(
|
||||||
|
'Не удалось проверить промокод. Попробуйте позже',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _tryApplyPromo({
|
||||||
|
required Future<PromoApplyResponse> Function({
|
||||||
|
required int planId,
|
||||||
|
required String promoCode,
|
||||||
|
}) apply,
|
||||||
|
required int planId,
|
||||||
|
required String promoCode,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final response = await apply(planId: planId, promoCode: promoCode);
|
||||||
|
if (response.success) return true;
|
||||||
|
|
||||||
|
showErrorToast(response.error ?? 'Не удалось применить промокод.');
|
||||||
|
return false;
|
||||||
|
} catch (_) {
|
||||||
|
showErrorToast('Не удалось применить промокод. Попробуйте позже');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,45 +1,87 @@
|
|||||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||||
import 'package:eatvio_app/core/widgets/bottom_sheet_handler_bar.dart';
|
import 'package:eatvio_app/core/widgets/bottom_sheet_handler_bar.dart';
|
||||||
|
import 'package:eatvio_app/features/payment/models/promo_code_model.dart';
|
||||||
import 'package:eatvio_app/generated/l10n.dart';
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// Результат работы модалки промокода.
|
/// Результат работы модалки промокода.
|
||||||
class PromoCodeResult {
|
class PromoCodeResult {
|
||||||
const PromoCodeResult({this.code});
|
const PromoCodeResult({
|
||||||
|
this.code,
|
||||||
|
this.discountPercent,
|
||||||
|
this.discountAmountRub,
|
||||||
|
this.originalAmountRub,
|
||||||
|
this.finalAmountRub,
|
||||||
|
this.alreadyApplied = false,
|
||||||
|
});
|
||||||
|
|
||||||
/// Применённый промокод или null, если пользователь продолжил без него.
|
/// Применённый промокод или null, если пользователь продолжил без него.
|
||||||
final String? code;
|
final String? code;
|
||||||
|
final int? discountPercent;
|
||||||
|
final double? discountAmountRub;
|
||||||
|
final double? originalAmountRub;
|
||||||
|
final double? finalAmountRub;
|
||||||
|
|
||||||
|
/// true — промокод уже на сервере (rehydrate Continue), apply не нужен.
|
||||||
|
final bool alreadyApplied;
|
||||||
|
|
||||||
bool get hasPromoCode => code != null;
|
bool get hasPromoCode => code != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ответ проверки промокода.
|
/// Ответ проверки промокода.
|
||||||
class PromoValidation {
|
class PromoValidation {
|
||||||
const PromoValidation.success() : error = null;
|
const PromoValidation.success({
|
||||||
const PromoValidation.failure(this.error);
|
this.discountPercent,
|
||||||
|
this.discountAmountRub,
|
||||||
|
this.originalAmountRub,
|
||||||
|
this.finalAmountRub,
|
||||||
|
}) : error = null;
|
||||||
|
|
||||||
|
const PromoValidation.failure(this.error)
|
||||||
|
: discountPercent = null,
|
||||||
|
discountAmountRub = null,
|
||||||
|
originalAmountRub = null,
|
||||||
|
finalAmountRub = null;
|
||||||
|
|
||||||
final String? error;
|
final String? error;
|
||||||
|
final int? discountPercent;
|
||||||
|
final double? discountAmountRub;
|
||||||
|
final double? originalAmountRub;
|
||||||
|
final double? finalAmountRub;
|
||||||
|
|
||||||
bool get isValid => error == null;
|
bool get isValid => error == null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Модальное окно ввода промокода.
|
/// Модальное окно ввода / просмотра применённого промокода.
|
||||||
class ItvioPromoCodeModal extends StatefulWidget {
|
class ItvioPromoCodeModal extends StatefulWidget {
|
||||||
const ItvioPromoCodeModal({super.key, this.onValidate});
|
const ItvioPromoCodeModal({
|
||||||
|
super.key,
|
||||||
|
this.onValidate,
|
||||||
|
this.onRemove,
|
||||||
|
this.initialPromo,
|
||||||
|
});
|
||||||
|
|
||||||
final Future<PromoValidation> Function(String code)? onValidate;
|
final Future<PromoValidation> Function(String code)? onValidate;
|
||||||
|
final Future<bool> Function()? onRemove;
|
||||||
|
final PromoCodeModel? initialPromo;
|
||||||
|
|
||||||
static Future<PromoCodeResult?> show(
|
static Future<PromoCodeResult?> show(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
Future<PromoValidation> Function(String code)? onValidate,
|
Future<PromoValidation> Function(String code)? onValidate,
|
||||||
|
Future<bool> Function()? onRemove,
|
||||||
|
PromoCodeModel? initialPromo,
|
||||||
}) {
|
}) {
|
||||||
return showModalBottomSheet<PromoCodeResult>(
|
return showModalBottomSheet<PromoCodeResult>(
|
||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
useSafeArea: true,
|
useSafeArea: true,
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
builder: (_) => ItvioPromoCodeModal(onValidate: onValidate),
|
builder: (_) => ItvioPromoCodeModal(
|
||||||
|
onValidate: onValidate,
|
||||||
|
onRemove: onRemove,
|
||||||
|
initialPromo: initialPromo,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,14 +89,32 @@ class ItvioPromoCodeModal extends StatefulWidget {
|
|||||||
State<ItvioPromoCodeModal> createState() => _ItvioPromoCodeModalState();
|
State<ItvioPromoCodeModal> createState() => _ItvioPromoCodeModalState();
|
||||||
}
|
}
|
||||||
|
|
||||||
enum _ModalState { input, loading }
|
enum _ModalState { input, loading, applied }
|
||||||
|
|
||||||
class _ItvioPromoCodeModalState extends State<ItvioPromoCodeModal> {
|
class _ItvioPromoCodeModalState extends State<ItvioPromoCodeModal> {
|
||||||
final _controller = TextEditingController();
|
final _controller = TextEditingController();
|
||||||
final _focusNode = FocusNode();
|
final _focusNode = FocusNode();
|
||||||
|
|
||||||
_ModalState _state = _ModalState.input;
|
late _ModalState _state;
|
||||||
String? _errorText;
|
String? _errorText;
|
||||||
|
int? _discountPercent;
|
||||||
|
double? _discountAmountRub;
|
||||||
|
String? _appliedCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final initial = widget.initialPromo;
|
||||||
|
if (initial != null) {
|
||||||
|
_state = _ModalState.applied;
|
||||||
|
_appliedCode = initial.promoCode;
|
||||||
|
_discountPercent = initial.discountPercent;
|
||||||
|
_discountAmountRub = initial.discountAmountRub;
|
||||||
|
_controller.text = initial.promoCode;
|
||||||
|
} else {
|
||||||
|
_state = _ModalState.input;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
@@ -98,7 +158,62 @@ class _ItvioPromoCodeModalState extends State<ItvioPromoCodeModal> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Navigator.of(context).pop(PromoCodeResult(code: code));
|
Navigator.of(context).pop(
|
||||||
|
PromoCodeResult(
|
||||||
|
code: code,
|
||||||
|
discountPercent: validation.discountPercent,
|
||||||
|
discountAmountRub: validation.discountAmountRub,
|
||||||
|
originalAmountRub: validation.originalAmountRub,
|
||||||
|
finalAmountRub: validation.finalAmountRub,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onContinueApplied() {
|
||||||
|
Navigator.of(context).pop(
|
||||||
|
PromoCodeResult(
|
||||||
|
code: _appliedCode,
|
||||||
|
discountPercent: _discountPercent,
|
||||||
|
discountAmountRub: _discountAmountRub,
|
||||||
|
alreadyApplied: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onRemove() async {
|
||||||
|
setState(() {
|
||||||
|
_state = _ModalState.loading;
|
||||||
|
_errorText = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
var success = true;
|
||||||
|
try {
|
||||||
|
if (widget.onRemove != null) {
|
||||||
|
success = await widget.onRemove!();
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
success = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
setState(() {
|
||||||
|
_state = _ModalState.applied;
|
||||||
|
_errorText = S.of(context).promo_remove_failed;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_state = _ModalState.input;
|
||||||
|
_appliedCode = null;
|
||||||
|
_discountPercent = null;
|
||||||
|
_discountAmountRub = null;
|
||||||
|
_errorText = null;
|
||||||
|
_controller.clear();
|
||||||
|
});
|
||||||
|
_focusNode.requestFocus();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onSkip() {
|
void _onSkip() {
|
||||||
@@ -107,6 +222,10 @@ class _ItvioPromoCodeModalState extends State<ItvioPromoCodeModal> {
|
|||||||
|
|
||||||
bool get _isLoading => _state == _ModalState.loading;
|
bool get _isLoading => _state == _ModalState.loading;
|
||||||
|
|
||||||
|
/// Applied UI while loading remove (state=loading, but promo still shown).
|
||||||
|
bool get _isApplied =>
|
||||||
|
_appliedCode != null && _state != _ModalState.input;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
|
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
|
||||||
@@ -127,40 +246,51 @@ class _ItvioPromoCodeModalState extends State<ItvioPromoCodeModal> {
|
|||||||
children: [
|
children: [
|
||||||
const Center(child: BottomSheetHandlerBar()),
|
const Center(child: BottomSheetHandlerBar()),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_buildHeader(),
|
_buildHeader(s),
|
||||||
|
if (_isApplied) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
s.promo_applied_subtitle,
|
||||||
|
style: const TextStyle(fontSize: 14, color: Color(0xFF6B7684)),
|
||||||
|
),
|
||||||
|
],
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Text(
|
Text(
|
||||||
'Промокод',
|
s.promo_code,
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: Color(0xFF6B7684),
|
color: Color(0xFF6B7684),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_buildField(),
|
_buildField(s),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_buildHint(),
|
_buildHint(s),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_buildApplyButton(s),
|
if (_isApplied) ...[
|
||||||
const SizedBox(height: 6),
|
_buildContinueButton(s),
|
||||||
_buildSkipButton(s),
|
const SizedBox(height: 6),
|
||||||
|
_buildRemoveButton(s),
|
||||||
|
] else ...[
|
||||||
|
_buildApplyButton(s),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
_buildSkipButton(s),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildHeader() {
|
Widget _buildHeader(S s) {
|
||||||
final s = S.of(context);
|
|
||||||
return Text(
|
return Text(
|
||||||
s.have_promo_code_form,
|
_isApplied ? s.promo_applied_title : s.have_promo_code_form,
|
||||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildField() {
|
Widget _buildField(S s) {
|
||||||
final hasError = _errorText != null;
|
final hasError = _errorText != null && !_isApplied;
|
||||||
final s = S.of(context);
|
|
||||||
|
|
||||||
return AnimatedContainer(
|
return AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 150),
|
duration: const Duration(milliseconds: 150),
|
||||||
@@ -171,7 +301,7 @@ class _ItvioPromoCodeModalState extends State<ItvioPromoCodeModal> {
|
|||||||
width: 2,
|
width: 2,
|
||||||
color: hasError
|
color: hasError
|
||||||
? appTheme.red
|
? appTheme.red
|
||||||
: (_focusNode.hasFocus
|
: (_focusNode.hasFocus && !_isApplied
|
||||||
? appTheme.blueA400
|
? appTheme.blueA400
|
||||||
: Colors.transparent),
|
: Colors.transparent),
|
||||||
),
|
),
|
||||||
@@ -182,14 +312,16 @@ class _ItvioPromoCodeModalState extends State<ItvioPromoCodeModal> {
|
|||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _controller,
|
controller: _controller,
|
||||||
focusNode: _focusNode,
|
focusNode: _focusNode,
|
||||||
enabled: !_isLoading,
|
enabled: !_isLoading && !_isApplied,
|
||||||
textCapitalization: TextCapitalization.characters,
|
textCapitalization: TextCapitalization.characters,
|
||||||
onChanged: (_) {
|
onChanged: (_) {
|
||||||
if (_errorText != null) {
|
if (_errorText != null) {
|
||||||
setState(() => _errorText = null);
|
setState(() => _errorText = null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSubmitted: (_) => _apply(),
|
onSubmitted: (_) {
|
||||||
|
if (!_isApplied) _apply();
|
||||||
|
},
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: s.enter_promo_code,
|
hintText: s.enter_promo_code,
|
||||||
hintStyle: const TextStyle(
|
hintStyle: const TextStyle(
|
||||||
@@ -202,9 +334,11 @@ class _ItvioPromoCodeModalState extends State<ItvioPromoCodeModal> {
|
|||||||
vertical: 14,
|
vertical: 14,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
color: Color(0xFF24303F),
|
color: _isApplied
|
||||||
|
? const Color(0xFF6B7684)
|
||||||
|
: const Color(0xFF24303F),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -213,13 +347,17 @@ class _ItvioPromoCodeModalState extends State<ItvioPromoCodeModal> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildHint() {
|
Widget _buildHint(S s) {
|
||||||
final String text;
|
final String text;
|
||||||
final Color color;
|
final Color color;
|
||||||
|
|
||||||
if (_errorText != null) {
|
if (_errorText != null) {
|
||||||
text = _errorText!;
|
text = _errorText!;
|
||||||
color = appTheme.red;
|
color = appTheme.red;
|
||||||
|
} else if (_isApplied && _discountPercent != null) {
|
||||||
|
final amount = (_discountAmountRub ?? 0).toStringAsFixed(0);
|
||||||
|
text = s.promo_discount_hint(_discountPercent!, amount);
|
||||||
|
color = const Color(0xFF27A06B);
|
||||||
} else {
|
} else {
|
||||||
text = '';
|
text = '';
|
||||||
color = Colors.transparent;
|
color = Colors.transparent;
|
||||||
@@ -264,6 +402,50 @@ class _ItvioPromoCodeModalState extends State<ItvioPromoCodeModal> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildContinueButton(S s) {
|
||||||
|
return SizedBox(
|
||||||
|
height: 50,
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: _isLoading ? null : _onContinueApplied,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: appTheme.blueA400,
|
||||||
|
foregroundColor: appTheme.whiteA700,
|
||||||
|
disabledBackgroundColor: appTheme.blueA400.withValues(alpha: 0.55),
|
||||||
|
shape: const StadiumBorder(),
|
||||||
|
textStyle: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(s.continue_),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildRemoveButton(S s) {
|
||||||
|
return SizedBox(
|
||||||
|
height: 46,
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: _isLoading ? null : _onRemove,
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: appTheme.blueA400,
|
||||||
|
shape: const StadiumBorder(),
|
||||||
|
textStyle: const TextStyle(fontSize: 14),
|
||||||
|
),
|
||||||
|
child: _isLoading
|
||||||
|
? SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2.5,
|
||||||
|
color: appTheme.blueA400,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text(s.promo_remove),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildSkipButton(S s) {
|
Widget _buildSkipButton(S s) {
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: 46,
|
height: 46,
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
import 'package:eatvio_app/features/chat/models/payment_model.dart';
|
import 'package:eatvio_app/features/chat/models/payment_model.dart';
|
||||||
import 'package:eatvio_app/features/chat/services/chat_service.dart';
|
import 'package:eatvio_app/features/chat/services/chat_service.dart';
|
||||||
|
import 'package:eatvio_app/features/payment/models/promo_applied_list_response.dart';
|
||||||
|
import 'package:eatvio_app/features/payment/models/promo_apply_response.dart';
|
||||||
|
import 'package:eatvio_app/features/payment/models/promo_code_model.dart';
|
||||||
|
import 'package:eatvio_app/features/payment/models/promo_remove_response.dart';
|
||||||
|
import 'package:eatvio_app/features/payment/models/promo_validate_response.dart';
|
||||||
|
|
||||||
/// Репозиторий оплаты консультаций (chosen_plan) через Alfa Bank / СБП.
|
/// Репозиторий оплаты консультаций (chosen_plan) через Alfa Bank / СБП.
|
||||||
class PaymentRepository {
|
class PaymentRepository {
|
||||||
@@ -15,4 +21,67 @@ class PaymentRepository {
|
|||||||
body: const {'payment_provider': 'alfa-bank'},
|
body: const {'payment_provider': 'alfa-bank'},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Список applied-промокодов текущего пользователя.
|
||||||
|
Future<PromoAppliedListResponse> listAppliedPromoCodes() {
|
||||||
|
return chatService.listAppliedPromoCodes();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applied-промокод для конкретного chosen_plan, либо null (нет / ошибка сети).
|
||||||
|
Future<PromoCodeModel?> findAppliedPromoForPlan(int planId) async {
|
||||||
|
try {
|
||||||
|
final response = await listAppliedPromoCodes();
|
||||||
|
for (final promo in response.promoCodes) {
|
||||||
|
if (promo.chosenPlanId == planId && promo.state == 'applied') {
|
||||||
|
return promo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Preview-валидация промокода без применения.
|
||||||
|
Future<PromoValidateResponse> validatePromoCode({
|
||||||
|
required int planId,
|
||||||
|
required String promoCode,
|
||||||
|
}) {
|
||||||
|
return chatService.validatePromoCode(
|
||||||
|
id: planId,
|
||||||
|
body: {'promo_code': promoCode},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Применяет промокод к chosen_plan. При HTTP 422 возвращает тело ответа.
|
||||||
|
Future<PromoApplyResponse> applyPromoCode({
|
||||||
|
required int planId,
|
||||||
|
required String promoCode,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
return await chatService.applyPromoCode(
|
||||||
|
id: planId,
|
||||||
|
body: {'promo_code': promoCode},
|
||||||
|
);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
final data = e.response?.data;
|
||||||
|
if (data is Map<String, dynamic>) {
|
||||||
|
return PromoApplyResponse.fromJson(data);
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Удаляет применённый промокод (idempotent).
|
||||||
|
Future<PromoRemoveResponse> removePromoCode({required int planId}) async {
|
||||||
|
try {
|
||||||
|
return await chatService.removePromoCode(id: planId);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
final data = e.response?.data;
|
||||||
|
if (data is Map<String, dynamic>) {
|
||||||
|
return PromoRemoveResponse.fromJson(data);
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ class ProfileCubit extends Cubit<ProfileState> {
|
|||||||
final ConsultantRepository consultantRepository;
|
final ConsultantRepository consultantRepository;
|
||||||
String _currentPeriod = '1';
|
String _currentPeriod = '1';
|
||||||
|
|
||||||
|
/// Bottom-nav / own profile instance, used to refresh posts after create/update/delete.
|
||||||
|
static ProfileCubit? myProfileCubit;
|
||||||
|
|
||||||
ProfileCubit({required this.profileRepository, required this.chatRepository, required this.consultantRepository}) : super(const ProfileState.loading());
|
ProfileCubit({required this.profileRepository, required this.chatRepository, required this.consultantRepository}) : super(const ProfileState.loading());
|
||||||
|
|
||||||
Future<void> getProfileBySlug(String slug) async {
|
Future<void> getProfileBySlug(String slug) async {
|
||||||
@@ -49,6 +52,7 @@ class ProfileCubit extends Cubit<ProfileState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> getMyProfile({String period = '1'}) async {
|
Future<void> getMyProfile({String period = '1'}) async {
|
||||||
|
myProfileCubit = this;
|
||||||
emit(const ProfileState.loading());
|
emit(const ProfileState.loading());
|
||||||
_currentPeriod = period;
|
_currentPeriod = period;
|
||||||
try {
|
try {
|
||||||
@@ -74,6 +78,22 @@ class ProfileCubit extends Cubit<ProfileState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reloads only the blog posts list without resetting the whole profile screen.
|
||||||
|
Future<void> reloadBlogPosts() async {
|
||||||
|
final currentState = state;
|
||||||
|
if (currentState is! ProfileStateLoaded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final blogPosts = await profileRepository.getBlogPostsByAuthorSlug(slug: currentState.profile.slug);
|
||||||
|
emit(currentState.copyWith(blogPosts: blogPosts));
|
||||||
|
} catch (e, st) {
|
||||||
|
await reportFrom(e, st, this);
|
||||||
|
log(e.toString(), stackTrace: st);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void updateFeedBlogPostList(FeedBlogPostModel feedBlogPostModel, bool wasLikedByMe) {
|
void updateFeedBlogPostList(FeedBlogPostModel feedBlogPostModel, bool wasLikedByMe) {
|
||||||
List<FeedBlogPostModel> feedBlogPostList = [...(state as ProfileStateLoaded).blogPosts];
|
List<FeedBlogPostModel> feedBlogPostList = [...(state as ProfileStateLoaded).blogPosts];
|
||||||
final int index = feedBlogPostList.indexOf(feedBlogPostModel);
|
final int index = feedBlogPostList.indexOf(feedBlogPostModel);
|
||||||
|
|||||||
@@ -436,8 +436,11 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
|||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () {
|
onTap: () async {
|
||||||
GoRouter.of(context).pushNamed('blogPostCreateScreen');
|
await GoRouter.of(context).pushNamed('blogPostCreateScreen');
|
||||||
|
if (mounted) {
|
||||||
|
await profileCubit.reloadBlogPosts();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: Ink(
|
child: Ink(
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
import 'package:cached_network_image/cached_network_image.dart';
|
|
||||||
import 'package:dismissible_page/dismissible_page.dart';
|
import 'package:dismissible_page/dismissible_page.dart';
|
||||||
import 'package:eatvio_app/core/app_config.dart';
|
import 'package:eatvio_app/core/app_config.dart';
|
||||||
import 'package:eatvio_app/core/enums/complained_content.dart';
|
import 'package:eatvio_app/core/enums/complained_content.dart';
|
||||||
import 'package:eatvio_app/core/locator_service.dart';
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
|
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||||
import 'package:eatvio_app/core/utils/date_time_utils.dart';
|
import 'package:eatvio_app/core/utils/date_time_utils.dart';
|
||||||
import 'package:eatvio_app/core/widgets/custom_popup.dart';
|
import 'package:eatvio_app/core/widgets/custom_popup.dart';
|
||||||
import 'package:eatvio_app/core/widgets/feed_icon_button.dart';
|
import 'package:eatvio_app/core/widgets/feed_icon_button.dart';
|
||||||
|
import 'package:eatvio_app/core/widgets/markdown_html_widget.dart';
|
||||||
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart';
|
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart';
|
||||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
||||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
import 'package:eatvio_app/features/feed/presentation/widgets/blog_post_media_content.dart';
|
||||||
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
|
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
|
||||||
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_state.dart';
|
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_state.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:eatvio_app/core/widgets/markdown_html_widget.dart';
|
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:share_plus/share_plus.dart';
|
import 'package:share_plus/share_plus.dart';
|
||||||
@@ -22,7 +24,11 @@ class ProfileBlogPostWidget extends StatefulWidget {
|
|||||||
final int index;
|
final int index;
|
||||||
final ProfileCubit profileCubit;
|
final ProfileCubit profileCubit;
|
||||||
|
|
||||||
const ProfileBlogPostWidget({super.key, required this.index, required this.profileCubit});
|
const ProfileBlogPostWidget({
|
||||||
|
super.key,
|
||||||
|
required this.index,
|
||||||
|
required this.profileCubit,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ProfileBlogPostWidget> createState() => _ProfileBlogPostWidgetState();
|
State<ProfileBlogPostWidget> createState() => _ProfileBlogPostWidgetState();
|
||||||
@@ -31,221 +37,289 @@ class ProfileBlogPostWidget extends StatefulWidget {
|
|||||||
class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
|
class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
|
||||||
late bool isLiked;
|
late bool isLiked;
|
||||||
late int totalLikes;
|
late int totalLikes;
|
||||||
|
int currentGalleryIndex = 0;
|
||||||
|
|
||||||
|
void _openGallery(List<String> imageUrls, int index, {Object? heroTag}) {
|
||||||
|
context.pushTransparentRoute(
|
||||||
|
rootNavigator: true,
|
||||||
|
PhotoViewScreen(imageUrls: imageUrls, index: index, heroTag: heroTag),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocBuilder<ProfileCubit, ProfileState>(
|
return BlocBuilder<ProfileCubit, ProfileState>(
|
||||||
bloc: widget.profileCubit,
|
bloc: widget.profileCubit,
|
||||||
builder: (BuildContext context, ProfileState state) {
|
builder: (BuildContext context, ProfileState state) {
|
||||||
if (state is ProfileStateLoaded) {
|
if (state is! ProfileStateLoaded) {
|
||||||
isLiked = state.blogPosts[widget.index].wasLikedByMe;
|
return const SizedBox.shrink();
|
||||||
totalLikes = state.blogPosts[widget.index].totalLikes;
|
}
|
||||||
return InkWell(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
final FeedBlogPostModel post = state.blogPosts[widget.index];
|
||||||
onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: state.blogPosts[widget.index].id),
|
isLiked = post.wasLikedByMe;
|
||||||
child: Ink(
|
totalLikes = post.totalLikes;
|
||||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(12), color: Colors.white),
|
final imageUrl = PostFeedMapper.resolveMediaUrl(post.image);
|
||||||
child: Column(
|
final embedUrl = post.videoEmbedUrl?.trim();
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
return InkWell(
|
||||||
children: [
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
onTap: () =>
|
||||||
|
GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id),
|
||||||
|
child: Ink(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
|
radius: 20,
|
||||||
|
backgroundColor: Colors.black12,
|
||||||
|
foregroundImage: NetworkImage(
|
||||||
|
post.author.authorProfileIcon.contains('https')
|
||||||
|
? post.author.authorProfileIcon
|
||||||
|
: sl<AppConfig>().baseUrl +
|
||||||
|
post.author.authorProfileIcon,
|
||||||
|
),
|
||||||
|
backgroundImage: const AssetImage(
|
||||||
|
'assets/images/no_avatar.png',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
post.author.authorName,
|
||||||
|
style: const TextStyle(
|
||||||
|
height: 1,
|
||||||
|
color: Colors.black87,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'${calculateDifference(post.createdAt)} в ${post.createdAt.hour.toString().padLeft(2, '0')}:${post.createdAt.minute.toString().padLeft(2, '0')}',
|
||||||
|
style: const TextStyle(
|
||||||
|
height: 1,
|
||||||
|
color: Colors.black45,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
CustomPopup(
|
||||||
|
isAuthor: post.author.authorIsMe,
|
||||||
|
complainedContentType: ComplainedContent.posts,
|
||||||
|
path: 'blog_post/${post.id}',
|
||||||
|
id: post.id,
|
||||||
|
feedBlogPostModel: post,
|
||||||
|
userSlug: post.author.authorSlug,
|
||||||
|
onPostsChanged: widget.profileCubit.reloadBlogPosts,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (post.title.isNotEmpty) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
child: Row(
|
child: Text(
|
||||||
children: [
|
post.title,
|
||||||
CircleAvatar(
|
style: const TextStyle(
|
||||||
radius: 20,
|
fontWeight: FontWeight.w600,
|
||||||
backgroundColor: Colors.black12,
|
fontSize: 18,
|
||||||
foregroundImage: NetworkImage(
|
color: Colors.black87,
|
||||||
state.blogPosts[widget.index].author.authorProfileIcon.contains('https')
|
|
||||||
? state.blogPosts[widget.index].author.authorProfileIcon
|
|
||||||
: sl<AppConfig>().baseUrl + state.blogPosts[widget.index].author.authorProfileIcon,
|
|
||||||
),
|
|
||||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
state.blogPosts[widget.index].author.authorName,
|
|
||||||
style: const TextStyle(height: 1, color: Colors.black87, fontWeight: FontWeight.w500),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
'${calculateDifference(state.blogPosts[widget.index].createdAt)} в ${state.blogPosts[widget.index].createdAt.hour.toString().padLeft(2, '0')}:${state.blogPosts[widget.index].createdAt.minute.toString().padLeft(2, '0')}',
|
|
||||||
style: const TextStyle(height: 1, color: Colors.black45),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
CustomPopup(
|
|
||||||
isAuthor: state.blogPosts[widget.index].author.authorIsMe,
|
|
||||||
complainedContentType: ComplainedContent.posts,
|
|
||||||
path: 'blog_post/${(state.blogPosts[widget.index].id)}',
|
|
||||||
id: state.blogPosts[widget.index].id,
|
|
||||||
feedBlogPostModel: state.blogPosts[widget.index],
|
|
||||||
userSlug: state.blogPosts[widget.index].author.authorSlug,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (state.blogPosts[widget.index].title.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
||||||
child: Text(
|
|
||||||
state.blogPosts[widget.index].title,
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 18, color: Colors.black87),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
if (state.blogPosts[widget.index].title.isEmpty) const SizedBox(height: 8),
|
],
|
||||||
if (state.blogPosts[widget.index].title.isNotEmpty && state.blogPosts[widget.index].description.isNotEmpty) const SizedBox(height: 8),
|
if (post.title.isEmpty) const SizedBox(height: 8),
|
||||||
if (state.blogPosts[widget.index].description.isNotEmpty)
|
if (post.title.isNotEmpty && post.description.isNotEmpty)
|
||||||
Padding(
|
const SizedBox(height: 8),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
if (post.description.isNotEmpty)
|
||||||
child: Builder(
|
Padding(
|
||||||
builder: (context) {
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
String description = state.blogPosts[widget.index].description;
|
child: MarkdownHtmlWidget(
|
||||||
final isLong = description.length > 200;
|
markdownText: _truncateDescription(post.description),
|
||||||
|
fontSize: 16,
|
||||||
if (isLong) {
|
textColor: Colors.black87,
|
||||||
// Умная обрезка: обрезаем markdown текст, не ломая синтаксис
|
),
|
||||||
int cutPoint = 200;
|
),
|
||||||
|
if (post.description.length > 200)
|
||||||
// Проверяем, есть ли незакрытые markdown теги
|
const Padding(
|
||||||
String preview = description.substring(0, cutPoint);
|
padding: EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: Text(
|
||||||
// Считаем открывающие и закрывающие теги
|
'Показать ещё',
|
||||||
int boldOpen = (preview.split('**').length - 1) ~/ 2;
|
style: TextStyle(
|
||||||
int boldClose = preview.split('**').length - 1 - boldOpen * 2;
|
height: 1,
|
||||||
bool hasUnclosedBold = boldOpen > boldClose;
|
color: Color(0xFF8B8B8B),
|
||||||
|
fontSize: 14,
|
||||||
// Если есть незакрытый bold, ищем закрытие
|
fontWeight: FontWeight.w600,
|
||||||
if (hasUnclosedBold) {
|
),
|
||||||
int searchEnd = (cutPoint + 100).clamp(0, description.length);
|
),
|
||||||
int boldClosePos = description.indexOf('**', cutPoint);
|
),
|
||||||
if (boldClosePos != -1 && boldClosePos < searchEnd) {
|
if (post.title.isNotEmpty || post.description.isNotEmpty)
|
||||||
cutPoint = boldClosePos + 2;
|
const SizedBox(height: 8),
|
||||||
} else {
|
BlogPostMediaContent(
|
||||||
// Если не нашли, обрезаем до последнего пробела
|
galleryImages: post.images,
|
||||||
cutPoint = description.lastIndexOf(' ', 200);
|
imageUrl: imageUrl,
|
||||||
if (cutPoint == -1 || cutPoint < 150) cutPoint = 200;
|
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty
|
||||||
}
|
? embedUrl
|
||||||
} else {
|
: null,
|
||||||
// Обрезаем до последнего пробела для красоты
|
videoUrlThumb: post.videoUrlThumb != null
|
||||||
cutPoint = description.lastIndexOf(' ', cutPoint);
|
? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb)
|
||||||
if (cutPoint == -1 || cutPoint < 150) cutPoint = 200;
|
: null,
|
||||||
}
|
currentGalleryIndex: currentGalleryIndex,
|
||||||
|
heroTag: 'profile-blog-post-${post.id}-cover',
|
||||||
description = description.substring(0, cutPoint);
|
onOpenGallery: (urls, index) => _openGallery(
|
||||||
}
|
urls,
|
||||||
|
index,
|
||||||
return MarkdownHtmlWidget(
|
heroTag: urls.length == 1
|
||||||
markdownText: description,
|
? 'profile-blog-post-${post.id}-cover'
|
||||||
fontSize: 16,
|
: null,
|
||||||
textColor: Colors.black87,
|
),
|
||||||
|
onGalleryChanged: (index) {
|
||||||
|
setState(() {
|
||||||
|
currentGalleryIndex = index;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
FeedIconButton(
|
||||||
|
icon: isLiked
|
||||||
|
? Icons.favorite_rounded
|
||||||
|
: Icons.favorite_border_rounded,
|
||||||
|
text: totalLikes.toString(),
|
||||||
|
iconColor: isLiked
|
||||||
|
? Colors.red.withValues(alpha: 0.8)
|
||||||
|
: null,
|
||||||
|
backgroundColor: isLiked
|
||||||
|
? Colors.red.withValues(alpha: 0.1)
|
||||||
|
: null,
|
||||||
|
textColor: isLiked
|
||||||
|
? Colors.red.withValues(alpha: 0.8)
|
||||||
|
: null,
|
||||||
|
onTap: () async {
|
||||||
|
setState(() {
|
||||||
|
isLiked = !isLiked;
|
||||||
|
isLiked ? totalLikes++ : totalLikes--;
|
||||||
|
});
|
||||||
|
isLiked
|
||||||
|
? await context.read<FeedCubit>().likePost(
|
||||||
|
post.id,
|
||||||
|
)
|
||||||
|
: await context.read<FeedCubit>().unlikedPost(
|
||||||
|
post.id,
|
||||||
|
);
|
||||||
|
widget.profileCubit.updateFeedBlogPostList(
|
||||||
|
post,
|
||||||
|
isLiked ? false : true,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 8),
|
||||||
if (state.blogPosts[widget.index].description.length > 200)
|
FeedIconButton(
|
||||||
const Padding(
|
svgIcon: SvgPicture.asset(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 12),
|
'assets/icons/comment.svg',
|
||||||
child: Text(
|
height: 20,
|
||||||
'Показать ещё',
|
width: 20,
|
||||||
style: TextStyle(height: 1, color: Color(0xFF8B8B8B), fontSize: 14, fontWeight: FontWeight.w600),
|
colorFilter: ColorFilter.mode(
|
||||||
),
|
appTheme.gray600,
|
||||||
),
|
BlendMode.srcIn,
|
||||||
if (state.blogPosts[widget.index].title.isNotEmpty || state.blogPosts[widget.index].description.isNotEmpty) const SizedBox(height: 8),
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () {
|
|
||||||
context.pushTransparentRoute(
|
|
||||||
rootNavigator: true,
|
|
||||||
PhotoViewScreen(imageUrls: ['${sl<AppConfig>().baseUrl}/storage/${state.blogPosts[widget.index].image}'], index: 0),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: Hero(
|
|
||||||
tag: '${sl<AppConfig>().baseUrl}/storage/${state.blogPosts[widget.index].image}',
|
|
||||||
child: CachedNetworkImage(
|
|
||||||
fit: BoxFit.fitWidth,
|
|
||||||
width: MediaQuery.of(context).size.width,
|
|
||||||
imageUrl: '${sl<AppConfig>().baseUrl}/storage/${state.blogPosts[widget.index].image}',
|
|
||||||
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
FeedIconButton(
|
|
||||||
icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
|
||||||
text: totalLikes.toString(),
|
|
||||||
iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
|
|
||||||
backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null,
|
|
||||||
textColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
|
|
||||||
onTap: () async {
|
|
||||||
setState(() {
|
|
||||||
isLiked = !isLiked;
|
|
||||||
isLiked ? totalLikes++ : totalLikes--;
|
|
||||||
});
|
|
||||||
isLiked
|
|
||||||
? await context.read<FeedCubit>().likePost(state.blogPosts[widget.index].id)
|
|
||||||
: await context.read<FeedCubit>().unlikedPost(state.blogPosts[widget.index].id);
|
|
||||||
widget.profileCubit.updateFeedBlogPostList(state.blogPosts[widget.index], isLiked ? false : true);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
FeedIconButton(
|
|
||||||
svgIcon: SvgPicture.asset(
|
|
||||||
'assets/icons/comment.svg',
|
|
||||||
height: 20,
|
|
||||||
width: 20,
|
|
||||||
colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn),
|
|
||||||
),
|
),
|
||||||
text: state.blogPosts[widget.index].totalComments.toString(),
|
|
||||||
onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: state.blogPosts[widget.index].id),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
text: post.totalComments.toString(),
|
||||||
FeedIconButton(
|
onTap: () => GoRouter.of(
|
||||||
svgIcon: SvgPicture.asset(
|
context,
|
||||||
height: 20,
|
).pushNamed('blogPostScreen', extra: post.id),
|
||||||
width: 20,
|
),
|
||||||
'assets/icons/share.svg',
|
const SizedBox(width: 8),
|
||||||
colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn),
|
FeedIconButton(
|
||||||
|
svgIcon: SvgPicture.asset(
|
||||||
|
height: 20,
|
||||||
|
width: 20,
|
||||||
|
'assets/icons/share.svg',
|
||||||
|
colorFilter: ColorFilter.mode(
|
||||||
|
appTheme.gray600,
|
||||||
|
BlendMode.srcIn,
|
||||||
),
|
),
|
||||||
onTap: () {
|
),
|
||||||
final box = context.findRenderObject() as RenderBox?;
|
onTap: () {
|
||||||
SharePlus.instance.share(
|
final box = context.findRenderObject() as RenderBox?;
|
||||||
ShareParams(
|
SharePlus.instance.share(
|
||||||
uri: Uri.parse('${sl<AppConfig>().baseUrl}/ru/blog_post/${state.blogPosts[widget.index].id}'),
|
ShareParams(
|
||||||
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
|
uri: Uri.parse(
|
||||||
|
'${sl<AppConfig>().baseUrl}/ru/blog_post/${post.id}',
|
||||||
),
|
),
|
||||||
);
|
sharePositionOrigin:
|
||||||
},
|
box!.localToGlobal(Offset.zero) & box.size,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Icon(
|
||||||
|
Icons.visibility_rounded,
|
||||||
|
size: 16,
|
||||||
|
color: appTheme.gray500,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
post.totalViews.toString(),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: appTheme.gray500,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
),
|
),
|
||||||
const Spacer(),
|
),
|
||||||
Icon(Icons.visibility_rounded, size: 16, color: appTheme.gray500),
|
],
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
state.blogPosts[widget.index].totalViews.toString(),
|
|
||||||
style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
),
|
||||||
],
|
const SizedBox(height: 8),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
}
|
);
|
||||||
return const SizedBox.shrink();
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _truncateDescription(String description) {
|
||||||
|
final isLong = description.length > 200;
|
||||||
|
if (!isLong) return description;
|
||||||
|
|
||||||
|
var cutPoint = 200;
|
||||||
|
final preview = description.substring(0, cutPoint);
|
||||||
|
final boldOpen = (preview.split('**').length - 1) ~/ 2;
|
||||||
|
final boldClose = preview.split('**').length - 1 - boldOpen * 2;
|
||||||
|
final hasUnclosedBold = boldOpen > boldClose;
|
||||||
|
|
||||||
|
if (hasUnclosedBold) {
|
||||||
|
final searchEnd = (cutPoint + 100).clamp(0, description.length);
|
||||||
|
final boldClosePos = description.indexOf('**', cutPoint);
|
||||||
|
if (boldClosePos != -1 && boldClosePos < searchEnd) {
|
||||||
|
cutPoint = boldClosePos + 2;
|
||||||
|
} else {
|
||||||
|
cutPoint = description.lastIndexOf(' ', 200);
|
||||||
|
if (cutPoint == -1 || cutPoint < 150) cutPoint = 200;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cutPoint = description.lastIndexOf(' ', cutPoint);
|
||||||
|
if (cutPoint == -1 || cutPoint < 150) cutPoint = 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
return description.substring(0, cutPoint);
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user