Compare commits
149 Commits
push-notif
...
promo_code
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| d6b57ef34d | |||
| 45f20cc903 | |||
| 746b9b6d28 | |||
|
|
2dbd4a455f | ||
| c7514e2cbf | |||
| f1abea70c5 | |||
| e4d397b478 | |||
| 9c5c6df751 | |||
|
|
48a984f5e9 | ||
|
|
5dc1f48fd1 | ||
|
|
5ec877ef72 | ||
|
|
dbaf639ac6 | ||
|
|
2a07ed596b | ||
| bd2de07b8f | |||
|
|
eee912f05b | ||
|
|
e316bf1e0e | ||
|
|
de19af14bf | ||
|
|
b064c7016e | ||
|
|
e94681266a | ||
|
|
0ca2d1a1cc | ||
|
|
be924265a1 | ||
| a0edfe942b | |||
|
|
51397003b5 | ||
|
|
e10610d93c | ||
|
|
d21cc46558 | ||
|
|
2ab26bbda5 | ||
|
|
41e556d172 | ||
|
|
6d920857f6 | ||
|
|
7183fc5d7d | ||
|
|
3852a06ab4 | ||
|
|
219908684f | ||
|
|
9ce7b035ff | ||
|
|
c8ad600d97 | ||
|
|
f493e07f9f | ||
|
|
c0c7e2e658 | ||
|
|
f913ae60fa | ||
|
|
2100e5ca02 | ||
|
|
10b36117a6 | ||
|
|
bde4eea7d1 | ||
|
|
59f42c9b1e | ||
|
|
c5bf5acfe7 | ||
| 7db9af9894 | |||
|
|
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 | ||
| fb99a42115 | |||
|
|
1acb158f36 | ||
|
|
54c27c28cd | ||
|
|
fe243965e3 | ||
|
|
56313a4fcc | ||
|
|
cb36ef8e07 | ||
|
|
b2072ab626 | ||
| 5b72684c95 | |||
|
|
91ad51d340 | ||
| 01282a35cb | |||
| 5d86b7c71b | |||
|
|
50aefde842 | ||
| 0991ac555e | |||
| 5fbd578463 | |||
|
|
426bd49aa1 | ||
| c6fc20d217 | |||
|
|
17e18dc68b | ||
| b5a889cbb0 | |||
| d483295aab | |||
| 985154c863 | |||
| f1e07b3d86 | |||
| 062079e7f1 | |||
| 24652884ee | |||
| ea9cdb93d4 | |||
| 7c4010b275 | |||
| 4458aaf74f | |||
| f20f871c88 | |||
| f61fb8c6b3 | |||
| 3d14dee4e9 | |||
| 38647b4ac6 | |||
| c836b600c6 | |||
| dfe11a6df2 | |||
| 35cad4e05e |
@@ -8,15 +8,19 @@ jobs:
|
||||
build:
|
||||
runs-on: [macos-arm64]
|
||||
steps:
|
||||
- name: Fastlane version
|
||||
run: fastlane --version
|
||||
|
||||
- name: Checkout
|
||||
uses: https://github.com/actions/checkout@v2
|
||||
|
||||
- name: Checkout env repository
|
||||
uses: https://github.com/actions/checkout@v2
|
||||
uses: https://github.com/actions/checkout@v3
|
||||
with:
|
||||
repository: mso7nueh/eatvio_app_env
|
||||
repository: eatvio/eatvio_app_env
|
||||
path: env_repo
|
||||
token: ${{ vars.MSO7NUEH_TOKEN }}
|
||||
# Здесь может быть токен любого юзера с доступом в gitea
|
||||
token: ${{ vars.READ_USER_GITEA_TOKEN }}
|
||||
ref: main
|
||||
|
||||
- name: Copy .env file
|
||||
@@ -54,23 +58,20 @@ jobs:
|
||||
|
||||
- name: Flutter build
|
||||
run: |
|
||||
flutter upgrade
|
||||
flutter --version
|
||||
flutter clean
|
||||
flutter pub get
|
||||
flutter pub upgrade
|
||||
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
|
||||
pod update
|
||||
pod install
|
||||
fastlane beta
|
||||
env:
|
||||
LANG: en_US.UTF-8
|
||||
LC_ALL: en_US.UTF-8
|
||||
MATCH_PASSWORD: ${{ vars.IOS_RUNNER_CI_PASSWORD }}
|
||||
FASTLANE_PASSWORD: ${{ vars.FASTLANE_PASSWORD }}
|
||||
MATCH_GIT_URL: http://gitea.office.local:3000/mso7nueh/fastlane
|
||||
MATCH_GIT_URL: http://eatvio:${{ vars.READ_USER_GITEA_TOKEN }}@gitea.office.local:3000/eatvio/fastlane.git
|
||||
FASTLANE_USER: eatvio2023@icloud.com
|
||||
FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD: ${{ vars.FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD }}
|
||||
API_APPSTORE_KEY: ${{ vars.API_APPSTORE_KEY }}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
name: Eatvio App Android Test
|
||||
|
||||
on: [pull_request]
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
env:
|
||||
flutter_version: '3.41.2'
|
||||
@@ -152,9 +155,10 @@ jobs:
|
||||
|
||||
- name: Get dependencies
|
||||
run: |
|
||||
flutter clean
|
||||
flutter pub get
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
flutter clean
|
||||
flutter pub cache repair
|
||||
flutter pub get
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
|
||||
- name: Native Analyze
|
||||
run: flutter analyze
|
||||
@@ -162,66 +166,66 @@ jobs:
|
||||
- name: Test
|
||||
run: flutter test --coverage
|
||||
|
||||
- name: Test coverage
|
||||
run: |
|
||||
echo "📊 Analyzing test coverage..."
|
||||
echo ""
|
||||
awk -F: '
|
||||
/^SF:/ {
|
||||
file = $0
|
||||
sub(/^SF:/, "", file)
|
||||
sub(/^[[:space:]]+/, "", file)
|
||||
is_generated = 0
|
||||
if (file ~ /\.(g|freezed|gr|mocks)\.dart$/) {
|
||||
is_generated = 1
|
||||
generated_files++
|
||||
} else if (file ~ /\/(build|\.dart_tool)\//) {
|
||||
is_generated = 1
|
||||
generated_files++
|
||||
} else {
|
||||
source_files++
|
||||
}
|
||||
}
|
||||
/^LF:/ {
|
||||
if (!is_generated) {
|
||||
total += $2
|
||||
} else {
|
||||
generated_lines += $2
|
||||
}
|
||||
}
|
||||
/^LH:/ {
|
||||
if (!is_generated) {
|
||||
covered += $2
|
||||
} else {
|
||||
generated_covered += $2
|
||||
}
|
||||
}
|
||||
END {
|
||||
print "📁 Files:"
|
||||
printf " Source files: %d\n", source_files
|
||||
printf " Generated files: %d (excluded)\n", generated_files
|
||||
print ""
|
||||
print "📝 Lines:"
|
||||
printf " Total lines: %d\n", total
|
||||
printf " Covered lines: %d\n", covered
|
||||
printf " Uncovered lines: %d\n", total - covered
|
||||
print ""
|
||||
if (total == 0) {
|
||||
print "⚠️ Coverage: 0%"
|
||||
exit 1
|
||||
} else {
|
||||
percentage = (covered / total) * 100
|
||||
printf "✅ Coverage: %.2f%%\n", percentage
|
||||
print ""
|
||||
if (percentage < 70) {
|
||||
printf "❌ FAIL: Coverage is below 70%%\n"
|
||||
exit 1
|
||||
} else {
|
||||
printf "💚 Excellent: Coverage is above 70%%\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
' coverage/lcov.info
|
||||
# - name: Test coverage
|
||||
# run: |
|
||||
# echo "📊 Analyzing test coverage..."
|
||||
# echo ""
|
||||
# awk -F: '
|
||||
# /^SF:/ {
|
||||
# file = $0
|
||||
# sub(/^SF:/, "", file)
|
||||
# sub(/^[[:space:]]+/, "", file)
|
||||
# is_generated = 0
|
||||
# if (file ~ /\.(g|freezed|gr|mocks)\.dart$/) {
|
||||
# is_generated = 1
|
||||
# generated_files++
|
||||
# } else if (file ~ /\/(build|\.dart_tool)\//) {
|
||||
# is_generated = 1
|
||||
# generated_files++
|
||||
# } else {
|
||||
# source_files++
|
||||
# }
|
||||
# }
|
||||
# /^LF:/ {
|
||||
# if (!is_generated) {
|
||||
# total += $2
|
||||
# } else {
|
||||
# generated_lines += $2
|
||||
# }
|
||||
# }
|
||||
# /^LH:/ {
|
||||
# if (!is_generated) {
|
||||
# covered += $2
|
||||
# } else {
|
||||
# generated_covered += $2
|
||||
# }
|
||||
# }
|
||||
# END {
|
||||
# print "📁 Files:"
|
||||
# printf " Source files: %d\n", source_files
|
||||
# printf " Generated files: %d (excluded)\n", generated_files
|
||||
# print ""
|
||||
# print "📝 Lines:"
|
||||
# printf " Total lines: %d\n", total
|
||||
# printf " Covered lines: %d\n", covered
|
||||
# printf " Uncovered lines: %d\n", total - covered
|
||||
# print ""
|
||||
# if (total == 0) {
|
||||
# print "⚠️ Coverage: 0%"
|
||||
# exit 1
|
||||
# } else {
|
||||
# percentage = (covered / total) * 100
|
||||
# printf "✅ Coverage: %.2f%%\n", percentage
|
||||
# print ""
|
||||
# if (percentage < 70) {
|
||||
# printf "❌ FAIL: Coverage is below 70%%\n"
|
||||
# exit 1
|
||||
# } else {
|
||||
# printf "💚 Excellent: Coverage is above 70%%\n"
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
# ' coverage/lcov.info
|
||||
|
||||
- name: Build apk
|
||||
run: |
|
||||
|
||||
3
.gitignore
vendored
@@ -24,4 +24,5 @@ coverage/
|
||||
backend/
|
||||
.fvm/
|
||||
.fvmrc
|
||||
/eatvio
|
||||
/eatvio
|
||||
*.code-workspace
|
||||
@@ -1,7 +1,7 @@
|
||||
# Current Context
|
||||
|
||||
## Project Status
|
||||
Memory Bank initialized on 2025-10-16. This is a mature Flutter application (v1.3.0+79) currently in production.
|
||||
Memory Bank initialized on 2025-10-16. This is a mature Flutter application (v1.3.24+106) currently in production.
|
||||
|
||||
## Recent Activity
|
||||
- Memory Bank initialization completed
|
||||
@@ -9,10 +9,19 @@ Memory Bank initialized on 2025-10-16. This is a mature Flutter application (v1.
|
||||
- Architecture and technology stack catalogued
|
||||
|
||||
## Current Focus
|
||||
Ready for development tasks. No active work in progress.
|
||||
Виджет видеоплеера ленты (`lib/features/feed/presentation/widgets/feed_video_player_widget.dart`)
|
||||
мигрирован с `webview_flutter` v4 на `flutter_inappwebview` 6.1.5. Цель — фикс
|
||||
fullscreen-кнопки внутри embed-плеера (Rutube/YouTube): `webview_flutter` не реализует
|
||||
Android `WebChromeClient.onShowCustomView/onHideCustomView`, а `flutter_inappwebview`
|
||||
делает это нативно (`InAppWebViewChromeClient`). Паритет UX сохранён (превью → play →
|
||||
скрытая загрузка → fade-in по Rutube `player:canPlay`, плюс страховочный таймер 4с
|
||||
для провайдеров без этого протокола). Добавлена ориентация landscape на fullscreen.
|
||||
Остальные 4 экрана на `webview_flutter` не трогались; обе зависимости в `pubspec.yaml`.
|
||||
|
||||
## Known Issues
|
||||
None documented at initialization.
|
||||
- 2 теста в `test/features/feed/` (`post_feed_pagination_model_test`,
|
||||
`post_feed_mapper_test`) падают на ветке `personalized_feed` — пре-существующие,
|
||||
связаны с fixtures/маппингом, не с видеоплеером.
|
||||
|
||||
## Next Steps
|
||||
Awaiting user direction for:
|
||||
|
||||
2
Makefile
@@ -10,4 +10,4 @@ build-generated-files:
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
|
||||
clean-coverage:
|
||||
@./scripts/remove_generated_from_coverage.sh
|
||||
@./scripts/remove_generated_from_coverage.sh
|
||||
|
||||
@@ -69,6 +69,8 @@ android {
|
||||
}
|
||||
|
||||
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.hms:push:6.13.0.301'
|
||||
// Huawei Analytics dependency to satisfy com.huawei.hianalytics.* references during R8
|
||||
|
||||
6
android/app/proguard-rules.pro
vendored
@@ -4,6 +4,12 @@
|
||||
-keep class 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.** { *; }
|
||||
-dontwarn io.flutter.**
|
||||
|
||||
|
||||
@@ -1,7 +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.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<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_MEDIA_IMAGES"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
|
||||
@@ -11,6 +17,16 @@
|
||||
android:usesCleartextTraffic="true"
|
||||
android:name="${applicationName}"
|
||||
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
|
||||
android:name=".HmsPushService"
|
||||
android:exported="false">
|
||||
@@ -31,7 +47,7 @@
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:supportsPictureInPicture="true"
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
@@ -75,6 +91,68 @@
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
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>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
@@ -86,5 +164,168 @@
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
<!-- SBP bank packages for СБП payment detection -->
|
||||
<package android:name="ru.sberbankmobile" />
|
||||
<package android:name="com.idamob.tinkoff.android" />
|
||||
<package android:name="ru.vtb24.mobilebanking.android" />
|
||||
<package android:name="ru.alfabank.mobile.android" />
|
||||
<package android:name="ru.raiffeisennews" />
|
||||
<package android:name="com.openbank" />
|
||||
<package android:name="ru.gazprombank.android.mobilebank.app" />
|
||||
<package android:name="com.logo.mbanking" />
|
||||
<package android:name="ru.sovcomcard.halva.v1" />
|
||||
<package android:name="ru.rosbank.android" />
|
||||
<package android:name="ru.rshb.dbo" />
|
||||
<package android:name="ru.mkb.mobile" />
|
||||
<package android:name="ru.ucb.android" />
|
||||
<package android:name="ru.unicredit.android" />
|
||||
<package android:name="ru.skbbank.ib" />
|
||||
<package android:name="ru.gebank.ib" />
|
||||
<package android:name="ru.avangard.sbpent" />
|
||||
<package android:name="ru.elplat.elplat2" />
|
||||
<package android:name="com.bifit.rncbbeta" />
|
||||
<package android:name="ru.ftc.faktura.expobank" />
|
||||
<package android:name="com.bssys.vbrrretail" />
|
||||
<package android:name="ru.wildberries.razz" />
|
||||
<package android:name="ru.artsofte.russiafl" />
|
||||
<package android:name="ru.ftc.faktura.multibank" />
|
||||
<package android:name="ru.bank_hlynov.xbank" />
|
||||
<package android:name="ru.ftc.faktura.vesta" />
|
||||
<package android:name="com.isimplelab.ibank.solidarnost" />
|
||||
<package android:name="com.bssys.roscapretail" />
|
||||
<package android:name="ru.ftc.faktura.kbhmb" />
|
||||
<package android:name="ru.mts.money" />
|
||||
<package android:name="ru.ftc.faktura.pskb" />
|
||||
<package android:name="ru.ftc.faktura.nskbl" />
|
||||
<package android:name="ru.akbars.mobile" />
|
||||
<package android:name="ru.ftc.faktura.rostfinance" />
|
||||
<package android:name="ru.ilb.bbmobile" />
|
||||
<package android:name="ru.mcbankrus.MCBankRus" />
|
||||
<package android:name="ru.albank.online.aebit" />
|
||||
<package android:name="com.bifit.mobile.citizen.icbru" />
|
||||
<package android:name="ru.ftc.faktura.orbank" />
|
||||
<package android:name="ru.ftc.faktura.agropromkredit" />
|
||||
<package android:name="com.energobank.digital" />
|
||||
<package android:name="com.bifit.mobile.citizen.kbnk" />
|
||||
<package android:name="ru.ftc.faktura.sdm" />
|
||||
<package android:name="com.bifit.mobile.citizen.mbb" />
|
||||
<package android:name="ru.ftc.faktura.absolutbank" />
|
||||
<package android:name="ru.ftc.faktura.akcept" />
|
||||
<package android:name="ru.faktura.etbank.person" />
|
||||
<package android:name="ru.ftc.faktura.razvitiestolica" />
|
||||
<package android:name="ru.ftc.faktura.resokreditbank" />
|
||||
<package android:name="ru.yoo.money" />
|
||||
<package android:name="ru.bspb" />
|
||||
<package android:name="com.isimplelab.ibank.kubankredit" />
|
||||
<package android:name="com.tochka.bank" />
|
||||
<package android:name="com.bssys.novikomretail" />
|
||||
<package android:name="ru.rosdorbank.physical" />
|
||||
<package android:name="ru.ftc.faktura.primsoc" />
|
||||
<package android:name="ru.ftc.faktura.banksaratov" />
|
||||
<package android:name="ru.ftc.faktura.tavrich" />
|
||||
<package android:name="com.bifit.mobile.citizen.thbank" />
|
||||
<package android:name="ru.ftc.faktura.kremlevskiy" />
|
||||
<package android:name="ru.ftc.faktura.tkbbank" />
|
||||
<package android:name="ru.chelyabinvestbank.investpay" />
|
||||
<package android:name="ru.ftc.faktura.derzhava" />
|
||||
<package android:name="ru.nbd.android" />
|
||||
<package android:name="ru.sngb.dbo.client.android" />
|
||||
<package android:name="ru.ftc.faktura.united" />
|
||||
<package android:name="ru.ftc.faktura.venetsbank" />
|
||||
<package android:name="ru.letobank.Prometheus" />
|
||||
<package android:name="ru.simpls.brs2.mobbank" />
|
||||
<package android:name="com.bifit.dvbank" />
|
||||
<package android:name="ru.ftc.faktura.intesabank" />
|
||||
<package android:name="com.mmonline.mobile" />
|
||||
<package android:name="cb.ibank.vuz" />
|
||||
<package android:name="ru.uralprombank.mobilebanknew.googleplay" />
|
||||
<package android:name="ru.ftc.faktura.nsbank" />
|
||||
<package android:name="ru.emb.android" />
|
||||
<package android:name="ru.ftc.faktura.maritimebank" />
|
||||
<package android:name="com.credituralbank.CUBmobilenew" />
|
||||
<package android:name="com.isimplelab.isimpleceo.uglemet" />
|
||||
<package android:name="com.bssys.stroylesretail" />
|
||||
<package android:name="ru.ftc.faktura.iturup" />
|
||||
<package android:name="ru.ftc.faktura.finbank" />
|
||||
<package android:name="ru.faktura.gaztransbank.person" />
|
||||
<package android:name="com.isimplelab.ibank.chelind" />
|
||||
<package android:name="ru.ftc.faktura.nokss" />
|
||||
<package android:name="ru.ftc.faktura.vlbb" />
|
||||
<package android:name="ru.ftc.faktura.kbb" />
|
||||
<package android:name="ru.faktura.avtograd.person" />
|
||||
<package android:name="ru.ftc.faktura.forshtadt" />
|
||||
<package android:name="ru.ftc.faktura.solidbank" />
|
||||
<package android:name="ru.ftc.faktura.alexbank" />
|
||||
<package android:name="ru.ftc.faktura.akibank" />
|
||||
<package android:name="ru.ftc.faktura.nipbank" />
|
||||
<package android:name="ru.ftc.faktura.alefbank" />
|
||||
<package android:name="com.bifit.vfbank" />
|
||||
<package android:name="com.bssys.uralfdretail" />
|
||||
<package android:name="com.snb.online" />
|
||||
<package android:name="com.bifit.mobile.citizen.okbank" />
|
||||
<package android:name="com.tatsotsbank.dbomobile" />
|
||||
<package android:name="ru.vtkbank.android" />
|
||||
<package android:name="ru.faktura.jtbank.person" />
|
||||
<package android:name="ru.ftc.faktura.ns" />
|
||||
<package android:name="ru.ftc.faktura.zemskybank" />
|
||||
<package android:name="com.bssys.aversretail" />
|
||||
<package android:name="ru.rusnarbank.correqts.retail" />
|
||||
<package android:name="ru.ftc.faktura.baikalinvestbank" />
|
||||
<package android:name="ru.sbi.android" />
|
||||
<package android:name="com.metkombank.mobile" />
|
||||
<package android:name="com.isimplelab.ionic.kazan.fl" />
|
||||
<package android:name="com.intervale.sbpas" />
|
||||
<package android:name="com.bifit.mobile.citizen.zarech" />
|
||||
<package android:name="ru.centrinvest.mobilebanking2018" />
|
||||
<package android:name="com.mifors.izhcombank" />
|
||||
<package android:name="com.bifit.pmobile.isbank" />
|
||||
<package android:name="ru.zenit.android" />
|
||||
<package android:name="ru.briginvest.sense" />
|
||||
<package android:name="com.bifit.mobile.citizen.cfb" />
|
||||
<package android:name="com.isimplelab.ionic.gorbank.prod" />
|
||||
<package android:name="ru.ftc.faktura.moscombank" />
|
||||
<package android:name="com.timerbank.retail" />
|
||||
<package android:name="com.mob.android" />
|
||||
<package android:name="com.citibank.mobile.ru" />
|
||||
<package android:name="com.bifit.atbbank" />
|
||||
<package android:name="ru.mw" />
|
||||
<package android:name="ru.bankuralsib.mb.android" />
|
||||
<package android:name="com.banksoyuz.artsofte" />
|
||||
<package android:name="ru.rubank.ubsmobile" />
|
||||
<package android:name="cb.ibank" />
|
||||
<package android:name="ru.ipb.ubsmobile" />
|
||||
<package android:name="com.openwaygroup.ic.panda.poidem" />
|
||||
<package android:name="com.bifit.mobile.citizen.tenderbank" />
|
||||
<package android:name="ru.otpbank.mobile" />
|
||||
<package android:name="ru.krk.ubsmobile" />
|
||||
<package android:name="ru.homecredit.mycredit" />
|
||||
<package android:name="cz.bsc.rc" />
|
||||
<package android:name="com.bifit.mobile.citizen.belsocbank" />
|
||||
<package android:name="com.hicebank.android" />
|
||||
<package android:name="com.bpc.crossplatform_trading.bpc_trading" />
|
||||
<package android:name="com.bifit.nrb" />
|
||||
<package android:name="com.bifit.mobile.citizen.moskb" />
|
||||
<package android:name="ru.isfront.android.kt" />
|
||||
<package android:name="ru.ftc.faktura.novobank" />
|
||||
<package android:name="com.smp.android" />
|
||||
<package android:name="com.bifit.mobile.citizen.nkbank" />
|
||||
<package android:name="com.isimplelab.ibank.toyota" />
|
||||
<package android:name="com.bifit.mobile.private.bbr" />
|
||||
<package android:name="ru.ftc.faktura.lanta" />
|
||||
<package android:name="com.yandex.bank" />
|
||||
<package android:name="ru.ftc.faktura.bgfbank" />
|
||||
<package android:name="com.idamobile.android.LockoBank" />
|
||||
<package android:name="com.bifit.mobile.citizen.MCBank" />
|
||||
<package android:name="com.cetelem.cetelem_android" />
|
||||
<package android:name="com.bssys.gutaretail" />
|
||||
<package android:name="ru.ftc.faktura.siab" />
|
||||
<package android:name="com.isimplelab.ionic.round.prod" />
|
||||
<package android:name="com.priobank.prio" />
|
||||
<package android:name="com.inbank.mobilebank" />
|
||||
<package android:name="com.isimplelab.isimplemobile.payjet" />
|
||||
<package android:name="ru.ftc.faktura.agroros" />
|
||||
<package android:name="com.bifit.mobile.citizen.invb" />
|
||||
<package android:name="com.compassplus.mobicash.customer" />
|
||||
<package android:name="ru.finambank.app" />
|
||||
</queries>
|
||||
</manifest>
|
||||
|
||||
@@ -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
|
||||
|
||||
// Берём action_url из data-поля, если оно есть.
|
||||
val actionUrl = message.dataOfMap["action_url"]
|
||||
if (VideoCallPushBridge.isEndedVideoCall(message.dataOfMap)) {
|
||||
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)
|
||||
}
|
||||
|
||||
private fun showNotification(message: RemoteMessage, actionUrl: String?) {
|
||||
val context = applicationContext
|
||||
|
||||
// Интент для запуска Flutter-активити.
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
if (!actionUrl.isNullOrBlank()) {
|
||||
@@ -48,7 +54,7 @@ class HmsPushService : HmsMessageService() {
|
||||
val channelId = "hms_push_channel"
|
||||
|
||||
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))
|
||||
.setContentText(message.notification?.body ?: "")
|
||||
.setAutoCancel(true)
|
||||
@@ -58,5 +64,3 @@ class HmsPushService : HmsMessageService() {
|
||||
NotificationManagerCompat.from(context).notify(message.messageId.hashCode(), notification)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
package com.eatvioapp.app
|
||||
|
||||
import android.app.PictureInPictureParams
|
||||
import android.content.Intent
|
||||
import android.content.pm.ApplicationInfo
|
||||
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.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import com.hiennv.flutter_callkit_incoming.FlutterCallkitIncomingPlugin
|
||||
import com.huawei.agconnect.config.AGConnectServicesConfig
|
||||
import com.huawei.hms.aaid.HmsInstanceId
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -13,13 +18,68 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MainActivity: FlutterActivity() {
|
||||
companion object {
|
||||
@JvmStatic
|
||||
var isInForeground: Boolean = false
|
||||
private set
|
||||
}
|
||||
|
||||
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 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) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
|
||||
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 ->
|
||||
when (call.method) {
|
||||
"getHuaweiToken" -> {
|
||||
@@ -75,17 +135,67 @@ class MainActivity: FlutterActivity() {
|
||||
|
||||
// Обрабатываем возможный action_url, с которым была открыта Activity (например, из HMS уведомления).
|
||||
handleIntentForActionUrl(intent)
|
||||
captureCallKitLaunchIntent(intent)
|
||||
}
|
||||
|
||||
override fun onUserLeaveHint() {
|
||||
super.onUserLeaveHint()
|
||||
enterVideoCallPictureInPictureIfNeeded()
|
||||
videoCallPipChannel?.invokeMethod("onUserLeaveHint", null)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
captureCallKitLaunchIntent(intent)
|
||||
// Обработка нового интента, когда Activity уже запущена (тап по уведомлению).
|
||||
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?) {
|
||||
if (intent == null) return
|
||||
|
||||
if (intent.hasExtra(FlutterCallkitIncomingPlugin.EXTRA_CALLKIT_CALL_DATA)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Вариант 1: action_url лежит в extras.
|
||||
val fromExtras = intent.extras?.getString("action_url")
|
||||
|
||||
@@ -101,4 +211,19 @@ class MainActivity: FlutterActivity() {
|
||||
// Проксируем URL во Flutter, где уже есть общая логика навигации по диплинкам.
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
assets/icons/video_call/eatvio_c.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
3
assets/icons/video_call/poor_connection.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="18" height="14" viewBox="0 0 18 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 12.0833V7.91666C0 7.56944 0.121528 7.2743 0.364583 7.03125C0.607639 6.78819 0.902778 6.66666 1.25 6.66666C1.59722 6.66666 1.89236 6.78819 2.13542 7.03125C2.37847 7.2743 2.5 7.56944 2.5 7.91666V12.0833C2.5 12.4306 2.37847 12.7257 2.13542 12.9687C1.89236 13.2118 1.59722 13.3333 1.25 13.3333C0.902778 13.3333 0.607639 13.2118 0.364583 12.9687C0.121528 12.7257 0 12.4306 0 12.0833ZM5 12.0833V5.83333C5 5.48611 5.12153 5.19097 5.36458 4.94791C5.60764 4.70486 5.90278 4.58333 6.25 4.58333C6.59722 4.58333 6.89236 4.70486 7.13542 4.94791C7.37847 5.19097 7.5 5.48611 7.5 5.83333V12.0833C7.5 12.4306 7.37847 12.7257 7.13542 12.9687C6.89236 13.2118 6.59722 13.3333 6.25 13.3333C5.90278 13.3333 5.60764 13.2118 5.36458 12.9687C5.12153 12.7257 5 12.4306 5 12.0833ZM10 12.0833V3.75C10 3.40278 10.1215 3.10764 10.3646 2.86458C10.6076 2.62153 10.9028 2.5 11.25 2.5C11.5972 2.5 11.8924 2.62153 12.1354 2.86458C12.3785 3.10764 12.5 3.40278 12.5 3.75V12.0833C12.5 12.4306 12.3785 12.7257 12.1354 12.9687C11.8924 13.2118 11.5972 13.3333 11.25 13.3333C10.9028 13.3333 10.6076 13.2118 10.3646 12.9687C10.1215 12.7257 10 12.4306 10 12.0833ZM15 6.8125V1.25C15 0.902777 15.1215 0.607639 15.3646 0.364583C15.6076 0.121528 15.9028 0 16.25 0C16.5972 0 16.8924 0.121528 17.1354 0.364583C17.3785 0.607639 17.5 0.902777 17.5 1.25V6.54166C17.3611 6.51389 17.2257 6.49305 17.0938 6.47916C16.9618 6.46528 16.8194 6.45833 16.6667 6.45833C16.375 6.45833 16.0868 6.48958 15.8021 6.55208C15.5174 6.61458 15.25 6.70139 15 6.8125ZM16.0729 13.0937C15.9132 12.934 15.8333 12.7361 15.8333 12.5C15.8333 12.2639 15.9132 12.066 16.0729 11.9062C16.2326 11.7465 16.4306 11.6667 16.6667 11.6667C16.9028 11.6667 17.1007 11.7465 17.2604 11.9062C17.4201 12.066 17.5 12.2639 17.5 12.5C17.5 12.7361 17.4201 12.934 17.2604 13.0937C17.1007 13.2535 16.9028 13.3333 16.6667 13.3333C16.4306 13.3333 16.2326 13.2535 16.0729 13.0937ZM15.8333 10V8.75C15.8333 8.51389 15.9132 8.31597 16.0729 8.15625C16.2326 7.99652 16.4306 7.91666 16.6667 7.91666C16.9028 7.91666 17.1007 7.99652 17.2604 8.15625C17.4201 8.31597 17.5 8.51389 17.5 8.75V10C17.5 10.2361 17.4201 10.434 17.2604 10.5937C17.1007 10.7535 16.9028 10.8333 16.6667 10.8333C16.4306 10.8333 16.2326 10.7535 16.0729 10.5937C15.9132 10.434 15.8333 10.2361 15.8333 10Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
3
assets/icons/video_call/vc_cam_off.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.292969 2.29289C0.683493 1.90237 1.31651 1.90237 1.70703 2.29289L3.41406 3.99992H11.4424C12.3865 3.99991 13.1558 3.99969 13.7803 4.05071C14.4257 4.10345 15.0042 4.21564 15.543 4.49016C16.3897 4.92159 17.0783 5.61023 17.5098 6.45696C17.7843 6.99573 17.8965 7.57424 17.9492 8.21965C17.9518 8.25175 17.9517 8.28444 17.9541 8.31731L22.4189 5.12981C23.0808 4.65704 24 5.12991 24 5.94328V18.0566C24 18.8699 23.0808 19.3428 22.4189 18.87L18 15.7138V18.5859L19.707 20.2929C20.0976 20.6834 20.0976 21.3164 19.707 21.707C19.3165 22.0975 18.6835 22.0975 18.293 21.707L0.292969 3.70696C-0.097555 3.31643 -0.0975551 2.68342 0.292969 2.29289ZM1 7.99895C1.55212 7.99895 1.99974 8.44689 2 8.99895V13.3993C2 14.3959 2.00036 15.0836 2.04395 15.6171C2.08658 16.1387 2.16554 16.4249 2.27246 16.6347C2.51215 17.105 2.89491 17.4868 3.36523 17.7265C3.57511 17.8334 3.86102 17.9124 4.38281 17.955C4.91619 17.9986 5.60333 17.9989 6.59961 17.9989H11C11.5521 17.9989 11.9997 18.4469 12 18.9989C12 19.5512 11.5523 19.9989 11 19.9989H6.55762C5.61342 19.999 4.84421 19.9992 4.21973 19.9482C3.57428 19.8954 2.99583 19.7832 2.45703 19.5087C1.61035 19.0773 0.921646 18.3886 0.490235 17.5419C0.215871 17.0032 0.103504 16.4245 0.0507818 15.7792C-0.000207456 15.1548 -1.10191e-05 14.3854 5.0477e-07 13.4413V8.99895C0.00026378 8.44689 0.447878 7.99895 1 7.99895ZM16 16.5859V10.5995C16 9.60324 15.9986 8.91611 15.9551 8.38274C15.9125 7.86113 15.8344 7.57502 15.7275 7.36516C15.4879 6.89475 15.1052 6.51207 14.6348 6.27239C14.4249 6.16547 14.1389 6.0865 13.6172 6.04387C13.0837 6.00029 12.396 5.99992 11.3994 5.99992H5.41406L16 16.5859Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
BIN
assets/icons/video_call/vc_camera_switch.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
assets/icons/video_call/vc_control_chat.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
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 |
BIN
assets/icons/video_call/vc_control_leave.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
assets/icons/video_call/vc_control_mic.png
Normal file
|
After Width: | Height: | Size: 694 B |
BIN
assets/icons/video_call/vc_control_mic_off.png
Normal file
|
After Width: | Height: | Size: 855 B |
BIN
assets/icons/video_call/vc_control_sound_active.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
assets/icons/video_call/vc_control_video.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
assets/icons/video_call/vc_expand.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
3
assets/icons/video_call/vc_expand.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.83398 9.03418C6.1464 8.72177 6.6534 8.72178 6.96582 9.03418C7.27824 9.3466 7.27824 9.8536 6.96582 10.166L2.73047 14.4004H5.59961C6.04144 14.4004 6.40039 14.7584 6.40039 15.2002C6.40032 15.642 6.04139 16 5.59961 16H0.799805C0.750353 16 0.702243 15.994 0.655273 15.9854C0.650431 15.9845 0.645453 15.9844 0.640625 15.9834C0.632394 15.9817 0.624348 15.9795 0.616211 15.9775C0.599845 15.9737 0.583526 15.9697 0.567383 15.9648C0.525709 15.9522 0.485753 15.936 0.447266 15.917C0.370564 15.8792 0.298188 15.8294 0.234375 15.7656C0.170233 15.7015 0.119834 15.6289 0.0820312 15.5518C0.0522722 15.4911 0.0292924 15.4265 0.015625 15.3584C0.0146554 15.3536 0.0145519 15.3486 0.0136719 15.3438C0.0052094 15.2971 7.59671e-06 15.2493 0 15.2002V10.4004C0 9.95863 0.358067 9.59972 0.799805 9.59961C1.24163 9.59961 1.59961 9.95856 1.59961 10.4004V13.2686L5.83398 9.03418ZM15.2002 0C15.2493 1.2747e-05 15.2971 0.0052045 15.3438 0.0136719C15.3486 0.0145519 15.3536 0.0146554 15.3584 0.015625C15.3666 0.017277 15.3747 0.0195822 15.3828 0.0214844C15.3992 0.0253083 15.4155 0.0293148 15.4316 0.0341797C15.4733 0.0467652 15.5133 0.0631386 15.5518 0.0820312C15.6289 0.119834 15.7015 0.170233 15.7656 0.234375C15.8294 0.298188 15.8792 0.370564 15.917 0.447266C15.9252 0.463964 15.9334 0.480716 15.9404 0.498047C15.9612 0.549032 15.9755 0.601773 15.9854 0.655273C15.994 0.702239 16 0.750358 16 0.799805V5.59961C16 6.04137 15.6419 6.40028 15.2002 6.40039C14.7584 6.40039 14.4004 6.04144 14.4004 5.59961V2.73047L10.166 6.96582C9.8536 7.27824 9.3466 7.27824 9.03418 6.96582C8.72178 6.6534 8.72177 6.1464 9.03418 5.83398L13.2695 1.59961H10.4004C9.95856 1.59961 9.59961 1.24163 9.59961 0.799805C9.59972 0.358067 9.95863 0 10.4004 0H15.2002Z" fill="#EFF0FA"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
9
assets/icons/video_call/vc_mic_off.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16 6.3V6C16 3.79 14.21 2 12 2C9.79 2 8 3.79 8 6V11" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9.04004 14.19C9.77004 15 10.83 15.5 12 15.5C14.21 15.5 16 13.71 16 11.5V11" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6.77979 16.9499C8.14979 18.2199 9.97978 18.9999 11.9998 18.9999C16.2198 18.9999 19.6498 15.5699 19.6498 11.3499V9.6499" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4.3501 9.6499V11.3499C4.3501 12.4099 4.5601 13.4099 4.9501 14.3299" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M20.0702 2.83984L3.93018 18.9898" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M11 3V6" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12 19V22" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
5
assets/icons/video_call/vc_mic_on.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 15.5C14.21 15.5 16 13.71 16 11.5V6C16 3.79 14.21 2 12 2C9.79 2 8 3.79 8 6V11.5C8 13.71 9.79 15.5 12 15.5Z" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4.35059 9.6499V11.3499C4.35059 15.5699 7.78059 18.9999 12.0006 18.9999C16.2206 18.9999 19.6506 15.5699 19.6506 11.3499V9.6499" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12 19V22" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 629 B |
3
assets/icons/video_call/vc_shrink.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.40023 8.79965C6.44894 8.79967 6.49651 8.80496 6.54281 8.81332C6.54796 8.81425 6.5533 8.81424 6.55844 8.81527C6.56666 8.81692 6.57472 8.81923 6.58285 8.82113C6.5992 8.82496 6.61555 8.82896 6.63168 8.83383C6.64056 8.83651 6.64929 8.83963 6.65805 8.84262C6.69881 8.85648 6.73865 8.87374 6.77719 8.89437C6.80436 8.90894 6.83012 8.9257 6.85531 8.9432C6.89387 8.97001 6.93129 8.99965 6.96566 9.03402C6.9997 9.06805 7.02889 9.10526 7.05551 9.1434C7.05897 9.14836 7.06289 9.153 7.06625 9.15804C7.12722 9.24979 7.16631 9.35128 7.18539 9.4559C7.19389 9.50255 7.20001 9.55035 7.20004 9.59945V14.4002C7.19984 14.8418 6.84178 15.1998 6.40023 15.2C5.95852 15.2 5.59964 14.8419 5.59945 14.4002V11.5311L1.36508 15.7655C1.05264 16.0777 0.546577 16.0778 0.234217 15.7655C-0.0781257 15.4531 -0.0780189 14.947 0.234217 14.6346L4.46762 10.4002H1.59945C1.15782 10.4 0.799647 10.0411 0.799647 9.59945C0.799876 9.15796 1.15796 8.79988 1.59945 8.79965H6.40023ZM14.6346 0.234217C14.947 -0.0780189 15.4531 -0.0781257 15.7655 0.234217C16.0778 0.546577 16.0777 1.05264 15.7655 1.36508L11.5321 5.59945H14.4002C14.8419 5.59964 15.2 5.95852 15.2 6.40023C15.1998 6.84178 14.8418 7.19984 14.4002 7.20004H9.59945C9.55035 7.20001 9.50255 7.19389 9.4559 7.18539C9.45074 7.18445 9.44541 7.18448 9.44027 7.18344C9.43204 7.18177 9.42399 7.17949 9.41586 7.17758C9.3995 7.17372 9.38317 7.16978 9.36703 7.16488C9.35943 7.16257 9.35207 7.1596 9.34457 7.15707C9.30221 7.14282 9.2605 7.12587 9.22054 7.10433C9.19365 7.08982 9.16833 7.0729 9.1434 7.05551C9.10526 7.02889 9.06805 6.9997 9.03402 6.96566C8.99965 6.93129 8.97001 6.89387 8.9432 6.85531C8.9257 6.83012 8.90894 6.80436 8.89437 6.77719C8.85465 6.70298 8.82796 6.62378 8.81332 6.54281C8.80496 6.49651 8.79967 6.44894 8.79965 6.40023V1.59945C8.79988 1.15796 9.15796 0.799876 9.59945 0.799647C10.0411 0.799647 10.4 1.15782 10.4002 1.59945V4.46762L14.6346 0.234217Z" fill="#EFF0FA"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
3
assets/icons/video_call/warning.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 0C15.523 0 20 4.477 20 10C20 15.523 15.523 20 10 20C4.477 20 0 15.523 0 10C0 4.477 4.477 0 10 0ZM10 2C7.87827 2 5.84344 2.84285 4.34315 4.34315C2.84285 5.84344 2 7.87827 2 10C2 12.1217 2.84285 14.1566 4.34315 15.6569C5.84344 17.1571 7.87827 18 10 18C12.1217 18 14.1566 17.1571 15.6569 15.6569C17.1571 14.1566 18 12.1217 18 10C18 7.87827 17.1571 5.84344 15.6569 4.34315C14.1566 2.84285 12.1217 2 10 2ZM10 13C10.2652 13 10.5196 13.1054 10.7071 13.2929C10.8946 13.4804 11 13.7348 11 14C11 14.2652 10.8946 14.5196 10.7071 14.7071C10.5196 14.8946 10.2652 15 10 15C9.73478 15 9.48043 14.8946 9.29289 14.7071C9.10536 14.5196 9 14.2652 9 14C9 13.7348 9.10536 13.4804 9.29289 13.2929C9.48043 13.1054 9.73478 13 10 13ZM10 4C10.2652 4 10.5196 4.10536 10.7071 4.29289C10.8946 4.48043 11 4.73478 11 5V11C11 11.2652 10.8946 11.5196 10.7071 11.7071C10.5196 11.8946 10.2652 12 10 12C9.73478 12 9.48043 11.8946 9.29289 11.7071C9.10536 11.5196 9 11.2652 9 11V5C9 4.73478 9.10536 4.48043 9.29289 4.29289C9.48043 4.10536 9.73478 4 10 4Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -47,8 +47,6 @@ post_install do |installer|
|
||||
## dart: PermissionGroup.photos
|
||||
'PERMISSION_PHOTOS=1',
|
||||
'PERMISSION_NOTIFICATIONS=1',
|
||||
## dart: PermissionGroup.mediaLibrary
|
||||
'PERMISSION_MEDIA_LIBRARY=1',
|
||||
]
|
||||
|
||||
end
|
||||
|
||||
@@ -397,7 +397,7 @@
|
||||
);
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Eatvio;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -548,7 +548,7 @@
|
||||
);
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Eatvio;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -587,7 +587,7 @@
|
||||
);
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Eatvio;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
|
||||
@@ -1,30 +1,517 @@
|
||||
import AVFAudio
|
||||
import CallKit
|
||||
import Flutter
|
||||
import PushKit
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
import YandexLoginSDK
|
||||
import flutter_callkit_incoming
|
||||
import flutter_foreground_task
|
||||
|
||||
@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(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
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, *)
|
||||
func application(
|
||||
_ application: UIApplication, continue userActivity: NSUserActivity,
|
||||
restorationHandler: @escaping ([Any]?) -> Void
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
continue userActivity: NSUserActivity,
|
||||
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
|
||||
) -> Bool {
|
||||
YXLSdk.shared.processUserActivity(userActivity)
|
||||
return true
|
||||
if YXLSdk.shared.processUserActivity(userActivity) {
|
||||
return true
|
||||
}
|
||||
return super.application(
|
||||
application,
|
||||
continue: userActivity,
|
||||
restorationHandler: restorationHandler
|
||||
)
|
||||
}
|
||||
|
||||
override public func application(
|
||||
_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]
|
||||
_ app: UIApplication,
|
||||
open url: URL,
|
||||
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
|
||||
) -> 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(
|
||||
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,18 +31,22 @@
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</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>
|
||||
<string>Предоставление доступа позволит Вам делать фотографии и добавлять к записям</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Нужен для звука в видеозвонках с консультантом</string>
|
||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||
<string>Может использоваться для стабильного поддержания связи во время звонка.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string></string>
|
||||
<string>Может использоваться для стабильного поддержания связи во время звонка.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Предоставление доступа позволит Вам добавлять фото к записям</string>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
<string>voip</string>
|
||||
<string>fetch</string>
|
||||
<string>remote-notification</string>
|
||||
</array>
|
||||
@@ -72,6 +76,57 @@
|
||||
<string>yandexauth</string>
|
||||
<string>yandexauth2</string>
|
||||
<string>yandexauth4</string>
|
||||
<!-- SBP bank schemes (top ~50 by popularity, iOS 15+ caps at 50) -->
|
||||
<string>bank100000000111</string>
|
||||
<string>bank100000000004</string>
|
||||
<string>bank100000000005</string>
|
||||
<string>bank100000000008</string>
|
||||
<string>bank100000000007</string>
|
||||
<string>bank100000000015</string>
|
||||
<string>bank100000000001</string>
|
||||
<string>bank100000000010</string>
|
||||
<string>bank100000000013</string>
|
||||
<string>bank100000000012</string>
|
||||
<string>bank100000000020</string>
|
||||
<string>bank100000000025</string>
|
||||
<string>bank100000000030</string>
|
||||
<string>bank100000000030_new</string>
|
||||
<string>bank100000000003</string>
|
||||
<string>bank100000000043</string>
|
||||
<string>bank100000000028</string>
|
||||
<string>bank100000000086</string>
|
||||
<string>bank100000000011</string>
|
||||
<string>bank100000000044</string>
|
||||
<string>bank100000000049</string>
|
||||
<string>bank100000000500</string>
|
||||
<string>bank100000000095</string>
|
||||
<string>bank100000000900</string>
|
||||
<string>bank100000000056</string>
|
||||
<string>bank100000000053</string>
|
||||
<string>bank100000000121</string>
|
||||
<string>bank100000000082</string>
|
||||
<string>bank100000000127</string>
|
||||
<string>bank100000000017</string>
|
||||
<string>bank100000000087</string>
|
||||
<string>bank100000000052</string>
|
||||
<string>bank100000000006</string>
|
||||
<string>bank100000000098</string>
|
||||
<string>bank100000000092</string>
|
||||
<string>bank100000000229</string>
|
||||
<string>bank100000000027</string>
|
||||
<string>bank100000000080</string>
|
||||
<string>bank100000000122</string>
|
||||
<string>bank100000000124</string>
|
||||
<string>bank100000000118</string>
|
||||
<string>bank100000000159</string>
|
||||
<string>bank100000000146</string>
|
||||
<string>bank100000000069</string>
|
||||
<string>bank100000000140</string>
|
||||
<string>bank100000000047</string>
|
||||
<string>bank100000000099</string>
|
||||
<string>bank100000000135</string>
|
||||
<string>bank100000000139</string>
|
||||
<string>bank100000000022</string>
|
||||
</array>
|
||||
<key>YAClientId</key>
|
||||
<string>5399b03636104c6d920b87c1ad329254</string>
|
||||
@@ -85,6 +140,14 @@
|
||||
<string>yx5399b03636104c6d920b87c1ad329254</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>Eatvio</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>eatvio</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -75,16 +75,24 @@ platform :ios do
|
||||
xcodeproj: "Runner.xcodeproj",
|
||||
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(
|
||||
workspace: "Runner.xcworkspace",
|
||||
scheme: "Runner",
|
||||
export_method: "app-store",
|
||||
codesigning_identity: "Apple Distribution",
|
||||
# codesigning_identity: "Apple Distribution",
|
||||
export_team_id: "VL68C36HYS"
|
||||
)
|
||||
|
||||
|
||||
|
||||
app_store_connect_api_key(
|
||||
key_id: "325JS9JCZH",
|
||||
issuer_id: "dfa27d1d-2bb8-4d4d-847e-0b32f9b880b3",
|
||||
@@ -92,6 +100,7 @@ platform :ios do
|
||||
duration: 1200,
|
||||
in_house: false
|
||||
)
|
||||
|
||||
|
||||
begin
|
||||
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/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/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/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -35,6 +36,7 @@ class AppConfig {
|
||||
/// Список глобальных BlocProvider'ов
|
||||
/// Используются lazy: true для отложенной инициализации, чтобы не блокировать старт приложения
|
||||
List<BlocProvider> get globalBlocProviders => [
|
||||
BlocProvider<VideoCallSessionCubit>.value(value: sl<VideoCallSessionCubit>()),
|
||||
BlocProvider<NewMessagesCubit>(lazy: true, create: (context) => sl<NewMessagesCubit>()),
|
||||
BlocProvider<NotificationsCubit>(lazy: true, create: (context) => sl<NotificationsCubit>()),
|
||||
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/locator_service.dart' as di;
|
||||
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:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
@@ -47,6 +48,7 @@ class AppInitializer {
|
||||
throw TimeoutException('Firebase initialization timeout');
|
||||
},
|
||||
);
|
||||
VideoCallFcmHandler.registerBackgroundHandler();
|
||||
if (sl<AppConfig>().buildType == AppBuildType.prod) {
|
||||
try {
|
||||
await FirebaseMessaging.instance.requestPermission().timeout(const Duration(seconds: 5));
|
||||
|
||||
@@ -123,7 +123,21 @@ class AppRouter {
|
||||
return '${InfoByOrderScreen.path}?planId=$consultationId';
|
||||
}
|
||||
|
||||
static String? _resolveDeepLinkUri(Uri uri) {
|
||||
/// Deep link после оплаты Alfa: `finance/alfa/return/{orderId}` → чат с query `orderId`.
|
||||
static String? _resolveFinanceAlfaReturnDeepLink(List<String> effectiveSegments) {
|
||||
if (effectiveSegments.length != 4 ||
|
||||
effectiveSegments[1] != 'alfa' ||
|
||||
effectiveSegments[2] != 'return') {
|
||||
return null;
|
||||
}
|
||||
final orderId = effectiveSegments[3];
|
||||
if (orderId.isEmpty) return null;
|
||||
developer.log('Deep link: finance/alfa/return/$orderId', name: 'AppRouter');
|
||||
return '${RouteConstants.chatScreen}?orderId=$orderId';
|
||||
}
|
||||
|
||||
/// Разбирает deep link URI в внутренний route target (или null, если host не наш).
|
||||
static String? resolveDeepLinkTarget(Uri uri) {
|
||||
final host = uri.host.toLowerCase();
|
||||
final segments = uri.pathSegments;
|
||||
|
||||
@@ -189,6 +203,10 @@ class AppRouter {
|
||||
final target = _resolveConsultationsDeepLink(effectiveSegments);
|
||||
if (target != null) return target;
|
||||
break;
|
||||
case 'finance':
|
||||
final target = _resolveFinanceAlfaReturnDeepLink(effectiveSegments);
|
||||
if (target != null) return target;
|
||||
break;
|
||||
}
|
||||
|
||||
developer.log('Unknown deep link format: ${effectiveSegments.length} effective segments, type=$type, redirecting to feed', name: 'AppRouter');
|
||||
@@ -196,7 +214,7 @@ class AppRouter {
|
||||
}
|
||||
|
||||
static String? _handleRedirect(BuildContext context, GoRouterState state) {
|
||||
return _resolveDeepLinkUri(state.uri);
|
||||
return resolveDeepLinkTarget(state.uri);
|
||||
}
|
||||
|
||||
static void navigateByDeepLinkUrl(String url) {
|
||||
@@ -230,7 +248,7 @@ class AppRouter {
|
||||
);
|
||||
}
|
||||
|
||||
final target = _resolveDeepLinkUri(uri);
|
||||
final target = resolveDeepLinkTarget(uri);
|
||||
FlutterNativeSplash.remove();
|
||||
if (target != null) {
|
||||
appRouter.go(target);
|
||||
|
||||
@@ -9,4 +9,5 @@ const List<String> planTypeValues = ['fit', 'slim', 'wellness', 'behavior'];
|
||||
const newMessagesChannelName = 'private-new-chat-messages';
|
||||
const newNotificationsChannelName = 'private-message-events';
|
||||
const directChatChannelName = 'private-direct-chat';
|
||||
const presenceDirectChatChannelName = 'presence-direct-chat';
|
||||
const thresholdForPagination = 150;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import 'package:eatvio_app/features/my_finance/presentation/cubit/user_cards_cub
|
||||
import 'package:eatvio_app/features/my_plans/presentation/cubit/my_plans_cubit.dart';
|
||||
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
|
||||
import 'package:eatvio_app/features/order_info/cubit/order_info_cubit.dart';
|
||||
import 'package:eatvio_app/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_cubit.dart';
|
||||
import 'package:eatvio_app/features/personal_diet_suggestion/cubit/personal_diet_suggestion_cubit.dart';
|
||||
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_cubit.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/plans_cubit/plans_cubit.dart' as profile_plans;
|
||||
@@ -51,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/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/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:get_it/get_it.dart';
|
||||
|
||||
@@ -71,6 +74,18 @@ class CubitRegistrations {
|
||||
sl.registerLazySingleton<ProfileAvatarCubit>(() => ProfileAvatarCubit(profileRepository: sl()));
|
||||
sl.registerLazySingleton<ChatsCubit>(() => ChatsCubit(chatRepository: sl(), wsManager: sl(), profileRepository: sl()));
|
||||
sl.registerLazySingleton<InternetConnectionCubit>(() => InternetConnectionCubit());
|
||||
sl.registerLazySingleton<VideoCallManager>(
|
||||
() => VideoCallManager(
|
||||
videoCallRepository: sl(),
|
||||
profileRepository: sl(),
|
||||
),
|
||||
);
|
||||
sl.registerLazySingleton<VideoCallSessionCubit>(
|
||||
() => VideoCallSessionCubit(
|
||||
videoCallManager: sl(),
|
||||
systemPipCoordinator: sl(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Регистрация Cubit'ов как фабрик
|
||||
@@ -135,6 +150,9 @@ class CubitRegistrations {
|
||||
sl.registerFactory<ComplainingCubit>(() => ComplainingCubit(repository: sl()));
|
||||
sl.registerFactory<UserCardsCubit>(() => UserCardsCubit(cardsRepository: sl()));
|
||||
sl.registerFactory<OrderInfoCubit>(() => OrderInfoCubit(chatRepository: sl(), profileRepository: sl()));
|
||||
sl.registerFactory<ConsultationPaymentCubit>(
|
||||
() => ConsultationPaymentCubit(paymentRepository: sl(), sbpRepository: sl()),
|
||||
);
|
||||
sl.registerFactory<ConsultationsCubit>(() => ConsultationsCubit(consultationsRepository: sl()));
|
||||
sl.registerFactory<ConsultantCommentsCubit>(() => ConsultantCommentsCubit(diaryRepository: sl()));
|
||||
sl.registerFactory<CancelPlanCubit>(() => CancelPlanCubit(cancelPlanRepository: sl()));
|
||||
|
||||
@@ -24,6 +24,8 @@ import 'package:eatvio_app/features/my_diets/repositories/my_diets_repository.da
|
||||
import 'package:eatvio_app/features/my_finance/repositories/cards_repository.dart';
|
||||
import 'package:eatvio_app/features/my_plans/repositories/my_diets_repository.dart';
|
||||
import 'package:eatvio_app/features/notifications/repository/notifications_repository.dart';
|
||||
import 'package:eatvio_app/features/payment/repositories/payment_repository.dart';
|
||||
import 'package:eatvio_app/features/payment/repositories/sbp_repository.dart';
|
||||
import 'package:eatvio_app/features/personal_diet_suggestion/repository/personal_diet_suggestion_repository.dart';
|
||||
import 'package:eatvio_app/features/plans/repositories/plan_repository.dart';
|
||||
import 'package:eatvio_app/features/plans/repositories/plans_repository.dart';
|
||||
@@ -33,6 +35,7 @@ import 'package:eatvio_app/features/recipe/repositories/my_recipes_repository.da
|
||||
import 'package:eatvio_app/features/registration/repositories/registration_repository.dart';
|
||||
import 'package:eatvio_app/features/registration_v2/repositories/registration_repository_v2.dart';
|
||||
import 'package:eatvio_app/features/settings/repositories/service_repository.dart';
|
||||
import 'package:eatvio_app/features/video_calls/repositories/video_call_repository.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
/// Регистрация всех Repository в системе внедрения зависимостей
|
||||
@@ -49,6 +52,7 @@ class RepositoryRegistrations {
|
||||
static void _registerSingletonRepositories() {
|
||||
// Core repositories
|
||||
sl.registerLazySingleton<ChatRepository>(() => ChatRepository(chatService: sl()));
|
||||
sl.registerLazySingleton<VideoCallRepository>(() => VideoCallRepository(videoCallService: sl()));
|
||||
sl.registerLazySingleton<FeedRepository>(() => FeedRepository(feedService: sl()));
|
||||
sl.registerLazySingleton<ProfileRepository>(() => ProfileRepository(profileService: sl()));
|
||||
sl.registerLazySingleton<CalendarRepository>(() => CalendarRepository(calendarService: sl()));
|
||||
@@ -72,6 +76,8 @@ class RepositoryRegistrations {
|
||||
sl.registerLazySingleton<BlockedUsersRepository>(() => BlockedUsersRepository(blockedUserService: sl()));
|
||||
sl.registerLazySingleton<CommentsRepository>(() => CommentsRepository(service: sl()));
|
||||
sl.registerLazySingleton<NotificationSettingsRepository>(() => NotificationSettingsRepository(sl()));
|
||||
sl.registerLazySingleton<SbpRepository>(() => SbpRepository(sbpService: sl()));
|
||||
sl.registerLazySingleton<PaymentRepository>(() => PaymentRepository(chatService: sl()));
|
||||
sl.registerLazySingleton<PersonalDietSuggestionRepository>(() => PersonalDietSuggestionRepository(personalDietSuggestionService: sl()));
|
||||
sl.registerLazySingleton<RegistrationRepositoryV2>(() => RegistrationRepositoryV2(registrationService: sl()));
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import 'package:eatvio_app/features/my_diets/services/my_diets_service.dart';
|
||||
import 'package:eatvio_app/features/my_finance/services/cards_service.dart';
|
||||
import 'package:eatvio_app/features/my_plans/services/my_plans_service.dart';
|
||||
import 'package:eatvio_app/features/notifications/service/notifications_service.dart';
|
||||
import 'package:eatvio_app/features/payment/services/sbp_service.dart';
|
||||
import 'package:eatvio_app/features/personal_diet_suggestion/services/personal_diet_suggestion_service.dart';
|
||||
import 'package:eatvio_app/features/plans/services/plan_service.dart';
|
||||
import 'package:eatvio_app/features/plans/services/plans_service.dart';
|
||||
@@ -35,6 +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_v2/services/registration_service_v2.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_system_pip_coordinator.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
/// Регистрация всех Service в системе внедрения зависимостей
|
||||
@@ -50,6 +54,7 @@ class ServiceRegistrations {
|
||||
static void _registerSingletonServices() {
|
||||
// Core services
|
||||
sl.registerLazySingleton<ChatService>(() => ChatService(sl()));
|
||||
sl.registerLazySingleton<VideoCallService>(() => VideoCallService(sl()));
|
||||
sl.registerLazySingleton<FeedService>(() => FeedService(sl()));
|
||||
sl.registerLazySingleton<ProfileService>(() => ProfileService(sl()));
|
||||
sl.registerLazySingleton<CalendarService>(() => CalendarService(sl()));
|
||||
@@ -73,6 +78,12 @@ class ServiceRegistrations {
|
||||
sl.registerLazySingleton<CreatePlanService>(() => CreatePlanService(sl()));
|
||||
sl.registerLazySingleton<NotificationsService>(() => NotificationsService(sl()));
|
||||
sl.registerLazySingleton<FirebaseApi>(() => FirebaseApi(errorReporter: sl()));
|
||||
sl.registerLazySingleton<VideoCallCallKitCoordinator>(
|
||||
() => VideoCallCallKitCoordinator(firebaseApi: sl()),
|
||||
);
|
||||
sl.registerLazySingleton<VideoCallSystemPipCoordinator>(
|
||||
() => VideoCallSystemPipCoordinator(),
|
||||
);
|
||||
sl.registerLazySingleton<ResetPasswordService>(() => ResetPasswordService(sl()));
|
||||
sl.registerLazySingleton<BlockedUserService>(() => BlockedUserService(sl()));
|
||||
sl.registerLazySingleton<CommentsService>(() => CommentsService(sl()));
|
||||
@@ -80,6 +91,7 @@ class ServiceRegistrations {
|
||||
sl.registerLazySingleton<ConsultantRatingService>(() => ConsultantRatingService(sl()));
|
||||
sl.registerLazySingleton<NotificationSettingsService>(() => NotificationSettingsService(sl()));
|
||||
sl.registerLazySingleton<ViewsService>(() => ViewsService(sl()));
|
||||
sl.registerLazySingleton<SbpService>(() => const SbpServiceImpl());
|
||||
sl.registerLazySingleton<PersonalDietSuggestionService>(() => PersonalDietSuggestionService(sl()));
|
||||
sl.registerLazySingleton<RegistrationServiceV2>(() => RegistrationServiceV2(sl()));
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ class ErrorReportPolicy {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
case DioExceptionType.sendTimeout:
|
||||
case DioExceptionType.receiveTimeout:
|
||||
case DioExceptionType.transformTimeout:
|
||||
case DioExceptionType.connectionError:
|
||||
return true;
|
||||
case DioExceptionType.unknown:
|
||||
|
||||
@@ -12,6 +12,7 @@ import 'package:eatvio_app/core/token_storage.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/services.dart' show MethodChannel, PlatformException;
|
||||
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
|
||||
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
|
||||
|
||||
class FirebaseApi {
|
||||
final ErrorReporter _errorReporter;
|
||||
@@ -87,6 +88,9 @@ class FirebaseApi {
|
||||
throw TimeoutException('registerPushToken timeout');
|
||||
},
|
||||
);
|
||||
if (Platform.isIOS) {
|
||||
await _registerIosVoipPushToken();
|
||||
}
|
||||
} catch (e, st) {
|
||||
log(e.toString(), stackTrace: st);
|
||||
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 {
|
||||
if (!Platform.isAndroid) return false;
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import 'dart:developer' as developer;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
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:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
@@ -50,7 +57,37 @@ class PushNavigationHandler {
|
||||
}
|
||||
|
||||
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?;
|
||||
if (actionUrl == null || actionUrl.isEmpty) {
|
||||
developer.log('PushNavigationHandler: no action_url in message data, source=$source, data=$data', name: 'PushNavigationHandler');
|
||||
|
||||
@@ -14,6 +14,8 @@ import 'package:eatvio_app/features/diary/presentation/pages/diary_screen.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/pages/empty_chat_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/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/stepper_become_consultant_screen.dart';
|
||||
import 'package:eatvio_app/features/consultant_rating/presentation/consultant_rating_screen.dart';
|
||||
@@ -210,13 +212,23 @@ class OtherRoutes {
|
||||
name: 'chatScreen',
|
||||
builder: (context, state) {
|
||||
final uuidFromQuery = state.uri.queryParameters['uuid'];
|
||||
if (uuidFromQuery != null) {
|
||||
return ChatScreen(uuid: uuidFromQuery, sendInitialMessage: false);
|
||||
final orderIdFromQuery = state.uri.queryParameters['orderId'];
|
||||
if (uuidFromQuery != null || orderIdFromQuery != null) {
|
||||
return ChatScreen(
|
||||
uuid: uuidFromQuery,
|
||||
orderId: orderIdFromQuery,
|
||||
sendInitialMessage: false,
|
||||
);
|
||||
}
|
||||
final data = state.extra as Map<String, dynamic>;
|
||||
final uuid = data['uuid'] as String;
|
||||
final uuid = data['uuid'] as String?;
|
||||
final orderId = data['orderId'] as String?;
|
||||
final sendInitialMessage = data['sendInitialMessage'] as bool?;
|
||||
return ChatScreen(uuid: uuid, sendInitialMessage: sendInitialMessage);
|
||||
return ChatScreen(
|
||||
uuid: uuid,
|
||||
orderId: orderId,
|
||||
sendInitialMessage: sendInitialMessage,
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
@@ -224,9 +236,44 @@ class OtherRoutes {
|
||||
name: 'emptyChatScreen',
|
||||
builder: (context, state) => EmptyChatScreen(user: state.extra as ProfileModel),
|
||||
),
|
||||
GoRoute(
|
||||
path: RouteConstants.videoCallScreen,
|
||||
name: VideoCallScreen.name,
|
||||
builder: (context, state) {
|
||||
final extra = state.extra;
|
||||
if (extra is Map<String, dynamic>) {
|
||||
return VideoCallScreen(
|
||||
conversationUuid: extra['conversationUuid'] 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?,
|
||||
entryMode: _videoCallEntryModeFromRouteExtra(extra['entryMode']),
|
||||
onBackToChat: extra['onBackToChat'] as void Function()?,
|
||||
onHangUp: extra['onHangUp'] as void Function()?,
|
||||
);
|
||||
}
|
||||
final uuid = state.uri.queryParameters['uuid'];
|
||||
if (uuid != null) {
|
||||
return VideoCallScreen(conversationUuid: uuid);
|
||||
}
|
||||
throw ArgumentError('VideoCallScreen: pass extra Map with conversationUuid or query uuid');
|
||||
},
|
||||
),
|
||||
|
||||
// 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(
|
||||
path: RouteConstants.stepperBecomeConsultantScreen,
|
||||
name: 'stepperBecomeConsultantScreen',
|
||||
@@ -234,7 +281,12 @@ class OtherRoutes {
|
||||
final data = state.extra as Map<String, dynamic>;
|
||||
final slug = data['slug'] as String?;
|
||||
final isCanceled = data['isCanceled'] as bool?;
|
||||
return StepperBecomeConsultantScreen(slug: slug, isCanceled: isCanceled);
|
||||
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()),
|
||||
@@ -307,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;
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ class RouteConstants {
|
||||
static const String photoViewScreen = '/photo-view-screen';
|
||||
static const String chatScreen = '/chat-screen';
|
||||
static const String emptyChatScreen = '/empty-chat-screen';
|
||||
static const String videoCallScreen = '/video-call-screen';
|
||||
static const String glossaryScreen = '/glossary-screen';
|
||||
static const String blockedUsersScreen = '/blocked-users-screen';
|
||||
static const String notificationsSettingsScreen = '/notifications-settings-screen';
|
||||
|
||||
@@ -80,6 +80,48 @@ class CustomTextStyles {
|
||||
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 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 {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
|
||||
/// Helper class for managing themes and colors.
|
||||
class ThemeHelper {
|
||||
// The current app theme
|
||||
@@ -200,6 +199,12 @@ class PrimaryColors {
|
||||
Color get grey7B => const Color(0xFF7B7B7B);
|
||||
|
||||
Color get greyD9 => const Color(0xFFD9D9D9);
|
||||
|
||||
Color get videoCallControlOffBackground => const Color(0xFFED2F2F);
|
||||
|
||||
Color get videoCallPipIconBackground => const Color(0xFF565656);
|
||||
|
||||
Color get videoCallNoticeBannerBackground => const Color(0xFF9D9D9D);
|
||||
}
|
||||
|
||||
PrimaryColors get appTheme => ThemeHelper().themeColor();
|
||||
|
||||
@@ -26,6 +26,13 @@ class WsManager {
|
||||
PrivateChannel? _privateNotificationChannel;
|
||||
PrivateChannel? _privateMessagesChannel;
|
||||
PrivateChannel? _privateDirectChatChannel;
|
||||
PresenceChannel? _presenceDirectChatChannel;
|
||||
|
||||
StreamSubscription? _presenceMemberAddedSubscription;
|
||||
StreamSubscription? _presenceMemberRemovedSubscription;
|
||||
StreamSubscription? _presenceSubscriptionSucceededSubscription;
|
||||
final List<_PresenceDirectChatListener> _presenceDirectChatListeners = [];
|
||||
bool _presenceChannelEventsBound = false;
|
||||
|
||||
StreamSubscription? _lifecycleSubscription;
|
||||
StreamSubscription? _eventSubscription;
|
||||
@@ -175,6 +182,107 @@ class WsManager {
|
||||
);
|
||||
}
|
||||
|
||||
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() {
|
||||
_privateMessagesChannel?.subscribe();
|
||||
_privateNotificationChannel?.subscribe();
|
||||
@@ -193,6 +301,19 @@ class WsManager {
|
||||
_privateDirectChatChannel?.unsubscribe();
|
||||
}
|
||||
|
||||
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 {
|
||||
await client?.disconnect();
|
||||
await _lifecycleSubscription?.cancel();
|
||||
@@ -204,6 +325,7 @@ class WsManager {
|
||||
client = null;
|
||||
userId = null;
|
||||
_privateDirectChatChannel?.unsubscribe();
|
||||
unsubscribePresenceDirectChatChannel();
|
||||
_privateMessagesChannel?.unsubscribe();
|
||||
_privateNotificationChannel?.unsubscribe();
|
||||
_privateDirectChatChannel = null;
|
||||
@@ -211,3 +333,13 @@ class WsManager {
|
||||
_privateNotificationChannel = null;
|
||||
}
|
||||
}
|
||||
|
||||
class _PresenceDirectChatListener {
|
||||
const _PresenceDirectChatListener({
|
||||
required this.counterpartUserId,
|
||||
required this.onCounterpartOnlineChanged,
|
||||
});
|
||||
|
||||
final int counterpartUserId;
|
||||
final void Function(bool isOnline) onCounterpartOnlineChanged;
|
||||
}
|
||||
|
||||
@@ -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/enums/complained_content.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/core/comments/comments_cubit/comments_cubit.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/widgets/blog_post_media_content.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/theme/theme_helper.dart';
|
||||
@@ -33,6 +36,7 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
final CommentsCubit commentsCubit = sl<CommentsCubit>();
|
||||
final textEditingController = TextEditingController();
|
||||
int? replyId;
|
||||
int currentGalleryIndex = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -40,6 +44,10 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
super.initState();
|
||||
}
|
||||
|
||||
void _openGallery(List<String> imageUrls, int index) {
|
||||
context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: imageUrls, index: index));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
@@ -242,8 +250,29 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
child: MarkdownHtmlWidget(markdownText: blogPostState.blogPostModel!.description),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (blogPostState.blogPostModel?.image.isNotEmpty ?? false)
|
||||
CachedNetworkImage(imageUrl: '${sl<AppConfig>().baseUrl}/storage/${blogPostState.blogPostModel?.image ?? ''}'),
|
||||
Builder(
|
||||
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,
|
||||
onOpenGallery: _openGallery,
|
||||
onGalleryChanged: (index) {
|
||||
setState(() {
|
||||
currentGalleryIndex = index;
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
|
||||
@@ -33,7 +33,7 @@ abstract class ChatMessageModel with _$ChatMessageModel {
|
||||
@JsonKey(name: 'read_by_to_user', defaultValue: false) required bool isRead,
|
||||
@JsonKey(name: 'is_author_message_auth_user') required bool? isCurrentUserAuthor,
|
||||
@JsonKey(name: 'room_id') required String? roomId,
|
||||
@JsonKey(name: 'type') String? type,
|
||||
@JsonKey(name: 'type', unknownEnumValue: ChatMessageType.unknown) ChatMessageType? type,
|
||||
@Default(MessageState.sent) @JsonKey(includeToJson: false, includeFromJson: false) MessageState messageState,
|
||||
@Default(null) @JsonKey(includeToJson: false, includeFromJson: false) String? uuid,
|
||||
}) = _ChatMessageModel;
|
||||
@@ -42,3 +42,22 @@ abstract class ChatMessageModel with _$ChatMessageModel {
|
||||
}
|
||||
|
||||
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: "profile_photo_path") required String? profilePhotoPath,
|
||||
@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;
|
||||
|
||||
factory ChatUser.fromJson(Map<String, dynamic> json) => _$ChatUserFromJson(json);
|
||||
|
||||
@@ -10,6 +10,8 @@ abstract class ChatUserModel with _$ChatUserModel {
|
||||
required String name,
|
||||
required String slug,
|
||||
@JsonKey(name: 'profile_photo_url') String? profilePhotoUrl,
|
||||
@Default(false) @JsonKey(name: 'is_online') bool isOnline,
|
||||
@Default('') @JsonKey(name: 'last_seen_text') String lastSeenText,
|
||||
}) = _ChatUserModel;
|
||||
|
||||
factory ChatUserModel.fromJson(Map<String, Object?> json) => _$ChatUserModelFromJson(json);
|
||||
|
||||
15
lib/features/chat/models/order_id_to_conversation_model.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'order_id_to_conversation_model.freezed.dart';
|
||||
part 'order_id_to_conversation_model.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class OrderIdToConversationModel with _$OrderIdToConversationModel {
|
||||
factory OrderIdToConversationModel({
|
||||
@JsonKey(name: 'chosen_plan_id') required int chosenPlanId,
|
||||
@JsonKey(name: 'conversation_uuid') required String conversationUuid,
|
||||
}) = _OrderIdToConversationModel;
|
||||
|
||||
factory OrderIdToConversationModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$OrderIdToConversationModelFromJson(json);
|
||||
}
|
||||
@@ -5,10 +5,12 @@ part 'payment_model.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class PaymentModel with _$PaymentModel {
|
||||
|
||||
factory PaymentModel({
|
||||
@JsonKey(name: 'checkout_url') required String paymentUrl,
|
||||
@JsonKey(name: 'order_id') String? orderId,
|
||||
@JsonKey(name: 'payment_provider') String? paymentProvider,
|
||||
}) = _PaymentModel;
|
||||
|
||||
factory PaymentModel.fromJson(Map<String, dynamic> json) => _$PaymentModelFromJson(json);
|
||||
}
|
||||
factory PaymentModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$PaymentModelFromJson(json);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ 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/chats_cubit/chats_cubit.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/my_finance/models/card_model.dart';
|
||||
import 'package:eatvio_app/features/my_finance/repositories/cards_repository.dart';
|
||||
import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart';
|
||||
@@ -34,6 +35,8 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
late int lastPage;
|
||||
bool isLoading = false;
|
||||
Timer? _timer;
|
||||
String? _activeChatUuid;
|
||||
void Function(bool isOnline)? _presenceOnlineListener;
|
||||
var uuid = const Uuid();
|
||||
|
||||
ChatRoomCubit({required this.chatRepository, required this.wsManager, required this.cardsRepository}) : super(const ChatRoomState.loading());
|
||||
@@ -91,17 +94,38 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> initChat(String uuid, {bool sendInitialMessage = false}) async {
|
||||
/// Инициализирует чат по [uuid] либо по [orderId] (resolve → conversation_uuid).
|
||||
///
|
||||
/// При наличии [orderId] сначала вызывается
|
||||
/// `GET /api/consultation/resolve/order_id_to_conversation/{orderId}`.
|
||||
///
|
||||
/// Возвращает conversation uuid, которым инициализировали чат (для reload без повторного resolve).
|
||||
Future<String?> initChat(String? uuid, {String? orderId, bool sendInitialMessage = false}) async {
|
||||
try {
|
||||
emit(const ChatRoomState.loading());
|
||||
final chatRoom = await chatRepository.getChatRoom(uuid);
|
||||
ChosenPlanModel? chosenPlan;
|
||||
if (chatRoom.chosenPlanId != null) {
|
||||
chosenPlan = await chatRepository.getChosenPlan(id: chatRoom.chosenPlanId!);
|
||||
|
||||
String? resolvedUuid = uuid;
|
||||
int? resolvedChosenPlanId;
|
||||
|
||||
if (orderId != null && orderId.isNotEmpty) {
|
||||
final resolved = await chatRepository.resolveOrderIdToConversation(orderId: orderId);
|
||||
resolvedUuid = resolved.conversationUuid;
|
||||
resolvedChosenPlanId = resolved.chosenPlanId;
|
||||
}
|
||||
final chatMessages = await chatRepository.getChatMessages(uuid, currentPage);
|
||||
|
||||
if (resolvedUuid == null || resolvedUuid.isEmpty) {
|
||||
throw ArgumentError('initChat requires uuid or orderId');
|
||||
}
|
||||
|
||||
final chatRoom = await chatRepository.getChatRoom(resolvedUuid);
|
||||
ChosenPlanModel? chosenPlan;
|
||||
final planId = resolvedChosenPlanId ?? chatRoom.chosenPlanId;
|
||||
if (planId != null) {
|
||||
chosenPlan = await chatRepository.getChosenPlan(id: planId);
|
||||
}
|
||||
final chatMessages = await chatRepository.getChatMessages(resolvedUuid, currentPage);
|
||||
lastPage = chatMessages.meta.lastPage;
|
||||
await _subscribeToChatChannel(uuid: uuid, chatId: chatRoom.id);
|
||||
await _subscribeToChatChannel(uuid: resolvedUuid, chatId: chatRoom.id);
|
||||
emit(
|
||||
ChatRoomState.loaded(
|
||||
chatRoom: chatRoom.copyWith(chosenPlan: chosenPlan),
|
||||
@@ -115,6 +139,7 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
if (chosenPlan?.consultantId == chatRoom.users.sender.id) {
|
||||
await checkCards();
|
||||
}
|
||||
return resolvedUuid;
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString());
|
||||
@@ -122,6 +147,7 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
messagesSubscription?.cancel();
|
||||
wsManager.unsubscribeDirectChatChannel();
|
||||
emit(const ChatRoomState.error(errorMessage: 'Ошибка получеия данных'));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,8 +196,10 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
Future<void> _subscribeToChatChannel({required String uuid, required String chatId}) async {
|
||||
final client = wsManager.client;
|
||||
if (client == null) return;
|
||||
_activeChatUuid = uuid;
|
||||
await wsManager.initPrivateDirectChatChannel(chatId);
|
||||
wsManager.subscribeDirectChatChannel();
|
||||
await _subscribeToPresenceChannel(chatId: chatId);
|
||||
messagesSubscription = wsManager.directChatStream?.listen((event) {
|
||||
AudioPlayer().play((AssetSource('audio/recive_message.wav')));
|
||||
final message = ChatMessageModel.fromJson(jsonDecode(event.data));
|
||||
@@ -197,6 +225,118 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
try {
|
||||
final messageUuid = uuid.v4();
|
||||
@@ -297,7 +437,7 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
try {
|
||||
final planId = (state as ChatRoomStateLoaded).chatRoom.chosenPlan?.id;
|
||||
if (planId == null) return null;
|
||||
final paymentModel = await chatRepository.getPaymentLink(id: planId);
|
||||
final paymentModel = await chatRepository.getPaymentLink(id: planId, paymentProvider: 'alfa-bank');
|
||||
return paymentModel.paymentUrl;
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
@@ -337,6 +477,10 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
if (_presenceOnlineListener != null) {
|
||||
wsManager.removePresenceDirectChatListener(_presenceOnlineListener!);
|
||||
_presenceOnlineListener = null;
|
||||
}
|
||||
wsManager.unsubscribeDirectChatChannel();
|
||||
_timer?.cancel();
|
||||
messagesSubscription?.cancel();
|
||||
@@ -435,3 +579,5 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum _ChatCounterpartField { sender, receiver }
|
||||
|
||||
@@ -13,17 +13,24 @@ 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/widgets/about_order_button.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/video_call_chat_message_widget.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_state.dart';
|
||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/widgets/chat_user_online_status_line.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/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/user_details/presentation/user_details_screen.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
@@ -35,10 +42,11 @@ import 'package:intl/intl.dart';
|
||||
import 'package:visibility_detector/visibility_detector.dart';
|
||||
|
||||
class ChatScreen extends StatefulWidget {
|
||||
final String uuid;
|
||||
final String? uuid;
|
||||
final String? orderId;
|
||||
final bool? sendInitialMessage;
|
||||
|
||||
const ChatScreen({required this.uuid, super.key, required this.sendInitialMessage});
|
||||
const ChatScreen({this.uuid, this.orderId, super.key, required this.sendInitialMessage});
|
||||
|
||||
@override
|
||||
State<ChatScreen> createState() => _ChatScreenState();
|
||||
@@ -54,6 +62,11 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
late AppLifecycleListener _listener;
|
||||
List<File> pickedFiles = [];
|
||||
|
||||
/// Conversation uuid после успешного init (в т.ч. после resolve по orderId).
|
||||
String? _effectiveUuid;
|
||||
|
||||
String? get conversationUuid => _effectiveUuid ?? widget.uuid;
|
||||
|
||||
void _onHide() {
|
||||
chatRoomCubit.cancelTimer();
|
||||
}
|
||||
@@ -63,26 +76,41 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
chatRoomCubit.setTimer();
|
||||
}
|
||||
|
||||
Future<void> _initChatRoom() async {
|
||||
final uuid = await chatRoomCubit.initChat(
|
||||
conversationUuid,
|
||||
orderId: _effectiveUuid == null ? widget.orderId : null,
|
||||
sendInitialMessage: widget.sendInitialMessage ?? false,
|
||||
);
|
||||
if (uuid != null && mounted) {
|
||||
setState(() => _effectiveUuid = uuid);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ChatScreen oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.uuid != widget.uuid) {
|
||||
chatRoomCubit.initChat(widget.uuid, sendInitialMessage: widget.sendInitialMessage ?? false);
|
||||
if (oldWidget.uuid != widget.uuid || oldWidget.orderId != widget.orderId) {
|
||||
_effectiveUuid = widget.uuid;
|
||||
_initChatRoom();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_effectiveUuid = widget.uuid;
|
||||
scrollController.addListener(_onScroll);
|
||||
chatRoomCubit.initChat(widget.uuid, sendInitialMessage: widget.sendInitialMessage ?? false);
|
||||
_initChatRoom();
|
||||
chatRoomCubit.setTimer();
|
||||
_listener = AppLifecycleListener(onHide: _onHide, onResume: _onResume);
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
final uuid = conversationUuid;
|
||||
if (uuid == null) return;
|
||||
if (scrollController.offset >= scrollController.position.maxScrollExtent && !scrollController.position.outOfRange) {
|
||||
chatRoomCubit.loadNextPage(widget.uuid);
|
||||
chatRoomCubit.loadNextPage(uuid);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,7 +144,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
},
|
||||
),
|
||||
),
|
||||
body: DataErrorWidget(onReload: () => chatRoomCubit.initChat(widget.uuid)),
|
||||
body: DataErrorWidget(onReload: _initChatRoom),
|
||||
);
|
||||
} else {
|
||||
return Scaffold(
|
||||
@@ -140,6 +168,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
}
|
||||
|
||||
Scaffold _buildChat(BuildContext context, ChatRoomStateLoaded state) {
|
||||
final chatCounterpart = resolveChatCounterpart(users: state.chatRoom.users, currentUserId: sl<WsManager>().userId);
|
||||
final chosenPlan = state.chatRoom.chosenPlan;
|
||||
final amIConsultant = chosenPlan?.consultantId == state.chatRoom.users.sender.id;
|
||||
final showUserDetailsButton = [
|
||||
@@ -216,23 +245,41 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(state.chatRoom.users.receiver.name, style: CustomTextStyles.titleSmallBold.copyWith(color: PrimaryColors().black)),
|
||||
AnimatedCrossFade(
|
||||
firstChild: Text('Печатает...', style: CustomTextStyles.bodySmallMulishPrimary),
|
||||
firstCurve: Curves.easeIn,
|
||||
secondCurve: Curves.easeOut,
|
||||
secondChild: const SizedBox.shrink(),
|
||||
crossFadeState: state.isTyping ? CrossFadeState.showFirst : CrossFadeState.showSecond,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
),
|
||||
],
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
state.chatRoom.users.receiver.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: CustomTextStyles.titleSmallBold.copyWith(color: PrimaryColors().black),
|
||||
),
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsetsDirectional.only(end: 8),
|
||||
child: _ChatHeaderVideoCallButton(onPressed: () => _openVideoCall(context, state, chatCounterpart)),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Stack(
|
||||
@@ -265,7 +312,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
index + 1 == state.chatMessages.length) {
|
||||
final isCurrentUserAuthor = state.chatRoom.users.receiver.id != state.chatMessages[index].authorId;
|
||||
final message = state.chatMessages[index];
|
||||
final isPlanOrderMessage = message.type == 'plan_order';
|
||||
final isPlanOrderMessage = message.type == ChatMessageType.planOrder;
|
||||
|
||||
PacketOptions? messageOption;
|
||||
if (isPlanOrderMessage && chosenPlan != null) {
|
||||
@@ -306,18 +353,13 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
});
|
||||
}
|
||||
},
|
||||
child: 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,
|
||||
child: _buildMessageBubble(
|
||||
context: context,
|
||||
state: state,
|
||||
chatCounterpart: chatCounterpart,
|
||||
isCurrentUserAuthor: isCurrentUserAuthor,
|
||||
message: message,
|
||||
amIConsultant: amIConsultant,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -325,7 +367,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
}
|
||||
final isCurrentUserAuthor = state.chatRoom.users.receiver.id != state.chatMessages[index].authorId;
|
||||
final message = state.chatMessages[index];
|
||||
final isPlanOrderMessage = message.type == 'plan_order';
|
||||
final isPlanOrderMessage = message.type == ChatMessageType.planOrder;
|
||||
|
||||
PacketOptions? messageOption;
|
||||
if (isPlanOrderMessage && chosenPlan != null) {
|
||||
@@ -362,18 +404,13 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
});
|
||||
}
|
||||
},
|
||||
child: 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,
|
||||
child: _buildMessageBubble(
|
||||
context: context,
|
||||
state: state,
|
||||
chatCounterpart: chatCounterpart,
|
||||
isCurrentUserAuthor: isCurrentUserAuthor,
|
||||
message: message,
|
||||
amIConsultant: amIConsultant,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -740,4 +777,80 @@ 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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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 {
|
||||
const _ChatHeaderVideoCallButton({required this.onPressed});
|
||||
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final tooltipMessage = S.maybeOf(context)?.video_call;
|
||||
final button = Material(
|
||||
color: theme.colorScheme.onPrimary,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
elevation: 0,
|
||||
child: InkWell(
|
||||
onTap: onPressed,
|
||||
customBorder: const CircleBorder(),
|
||||
child: SizedBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Center(child: Icon(Icons.videocam_rounded, color: appTheme.blueA400, size: 18)),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (tooltipMessage == null || tooltipMessage.isEmpty) {
|
||||
return button;
|
||||
}
|
||||
return Tooltip(message: tooltipMessage, child: button);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ class ChatBubbleWidget extends StatelessWidget {
|
||||
final MessageState messageState;
|
||||
final String messageUuid;
|
||||
final String userSlug;
|
||||
final String? messageType;
|
||||
final ChatMessageType? messageType;
|
||||
final bool? isConsultant;
|
||||
final List<Map<String, dynamic>>? urlsMediaInfo;
|
||||
@override
|
||||
@@ -182,33 +182,36 @@ class ChatBubbleWidget extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.all(4),
|
||||
horizontalTitleGap: 8,
|
||||
onTap: () async {
|
||||
if (mediaItem is File) {
|
||||
// Для локальных файлов используем file:// URI
|
||||
final fileUri = Uri.file(mediaItem.path);
|
||||
if (await canLaunchUrl(fileUri)) {
|
||||
await launchUrl(fileUri, mode: LaunchMode.externalApplication);
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.all(4),
|
||||
horizontalTitleGap: 8,
|
||||
onTap: () async {
|
||||
if (mediaItem is File) {
|
||||
// Для локальных файлов используем file:// URI
|
||||
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
|
||||
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);
|
||||
}
|
||||
}
|
||||
},
|
||||
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),
|
||||
},
|
||||
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() {
|
||||
if (messageType == 'plan_order') {
|
||||
if (messageType == ChatMessageType.planOrder) {
|
||||
if (isConsultant == true) {
|
||||
// Текст для консультанта
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,14 @@ import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/core/widgets/bottom_sheet_with_blur.dart';
|
||||
import 'package:eatvio_app/core/widgets/custom_outlined_button.dart';
|
||||
import 'package:eatvio_app/core/widgets/loader.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/bloc/chat_room_cubit/chat_room_state.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/pages/payment_screen.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/widgets/chose_plan_date_widget.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/widgets/complete_plan_bottom_sheet.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/widgets/plan_complain_bottom_sheet.dart';
|
||||
import 'package:eatvio_app/features/my_diets/presentation/my_diets_screen.dart';
|
||||
import 'package:eatvio_app/features/my_finance/presentation/pages/card_settings.dart';
|
||||
import 'package:eatvio_app/features/payment/presentation/payment_flow.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -29,7 +28,6 @@ class NotificationBanner extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _NotificationBannerState extends State<NotificationBanner> {
|
||||
bool isLoading = false;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
@@ -54,13 +52,7 @@ class _NotificationBannerState extends State<NotificationBanner> {
|
||||
padding: EdgeInsets.only(top: 12, right: 8, left: 12),
|
||||
child: _StarIcon(),
|
||||
),
|
||||
if (isLoading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 40, top: 24),
|
||||
child: Loader(),
|
||||
)
|
||||
else
|
||||
Padding(
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -111,21 +103,10 @@ class _NotificationBannerState extends State<NotificationBanner> {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
GestureDetector(
|
||||
onTap: () async {
|
||||
onTap: () {
|
||||
final planId = (context.read<ChatRoomCubit>().state as ChatRoomStateLoaded).chatRoom.chosenPlan?.id;
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
});
|
||||
final paymentLink = await context.read<ChatRoomCubit>().getPaymentLink();
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
if (paymentLink != null && planId != null) {
|
||||
if (!context.mounted) return;
|
||||
GoRouter.of(context).pushReplacement(
|
||||
PaymentScreen.path,
|
||||
extra: {'url': paymentLink, 'planId': planId},
|
||||
);
|
||||
if (planId != null) {
|
||||
showConsultationPaymentFlow(context, planId: planId);
|
||||
}
|
||||
},
|
||||
child: Text(s.pay, style: TextStyles.textLSemibold.copyWith(color: appTheme.blueA400)),
|
||||
|
||||
@@ -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,7 @@ import 'package:eatvio_app/features/chat/models/chat_message_model.dart';
|
||||
import 'package:eatvio_app/features/chat/models/chat_messages_model.dart';
|
||||
import 'package:eatvio_app/features/chat/models/chat_room_model.dart';
|
||||
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/payment_model.dart';
|
||||
import 'package:eatvio_app/features/chat/models/plan_complain_model.dart';
|
||||
import 'package:eatvio_app/features/chat/services/chat_service.dart';
|
||||
@@ -98,8 +99,19 @@ class ChatRepository {
|
||||
|
||||
Future<PaymentModel> getPaymentLink({
|
||||
required int id,
|
||||
required String paymentProvider,
|
||||
}) async {
|
||||
return await chatService.getPaymentLink(id: id);
|
||||
return await chatService.getPaymentLink(
|
||||
id: id,
|
||||
body: {'payment_provider': paymentProvider},
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
Future<OrderIdToConversationModel> resolveOrderIdToConversation({
|
||||
required String orderId,
|
||||
}) async {
|
||||
return await chatService.resolveOrderIdToConversation(orderId: orderId);
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,13 @@ import 'package:eatvio_app/features/chat/models/chat_message_model.dart';
|
||||
import 'package:eatvio_app/features/chat/models/chat_messages_model.dart';
|
||||
import 'package:eatvio_app/features/chat/models/chat_room_model.dart';
|
||||
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/payment_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:retrofit/retrofit.dart';
|
||||
|
||||
@@ -65,6 +70,32 @@ abstract class ChatService {
|
||||
@POST('/api/chosen_plans/{id}/start_payment_process')
|
||||
Future<PaymentModel> getPaymentLink({
|
||||
@Path() required int id,
|
||||
@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}')
|
||||
Future<OrderIdToConversationModel> resolveOrderIdToConversation({
|
||||
@Path() required String orderId,
|
||||
});
|
||||
|
||||
@GET('/api/chosen_plans/show/{id}')
|
||||
|
||||
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._();
|
||||
|
||||
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 hiddenGroup = 'hidden_user_certs';
|
||||
|
||||
@@ -6,27 +6,100 @@ part 'consultant_profile_model.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class ConsultantProfileModel with _$ConsultantProfileModel {
|
||||
const ConsultantProfileModel._();
|
||||
|
||||
const factory ConsultantProfileModel({
|
||||
@JsonKey(name: 'has_medical_education', includeIfNull: false) bool? hasMedicalEducation,
|
||||
@JsonKey(name: 'social_links', includeIfNull: false) SocialLinksModel? socialLinks,
|
||||
@JsonKey(name: 'cert_photos', includeIfNull: false) List<String>? photos,
|
||||
@JsonKey(name: 'completed_courses', includeIfNull: false) List<CertificatesModel>? certificates,
|
||||
@JsonKey(name: 'has_medical_education', includeIfNull: false)
|
||||
bool? hasMedicalEducation,
|
||||
@JsonKey(name: 'social_links', includeIfNull: false)
|
||||
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;
|
||||
|
||||
factory ConsultantProfileModel.fromJson(Map<String, Object?> json) => _$ConsultantProfileModelFromJson(json);
|
||||
factory ConsultantProfileModel.fromJson(Map<String, Object?> json) =>
|
||||
_$ConsultantProfileModelFromJson(json);
|
||||
|
||||
bool get hasValidPassportData {
|
||||
final last = lastName ?? '';
|
||||
final first = firstName ?? '';
|
||||
final innValue = inn ?? '';
|
||||
final snilsValue = snils ?? '';
|
||||
final series = passportSeries ?? '';
|
||||
final number = passportNumber ?? '';
|
||||
final issueDate = passportIssueDate ?? '';
|
||||
final issuer = passportIssuer ?? '';
|
||||
final issuerCode = passportIssuerCode ?? '';
|
||||
final birth = birthDate ?? '';
|
||||
final birthPlaceValue = birthPlace ?? '';
|
||||
final registration = registrationAddress ?? '';
|
||||
final residence = residenceAddress ?? '';
|
||||
final citizenshipValue = citizenship ?? '';
|
||||
|
||||
return last.trim().isNotEmpty &&
|
||||
first.trim().isNotEmpty &&
|
||||
RegExp(r'^\d{12}$').hasMatch(innValue) &&
|
||||
RegExp(r'^\d{3}-\d{3}-\d{3} \d{2}$').hasMatch(snilsValue) &&
|
||||
RegExp(r'^\d{4}$').hasMatch(series) &&
|
||||
RegExp(r'^\d{6}$').hasMatch(number) &&
|
||||
RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(issueDate) &&
|
||||
issuer.trim().isNotEmpty &&
|
||||
RegExp(r'^\d{3}-\d{3}$').hasMatch(issuerCode) &&
|
||||
RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(birth) &&
|
||||
birthPlaceValue.trim().isNotEmpty &&
|
||||
registration.trim().isNotEmpty &&
|
||||
residence.trim().isNotEmpty &&
|
||||
RegExp(r'^[A-Za-z]{2}$').hasMatch(citizenshipValue);
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class SocialLinksModel with _$SocialLinksModel {
|
||||
const factory SocialLinksModel({
|
||||
@JsonKey(name: 'vk_link', includeIfNull: false) String? vkLink,
|
||||
@JsonKey(name: 'tg_link', includeIfNull: false) String? tgLink,
|
||||
@JsonKey(name: 'fb_link', includeIfNull: false) String? fbLink,
|
||||
@JsonKey(name: 'insta_link', includeIfNull: false) String? instaLink,
|
||||
@JsonKey(name: 'personal_site_link', includeIfNull: false) String? personalSiteLink,
|
||||
@JsonKey(name: 'vk_link', includeIfNull: false) String? vkLink,
|
||||
@JsonKey(name: 'tg_link', includeIfNull: false) String? tgLink,
|
||||
@JsonKey(name: 'fb_link', includeIfNull: false) String? fbLink,
|
||||
@JsonKey(name: 'insta_link', includeIfNull: false) String? instaLink,
|
||||
@JsonKey(name: 'personal_site_link', includeIfNull: false)
|
||||
String? personalSiteLink,
|
||||
}) = _SocialLinksModel;
|
||||
|
||||
factory SocialLinksModel.fromJson(Map<String, Object?> json) => _$SocialLinksModelFromJson(json);
|
||||
factory SocialLinksModel.fromJson(Map<String, Object?> json) =>
|
||||
_$SocialLinksModelFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
@@ -36,5 +109,6 @@ abstract class CertificatesModel with _$CertificatesModel {
|
||||
required String date,
|
||||
}) = _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:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
@@ -11,26 +12,81 @@ abstract class ConsultationRequestModel with _$ConsultationRequestModel {
|
||||
@JsonKey(name: 'rating_place') int? ratingPlace,
|
||||
@JsonKey(name: 'education_resource') required EducationModel educationModel,
|
||||
@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;
|
||||
|
||||
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
|
||||
abstract class CertPhotosModel with _$CertPhotosModel {
|
||||
const factory CertPhotosModel({
|
||||
required String url,
|
||||
}) = _CertPhotosModel;
|
||||
const factory 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 {
|
||||
approved,
|
||||
created,
|
||||
canceled
|
||||
}
|
||||
enum ModerationStatus { approved, created, canceled }
|
||||
|
||||
@freezed
|
||||
abstract class CanceledModerationModel with _$CanceledModerationModel {
|
||||
@@ -40,5 +96,6 @@ abstract class CanceledModerationModel with _$CanceledModerationModel {
|
||||
required List<String>? additional,
|
||||
}) = _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,117 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
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/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/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/repositories/consultant_repository.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/utils/laravel_validation_errors.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||
|
||||
class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
||||
final ConsultantRepository consultantRepository;
|
||||
final Duration scoringPollInterval;
|
||||
bool _update = false;
|
||||
String? _userSlug;
|
||||
Timer? _scoringPollTimer;
|
||||
|
||||
RegisterConsultantCubit({required this.consultantRepository})
|
||||
: super(
|
||||
const RegisterConsultantState(
|
||||
consultant: ConsultantProfileModel(socialLinks: SocialLinksModel(), hasMedicalEducation: true),
|
||||
loading: true,
|
||||
acceptOffer: false,
|
||||
isUpdating: false,
|
||||
),
|
||||
);
|
||||
static const String individualPersonLegalType = 'individualPerson';
|
||||
|
||||
RegisterConsultantCubit({
|
||||
required this.consultantRepository,
|
||||
this.scoringPollInterval = const Duration(seconds: 3),
|
||||
}) : super(
|
||||
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 {
|
||||
try {
|
||||
_update = true;
|
||||
final myRequestStatus = await consultantRepository.getMyRequestStatus(slug);
|
||||
_userSlug = slug;
|
||||
final myRequestStatus = await consultantRepository.getMyRequestStatus(
|
||||
slug,
|
||||
);
|
||||
final certPhotos = await consultantRepository.getConsultantCertPhotos();
|
||||
final sbpBanks = await _loadSbpBanks();
|
||||
|
||||
List<CertificatesModel>? certificates = myRequestStatus.educationModel.completedCourses?.map((courseMap) {
|
||||
final course = courseMap.values.first;
|
||||
List<CertificatesModel>? certificates = myRequestStatus
|
||||
.educationModel
|
||||
.completedCourses
|
||||
?.map((courseMap) {
|
||||
final course = courseMap.values.first;
|
||||
|
||||
return CertificatesModel(title: course.title, date: course.date);
|
||||
}).toList();
|
||||
return CertificatesModel(title: course.title, date: course.date);
|
||||
})
|
||||
.toList();
|
||||
|
||||
String? sanitizeLink(String? link) {
|
||||
return (link != null && link.isNotEmpty) ? link : null;
|
||||
}
|
||||
|
||||
final legal = myRequestStatus.legalEntity;
|
||||
final scoringStatus = legal?.alfaScoringStatus ?? AlfaScoringStatus.none;
|
||||
|
||||
emit(
|
||||
RegisterConsultantState(
|
||||
consultant: ConsultantProfileModel(
|
||||
@@ -46,33 +121,107 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
||||
tgLink: sanitizeLink(myRequestStatus.educationModel.tgLink),
|
||||
fbLink: sanitizeLink(myRequestStatus.educationModel.fbLink),
|
||||
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,
|
||||
acceptFrdoAgreement: false,
|
||||
loading: false,
|
||||
requestModel: myRequestStatus,
|
||||
certPhotos: certPhotos,
|
||||
sbpBanks: sbpBanks,
|
||||
isUpdating: _update,
|
||||
alfaScoringStatus: scoringStatus,
|
||||
isScoringPolling: scoringStatus.isProcessing,
|
||||
scoringRetryInProgress: false,
|
||||
awaitingScoringOutcome: false,
|
||||
fieldErrors: const <String, String>{},
|
||||
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) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString());
|
||||
emit(state.copyWith(loading: false, error: e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> disableLoading() async {
|
||||
try {
|
||||
final certPhotos = await consultantRepository.getConsultantCertPhotos();
|
||||
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()));
|
||||
final parsed = parseLaravelValidationErrors(e);
|
||||
emit(
|
||||
state.copyWith(
|
||||
loading: false,
|
||||
error: parsed.summary,
|
||||
fieldErrors: parsed.fieldErrors,
|
||||
awaitingScoringOutcome: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,41 +231,325 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
||||
if (slug != null) {
|
||||
await getMyRequestStatus(slug);
|
||||
} else {
|
||||
await disableLoading();
|
||||
await disableLoading(slug: _userSlug);
|
||||
}
|
||||
}
|
||||
|
||||
void updateConsultantProfileModel(ConsultantProfileModel consultant, [bool acceptOffer = false]) {
|
||||
emit(state.copyWith(consultant: consultant, acceptOffer: acceptOffer, loading: false, error: null));
|
||||
void updateConsultantProfileModel(
|
||||
ConsultantProfileModel consultant, [
|
||||
bool? acceptOffer,
|
||||
bool? acceptFrdoAgreement,
|
||||
]) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
consultant: consultant,
|
||||
acceptOffer: acceptOffer ?? state.acceptOffer,
|
||||
acceptFrdoAgreement: acceptFrdoAgreement ?? state.acceptFrdoAgreement,
|
||||
loading: false,
|
||||
error: null,
|
||||
fieldErrors: const <String, String>{},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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 {
|
||||
emit(state.copyWith(error: null));
|
||||
if (!state.consultant.hasValidPassportData) {
|
||||
emit(state.copyWith(error: 'Заполните паспортные данные'));
|
||||
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 {
|
||||
if (state.newAddedPhotos.isNotEmpty) {
|
||||
await consultantRepository.storeConsultantCertPhotos(files: state.newAddedPhotos);
|
||||
await consultantRepository.storeConsultantCertPhotos(
|
||||
files: state.newAddedPhotos,
|
||||
);
|
||||
}
|
||||
if (state.deletedPhotoIds.isNotEmpty) {
|
||||
for (final int id in state.deletedPhotoIds) {
|
||||
await consultantRepository.deletePhoto(id);
|
||||
}
|
||||
}
|
||||
await consultantRepository.registerConsultant(consultant: state.consultant, update: _update);
|
||||
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) {
|
||||
await reportFrom(e, st, this);
|
||||
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;
|
||||
}
|
||||
emit(
|
||||
state.copyWith(
|
||||
scoringRetryInProgress: true,
|
||||
error: null,
|
||||
fieldErrors: const <String, String>{},
|
||||
alfaScoringStatus: AlfaScoringStatus.beneficiaryCreating,
|
||||
isScoringPolling: true,
|
||||
awaitingScoringOutcome: true,
|
||||
),
|
||||
);
|
||||
_update = true;
|
||||
try {
|
||||
if (!state.consultant.hasValidPassportData) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
scoringRetryInProgress: false,
|
||||
isScoringPolling: false,
|
||||
awaitingScoringOutcome: false,
|
||||
alfaScoringStatus: AlfaScoringStatus.scoringFailed,
|
||||
error: 'Заполните паспортные данные',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
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 {
|
||||
if (files.isEmpty) {
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString());
|
||||
@@ -130,7 +563,19 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
||||
return;
|
||||
}
|
||||
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 '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_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:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'register_consultant_state.freezed.dart';
|
||||
@@ -12,12 +14,22 @@ abstract class RegisterConsultantState with _$RegisterConsultantState {
|
||||
const factory RegisterConsultantState({
|
||||
required ConsultantProfileModel consultant,
|
||||
required bool acceptOffer,
|
||||
@Default(false) bool acceptFrdoAgreement,
|
||||
required bool loading,
|
||||
required bool isUpdating,
|
||||
ConsultationRequestModel? requestModel,
|
||||
@Default(<ConsultantCertPhotoModel>[]) List<ConsultantCertPhotoModel> certPhotos,
|
||||
@Default(<ConsultantCertPhotoModel>[])
|
||||
List<ConsultantCertPhotoModel> certPhotos,
|
||||
@Default(<SbpBankModel>[]) List<SbpBankModel> sbpBanks,
|
||||
@Default(<File>[]) List<File> newAddedPhotos,
|
||||
@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,
|
||||
}) = _RegisterConsultantState;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ class AdditionalSettingsScreen extends StatefulWidget {
|
||||
const AdditionalSettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<AdditionalSettingsScreen> createState() => _AdditionalSettingsScreenState();
|
||||
State<AdditionalSettingsScreen> createState() =>
|
||||
_AdditionalSettingsScreenState();
|
||||
}
|
||||
|
||||
class _AdditionalSettingsScreenState extends State<AdditionalSettingsScreen> {
|
||||
@@ -17,7 +18,12 @@ class _AdditionalSettingsScreenState extends State<AdditionalSettingsScreen> {
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
if (context.read<RegisterConsultantCubit>().state.consultant.hasMedicalEducation != null) {
|
||||
if (context
|
||||
.read<RegisterConsultantCubit>()
|
||||
.state
|
||||
.consultant
|
||||
.hasMedicalEducation !=
|
||||
null) {
|
||||
haveMedicalDegree = context
|
||||
.read<RegisterConsultantCubit>()
|
||||
.state
|
||||
@@ -52,7 +58,8 @@ class _AdditionalSettingsScreenState extends State<AdditionalSettingsScreen> {
|
||||
value: publishDiplomasAndCertificatesInProfile,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
publishDiplomasAndCertificatesInProfile = !publishDiplomasAndCertificatesInProfile;
|
||||
publishDiplomasAndCertificatesInProfile =
|
||||
!publishDiplomasAndCertificatesInProfile;
|
||||
});
|
||||
},
|
||||
),
|
||||
@@ -64,11 +71,15 @@ class _AdditionalSettingsScreenState extends State<AdditionalSettingsScreen> {
|
||||
setState(() {
|
||||
haveMedicalDegree = !haveMedicalDegree;
|
||||
});
|
||||
context.read<RegisterConsultantCubit>().updateConsultantProfileModel(
|
||||
context.read<RegisterConsultantCubit>().state.consultant.copyWith(
|
||||
hasMedicalEducation: haveMedicalDegree,
|
||||
),
|
||||
);
|
||||
context
|
||||
.read<RegisterConsultantCubit>()
|
||||
.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';
|
||||
|
||||
class BecomeConsultantScreen extends StatefulWidget {
|
||||
const BecomeConsultantScreen({super.key});
|
||||
final String? slug;
|
||||
|
||||
const BecomeConsultantScreen({super.key, this.slug});
|
||||
|
||||
@override
|
||||
State<BecomeConsultantScreen> createState() => _BecomeConsultantScreenState();
|
||||
@@ -17,7 +19,10 @@ class _BecomeConsultantScreenState extends State<BecomeConsultantScreen> {
|
||||
title: const Text('Стать консультантом'),
|
||||
automaticallyImplyLeading: 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(
|
||||
padding: EdgeInsets.all(16),
|
||||
@@ -32,16 +37,27 @@ class _BecomeConsultantScreenState extends State<BecomeConsultantScreen> {
|
||||
|
||||
InkWell _startRegistrationButton() {
|
||||
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),
|
||||
child: Ink(
|
||||
height: 52,
|
||||
width: MediaQuery.of(context).size.width - 32,
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
color: appTheme.blueA400,
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'Начать регистрацию',
|
||||
style: TextStyle(color: Colors.white, height: 1, fontWeight: FontWeight.w600, fontSize: 18),
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -7,15 +7,18 @@ import 'package:flutter/material.dart';
|
||||
import 'package:eatvio_app/core/utils/date_time_utils.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class CertificatesAndDiplomasScreen extends StatefulWidget {
|
||||
const CertificatesAndDiplomasScreen({super.key});
|
||||
|
||||
@override
|
||||
State<CertificatesAndDiplomasScreen> createState() => _CertificatesAndDiplomasScreenState();
|
||||
State<CertificatesAndDiplomasScreen> createState() =>
|
||||
_CertificatesAndDiplomasScreenState();
|
||||
}
|
||||
|
||||
class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasScreen> {
|
||||
class _CertificatesAndDiplomasScreenState
|
||||
extends State<CertificatesAndDiplomasScreen> {
|
||||
final scrollController = ScrollController();
|
||||
|
||||
List<DiplomaEntity> diplomasList = [
|
||||
@@ -29,9 +32,15 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (context.read<RegisterConsultantCubit>().state.consultant.certificates != null) {
|
||||
if (context.read<RegisterConsultantCubit>().state.consultant.certificates !=
|
||||
null) {
|
||||
diplomasList = [
|
||||
...context.read<RegisterConsultantCubit>().state.consultant.certificates!.map(
|
||||
...context
|
||||
.read<RegisterConsultantCubit>()
|
||||
.state
|
||||
.consultant
|
||||
.certificates!
|
||||
.map(
|
||||
(e) => DiplomaEntity(
|
||||
date: DateTime.tryParse(e.date),
|
||||
textEditingController: TextEditingController(text: e.title),
|
||||
@@ -70,7 +79,8 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
|
||||
ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemBuilder: (context, index) => _diplomaInfo(diploma: diplomasList[index]),
|
||||
itemBuilder: (context, index) =>
|
||||
_diplomaInfo(diploma: diplomasList[index]),
|
||||
separatorBuilder: (context, index) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: _divider(context),
|
||||
@@ -93,9 +103,7 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
|
||||
);
|
||||
}
|
||||
|
||||
Column _diplomaInfo({
|
||||
required DiplomaEntity diploma,
|
||||
}) {
|
||||
Column _diplomaInfo({required DiplomaEntity diploma}) {
|
||||
return Column(
|
||||
children: [
|
||||
CustomTextFormField(
|
||||
@@ -111,7 +119,9 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: MediaQuery.of(context).size.width - (diploma.enableDeleteButton ? 92 : 32),
|
||||
width:
|
||||
MediaQuery.of(context).size.width -
|
||||
(diploma.enableDeleteButton ? 92 : 32),
|
||||
child: ListTile(
|
||||
onTap: onTapCalendar(context, diploma),
|
||||
trailing: Icon(
|
||||
@@ -122,7 +132,9 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
|
||||
title: Text(
|
||||
diploma.date?.format('dd MMMM yyyy', 'ru') ?? 'Дата выдачи',
|
||||
style: TextStyle(
|
||||
color: diploma.date == null ? Colors.grey.shade400 : Colors.black,
|
||||
color: diploma.date == null
|
||||
? Colors.grey.shade400
|
||||
: Colors.black,
|
||||
),
|
||||
),
|
||||
tileColor: const Color(0xFFF6F6F6),
|
||||
@@ -148,9 +160,7 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
|
||||
color: const Color(0xFFF6F6F6),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.delete_outline_rounded,
|
||||
),
|
||||
child: const Icon(Icons.delete_outline_rounded),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -190,16 +200,14 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
color: CupertinoColors.systemBackground.resolveFrom(context),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: child,
|
||||
),
|
||||
child: SafeArea(top: false, child: child),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void checkLast() {
|
||||
if (diplomasList.last.textEditingController.text.isNotEmpty || diplomasList.last.date != null) {
|
||||
if (diplomasList.last.textEditingController.text.isNotEmpty ||
|
||||
diplomasList.last.date != null) {
|
||||
setState(() {
|
||||
diplomasList.add(
|
||||
DiplomaEntity(
|
||||
@@ -230,12 +238,19 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
|
||||
|
||||
void _saveState() {
|
||||
context.read<RegisterConsultantCubit>().updateConsultantProfileModel(
|
||||
context.read<RegisterConsultantCubit>().state.consultant.copyWith(
|
||||
certificates: diplomasList.map((e) => CertificatesModel(title: e.textEditingController.text, date: e.date.toString())).toList().sublist(
|
||||
0,
|
||||
diplomasList.length - 1,
|
||||
),
|
||||
context.read<RegisterConsultantCubit>().state.consultant.copyWith(
|
||||
certificates: diplomasList
|
||||
.map(
|
||||
(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/const.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/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_bloc/flutter_bloc.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
@@ -15,10 +18,29 @@ class LastStepScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
final cubit = context.watch<RegisterConsultantCubit>();
|
||||
final state = cubit.state;
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(bottom: 80, top: 16),
|
||||
child: Column(
|
||||
@@ -27,34 +49,138 @@ class _LastStepScreenState extends State<LastStepScreen> {
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text('Последний шаг', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, height: 1.1)),
|
||||
child: Text(
|
||||
'Последний шаг',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
),
|
||||
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),
|
||||
CheckboxListTile(
|
||||
value: checkboxState,
|
||||
value: state.acceptOffer,
|
||||
title: Text(
|
||||
hidePlans
|
||||
? 'Я ознакомлен и принимаю политику конфиденциальности и защиту персональных данных'
|
||||
: 'Я ознакомлен и принимаю договор публичной офёрты',
|
||||
style: const TextStyle(fontSize: 18, height: 1),
|
||||
: 'Я ознакомлен и принимаю публичную оферту и политику конфиденциальности',
|
||||
style: const TextStyle(fontSize: 18, height: 1.2),
|
||||
),
|
||||
onChanged: (_) {
|
||||
setState(() {
|
||||
checkboxState = !checkboxState;
|
||||
});
|
||||
context.read<RegisterConsultantCubit>().updateConsultantProfileModel(context.read<RegisterConsultantCubit>().state.consultant, checkboxState);
|
||||
onChanged: (value) {
|
||||
cubit.updateAgreements(acceptOffer: value ?? false);
|
||||
},
|
||||
activeColor: appTheme.blueA400,
|
||||
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: const EdgeInsets.symmetric(horizontal: 4),
|
||||
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(
|
||||
hidePlans ? 'Политика конфиденциальности и пользовательское соглашение' : 'Договор офёрты',
|
||||
style: const TextStyle(height: 1, fontSize: 16, color: Color(0xFF323F4B), decoration: TextDecoration.underline),
|
||||
hidePlans
|
||||
? 'Политика конфиденциальности и пользовательское соглашение'
|
||||
: 'Договор оферты',
|
||||
style: const TextStyle(
|
||||
height: 1,
|
||||
fontSize: 16,
|
||||
color: Color(0xFF323F4B),
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -67,7 +193,10 @@ class _LastStepScreenState extends State<LastStepScreen> {
|
||||
return Container(
|
||||
height: 0.4,
|
||||
width: MediaQuery.of(context).size.width - 32,
|
||||
decoration: BoxDecoration(color: Colors.black26, borderRadius: BorderRadius.circular(16)),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black26,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,591 @@
|
||||
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_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,
|
||||
);
|
||||
|
||||
@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,
|
||||
hasError: errors.containsKey('last_name'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
PassportFieldWithError(
|
||||
error: errors['first_name'],
|
||||
child: CustomTextFormField(
|
||||
hintText: 'Имя',
|
||||
controller: firstNameController,
|
||||
hasError: errors.containsKey('first_name'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
PassportFieldWithError(
|
||||
error: errors['middle_name'],
|
||||
child: CustomTextFormField(
|
||||
hintText: 'Отчество',
|
||||
controller: middleNameController,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
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),
|
||||
BlocBuilder<RegisterConsultantCubit, RegisterConsultantState>(
|
||||
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 totalPhotosCount = photos.length + addedPhotos.length;
|
||||
return Column(
|
||||
children: [
|
||||
if (isUploading) ...[const LinearProgressIndicator(), const SizedBox(height: 16)],
|
||||
if (isUploading) ...[
|
||||
const LinearProgressIndicator(),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
childAspectRatio: 3 / 4,
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
),
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
childAspectRatio: 3 / 4,
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
),
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
if (index == totalPhotosCount) {
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: () => _pickAndUploadPhotos(),
|
||||
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),
|
||||
),
|
||||
);
|
||||
@@ -72,7 +88,8 @@ class _PhotoOrScansScreenState extends State<PhotoOrScansScreen> {
|
||||
return _LocalCertPhotoCard(
|
||||
photo: localPhoto,
|
||||
allPhotos: addedPhotos,
|
||||
onRemove: () => _removePhoto(path: localPhoto.uri.path),
|
||||
onRemove: () =>
|
||||
_removePhoto(path: localPhoto.uri.path),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -99,7 +116,9 @@ class _PhotoOrScansScreenState extends State<PhotoOrScansScreen> {
|
||||
});
|
||||
try {
|
||||
// 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 {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -119,7 +138,11 @@ class _CertPhotoCard extends StatelessWidget {
|
||||
final List<ConsultantCertPhotoModel> allPhotos;
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
@@ -131,7 +154,11 @@ class _CertPhotoCard extends StatelessWidget {
|
||||
onTap: () {
|
||||
context.pushTransparentRoute(
|
||||
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),
|
||||
@@ -141,7 +168,10 @@ class _CertPhotoCard extends StatelessWidget {
|
||||
Ink(
|
||||
decoration: BoxDecoration(
|
||||
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(
|
||||
@@ -152,8 +182,15 @@ class _CertPhotoCard extends StatelessWidget {
|
||||
customBorder: const CircleBorder(),
|
||||
child: Ink(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: const BoxDecoration(color: Color(0xFFF1F2F6), shape: BoxShape.circle),
|
||||
child: const Icon(size: 20, Icons.close, color: Colors.black),
|
||||
decoration: const BoxDecoration(
|
||||
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 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
|
||||
Widget build(BuildContext context) {
|
||||
@@ -210,8 +251,15 @@ class _LocalCertPhotoCard extends StatelessWidget {
|
||||
customBorder: const CircleBorder(),
|
||||
child: Ink(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: const BoxDecoration(color: Color(0xFFF1F2F6), shape: BoxShape.circle),
|
||||
child: const Icon(size: 20, Icons.close, color: Colors.black),
|
||||
decoration: const BoxDecoration(
|
||||
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/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class RegistrationAsScreen extends StatefulWidget {
|
||||
const RegistrationAsScreen({super.key});
|
||||
@@ -11,7 +13,26 @@ class RegistrationAsScreen extends StatefulWidget {
|
||||
enum RegistrationAsType { physical, selfBusy, iP, ooo }
|
||||
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
@@ -23,7 +44,14 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text('Регистрация в качестве', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, height: 1.1)),
|
||||
child: Text(
|
||||
'Регистрация в качестве',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Column(
|
||||
@@ -36,16 +64,15 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
|
||||
groupValue: registrationAsType,
|
||||
activeColor: appTheme.blueA400,
|
||||
// ignore: deprecated_member_use
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
registrationAsType = value!;
|
||||
});
|
||||
},
|
||||
onChanged: (_) {},
|
||||
),
|
||||
const Expanded(
|
||||
child: Padding(
|
||||
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,
|
||||
activeColor: appTheme.blueA400,
|
||||
// ignore: deprecated_member_use
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
registrationAsType = value!;
|
||||
});
|
||||
},
|
||||
onChanged: null,
|
||||
),
|
||||
const Expanded(
|
||||
child: Padding(
|
||||
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,
|
||||
activeColor: appTheme.blueA400,
|
||||
// ignore: deprecated_member_use
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
registrationAsType = value!;
|
||||
});
|
||||
},
|
||||
onChanged: null,
|
||||
),
|
||||
const Expanded(
|
||||
child: Padding(
|
||||
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,
|
||||
activeColor: appTheme.blueA400,
|
||||
// ignore: deprecated_member_use
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
registrationAsType = value!;
|
||||
});
|
||||
},
|
||||
onChanged: null,
|
||||
),
|
||||
const Expanded(
|
||||
child: Padding(
|
||||
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(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text('* На данный момент регистрация доступна только в качестве физического лица. Остальные варианты скоро появятся'),
|
||||
child: Text(
|
||||
'* На данный момент регистрация доступна только в качестве физического лица. Остальные варианты скоро появятся',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -132,7 +173,10 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
|
||||
return Container(
|
||||
height: 0.4,
|
||||
width: MediaQuery.of(context).size.width - 32,
|
||||
decoration: BoxDecoration(color: Colors.black26, borderRadius: BorderRadius.circular(16)),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black26,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,32 +19,47 @@ class _SocialNetworksScreenState extends State<SocialNetworksScreen> {
|
||||
|
||||
var maskFormatter = MaskTextInputFormatter(
|
||||
mask: 'https://########################################',
|
||||
filter: {
|
||||
'#': RegExp(r"[a-zA-Z. ]+"),
|
||||
},
|
||||
filter: {'#': RegExp(r"[a-zA-Z. ]+")},
|
||||
type: MaskAutoCompletionType.lazy,
|
||||
);
|
||||
var maskTgFormatter = MaskTextInputFormatter(
|
||||
mask: 'https://tg.me/########################################',
|
||||
filter: {
|
||||
'#': RegExp(r"[a-zA-Z. ]+"),
|
||||
},
|
||||
filter: {'#': RegExp(r"[a-zA-Z. ]+")},
|
||||
type: MaskAutoCompletionType.lazy,
|
||||
);
|
||||
var maskVkFormatter = MaskTextInputFormatter(
|
||||
mask: 'https://vk.com/########################################',
|
||||
filter: {
|
||||
'#': RegExp(r"[a-zA-Z. ]+"),
|
||||
},
|
||||
filter: {'#': RegExp(r"[a-zA-Z. ]+")},
|
||||
type: MaskAutoCompletionType.lazy,
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
vkController.text = context.read<RegisterConsultantCubit>().state.consultant.socialLinks!.vkLink ?? '';
|
||||
telegramController.text = context.read<RegisterConsultantCubit>().state.consultant.socialLinks!.tgLink ?? '';
|
||||
webSiteController.text = context.read<RegisterConsultantCubit>().state.consultant.socialLinks!.personalSiteLink ?? '';
|
||||
vkController.text =
|
||||
context
|
||||
.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
|
||||
@@ -75,9 +90,7 @@ class _SocialNetworksScreenState extends State<SocialNetworksScreen> {
|
||||
color: const Color(0xFFF6F6F6),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: SvgPicture.asset(
|
||||
'assets/icons/vk.svg',
|
||||
),
|
||||
child: SvgPicture.asset('assets/icons/vk.svg'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
@@ -105,9 +118,7 @@ class _SocialNetworksScreenState extends State<SocialNetworksScreen> {
|
||||
color: const Color(0xFFF6F6F6),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: SvgPicture.asset(
|
||||
'assets/icons/telegram.svg',
|
||||
),
|
||||
child: SvgPicture.asset('assets/icons/telegram.svg'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
@@ -135,9 +146,7 @@ class _SocialNetworksScreenState extends State<SocialNetworksScreen> {
|
||||
color: const Color(0xFFF6F6F6),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: SvgPicture.asset(
|
||||
'assets/icons/web.svg',
|
||||
),
|
||||
child: SvgPicture.asset('assets/icons/web.svg'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
@@ -164,25 +173,45 @@ class _SocialNetworksScreenState extends State<SocialNetworksScreen> {
|
||||
String? personalSiteLink,
|
||||
}) {
|
||||
context.read<RegisterConsultantCubit>().updateConsultantProfileModel(
|
||||
context.read<RegisterConsultantCubit>().state.consultant.copyWith(
|
||||
socialLinks: context.read<RegisterConsultantCubit>().state.consultant.socialLinks?.copyWith(
|
||||
tgLink: tgLink == null
|
||||
? context.read<RegisterConsultantCubit>().state.consultant.socialLinks?.tgLink
|
||||
: tgLink.isEmpty
|
||||
? null
|
||||
: 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
context.read<RegisterConsultantCubit>().state.consultant.copyWith(
|
||||
socialLinks: context
|
||||
.read<RegisterConsultantCubit>()
|
||||
.state
|
||||
.consultant
|
||||
.socialLinks
|
||||
?.copyWith(
|
||||
tgLink: tgLink == null
|
||||
? context
|
||||
.read<RegisterConsultantCubit>()
|
||||
.state
|
||||
.consultant
|
||||
.socialLinks
|
||||
?.tgLink
|
||||
: tgLink.isEmpty
|
||||
? null
|
||||
: 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/widgets/data_error_widget.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_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/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/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/registration_as_screen.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/social_networks_screen.dart';
|
||||
@@ -21,39 +23,145 @@ import 'package:lottie/lottie.dart';
|
||||
class StepperBecomeConsultantScreen extends StatefulWidget {
|
||||
final String? slug;
|
||||
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
|
||||
State<StepperBecomeConsultantScreen> createState() => _StepperBecomeConsultantScreenState();
|
||||
State<StepperBecomeConsultantScreen> createState() =>
|
||||
_StepperBecomeConsultantScreenState();
|
||||
}
|
||||
|
||||
class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantScreen> {
|
||||
double progressValue = 1 / 6;
|
||||
class _StepperBecomeConsultantScreenState
|
||||
extends State<StepperBecomeConsultantScreen> {
|
||||
double progressValue = 0;
|
||||
int step = 1;
|
||||
final pageController = PageController();
|
||||
late final RegisterConsultantCubit registrationConsultantCubit;
|
||||
bool isLoading = false;
|
||||
bool _successSheetShown = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.slug == null) {
|
||||
registrationConsultantCubit = sl<RegisterConsultantCubit>()..disableLoading();
|
||||
if (widget.isNewRegistration || widget.slug == null) {
|
||||
registrationConsultantCubit = sl<RegisterConsultantCubit>()
|
||||
..disableLoading(slug: widget.slug);
|
||||
} 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
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<RegisterConsultantCubit>(
|
||||
create: (context) => registrationConsultantCubit,
|
||||
child: BlocBuilder<RegisterConsultantCubit, RegisterConsultantState>(
|
||||
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) {
|
||||
if (state.error != null &&
|
||||
state.requestModel == null &&
|
||||
widget.slug != null &&
|
||||
state.alfaScoringStatus == AlfaScoringStatus.none) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
centerTitle: true,
|
||||
@@ -72,248 +180,320 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
softWrap: true,
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
style: TextStyles.headingXLSemibold.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
body: DataErrorWidget(
|
||||
onReload: () => context.read<RegisterConsultantCubit>().reloadData(widget.slug),
|
||||
onReload: () => context
|
||||
.read<RegisterConsultantCubit>()
|
||||
.reloadData(widget.slug),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (progressValue == 0) {
|
||||
progressValue = 1 / totalSteps;
|
||||
}
|
||||
|
||||
final submitDisabled =
|
||||
isLoading ||
|
||||
state.isScoringPolling ||
|
||||
state.scoringRetryInProgress;
|
||||
|
||||
return Scaffold(
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
|
||||
floatingActionButton: Row(
|
||||
children: [
|
||||
Container(
|
||||
height: 48,
|
||||
width: MediaQuery.of(context).size.width * 0.2,
|
||||
padding: const EdgeInsets.only(left: 16, right: 4),
|
||||
child: FloatingActionButton.extended(
|
||||
heroTag: 'backButtonRegistration',
|
||||
highlightElevation: 0,
|
||||
focusElevation: 0,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
backgroundColor: const Color(0xFFEAF2FD),
|
||||
onPressed: () async {
|
||||
setState(() {
|
||||
pageController.previousPage(duration: const Duration(milliseconds: 350), curve: Curves.easeInOut);
|
||||
});
|
||||
},
|
||||
label: Icon(Icons.arrow_back_ios_new_rounded, color: appTheme.blueA400),
|
||||
floatingActionButtonLocation:
|
||||
FloatingActionButtonLocation.centerFloat,
|
||||
floatingActionButton: Row(
|
||||
children: [
|
||||
Container(
|
||||
height: 48,
|
||||
width: MediaQuery.of(context).size.width * 0.2,
|
||||
padding: const EdgeInsets.only(left: 16, right: 4),
|
||||
child: FloatingActionButton.extended(
|
||||
heroTag: 'backButtonRegistration',
|
||||
highlightElevation: 0,
|
||||
focusElevation: 0,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
backgroundColor: const Color(0xFFEAF2FD),
|
||||
onPressed: () async {
|
||||
setState(() {
|
||||
pageController.previousPage(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
});
|
||||
},
|
||||
label: Icon(
|
||||
Icons.arrow_back_ios_new_rounded,
|
||||
color: appTheme.blueA400,
|
||||
),
|
||||
),
|
||||
if (widget.isCanceled == true)
|
||||
InkWell(
|
||||
onTap: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(state.requestModel!.canceledModeration!.title),
|
||||
content: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (int i = 0; i < state.requestModel!.canceledModeration!.additional!.length; i++) ...[
|
||||
Text('• ${state.requestModel!.canceledModeration!.additional![i]}'),
|
||||
),
|
||||
if (widget.isCanceled == true && step != totalSteps)
|
||||
InkWell(
|
||||
onTap: () {
|
||||
final canceledModeration =
|
||||
state.requestModel?.canceledModeration;
|
||||
if (canceledModeration == null) return;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(canceledModeration.title),
|
||||
content: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (canceledModeration.additional != null)
|
||||
for (final item
|
||||
in canceledModeration.additional!) ...[
|
||||
Text('• $item'),
|
||||
],
|
||||
],
|
||||
),
|
||||
actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Ink(
|
||||
height: 48,
|
||||
width: MediaQuery.of(context).size.width * 0.6,
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: const Color(0x33FFCC00)),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'Комментарий',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF323F4B), height: 1),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Ink(
|
||||
height: 48,
|
||||
width: MediaQuery.of(context).size.width * 0.6,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
color: const Color(0x33FFCC00),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'Комментарий',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF323F4B),
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
height: 48,
|
||||
width: widget.isCanceled == true
|
||||
? MediaQuery.of(context).size.width * 0.2
|
||||
: MediaQuery.of(context).size.width * 0.8,
|
||||
padding: const EdgeInsets.only(left: 4, right: 16),
|
||||
child: FloatingActionButton.extended(
|
||||
heroTag: 'nextButtonRegistration',
|
||||
highlightElevation: 0,
|
||||
elevation: 0,
|
||||
focusElevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
backgroundColor: isLoading
|
||||
? Colors.grey
|
||||
: widget.isCanceled == true
|
||||
? const Color(0xFFEAF2FD)
|
||||
: appTheme.blueA400,
|
||||
onPressed: isLoading
|
||||
? null
|
||||
: () async {
|
||||
if (step == 6) {
|
||||
if (widget.isCanceled == true) {
|
||||
return;
|
||||
}
|
||||
if (registrationConsultantCubit.state.acceptOffer) {
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
});
|
||||
try {
|
||||
await registrationConsultantCubit.registerConsultant();
|
||||
if (!context.mounted) return;
|
||||
if (registrationConsultantCubit.state.error != null) {
|
||||
return;
|
||||
}
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
height: 48,
|
||||
width: widget.isCanceled == true && step != totalSteps
|
||||
? MediaQuery.of(context).size.width * 0.2
|
||||
: MediaQuery.of(context).size.width * 0.8,
|
||||
padding: const EdgeInsets.only(left: 4, right: 16),
|
||||
child: FloatingActionButton.extended(
|
||||
heroTag: 'nextButtonRegistration',
|
||||
highlightElevation: 0,
|
||||
elevation: 0,
|
||||
focusElevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
backgroundColor: submitDisabled
|
||||
? Colors.grey
|
||||
: widget.isCanceled == true && step != totalSteps
|
||||
? const Color(0xFFEAF2FD)
|
||||
: appTheme.blueA400,
|
||||
onPressed: submitDisabled
|
||||
? null
|
||||
: () async {
|
||||
if (step == totalSteps) {
|
||||
if (!state.consultant.hasValidPassportData) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
actionsPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
),
|
||||
content: const Text(
|
||||
'Заполните паспортные данные',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: const Text('Ок'),
|
||||
onPressed: () =>
|
||||
Navigator.of(context).pop(),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
showDialog<void>(
|
||||
],
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (registrationConsultantCubit
|
||||
.hasAcceptedRequiredAgreements) {
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
});
|
||||
try {
|
||||
await registrationConsultantCubit
|
||||
.registerConsultant();
|
||||
if (!context.mounted) return;
|
||||
if (registrationConsultantCubit.state.error !=
|
||||
null) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
actionsPadding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
content: Text(e.toString()),
|
||||
actionsPadding:
|
||||
const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
),
|
||||
content: Text(
|
||||
registrationConsultantCubit
|
||||
.state
|
||||
.error!,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: const Text('Ок'),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
onPressed: () =>
|
||||
Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
showDialog(
|
||||
// Success sheet is shown by BlocListener when scoring completes.
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
actionsPadding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
content: const Text('Примите договор публичной офёрты'),
|
||||
actionsPadding:
|
||||
const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
),
|
||||
content: Text(e.toString()),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: const Text('Ок'),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
onPressed: () =>
|
||||
Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
setState(() {
|
||||
pageController.nextPage(duration: const Duration(milliseconds: 350), curve: Curves.easeInOut);
|
||||
});
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
actionsPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
),
|
||||
content: Text(
|
||||
state.isUpdating
|
||||
? 'Примите договор публичной оферты'
|
||||
: 'Примите оферту и согласие ФРДО',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: const Text('Ок'),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
label: isLoading
|
||||
? const CircularProgressIndicator()
|
||||
: widget.isCanceled == true
|
||||
? Icon(Icons.arrow_forward_ios_outlined, color: appTheme.blueA400)
|
||||
: Text('Продолжить', style: theme.textTheme.titleSmall!.copyWith(color: Colors.white)),
|
||||
),
|
||||
} else {
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
setState(() {
|
||||
pageController.nextPage(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
label: submitDisabled && step == totalSteps
|
||||
? const CircularProgressIndicator()
|
||||
: widget.isCanceled == true && step != totalSteps
|
||||
? Icon(
|
||||
Icons.arrow_forward_ios_outlined,
|
||||
color: appTheme.blueA400,
|
||||
)
|
||||
: Text(
|
||||
step == totalSteps ? 'Отправить' : 'Продолжить',
|
||||
style: theme.textTheme.titleSmall!.copyWith(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
leading: Builder(
|
||||
builder: (context) => IconButton(icon: const Icon(Icons.close_rounded), onPressed: () => GoRouter.of(context).pop()),
|
||||
),
|
||||
centerTitle: true,
|
||||
title: SvgPicture.asset(ImageConstant.imgLogoEatvio, height: 48, fit: BoxFit.contain),
|
||||
actions: [Padding(padding: const EdgeInsets.only(right: 16), child: Text('шаг $step из 6'))],
|
||||
bottom: PreferredSize(
|
||||
preferredSize: Size(MediaQuery.of(context).size.width, 32),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: TweenAnimationBuilder<double>(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
tween: Tween<double>(begin: 1 / 6, end: progressValue),
|
||||
builder: (context, value, _) => LinearProgressIndicator(
|
||||
value: value,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
minHeight: 8,
|
||||
backgroundColor: Colors.grey.shade100,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(appTheme.blueA400),
|
||||
],
|
||||
),
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
leading: Builder(
|
||||
builder: (context) => IconButton(
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
onPressed: () => GoRouter.of(context).pop(),
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
title: SvgPicture.asset(
|
||||
ImageConstant.imgLogoEatvio,
|
||||
height: 48,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
child: Text('шаг $step из $totalSteps'),
|
||||
),
|
||||
],
|
||||
bottom: PreferredSize(
|
||||
preferredSize: Size(MediaQuery.of(context).size.width, 32),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: TweenAnimationBuilder<double>(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
tween: Tween<double>(
|
||||
begin: 1 / totalSteps,
|
||||
end: progressValue,
|
||||
),
|
||||
builder: (context, value, _) => LinearProgressIndicator(
|
||||
value: value,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
minHeight: 8,
|
||||
backgroundColor: Colors.grey.shade100,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
appTheme.blueA400,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
body: PageView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
controller: pageController,
|
||||
onPageChanged: (index) {
|
||||
setState(() {
|
||||
step = index + 1;
|
||||
progressValue = step / 6;
|
||||
});
|
||||
},
|
||||
children: const [
|
||||
CertificatesAndDiplomasScreen(),
|
||||
PhotoOrScansScreen(),
|
||||
AdditionalSettingsScreen(),
|
||||
SocialNetworksScreen(),
|
||||
RegistrationAsScreen(),
|
||||
LastStepScreen(),
|
||||
],
|
||||
),
|
||||
);
|
||||
),
|
||||
body: PageView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
controller: pageController,
|
||||
onPageChanged: (index) {
|
||||
setState(() {
|
||||
step = index + 1;
|
||||
progressValue = step / totalSteps;
|
||||
});
|
||||
},
|
||||
children: _pages(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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_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/services/consultant_service.dart';
|
||||
import 'package:http_parser/http_parser.dart';
|
||||
|
||||
@@ -11,36 +12,53 @@ class ConsultantRepository {
|
||||
|
||||
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) {
|
||||
return await consultantService.consultantUpdate(consultantModel: consultant);
|
||||
return await consultantService.consultantUpdate(
|
||||
consultantModel: consultant,
|
||||
);
|
||||
} 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);
|
||||
|
||||
}
|
||||
|
||||
/// Своя заявка — education + legal/passport для регистрации и update.
|
||||
Future<ConsultationRequestModel> getMyRequestStatus(String slug) async {
|
||||
return await consultantService.getMyRequestStatus(slug: slug);
|
||||
}
|
||||
|
||||
Future<List<SbpBankModel>> getSbpBanks() async {
|
||||
return await consultantService.getSbpBanks();
|
||||
}
|
||||
|
||||
Future<List<ConsultantCertPhotoModel>> getConsultantCertPhotos() async {
|
||||
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>[];
|
||||
|
||||
for (final file in files) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -48,22 +66,24 @@ class ConsultantRepository {
|
||||
return [];
|
||||
}
|
||||
|
||||
return await consultantService.storeConsultantCertPhotos(photos: multipartFiles);
|
||||
|
||||
return await consultantService.storeConsultantCertPhotos(
|
||||
photos: multipartFiles,
|
||||
);
|
||||
}
|
||||
|
||||
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 {
|
||||
await consultantService.unHideConsultantCertPhoto(payload: {'cert_photo_id': id});
|
||||
|
||||
await consultantService.unHideConsultantCertPhoto(
|
||||
payload: {'cert_photo_id': id},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deletePhoto(int id) async {
|
||||
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_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:retrofit/retrofit.dart';
|
||||
|
||||
part 'consultant_service.g.dart';
|
||||
@@ -10,21 +11,39 @@ part 'consultant_service.g.dart';
|
||||
abstract class ConsultantService {
|
||||
factory ConsultantService(Dio dio, {String baseUrl}) = _ConsultantService;
|
||||
|
||||
@POST('/api/consultant_request/registration')
|
||||
Future<void> consultantRegistration({@Body() required ConsultantProfileModel consultantModel});
|
||||
@POST('/api/v2/consultant_request/registration')
|
||||
Future<void> consultantRegistration({
|
||||
@Body() required ConsultantProfileModel consultantModel,
|
||||
});
|
||||
|
||||
@POST('/api/consultant_request/photos/upload')
|
||||
@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')
|
||||
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')
|
||||
Future<void> consultantUpdate({@Body() required ConsultantProfileModel consultantModel});
|
||||
@PUT('/api/v2/consultant_request/update')
|
||||
Future<void> consultantUpdate({
|
||||
@Body() required ConsultantProfileModel consultantModel,
|
||||
});
|
||||
|
||||
@GET('/api/finances/sbp_banks')
|
||||
Future<List<SbpBankModel>> getSbpBanks();
|
||||
|
||||
@GET('/api/v2/consultant_request/cert_photos')
|
||||
Future<List<ConsultantCertPhotoModel>> getConsultantCertPhotos();
|
||||
@@ -32,14 +51,19 @@ abstract class ConsultantService {
|
||||
@POST('/api/v2/consultant_request/cert_photos/store')
|
||||
@MultiPart()
|
||||
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')
|
||||
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')
|
||||
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')
|
||||
Future<void> deletePhoto({@Path() required int id});
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
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' => 'Некорректный код подразделения',
|
||||
'birth_date' => 'Некорректная дата рождения',
|
||||
'citizenship' => 'Некорректный код гражданства',
|
||||
'sbp_phone' => 'Некорректный телефон СБП',
|
||||
'title' => 'Слишком короткое название курса',
|
||||
'date' => 'Некорректная дата курса',
|
||||
_ => 'Некорректное значение',
|
||||
};
|
||||
}
|
||||
|
||||
return 'Проверьте поле';
|
||||
}
|
||||
@@ -106,9 +106,7 @@ class _AboutDietScreenState extends State<AboutDietScreen> {
|
||||
backgroundColor: appTheme.gray100,
|
||||
resizeToAvoidBottomInset: true,
|
||||
bottomSheet: Container(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).padding.bottom + 8,
|
||||
),
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom + 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(top: BorderSide(color: Colors.black.withValues(alpha: 0.1))),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/post_feed_pagination_model.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'feed_blog_post_model.freezed.dart';
|
||||
@@ -20,6 +21,9 @@ abstract class FeedBlogPostModel with _$FeedBlogPostModel {
|
||||
@JsonKey(name: "total_views") required int totalViews,
|
||||
@JsonKey(name: "total_comments") required int totalComments,
|
||||
@JsonKey(name: "was_liked_by_me") required bool wasLikedByMe,
|
||||
@Default([]) List<FeedGalleryImage> images,
|
||||
@JsonKey(name: "video_url_embed") String? videoEmbedUrl,
|
||||
@JsonKey(name: "video_url_thumb") String? videoUrlThumb,
|
||||
}) = _FeedBlogPostModel;
|
||||
|
||||
factory FeedBlogPostModel.fromJson(Map<String, Object?> json) => _$FeedBlogPostModelFromJson(json);
|
||||
|
||||
41
lib/features/feed/models/post_feed_mapper.dart
Normal file
@@ -0,0 +1,41 @@
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/locator_service.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/post_feed_pagination_model.dart';
|
||||
|
||||
class PostFeedMapper {
|
||||
static FeedBlogPostModel fromPostFeedItem(PostFeedItemModel item, {int? currentUserId}) {
|
||||
return FeedBlogPostModel(
|
||||
id: item.postId,
|
||||
state: item.postState,
|
||||
image: resolveMediaUrl(item.postImageUrl),
|
||||
description: item.postTextMd ?? item.postShortDescription ?? '',
|
||||
author: Author(
|
||||
authorSlug: item.postAuthorDetails?.slug ?? '',
|
||||
authorName: item.postAuthorName,
|
||||
authorProfileIcon: resolveMediaUrl(item.postAuthorImageUrl),
|
||||
authorIsMe: currentUserId != null && item.postAuthorId == currentUserId,
|
||||
isEntitySubscribedByMe: item.isEntitySubscribedByMe,
|
||||
),
|
||||
videoUrl: '',
|
||||
createdAt: item.postCreatedAt,
|
||||
updatedAt: item.updatedAt,
|
||||
title: item.postTitle,
|
||||
totalLikes: item.postLikesCount,
|
||||
totalViews: item.postViewsCount,
|
||||
totalComments: item.postCommentsCount,
|
||||
wasLikedByMe: item.postIsLikedByUser,
|
||||
images: item.images,
|
||||
videoEmbedUrl: item.videoEmbedUrl,
|
||||
videoUrlThumb: item.videoUrlThumb,
|
||||
);
|
||||
}
|
||||
|
||||
static String resolveMediaUrl(String? url) {
|
||||
if (url == null || url.isEmpty) return '';
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) return url;
|
||||
if (url.startsWith('/')) return '${sl<AppConfig>().baseUrl}$url';
|
||||
return '${sl<AppConfig>().baseUrl}/storage/$url';
|
||||
}
|
||||
}
|
||||
95
lib/features/feed/models/post_feed_pagination_model.dart
Normal file
@@ -0,0 +1,95 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'post_feed_pagination_model.freezed.dart';
|
||||
part 'post_feed_pagination_model.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class PostFeedPaginationModel with _$PostFeedPaginationModel {
|
||||
const factory PostFeedPaginationModel({
|
||||
@JsonKey(name: 'data') required List<PostFeedItemModel> data,
|
||||
required CursorLinks links,
|
||||
required CursorMeta meta,
|
||||
}) = _PostFeedPaginationModel;
|
||||
|
||||
factory PostFeedPaginationModel.fromJson(Map<String, Object?> json) => _$PostFeedPaginationModelFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class PostFeedItemModel with _$PostFeedItemModel {
|
||||
const factory PostFeedItemModel({
|
||||
required int id,
|
||||
@JsonKey(name: 'post_id') required int postId,
|
||||
@JsonKey(name: 'post_author_id') required int postAuthorId,
|
||||
@JsonKey(name: 'post_author_name') required String postAuthorName,
|
||||
@JsonKey(name: 'post_author_image_url') String? postAuthorImageUrl,
|
||||
@JsonKey(name: 'post_author_profile_url') String? postAuthorProfileUrl,
|
||||
@JsonKey(name: 'post_created_at', fromJson: parseDateTime) required DateTime postCreatedAt,
|
||||
@JsonKey(name: 'post_title') required String postTitle,
|
||||
@JsonKey(name: 'post_short_description') String? postShortDescription,
|
||||
@JsonKey(name: 'post_text_md') String? postTextMd,
|
||||
@JsonKey(name: 'post_image_url') String? postImageUrl,
|
||||
@Default([]) List<FeedGalleryImage> images,
|
||||
@JsonKey(name: 'video_embed_url') String? videoEmbedUrl,
|
||||
@JsonKey(name: 'post_views_count') required int postViewsCount,
|
||||
@JsonKey(name: 'post_likes_count') required int postLikesCount,
|
||||
@JsonKey(name: 'post_comments_count') required int postCommentsCount,
|
||||
@JsonKey(name: 'post_is_liked_by_user') required bool postIsLikedByUser,
|
||||
@JsonKey(name: 'is_entity_subscribed_by_me') bool? isEntitySubscribedByMe,
|
||||
@JsonKey(name: 'post_url') String? postUrl,
|
||||
@JsonKey(name: 'post_state') required String postState,
|
||||
@JsonKey(name: 'post_author_detailz') PostAuthorDetails? postAuthorDetails,
|
||||
@JsonKey(name: "video_url_thumb") String? videoUrlThumb,
|
||||
@JsonKey(name: 'created_at', fromJson: parseDateTime) required DateTime createdAt,
|
||||
@JsonKey(name: 'updated_at', fromJson: parseDateTime) required DateTime updatedAt,
|
||||
}) = _PostFeedItemModel;
|
||||
|
||||
factory PostFeedItemModel.fromJson(Map<String, Object?> json) => _$PostFeedItemModelFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class FeedGalleryImage with _$FeedGalleryImage {
|
||||
const factory FeedGalleryImage({
|
||||
required String url,
|
||||
@Default('horizontal') String orientation,
|
||||
int? id,
|
||||
int? position,
|
||||
}) = _FeedGalleryImage;
|
||||
|
||||
factory FeedGalleryImage.fromJson(Map<String, Object?> json) => _$FeedGalleryImageFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class PostAuthorDetails with _$PostAuthorDetails {
|
||||
const factory PostAuthorDetails({
|
||||
required String slug,
|
||||
@JsonKey(name: 'is_consultant') required bool isConsultant,
|
||||
}) = _PostAuthorDetails;
|
||||
|
||||
factory PostAuthorDetails.fromJson(Map<String, Object?> json) => _$PostAuthorDetailsFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class CursorLinks with _$CursorLinks {
|
||||
const factory CursorLinks({
|
||||
required String? first,
|
||||
required String? last,
|
||||
required String? prev,
|
||||
required String? next,
|
||||
}) = _CursorLinks;
|
||||
|
||||
factory CursorLinks.fromJson(Map<String, Object?> json) => _$CursorLinksFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class CursorMeta with _$CursorMeta {
|
||||
const factory CursorMeta({
|
||||
required String path,
|
||||
@JsonKey(name: 'per_page') required int perPage,
|
||||
@JsonKey(name: 'next_cursor') String? nextCursor,
|
||||
@JsonKey(name: 'prev_cursor') String? prevCursor,
|
||||
}) = _CursorMeta;
|
||||
|
||||
factory CursorMeta.fromJson(Map<String, Object?> json) => _$CursorMetaFromJson(json);
|
||||
}
|
||||
|
||||
DateTime parseDateTime(String date) => DateTime.parse(date).toLocal();
|
||||
@@ -8,8 +8,8 @@ import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||
|
||||
class FeedCubit extends Cubit<FeedState> {
|
||||
final FeedRepository feedRepository;
|
||||
int currentPage = 1;
|
||||
late int lastPage;
|
||||
String? _nextCursor;
|
||||
bool _hasMore = true;
|
||||
bool isLoading = false;
|
||||
String? _query;
|
||||
|
||||
@@ -38,13 +38,16 @@ class FeedCubit extends Cubit<FeedState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadFeedBlogPostList({String? query}) async {
|
||||
Future<void> loadFeedBlogPostList({String? query, int? currentUserId}) async {
|
||||
try {
|
||||
_query = query;
|
||||
final feedBlogPosts = await feedRepository.getFeedBlogPosts(query: query);
|
||||
lastPage = feedBlogPosts.meta.lastPage;
|
||||
_nextCursor = null;
|
||||
_hasMore = true;
|
||||
final feed = await feedRepository.getFeed(search: query, currentUserId: currentUserId);
|
||||
_nextCursor = feed.nextCursor;
|
||||
_hasMore = _nextCursor != null;
|
||||
if (!isClosed) {
|
||||
emit(FeedState.loaded(feedBlogPostList: feedBlogPosts.feedBlogPostList, isLoading: false));
|
||||
emit(FeedState.loaded(feedBlogPostList: feed.posts, isLoading: false));
|
||||
}
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
@@ -54,29 +57,35 @@ class FeedCubit extends Cubit<FeedState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadNextPage() async {
|
||||
if (currentPage < lastPage && !isLoading && !isClosed) {
|
||||
isLoading = true;
|
||||
Future<void> loadNextPage({int? currentUserId}) async {
|
||||
if (!_hasMore || isLoading || isClosed || _nextCursor == null || state is! FeedStateLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isClosed) {
|
||||
emit((state as FeedStateLoaded).copyWith(isLoading: isLoading));
|
||||
}
|
||||
isLoading = true;
|
||||
emit((state as FeedStateLoaded).copyWith(isLoading: true));
|
||||
|
||||
try {
|
||||
if (currentPage < lastPage && !isClosed) {
|
||||
currentPage++;
|
||||
final feedBlogPosts = await feedRepository.getFeedBlogPosts(page: currentPage, query: _query);
|
||||
isLoading = false;
|
||||
if (!isClosed) {
|
||||
emit(FeedState.loaded(feedBlogPostList: [...(state as FeedStateLoaded).feedBlogPostList, ...feedBlogPosts.feedBlogPostList], isLoading: isLoading));
|
||||
}
|
||||
}
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
if (!isClosed) {
|
||||
emit(FeedState.error(errorMessage: e.toString()));
|
||||
}
|
||||
try {
|
||||
final feed = await feedRepository.getFeed(search: _query, cursor: _nextCursor, currentUserId: currentUserId);
|
||||
_nextCursor = feed.nextCursor;
|
||||
_hasMore = _nextCursor != null;
|
||||
if (!isClosed && state is FeedStateLoaded) {
|
||||
emit(
|
||||
FeedState.loaded(
|
||||
feedBlogPostList: [...(state as FeedStateLoaded).feedBlogPostList, ...feed.posts],
|
||||
isLoading: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
if (!isClosed && state is FeedStateLoaded) {
|
||||
emit((state as FeedStateLoaded).copyWith(isLoading: false));
|
||||
} else if (!isClosed) {
|
||||
emit(FeedState.error(errorMessage: e.toString()));
|
||||
}
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ class _FeedScreenContentState extends State<FeedScreenContent> {
|
||||
final state = feedCubit.state;
|
||||
// Загружаем данные, если они еще не загружены
|
||||
if (state is! FeedStateLoaded) {
|
||||
feedCubit.loadFeedBlogPostList();
|
||||
feedCubit.loadFeedBlogPostList(currentUserId: context.read<FeedProfileCubit>().state.profile?.id);
|
||||
}
|
||||
});
|
||||
super.initState();
|
||||
@@ -201,7 +201,10 @@ class _FeedScreenContentState extends State<FeedScreenContent> {
|
||||
EasyDebounce.debounce('searchFeed', const Duration(milliseconds: 500), () {
|
||||
switch (_selectedChipIndex) {
|
||||
case 0:
|
||||
context.read<FeedCubit>().loadFeedBlogPostList(query: query);
|
||||
context.read<FeedCubit>().loadFeedBlogPostList(
|
||||
query: query,
|
||||
currentUserId: context.read<FeedProfileCubit>().state.profile?.id,
|
||||
);
|
||||
break;
|
||||
case 1 when !hidePlans:
|
||||
context.read<PlansCubit>().loadPlans(query: query);
|
||||
@@ -224,7 +227,9 @@ class _FeedScreenContentState extends State<FeedScreenContent> {
|
||||
EasyDebounce.debounce('searchFeed', const Duration(milliseconds: 500), () {
|
||||
switch (_selectedChipIndex) {
|
||||
case 0:
|
||||
context.read<FeedCubit>().loadFeedBlogPostList();
|
||||
context.read<FeedCubit>().loadFeedBlogPostList(
|
||||
currentUserId: context.read<FeedProfileCubit>().state.profile?.id,
|
||||
);
|
||||
break;
|
||||
case 1 when !hidePlans:
|
||||
context.read<PlansCubit>().loadPlans();
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
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,
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
@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) {
|
||||
return GestureDetector(
|
||||
onTap: () => widget.onOpenGallery([widget.imageUrl], 0),
|
||||
child: Hero(
|
||||
tag: widget.imageUrl,
|
||||
child: CachedNetworkImage(
|
||||
fit: BoxFit.fitWidth,
|
||||
width: width,
|
||||
imageUrl: widget.imageUrl,
|
||||
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||