forked from eatvio/eatvio_app
Compare commits
164 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 67e6526552 | |||
|
|
1c8709fc50 | ||
| 6b4c88068e | |||
|
|
d30c63be85 | ||
|
|
0ddd4c6b14 | ||
| c45612decb | |||
|
|
2779f79464 | ||
| 05eaa3caa9 | |||
|
|
d8d7a211bd | ||
| e6601e2fe6 | |||
|
|
992573aead | ||
| d2fc087699 | |||
|
|
3122394037 | ||
| 18925fd95e | |||
|
|
9916828956 | ||
| fb68f02ca4 | |||
|
|
8cb3a184b0 | ||
| 4b72258b1f | |||
|
|
592f387221 | ||
|
|
dcf695777a | ||
|
|
141cf5543d | ||
| 40ba5f304e | |||
|
|
996cf07fb8 | ||
|
|
1766314bbe | ||
| d1575d7ac4 | |||
| 94bca6a793 | |||
|
|
a6b94f835c | ||
|
|
4c38026748 | ||
|
|
355ec2df48 | ||
| 04195dd95b | |||
|
|
d3336b1263 | ||
| 82f5c9a7fe | |||
|
|
39a512193e | ||
| 4dc5839317 | |||
|
|
da6158625f | ||
|
|
5a8f8aee3e | ||
|
|
86e9d0cc31 | ||
| 60080c7407 | |||
|
|
6d2935400a | ||
| 293601f1e1 | |||
|
|
ff17b813ac | ||
| 6a259e5452 | |||
|
|
9288a5c849 | ||
|
|
4ef174ebde | ||
|
|
1901248539 | ||
| b2e700a4eb | |||
|
|
1bbb08f2a2 | ||
| 61f1b76f99 | |||
|
|
ef4df92f58 | ||
|
|
dcd343e765 | ||
|
|
98c175df44 | ||
|
|
cb312037b2 | ||
| 572a6f1943 | |||
|
|
a6ae9abb57 | ||
| 431354170e | |||
|
|
c38e82f558 | ||
| afd6f9a93a | |||
|
|
641eb4d2e2 | ||
| 74c66c9600 | |||
|
|
73e89ff7a9 | ||
|
|
f8e06b6d7a | ||
|
|
866ea2fe3b | ||
| d73f68e24b | |||
|
|
61cded0081 | ||
|
|
df634fedd8 | ||
|
|
603a158cf2 | ||
| a1bae68307 | |||
|
|
2dce28b196 | ||
| ae4f9d2e9b | |||
|
|
a00aae36cf | ||
|
|
27e98bdb44 | ||
|
|
8ec6d5b799 | ||
|
|
077cfb3a67 | ||
| c4f5cae36d | |||
|
|
b040c6e020 | ||
|
|
745e77f948 | ||
|
|
c6ea1d414d | ||
|
|
10af279d87 | ||
| d0879e556f | |||
|
|
8f933118a3 | ||
| 7dc7eab774 | |||
|
|
16a13488d1 | ||
|
|
de7bf8dc19 | ||
| eeb261df0a | |||
|
|
1b03a0d5e1 | ||
|
|
0208ed753a | ||
| 184f0417a7 | |||
|
|
6265e865e9 | ||
| 1550b7ea0b | |||
|
|
db10895ebb | ||
|
|
a7361d3ab1 | ||
| 1c54063a35 | |||
| efae7fc958 | |||
|
|
90ac4e750d | ||
|
|
6d7419c283 | ||
| caf45a9050 | |||
|
|
0a5e7d95c4 | ||
|
|
85482173ad | ||
| 65f7992d40 | |||
|
|
52a01bee12 | ||
| 179fc0ad99 | |||
|
|
8494e61eec | ||
|
|
5c2458ecad | ||
| b63bab8bf5 | |||
| 7790014726 | |||
|
|
0d588bcd57 | ||
| b679e90ba2 | |||
|
|
6b5442eb87 | ||
| 2474c5bea3 | |||
|
|
478a53674a | ||
| 7f278d0f59 | |||
|
|
53426c858c | ||
| 88f0cde88f | |||
|
|
a5fcf62524 | ||
| a2e41a2ff5 | |||
|
|
5b1beafbab | ||
| 4eadecd6f2 | |||
|
|
6292db07a1 | ||
| ab29b6b329 | |||
|
|
af5d182a79 | ||
|
|
04de956769 | ||
| 191bb74aa4 | |||
|
|
3210f38d4a | ||
| 27a98a496d | |||
| d8ecc7f3b1 | |||
| 063da03d7e | |||
| 6b5927a99e | |||
| afab72f7bc | |||
| 2770ff0e80 | |||
| 1200021bb6 | |||
|
|
9e8b8b89fc | ||
| a24d052193 | |||
|
|
9de0efa28e | ||
| 37504aa292 | |||
| c8588a2e1f | |||
|
|
e0cab89126 | ||
|
|
954d4955da | ||
| e5086d7fbc | |||
| 0d93768dc3 | |||
|
|
8c5fc8d827 | ||
|
|
2d6b042d45 | ||
| d56aa45c92 | |||
|
|
b3d0a26667 | ||
| 8835d65944 | |||
|
|
cc9aa6bffb | ||
| a15e8b78a5 | |||
|
|
0667ab81a8 | ||
|
|
1dc60eec64 | ||
|
|
69f0f1e919 | ||
|
|
a6fe6afb80 | ||
|
|
c8c8ade083 | ||
|
|
037f153273 | ||
| 6a17bf3a95 | |||
|
|
2cccc9e253 | ||
| dcf7b25989 | |||
| d6b57ef34d | |||
| 45f20cc903 | |||
| 746b9b6d28 | |||
|
|
2dbd4a455f | ||
| c7514e2cbf | |||
| f1abea70c5 | |||
| e4d397b478 | |||
| 9c5c6df751 | |||
|
|
48a984f5e9 |
62
.gitea/ISSUE_TEMPLATE/bug_report.yml
Normal file
62
.gitea/ISSUE_TEMPLATE/bug_report.yml
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
name: "🐞 Bug Report"
|
||||||
|
description: "Report a bug to help us improve"
|
||||||
|
title: "[Bug]: "
|
||||||
|
labels: ["bug"]
|
||||||
|
body:
|
||||||
|
- type: dropdown
|
||||||
|
id: stand
|
||||||
|
attributes:
|
||||||
|
label: "Stand"
|
||||||
|
description: "Select the environment where the bug occurs"
|
||||||
|
options:
|
||||||
|
- "prod"
|
||||||
|
- "stage-dev2"
|
||||||
|
- "local"
|
||||||
|
- "all"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: platform
|
||||||
|
attributes:
|
||||||
|
label: "Platform"
|
||||||
|
description: "Select the platform where the bug occurs"
|
||||||
|
options:
|
||||||
|
- "Android"
|
||||||
|
- "iPhone"
|
||||||
|
- "Huawei"
|
||||||
|
- "All"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: device_model
|
||||||
|
attributes:
|
||||||
|
label: "Device Model"
|
||||||
|
description: "Optional. Specify the device where the bug occurs"
|
||||||
|
placeholder: "e.g., Samsung Galaxy S23, iPhone 15 Pro, Huawei P60"
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: reproduction_steps
|
||||||
|
attributes:
|
||||||
|
label: "Steps to Reproduce"
|
||||||
|
description: "Clear numbered steps"
|
||||||
|
placeholder: "1. Click X\n2. Navigate to Y..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: expected_actual
|
||||||
|
attributes:
|
||||||
|
label: "Expected vs Actual Behavior"
|
||||||
|
description: "What should happen vs what happens"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: "Description"
|
||||||
|
description: "Дополнительная информация описывающая проблему"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
5
.gitea/ISSUE_TEMPLATE/config.yml
Normal file
5
.gitea/ISSUE_TEMPLATE/config.yml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: "💬 Community Support"
|
||||||
|
url: "https://pepe.eatv.io/channel/general"
|
||||||
|
about: "Ask questions here"
|
||||||
26
.gitea/ISSUE_TEMPLATE/feature_request.yml
Normal file
26
.gitea/ISSUE_TEMPLATE/feature_request.yml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
name: "✨ Feature Request"
|
||||||
|
description: "Suggest an idea for this project"
|
||||||
|
title: "[Feature]: "
|
||||||
|
labels: ["enhancement"]
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
id: use_case
|
||||||
|
attributes:
|
||||||
|
label: "Use Case"
|
||||||
|
description: "Problem solved or value added"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: "Description"
|
||||||
|
description: "Дополнительная информация описывающая функциональность"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: priority
|
||||||
|
attributes:
|
||||||
|
label: "Priority Level"
|
||||||
|
options: ["Low", "Medium", "High"]
|
||||||
@@ -8,8 +8,16 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
runs-on: [macos-arm64]
|
runs-on: [macos-arm64]
|
||||||
steps:
|
steps:
|
||||||
|
# launchd does not load the login-shell PATH, so Homebrew git is invisible to act.
|
||||||
|
- name: Add Homebrew to PATH
|
||||||
|
run: |
|
||||||
|
echo "/opt/homebrew/bin" >> "$GITHUB_PATH"
|
||||||
|
echo "/opt/homebrew/sbin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
- name: Fastlane version
|
- name: Fastlane version
|
||||||
run: fastlane --version
|
run: |
|
||||||
|
git --version
|
||||||
|
fastlane --version
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: https://github.com/actions/checkout@v2
|
uses: https://github.com/actions/checkout@v2
|
||||||
@@ -59,12 +67,16 @@ jobs:
|
|||||||
- name: Flutter build
|
- name: Flutter build
|
||||||
run: |
|
run: |
|
||||||
flutter --version
|
flutter --version
|
||||||
|
flutter config --no-enable-swift-package-manager
|
||||||
|
rm -rf ios/Podfile.lock
|
||||||
flutter clean
|
flutter clean
|
||||||
flutter pub get
|
flutter pub get
|
||||||
flutter pub run build_runner build --delete-conflicting-outputs
|
flutter pub run build_runner build --delete-conflicting-outputs
|
||||||
flutter build ios --config-only --release
|
|
||||||
cd ios
|
cd ios
|
||||||
pod install
|
pod install --repo-update
|
||||||
|
cd ..
|
||||||
|
flutter build ios --config-only --release
|
||||||
|
cd ios
|
||||||
fastlane beta
|
fastlane beta
|
||||||
env:
|
env:
|
||||||
LANG: en_US.UTF-8
|
LANG: en_US.UTF-8
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -24,4 +24,5 @@ coverage/
|
|||||||
backend/
|
backend/
|
||||||
.fvm/
|
.fvm/
|
||||||
.fvmrc
|
.fvmrc
|
||||||
/eatvio
|
/eatvio
|
||||||
|
*.code-workspace
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# Current Context
|
# Current Context
|
||||||
|
|
||||||
## Project Status
|
## 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
|
## Recent Activity
|
||||||
- Memory Bank initialization completed
|
- 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
|
- Architecture and technology stack catalogued
|
||||||
|
|
||||||
## Current Focus
|
## 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
|
## Known Issues
|
||||||
None documented at initialization.
|
- 2 теста в `test/features/feed/` (`post_feed_pagination_model_test`,
|
||||||
|
`post_feed_mapper_test`) падают на ветке `personalized_feed` — пре-существующие,
|
||||||
|
связаны с fixtures/маппингом, не с видеоплеером.
|
||||||
|
|
||||||
## Next Steps
|
## Next Steps
|
||||||
Awaiting user direction for:
|
Awaiting user direction for:
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ android {
|
|||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
manifestPlaceholders = [
|
manifestPlaceholders = [
|
||||||
YANDEX_CLIENT_ID: yandexClientId,
|
YANDEX_CLIENT_ID: yandexClientId,
|
||||||
applicationName: "io.flutter.app.FlutterApplication"
|
applicationName: "com.eatvioapp.app.EatvioApplication"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +69,7 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
testImplementation 'junit:junit:4.13.2'
|
||||||
implementation platform('com.google.firebase:firebase-bom:34.4.0')
|
implementation platform('com.google.firebase:firebase-bom:34.4.0')
|
||||||
implementation 'com.google.firebase:firebase-messaging'
|
implementation 'com.google.firebase:firebase-messaging'
|
||||||
implementation 'com.huawei.agconnect:agconnect-core:1.9.5.300'
|
implementation 'com.huawei.agconnect:agconnect-core:1.9.5.300'
|
||||||
|
|||||||
@@ -78,12 +78,7 @@
|
|||||||
<data android:host="eatvio.com" />
|
<data android:host="eatvio.com" />
|
||||||
<data android:host="www.eatvio.com" />
|
<data android:host="www.eatvio.com" />
|
||||||
|
|
||||||
<!-- Development/Subdomains -->
|
|
||||||
<data android:host="*.eatvio.ru" />
|
|
||||||
<data android:host="*.eatvio.com" />
|
|
||||||
|
|
||||||
<!-- Simplify patterns to allow navigation within the app for all matching hosts -->
|
|
||||||
<data android:pathPattern="/.*" />
|
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
@@ -92,8 +87,11 @@
|
|||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
|
|
||||||
<!-- Tap on notification body opens CallkitIncomingActivity via PendingIntent.
|
<!-- The incoming notification's content intent and full-screen intent both open
|
||||||
Replace it with a bridge that routes to the same accept flow as Answer. -->
|
CallkitIncomingActivity. Replace it with a bridge: a tap on an unlocked phone
|
||||||
|
routes to the same accept flow as Answer; on a locked/dark screen it shows a
|
||||||
|
ringing screen that wakes the device. Own task affinity keeps the plugin's
|
||||||
|
CLEAR_TASK flag from killing MainActivity. -->
|
||||||
<activity
|
<activity
|
||||||
android:name="com.hiennv.flutter_callkit_incoming.CallkitIncomingActivity"
|
android:name="com.hiennv.flutter_callkit_incoming.CallkitIncomingActivity"
|
||||||
tools:node="remove" />
|
tools:node="remove" />
|
||||||
@@ -102,20 +100,26 @@
|
|||||||
android:name=".EatvioCallkitIncomingBridgeActivity"
|
android:name=".EatvioCallkitIncomingBridgeActivity"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:theme="@android:style/Theme.Translucent.NoTitleBar"
|
android:theme="@android:style/Theme.Translucent.NoTitleBar"
|
||||||
|
android:taskAffinity=""
|
||||||
|
android:allowTaskReparenting="false"
|
||||||
android:excludeFromRecents="true"
|
android:excludeFromRecents="true"
|
||||||
android:noHistory="true"
|
android:noHistory="true"
|
||||||
|
android:showWhenLocked="true"
|
||||||
android:turnScreenOn="true"
|
android:turnScreenOn="true"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleInstance"
|
||||||
android:configChanges="orientation" />
|
android:configChanges="orientation" />
|
||||||
|
|
||||||
<activity-alias
|
<activity-alias
|
||||||
android:name="com.hiennv.flutter_callkit_incoming.CallkitIncomingActivity"
|
android:name="com.hiennv.flutter_callkit_incoming.CallkitIncomingActivity"
|
||||||
android:targetActivity=".EatvioCallkitIncomingBridgeActivity"
|
android:targetActivity=".EatvioCallkitIncomingBridgeActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
android:taskAffinity=""
|
||||||
|
android:allowTaskReparenting="false"
|
||||||
android:excludeFromRecents="true"
|
android:excludeFromRecents="true"
|
||||||
android:noHistory="true"
|
android:noHistory="true"
|
||||||
|
android:showWhenLocked="true"
|
||||||
android:turnScreenOn="true"
|
android:turnScreenOn="true"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleInstance"
|
||||||
android:configChanges="orientation"
|
android:configChanges="orientation"
|
||||||
android:permission="${applicationId}.PERMISSION_CALL">
|
android:permission="${applicationId}.PERMISSION_CALL">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
|
|||||||
@@ -13,14 +13,12 @@ object CallKitAcceptIntentMapper {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return hashMapOf(
|
return payloadMap(data, action)
|
||||||
"action" to action,
|
}
|
||||||
"id" to data.getString(CallkitConstants.EXTRA_CALLKIT_ID, ""),
|
|
||||||
"handle" to data.getString(CallkitConstants.EXTRA_CALLKIT_HANDLE, ""),
|
fun declinePayloadFromBundle(data: Bundle, action: String): Map<String, Any?>? {
|
||||||
"nameCaller" to data.getString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, ""),
|
if (!action.contains(CallkitConstants.ACTION_CALL_DECLINE)) return null
|
||||||
"avatar" to data.getString(CallkitConstants.EXTRA_CALLKIT_AVATAR, ""),
|
return payloadMap(data, action)
|
||||||
"extra" to data.getSerializable(CallkitConstants.EXTRA_CALLKIT_EXTRA),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun payloadFromIntent(intent: Intent?): Map<String, Any?>? {
|
fun payloadFromIntent(intent: Intent?): Map<String, Any?>? {
|
||||||
@@ -35,6 +33,16 @@ object CallKitAcceptIntentMapper {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return payloadMap(data, action)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearCallKitLaunchIntent(intent: Intent?) {
|
||||||
|
if (intent == null) return
|
||||||
|
intent.removeExtra(FlutterCallkitIncomingPlugin.EXTRA_CALLKIT_CALL_DATA)
|
||||||
|
intent.action = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun payloadMap(data: Bundle, action: String): Map<String, Any?> {
|
||||||
return hashMapOf(
|
return hashMapOf(
|
||||||
"action" to action,
|
"action" to action,
|
||||||
"id" to data.getString(CallkitConstants.EXTRA_CALLKIT_ID, ""),
|
"id" to data.getString(CallkitConstants.EXTRA_CALLKIT_ID, ""),
|
||||||
@@ -44,10 +52,4 @@ object CallKitAcceptIntentMapper {
|
|||||||
"extra" to data.getSerializable(CallkitConstants.EXTRA_CALLKIT_EXTRA),
|
"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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,20 @@ import com.hiennv.flutter_callkit_incoming.CallkitIncomingBroadcastReceiver
|
|||||||
|
|
||||||
object CallKitIntentBridge {
|
object CallKitIntentBridge {
|
||||||
fun dispatchCallKitAction(context: Context, action: String, data: Bundle?) {
|
fun dispatchCallKitAction(context: Context, action: String, data: Bundle?) {
|
||||||
|
if (IncomingCallRingtonePolicy.actionStopsRingtone(action)) {
|
||||||
|
VideoCallPushBridge.silenceIncomingCall(context)
|
||||||
|
}
|
||||||
|
|
||||||
if (data != null) {
|
if (data != null) {
|
||||||
CallKitAcceptIntentMapper.payloadFromBundle(data, action)?.let { payload ->
|
CallKitAcceptIntentMapper.payloadFromBundle(data, action)?.let { payload ->
|
||||||
PendingCallKitAcceptBridge.store(payload)
|
PendingCallKitAcceptBridge.store(payload)
|
||||||
}
|
}
|
||||||
|
CallKitAcceptIntentMapper.declinePayloadFromBundle(data, action)?.let { payload ->
|
||||||
|
PendingCallKitDeclineBridge.store(payload)
|
||||||
|
if (MainActivity.isInForeground) {
|
||||||
|
MainActivity.onPendingDecline?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val broadcastIntent = CallkitIncomingBroadcastReceiver.getIntent(context, action, data)
|
val broadcastIntent = CallkitIncomingBroadcastReceiver.getIntent(context, action, data)
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.eatvioapp.app
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
|
||||||
|
class EatvioApplication : Application() {
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
VideoCallDeclineReporter.install(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,71 @@
|
|||||||
package com.eatvioapp.app
|
package com.eatvioapp.app
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
|
import android.app.KeyguardManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.ActivityInfo
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.os.PowerManager
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.View
|
||||||
|
import android.view.WindowManager
|
||||||
|
import android.view.ViewGroup.MarginLayoutParams
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.TextView
|
||||||
|
import com.hiennv.flutter_callkit_incoming.AppUtils
|
||||||
import com.hiennv.flutter_callkit_incoming.CallkitConstants
|
import com.hiennv.flutter_callkit_incoming.CallkitConstants
|
||||||
|
import com.hiennv.flutter_callkit_incoming.CallkitNotificationManager
|
||||||
|
import com.hiennv.flutter_callkit_incoming.ImageLoaderProvider
|
||||||
|
import com.hiennv.flutter_callkit_incoming.Utils
|
||||||
|
import com.hiennv.flutter_callkit_incoming.widgets.RippleRelativeLayout
|
||||||
|
import java.lang.ref.WeakReference
|
||||||
|
import kotlin.math.abs
|
||||||
|
import com.hiennv.flutter_callkit_incoming.R as CallkitR
|
||||||
|
|
||||||
/// Redirects notification body / full-screen intent taps to the same accept path as
|
/// Target of the incoming notification's content intent and full-screen intent.
|
||||||
/// the Answer button, instead of showing the native Callkit incoming UI.
|
///
|
||||||
|
/// Unlocked, lit screen: the user tapped the notification body, route it to the same
|
||||||
|
/// accept flow as the Answer button.
|
||||||
|
///
|
||||||
|
/// Locked or dark screen: Android fired the full-screen intent on its own, so show a
|
||||||
|
/// ringing screen that wakes the device and lets the user answer or decline.
|
||||||
class EatvioCallkitIncomingBridgeActivity : Activity() {
|
class EatvioCallkitIncomingBridgeActivity : Activity() {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "EatvioIncomingBridge"
|
||||||
|
private const val WAKE_LOCK_TAG = "eatvio:incoming_call"
|
||||||
|
private const val DEFAULT_RING_DURATION_MS = 45_000L
|
||||||
|
private const val MIN_RING_SCREEN_MS = 1_000L
|
||||||
|
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
private var ringingScreen: WeakReference<EatvioCallkitIncomingBridgeActivity>? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes the ringing screen once the call stopped ringing: answered or declined
|
||||||
|
* from the notification, timed out, or ended by the caller.
|
||||||
|
*/
|
||||||
|
fun dismissRingingScreen() {
|
||||||
|
mainHandler.post {
|
||||||
|
ringingScreen?.get()?.closeRingingScreen()
|
||||||
|
ringingScreen = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shouldShowRingingScreen(keyguardLocked: Boolean, screenInteractive: Boolean): Boolean {
|
||||||
|
return keyguardLocked || !screenInteractive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var callData: Bundle? = null
|
||||||
|
private var wakeLock: PowerManager.WakeLock? = null
|
||||||
|
private var ringTimeout: Runnable? = null
|
||||||
|
private var actionTaken = false
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
@@ -15,15 +74,256 @@ class EatvioCallkitIncomingBridgeActivity : Activity() {
|
|||||||
finish()
|
finish()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
callData = data
|
||||||
|
|
||||||
CallKitIntentBridge.dispatchCallKitAction(
|
if (!launchedOnLockedOrDarkScreen()) {
|
||||||
this,
|
Log.i(TAG, "tap on notification body: accept")
|
||||||
CallkitConstants.ACTION_CALL_ACCEPT,
|
CallKitIntentBridge.dispatchCallKitAction(this, CallkitConstants.ACTION_CALL_ACCEPT, data)
|
||||||
data,
|
finish()
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
overridePendingTransition(0, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.i(TAG, "full-screen intent on locked/dark screen: show ringing screen")
|
||||||
|
showRingingScreen(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
setIntent(intent)
|
||||||
|
val data = intent.extras?.getBundle(CallkitConstants.EXTRA_CALLKIT_INCOMING_DATA) ?: return
|
||||||
|
if (ringingScreen?.get() !== this) return
|
||||||
|
callData = data
|
||||||
|
bindCallData(data)
|
||||||
|
scheduleRingTimeout(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
cancelRingTimeout()
|
||||||
|
releaseWakeLock()
|
||||||
|
if (ringingScreen?.get() === this) {
|
||||||
|
ringingScreen = null
|
||||||
|
}
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Deprecated("Deprecated in Java")
|
||||||
|
override fun onBackPressed() {
|
||||||
|
// The ringing screen is dismissed by Answer / Decline / timeout only.
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun launchedOnLockedOrDarkScreen(): Boolean {
|
||||||
|
val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
|
||||||
|
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
|
return shouldShowRingingScreen(
|
||||||
|
keyguardLocked = keyguardManager.isKeyguardLocked,
|
||||||
|
screenInteractive = powerManager.isInteractive,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showRingingScreen(data: Bundle) {
|
||||||
|
ringingScreen = WeakReference(this)
|
||||||
|
|
||||||
|
setTheme(CallkitR.style.CallkitIncomingTheme)
|
||||||
|
requestedOrientation = if (Utils.isTablet(this)) {
|
||||||
|
ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||||
|
} else {
|
||||||
|
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||||
|
}
|
||||||
|
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
||||||
|
setTurnScreenOn(true)
|
||||||
|
setShowWhenLocked(true)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
window.addFlags(
|
||||||
|
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON or
|
||||||
|
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||||
|
window.statusBarColor = Color.TRANSPARENT
|
||||||
|
window.navigationBarColor = Color.TRANSPARENT
|
||||||
|
}
|
||||||
|
|
||||||
|
acquireWakeLock(ringDuration(data))
|
||||||
|
VideoCallPushBridge.keepRingingOnFullScreen()
|
||||||
|
|
||||||
|
setContentView(CallkitR.layout.activity_callkit_incoming)
|
||||||
|
bindCallData(data)
|
||||||
|
scheduleRingTimeout(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindCallData(data: Bundle) {
|
||||||
|
val background: ImageView = findViewById(CallkitR.id.ivBackground)
|
||||||
|
val ripple: RippleRelativeLayout = findViewById(CallkitR.id.llBackgroundAnimation)
|
||||||
|
val callerName: TextView = findViewById(CallkitR.id.tvNameCaller)
|
||||||
|
val callerNumber: TextView = findViewById(CallkitR.id.tvNumber)
|
||||||
|
val logo: ImageView = findViewById(CallkitR.id.ivLogo)
|
||||||
|
val avatar: ImageView = findViewById(CallkitR.id.ivAvatar)
|
||||||
|
val actions: LinearLayout = findViewById(CallkitR.id.llAction)
|
||||||
|
val acceptButton: ImageView = findViewById(CallkitR.id.ivAcceptCall)
|
||||||
|
val acceptLabel: TextView = findViewById(CallkitR.id.tvAccept)
|
||||||
|
val declineButton: ImageView = findViewById(CallkitR.id.ivDeclineCall)
|
||||||
|
val declineLabel: TextView = findViewById(CallkitR.id.tvDecline)
|
||||||
|
|
||||||
|
ripple.layoutParams.height = Utils.getScreenWidth() + Utils.getStatusBarHeight(this)
|
||||||
|
ripple.startRippleAnimation()
|
||||||
|
(actions.layoutParams as? MarginLayoutParams)?.let { params ->
|
||||||
|
params.setMargins(0, 0, 0, Utils.getNavigationBarHeight(this))
|
||||||
|
actions.layoutParams = params
|
||||||
|
}
|
||||||
|
|
||||||
|
callerName.text = data.getString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, "")
|
||||||
|
callerNumber.visibility = View.INVISIBLE
|
||||||
|
logo.visibility = View.INVISIBLE
|
||||||
|
|
||||||
|
val textColor = parseColor(data.getString(CallkitConstants.EXTRA_CALLKIT_TEXT_COLOR), Color.WHITE)
|
||||||
|
callerName.setTextColor(textColor)
|
||||||
|
acceptLabel.setTextColor(textColor)
|
||||||
|
declineLabel.setTextColor(textColor)
|
||||||
|
background.setBackgroundColor(
|
||||||
|
parseColor(data.getString(CallkitConstants.EXTRA_CALLKIT_BACKGROUND_COLOR), Color.parseColor("#0955fa")),
|
||||||
)
|
)
|
||||||
|
|
||||||
finish()
|
val avatarUrl = data.getString(CallkitConstants.EXTRA_CALLKIT_AVATAR, "")
|
||||||
@Suppress("DEPRECATION")
|
if (!avatarUrl.isNullOrEmpty()) {
|
||||||
overridePendingTransition(0, 0)
|
avatar.visibility = View.VISIBLE
|
||||||
|
@Suppress("DEPRECATION", "UNCHECKED_CAST")
|
||||||
|
val headers = data.getSerializable(CallkitConstants.EXTRA_CALLKIT_HEADERS) as? HashMap<String, Any?>
|
||||||
|
try {
|
||||||
|
ImageLoaderProvider.loadImage(this, avatarUrl, headers, CallkitR.drawable.ic_default_avatar, avatar)
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(TAG, "failed to load caller avatar", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.getInt(CallkitConstants.EXTRA_CALLKIT_TYPE, 0) > 0) {
|
||||||
|
acceptButton.setImageResource(CallkitR.drawable.ic_video)
|
||||||
|
}
|
||||||
|
acceptButton.background = AppUtils.createCircleDrawable(
|
||||||
|
parseColor(data.getString(CallkitConstants.EXTRA_CALLKIT_ACCEPT_COLOR), Color.parseColor("#4CAF50")),
|
||||||
|
)
|
||||||
|
declineButton.background = AppUtils.createCircleDrawable(
|
||||||
|
parseColor(data.getString(CallkitConstants.EXTRA_CALLKIT_DECLINE_COLOR), Color.parseColor("#F44336")),
|
||||||
|
)
|
||||||
|
|
||||||
|
val acceptText = data.getString(CallkitConstants.EXTRA_CALLKIT_TEXT_ACCEPT, "")
|
||||||
|
acceptLabel.text = if (acceptText.isNullOrEmpty()) getString(CallkitR.string.text_accept) else acceptText
|
||||||
|
val declineText = data.getString(CallkitConstants.EXTRA_CALLKIT_TEXT_DECLINE, "")
|
||||||
|
declineLabel.text = if (declineText.isNullOrEmpty()) getString(CallkitR.string.text_decline) else declineText
|
||||||
|
|
||||||
|
acceptButton.setOnClickListener { onAcceptClick() }
|
||||||
|
declineButton.setOnClickListener { onDeclineClick() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onAcceptClick() {
|
||||||
|
val data = callData ?: return
|
||||||
|
if (actionTaken) return
|
||||||
|
actionTaken = true
|
||||||
|
cancelRingTimeout()
|
||||||
|
Log.i(TAG, "ringing screen: accept")
|
||||||
|
|
||||||
|
// Same order as the plugin's own incoming activity: dispatch the accept, then ask
|
||||||
|
// the keyguard to unlock so MainActivity (which cannot show over the keyguard)
|
||||||
|
// becomes visible as soon as the user unlocks.
|
||||||
|
CallKitIntentBridge.dispatchCallKitAction(this, CallkitConstants.ACTION_CALL_ACCEPT, data)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
|
||||||
|
if (keyguardManager.isKeyguardLocked) {
|
||||||
|
keyguardManager.requestDismissKeyguard(this, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finishRingingTask()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onDeclineClick() {
|
||||||
|
val data = callData ?: return
|
||||||
|
if (actionTaken) return
|
||||||
|
actionTaken = true
|
||||||
|
cancelRingTimeout()
|
||||||
|
Log.i(TAG, "ringing screen: decline")
|
||||||
|
|
||||||
|
CallKitIntentBridge.dispatchCallKitAction(this, CallkitConstants.ACTION_CALL_DECLINE, data)
|
||||||
|
finishRingingTask()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun closeRingingScreen() {
|
||||||
|
if (isFinishing) return
|
||||||
|
cancelRingTimeout()
|
||||||
|
finishRingingTask()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun finishRingingTask() {
|
||||||
|
if (isFinishing) return
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||||
|
finishAndRemoveTask()
|
||||||
|
} else {
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ringDuration(data: Bundle): Long {
|
||||||
|
val duration = data.getLong(CallkitConstants.EXTRA_CALLKIT_DURATION, DEFAULT_RING_DURATION_MS)
|
||||||
|
return if (duration > 0L) duration else DEFAULT_RING_DURATION_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The notification was posted before this screen appeared, so ring only for the
|
||||||
|
* remaining part of the call duration.
|
||||||
|
*/
|
||||||
|
private fun scheduleRingTimeout(data: Bundle) {
|
||||||
|
cancelRingTimeout()
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val startedAt = data.getLong(CallkitNotificationManager.EXTRA_TIME_START_CALL, now)
|
||||||
|
val remaining = (ringDuration(data) - abs(now - startedAt)).coerceAtLeast(MIN_RING_SCREEN_MS)
|
||||||
|
val timeout = Runnable {
|
||||||
|
Log.i(TAG, "ringing screen: timed out")
|
||||||
|
closeRingingScreen()
|
||||||
|
}
|
||||||
|
ringTimeout = timeout
|
||||||
|
mainHandler.postDelayed(timeout, remaining)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelRingTimeout() {
|
||||||
|
ringTimeout?.let { mainHandler.removeCallbacks(it) }
|
||||||
|
ringTimeout = null
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
private fun acquireWakeLock(durationMs: Long) {
|
||||||
|
try {
|
||||||
|
val powerManager = applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
|
val lock = powerManager.newWakeLock(
|
||||||
|
PowerManager.SCREEN_BRIGHT_WAKE_LOCK or
|
||||||
|
PowerManager.ACQUIRE_CAUSES_WAKEUP or
|
||||||
|
PowerManager.ON_AFTER_RELEASE,
|
||||||
|
WAKE_LOCK_TAG,
|
||||||
|
)
|
||||||
|
lock.acquire(durationMs)
|
||||||
|
wakeLock = lock
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(TAG, "failed to acquire wake lock", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun releaseWakeLock() {
|
||||||
|
val lock = wakeLock ?: return
|
||||||
|
wakeLock = null
|
||||||
|
try {
|
||||||
|
if (lock.isHeld) lock.release()
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(TAG, "failed to release wake lock", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseColor(value: String?, fallback: Int): Int {
|
||||||
|
if (value.isNullOrBlank()) return fallback
|
||||||
|
return try {
|
||||||
|
Color.parseColor(value)
|
||||||
|
} catch (error: IllegalArgumentException) {
|
||||||
|
fallback
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class EatvioFcmMessagingService : FirebaseMessagingService() {
|
|||||||
Log.i(TAG, "onMessageReceived: ${message.data}")
|
Log.i(TAG, "onMessageReceived: ${message.data}")
|
||||||
|
|
||||||
if (VideoCallPushBridge.isEndedVideoCall(message.data)) {
|
if (VideoCallPushBridge.isEndedVideoCall(message.data)) {
|
||||||
VideoCallPushBridge.dismissAllIncomingCalls(this)
|
VideoCallPushBridge.dismissEndedVideoCall(this, message.data)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,11 +24,9 @@ class EatvioFcmMessagingService : FirebaseMessagingService() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (MainActivity.isInForeground) {
|
// Data-only call pushes are delivered only to this service.
|
||||||
Log.i(TAG, "App in foreground, deferring incoming call to Flutter handler")
|
// FlutterFirebaseMessagingService is removed, so the Dart listener does
|
||||||
return
|
// not see them. Show the incoming UI here for both foreground and background.
|
||||||
}
|
|
||||||
|
|
||||||
VideoCallPushBridge.showIncomingCall(this, message.data)
|
VideoCallPushBridge.showIncomingCall(this, message.data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class HmsPushService : HmsMessageService() {
|
|||||||
if (message == null) return
|
if (message == null) return
|
||||||
|
|
||||||
if (VideoCallPushBridge.isEndedVideoCall(message.dataOfMap)) {
|
if (VideoCallPushBridge.isEndedVideoCall(message.dataOfMap)) {
|
||||||
VideoCallPushBridge.dismissAllIncomingCalls(this)
|
VideoCallPushBridge.dismissEndedVideoCall(this, message.dataOfMap)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.eatvioapp.app
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call actions that must stop the incoming ringtone.
|
||||||
|
* Accept is included: answering the call is not the same as ending it,
|
||||||
|
* but the ringing player is a separate object from the in-call audio.
|
||||||
|
*/
|
||||||
|
internal object IncomingCallRingtonePolicy {
|
||||||
|
fun actionStopsRingtone(action: String): Boolean {
|
||||||
|
return action.contains("ACTION_CALL_ACCEPT") ||
|
||||||
|
action.contains("ACTION_CALL_DECLINE") ||
|
||||||
|
action.contains("ACTION_CALL_ENDED") ||
|
||||||
|
action.contains("ACTION_CALL_TIMEOUT")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,9 @@ import io.flutter.plugin.common.MethodChannel
|
|||||||
import com.hiennv.flutter_callkit_incoming.FlutterCallkitIncomingPlugin
|
import com.hiennv.flutter_callkit_incoming.FlutterCallkitIncomingPlugin
|
||||||
import com.huawei.agconnect.config.AGConnectServicesConfig
|
import com.huawei.agconnect.config.AGConnectServicesConfig
|
||||||
import com.huawei.hms.aaid.HmsInstanceId
|
import com.huawei.hms.aaid.HmsInstanceId
|
||||||
|
import com.huawei.hms.api.ConnectionResult
|
||||||
|
import com.huawei.hms.api.HuaweiApiAvailability
|
||||||
|
import com.huawei.hms.common.ApiException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -22,6 +25,9 @@ class MainActivity: FlutterActivity() {
|
|||||||
@JvmStatic
|
@JvmStatic
|
||||||
var isInForeground: Boolean = false
|
var isInForeground: Boolean = false
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
var onPendingDecline: (() -> Unit)? = null
|
||||||
}
|
}
|
||||||
|
|
||||||
private val channelName = "com.eatvioapp/hms_push"
|
private val channelName = "com.eatvioapp/hms_push"
|
||||||
@@ -35,13 +41,18 @@ class MainActivity: FlutterActivity() {
|
|||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
isInForeground = true
|
isInForeground = true
|
||||||
|
onPendingDecline = { scheduleDeliverPendingCallKitDecline() }
|
||||||
captureCallKitLaunchIntent(intent)
|
captureCallKitLaunchIntent(intent)
|
||||||
if (PendingCallKitAcceptBridge.peek() != null) {
|
if (PendingCallKitAcceptBridge.peek() != null) {
|
||||||
scheduleDeliverPendingCallKitAccept()
|
scheduleDeliverPendingCallKitAccept()
|
||||||
}
|
}
|
||||||
|
if (PendingCallKitDeclineBridge.peek() != null) {
|
||||||
|
scheduleDeliverPendingCallKitDecline()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
|
onPendingDecline = null
|
||||||
isInForeground = false
|
isInForeground = false
|
||||||
super.onPause()
|
super.onPause()
|
||||||
}
|
}
|
||||||
@@ -52,22 +63,50 @@ class MainActivity: FlutterActivity() {
|
|||||||
methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName)
|
methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName)
|
||||||
videoCallPipChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, videoCallPipChannelName)
|
videoCallPipChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, videoCallPipChannelName)
|
||||||
videoCallPushChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, videoCallPushChannelName)
|
videoCallPushChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, videoCallPushChannelName)
|
||||||
|
onPendingDecline = { scheduleDeliverPendingCallKitDecline() }
|
||||||
videoCallPushChannel?.setMethodCallHandler { call, result ->
|
videoCallPushChannel?.setMethodCallHandler { call, result ->
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
"dismissIncomingCalls" -> {
|
"dismissIncomingCalls" -> {
|
||||||
try {
|
try {
|
||||||
VideoCallPushBridge.dismissAllIncomingCalls(applicationContext)
|
val args = call.arguments as? Map<*, *>
|
||||||
|
val callId = args?.get("call_id")?.toString()
|
||||||
|
?: args?.get("callId")?.toString()
|
||||||
|
val conversationUuid = args?.get("conversation_uuid")?.toString()
|
||||||
|
?: args?.get("conversationUuid")?.toString()
|
||||||
|
VideoCallPushBridge.dismissIncomingCalls(
|
||||||
|
applicationContext,
|
||||||
|
callId = callId,
|
||||||
|
conversationUuid = conversationUuid,
|
||||||
|
)
|
||||||
result.success(null)
|
result.success(null)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
result.error("DISMISS_INCOMING_CALLS_FAILED", e.message, null)
|
result.error("DISMISS_INCOMING_CALLS_FAILED", e.message, null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"showIncomingCall" -> {
|
||||||
|
try {
|
||||||
|
val args = call.arguments as? Map<*, *> ?: emptyMap<Any, Any>()
|
||||||
|
val data = HashMap<String, String>()
|
||||||
|
for ((key, value) in args) {
|
||||||
|
val name = key as? String ?: continue
|
||||||
|
if (value == null) continue
|
||||||
|
data[name] = value.toString()
|
||||||
|
}
|
||||||
|
VideoCallPushBridge.showIncomingCall(applicationContext, data)
|
||||||
|
result.success(null)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
result.error("SHOW_INCOMING_CALL_FAILED", e.message, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
"drainPendingCallKitAccept" -> {
|
"drainPendingCallKitAccept" -> {
|
||||||
result.success(PendingCallKitAcceptBridge.drain())
|
result.success(PendingCallKitAcceptBridge.drain())
|
||||||
}
|
}
|
||||||
"peekPendingCallKitAccept" -> {
|
"peekPendingCallKitAccept" -> {
|
||||||
result.success(PendingCallKitAcceptBridge.peekPending())
|
result.success(PendingCallKitAcceptBridge.peekPending())
|
||||||
}
|
}
|
||||||
|
"drainPendingCallKitDecline" -> {
|
||||||
|
result.success(PendingCallKitDeclineBridge.drain())
|
||||||
|
}
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,6 +121,20 @@ class MainActivity: FlutterActivity() {
|
|||||||
}
|
}
|
||||||
methodChannel?.setMethodCallHandler { call, result ->
|
methodChannel?.setMethodCallHandler { call, result ->
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
|
"isHmsAvailable" -> {
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
val available = try {
|
||||||
|
HuaweiApiAvailability.getInstance()
|
||||||
|
.isHuaweiMobileServicesAvailable(applicationContext) == ConnectionResult.SUCCESS
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.w("HMS", "HMS availability check failed: ${e.message}")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
runOnUiThread {
|
||||||
|
result.success(available)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
"getHuaweiToken" -> {
|
"getHuaweiToken" -> {
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
try {
|
try {
|
||||||
@@ -121,10 +174,30 @@ class MainActivity: FlutterActivity() {
|
|||||||
runOnUiThread {
|
runOnUiThread {
|
||||||
result.success(token)
|
result.success(token)
|
||||||
}
|
}
|
||||||
|
} catch (e: ApiException) {
|
||||||
|
if (e.statusCode == 907135003) {
|
||||||
|
android.util.Log.w("HMS", "HMS client API invalid (${e.statusCode}), falling back to FCM")
|
||||||
|
runOnUiThread {
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
android.util.Log.e("HMS", "Error getting HMS token: ${e.statusCode}: ${e.message}", e)
|
||||||
|
runOnUiThread {
|
||||||
|
result.error("HMS_TOKEN_ERROR", "${e.statusCode}: ${e.message}", null)
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
android.util.Log.e("HMS", "Error getting HMS token: ${e.message}", e)
|
val message = e.message.orEmpty()
|
||||||
runOnUiThread {
|
if (message.contains("907135003") || message.contains("client api invalid", ignoreCase = true)) {
|
||||||
result.error("HMS_TOKEN_ERROR", e.message, null)
|
android.util.Log.w("HMS", "HMS client API invalid, falling back to FCM")
|
||||||
|
runOnUiThread {
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
android.util.Log.e("HMS", "Error getting HMS token: $message", e)
|
||||||
|
runOnUiThread {
|
||||||
|
result.error("HMS_TOKEN_ERROR", e.message, null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -189,6 +262,30 @@ class MainActivity: FlutterActivity() {
|
|||||||
channel.invokeMethod("onCallKitLaunchIntent", payload)
|
channel.invokeMethod("onCallKitLaunchIntent", payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun scheduleDeliverPendingCallKitDecline() {
|
||||||
|
PendingCallKitDeclineBridge.scheduleDeliverToFlutter(
|
||||||
|
deliver = { deliverPendingCallKitDeclineToFlutter() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deliverPendingCallKitDeclineToFlutter() {
|
||||||
|
val payload = PendingCallKitDeclineBridge.peek() ?: return
|
||||||
|
val channel = videoCallPushChannel
|
||||||
|
if (channel == null) {
|
||||||
|
android.util.Log.w(
|
||||||
|
"VideoCallPush",
|
||||||
|
"deliverPendingCallKitDeclineToFlutter: channel not ready",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
android.util.Log.i(
|
||||||
|
"VideoCallPush",
|
||||||
|
"deliverPendingCallKitDeclineToFlutter: id=${payload["id"]}",
|
||||||
|
)
|
||||||
|
channel.invokeMethod("onCallKitDecline", payload)
|
||||||
|
}
|
||||||
|
|
||||||
private fun handleIntentForActionUrl(intent: Intent?) {
|
private fun handleIntentForActionUrl(intent: Intent?) {
|
||||||
if (intent == null) return
|
if (intent == null) return
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.eatvioapp.app
|
||||||
|
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
/// Stores a decline until Flutter can POST /video-call/decline.
|
||||||
|
///
|
||||||
|
/// The plugin broadcasts the decline. If the Flutter engine is not attached,
|
||||||
|
/// that broadcast never reaches the backend and the call keeps ringing on the
|
||||||
|
/// user's other devices.
|
||||||
|
object PendingCallKitDeclineBridge {
|
||||||
|
private const val TAG = "PendingCallKitDecline"
|
||||||
|
|
||||||
|
@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 decline 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 decline id=${payload["id"]}")
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
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,73 @@
|
|||||||
|
package com.eatvioapp.app
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
|
internal object FlutterSharedPrefs {
|
||||||
|
const val NAME = "FlutterSharedPreferences"
|
||||||
|
private const val PREFIX = "flutter."
|
||||||
|
|
||||||
|
fun string(context: Context, key: String): String? {
|
||||||
|
return context
|
||||||
|
.getSharedPreferences(NAME, Context.MODE_PRIVATE)
|
||||||
|
.getString(PREFIX + key, null)
|
||||||
|
?.trim()
|
||||||
|
?.takeIf { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal object VideoCallDeclineClient {
|
||||||
|
private const val TAG = "VideoCallDecline"
|
||||||
|
private const val TIMEOUT_MS = 4_000
|
||||||
|
|
||||||
|
fun send(context: Context, handle: String?, extra: Map<*, *>?) {
|
||||||
|
val fields = VideoCallDeclineRequestFactory.fields(handle, extra)
|
||||||
|
val baseUrl = FlutterSharedPrefs.string(context, "apiBaseUrl")
|
||||||
|
?: FlutterSharedPrefs.string(context, "customBaseUrl")
|
||||||
|
val request = VideoCallDeclineRequestFactory.build(
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
token = FlutterSharedPrefs.string(context, "token"),
|
||||||
|
hwId = FlutterSharedPrefs.string(context, "eatvio.android_install_hw_id"),
|
||||||
|
fields = fields,
|
||||||
|
)
|
||||||
|
if (request == null) {
|
||||||
|
Log.w(
|
||||||
|
TAG,
|
||||||
|
"skip decline POST conversation=${fields.conversationUuid} ended=${fields.endedPush}",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val code = post(request)
|
||||||
|
Log.i(TAG, "decline POST status=$code conversation=${fields.conversationUuid}")
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(TAG, "decline POST failed conversation=${fields.conversationUuid}", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun post(request: VideoCallDeclineRequest): Int {
|
||||||
|
val connection = (URL(request.url).openConnection() as HttpURLConnection).apply {
|
||||||
|
requestMethod = "POST"
|
||||||
|
connectTimeout = TIMEOUT_MS
|
||||||
|
readTimeout = TIMEOUT_MS
|
||||||
|
doOutput = true
|
||||||
|
setRequestProperty("Authorization", "Bearer ${request.token}")
|
||||||
|
setRequestProperty("Accept", "application/json")
|
||||||
|
setRequestProperty("Content-Type", "application/json; charset=utf-8")
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
connection.outputStream.use { stream ->
|
||||||
|
stream.write(request.body.toByteArray(Charsets.UTF_8))
|
||||||
|
}
|
||||||
|
val code = connection.responseCode
|
||||||
|
val response = if (code in 200..299) connection.inputStream else connection.errorStream
|
||||||
|
response?.use { stream -> stream.readBytes() }
|
||||||
|
return code
|
||||||
|
} finally {
|
||||||
|
connection.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package com.eatvioapp.app
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.os.BundleCompat
|
||||||
|
import com.hiennv.flutter_callkit_incoming.CallkitConstants
|
||||||
|
import com.hiennv.flutter_callkit_incoming.CallkitEventCallback
|
||||||
|
import com.hiennv.flutter_callkit_incoming.FlutterCallkitIncomingPlugin
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends `POST /video-call/decline` from the process that handled the tap.
|
||||||
|
*
|
||||||
|
* The notification Decline button is an explicit broadcast into the CallKit
|
||||||
|
* plugin. That broadcast starts the process when the app was killed and does
|
||||||
|
* not start Flutter. The plugin notifies this callback before it returns, so
|
||||||
|
* the request can finish while the process is still alive.
|
||||||
|
*/
|
||||||
|
object VideoCallDeclineReporter : CallkitEventCallback {
|
||||||
|
private const val TAG = "VideoCallDecline"
|
||||||
|
private const val WAIT_FOR_HTTP_MS = 9_000L
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var appContext: Context? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var installed = false
|
||||||
|
|
||||||
|
fun install(context: Context) {
|
||||||
|
if (installed) return
|
||||||
|
installed = true
|
||||||
|
appContext = context.applicationContext
|
||||||
|
FlutterCallkitIncomingPlugin.registerEventCallback(this)
|
||||||
|
Log.i(TAG, "installed")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCallEvent(event: CallkitEventCallback.CallEvent, callData: Bundle) {
|
||||||
|
if (event != CallkitEventCallback.CallEvent.DECLINE) return
|
||||||
|
val context = appContext ?: return
|
||||||
|
val handle = callData.getString(CallkitConstants.EXTRA_CALLKIT_HANDLE)
|
||||||
|
val extra = copyExtra(callData)
|
||||||
|
if (MainActivity.isInForeground) {
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
VideoCallDeclineClient.send(context, handle, extra)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The Decline button starts this process and then the broadcast ends.
|
||||||
|
// Wait here so Android does not kill the process before the POST returns.
|
||||||
|
runBlocking(Dispatchers.IO) {
|
||||||
|
withTimeoutOrNull(WAIT_FOR_HTTP_MS) {
|
||||||
|
VideoCallDeclineClient.send(context, handle, extra)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun copyExtra(callData: Bundle): Map<String, Any?> {
|
||||||
|
val raw = BundleCompat.getSerializable(
|
||||||
|
callData,
|
||||||
|
CallkitConstants.EXTRA_CALLKIT_EXTRA,
|
||||||
|
HashMap::class.java,
|
||||||
|
) ?: return emptyMap()
|
||||||
|
return buildMap {
|
||||||
|
for ((key, value) in raw) {
|
||||||
|
if (key is String) put(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package com.eatvioapp.app
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body of `POST /api/v2/chat/{conversation}/video-call/decline`.
|
||||||
|
*
|
||||||
|
* [callId] is omitted when it is missing, not a uuid, or equal to the
|
||||||
|
* conversation uuid: the server treats `call_id` as the session uuid.
|
||||||
|
*/
|
||||||
|
internal data class VideoCallDeclineRequest(
|
||||||
|
val url: String,
|
||||||
|
val token: String,
|
||||||
|
val body: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal data class VideoCallDeclineFields(
|
||||||
|
val conversationUuid: String,
|
||||||
|
val callId: String?,
|
||||||
|
val endedPush: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal object VideoCallDeclineRequestFactory {
|
||||||
|
private val HEX = "0123456789ABCDEF".toCharArray()
|
||||||
|
private val uuidPattern =
|
||||||
|
Regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
|
||||||
|
|
||||||
|
fun fields(handle: String?, extra: Map<*, *>?): VideoCallDeclineFields {
|
||||||
|
val conversation = firstString(
|
||||||
|
extra,
|
||||||
|
listOf("conversationUuid", "conversation_uuid"),
|
||||||
|
) ?: handle?.trim().orEmpty()
|
||||||
|
val rawCallId = firstString(extra, listOf("call_id", "callId"))
|
||||||
|
return VideoCallDeclineFields(
|
||||||
|
conversationUuid = conversation,
|
||||||
|
callId = backendCallId(rawCallId, conversation),
|
||||||
|
endedPush = marksEndedPush(extra),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun build(
|
||||||
|
baseUrl: String?,
|
||||||
|
token: String?,
|
||||||
|
hwId: String?,
|
||||||
|
fields: VideoCallDeclineFields,
|
||||||
|
): VideoCallDeclineRequest? {
|
||||||
|
if (fields.endedPush) return null
|
||||||
|
val conversation = fields.conversationUuid.trim()
|
||||||
|
val authToken = token?.trim().orEmpty()
|
||||||
|
val deviceId = hwId?.trim().orEmpty()
|
||||||
|
val root = baseUrl?.trim()?.trimEnd('/').orEmpty()
|
||||||
|
if (conversation.isEmpty() || authToken.isEmpty()) return null
|
||||||
|
if (deviceId.length < 8 || deviceId.length > 64) return null
|
||||||
|
if (!root.startsWith("http://") && !root.startsWith("https://")) return null
|
||||||
|
|
||||||
|
val encoded = encodePathSegment(conversation)
|
||||||
|
return VideoCallDeclineRequest(
|
||||||
|
url = "$root/api/v2/chat/$encoded/video-call/decline",
|
||||||
|
token = authToken,
|
||||||
|
body = jsonBody(deviceId, fields.callId),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun backendCallId(callId: String?, conversationUuid: String): String? {
|
||||||
|
val id = callId?.trim().orEmpty()
|
||||||
|
if (!uuidPattern.matches(id)) return null
|
||||||
|
if (id.equals(conversationUuid.trim(), ignoreCase = true)) return null
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun firstString(extra: Map<*, *>?, keys: List<String>): String? {
|
||||||
|
if (extra == null) return null
|
||||||
|
for (key in keys) {
|
||||||
|
val value = extra[key] as? String ?: continue
|
||||||
|
val trimmed = value.trim()
|
||||||
|
if (trimmed.isNotEmpty()) return trimmed
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun marksEndedPush(extra: Map<*, *>?): Boolean {
|
||||||
|
val value = extra?.get("endedPush") ?: return false
|
||||||
|
return when (value) {
|
||||||
|
is Boolean -> value
|
||||||
|
is Number -> value.toInt() != 0
|
||||||
|
is String -> {
|
||||||
|
val normalized = value.trim().lowercase()
|
||||||
|
normalized == "true" || normalized == "1"
|
||||||
|
}
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun jsonBody(hwId: String, callId: String?): String {
|
||||||
|
val hw = jsonString(hwId)
|
||||||
|
if (callId == null) return """{"hw_id":$hw}"""
|
||||||
|
return """{"hw_id":$hw,"call_id":${jsonString(callId)}}"""
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun encodePathSegment(value: String): String {
|
||||||
|
val bytes = value.encodeToByteArray()
|
||||||
|
return buildString(bytes.size) {
|
||||||
|
for (byte in bytes) {
|
||||||
|
val code = byte.toInt() and 0xFF
|
||||||
|
val allowed = code in 'A'.code..'Z'.code ||
|
||||||
|
code in 'a'.code..'z'.code ||
|
||||||
|
code in '0'.code..'9'.code ||
|
||||||
|
code == '-'.code ||
|
||||||
|
code == '_'.code ||
|
||||||
|
code == '.'.code ||
|
||||||
|
code == '~'.code
|
||||||
|
if (allowed) {
|
||||||
|
append(code.toChar())
|
||||||
|
} else {
|
||||||
|
append('%')
|
||||||
|
append(HEX[code ushr 4])
|
||||||
|
append(HEX[code and 0x0F])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun jsonString(value: String): String {
|
||||||
|
val escaped = buildString {
|
||||||
|
for (char in value) {
|
||||||
|
when (char) {
|
||||||
|
'\\' -> append("\\\\")
|
||||||
|
'"' -> append("\\\"")
|
||||||
|
'\n' -> append("\\n")
|
||||||
|
'\r' -> append("\\r")
|
||||||
|
'\t' -> append("\\t")
|
||||||
|
else -> append(char)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "\"$escaped\""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,10 +4,12 @@ import android.content.Context
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
|
import android.os.PowerManager
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import com.hiennv.flutter_callkit_incoming.CallkitConstants
|
import com.hiennv.flutter_callkit_incoming.CallkitConstants
|
||||||
|
import com.hiennv.flutter_callkit_incoming.CallkitEventCallback
|
||||||
import com.hiennv.flutter_callkit_incoming.CallkitNotificationManager
|
import com.hiennv.flutter_callkit_incoming.CallkitNotificationManager
|
||||||
import com.hiennv.flutter_callkit_incoming.CallkitNotificationService
|
import com.hiennv.flutter_callkit_incoming.CallkitNotificationService
|
||||||
import com.hiennv.flutter_callkit_incoming.CallkitSoundPlayerManager
|
import com.hiennv.flutter_callkit_incoming.CallkitSoundPlayerManager
|
||||||
@@ -16,15 +18,42 @@ import com.hiennv.flutter_callkit_incoming.FlutterCallkitIncomingPlugin
|
|||||||
import com.hiennv.flutter_callkit_incoming.addCall
|
import com.hiennv.flutter_callkit_incoming.addCall
|
||||||
import com.hiennv.flutter_callkit_incoming.getDataActiveCalls
|
import com.hiennv.flutter_callkit_incoming.getDataActiveCalls
|
||||||
import com.hiennv.flutter_callkit_incoming.removeAllCalls
|
import com.hiennv.flutter_callkit_incoming.removeAllCalls
|
||||||
|
import com.hiennv.flutter_callkit_incoming.removeCall
|
||||||
import java.util.HashMap
|
import java.util.HashMap
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
object VideoCallPushBridge {
|
object VideoCallPushBridge {
|
||||||
private const val TAG = "VideoCallPushBridge"
|
private const val TAG = "VideoCallPushBridge"
|
||||||
private const val ANDROID_ACCENT_COLOR = "#2787F5"
|
private const val ANDROID_ACCENT_COLOR = "#2787F5"
|
||||||
private const val MISSED_CALL_CLEANUP_DELAY_MS = 250L
|
private const val MISSED_CALL_CLEANUP_DELAY_MS = 250L
|
||||||
|
private const val TOMBSTONE_TTL_MS = 2 * 60 * 1000L
|
||||||
|
private const val WAKE_LOCK_TAG = "eatvio:incoming_call_ring"
|
||||||
|
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
private val playbackLock = Any()
|
||||||
|
private val playbackGeneration = AtomicInteger(0)
|
||||||
|
private val tombstonedCallIds = HashMap<String, Long>()
|
||||||
|
|
||||||
private var activeSoundManager: CallkitSoundPlayerManager? = null
|
private var activeSoundManager: CallkitSoundPlayerManager? = null
|
||||||
private var activeNotificationManager: CallkitNotificationManager? = null
|
private var activeNotificationManager: CallkitNotificationManager? = null
|
||||||
|
private var activeCallBundle: Bundle? = null
|
||||||
|
private var storedAppContext: Context? = null
|
||||||
|
private var ringtoneTimeout: Runnable? = null
|
||||||
|
private var ringWakeLock: PowerManager.WakeLock? = null
|
||||||
|
private var callbackRegistered = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The plugin stops only the sound player owned by [FlutterCallkitIncomingPlugin].
|
||||||
|
* Incoming pushes start a different player, so accept/decline never reached it
|
||||||
|
* and the ringtone kept looping until the process died.
|
||||||
|
*/
|
||||||
|
private val incomingCallEventCallback = object : CallkitEventCallback {
|
||||||
|
override fun onCallEvent(event: CallkitEventCallback.CallEvent, callData: Bundle) {
|
||||||
|
val context = storedAppContext ?: return
|
||||||
|
Log.i(TAG, "incoming call event=$event, stopping ringtone")
|
||||||
|
silenceIncomingCall(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun isIncomingVideoCall(data: Map<String, String>): Boolean {
|
fun isIncomingVideoCall(data: Map<String, String>): Boolean {
|
||||||
val rawType = data["type"]
|
val rawType = data["type"]
|
||||||
@@ -89,18 +118,191 @@ object VideoCallPushBridge {
|
|||||||
|
|
||||||
fun showIncomingCall(context: Context, data: Map<String, String>) {
|
fun showIncomingCall(context: Context, data: Map<String, String>) {
|
||||||
val appContext = context.applicationContext
|
val appContext = context.applicationContext
|
||||||
|
storedAppContext = appContext
|
||||||
|
ensureIncomingCallCallbackRegistered()
|
||||||
|
|
||||||
|
val serverCallId = callId(data)
|
||||||
|
if (isTombstoned(serverCallId)) {
|
||||||
|
Log.i(TAG, "showIncomingCall: skip tombstoned call_id=$serverCallId")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val callData = buildCallDataBundle(data)
|
val callData = buildCallDataBundle(data)
|
||||||
if (callData == null) {
|
if (callData == null) {
|
||||||
Log.w(TAG, "showIncomingCall: missing conversation uuid, data=$data")
|
Log.w(TAG, "showIncomingCall: missing conversation uuid, data=$data")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.i(TAG, "showIncomingCall: conversation=${callData.getString(CallkitConstants.EXTRA_CALLKIT_ID)}")
|
val callId = callData.getString(CallkitConstants.EXTRA_CALLKIT_ID)
|
||||||
|
val seenGeneration = playbackGeneration.get()
|
||||||
|
Log.i(TAG, "showIncomingCall: id=$callId conversation=${conversationUuid(data)}")
|
||||||
|
|
||||||
|
// Ringtone.play() and stop() must run on the same thread. FCM delivers
|
||||||
|
// pushes on a background thread, while Answer runs on the main thread.
|
||||||
|
// A second show for the same conversation must not bump the generation:
|
||||||
|
// that would cancel the timeout of the call that is already ringing.
|
||||||
|
mainHandler.post {
|
||||||
|
synchronized(playbackLock) {
|
||||||
|
if (seenGeneration != playbackGeneration.get()) {
|
||||||
|
Log.i(TAG, "showIncomingCall: skipped after silence")
|
||||||
|
return@post
|
||||||
|
}
|
||||||
|
val alreadyRinging = activeCallBundle?.getString(CallkitConstants.EXTRA_CALLKIT_ID)
|
||||||
|
if (!callId.isNullOrBlank() && callId == alreadyRinging) {
|
||||||
|
Log.i(TAG, "showIncomingCall: already ringing $callId")
|
||||||
|
return@post
|
||||||
|
}
|
||||||
|
val generation = playbackGeneration.incrementAndGet()
|
||||||
|
startIncomingCallLocked(appContext, callData, generation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops the ringtone that [showIncomingCall] started and removes the incoming
|
||||||
|
* notification. Safe to call more than once.
|
||||||
|
*/
|
||||||
|
fun silenceIncomingCall(context: Context) {
|
||||||
|
val appContext = context.applicationContext
|
||||||
|
storedAppContext = appContext
|
||||||
|
Log.i(TAG, "silenceIncomingCall")
|
||||||
|
playbackGeneration.incrementAndGet()
|
||||||
|
val stop = Runnable {
|
||||||
|
synchronized(playbackLock) {
|
||||||
|
stopPlaybackLocked(appContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||||
|
stop.run()
|
||||||
|
} else {
|
||||||
|
mainHandler.post(stop)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The plugin's sound player stops on ACTION_SCREEN_OFF. The ringing screen turns
|
||||||
|
* the display on itself, so keep ringing through that transition.
|
||||||
|
*/
|
||||||
|
fun keepRingingOnFullScreen() {
|
||||||
|
synchronized(playbackLock) {
|
||||||
|
activeSoundManager?.keepRingingOnFullScreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissIncomingCalls(
|
||||||
|
context: Context,
|
||||||
|
callId: String? = null,
|
||||||
|
conversationUuid: String? = null,
|
||||||
|
) {
|
||||||
|
val normalizedCallId = callId?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
val normalizedConversation = conversationUuid?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
if (normalizedCallId != null) {
|
||||||
|
markTombstone(normalizedCallId)
|
||||||
|
}
|
||||||
|
|
||||||
|
val appContext = context.applicationContext
|
||||||
|
val calls = getDataActiveCalls(appContext)
|
||||||
|
Log.i(
|
||||||
|
TAG,
|
||||||
|
"dismissIncomingCalls: active=${calls.size} call_id=${normalizedCallId ?: "-"} " +
|
||||||
|
"conversation=${normalizedConversation ?: "-"}",
|
||||||
|
)
|
||||||
|
playbackGeneration.incrementAndGet()
|
||||||
|
|
||||||
|
val dismiss = Runnable {
|
||||||
|
synchronized(playbackLock) {
|
||||||
|
val notificationManager = resolveNotificationManager(appContext)
|
||||||
|
val allBundles = calls.map { it.toBundle() }
|
||||||
|
val targeted = hasDismissTarget(normalizedCallId, normalizedConversation)
|
||||||
|
val bundles = if (targeted) {
|
||||||
|
allBundles.filter { bundleMatches(it, normalizedCallId, normalizedConversation) }
|
||||||
|
} else {
|
||||||
|
allBundles
|
||||||
|
}
|
||||||
|
|
||||||
|
val activeMatches = activeCallBundle?.let { bundle ->
|
||||||
|
!targeted || bundleMatches(bundle, normalizedCallId, normalizedConversation)
|
||||||
|
} == true
|
||||||
|
if (activeMatches || !targeted) {
|
||||||
|
stopPlaybackLocked(appContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (bundle in bundles) {
|
||||||
|
try {
|
||||||
|
dismissIncomingCallNotification(appContext, notificationManager, bundle)
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(
|
||||||
|
TAG,
|
||||||
|
"dismissIncomingCalls: failed for call ${bundle.getString(CallkitConstants.EXTRA_CALLKIT_ID)}",
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targeted || bundles.isNotEmpty()) {
|
||||||
|
try {
|
||||||
|
CallkitNotificationService.stopService(appContext)
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(TAG, "dismissIncomingCalls: failed to stop callkit service", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targeted) {
|
||||||
|
try {
|
||||||
|
removeAllCalls(appContext)
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(TAG, "dismissIncomingCalls: failed to remove persisted calls", error)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (bundle in bundles) {
|
||||||
|
try {
|
||||||
|
removeCall(appContext, Data.fromBundle(bundle))
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(TAG, "dismissIncomingCalls: failed to remove targeted call", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeMatches || !targeted) {
|
||||||
|
stopIncomingRingtone()
|
||||||
|
}
|
||||||
|
scheduleMissedCallCleanup(notificationManager, bundles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||||
|
dismiss.run()
|
||||||
|
} else {
|
||||||
|
mainHandler.post(dismiss)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissAllIncomingCalls(context: Context) {
|
||||||
|
dismissIncomingCalls(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissEndedVideoCall(context: Context, data: Map<String, String>) {
|
||||||
|
dismissIncomingCalls(
|
||||||
|
context = context,
|
||||||
|
callId = callId(data),
|
||||||
|
conversationUuid = conversationUuid(data),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureIncomingCallCallbackRegistered() {
|
||||||
|
if (callbackRegistered) return
|
||||||
|
FlutterCallkitIncomingPlugin.registerEventCallback(incomingCallEventCallback)
|
||||||
|
callbackRegistered = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startIncomingCallLocked(appContext: Context, callData: Bundle, generation: Int) {
|
||||||
|
stopPlaybackLocked(appContext)
|
||||||
|
|
||||||
val soundManager = CallkitSoundPlayerManager(appContext)
|
val soundManager = CallkitSoundPlayerManager(appContext)
|
||||||
val notificationManager = CallkitNotificationManager(appContext, soundManager)
|
val notificationManager = CallkitNotificationManager(appContext, soundManager)
|
||||||
activeSoundManager = soundManager
|
activeSoundManager = soundManager
|
||||||
activeNotificationManager = notificationManager
|
activeNotificationManager = notificationManager
|
||||||
|
activeCallBundle = callData
|
||||||
notificationManager.createNotificationChanel(callData)
|
notificationManager.createNotificationChanel(callData)
|
||||||
notificationManager.showIncomingNotification(callData)
|
notificationManager.showIncomingNotification(callData)
|
||||||
|
|
||||||
@@ -109,46 +311,78 @@ object VideoCallPushBridge {
|
|||||||
} catch (error: Exception) {
|
} catch (error: Exception) {
|
||||||
Log.w(TAG, "showIncomingCall: failed to persist active call", error)
|
Log.w(TAG, "showIncomingCall: failed to persist active call", error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val durationMs = callData.getLong(CallkitConstants.EXTRA_CALLKIT_DURATION, 45_000L)
|
||||||
|
wakeScreenLocked(appContext, if (durationMs > 0L) durationMs else 45_000L)
|
||||||
|
if (durationMs > 0L) {
|
||||||
|
val timeout = Runnable {
|
||||||
|
synchronized(playbackLock) {
|
||||||
|
if (generation != playbackGeneration.get()) return@Runnable
|
||||||
|
Log.i(TAG, "incoming ringtone timed out")
|
||||||
|
stopPlaybackLocked(appContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ringtoneTimeout = timeout
|
||||||
|
mainHandler.postDelayed(timeout, durationMs)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun dismissAllIncomingCalls(context: Context) {
|
/**
|
||||||
val appContext = context.applicationContext
|
* Turns the screen on while the call rings. The full-screen intent of the
|
||||||
val calls = getDataActiveCalls(appContext)
|
* notification does the same only when the system grants it; the wake lock
|
||||||
Log.i(TAG, "dismissAllIncomingCalls: active calls=${calls.size}")
|
* works without any grant, so the lock screen with the call card lights up.
|
||||||
|
*/
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
private fun wakeScreenLocked(appContext: Context, durationMs: Long) {
|
||||||
|
releaseScreenWakeLockLocked()
|
||||||
|
try {
|
||||||
|
val powerManager = appContext.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
|
val lock = powerManager.newWakeLock(
|
||||||
|
PowerManager.SCREEN_BRIGHT_WAKE_LOCK or
|
||||||
|
PowerManager.ACQUIRE_CAUSES_WAKEUP or
|
||||||
|
PowerManager.ON_AFTER_RELEASE,
|
||||||
|
WAKE_LOCK_TAG,
|
||||||
|
)
|
||||||
|
lock.acquire(durationMs)
|
||||||
|
ringWakeLock = lock
|
||||||
|
Log.i(TAG, "wakeScreen: screen on for ${durationMs}ms, interactive=${powerManager.isInteractive}")
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(TAG, "wakeScreen: failed to acquire wake lock", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
stopIncomingRingtone(appContext)
|
private fun releaseScreenWakeLockLocked() {
|
||||||
|
val lock = ringWakeLock ?: return
|
||||||
|
ringWakeLock = null
|
||||||
|
try {
|
||||||
|
if (lock.isHeld) lock.release()
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(TAG, "wakeScreen: failed to release wake lock", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val notificationManager = resolveNotificationManager(appContext)
|
private fun stopPlaybackLocked(appContext: Context) {
|
||||||
val bundles = calls.map { it.toBundle() }
|
cancelRingtoneTimeoutLocked()
|
||||||
|
releaseScreenWakeLockLocked()
|
||||||
for (bundle in bundles) {
|
val notificationManager = activeNotificationManager
|
||||||
|
val bundle = activeCallBundle
|
||||||
|
stopIncomingRingtone()
|
||||||
|
if (notificationManager != null && bundle != null) {
|
||||||
try {
|
try {
|
||||||
dismissIncomingCallNotification(appContext, notificationManager, bundle)
|
dismissIncomingCallNotification(appContext, notificationManager, bundle)
|
||||||
} catch (error: Exception) {
|
} catch (error: Exception) {
|
||||||
Log.w(
|
Log.w(TAG, "stopPlayback: failed to dismiss incoming notification", error)
|
||||||
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
|
activeSoundManager = null
|
||||||
activeNotificationManager = null
|
activeNotificationManager = null
|
||||||
|
activeCallBundle = null
|
||||||
|
EatvioCallkitIncomingBridgeActivity.dismissRingingScreen()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelRingtoneTimeoutLocked() {
|
||||||
|
ringtoneTimeout?.let { mainHandler.removeCallbacks(it) }
|
||||||
|
ringtoneTimeout = null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun resolveNotificationManager(context: Context): CallkitNotificationManager {
|
private fun resolveNotificationManager(context: Context): CallkitNotificationManager {
|
||||||
@@ -161,11 +395,19 @@ object VideoCallPushBridge {
|
|||||||
return CallkitNotificationManager(context, soundManager)
|
return CallkitNotificationManager(context, soundManager)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun stopIncomingRingtone(context: Context) {
|
private fun stopIncomingRingtone() {
|
||||||
activeSoundManager?.stop()
|
try {
|
||||||
FlutterCallkitIncomingPlugin.getInstance()
|
activeSoundManager?.stop()
|
||||||
?.getCallkitSoundPlayerManager()
|
} catch (error: Exception) {
|
||||||
?.stop()
|
Log.w(TAG, "stopIncomingRingtone: active player", error)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
FlutterCallkitIncomingPlugin.getInstance()
|
||||||
|
?.getCallkitSoundPlayerManager()
|
||||||
|
?.stop()
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(TAG, "stopIncomingRingtone: plugin player", error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -222,6 +464,8 @@ object VideoCallPushBridge {
|
|||||||
|
|
||||||
private fun buildCallDataBundle(data: Map<String, String>): Bundle? {
|
private fun buildCallDataBundle(data: Map<String, String>): Bundle? {
|
||||||
val conversationUuid = conversationUuid(data) ?: return null
|
val conversationUuid = conversationUuid(data) ?: return null
|
||||||
|
val serverCallId = callId(data)
|
||||||
|
val callKitId = serverCallId ?: conversationUuid
|
||||||
val callerName = data["counterpartName"]
|
val callerName = data["counterpartName"]
|
||||||
?: data["counterpart_name"]
|
?: data["counterpart_name"]
|
||||||
?: data["caller_name"]
|
?: data["caller_name"]
|
||||||
@@ -247,6 +491,9 @@ object VideoCallPushBridge {
|
|||||||
|
|
||||||
val extra = HashMap<String, Any?>()
|
val extra = HashMap<String, Any?>()
|
||||||
extra["conversationUuid"] = conversationUuid
|
extra["conversationUuid"] = conversationUuid
|
||||||
|
if (!serverCallId.isNullOrBlank()) {
|
||||||
|
extra["call_id"] = serverCallId
|
||||||
|
}
|
||||||
extra["counterpartName"] = callerName
|
extra["counterpartName"] = callerName
|
||||||
if (avatarUrl.isNotBlank()) {
|
if (avatarUrl.isNotBlank()) {
|
||||||
extra["counterpartAvatarUrl"] = avatarUrl
|
extra["counterpartAvatarUrl"] = avatarUrl
|
||||||
@@ -257,14 +504,18 @@ object VideoCallPushBridge {
|
|||||||
if (counterpartIsConsultant) {
|
if (counterpartIsConsultant) {
|
||||||
extra["counterpartIsConsultant"] = true
|
extra["counterpartIsConsultant"] = true
|
||||||
}
|
}
|
||||||
|
val messageId = data["message_id"] ?: data["messageId"]
|
||||||
|
if (!messageId.isNullOrBlank()) {
|
||||||
|
extra["message_id"] = messageId
|
||||||
|
}
|
||||||
|
|
||||||
return Bundle().apply {
|
return Bundle().apply {
|
||||||
putString(CallkitConstants.EXTRA_CALLKIT_ID, conversationUuid)
|
putString(CallkitConstants.EXTRA_CALLKIT_ID, callKitId)
|
||||||
putString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, callerName)
|
putString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, callerName)
|
||||||
putString(CallkitConstants.EXTRA_CALLKIT_APP_NAME, "Итвио")
|
putString(CallkitConstants.EXTRA_CALLKIT_APP_NAME, "Итвио")
|
||||||
putString(CallkitConstants.EXTRA_CALLKIT_HANDLE, conversationUuid)
|
putString(CallkitConstants.EXTRA_CALLKIT_HANDLE, conversationUuid)
|
||||||
putString(CallkitConstants.EXTRA_CALLKIT_AVATAR, avatarUrl)
|
putString(CallkitConstants.EXTRA_CALLKIT_AVATAR, avatarUrl)
|
||||||
putInt(CallkitConstants.EXTRA_CALLKIT_TYPE, 0)
|
putInt(CallkitConstants.EXTRA_CALLKIT_TYPE, 1)
|
||||||
putLong(CallkitConstants.EXTRA_CALLKIT_DURATION, 45000L)
|
putLong(CallkitConstants.EXTRA_CALLKIT_DURATION, 45000L)
|
||||||
putString(CallkitConstants.EXTRA_CALLKIT_TEXT_ACCEPT, "Answer")
|
putString(CallkitConstants.EXTRA_CALLKIT_TEXT_ACCEPT, "Answer")
|
||||||
putString(CallkitConstants.EXTRA_CALLKIT_TEXT_DECLINE, "Decline")
|
putString(CallkitConstants.EXTRA_CALLKIT_TEXT_DECLINE, "Decline")
|
||||||
@@ -284,6 +535,77 @@ object VideoCallPushBridge {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun callId(data: Map<String, String>): String? {
|
||||||
|
return data["call_id"]?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
?: data["callId"]?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasDismissTarget(callId: String?, conversationUuid: String?): Boolean {
|
||||||
|
return !callId.isNullOrBlank() || !conversationUuid.isNullOrBlank()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bundleMatches(
|
||||||
|
bundle: Bundle,
|
||||||
|
callId: String?,
|
||||||
|
conversationUuid: String?,
|
||||||
|
): Boolean {
|
||||||
|
val normalizedCallId = callId?.trim()?.lowercase()
|
||||||
|
if (!normalizedCallId.isNullOrEmpty()) {
|
||||||
|
val id = bundle.getString(CallkitConstants.EXTRA_CALLKIT_ID)?.trim()?.lowercase()
|
||||||
|
if (id != null && id == normalizedCallId) return true
|
||||||
|
val extraCallId = extraString(bundle, "call_id")
|
||||||
|
?: extraString(bundle, "callId")
|
||||||
|
if (extraCallId != null && extraCallId.lowercase() == normalizedCallId) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// A ring that already stores its own call id must not be closed by
|
||||||
|
// another call in the same chat. Legacy rings have no call id and
|
||||||
|
// still match the conversation.
|
||||||
|
if (!extraCallId.isNullOrBlank()) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val normalizedConversation = conversationUuid?.trim()?.lowercase() ?: return false
|
||||||
|
val handle = bundle.getString(CallkitConstants.EXTRA_CALLKIT_HANDLE)?.trim()?.lowercase()
|
||||||
|
if (handle != null && handle == normalizedConversation) return true
|
||||||
|
val extraConversation = extraString(bundle, "conversationUuid")
|
||||||
|
?: extraString(bundle, "conversation_uuid")
|
||||||
|
return extraConversation?.lowercase() == normalizedConversation
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
private fun extraString(bundle: Bundle, key: String): String? {
|
||||||
|
val extra = bundle.getSerializable(CallkitConstants.EXTRA_CALLKIT_EXTRA) as? HashMap<String, Any?>
|
||||||
|
?: return null
|
||||||
|
val value = extra[key] ?: return null
|
||||||
|
val text = value.toString().trim()
|
||||||
|
return text.takeIf { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun markTombstone(callId: String) {
|
||||||
|
val normalized = callId.trim().lowercase()
|
||||||
|
if (normalized.isEmpty()) return
|
||||||
|
pruneTombstones()
|
||||||
|
tombstonedCallIds[normalized] = System.currentTimeMillis() + TOMBSTONE_TTL_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isTombstoned(callId: String?): Boolean {
|
||||||
|
val normalized = callId?.trim()?.lowercase() ?: return false
|
||||||
|
if (normalized.isEmpty()) return false
|
||||||
|
pruneTombstones()
|
||||||
|
return tombstonedCallIds.containsKey(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pruneTombstones() {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val iterator = tombstonedCallIds.entries.iterator()
|
||||||
|
while (iterator.hasNext()) {
|
||||||
|
val entry = iterator.next()
|
||||||
|
if (entry.value <= now) {
|
||||||
|
iterator.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun conversationUuid(data: Map<String, String>): String? {
|
private fun conversationUuid(data: Map<String, String>): String? {
|
||||||
val fromData = data["conversationUuid"]
|
val fromData = data["conversationUuid"]
|
||||||
?: data["conversation_uuid"]
|
?: data["conversation_uuid"]
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.eatvioapp.app
|
||||||
|
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class IncomingCallRingtonePolicyTest {
|
||||||
|
@Test
|
||||||
|
fun answerDeclineEndAndTimeoutStopTheRingtone() {
|
||||||
|
assertTrue(
|
||||||
|
IncomingCallRingtonePolicy.actionStopsRingtone(
|
||||||
|
"com.hiennv.flutter_callkit_incoming.ACTION_CALL_ACCEPT",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
IncomingCallRingtonePolicy.actionStopsRingtone(
|
||||||
|
"com.hiennv.flutter_callkit_incoming.ACTION_CALL_DECLINE",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
IncomingCallRingtonePolicy.actionStopsRingtone(
|
||||||
|
"com.hiennv.flutter_callkit_incoming.ACTION_CALL_ENDED",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
IncomingCallRingtonePolicy.actionStopsRingtone(
|
||||||
|
"com.hiennv.flutter_callkit_incoming.ACTION_CALL_TIMEOUT",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun incomingAndStartActionsKeepTheRingtonePlaying() {
|
||||||
|
assertFalse(
|
||||||
|
IncomingCallRingtonePolicy.actionStopsRingtone(
|
||||||
|
"com.hiennv.flutter_callkit_incoming.ACTION_CALL_INCOMING",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertFalse(
|
||||||
|
IncomingCallRingtonePolicy.actionStopsRingtone(
|
||||||
|
"com.hiennv.flutter_callkit_incoming.ACTION_CALL_START",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package com.eatvioapp.app
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class VideoCallDeclineRequestTest {
|
||||||
|
private val baseUrl = "https://eatvio.example"
|
||||||
|
private val token = "sanctum-token"
|
||||||
|
private val hwId = "install-hw-id-01"
|
||||||
|
private val conversation = "11111111-1111-1111-1111-111111111111"
|
||||||
|
private val callId = "22222222-2222-2222-2222-222222222222"
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildsDeclinePostWithCallId() {
|
||||||
|
val request = request(callId = callId)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"https://eatvio.example/api/v2/chat/$conversation/video-call/decline",
|
||||||
|
request?.url,
|
||||||
|
)
|
||||||
|
assertEquals(token, request?.token)
|
||||||
|
assertEquals(
|
||||||
|
"""{"hw_id":"$hwId","call_id":"$callId"}""",
|
||||||
|
request?.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun omitsCallIdWhenItIsTheConversationUuid() {
|
||||||
|
val request = request(callId = conversation.uppercase())
|
||||||
|
|
||||||
|
assertEquals("""{"hw_id":"$hwId"}""", request?.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun omitsCallIdThatIsNotAUuid() {
|
||||||
|
val request = request(callId = "message-15")
|
||||||
|
|
||||||
|
assertEquals("""{"hw_id":"$hwId"}""", request?.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun readsConversationFromHandleWhenExtraHasNone() {
|
||||||
|
val fields = VideoCallDeclineRequestFactory.fields(conversation, emptyMap<String, Any?>())
|
||||||
|
val request = VideoCallDeclineRequestFactory.build(baseUrl, token, hwId, fields)
|
||||||
|
|
||||||
|
assertTrue(request?.url?.contains(conversation) == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun skipsEndedPush() {
|
||||||
|
val fields = VideoCallDeclineRequestFactory.fields(
|
||||||
|
conversation,
|
||||||
|
mapOf("conversationUuid" to conversation, "endedPush" to true),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNull(VideoCallDeclineRequestFactory.build(baseUrl, token, hwId, fields))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun skipsMissingTokenBaseUrlOrShortHwId() {
|
||||||
|
val fields = VideoCallDeclineRequestFactory.fields(
|
||||||
|
null,
|
||||||
|
mapOf("conversationUuid" to conversation, "call_id" to callId),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNull(VideoCallDeclineRequestFactory.build(null, token, hwId, fields))
|
||||||
|
assertNull(VideoCallDeclineRequestFactory.build(baseUrl, " ", hwId, fields))
|
||||||
|
assertNull(VideoCallDeclineRequestFactory.build(baseUrl, token, "short", fields))
|
||||||
|
assertNull(VideoCallDeclineRequestFactory.build("eatvio.example", token, hwId, fields))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun trimsATrailingSlashOnTheBaseUrl() {
|
||||||
|
val request = VideoCallDeclineRequestFactory.build(
|
||||||
|
"$baseUrl/",
|
||||||
|
token,
|
||||||
|
hwId,
|
||||||
|
VideoCallDeclineRequestFactory.fields(
|
||||||
|
null,
|
||||||
|
mapOf("conversationUuid" to conversation),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"https://eatvio.example/api/v2/chat/$conversation/video-call/decline",
|
||||||
|
request?.url,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun request(callId: String?): VideoCallDeclineRequest? {
|
||||||
|
val fields = VideoCallDeclineRequestFactory.fields(
|
||||||
|
null,
|
||||||
|
mapOf(
|
||||||
|
"conversationUuid" to conversation,
|
||||||
|
"call_id" to callId,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return VideoCallDeclineRequestFactory.build(baseUrl, token, hwId, fields)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -650,7 +650,7 @@ code + .copy-button {
|
|||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
function configurationCacheProblems() { return (
|
function configurationCacheProblems() { return (
|
||||||
// begin-report-data
|
// begin-report-data
|
||||||
{"diagnostics":[{"problem":[{"text":"Properties should be assigned using the 'propName = value' syntax. Setting a property via the Gradle-generated 'propName value' or 'propName(value)' syntax in Groovy DSL has been deprecated."}],"severity":"WARNING","problemDetails":[{"text":"This is scheduled to be removed in Gradle 10."}],"contextualLabel":"Properties should be assigned using the 'propName = value' syntax. Setting a property via the Gradle-generated 'propName value' or 'propName(value)' syntax in Groovy DSL has been deprecated.","documentationLink":"https://docs.gradle.org/9.0.0/userguide/upgrading_version_8.html#groovy_space_assignment_syntax","problemId":[{"name":"deprecation","displayName":"Deprecation"},{"name":"properties-should-be-assigned-using-the-propname-value-syntax-setting-a-property-via-the-gradle-generated-propname-value-or-propname-value-syntax-in-groovy-dsl","displayName":"Properties should be assigned using the 'propName = value' syntax. Setting a property via the Gradle-generated 'propName value' or 'propName(value)' syntax in Groovy DSL has been deprecated."}],"solutions":[[{"text":"Use assignment ('url = <value>') instead."}]]}],"problemsReport":{"totalProblemCount":1,"requestedTasks":":app:compileDebugKotlin","documentationLink":"https://docs.gradle.org/9.0.0/userguide/reporting_problems.html","documentationLinkCaption":"Problem report","summaries":[]}}
|
{"diagnostics":[{"problem":[{"text":"Properties should be assigned using the 'propName = value' syntax. Setting a property via the Gradle-generated 'propName value' or 'propName(value)' syntax in Groovy DSL has been deprecated."}],"severity":"WARNING","problemDetails":[{"text":"This is scheduled to be removed in Gradle 10."}],"contextualLabel":"Properties should be assigned using the 'propName = value' syntax. Setting a property via the Gradle-generated 'propName value' or 'propName(value)' syntax in Groovy DSL has been deprecated.","documentationLink":"https://docs.gradle.org/9.0.0/userguide/upgrading_version_8.html#groovy_space_assignment_syntax","problemId":[{"name":"deprecation","displayName":"Deprecation"},{"name":"properties-should-be-assigned-using-the-propname-value-syntax-setting-a-property-via-the-gradle-generated-propname-value-or-propname-value-syntax-in-groovy-dsl","displayName":"Properties should be assigned using the 'propName = value' syntax. Setting a property via the Gradle-generated 'propName value' or 'propName(value)' syntax in Groovy DSL has been deprecated."}],"solutions":[[{"text":"Use assignment ('url = <value>') instead."}]]}],"problemsReport":{"totalProblemCount":1,"requestedTasks":":app:testDebugUnitTest --tests com.eatvioapp.app.VideoCallDeclineRequestTest","documentationLink":"https://docs.gradle.org/9.0.0/userguide/reporting_problems.html","documentationLinkCaption":"Problem report","summaries":[]}}
|
||||||
// end-report-data
|
// end-report-data
|
||||||
);}
|
);}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -44,11 +44,11 @@ post_install do |installer|
|
|||||||
'$(inherited)',
|
'$(inherited)',
|
||||||
## dart: PermissionGroup.camera
|
## dart: PermissionGroup.camera
|
||||||
'PERMISSION_CAMERA=1',
|
'PERMISSION_CAMERA=1',
|
||||||
|
## dart: PermissionGroup.microphone
|
||||||
|
'PERMISSION_MICROPHONE=1',
|
||||||
## dart: PermissionGroup.photos
|
## dart: PermissionGroup.photos
|
||||||
'PERMISSION_PHOTOS=1',
|
'PERMISSION_PHOTOS=1',
|
||||||
'PERMISSION_NOTIFICATIONS=1',
|
'PERMISSION_NOTIFICATIONS=1',
|
||||||
## dart: PermissionGroup.mediaLibrary
|
|
||||||
'PERMISSION_MEDIA_LIBRARY=1',
|
|
||||||
]
|
]
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||||
46B43B96F809CEC6058EFDD3 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 15ADDB2A35F334AF0AE50011 /* Pods_Runner.framework */; };
|
46B43B96F809CEC6058EFDD3 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 15ADDB2A35F334AF0AE50011 /* Pods_Runner.framework */; };
|
||||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||||
|
E47D0C1B2F0B4D1000EA7A01 /* VideoCallDeclineClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = E47D0C1A2F0B4D1000EA7A01 /* VideoCallDeclineClient.swift */; };
|
||||||
|
C4A8E2022E9A4B1000123456 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A8E2012E9A4B1000123456 /* SceneDelegate.swift */; };
|
||||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||||
@@ -37,6 +39,8 @@
|
|||||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
|
E47D0C1A2F0B4D1000EA7A01 /* VideoCallDeclineClient.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoCallDeclineClient.swift; sourceTree = "<group>"; };
|
||||||
|
C4A8E2012E9A4B1000123456 /* SceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||||
938EE8FB5BAA359AAEDEFFF8 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
938EE8FB5BAA359AAEDEFFF8 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
||||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||||
@@ -115,6 +119,8 @@
|
|||||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
||||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||||
|
E47D0C1A2F0B4D1000EA7A01 /* VideoCallDeclineClient.swift */,
|
||||||
|
C4A8E2012E9A4B1000123456 /* SceneDelegate.swift */,
|
||||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||||
FEE92A0E2D772EA300E6E989 /* Runner.entitlements */,
|
FEE92A0E2D772EA300E6E989 /* Runner.entitlements */,
|
||||||
);
|
);
|
||||||
@@ -300,6 +306,8 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||||
|
E47D0C1B2F0B4D1000EA7A01 /* VideoCallDeclineClient.swift in Sources */,
|
||||||
|
C4A8E2022E9A4B1000123456 /* SceneDelegate.swift in Sources */,
|
||||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -31,8 +31,6 @@
|
|||||||
<key>NSAllowsArbitraryLoads</key>
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
<key>NSAppleMusicUsageDescription</key>
|
|
||||||
<string>This permission is not needed by the app, but it is required by an underlying API. If you see this dialog, contact us.</string>
|
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>Предоставление доступа позволит Вам делать фотографии и добавлять к записям</string>
|
<string>Предоставление доступа позволит Вам делать фотографии и добавлять к записям</string>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
@@ -43,6 +41,27 @@
|
|||||||
<string>Может использоваться для стабильного поддержания связи во время звонка.</string>
|
<string>Может использоваться для стабильного поддержания связи во время звонка.</string>
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
<string>Предоставление доступа позволит Вам добавлять фото к записям</string>
|
<string>Предоставление доступа позволит Вам добавлять фото к записям</string>
|
||||||
|
<key>UIApplicationSceneManifest</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
<false/>
|
||||||
|
<key>UISceneConfigurations</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIWindowSceneSessionRoleApplication</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>UISceneClassName</key>
|
||||||
|
<string>UIWindowScene</string>
|
||||||
|
<key>UISceneConfigurationName</key>
|
||||||
|
<string>flutter</string>
|
||||||
|
<key>UISceneDelegateClassName</key>
|
||||||
|
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
|
||||||
|
<key>UISceneStoryboardFile</key>
|
||||||
|
<string>Main</string>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
<array>
|
<array>
|
||||||
<string>applinks:eatvio.ru</string>
|
<string>applinks:eatvio.ru</string>
|
||||||
<string>applinks:www.eatvio.ru</string>
|
<string>applinks:www.eatvio.ru</string>
|
||||||
|
<string>applinks:eatvio.com</string>
|
||||||
|
<string>applinks:www.eatvio.com</string>
|
||||||
<string>applinks:5399b03636104c6d920b87c1ad329254.oauth.yandex.ru</string>
|
<string>applinks:5399b03636104c6d920b87c1ad329254.oauth.yandex.ru</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
86
ios/Runner/SceneDelegate.swift
Normal file
86
ios/Runner/SceneDelegate.swift
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import Flutter
|
||||||
|
import UIKit
|
||||||
|
import YandexLoginSDK
|
||||||
|
|
||||||
|
/// iOS 27 requires a UIScene lifecycle. Flutter 3.38 does not yet have
|
||||||
|
/// `FlutterImplicitEngineDelegate`, so channels and URL handling are wired
|
||||||
|
/// here after the scene creates the Flutter view.
|
||||||
|
class SceneDelegate: FlutterSceneDelegate {
|
||||||
|
override func scene(
|
||||||
|
_ scene: UIScene,
|
||||||
|
willConnectTo session: UISceneSession,
|
||||||
|
options connectionOptions: UIScene.ConnectionOptions
|
||||||
|
) {
|
||||||
|
super.scene(scene, willConnectTo: session, options: connectionOptions)
|
||||||
|
registerCallChannels()
|
||||||
|
handleConnectionOptions(connectionOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func sceneDidBecomeActive(_ scene: UIScene) {
|
||||||
|
super.sceneDidBecomeActive(scene)
|
||||||
|
registerCallChannels()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
|
||||||
|
var handledCustomURL = false
|
||||||
|
if let appDelegate = UIApplication.shared.delegate as? AppDelegate {
|
||||||
|
for context in URLContexts {
|
||||||
|
if appDelegate.handleOpenURL(
|
||||||
|
context.url,
|
||||||
|
sourceApplication: context.options.sourceApplication
|
||||||
|
) {
|
||||||
|
handledCustomURL = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !handledCustomURL {
|
||||||
|
super.scene(scene, openURLContexts: URLContexts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
|
||||||
|
if YXLSdk.shared.processUserActivity(userActivity) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
super.scene(scene, continue: userActivity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func registerCallChannels() {
|
||||||
|
guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
appDelegate.registerCallChannels(from: flutterViewController)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var flutterViewController: FlutterViewController? {
|
||||||
|
if let controller = window?.rootViewController as? FlutterViewController {
|
||||||
|
return controller
|
||||||
|
}
|
||||||
|
for scene in UIApplication.shared.connectedScenes {
|
||||||
|
guard let windowScene = scene as? UIWindowScene else { continue }
|
||||||
|
for sceneWindow in windowScene.windows {
|
||||||
|
if let controller = sceneWindow.rootViewController as? FlutterViewController {
|
||||||
|
return controller
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleConnectionOptions(_ options: UIScene.ConnectionOptions) {
|
||||||
|
guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for context in options.urlContexts {
|
||||||
|
_ = appDelegate.handleOpenURL(
|
||||||
|
context.url,
|
||||||
|
sourceApplication: context.options.sourceApplication
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for activity in options.userActivities {
|
||||||
|
if YXLSdk.shared.processUserActivity(activity) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
149
ios/Runner/VideoCallDeclineClient.swift
Normal file
149
ios/Runner/VideoCallDeclineClient.swift
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
enum VideoCallDeclineClient {
|
||||||
|
private static let uuidPattern =
|
||||||
|
#"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"#
|
||||||
|
|
||||||
|
/// `POST /video-call/decline` from the CallKit callback. The process is
|
||||||
|
/// awake here even when the user never opens the app afterwards.
|
||||||
|
static func send(extra: [String: Any], handle: String) {
|
||||||
|
let fields = declineFields(extra: extra, handle: handle)
|
||||||
|
guard let request = makeRequest(
|
||||||
|
baseUrl: prefsString("apiBaseUrl") ?? prefsString("customBaseUrl"),
|
||||||
|
token: prefsString("token"),
|
||||||
|
hwId: UIDevice.current.identifierForVendor?.uuidString,
|
||||||
|
fields: fields
|
||||||
|
) else {
|
||||||
|
NSLog(
|
||||||
|
"[EatvioVoIP] decline POST skipped conversation=\(fields.conversationUuid) ended=\(fields.endedPush)"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let task = EatvioBackgroundTask()
|
||||||
|
task.begin(name: "video-call-decline")
|
||||||
|
let urlTask = URLSession.shared.dataTask(with: request) { _, response, error in
|
||||||
|
let code = (response as? HTTPURLResponse)?.statusCode ?? -1
|
||||||
|
NSLog(
|
||||||
|
"[EatvioVoIP] decline POST status=\(code) conversation=\(fields.conversationUuid) error=\(error?.localizedDescription ?? "-")"
|
||||||
|
)
|
||||||
|
task.end()
|
||||||
|
}
|
||||||
|
urlTask.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func makeRequest(
|
||||||
|
baseUrl: String?,
|
||||||
|
token: String?,
|
||||||
|
hwId: String?,
|
||||||
|
fields: VideoCallDeclineFields
|
||||||
|
) -> URLRequest? {
|
||||||
|
if fields.endedPush { return nil }
|
||||||
|
let conversation = fields.conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let authToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
let deviceId = hwId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
var root = baseUrl?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
while root.hasSuffix("/") {
|
||||||
|
root.removeLast()
|
||||||
|
}
|
||||||
|
guard !conversation.isEmpty, !authToken.isEmpty else { return nil }
|
||||||
|
guard deviceId.count >= 8, deviceId.count <= 64 else { return nil }
|
||||||
|
guard root.hasPrefix("http://") || root.hasPrefix("https://") else { return nil }
|
||||||
|
guard let encoded = conversation.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed),
|
||||||
|
let url = URL(string: "\(root)/api/v2/chat/\(encoded)/video-call/decline")
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
var body: [String: String] = ["hw_id": deviceId]
|
||||||
|
if let callId = fields.callId {
|
||||||
|
body["call_id"] = callId
|
||||||
|
}
|
||||||
|
guard let data = try? JSONSerialization.data(withJSONObject: body) else { return nil }
|
||||||
|
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.timeoutInterval = 10
|
||||||
|
request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.httpBody = data
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func declineFields(extra: [String: Any], handle: String) -> VideoCallDeclineFields {
|
||||||
|
let conversation = firstString(extra, keys: ["conversationUuid", "conversation_uuid"])
|
||||||
|
?? handle.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let rawCallId = firstString(extra, keys: ["call_id", "callId"])
|
||||||
|
return VideoCallDeclineFields(
|
||||||
|
conversationUuid: conversation,
|
||||||
|
callId: backendCallId(rawCallId, conversation: conversation),
|
||||||
|
endedPush: marksEndedPush(extra["endedPush"])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func backendCallId(_ callId: String?, conversation: String) -> String? {
|
||||||
|
let id = callId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
guard let regex = try? NSRegularExpression(pattern: uuidPattern),
|
||||||
|
regex.firstMatch(in: id, range: NSRange(id.startIndex..., in: id)) != nil
|
||||||
|
else { return nil }
|
||||||
|
if id.compare(conversation.trimmingCharacters(in: .whitespacesAndNewlines), options: .caseInsensitive) == .orderedSame {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func firstString(_ extra: [String: Any], keys: [String]) -> String? {
|
||||||
|
for key in keys {
|
||||||
|
guard let value = extra[key] as? String else { continue }
|
||||||
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !trimmed.isEmpty { return trimmed }
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func marksEndedPush(_ value: Any?) -> Bool {
|
||||||
|
if let bool = value as? Bool { return bool }
|
||||||
|
if let number = value as? NSNumber { return number.intValue != 0 }
|
||||||
|
if let text = value as? String {
|
||||||
|
let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
return normalized == "true" || normalized == "1"
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func prefsString(_ key: String) -> String? {
|
||||||
|
let value = UserDefaults.standard.string(forKey: "flutter.\(key)")?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if let value, !value.isEmpty { return value }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct VideoCallDeclineFields {
|
||||||
|
var conversationUuid: String
|
||||||
|
var callId: String?
|
||||||
|
var endedPush: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class EatvioBackgroundTask {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var id: UIBackgroundTaskIdentifier = .invalid
|
||||||
|
|
||||||
|
func begin(name: String) {
|
||||||
|
let taskId = UIApplication.shared.beginBackgroundTask(withName: name) { [weak self] in
|
||||||
|
self?.end()
|
||||||
|
}
|
||||||
|
lock.lock()
|
||||||
|
id = taskId
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func end() {
|
||||||
|
lock.lock()
|
||||||
|
let current = id
|
||||||
|
id = .invalid
|
||||||
|
lock.unlock()
|
||||||
|
guard current != .invalid else { return }
|
||||||
|
UIApplication.shared.endBackgroundTask(current)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:eatvio_app/core/app_initializer.dart';
|
import 'package:eatvio_app/core/app_initializer.dart';
|
||||||
import 'package:eatvio_app/core/cubit/marketing/marketing_cubit.dart';
|
import 'package:eatvio_app/core/cubit/marketing/marketing_cubit.dart';
|
||||||
import 'package:eatvio_app/core/env_config.dart';
|
import 'package:eatvio_app/core/env_config.dart';
|
||||||
import 'package:eatvio_app/core/internet_connection_cubit/internet_connection_cubit.dart';
|
import 'package:eatvio_app/core/internet_connection_cubit/internet_connection_cubit.dart';
|
||||||
import 'package:eatvio_app/core/locator_service.dart';
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
|
import 'package:eatvio_app/core/prefs.dart';
|
||||||
|
import 'package:eatvio_app/core/prefs_keys.dart';
|
||||||
import 'package:eatvio_app/core/views/cubit/views_cubit.dart';
|
import 'package:eatvio_app/core/views/cubit/views_cubit.dart';
|
||||||
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/new_messages_cubit/new_messages_cubit.dart';
|
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/new_messages_cubit/new_messages_cubit.dart';
|
||||||
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
|
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
|
||||||
@@ -31,20 +35,51 @@ class AppConfig {
|
|||||||
|
|
||||||
void setBaseUrl(String baseUrls) {
|
void setBaseUrl(String baseUrls) {
|
||||||
_baseUrl = baseUrls;
|
_baseUrl = baseUrls;
|
||||||
|
_persistBaseUrl(baseUrls);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _persistBaseUrl(String baseUrl) {
|
||||||
|
if (!sl.isRegistered<Prefs>()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final normalized = baseUrl.trim();
|
||||||
|
if (normalized.isEmpty) {
|
||||||
|
unawaited(sl<Prefs>().remove(PrefsKeys.customBaseUrl));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unawaited(sl<Prefs>().setString(PrefsKeys.customBaseUrl, normalized));
|
||||||
|
unawaited(sl<Prefs>().setString(PrefsKeys.apiBaseUrl, normalized));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Список глобальных BlocProvider'ов
|
/// Список глобальных BlocProvider'ов
|
||||||
/// Используются lazy: true для отложенной инициализации, чтобы не блокировать старт приложения
|
/// Используются lazy: true для отложенной инициализации, чтобы не блокировать старт приложения
|
||||||
List<BlocProvider> get globalBlocProviders => [
|
List<BlocProvider> get globalBlocProviders => [
|
||||||
BlocProvider<VideoCallSessionCubit>.value(value: sl<VideoCallSessionCubit>()),
|
BlocProvider<VideoCallSessionCubit>.value(
|
||||||
BlocProvider<NewMessagesCubit>(lazy: true, create: (context) => sl<NewMessagesCubit>()),
|
value: sl<VideoCallSessionCubit>(),
|
||||||
BlocProvider<NotificationsCubit>(lazy: true, create: (context) => sl<NotificationsCubit>()),
|
),
|
||||||
|
BlocProvider<NewMessagesCubit>(
|
||||||
|
lazy: true,
|
||||||
|
create: (context) => sl<NewMessagesCubit>(),
|
||||||
|
),
|
||||||
|
BlocProvider<NotificationsCubit>(
|
||||||
|
lazy: true,
|
||||||
|
create: (context) => sl<NotificationsCubit>(),
|
||||||
|
),
|
||||||
BlocProvider<FeedCubit>(lazy: true, create: (context) => sl<FeedCubit>()),
|
BlocProvider<FeedCubit>(lazy: true, create: (context) => sl<FeedCubit>()),
|
||||||
BlocProvider<ProfileAvatarCubit>(lazy: true, create: (context) => sl<ProfileAvatarCubit>()),
|
BlocProvider<ProfileAvatarCubit>(
|
||||||
|
lazy: true,
|
||||||
|
create: (context) => sl<ProfileAvatarCubit>(),
|
||||||
|
),
|
||||||
BlocProvider<ChatsCubit>(lazy: true, create: (context) => sl<ChatsCubit>()),
|
BlocProvider<ChatsCubit>(lazy: true, create: (context) => sl<ChatsCubit>()),
|
||||||
BlocProvider<ViewsCubit>(lazy: true, create: (context) => sl<ViewsCubit>()),
|
BlocProvider<ViewsCubit>(lazy: true, create: (context) => sl<ViewsCubit>()),
|
||||||
BlocProvider<InternetConnectionCubit>(lazy: false, create: (context) => sl<InternetConnectionCubit>()),
|
BlocProvider<InternetConnectionCubit>(
|
||||||
BlocProvider<MarketingCubit>(lazy: true, create: (context) => sl<MarketingCubit>()),
|
lazy: false,
|
||||||
|
create: (context) => sl<InternetConnectionCubit>(),
|
||||||
|
),
|
||||||
|
BlocProvider<MarketingCubit>(
|
||||||
|
lazy: true,
|
||||||
|
create: (context) => sl<MarketingCubit>(),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Делегаты локализации
|
/// Делегаты локализации
|
||||||
@@ -56,5 +91,7 @@ class AppConfig {
|
|||||||
];
|
];
|
||||||
|
|
||||||
/// Поддерживаемые локали
|
/// Поддерживаемые локали
|
||||||
List<Locale> get supportedLocales => [const Locale.fromSubtags(languageCode: 'ru')];
|
List<Locale> get supportedLocales => [
|
||||||
|
const Locale.fromSubtags(languageCode: 'ru'),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:developer';
|
|
||||||
import 'dart:io' as io;
|
import 'dart:io' as io;
|
||||||
import 'package:eatvio_app/core/app_config.dart';
|
|
||||||
import 'package:eatvio_app/core/env_config.dart';
|
import 'package:eatvio_app/core/env_config.dart';
|
||||||
import 'package:eatvio_app/core/error_reporting/error_report_policy.dart';
|
import 'package:eatvio_app/core/error_reporting/error_report_policy.dart';
|
||||||
import 'package:eatvio_app/core/locator_service.dart' as di;
|
import 'package:eatvio_app/core/locator_service.dart' as di;
|
||||||
import 'package:eatvio_app/core/locator_service.dart';
|
|
||||||
import 'package:eatvio_app/features/video_calls/services/video_call_fcm_handler.dart';
|
import 'package:eatvio_app/features/video_calls/services/video_call_fcm_handler.dart';
|
||||||
import 'package:eatvio_app/firebase_options.dart';
|
import 'package:eatvio_app/firebase_options.dart';
|
||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_native_splash/flutter_native_splash.dart';
|
import 'package:flutter_native_splash/flutter_native_splash.dart';
|
||||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
@@ -18,7 +14,6 @@ import 'package:sentry_flutter/sentry_flutter.dart';
|
|||||||
|
|
||||||
/// Класс для инициализации приложения
|
/// Класс для инициализации приложения
|
||||||
class AppInitializer {
|
class AppInitializer {
|
||||||
|
|
||||||
/// Инициализация всех сервисов приложения
|
/// Инициализация всех сервисов приложения
|
||||||
static Future<void> initialize({required AppBuildType buildType}) async {
|
static Future<void> initialize({required AppBuildType buildType}) async {
|
||||||
await _initializeEnvironment();
|
await _initializeEnvironment();
|
||||||
@@ -42,25 +37,21 @@ class AppInitializer {
|
|||||||
|
|
||||||
/// Инициализация Firebase
|
/// Инициализация Firebase
|
||||||
static Future<void> _initializeFirebase() async {
|
static Future<void> _initializeFirebase() async {
|
||||||
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform).timeout(
|
await Firebase.initializeApp(
|
||||||
|
options: DefaultFirebaseOptions.currentPlatform,
|
||||||
|
).timeout(
|
||||||
const Duration(seconds: 10),
|
const Duration(seconds: 10),
|
||||||
onTimeout: () {
|
onTimeout: () {
|
||||||
throw TimeoutException('Firebase initialization timeout');
|
throw TimeoutException('Firebase initialization timeout');
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
VideoCallFcmHandler.registerBackgroundHandler();
|
VideoCallFcmHandler.registerBackgroundHandler();
|
||||||
if (sl<AppConfig>().buildType == AppBuildType.prod) {
|
|
||||||
try {
|
|
||||||
await FirebaseMessaging.instance.requestPermission().timeout(const Duration(seconds: 5));
|
|
||||||
} catch (e) {
|
|
||||||
// Разрешение не критично, продолжаем работу
|
|
||||||
log('Failed to request Firebase messaging permission: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Инициализация системы внедрения зависимостей
|
/// Инициализация системы внедрения зависимостей
|
||||||
static Future<void> _initializeDependencyInjection({required AppBuildType buildType}) async {
|
static Future<void> _initializeDependencyInjection({
|
||||||
|
required AppBuildType buildType,
|
||||||
|
}) async {
|
||||||
await di.init(buildType: buildType);
|
await di.init(buildType: buildType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
30
lib/core/cached_base_url.dart
Normal file
30
lib/core/cached_base_url.dart
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:eatvio_app/core/app_config.dart';
|
||||||
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
|
import 'package:eatvio_app/core/prefs.dart';
|
||||||
|
import 'package:eatvio_app/core/prefs_keys.dart';
|
||||||
|
|
||||||
|
/// Подставляет сохранённый base URL до создания HTTP-клиента.
|
||||||
|
///
|
||||||
|
/// Действующий адрес также пишется в [PrefsKeys.apiBaseUrl], чтобы нативный
|
||||||
|
/// decline мог собрать `POST /video-call/decline`, когда Flutter не запущен.
|
||||||
|
void applyCachedBaseUrl() {
|
||||||
|
if (!sl.isRegistered<Prefs>() || !sl.isRegistered<AppConfig>()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final cached = sl<Prefs>().getString(PrefsKeys.customBaseUrl)?.trim();
|
||||||
|
if (cached != null && cached.isNotEmpty) {
|
||||||
|
sl<AppConfig>().setBaseUrl(cached);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_persistApiBaseUrl(sl<AppConfig>().baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _persistApiBaseUrl(String baseUrl) {
|
||||||
|
final normalized = baseUrl.trim();
|
||||||
|
if (normalized.isEmpty || !sl.isRegistered<Prefs>()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unawaited(sl<Prefs>().setString(PrefsKeys.apiBaseUrl, normalized));
|
||||||
|
}
|
||||||
@@ -1,16 +1,70 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:device_info_plus/device_info_plus.dart';
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
/// Stable id of this install, sent as `hw_id` on push registration and on
|
||||||
|
/// video-call accept/decline.
|
||||||
|
///
|
||||||
|
/// Android `Build.ID` is the firmware id. Two phones on the same build share
|
||||||
|
/// it, so the backend treats them as one device and a decline on one does not
|
||||||
|
/// reach the other. A per-install id stays unique.
|
||||||
abstract final class DeviceHwIdProvider {
|
abstract final class DeviceHwIdProvider {
|
||||||
static Future<String?> current() async {
|
static const androidInstallIdKey = 'eatvio.android_install_hw_id';
|
||||||
final deviceInfo = DeviceInfoPlugin();
|
static const androidTokenMigratedKey = 'eatvio.android_hw_id_token_migrated';
|
||||||
|
|
||||||
|
static Future<String?> current({SharedPreferences? preferences}) async {
|
||||||
if (Platform.isIOS) {
|
if (Platform.isIOS) {
|
||||||
return (await deviceInfo.iosInfo).identifierForVendor;
|
return (await DeviceInfoPlugin().iosInfo).identifierForVendor;
|
||||||
}
|
}
|
||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
return (await deviceInfo.androidInfo).id;
|
return androidInstallId(preferences: preferences);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<String> androidInstallId({
|
||||||
|
SharedPreferences? preferences,
|
||||||
|
}) async {
|
||||||
|
final prefs = preferences ?? await SharedPreferences.getInstance();
|
||||||
|
final existing = prefs.getString(androidInstallIdKey)?.trim();
|
||||||
|
if (existing != null && existing.length >= 8) return existing;
|
||||||
|
|
||||||
|
final created = _newInstallId();
|
||||||
|
await prefs.setString(androidInstallIdKey, created);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The previous Android registration used `Build.ID`. Drop that token once
|
||||||
|
/// and register again with the install id.
|
||||||
|
static Future<bool> androidTokenNeedsReregister({
|
||||||
|
SharedPreferences? preferences,
|
||||||
|
}) async {
|
||||||
|
if (!Platform.isAndroid) return false;
|
||||||
|
final prefs = preferences ?? await SharedPreferences.getInstance();
|
||||||
|
return prefs.getBool(androidTokenMigratedKey) != true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> markAndroidTokenReregistered({
|
||||||
|
SharedPreferences? preferences,
|
||||||
|
}) async {
|
||||||
|
final prefs = preferences ?? await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(androidTokenMigratedKey, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _newInstallId() {
|
||||||
|
final random = Random.secure();
|
||||||
|
final bytes = List<int>.generate(16, (_) => random.nextInt(256));
|
||||||
|
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||||
|
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||||
|
final hex = bytes
|
||||||
|
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join();
|
||||||
|
return '${hex.substring(0, 8)}-'
|
||||||
|
'${hex.substring(8, 12)}-'
|
||||||
|
'${hex.substring(12, 16)}-'
|
||||||
|
'${hex.substring(16, 20)}-'
|
||||||
|
'${hex.substring(20)}';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ class CubitRegistrations {
|
|||||||
|
|
||||||
// Content viewing cubits
|
// Content viewing cubits
|
||||||
sl.registerFactory<AboutDietCubit>(() => AboutDietCubit(dietsRepository: sl(), profileRepository: sl()));
|
sl.registerFactory<AboutDietCubit>(() => AboutDietCubit(dietsRepository: sl(), profileRepository: sl()));
|
||||||
sl.registerFactory<AboutPlanCubit>(() => AboutPlanCubit(planRepository: sl()));
|
sl.registerFactory<AboutPlanCubit>(() => AboutPlanCubit(planRepository: sl(), profileRepository: sl(), dietRepository: sl()));
|
||||||
sl.registerFactory<AboutRecipeCubit>(() => AboutRecipeCubit(recipeRepository: sl(), profileRepository: sl()));
|
sl.registerFactory<AboutRecipeCubit>(() => AboutRecipeCubit(recipeRepository: sl(), profileRepository: sl()));
|
||||||
sl.registerFactory<BlogPostCubit>(() => BlogPostCubit(sl()));
|
sl.registerFactory<BlogPostCubit>(() => BlogPostCubit(sl()));
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:eatvio_app/core/push/hms_client_error.dart';
|
||||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||||
|
|
||||||
/// Rules for which errors are sent to crash reporting.
|
/// Rules for which errors are sent to crash reporting.
|
||||||
@@ -15,6 +16,7 @@ class ErrorReportPolicy {
|
|||||||
if (error == null) return false;
|
if (error == null) return false;
|
||||||
if (error is String) return false;
|
if (error is String) return false;
|
||||||
if (error is SocketException) return false;
|
if (error is SocketException) return false;
|
||||||
|
if (isHmsClientUnavailable(error)) return false;
|
||||||
|
|
||||||
if (error is DioException) {
|
if (error is DioException) {
|
||||||
return _shouldReportDio(error);
|
return _shouldReportDio(error);
|
||||||
|
|||||||
@@ -3,59 +3,59 @@ import 'dart:developer';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:eatvio_app/core/app_config.dart';
|
import 'package:eatvio_app/core/app_config.dart';
|
||||||
import 'package:eatvio_app/core/app_initializer.dart';
|
import 'package:eatvio_app/core/app_initializer.dart';
|
||||||
import 'package:eatvio_app/core/utils/toasts.dart';
|
import 'package:eatvio_app/core/device/device_hw_id_provider.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:eatvio_app/core/notifications/notification_permission_requester.dart';
|
||||||
|
import 'package:eatvio_app/core/push/hms_client_error.dart';
|
||||||
import 'package:device_info_plus/device_info_plus.dart';
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:eatvio_app/core/locator_service.dart';
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
import 'package:eatvio_app/core/token_storage.dart';
|
import 'package:eatvio_app/core/token_storage.dart';
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
import 'package:flutter/services.dart' show MethodChannel, PlatformException;
|
import 'package:flutter/services.dart';
|
||||||
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
|
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
|
||||||
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
|
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
|
||||||
|
|
||||||
class FirebaseApi {
|
class FirebaseApi {
|
||||||
final ErrorReporter _errorReporter;
|
final ErrorReporter _errorReporter;
|
||||||
|
final NotificationPermissionRequester _notificationPermissionRequester;
|
||||||
final _firebaseMessaging = FirebaseMessaging.instance;
|
final _firebaseMessaging = FirebaseMessaging.instance;
|
||||||
|
|
||||||
FirebaseApi({required ErrorReporter errorReporter}) : _errorReporter = errorReporter;
|
FirebaseApi({
|
||||||
|
required ErrorReporter errorReporter,
|
||||||
|
NotificationPermissionRequester? notificationPermissionRequester,
|
||||||
|
}) : _errorReporter = errorReporter,
|
||||||
|
_notificationPermissionRequester = notificationPermissionRequester ?? NotificationPermissionRequester();
|
||||||
|
|
||||||
Future<String?> getToken() async {
|
Future<String?> getToken() async {
|
||||||
String? token;
|
final credentials = await _resolvePushCredentials();
|
||||||
|
return credentials.token;
|
||||||
|
}
|
||||||
|
|
||||||
if (await _isHuaweiDevice()) {
|
Future<void> _requestNotificationPermission() async {
|
||||||
token = await _getHuaweiPushTokenNative().timeout(
|
if (sl<AppConfig>().buildType != AppBuildType.prod) return;
|
||||||
const Duration(seconds: 5),
|
try {
|
||||||
onTimeout: () => null,
|
await _notificationPermissionRequester.requestOnce(
|
||||||
);
|
() => _firebaseMessaging.requestPermission(),
|
||||||
} else {
|
|
||||||
if ((sl<AppConfig>().buildType == AppBuildType.prod)) {
|
|
||||||
try {
|
|
||||||
await _firebaseMessaging.requestPermission().timeout(
|
|
||||||
const Duration(seconds: 5),
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
// Разрешение не критично, продолжаем
|
|
||||||
log('Failed to request Firebase messaging permission: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
token = await _firebaseMessaging.getToken().timeout(
|
|
||||||
const Duration(seconds: 5),
|
|
||||||
onTimeout: () => null,
|
|
||||||
);
|
);
|
||||||
|
} catch (e) {
|
||||||
|
log('Failed to request Firebase messaging permission: $e');
|
||||||
}
|
}
|
||||||
return token;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> registerAnonymousPushToken() async {
|
Future<void> registerAnonymousPushToken() async {
|
||||||
try {
|
try {
|
||||||
final deviceInfo = DeviceInfoPlugin();
|
await _requestNotificationPermission();
|
||||||
|
final credentials = await _resolvePushCredentials();
|
||||||
|
final token = credentials.token;
|
||||||
|
if (token == null || token.isEmpty) return;
|
||||||
|
final hwId = await DeviceHwIdProvider.current();
|
||||||
|
if (hwId == null || hwId.length < 8) return;
|
||||||
await sl<Dio>().post(
|
await sl<Dio>().post(
|
||||||
'/api/anonymous_token',
|
'/api/anonymous_token',
|
||||||
data: {
|
data: {
|
||||||
'platform': Platform.isIOS ? 'ios' : (await _isHuaweiDevice()) ? 'huawei' : 'android',
|
'platform': credentials.platform,
|
||||||
'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id,
|
'hw_id': hwId,
|
||||||
'token': await getToken(),
|
'token': token,
|
||||||
},
|
},
|
||||||
).timeout(
|
).timeout(
|
||||||
const Duration(seconds: 10),
|
const Duration(seconds: 10),
|
||||||
@@ -71,31 +71,52 @@ class FirebaseApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> registerPushToken() async {
|
Future<void> registerPushToken() async {
|
||||||
if (await TokenStorage.haveTokenInStorage()) {
|
if (!await TokenStorage.haveTokenInStorage()) return;
|
||||||
try {
|
try {
|
||||||
final deviceInfo = DeviceInfoPlugin();
|
await _requestNotificationPermission();
|
||||||
final isHuawei = Platform.isAndroid ? await _isHuaweiDevice() : false;
|
final credentials = await _resolvePushCredentials();
|
||||||
await sl<Dio>().post(
|
final token = credentials.token;
|
||||||
'/api/profile/notification/token/register',
|
final hwId = await DeviceHwIdProvider.current();
|
||||||
data: {
|
final reregisterAndroid =
|
||||||
'platform': Platform.isIOS ? 'ios' : (isHuawei ? 'huawei' : 'android'),
|
await DeviceHwIdProvider.androidTokenNeedsReregister();
|
||||||
'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id,
|
if (token != null &&
|
||||||
'token': await getToken(),
|
token.isNotEmpty &&
|
||||||
},
|
hwId != null &&
|
||||||
).timeout(
|
hwId.length >= 8) {
|
||||||
const Duration(seconds: 10),
|
if (reregisterAndroid) {
|
||||||
onTimeout: () {
|
try {
|
||||||
throw TimeoutException('registerPushToken timeout');
|
await unregisterPushToken();
|
||||||
},
|
} catch (_) {}
|
||||||
);
|
}
|
||||||
if (Platform.isIOS) {
|
await sl<Dio>()
|
||||||
await _registerIosVoipPushToken();
|
.post(
|
||||||
|
'/api/profile/notification/token/register',
|
||||||
|
data: {
|
||||||
|
'platform': credentials.platform,
|
||||||
|
'hw_id': hwId,
|
||||||
|
'token': token,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.timeout(
|
||||||
|
const Duration(seconds: 10),
|
||||||
|
onTimeout: () {
|
||||||
|
throw TimeoutException('registerPushToken timeout');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (reregisterAndroid) {
|
||||||
|
await DeviceHwIdProvider.markAndroidTokenReregistered();
|
||||||
}
|
}
|
||||||
} catch (e, st) {
|
|
||||||
log(e.toString(), stackTrace: st);
|
|
||||||
await _errorReporter.report(e, st, origin: 'FirebaseApi');
|
|
||||||
// Не пробрасываем ошибку дальше, так как это не критичная операция
|
|
||||||
}
|
}
|
||||||
|
} catch (e, st) {
|
||||||
|
log(e.toString(), stackTrace: st);
|
||||||
|
await _errorReporter.report(e, st, origin: 'FirebaseApi');
|
||||||
|
// Не пробрасываем ошибку дальше, так как это не критичная операция
|
||||||
|
}
|
||||||
|
// Incoming calls on iOS come only through the VoIP token. It does not
|
||||||
|
// depend on the FCM token, so a missing or failed FCM registration must
|
||||||
|
// not leave the device without calls.
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
await _registerIosVoipPushToken();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,13 +136,13 @@ class FirebaseApi {
|
|||||||
Future<void> unregisterPushToken() async {
|
Future<void> unregisterPushToken() async {
|
||||||
if (!await TokenStorage.haveTokenInStorage()) return;
|
if (!await TokenStorage.haveTokenInStorage()) return;
|
||||||
try {
|
try {
|
||||||
final isHuawei = Platform.isAndroid ? await _isHuaweiDevice() : false;
|
final credentials = await _resolvePushCredentials();
|
||||||
final platform = Platform.isIOS ? 'ios' : (isHuawei ? 'huawei' : 'android');
|
final token = credentials.token ?? '';
|
||||||
final token = (await getToken()) ?? '';
|
if (token.isEmpty) return;
|
||||||
await sl<Dio>().post(
|
await sl<Dio>().post(
|
||||||
'/api/profile/notification/token/unregister',
|
'/api/profile/notification/token/unregister',
|
||||||
data: {
|
data: {
|
||||||
'platform': platform,
|
'platform': credentials.platform,
|
||||||
'token': token,
|
'token': token,
|
||||||
},
|
},
|
||||||
).timeout(
|
).timeout(
|
||||||
@@ -141,12 +162,13 @@ class FirebaseApi {
|
|||||||
final token = (await FlutterCallkitIncoming.getDevicePushTokenVoIP())?.trim();
|
final token = (await FlutterCallkitIncoming.getDevicePushTokenVoIP())?.trim();
|
||||||
if (token == null || token.isEmpty) return;
|
if (token == null || token.isEmpty) return;
|
||||||
|
|
||||||
final deviceInfo = DeviceInfoPlugin();
|
final hwId = await DeviceHwIdProvider.current();
|
||||||
|
if (hwId == null || hwId.length < 8) return;
|
||||||
await sl<Dio>().post(
|
await sl<Dio>().post(
|
||||||
'/api/profile/notification/token/voip/register',
|
'/api/profile/notification/token/voip/register',
|
||||||
data: {
|
data: {
|
||||||
'platform': 'ios',
|
'platform': 'ios',
|
||||||
'hw_id': (await deviceInfo.iosInfo).identifierForVendor,
|
'hw_id': hwId,
|
||||||
'token': token,
|
'token': token,
|
||||||
},
|
},
|
||||||
).timeout(
|
).timeout(
|
||||||
@@ -181,30 +203,74 @@ class FirebaseApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<({String? token, String platform})> _resolvePushCredentials() async {
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
return (
|
||||||
|
token: await _getFcmToken(),
|
||||||
|
platform: pushPlatform(isIos: true, huaweiFamily: false, hmsAvailable: false, hasHmsToken: false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final huaweiFamily = await _isHuaweiDevice();
|
||||||
|
if (huaweiFamily && await _isHmsAvailable()) {
|
||||||
|
final token = await _getHuaweiPushTokenNative().timeout(
|
||||||
|
const Duration(seconds: 5),
|
||||||
|
onTimeout: () => null,
|
||||||
|
);
|
||||||
|
if (token != null && token.isNotEmpty) {
|
||||||
|
return (
|
||||||
|
token: token,
|
||||||
|
platform: pushPlatform(isIos: false, huaweiFamily: true, hmsAvailable: true, hasHmsToken: true),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
token: await _getFcmToken(),
|
||||||
|
platform: pushPlatform(isIos: false, huaweiFamily: huaweiFamily, hmsAvailable: false, hasHmsToken: false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> _getFcmToken() {
|
||||||
|
return _firebaseMessaging.getToken().timeout(
|
||||||
|
const Duration(seconds: 5),
|
||||||
|
onTimeout: () => null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<bool> _isHuaweiDevice() async {
|
Future<bool> _isHuaweiDevice() async {
|
||||||
if (!Platform.isAndroid) return false;
|
if (!Platform.isAndroid) return false;
|
||||||
try {
|
try {
|
||||||
final info = await DeviceInfoPlugin().androidInfo;
|
final info = await DeviceInfoPlugin().androidInfo;
|
||||||
final brand = info.brand.toLowerCase();
|
return isHuaweiFamilyBrand(brand: info.brand, manufacturer: info.manufacturer);
|
||||||
final manufacturer = info.manufacturer.toLowerCase();
|
|
||||||
return brand.contains('huawei') || brand.contains('honor') || manufacturer.contains('huawei') || manufacturer.contains('honor');
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<bool> _isHmsAvailable() async {
|
||||||
|
try {
|
||||||
|
const channel = MethodChannel('com.eatvioapp/hms_push');
|
||||||
|
final available = await channel.invokeMethod<bool>('isHmsAvailable').timeout(const Duration(seconds: 5));
|
||||||
|
return available ?? false;
|
||||||
|
} catch (e, st) {
|
||||||
|
log('HMS availability check failed: $e', stackTrace: st);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<String?> _getHuaweiPushTokenNative() async {
|
Future<String?> _getHuaweiPushTokenNative() async {
|
||||||
try {
|
try {
|
||||||
const channel = MethodChannel('com.eatvioapp/hms_push');
|
const channel = MethodChannel('com.eatvioapp/hms_push');
|
||||||
final String? token = await channel.invokeMethod<String>('getHuaweiToken');
|
final String? token = await channel.invokeMethod<String>('getHuaweiToken');
|
||||||
return token;
|
return token;
|
||||||
} on PlatformException catch (e, st) {
|
} on PlatformException catch (e, st) {
|
||||||
showErrorToast('$e $st');
|
|
||||||
log(e.toString(), stackTrace: st);
|
log(e.toString(), stackTrace: st);
|
||||||
await _errorReporter.report(e, st, origin: 'FirebaseApi');
|
if (!isHmsClientUnavailable(e)) {
|
||||||
|
await _errorReporter.report(e, st, origin: 'FirebaseApi');
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
showErrorToast('$e $st');
|
|
||||||
log(e.toString(), stackTrace: st);
|
log(e.toString(), stackTrace: st);
|
||||||
await _errorReporter.report(e, st, origin: 'FirebaseApi');
|
await _errorReporter.report(e, st, origin: 'FirebaseApi');
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:eatvio_app/core/app_router.dart';
|
import 'package:eatvio_app/core/app_router.dart';
|
||||||
|
import 'package:eatvio_app/core/interceptors/verification_requirement.dart';
|
||||||
import 'package:eatvio_app/core/locator_service.dart';
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
import 'package:eatvio_app/core/routing/route_constants.dart';
|
import 'package:eatvio_app/core/routing/route_constants.dart';
|
||||||
import 'package:eatvio_app/core/widgets/show_error_dialog.dart';
|
import 'package:eatvio_app/core/widgets/show_error_dialog.dart';
|
||||||
@@ -9,28 +10,77 @@ import 'package:go_router/go_router.dart';
|
|||||||
class PhoneVerifyErrorInterceptor extends Interceptor {
|
class PhoneVerifyErrorInterceptor extends Interceptor {
|
||||||
@override
|
@override
|
||||||
void onError(DioException err, ErrorInterceptorHandler handler) async {
|
void onError(DioException err, ErrorInterceptorHandler handler) async {
|
||||||
if (err.response?.statusCode == 405) {
|
if (err.response?.statusCode != 405) {
|
||||||
final data = err.response?.data;
|
return handler.next(err);
|
||||||
|
}
|
||||||
if (data is Map<String, dynamic> && data['type'] == 'unverificated_phone') {
|
|
||||||
final String phone = await sl<ProfileRepository>().getMyPhone();
|
final requirement = VerificationRequirement.tryParse(err.response?.data);
|
||||||
final String passKey = await sl<ProfileRepository>().getPassKey(routeName: 'user_detailz.change-phone-verify', routeParams: {"phone": phone});
|
if (requirement == null) {
|
||||||
await showErrorDialog(
|
return handler.next(err);
|
||||||
AppRouter.navigatorKey.currentContext!,
|
}
|
||||||
title: 'Привяжите номер телефона',
|
|
||||||
description: 'Для того, что бы выполнить это действие необходимо привязать номер телефона',
|
// Бэкенд может требовать подтверждения почты, телефона или обоих каналов сразу.
|
||||||
);
|
if (requirement.needsEmail) {
|
||||||
final currentContext = AppRouter.navigatorKey.currentContext;
|
await _handleEmailVerificationRequired();
|
||||||
|
} else {
|
||||||
if (currentContext != null && currentContext.mounted && GoRouter.of(currentContext).state.path == RouteConstants.stepperBecomeConsultantScreen) {
|
await _handlePhoneVerificationRequired();
|
||||||
GoRouter.of(currentContext).pop();
|
}
|
||||||
}
|
|
||||||
AppRouter.navigatorKey.currentContext?.pushNamed('verifyScreen', extra: passKey);
|
return handler.reject(
|
||||||
return handler.reject(
|
DioException(
|
||||||
DioException(requestOptions: err.requestOptions, response: err.response, error: 'Phone number not verified', type: DioExceptionType.badResponse),
|
requestOptions: err.requestOptions,
|
||||||
);
|
response: err.response,
|
||||||
}
|
error: 'Phone number not verified',
|
||||||
|
type: DioExceptionType.badResponse,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Старый сценарий: показывает привязку телефона и открывает passkey-экран.
|
||||||
|
Future<void> _handlePhoneVerificationRequired() async {
|
||||||
|
final String phone = await sl<ProfileRepository>().getMyPhone();
|
||||||
|
final String passKey = await sl<ProfileRepository>().getPassKey(
|
||||||
|
routeName: 'user_detailz.change-phone-verify',
|
||||||
|
routeParams: {"phone": phone},
|
||||||
|
);
|
||||||
|
await showErrorDialog(
|
||||||
|
AppRouter.navigatorKey.currentContext!,
|
||||||
|
title: 'Привяжите номер телефона',
|
||||||
|
description:
|
||||||
|
'Для того, что бы выполнить это действие необходимо привязать номер телефона',
|
||||||
|
);
|
||||||
|
final currentContext = AppRouter.navigatorKey.currentContext;
|
||||||
|
|
||||||
|
if (currentContext != null &&
|
||||||
|
currentContext.mounted &&
|
||||||
|
GoRouter.of(currentContext).state.path ==
|
||||||
|
RouteConstants.stepperBecomeConsultantScreen) {
|
||||||
|
GoRouter.of(currentContext).pop();
|
||||||
|
}
|
||||||
|
AppRouter.navigatorKey.currentContext?.pushNamed(
|
||||||
|
'verifyScreen',
|
||||||
|
extra: passKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Новый сценарий: предлагает подтвердить e-mail и открывает экран ввода кода.
|
||||||
|
Future<void> _handleEmailVerificationRequired() async {
|
||||||
|
final context = AppRouter.navigatorKey.currentContext;
|
||||||
|
|
||||||
|
if (context == null || !context.mounted) {
|
||||||
|
// UI недоступен (например, в тестах) — просто отклоняем запрос, без падения.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await showErrorDialog(
|
||||||
|
context,
|
||||||
|
title: 'Подтвердите e-mail',
|
||||||
|
description:
|
||||||
|
'Чтобы публиковать материалы, подтвердите адрес электронной почты. Мы отправим код на вашу почту.',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (context.mounted) {
|
||||||
|
context.pushNamed('verifyEmailScreen', extra: true);
|
||||||
}
|
}
|
||||||
return handler.next(err);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
61
lib/core/interceptors/verification_requirement.dart
Normal file
61
lib/core/interceptors/verification_requirement.dart
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/// Канал верификации, которого не хватает для публичного действия.
|
||||||
|
enum VerificationChannel { phone, email }
|
||||||
|
|
||||||
|
/// Разбор 405-контракта верификации (type/reason/missing) в список каналов.
|
||||||
|
///
|
||||||
|
/// Бэкенд отвечает на публичное действие без подтверждения так:
|
||||||
|
/// `{"type": "unverificated_phone", "reason": "verification_required", "missing": ["email"]}`.
|
||||||
|
/// Старые ответы не содержали поля `missing` — тогда не хватало только телефона.
|
||||||
|
class VerificationRequirement {
|
||||||
|
const VerificationRequirement({required this.missing});
|
||||||
|
|
||||||
|
/// Каналы, подтверждение которых требует бэкенд.
|
||||||
|
final List<VerificationChannel> missing;
|
||||||
|
|
||||||
|
bool get needsEmail => missing.contains(VerificationChannel.email);
|
||||||
|
|
||||||
|
bool get needsPhone => missing.contains(VerificationChannel.phone);
|
||||||
|
|
||||||
|
/// Возвращает null, если ответ не является отказом верификации.
|
||||||
|
static VerificationRequirement? tryParse(Object? data) {
|
||||||
|
if (data is! Map) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (data['type'] != 'unverificated_phone') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final Object? rawMissing = data['missing'];
|
||||||
|
if (rawMissing is! List) {
|
||||||
|
// Legacy-контракт: поля missing не было, значит не подтверждён телефон.
|
||||||
|
return const VerificationRequirement(
|
||||||
|
missing: [VerificationChannel.phone],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasPhone = false;
|
||||||
|
var hasEmail = false;
|
||||||
|
for (final Object? value in rawMissing) {
|
||||||
|
// Неизвестные значения игнорируем, чтобы новые каналы бэкенда не ломали разбор.
|
||||||
|
if (value == 'phone') {
|
||||||
|
hasPhone = true;
|
||||||
|
} else if (value == 'email') {
|
||||||
|
hasEmail = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пустой или нераспознанный missing трактуем как legacy-контракт «телефон».
|
||||||
|
if (!hasPhone && !hasEmail) {
|
||||||
|
return const VerificationRequirement(
|
||||||
|
missing: [VerificationChannel.phone],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return VerificationRequirement(
|
||||||
|
missing: [
|
||||||
|
if (hasPhone) VerificationChannel.phone,
|
||||||
|
if (hasEmail) VerificationChannel.email,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:eatvio_app/core/app_initializer.dart';
|
import 'package:eatvio_app/core/app_initializer.dart';
|
||||||
|
import 'package:eatvio_app/core/cached_base_url.dart';
|
||||||
import 'package:eatvio_app/core/di/app_config_registration.dart';
|
import 'package:eatvio_app/core/di/app_config_registration.dart';
|
||||||
import 'package:eatvio_app/core/di/cubit_registrations.dart';
|
import 'package:eatvio_app/core/di/cubit_registrations.dart';
|
||||||
import 'package:eatvio_app/core/di/external_registrations.dart';
|
import 'package:eatvio_app/core/di/external_registrations.dart';
|
||||||
@@ -21,6 +22,8 @@ Future<void> init({required AppBuildType buildType}) async {
|
|||||||
|
|
||||||
// Ожидание инициализации асинхронных зависимостей
|
// Ожидание инициализации асинхронных зависимостей
|
||||||
await sl.getAsync<Prefs>();
|
await sl.getAsync<Prefs>();
|
||||||
|
// До создания Dio и любых запросов к бэкенду.
|
||||||
|
applyCachedBaseUrl();
|
||||||
|
|
||||||
// Регистрация сервисов
|
// Регистрация сервисов
|
||||||
ServiceRegistrations.registerAll();
|
ServiceRegistrations.registerAll();
|
||||||
@@ -32,5 +35,7 @@ Future<void> init({required AppBuildType buildType}) async {
|
|||||||
CubitRegistrations.registerAll();
|
CubitRegistrations.registerAll();
|
||||||
|
|
||||||
// Регистрация WebSocket менеджера
|
// Регистрация WebSocket менеджера
|
||||||
sl.registerLazySingleton<WsManager>(() => WsManager(wsService: sl(), errorReporter: sl()));
|
sl.registerLazySingleton<WsManager>(
|
||||||
|
() => WsManager(wsService: sl(), errorReporter: sl()),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
class NotificationPermissionRequester {
|
||||||
|
Future<void>? _inFlight;
|
||||||
|
|
||||||
|
/// Coalesces overlapping calls so the OS prompt is shown at most once at a time.
|
||||||
|
Future<void> requestOnce(Future<void> Function() requestPermission) async {
|
||||||
|
final inFlight = _inFlight;
|
||||||
|
if (inFlight != null) {
|
||||||
|
await inFlight;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final future = requestPermission();
|
||||||
|
_inFlight = future;
|
||||||
|
try {
|
||||||
|
await future;
|
||||||
|
} finally {
|
||||||
|
if (identical(_inFlight, future)) {
|
||||||
|
_inFlight = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,10 +62,18 @@ class PushNavigationHandler {
|
|||||||
final endedPayload = VideoCallEndedPushPayload.tryParse(data);
|
final endedPayload = VideoCallEndedPushPayload.tryParse(data);
|
||||||
if (endedPayload != null) {
|
if (endedPayload != null) {
|
||||||
developer.log(
|
developer.log(
|
||||||
'PushNavigationHandler: dismiss incoming call UI, source=$source',
|
'PushNavigationHandler: dismiss incoming call UI, source=$source '
|
||||||
|
'call_id=${endedPayload.callId ?? '-'} '
|
||||||
|
'reason=${endedPayload.reason?.name ?? '-'}',
|
||||||
name: 'PushNavigationHandler',
|
name: 'PushNavigationHandler',
|
||||||
);
|
);
|
||||||
unawaited(VideoCallEndedPushHandler.dismissIncomingCallUi());
|
unawaited(
|
||||||
|
VideoCallEndedPushHandler.dismissIncomingCallUi(
|
||||||
|
conversationUuid: endedPayload.conversationUuid,
|
||||||
|
callId: endedPayload.callId,
|
||||||
|
reason: endedPayload.reason,
|
||||||
|
),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,9 @@
|
|||||||
enum PrefsKeys { hasAnonymousTokenBeenSent, welcomeScreenWasShowed, registrationDate, marketingBottomSheetHasBeenShown, isEmailVerificationSkipped }
|
enum PrefsKeys {
|
||||||
|
hasAnonymousTokenBeenSent,
|
||||||
|
welcomeScreenWasShowed,
|
||||||
|
registrationDate,
|
||||||
|
marketingBottomSheetHasBeenShown,
|
||||||
|
isEmailVerificationSkipped,
|
||||||
|
customBaseUrl,
|
||||||
|
apiBaseUrl,
|
||||||
|
}
|
||||||
|
|||||||
36
lib/core/push/hms_client_error.dart
Normal file
36
lib/core/push/hms_client_error.dart
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
bool isHuaweiFamilyBrand({
|
||||||
|
required String brand,
|
||||||
|
required String manufacturer,
|
||||||
|
}) {
|
||||||
|
final normalizedBrand = brand.toLowerCase();
|
||||||
|
final normalizedManufacturer = manufacturer.toLowerCase();
|
||||||
|
return normalizedBrand.contains('huawei') ||
|
||||||
|
normalizedBrand.contains('honor') ||
|
||||||
|
normalizedManufacturer.contains('huawei') ||
|
||||||
|
normalizedManufacturer.contains('honor');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HMS Core is missing or rejects this app.
|
||||||
|
///
|
||||||
|
/// Honor devices with Google services return `907135003: client api invalid`.
|
||||||
|
/// That is a device capability, not an app crash: the caller should use FCM.
|
||||||
|
bool isHmsClientUnavailable(Object error) {
|
||||||
|
if (error is! PlatformException) return false;
|
||||||
|
final message = '${error.message ?? ''} ${error.details ?? ''}'.toLowerCase();
|
||||||
|
return message.contains('907135003') ||
|
||||||
|
message.contains('client api invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `huawei` only when an HMS token was actually issued. Otherwise Android uses FCM.
|
||||||
|
String pushPlatform({
|
||||||
|
required bool isIos,
|
||||||
|
required bool huaweiFamily,
|
||||||
|
required bool hmsAvailable,
|
||||||
|
required bool hasHmsToken,
|
||||||
|
}) {
|
||||||
|
if (isIos) return 'ios';
|
||||||
|
if (huaweiFamily && hmsAvailable && hasHmsToken) return 'huawei';
|
||||||
|
return 'android';
|
||||||
|
}
|
||||||
@@ -34,7 +34,7 @@ class AuthRoutes {
|
|||||||
name: 'verifyScreen',
|
name: 'verifyScreen',
|
||||||
builder: (context, state) => VerifyScreen(passKey: state.extra as String),
|
builder: (context, state) => VerifyScreen(passKey: state.extra as String),
|
||||||
),
|
),
|
||||||
GoRoute(path: RouteConstants.verifyEmailScreen, name: 'verifyEmailScreen', builder: (context, state) => const VerifyEmailScreen()),
|
GoRoute(path: RouteConstants.verifyEmailScreen, name: 'verifyEmailScreen', builder: (context, state) => VerifyEmailScreen(returnOnSuccess: state.extra == true)),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: RouteConstants.registerViaSocialScreen,
|
path: RouteConstants.registerViaSocialScreen,
|
||||||
name: 'registerViaSocialScreen',
|
name: 'registerViaSocialScreen',
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import 'package:eatvio_app/features/create_diet/presentation/diet_preview_screen
|
|||||||
import 'package:eatvio_app/features/create_plan/presentation/create_plan_screen.dart';
|
import 'package:eatvio_app/features/create_plan/presentation/create_plan_screen.dart';
|
||||||
import 'package:eatvio_app/features/create_recipe/presentation/pages/recipe_create_screen.dart';
|
import 'package:eatvio_app/features/create_recipe/presentation/pages/recipe_create_screen.dart';
|
||||||
import 'package:eatvio_app/features/dev_tools/presentation/dev_tools_screen.dart';
|
import 'package:eatvio_app/features/dev_tools/presentation/dev_tools_screen.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||||
import 'package:eatvio_app/features/feed/models/recipes_model.dart';
|
import 'package:eatvio_app/features/feed/models/recipes_model.dart';
|
||||||
import 'package:eatvio_app/features/glossary/presentation/pages/glossary_screen.dart';
|
import 'package:eatvio_app/features/glossary/presentation/pages/glossary_screen.dart';
|
||||||
import 'package:eatvio_app/features/menu/presentation/pages/blocked_users_screen.dart';
|
import 'package:eatvio_app/features/menu/presentation/pages/blocked_users_screen.dart';
|
||||||
@@ -183,7 +184,14 @@ class OtherRoutes {
|
|||||||
return DietPreviewScreen(diet: diet, isImageInBase64: isImageInBase64);
|
return DietPreviewScreen(diet: diet, isImageInBase64: isImageInBase64);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
GoRoute(path: RouteConstants.createPlanScreen, name: 'createPlanScreen', builder: (context, state) => const CreatePlanScreen()),
|
GoRoute(
|
||||||
|
path: RouteConstants.createPlanScreen,
|
||||||
|
name: 'createPlanScreen',
|
||||||
|
builder: (context, state) {
|
||||||
|
final plan = state.extra is PlanModel ? state.extra as PlanModel : null;
|
||||||
|
return CreatePlanScreen(plan: plan);
|
||||||
|
},
|
||||||
|
),
|
||||||
GoRoute(path: MyDietsScreen.path, name: MyDietsScreen.name, builder: (context, state) => const MyDietsScreen()),
|
GoRoute(path: MyDietsScreen.path, name: MyDietsScreen.name, builder: (context, state) => const MyDietsScreen()),
|
||||||
GoRoute(path: MyPlansScreen.path, name: MyPlansScreen.name, builder: (context, state) => const MyPlansScreen()),
|
GoRoute(path: MyPlansScreen.path, name: MyPlansScreen.name, builder: (context, state) => const MyPlansScreen()),
|
||||||
GoRoute(path: MyRecipesScreen.path, name: MyRecipesScreen.name, builder: (context, state) => const MyRecipesScreen()),
|
GoRoute(path: MyRecipesScreen.path, name: MyRecipesScreen.name, builder: (context, state) => const MyRecipesScreen()),
|
||||||
@@ -265,7 +273,15 @@ class OtherRoutes {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Consultant routes
|
// Consultant routes
|
||||||
GoRoute(path: RouteConstants.becomeConsultantScreen, name: 'becomeConsultantScreen', builder: (context, state) => const BecomeConsultantScreen()),
|
GoRoute(
|
||||||
|
path: RouteConstants.becomeConsultantScreen,
|
||||||
|
name: 'becomeConsultantScreen',
|
||||||
|
builder: (context, state) {
|
||||||
|
final data = state.extra as Map<String, dynamic>?;
|
||||||
|
final slug = data?['slug'] as String?;
|
||||||
|
return BecomeConsultantScreen(slug: slug);
|
||||||
|
},
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: RouteConstants.stepperBecomeConsultantScreen,
|
path: RouteConstants.stepperBecomeConsultantScreen,
|
||||||
name: 'stepperBecomeConsultantScreen',
|
name: 'stepperBecomeConsultantScreen',
|
||||||
@@ -273,7 +289,12 @@ class OtherRoutes {
|
|||||||
final data = state.extra as Map<String, dynamic>;
|
final data = state.extra as Map<String, dynamic>;
|
||||||
final slug = data['slug'] as String?;
|
final slug = data['slug'] as String?;
|
||||||
final isCanceled = data['isCanceled'] as bool?;
|
final isCanceled = data['isCanceled'] as bool?;
|
||||||
return StepperBecomeConsultantScreen(slug: slug, isCanceled: isCanceled);
|
final isNewRegistration = data['isNewRegistration'] as bool? ?? false;
|
||||||
|
return StepperBecomeConsultantScreen(
|
||||||
|
slug: slug,
|
||||||
|
isCanceled: isCanceled,
|
||||||
|
isNewRegistration: isNewRegistration,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
GoRoute(path: RouteConstants.calendarScreen, name: 'calendarScreen', builder: (context, state) => const CalendarScreen()),
|
GoRoute(path: RouteConstants.calendarScreen, name: 'calendarScreen', builder: (context, state) => const CalendarScreen()),
|
||||||
|
|||||||
61
lib/core/utils/inline_svg_css.dart
Normal file
61
lib/core/utils/inline_svg_css.dart
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/// flutter_svg / vector_graphics ignore `<style>` blocks.
|
||||||
|
/// Paths then get the SVG default fill (black), so badges look like a black blob.
|
||||||
|
/// This inlines class rules from `<style>` as attributes before parsing.
|
||||||
|
String inlineSvgCss(String svg) {
|
||||||
|
final styleBlockPattern = RegExp(
|
||||||
|
r'<style[^>]*>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?</style>',
|
||||||
|
caseSensitive: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
final classProps = <String, Map<String, String>>{};
|
||||||
|
for (final match in styleBlockPattern.allMatches(svg)) {
|
||||||
|
final css = match.group(1) ?? '';
|
||||||
|
final rulePattern = RegExp(r'\.([A-Za-z0-9_-]+)\s*\{([^}]*)\}');
|
||||||
|
for (final rule in rulePattern.allMatches(css)) {
|
||||||
|
final className = rule.group(1)!;
|
||||||
|
final body = rule.group(2)!;
|
||||||
|
final props = classProps.putIfAbsent(className, () => <String, String>{});
|
||||||
|
for (final declaration in body.split(';')) {
|
||||||
|
final colon = declaration.indexOf(':');
|
||||||
|
if (colon <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final key = declaration.substring(0, colon).trim();
|
||||||
|
final value = declaration.substring(colon + 1).trim();
|
||||||
|
if (key.isNotEmpty && value.isNotEmpty) {
|
||||||
|
props[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = svg.replaceAll(styleBlockPattern, '');
|
||||||
|
if (classProps.isEmpty) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
result = result.replaceAllMapped(RegExp(r'''\sclass=(["'])([^"']+)\1'''), (
|
||||||
|
Match match,
|
||||||
|
) {
|
||||||
|
final classes = match
|
||||||
|
.group(2)!
|
||||||
|
.split(RegExp(r'\s+'))
|
||||||
|
.where((name) => name.isNotEmpty);
|
||||||
|
final attrs = <String, String>{};
|
||||||
|
for (final className in classes) {
|
||||||
|
final props = classProps[className];
|
||||||
|
if (props != null) {
|
||||||
|
attrs.addAll(props);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (attrs.isEmpty) {
|
||||||
|
return match.group(0)!;
|
||||||
|
}
|
||||||
|
final attrString = attrs.entries
|
||||||
|
.map((entry) => '${entry.key}="${entry.value}"')
|
||||||
|
.join(' ');
|
||||||
|
return ' $attrString';
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
11
lib/core/utils/url_utils.dart
Normal file
11
lib/core/utils/url_utils.dart
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/// Joins an API media path with [baseUrl].
|
||||||
|
///
|
||||||
|
/// Uploaded photos come back as relative paths (`/storage/...`).
|
||||||
|
/// Default avatars are already absolute (`https://ui-avatars...`) and must stay as-is.
|
||||||
|
String joinBaseUrl(String baseUrl, String path) {
|
||||||
|
final normalized = path.toLowerCase();
|
||||||
|
if (normalized.startsWith('https://') || normalized.startsWith('http://')) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
return '$baseUrl$path';
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ class WsManager {
|
|||||||
final ErrorReporter _errorReporter;
|
final ErrorReporter _errorReporter;
|
||||||
|
|
||||||
WsManager({required this.wsService, required ErrorReporter errorReporter})
|
WsManager({required this.wsService, required ErrorReporter errorReporter})
|
||||||
: _errorReporter = errorReporter;
|
: _errorReporter = errorReporter;
|
||||||
|
|
||||||
PusherChannelsClient? client;
|
PusherChannelsClient? client;
|
||||||
int? userId;
|
int? userId;
|
||||||
@@ -38,15 +38,53 @@ class WsManager {
|
|||||||
StreamSubscription? _eventSubscription;
|
StreamSubscription? _eventSubscription;
|
||||||
StreamSubscription? _connectionEstablishedSubscription;
|
StreamSubscription? _connectionEstablishedSubscription;
|
||||||
|
|
||||||
Stream<ChannelReadEvent>? get newMessagesStream => _privateMessagesChannel?.bind('App\\Events\\ChatMessageUpdated');
|
/// Широковещательный сигнал о восстановлении соединения после обрыва (S5).
|
||||||
|
///
|
||||||
|
/// Контроллер создаётся один раз и не закрывается в [clear]: подписчики
|
||||||
|
/// (чаты, окно чата) переживают логаут/повторный вход и продолжают получать
|
||||||
|
/// сигналы. Если контроллер всё же закрыт, он пересоздаётся в [initialize].
|
||||||
|
StreamController<void> _reconnectController =
|
||||||
|
StreamController<void>.broadcast();
|
||||||
|
|
||||||
Stream<ChannelReadEvent>? get newNotificationsStream => _privateNotificationChannel?.bind('Illuminate\\Notifications\\Events\\BroadcastNotificationCreated');
|
Stream<ChannelReadEvent>? _directChatStream;
|
||||||
|
Stream<ChannelReadEvent>? _clientTypingStream;
|
||||||
|
|
||||||
Stream<ChannelReadEvent>? get directChatStream => _privateDirectChatChannel?.bind('App\\Events\\ChatMessageUpdated');
|
Stream<ChannelReadEvent>? get newMessagesStream =>
|
||||||
|
_privateMessagesChannel?.bind('App\\Events\\ChatMessageUpdated');
|
||||||
|
|
||||||
Stream<ChannelReadEvent>? get clientTypingStream => _privateDirectChatChannel?.bind('client-typing');
|
/// The callee rejected the ring. Broadcast to the caller and the callee.
|
||||||
|
Stream<ChannelReadEvent>? get videoCallDeclinedStream =>
|
||||||
|
_privateMessagesChannel?.bind('video-call-declined');
|
||||||
|
|
||||||
|
/// The call was cancelled or hung up. Same channel the web client uses.
|
||||||
|
Stream<ChannelReadEvent>? get videoCallEndedStream =>
|
||||||
|
_privateMessagesChannel?.bind('video-call-ended');
|
||||||
|
|
||||||
|
/// Another device accepted the ring. Ringing on this device must stop,
|
||||||
|
/// except on the device that accepted.
|
||||||
|
Stream<ChannelReadEvent>? get videoCallAcceptedStream =>
|
||||||
|
_privateMessagesChannel?.bind('video-call-accepted');
|
||||||
|
|
||||||
|
Stream<ChannelReadEvent>? get newNotificationsStream =>
|
||||||
|
_privateNotificationChannel?.bind(
|
||||||
|
'Illuminate\\Notifications\\Events\\BroadcastNotificationCreated',
|
||||||
|
);
|
||||||
|
|
||||||
|
Stream<ChannelReadEvent>? get directChatStream => _directChatStream;
|
||||||
|
|
||||||
|
Stream<ChannelReadEvent>? get clientTypingStream => _clientTypingStream;
|
||||||
|
|
||||||
|
/// Сигнал о реконнекте сокета: эмитится после повторной подписки на каналы.
|
||||||
|
///
|
||||||
|
/// Подписчики используют его, чтобы догрузить пропущенные за время обрыва
|
||||||
|
/// сообщения (S5).
|
||||||
|
Stream<void> get reconnected => _reconnectController.stream;
|
||||||
|
|
||||||
Future<void> initialize() async {
|
Future<void> initialize() async {
|
||||||
|
if (_reconnectController.isClosed) {
|
||||||
|
_reconnectController = StreamController<void>.broadcast();
|
||||||
|
}
|
||||||
|
|
||||||
if (client == null) {
|
if (client == null) {
|
||||||
try {
|
try {
|
||||||
if (!await TokenStorage.haveTokenInStorage()) return;
|
if (!await TokenStorage.haveTokenInStorage()) return;
|
||||||
@@ -66,15 +104,9 @@ class WsManager {
|
|||||||
connectionErrorHandler: (exception, trace, refresh) {
|
connectionErrorHandler: (exception, trace, refresh) {
|
||||||
refresh();
|
refresh();
|
||||||
},
|
},
|
||||||
minimumReconnectDelayDuration: const Duration(
|
minimumReconnectDelayDuration: const Duration(seconds: 1),
|
||||||
seconds: 1,
|
defaultActivityDuration: const Duration(seconds: 30),
|
||||||
),
|
waitForPongDuration: const Duration(seconds: 30),
|
||||||
defaultActivityDuration: const Duration(
|
|
||||||
seconds: 30,
|
|
||||||
),
|
|
||||||
waitForPongDuration: const Duration(
|
|
||||||
seconds: 30,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
String? extractSocketId(String? data) {
|
String? extractSocketId(String? data) {
|
||||||
@@ -87,7 +119,9 @@ class WsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_connectionEstablishedSubscription = client?.eventStream.listen((event) async {
|
_connectionEstablishedSubscription = client?.eventStream.listen((
|
||||||
|
event,
|
||||||
|
) async {
|
||||||
if (event.name == 'pusher:connection_established') {
|
if (event.name == 'pusher:connection_established') {
|
||||||
final data = event.data;
|
final data = event.data;
|
||||||
final socketId = extractSocketId(data);
|
final socketId = extractSocketId(data);
|
||||||
@@ -104,9 +138,9 @@ class WsManager {
|
|||||||
await _initPrivateMessagesChannel();
|
await _initPrivateMessagesChannel();
|
||||||
|
|
||||||
_lifecycleSubscription = client?.lifecycleStream.listen((data) {
|
_lifecycleSubscription = client?.lifecycleStream.listen((data) {
|
||||||
if (data == PusherChannelsClientLifeCycleState.establishedConnection) {
|
if (data ==
|
||||||
_unsubscribe();
|
PusherChannelsClientLifeCycleState.establishedConnection) {
|
||||||
_subscribe();
|
handleConnectionEstablished();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
_eventSubscription = client?.eventStream.listen((event) {
|
_eventSubscription = client?.eventStream.listen((event) {
|
||||||
@@ -147,12 +181,13 @@ class WsManager {
|
|||||||
final token = await TokenStorage.getTokenFromStorage();
|
final token = await TokenStorage.getTokenFromStorage();
|
||||||
_privateNotificationChannel = client?.privateChannel(
|
_privateNotificationChannel = client?.privateChannel(
|
||||||
'$newNotificationsChannelName.$userId',
|
'$newNotificationsChannelName.$userId',
|
||||||
authorizationDelegate: EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel(
|
authorizationDelegate:
|
||||||
authorizationEndpoint: Uri.parse('${sl<AppConfig>().baseUrl}/broadcasting/auth'),
|
EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel(
|
||||||
headers: {
|
authorizationEndpoint: Uri.parse(
|
||||||
'Authorization': 'Bearer $token',
|
'${sl<AppConfig>().baseUrl}/broadcasting/auth',
|
||||||
},
|
),
|
||||||
),
|
headers: {'Authorization': 'Bearer $token'},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,38 +195,46 @@ class WsManager {
|
|||||||
final token = await TokenStorage.getTokenFromStorage();
|
final token = await TokenStorage.getTokenFromStorage();
|
||||||
_privateMessagesChannel = client?.privateChannel(
|
_privateMessagesChannel = client?.privateChannel(
|
||||||
'$newMessagesChannelName.$userId',
|
'$newMessagesChannelName.$userId',
|
||||||
authorizationDelegate: EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel(
|
authorizationDelegate:
|
||||||
authorizationEndpoint: Uri.parse('${sl<AppConfig>().baseUrl}/broadcasting/auth'),
|
EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel(
|
||||||
headers: {
|
authorizationEndpoint: Uri.parse(
|
||||||
'Authorization': 'Bearer $token',
|
'${sl<AppConfig>().baseUrl}/broadcasting/auth',
|
||||||
},
|
),
|
||||||
),
|
headers: {'Authorization': 'Bearer $token'},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> initPrivateDirectChatChannel(String chatId) async {
|
Future<void> initPrivateDirectChatChannel(String chatId) async {
|
||||||
|
_privateDirectChatChannel?.unsubscribe();
|
||||||
final token = await TokenStorage.getTokenFromStorage();
|
final token = await TokenStorage.getTokenFromStorage();
|
||||||
_privateDirectChatChannel = client?.privateChannel(
|
_privateDirectChatChannel = client?.privateChannel(
|
||||||
'$directChatChannelName.$chatId',
|
'$directChatChannelName.$chatId',
|
||||||
authorizationDelegate: EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel(
|
authorizationDelegate:
|
||||||
authorizationEndpoint: Uri.parse('${sl<AppConfig>().baseUrl}/broadcasting/auth'),
|
EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel(
|
||||||
headers: {
|
authorizationEndpoint: Uri.parse(
|
||||||
'Authorization': 'Bearer $token',
|
'${sl<AppConfig>().baseUrl}/broadcasting/auth',
|
||||||
},
|
),
|
||||||
),
|
headers: {'Authorization': 'Bearer $token'},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
_directChatStream = _privateDirectChatChannel?.bind(
|
||||||
|
'App\\Events\\ChatMessageUpdated',
|
||||||
|
);
|
||||||
|
_clientTypingStream = _privateDirectChatChannel?.bind('client-typing');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> initPresenceDirectChatChannel(String chatId) async {
|
Future<void> initPresenceDirectChatChannel(String chatId) async {
|
||||||
final token = await TokenStorage.getTokenFromStorage();
|
final token = await TokenStorage.getTokenFromStorage();
|
||||||
_presenceDirectChatChannel = client?.presenceChannel(
|
_presenceDirectChatChannel = client?.presenceChannel(
|
||||||
'$presenceDirectChatChannelName.$chatId',
|
'$presenceDirectChatChannelName.$chatId',
|
||||||
authorizationDelegate: EndpointAuthorizableChannelTokenAuthorizationDelegate.forPresenceChannel(
|
authorizationDelegate:
|
||||||
authorizationEndpoint: Uri.parse('${sl<AppConfig>().baseUrl}/broadcasting/auth'),
|
EndpointAuthorizableChannelTokenAuthorizationDelegate.forPresenceChannel(
|
||||||
headers: {
|
authorizationEndpoint: Uri.parse(
|
||||||
'Authorization': 'Bearer $token',
|
'${sl<AppConfig>().baseUrl}/broadcasting/auth',
|
||||||
},
|
),
|
||||||
),
|
headers: {'Authorization': 'Bearer $token'},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,7 +261,8 @@ class WsManager {
|
|||||||
void Function(bool isOnline) onCounterpartOnlineChanged,
|
void Function(bool isOnline) onCounterpartOnlineChanged,
|
||||||
) {
|
) {
|
||||||
_presenceDirectChatListeners.removeWhere(
|
_presenceDirectChatListeners.removeWhere(
|
||||||
(listener) => listener.onCounterpartOnlineChanged == onCounterpartOnlineChanged,
|
(listener) =>
|
||||||
|
listener.onCounterpartOnlineChanged == onCounterpartOnlineChanged,
|
||||||
);
|
);
|
||||||
if (_presenceDirectChatListeners.isEmpty) {
|
if (_presenceDirectChatListeners.isEmpty) {
|
||||||
unsubscribePresenceDirectChatChannel();
|
unsubscribePresenceDirectChatChannel();
|
||||||
@@ -232,21 +276,26 @@ class WsManager {
|
|||||||
|
|
||||||
_presenceChannelEventsBound = true;
|
_presenceChannelEventsBound = true;
|
||||||
|
|
||||||
_presenceSubscriptionSucceededSubscription =
|
_presenceSubscriptionSucceededSubscription = channel
|
||||||
channel.whenSubscriptionSucceeded().listen((_) {
|
.whenSubscriptionSucceeded()
|
||||||
final counterpartIds = _presenceDirectChatListeners
|
.listen((_) {
|
||||||
.map((listener) => listener.counterpartUserId)
|
final counterpartIds = _presenceDirectChatListeners
|
||||||
.toSet();
|
.map((listener) => listener.counterpartUserId)
|
||||||
for (final counterpartUserId in counterpartIds) {
|
.toSet();
|
||||||
_emitCounterpartPresence(counterpartUserId);
|
for (final counterpartUserId in counterpartIds) {
|
||||||
}
|
_emitCounterpartPresence(counterpartUserId);
|
||||||
});
|
}
|
||||||
_presenceMemberAddedSubscription = channel.whenMemberAdded().listen((event) {
|
});
|
||||||
|
_presenceMemberAddedSubscription = channel.whenMemberAdded().listen((
|
||||||
|
event,
|
||||||
|
) {
|
||||||
final memberUserId = _parsePresenceMemberUserId(event.data);
|
final memberUserId = _parsePresenceMemberUserId(event.data);
|
||||||
if (memberUserId == null) return;
|
if (memberUserId == null) return;
|
||||||
_notifyPresenceListeners(memberUserId, isOnline: true);
|
_notifyPresenceListeners(memberUserId, isOnline: true);
|
||||||
});
|
});
|
||||||
_presenceMemberRemovedSubscription = channel.whenMemberRemoved().listen((event) {
|
_presenceMemberRemovedSubscription = channel.whenMemberRemoved().listen((
|
||||||
|
event,
|
||||||
|
) {
|
||||||
final memberUserId = _parsePresenceMemberUserId(event.data);
|
final memberUserId = _parsePresenceMemberUserId(event.data);
|
||||||
if (memberUserId == null) return;
|
if (memberUserId == null) return;
|
||||||
_notifyPresenceListeners(memberUserId, isOnline: false);
|
_notifyPresenceListeners(memberUserId, isOnline: false);
|
||||||
@@ -258,11 +307,15 @@ class WsManager {
|
|||||||
if (channel == null) return;
|
if (channel == null) return;
|
||||||
final members = channel.state?.members;
|
final members = channel.state?.members;
|
||||||
if (members == null) return;
|
if (members == null) return;
|
||||||
final isOnline = members.getMemberInfo(counterpartUserId.toString()) != null;
|
final isOnline =
|
||||||
|
members.getMemberInfo(counterpartUserId.toString()) != null;
|
||||||
_notifyPresenceListeners(counterpartUserId, isOnline: isOnline);
|
_notifyPresenceListeners(counterpartUserId, isOnline: isOnline);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _notifyPresenceListeners(int counterpartUserId, {required bool isOnline}) {
|
void _notifyPresenceListeners(
|
||||||
|
int counterpartUserId, {
|
||||||
|
required bool isOnline,
|
||||||
|
}) {
|
||||||
for (final listener in _presenceDirectChatListeners) {
|
for (final listener in _presenceDirectChatListeners) {
|
||||||
if (listener.counterpartUserId == counterpartUserId) {
|
if (listener.counterpartUserId == counterpartUserId) {
|
||||||
listener.onCounterpartOnlineChanged(isOnline);
|
listener.onCounterpartOnlineChanged(isOnline);
|
||||||
@@ -293,12 +346,27 @@ class WsManager {
|
|||||||
_privateNotificationChannel?.unsubscribe();
|
_privateNotificationChannel?.unsubscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Повторно подписывается на каналы и сигналит подписчикам о реконнекте.
|
||||||
|
///
|
||||||
|
/// Вызывается при `establishedConnection` — в том числе после обрыва сети.
|
||||||
|
/// Публичный, чтобы сигнал [reconnected] покрывался тестами без реального
|
||||||
|
/// Pusher-клиента.
|
||||||
|
void handleConnectionEstablished() {
|
||||||
|
_unsubscribe();
|
||||||
|
_subscribe();
|
||||||
|
_privateDirectChatChannel?.subscribe();
|
||||||
|
_presenceDirectChatChannel?.subscribe();
|
||||||
|
_reconnectController.add(null);
|
||||||
|
}
|
||||||
|
|
||||||
void subscribeDirectChatChannel() {
|
void subscribeDirectChatChannel() {
|
||||||
_privateDirectChatChannel?.subscribe();
|
_privateDirectChatChannel?.subscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
void unsubscribeDirectChatChannel() {
|
void unsubscribeDirectChatChannel() {
|
||||||
_privateDirectChatChannel?.unsubscribe();
|
_privateDirectChatChannel?.unsubscribe();
|
||||||
|
_directChatStream = null;
|
||||||
|
_clientTypingStream = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
void unsubscribePresenceDirectChatChannel() {
|
void unsubscribePresenceDirectChatChannel() {
|
||||||
@@ -331,6 +399,8 @@ class WsManager {
|
|||||||
_privateDirectChatChannel = null;
|
_privateDirectChatChannel = null;
|
||||||
_privateMessagesChannel = null;
|
_privateMessagesChannel = null;
|
||||||
_privateNotificationChannel = null;
|
_privateNotificationChannel = null;
|
||||||
|
_directChatStream = null;
|
||||||
|
_clientTypingStream = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,22 +6,34 @@ import 'package:eatvio_app/core/utils/toasts.dart';
|
|||||||
import 'package:eatvio_app/core/widgets/show_error_dialog.dart';
|
import 'package:eatvio_app/core/widgets/show_error_dialog.dart';
|
||||||
import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_cubit.dart';
|
import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_cubit.dart';
|
||||||
import 'package:eatvio_app/features/complaining/presentation/complaining_bottom_sheet.dart';
|
import 'package:eatvio_app/features/complaining/presentation/complaining_bottom_sheet.dart';
|
||||||
|
import 'package:eatvio_app/features/create_diet/presentation/create_diet_screen.dart';
|
||||||
|
import 'package:eatvio_app/features/create_plan/presentation/create_plan_screen.dart';
|
||||||
|
import 'package:eatvio_app/features/create_recipe/presentation/pages/recipe_create_screen.dart';
|
||||||
|
import 'package:eatvio_app/features/diets/presentation/cubit/about_diet_cubit.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/presentation/bloc/diets_cubit/diets_cubit.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/presentation/bloc/plans_cubit/plans_cubit.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/presentation/bloc/recipes_cubit/recipes_cubit.dart';
|
||||||
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
||||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||||
import 'package:eatvio_app/features/feed/models/recipes_model.dart';
|
import 'package:eatvio_app/features/feed/models/recipes_model.dart';
|
||||||
import 'package:eatvio_app/features/feed/presentation/bloc/diets_cubit/diets_cubit.dart';
|
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_cubit.dart';
|
||||||
import 'package:eatvio_app/features/feed/presentation/bloc/recipes_cubit/recipes_cubit.dart';
|
import 'package:eatvio_app/features/recipe/presentation/cubit/about_recipe_cubit.dart';
|
||||||
import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_cubit.dart';
|
import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_cubit.dart';
|
||||||
import 'package:eatvio_app/features/my_plans/presentation/cubit/my_plans_cubit.dart';
|
import 'package:eatvio_app/features/my_plans/presentation/cubit/my_plans_cubit.dart';
|
||||||
|
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
|
||||||
import 'package:eatvio_app/generated/l10n.dart';
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:share_plus/share_plus.dart';
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
|
||||||
class CustomPopup extends StatelessWidget {
|
class CustomPopup extends StatelessWidget {
|
||||||
final bool isAuthor;
|
final bool isAuthor;
|
||||||
|
final bool isAuthorAdmin;
|
||||||
final ComplainedContent? complainedContentType;
|
final ComplainedContent? complainedContentType;
|
||||||
final String path;
|
final String path;
|
||||||
final String? slug;
|
final String? slug;
|
||||||
@@ -29,11 +41,14 @@ class CustomPopup extends StatelessWidget {
|
|||||||
final FeedBlogPostModel? feedBlogPostModel;
|
final FeedBlogPostModel? feedBlogPostModel;
|
||||||
final Recipe? recipe;
|
final Recipe? recipe;
|
||||||
final Diet? diet;
|
final Diet? diet;
|
||||||
|
final PlanModel? plan;
|
||||||
final String userSlug;
|
final String userSlug;
|
||||||
|
final VoidCallback? onPostsChanged;
|
||||||
|
|
||||||
const CustomPopup({
|
const CustomPopup({
|
||||||
super.key,
|
super.key,
|
||||||
required this.isAuthor,
|
required this.isAuthor,
|
||||||
|
this.isAuthorAdmin = false,
|
||||||
this.complainedContentType,
|
this.complainedContentType,
|
||||||
required this.path,
|
required this.path,
|
||||||
this.id,
|
this.id,
|
||||||
@@ -41,9 +56,14 @@ class CustomPopup extends StatelessWidget {
|
|||||||
this.feedBlogPostModel,
|
this.feedBlogPostModel,
|
||||||
this.recipe,
|
this.recipe,
|
||||||
this.diet,
|
this.diet,
|
||||||
required this.userSlug
|
this.plan,
|
||||||
|
required this.userSlug,
|
||||||
|
this.onPostsChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
bool get _isAdminAuthor =>
|
||||||
|
isAuthorAdmin || (diet?.author.isAdmin ?? false) || (recipe?.author.isAdmin ?? false) || (feedBlogPostModel?.author.isAdmin ?? false);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final s = S.of(context);
|
final s = S.of(context);
|
||||||
@@ -76,7 +96,103 @@ class CustomPopup extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<PopupMenuEntry> _buildMenuItems(S s,BuildContext context) {
|
Future<void> _openEditor(BuildContext context) async {
|
||||||
|
final router = GoRouter.of(context);
|
||||||
|
switch (complainedContentType) {
|
||||||
|
case ComplainedContent.posts:
|
||||||
|
await router.pushNamed('blogPostCreateScreen', extra: feedBlogPostModel);
|
||||||
|
onPostsChanged?.call();
|
||||||
|
case ComplainedContent.recipe:
|
||||||
|
final currentRecipe = recipe;
|
||||||
|
if (currentRecipe == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final result = await router.pushNamed(RecipeCreateScreen.name, extra: currentRecipe);
|
||||||
|
if (result != null && context.mounted) {
|
||||||
|
_reloadEditedContent(context);
|
||||||
|
}
|
||||||
|
case ComplainedContent.diet:
|
||||||
|
final currentDiet = diet;
|
||||||
|
if (currentDiet == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final result = await router.pushNamed(CreateDietScreen.name, extra: {'diet': currentDiet, 'isEditing': true});
|
||||||
|
if (result == true && context.mounted) {
|
||||||
|
_reloadEditedContent(context);
|
||||||
|
}
|
||||||
|
case ComplainedContent.plan:
|
||||||
|
final currentPlan = plan;
|
||||||
|
if (currentPlan == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final result = await router.pushNamed(CreatePlanScreen.name, extra: currentPlan);
|
||||||
|
if (result == true && context.mounted) {
|
||||||
|
_reloadEditedContent(context);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _reloadEditedContent(BuildContext context) {
|
||||||
|
switch (complainedContentType) {
|
||||||
|
case ComplainedContent.recipe:
|
||||||
|
final currentRecipe = recipe;
|
||||||
|
if (currentRecipe == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
context.read<AboutRecipeCubit>().getRecipe(currentRecipe.slug);
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
context.read<RecipesCubit>().loadRecipesList();
|
||||||
|
} catch (_) {}
|
||||||
|
case ComplainedContent.diet:
|
||||||
|
final currentDiet = diet;
|
||||||
|
if (currentDiet == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
context.read<AboutDietCubit>().getDiet(id: currentDiet.id, slug: currentDiet.slug);
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
context.read<DietsCubit>().loadDiets();
|
||||||
|
} catch (_) {}
|
||||||
|
case ComplainedContent.plan:
|
||||||
|
final currentPlan = plan;
|
||||||
|
if (currentPlan == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
context.read<AboutPlanCubit>().getPlan(slug: currentPlan.slug);
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
context.read<PlansCubit>().loadPlans();
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
context.read<MyPlansCubit>().loadPlans();
|
||||||
|
} catch (_) {}
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _showsEditAction() {
|
||||||
|
switch (complainedContentType) {
|
||||||
|
case ComplainedContent.posts:
|
||||||
|
return feedBlogPostModel != null || id != null;
|
||||||
|
case ComplainedContent.recipe:
|
||||||
|
return recipe != null;
|
||||||
|
case ComplainedContent.diet:
|
||||||
|
return diet != null;
|
||||||
|
case ComplainedContent.plan:
|
||||||
|
return plan != null;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<PopupMenuEntry> _buildMenuItems(S s, BuildContext context) {
|
||||||
final entries = <PopupMenuEntry>[];
|
final entries = <PopupMenuEntry>[];
|
||||||
|
|
||||||
void addItem(String icon, String text, Function() onTap) {
|
void addItem(String icon, String text, Function() onTap) {
|
||||||
@@ -100,11 +216,9 @@ class CustomPopup extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
if (complainedContentType == ComplainedContent.posts) {
|
if (_showsEditAction()) {
|
||||||
addItem('assets/icons/edit_paint.svg', 'Редактировать', () async {
|
addItem('assets/icons/edit_paint.svg', s.edit, () {
|
||||||
if (complainedContentType == ComplainedContent.posts) {
|
_openEditor(context);
|
||||||
GoRouter.of(context).pushNamed('blogPostCreateScreen', extra: feedBlogPostModel);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
addItem('assets/icons/red_trash.svg', 'Удалить', () async {
|
addItem('assets/icons/red_trash.svg', 'Удалить', () async {
|
||||||
@@ -114,7 +228,14 @@ class CustomPopup extends StatelessWidget {
|
|||||||
showErrorDialog(context, title: S.current.could_not_delete_blog_post);
|
showErrorDialog(context, title: S.current.could_not_delete_blog_post);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
showSuccessToast('Пост удален, обновите ленту');
|
onPostsChanged?.call();
|
||||||
|
ProfileCubit.myProfileCubit?.reloadBlogPosts();
|
||||||
|
if (context.mounted) {
|
||||||
|
try {
|
||||||
|
context.read<FeedCubit>().loadFeedBlogPostList();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
showSuccessToast('Пост удален');
|
||||||
} else if (complainedContentType == ComplainedContent.recipe) {
|
} else if (complainedContentType == ComplainedContent.recipe) {
|
||||||
final result = await sl<RecipesCubit>().deleteRecipe(slug!);
|
final result = await sl<RecipesCubit>().deleteRecipe(slug!);
|
||||||
if (!result && context.mounted) {
|
if (!result && context.mounted) {
|
||||||
@@ -154,19 +275,21 @@ class CustomPopup extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
addItem('assets/icons/complain.svg', 'Пожаловаться', () {
|
if (!_isAdminAuthor) {
|
||||||
if (complainedContentType != null) {
|
addItem('assets/icons/complain.svg', 'Пожаловаться', () {
|
||||||
showModalBottomSheet(
|
if (complainedContentType != null) {
|
||||||
context: context,
|
showModalBottomSheet(
|
||||||
isScrollControlled: true,
|
context: context,
|
||||||
builder: (context) => ComplainingBottomSheet(
|
isScrollControlled: true,
|
||||||
complainedContentType: complainedContentType!,
|
builder: (context) => ComplainingBottomSheet(
|
||||||
id: id,
|
complainedContentType: complainedContentType!,
|
||||||
slug: slug,
|
id: id,
|
||||||
),
|
slug: slug,
|
||||||
);
|
),
|
||||||
}
|
);
|
||||||
});
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
addItem(
|
addItem(
|
||||||
'assets/icons/complain.svg',
|
'assets/icons/complain.svg',
|
||||||
'Заблокировать автора',
|
'Заблокировать автора',
|
||||||
|
|||||||
57
lib/core/widgets/network_svg_picture.dart
Normal file
57
lib/core/widgets/network_svg_picture.dart
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import 'package:eatvio_app/core/utils/inline_svg_css.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
final Map<String, String> _inlinedSvgCache = <String, String>{};
|
||||||
|
|
||||||
|
class NetworkSvgPicture extends StatefulWidget {
|
||||||
|
const NetworkSvgPicture({
|
||||||
|
super.key,
|
||||||
|
required this.url,
|
||||||
|
this.width,
|
||||||
|
this.height,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String url;
|
||||||
|
final double? width;
|
||||||
|
final double? height;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<NetworkSvgPicture> createState() => _NetworkSvgPictureState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NetworkSvgPictureState extends State<NetworkSvgPicture> {
|
||||||
|
late final Future<String> _svgFuture = _load(widget.url);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return FutureBuilder<String>(
|
||||||
|
future: _svgFuture,
|
||||||
|
builder: (BuildContext context, AsyncSnapshot<String> snapshot) {
|
||||||
|
if (!snapshot.hasData) {
|
||||||
|
return SizedBox(width: widget.width, height: widget.height);
|
||||||
|
}
|
||||||
|
return SvgPicture.string(
|
||||||
|
snapshot.data!,
|
||||||
|
width: widget.width,
|
||||||
|
height: widget.height,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<String> _load(String url) async {
|
||||||
|
final cached = _inlinedSvgCache[url];
|
||||||
|
if (cached != null) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
final response = await http.get(Uri.parse(url));
|
||||||
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
|
throw Exception('Failed to load SVG from $url');
|
||||||
|
}
|
||||||
|
final processed = inlineSvgCss(response.body);
|
||||||
|
_inlinedSvgCache[url] = processed;
|
||||||
|
return processed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ class SuccessPublishedDietOrPlanBottomSheet extends StatelessWidget {
|
|||||||
if (isDrafted == true) {
|
if (isDrafted == true) {
|
||||||
text = 'cохранена в черновиках';
|
text = 'cохранена в черновиках';
|
||||||
} else if (isUpdated == true) {
|
} else if (isUpdated == true) {
|
||||||
text = 'успешно обновлена';
|
text = isPlan ? 'успешно обновлён' : 'успешно обновлена';
|
||||||
} else if (isPlan == true) {
|
} else if (isPlan == true) {
|
||||||
text = 'опубликован';
|
text = 'опубликован';
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:dismissible_page/dismissible_page.dart';
|
||||||
import 'package:eatvio_app/core/app_config.dart';
|
import 'package:eatvio_app/core/app_config.dart';
|
||||||
import 'package:eatvio_app/core/enums/complained_content.dart';
|
import 'package:eatvio_app/core/enums/complained_content.dart';
|
||||||
import 'package:eatvio_app/core/utils/date_time_utils.dart';
|
import 'package:eatvio_app/core/utils/date_time_utils.dart';
|
||||||
|
import 'package:eatvio_app/core/utils/url_utils.dart';
|
||||||
import 'package:eatvio_app/core/widgets/custom_popup.dart';
|
import 'package:eatvio_app/core/widgets/custom_popup.dart';
|
||||||
import 'package:eatvio_app/core/widgets/loader.dart';
|
import 'package:eatvio_app/core/widgets/loader.dart';
|
||||||
import 'package:eatvio_app/core/widgets/markdown_html_widget.dart';
|
import 'package:eatvio_app/core/widgets/markdown_html_widget.dart';
|
||||||
@@ -9,7 +10,10 @@ import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_cubit
|
|||||||
import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_state.dart';
|
import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_state.dart';
|
||||||
import 'package:eatvio_app/core/comments/comments_cubit/comments_cubit.dart';
|
import 'package:eatvio_app/core/comments/comments_cubit/comments_cubit.dart';
|
||||||
import 'package:eatvio_app/core/comments/comments_cubit/comments_state.dart';
|
import 'package:eatvio_app/core/comments/comments_cubit/comments_state.dart';
|
||||||
|
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart';
|
||||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/presentation/widgets/blog_post_media_content.dart';
|
||||||
import 'package:eatvio_app/features/feed/presentation/widgets/comments_widget.dart';
|
import 'package:eatvio_app/features/feed/presentation/widgets/comments_widget.dart';
|
||||||
import 'package:eatvio_app/core/locator_service.dart';
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||||
@@ -23,7 +27,11 @@ class BlogPostScreen extends StatefulWidget {
|
|||||||
final int id;
|
final int id;
|
||||||
final int? highlightedCommentId;
|
final int? highlightedCommentId;
|
||||||
|
|
||||||
const BlogPostScreen({super.key, required this.id, this.highlightedCommentId});
|
const BlogPostScreen({
|
||||||
|
super.key,
|
||||||
|
required this.id,
|
||||||
|
this.highlightedCommentId,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<BlogPostScreen> createState() => _BlogPostScreenState();
|
State<BlogPostScreen> createState() => _BlogPostScreenState();
|
||||||
@@ -33,6 +41,7 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
|||||||
final CommentsCubit commentsCubit = sl<CommentsCubit>();
|
final CommentsCubit commentsCubit = sl<CommentsCubit>();
|
||||||
final textEditingController = TextEditingController();
|
final textEditingController = TextEditingController();
|
||||||
int? replyId;
|
int? replyId;
|
||||||
|
int currentGalleryIndex = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -40,12 +49,20 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
|||||||
super.initState();
|
super.initState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _openGallery(List<String> imageUrls, int index, {Object? heroTag}) {
|
||||||
|
context.pushTransparentRoute(
|
||||||
|
rootNavigator: true,
|
||||||
|
PhotoViewScreen(imageUrls: imageUrls, index: index, heroTag: heroTag),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocProvider(
|
return BlocProvider(
|
||||||
create: (context) => sl<BlogPostCubit>()..getBlogPost(widget.id),
|
create: (context) => sl<BlogPostCubit>()..getBlogPost(widget.id),
|
||||||
child: BlocProvider<CommentsCubit>(
|
child: BlocProvider<CommentsCubit>(
|
||||||
create: (BuildContext context) => commentsCubit..getComments(widget.id.toString(), CommentSourceType.blogPost, true),
|
create: (BuildContext context) => commentsCubit
|
||||||
|
..getComments(widget.id.toString(), CommentSourceType.blogPost, true),
|
||||||
child: BlocBuilder<BlogPostCubit, BlogPostState>(
|
child: BlocBuilder<BlogPostCubit, BlogPostState>(
|
||||||
builder: (context, blogPostState) {
|
builder: (context, blogPostState) {
|
||||||
return BlocConsumer<CommentsCubit, CommentsState>(
|
return BlocConsumer<CommentsCubit, CommentsState>(
|
||||||
@@ -73,15 +90,23 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
|||||||
body: const Loader(),
|
body: const Loader(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final bool isLiked = blogPostState.blogPostModel?.wasLikedByMe ?? false;
|
final bool isLiked =
|
||||||
final int totalLikes = blogPostState.blogPostModel?.totalLikes ?? 0;
|
blogPostState.blogPostModel?.wasLikedByMe ?? false;
|
||||||
|
final int totalLikes =
|
||||||
|
blogPostState.blogPostModel?.totalLikes ?? 0;
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
resizeToAvoidBottomInset: true,
|
resizeToAvoidBottomInset: true,
|
||||||
bottomSheet: Container(
|
bottomSheet: Container(
|
||||||
padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom + 8),
|
padding: EdgeInsets.only(
|
||||||
|
bottom: MediaQuery.of(context).padding.bottom + 8,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
border: Border(top: BorderSide(color: Colors.black.withValues(alpha: 0.1))),
|
border: Border(
|
||||||
|
top: BorderSide(
|
||||||
|
color: Colors.black.withValues(alpha: 0.1),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||||
@@ -99,10 +124,17 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
|||||||
FocusManager.instance.primaryFocus?.unfocus();
|
FocusManager.instance.primaryFocus?.unfocus();
|
||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(color: appTheme.blueA400, shape: BoxShape.circle),
|
decoration: BoxDecoration(
|
||||||
|
color: appTheme.blueA400,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
height: 32,
|
height: 32,
|
||||||
width: 32,
|
width: 32,
|
||||||
child: const Icon(Icons.close_rounded, size: 18, color: Colors.white),
|
child: const Icon(
|
||||||
|
Icons.close_rounded,
|
||||||
|
size: 18,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (replyId != null) const SizedBox(width: 8),
|
if (replyId != null) const SizedBox(width: 8),
|
||||||
@@ -110,7 +142,8 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
|||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(minHeight: 52),
|
constraints: const BoxConstraints(minHeight: 52),
|
||||||
child: TextFormField(
|
child: TextFormField(
|
||||||
textCapitalization: TextCapitalization.sentences,
|
textCapitalization:
|
||||||
|
TextCapitalization.sentences,
|
||||||
onChanged: (_) {
|
onChanged: (_) {
|
||||||
setState(() {});
|
setState(() {});
|
||||||
},
|
},
|
||||||
@@ -118,26 +151,48 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
|||||||
maxLines: 5,
|
maxLines: 5,
|
||||||
minLines: 1,
|
minLines: 1,
|
||||||
keyboardType: TextInputType.multiline,
|
keyboardType: TextInputType.multiline,
|
||||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, color: Colors.black, height: 1.2),
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
color: Colors.black,
|
||||||
|
height: 1.2,
|
||||||
|
),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
isDense: false,
|
isDense: false,
|
||||||
// убираем true, чтобы padding учитывался корректно
|
// убираем true, чтобы padding учитывался корректно
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 16,
|
||||||
|
),
|
||||||
// вертикальный паддинг увеличен
|
// вертикальный паддинг увеличен
|
||||||
hintText: S.of(context).comment_hint,
|
hintText: S.of(context).comment_hint,
|
||||||
hintStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, height: 1.2, color: Color(0xFFAEAEB2)),
|
hintStyle: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
height: 1.2,
|
||||||
|
color: Color(0xFFAEAEB2),
|
||||||
|
),
|
||||||
filled: true,
|
filled: true,
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
borderSide: const BorderSide(
|
||||||
|
color: Color(0xFFD1D1D6),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
borderSide: const BorderSide(
|
||||||
|
color: Color(0xFFD1D1D6),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
borderSide: const BorderSide(
|
||||||
|
color: Color(0xFFD1D1D6),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -155,7 +210,11 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
|||||||
);
|
);
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
|
|
||||||
if (isSent) context.read<FeedCubit>().incrementCommentsState(widget.id);
|
if (isSent) {
|
||||||
|
context
|
||||||
|
.read<FeedCubit>()
|
||||||
|
.incrementCommentsState(widget.id);
|
||||||
|
}
|
||||||
|
|
||||||
context.read<CommentsCubit>().setReplyId(null);
|
context.read<CommentsCubit>().setReplyId(null);
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -166,10 +225,19 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(color: textEditingController.text.length > 2 ? appTheme.blueA400 : Colors.grey, shape: BoxShape.circle),
|
decoration: BoxDecoration(
|
||||||
|
color: textEditingController.text.length > 2
|
||||||
|
? appTheme.blueA400
|
||||||
|
: Colors.grey,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
height: 32,
|
height: 32,
|
||||||
width: 32,
|
width: 32,
|
||||||
child: const Icon(Icons.send_rounded, size: 18, color: Colors.white),
|
child: const Icon(
|
||||||
|
Icons.send_rounded,
|
||||||
|
size: 18,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -189,23 +257,36 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
|||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
CustomPopup(
|
CustomPopup(
|
||||||
isAuthor: blogPostState.blogPostModel?.author.authorIsMe ?? false,
|
isAuthor:
|
||||||
|
blogPostState.blogPostModel?.author.authorIsMe ??
|
||||||
|
false,
|
||||||
|
isAuthorAdmin:
|
||||||
|
blogPostState.blogPostModel?.author.isAdmin ??
|
||||||
|
false,
|
||||||
complainedContentType: ComplainedContent.posts,
|
complainedContentType: ComplainedContent.posts,
|
||||||
path: 'blog_post/${(widget.id)}',
|
path: 'blog_post/${(widget.id)}',
|
||||||
id: widget.id,
|
id: widget.id,
|
||||||
feedBlogPostModel: blogPostState.blogPostModel,
|
feedBlogPostModel: blogPostState.blogPostModel,
|
||||||
userSlug: blogPostState.blogPostModel?.author.authorSlug ?? '',
|
userSlug:
|
||||||
|
blogPostState.blogPostModel?.author.authorSlug ??
|
||||||
|
'',
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: SingleChildScrollView(
|
body: SingleChildScrollView(
|
||||||
padding: EdgeInsets.only(bottom: 160 + MediaQuery.of(context).padding.bottom), // поле комментария (52-200) + padding (12+12+56) + safe area
|
padding: EdgeInsets.only(
|
||||||
|
bottom: 160 + MediaQuery.of(context).padding.bottom,
|
||||||
|
), // поле комментария (52-200) + padding (12+12+56) + safe area
|
||||||
physics: const BouncingScrollPhysics(),
|
physics: const BouncingScrollPhysics(),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () => GoRouter.of(context).pushNamed('profileScreen', extra: blogPostState.blogPostModel?.author.authorSlug),
|
onTap: () => GoRouter.of(context).pushNamed(
|
||||||
|
'profileScreen',
|
||||||
|
extra:
|
||||||
|
blogPostState.blogPostModel?.author.authorSlug,
|
||||||
|
),
|
||||||
child: Ink(
|
child: Ink(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -214,21 +295,48 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
|||||||
children: [
|
children: [
|
||||||
CircleAvatar(
|
CircleAvatar(
|
||||||
radius: 16,
|
radius: 16,
|
||||||
foregroundImage: NetworkImage(sl<AppConfig>().baseUrl + (blogPostState.blogPostModel?.author.authorProfileIcon ?? '')),
|
foregroundImage: NetworkImage(
|
||||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
joinBaseUrl(
|
||||||
|
sl<AppConfig>().baseUrl,
|
||||||
|
blogPostState
|
||||||
|
.blogPostModel
|
||||||
|
?.author
|
||||||
|
.authorProfileIcon ??
|
||||||
|
'',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
backgroundImage: const AssetImage(
|
||||||
|
'assets/images/no_avatar.png',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Column(
|
Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
blogPostState.blogPostModel?.author.authorName ?? '',
|
blogPostState
|
||||||
style: const TextStyle(color: Colors.black, fontSize: 14, fontWeight: FontWeight.w400, height: 1),
|
.blogPostModel
|
||||||
|
?.author
|
||||||
|
.authorName ??
|
||||||
|
'',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.black,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
height: 1,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
(blogPostState.blogPostModel?.createdAt ?? DateTime.now()).format('dd MMMM', 'ru'),
|
(blogPostState.blogPostModel?.createdAt ??
|
||||||
style: TextStyle(color: Colors.grey.shade600, fontSize: 12, fontWeight: FontWeight.w400, height: 1),
|
DateTime.now())
|
||||||
|
.format('dd MMMM', 'ru'),
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
height: 1,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -236,31 +344,99 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (blogPostState.blogPostModel?.description.isNotEmpty ?? false)
|
if (blogPostState
|
||||||
|
.blogPostModel
|
||||||
|
?.description
|
||||||
|
.isNotEmpty ??
|
||||||
|
false)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(
|
||||||
child: MarkdownHtmlWidget(markdownText: blogPostState.blogPostModel!.description),
|
horizontal: 16,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
child: MarkdownHtmlWidget(
|
||||||
|
markdownText:
|
||||||
|
blogPostState.blogPostModel!.description,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
if (blogPostState.blogPostModel?.image.isNotEmpty ?? false)
|
Builder(
|
||||||
CachedNetworkImage(imageUrl: '${sl<AppConfig>().baseUrl}/storage/${blogPostState.blogPostModel?.image ?? ''}'),
|
builder: (context) {
|
||||||
|
final post = blogPostState.blogPostModel;
|
||||||
|
if (post == null) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
final imageUrl = PostFeedMapper.resolveMediaUrl(
|
||||||
|
post.image,
|
||||||
|
);
|
||||||
|
final embedUrl = post.videoEmbedUrl?.trim();
|
||||||
|
return BlogPostMediaContent(
|
||||||
|
galleryImages: post.images,
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
videoEmbedUrl:
|
||||||
|
embedUrl != null && embedUrl.isNotEmpty
|
||||||
|
? embedUrl
|
||||||
|
: null,
|
||||||
|
videoUrlThumb: post.videoUrlThumb != null
|
||||||
|
? PostFeedMapper.resolveMediaUrl(
|
||||||
|
post.videoUrlThumb,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
currentGalleryIndex: currentGalleryIndex,
|
||||||
|
heroTag: 'blog-post-screen-${post.id}-cover',
|
||||||
|
onOpenGallery: (urls, index) => _openGallery(
|
||||||
|
urls,
|
||||||
|
index,
|
||||||
|
heroTag: urls.length == 1
|
||||||
|
? 'blog-post-screen-${post.id}-cover'
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
onGalleryChanged: (index) {
|
||||||
|
setState(() {
|
||||||
|
currentGalleryIndex = index;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
_iconButton(
|
_iconButton(
|
||||||
icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
icon: isLiked
|
||||||
|
? Icons.favorite_rounded
|
||||||
|
: Icons.favorite_border_rounded,
|
||||||
text: totalLikes.toString(),
|
text: totalLikes.toString(),
|
||||||
iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
|
iconColor: isLiked
|
||||||
backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null,
|
? Colors.red.withValues(alpha: 0.8)
|
||||||
textColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
|
: null,
|
||||||
|
backgroundColor: isLiked
|
||||||
|
? Colors.red.withValues(alpha: 0.1)
|
||||||
|
: null,
|
||||||
|
textColor: isLiked
|
||||||
|
? Colors.red.withValues(alpha: 0.8)
|
||||||
|
: null,
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
await context.read<BlogPostCubit>().toggleLike();
|
await context
|
||||||
|
.read<BlogPostCubit>()
|
||||||
|
.toggleLike();
|
||||||
// ignore: use_build_context_synchronously
|
// ignore: use_build_context_synchronously
|
||||||
final updated = context.read<BlogPostCubit>().state.blogPostModel;
|
final updated = context
|
||||||
|
.read<BlogPostCubit>()
|
||||||
|
.state
|
||||||
|
.blogPostModel;
|
||||||
if (updated != null) {
|
if (updated != null) {
|
||||||
// ignore: use_build_context_synchronously
|
// ignore: use_build_context_synchronously
|
||||||
context.read<FeedCubit>().updateFeedBlogPostList(updated, updated.wasLikedByMe);
|
context
|
||||||
|
.read<FeedCubit>()
|
||||||
|
.updateFeedBlogPostList(
|
||||||
|
updated,
|
||||||
|
updated.wasLikedByMe,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -268,21 +444,36 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
|||||||
_iconButton(
|
_iconButton(
|
||||||
icon: Icons.reply_rounded,
|
icon: Icons.reply_rounded,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
final box = context.findRenderObject() as RenderBox?;
|
final box =
|
||||||
|
context.findRenderObject() as RenderBox?;
|
||||||
SharePlus.instance.share(
|
SharePlus.instance.share(
|
||||||
ShareParams(
|
ShareParams(
|
||||||
uri: Uri.parse('${sl<AppConfig>().baseUrl}/ru/blog_post/${widget.id}'),
|
uri: Uri.parse(
|
||||||
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
|
'${sl<AppConfig>().baseUrl}/ru/blog_post/${widget.id}',
|
||||||
|
),
|
||||||
|
sharePositionOrigin:
|
||||||
|
box!.localToGlobal(Offset.zero) &
|
||||||
|
box.size,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
Icon(Icons.visibility_rounded, size: 16, color: appTheme.gray500),
|
Icon(
|
||||||
|
Icons.visibility_rounded,
|
||||||
|
size: 16,
|
||||||
|
color: appTheme.gray500,
|
||||||
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
blogPostState.blogPostModel?.totalViews.toString() ?? '',
|
blogPostState.blogPostModel?.totalViews
|
||||||
style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400),
|
.toString() ??
|
||||||
|
'',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: appTheme.gray500,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -290,7 +481,10 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
|||||||
Container(
|
Container(
|
||||||
width: MediaQuery.of(context).size.width - 32,
|
width: MediaQuery.of(context).size.width - 32,
|
||||||
height: 0.4,
|
height: 0.4,
|
||||||
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(16)),
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.2),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Container(
|
Container(
|
||||||
@@ -298,7 +492,11 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
|||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: Text(
|
child: Text(
|
||||||
'${blogPostState.blogPostModel?.totalComments} КОММЕНТАРИЕВ',
|
'${blogPostState.blogPostModel?.totalComments} КОММЕНТАРИЕВ',
|
||||||
style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400),
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: appTheme.gray500,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
@@ -323,13 +521,23 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
InkWell _iconButton({required IconData icon, String? text, Function()? onTap, Color? iconColor, Color? backgroundColor, Color? textColor}) {
|
InkWell _iconButton({
|
||||||
|
required IconData icon,
|
||||||
|
String? text,
|
||||||
|
Function()? onTap,
|
||||||
|
Color? iconColor,
|
||||||
|
Color? backgroundColor,
|
||||||
|
Color? textColor,
|
||||||
|
}) {
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: onTap ?? () {},
|
onTap: onTap ?? () {},
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
child: Ink(
|
child: Ink(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
decoration: BoxDecoration(color: backgroundColor ?? appTheme.gray100, borderRadius: BorderRadius.circular(16)),
|
decoration: BoxDecoration(
|
||||||
|
color: backgroundColor ?? appTheme.gray100,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
height: 32,
|
height: 32,
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -339,7 +547,10 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
|||||||
if (text != null)
|
if (text != null)
|
||||||
Text(
|
Text(
|
||||||
text,
|
text,
|
||||||
style: TextStyle(color: textColor ?? appTheme.gray600, fontWeight: FontWeight.w500),
|
style: TextStyle(
|
||||||
|
color: textColor ?? appTheme.gray600,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,38 +3,195 @@ import 'dart:developer';
|
|||||||
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||||
import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_state.dart';
|
import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_state.dart';
|
||||||
import 'package:eatvio_app/features/blog_post_create/repositories/blog_post_repository.dart';
|
import 'package:eatvio_app/features/blog_post_create/repositories/blog_post_repository.dart';
|
||||||
|
import 'package:eatvio_app/features/blog_post_create/validation/blog_post_form_validator.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||||
|
|
||||||
class BlogPostCreateCubit extends Cubit<BlogPostCreateState> {
|
class BlogPostCreateCubit extends Cubit<BlogPostCreateState> {
|
||||||
final BlogPostRepository blogPostRepository;
|
final BlogPostRepository blogPostRepository;
|
||||||
|
|
||||||
BlogPostCreateCubit({required this.blogPostRepository}) : super(const BlogPostCreateState());
|
BlogPostCreateCubit({required this.blogPostRepository})
|
||||||
|
: super(const BlogPostCreateState());
|
||||||
|
|
||||||
|
void clearTitleError() {
|
||||||
|
if (state.titleError != null || state.formError != null) {
|
||||||
|
emit(state.copyWith(titleError: null, formError: null));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearDescriptionError() {
|
||||||
|
if (state.descriptionError != null || state.formError != null) {
|
||||||
|
emit(state.copyWith(descriptionError: null, formError: null));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearVideoUrlError() {
|
||||||
|
if (state.videoUrlError != null || state.formError != null) {
|
||||||
|
emit(state.copyWith(videoUrlError: null, formError: null));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearImagesError() {
|
||||||
|
if (state.imagesError != null || state.formError != null) {
|
||||||
|
emit(state.copyWith(imagesError: null, formError: null));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void setImagesError(String? error) {
|
||||||
|
if (state.imagesError == error &&
|
||||||
|
state.formError == null &&
|
||||||
|
state.errorMessage == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit(
|
||||||
|
state.copyWith(imagesError: error, formError: null, errorMessage: null),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void resetFormErrors() {
|
||||||
|
if (!state.hasValidationErrors &&
|
||||||
|
!state.isSuccess &&
|
||||||
|
state.errorMessage == null &&
|
||||||
|
!state.isLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit(const BlogPostCreateState());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates form fields and emits error messages on [BlogPostCreateState].
|
||||||
|
/// Returns `true` when the form is valid.
|
||||||
|
bool validateForm({
|
||||||
|
required String title,
|
||||||
|
required String description,
|
||||||
|
required String videoUrl,
|
||||||
|
required int imageCount,
|
||||||
|
Iterable<String> imagePaths = const [],
|
||||||
|
}) {
|
||||||
|
final titleError = BlogPostFormValidator.title(title);
|
||||||
|
final descriptionError = BlogPostFormValidator.description(description);
|
||||||
|
final videoUrlError = BlogPostFormValidator.videoUrl(videoUrl);
|
||||||
|
final imagesError =
|
||||||
|
BlogPostFormValidator.galleryCount(imageCount) ??
|
||||||
|
BlogPostFormValidator.imagePaths(imagePaths);
|
||||||
|
final formError = BlogPostFormValidator.contentRequiredMessage(
|
||||||
|
title: title,
|
||||||
|
description: description,
|
||||||
|
videoUrl: videoUrl,
|
||||||
|
imageCount: imageCount,
|
||||||
|
);
|
||||||
|
|
||||||
|
final hasFieldErrors =
|
||||||
|
titleError != null ||
|
||||||
|
descriptionError != null ||
|
||||||
|
videoUrlError != null ||
|
||||||
|
imagesError != null;
|
||||||
|
final resolvedFormError = hasFieldErrors ? null : formError;
|
||||||
|
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
titleError: titleError,
|
||||||
|
descriptionError: descriptionError,
|
||||||
|
videoUrlError: videoUrlError,
|
||||||
|
imagesError: imagesError,
|
||||||
|
// Form-level message only when individual fields are fine but content is empty.
|
||||||
|
formError: resolvedFormError,
|
||||||
|
isSuccess: false,
|
||||||
|
errorMessage: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return titleError == null &&
|
||||||
|
descriptionError == null &&
|
||||||
|
videoUrlError == null &&
|
||||||
|
imagesError == null &&
|
||||||
|
resolvedFormError == null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> createBlogPost({
|
||||||
|
String? title,
|
||||||
|
String? videoUrl,
|
||||||
|
String? description,
|
||||||
|
List<XFile> images = const [],
|
||||||
|
}) async {
|
||||||
|
final trimmedTitle = title?.trim() ?? '';
|
||||||
|
final trimmedDescription = description?.trim() ?? '';
|
||||||
|
final trimmedVideoUrl = videoUrl?.trim() ?? '';
|
||||||
|
|
||||||
|
final isValid = validateForm(
|
||||||
|
title: trimmedTitle,
|
||||||
|
description: trimmedDescription,
|
||||||
|
videoUrl: trimmedVideoUrl,
|
||||||
|
imageCount: images.length,
|
||||||
|
imagePaths: images.map((image) => image.path),
|
||||||
|
);
|
||||||
|
if (!isValid) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
Future<bool> createBlogPost({String? title, String? videoUrl, String? description, XFile? image}) async {
|
|
||||||
try {
|
try {
|
||||||
emit(const BlogPostCreateState.loading());
|
emit(
|
||||||
await blogPostRepository.createBlogPost(title, videoUrl, description, image);
|
state.copyWith(isLoading: true, isSuccess: false, errorMessage: null),
|
||||||
emit(const BlogPostCreateState.loaded());
|
);
|
||||||
|
await blogPostRepository.createBlogPost(
|
||||||
|
title: trimmedTitle.isEmpty ? null : trimmedTitle,
|
||||||
|
videoUrl: trimmedVideoUrl.isEmpty ? null : trimmedVideoUrl,
|
||||||
|
description: trimmedDescription.isEmpty ? null : trimmedDescription,
|
||||||
|
images: images,
|
||||||
|
);
|
||||||
|
emit(const BlogPostCreateState(isSuccess: true));
|
||||||
return true;
|
return true;
|
||||||
} catch (error, stackTrace) {
|
} catch (error, stackTrace) {
|
||||||
log('BlogPostCreateCubit: $error', stackTrace: stackTrace);
|
log('BlogPostCreateCubit: $error', stackTrace: stackTrace);
|
||||||
await reportFrom(error, stackTrace, this);
|
await reportFrom(error, stackTrace, this);
|
||||||
emit(BlogPostCreateState.error(errorMessage: error.toString()));
|
emit(BlogPostCreateState(errorMessage: error.toString()));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> updateBlogPost({required int id, String? title, String? videoUrl, String? description, XFile? image, String? oldImageUrl}) async {
|
Future<bool> updateBlogPost({
|
||||||
|
required int id,
|
||||||
|
String? title,
|
||||||
|
String? videoUrl,
|
||||||
|
String? description,
|
||||||
|
List<int> existingMediaIds = const [],
|
||||||
|
List<XFile> newImages = const [],
|
||||||
|
List<int> mediaIdsToDelete = const [],
|
||||||
|
}) async {
|
||||||
|
final trimmedTitle = title?.trim() ?? '';
|
||||||
|
final trimmedDescription = description?.trim() ?? '';
|
||||||
|
final trimmedVideoUrl = videoUrl?.trim() ?? '';
|
||||||
|
final imageCount = existingMediaIds.length + newImages.length;
|
||||||
|
|
||||||
|
final isValid = validateForm(
|
||||||
|
title: trimmedTitle,
|
||||||
|
description: trimmedDescription,
|
||||||
|
videoUrl: trimmedVideoUrl,
|
||||||
|
imageCount: imageCount,
|
||||||
|
imagePaths: newImages.map((image) => image.path),
|
||||||
|
);
|
||||||
|
if (!isValid) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
emit(const BlogPostCreateState.loading());
|
emit(
|
||||||
await blogPostRepository.updateBlogPost(id, title, videoUrl, description, image, oldImageUrl);
|
state.copyWith(isLoading: true, isSuccess: false, errorMessage: null),
|
||||||
emit(const BlogPostCreateState.loaded());
|
);
|
||||||
|
await blogPostRepository.updateBlogPost(
|
||||||
|
id: id,
|
||||||
|
title: trimmedTitle,
|
||||||
|
videoUrl: trimmedVideoUrl,
|
||||||
|
description: trimmedDescription,
|
||||||
|
existingMediaIds: existingMediaIds,
|
||||||
|
newImages: newImages,
|
||||||
|
mediaIdsToDelete: mediaIdsToDelete,
|
||||||
|
);
|
||||||
|
emit(const BlogPostCreateState(isSuccess: true));
|
||||||
return true;
|
return true;
|
||||||
} catch (error, stackTrace) {
|
} catch (error, stackTrace) {
|
||||||
log('BlogPostCreateCubit: $error', stackTrace: stackTrace);
|
log('BlogPostCreateCubit: $error', stackTrace: stackTrace);
|
||||||
await reportFrom(error, stackTrace, this);
|
await reportFrom(error, stackTrace, this);
|
||||||
emit(BlogPostCreateState.error(errorMessage: error.toString()));
|
emit(BlogPostCreateState(errorMessage: error.toString()));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,23 @@ part 'blog_post_create_state.freezed.dart';
|
|||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
abstract class BlogPostCreateState with _$BlogPostCreateState {
|
abstract class BlogPostCreateState with _$BlogPostCreateState {
|
||||||
const factory BlogPostCreateState() = _BlogPostCreateState;
|
const BlogPostCreateState._();
|
||||||
|
|
||||||
const factory BlogPostCreateState.loading() = BlogPostCreateStateLoading;
|
const factory BlogPostCreateState({
|
||||||
|
@Default(false) bool isLoading,
|
||||||
|
@Default(false) bool isSuccess,
|
||||||
|
String? errorMessage,
|
||||||
|
String? titleError,
|
||||||
|
String? descriptionError,
|
||||||
|
String? videoUrlError,
|
||||||
|
String? imagesError,
|
||||||
|
String? formError,
|
||||||
|
}) = _BlogPostCreateState;
|
||||||
|
|
||||||
const factory BlogPostCreateState.loaded() = BlogPostCreateStateLoaded;
|
bool get hasValidationErrors =>
|
||||||
|
titleError != null ||
|
||||||
const factory BlogPostCreateState.error({
|
descriptionError != null ||
|
||||||
required String errorMessage,
|
videoUrlError != null ||
|
||||||
}) = BlogPostCreateStateError;
|
imagesError != null ||
|
||||||
|
formError != null;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,79 +1,122 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:eatvio_app/core/app_config.dart';
|
|
||||||
import 'package:eatvio_app/core/locator_service.dart';
|
|
||||||
import 'package:http/http.dart' as http;
|
|
||||||
import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart';
|
import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart';
|
||||||
import 'package:eatvio_app/features/blog_post_create/services/blog_post_service.dart';
|
import 'package:eatvio_app/features/blog_post_create/services/blog_post_service.dart';
|
||||||
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
|
||||||
|
|
||||||
class BlogPostRepository {
|
class BlogPostRepository {
|
||||||
|
static const maxGalleryImages = 10;
|
||||||
|
|
||||||
final BlogPostService blogPostService;
|
final BlogPostService blogPostService;
|
||||||
|
|
||||||
BlogPostRepository({
|
BlogPostRepository({required this.blogPostService});
|
||||||
required this.blogPostService,
|
|
||||||
});
|
|
||||||
|
|
||||||
Future<void> createBlogPost(
|
Future<void> createBlogPost({
|
||||||
String? title,
|
String? title,
|
||||||
String? videoUrl,
|
String? videoUrl,
|
||||||
String? description,
|
String? description,
|
||||||
XFile? image,
|
List<XFile> images = const [],
|
||||||
) async {
|
}) async {
|
||||||
final formData = FormData.fromMap({
|
final formData = await _buildFormData(
|
||||||
if (title != null && title.isNotEmpty) 'title': title,
|
title: title,
|
||||||
if (description != null && description.isNotEmpty) 'description_md': description,
|
videoUrl: videoUrl,
|
||||||
if (videoUrl != null && videoUrl.isNotEmpty) 'video_url': videoUrl,
|
description: description,
|
||||||
if (image != null) 'image': await MultipartFile.fromFile((await ImageCompressUtility.compressFile(File(image.path))).path, filename: image.name),
|
newImages: images,
|
||||||
});
|
|
||||||
|
|
||||||
return blogPostService.createBlogPost(
|
|
||||||
formData: formData,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return blogPostService.createBlogPost(formData: formData);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<XFile?> downloadImage(String imageUrl) async {
|
Future<void> updateBlogPost({
|
||||||
final response = await http.get(Uri.parse('${sl<AppConfig>().baseUrl}/storage/$imageUrl'));
|
required int id,
|
||||||
|
String? title,
|
||||||
if (response.statusCode == 200) {
|
String? videoUrl,
|
||||||
final directory = await getTemporaryDirectory();
|
String? description,
|
||||||
final filePath = '${directory.path}/image.jpg';
|
List<int> existingMediaIds = const [],
|
||||||
final file = File(filePath);
|
List<XFile> newImages = const [],
|
||||||
await file.writeAsBytes(response.bodyBytes);
|
List<int> mediaIdsToDelete = const [],
|
||||||
|
}) async {
|
||||||
return XFile(file.path);
|
if (mediaIdsToDelete.isNotEmpty) {
|
||||||
|
await Future.wait(
|
||||||
|
mediaIdsToDelete.map(
|
||||||
|
(mediaId) =>
|
||||||
|
blogPostService.deleteBlogPostMedia(postId: id, mediaId: mediaId),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
|
final formData = await _buildFormData(
|
||||||
|
title: title ?? '',
|
||||||
|
videoUrl: videoUrl ?? '',
|
||||||
|
description: description ?? '',
|
||||||
|
existingMediaIds: existingMediaIds,
|
||||||
|
newImages: newImages,
|
||||||
|
includeExistingMediaIds: true,
|
||||||
|
includeEmptyTextFields: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
return blogPostService.updateBlogPost(id: id, formData: formData);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateBlogPost(
|
Future<void> deleteBlogPostMedia({
|
||||||
int id,
|
required int postId,
|
||||||
String? title,
|
required int mediaId,
|
||||||
String? videoUrl,
|
}) async {
|
||||||
String? description,
|
await blogPostService.deleteBlogPostMedia(postId: postId, mediaId: mediaId);
|
||||||
XFile? image,
|
|
||||||
String? oldImageUrl,
|
|
||||||
) async {
|
|
||||||
final formData = FormData.fromMap({
|
|
||||||
'title': title,
|
|
||||||
'description_md': description,
|
|
||||||
'video_url': videoUrl,
|
|
||||||
'image': image != null
|
|
||||||
? await MultipartFile.fromFile((await ImageCompressUtility.compressFile(File(image.path))).path, filename: image.name)
|
|
||||||
: oldImageUrl != '' && oldImageUrl != null
|
|
||||||
? await MultipartFile.fromFile((await downloadImage(oldImageUrl))?.path ?? '', filename: oldImageUrl.split('/').last)
|
|
||||||
: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
return blogPostService.updateBlogPost(
|
|
||||||
id: id,
|
|
||||||
formData: formData,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> deleteBlogPost(int id) async {
|
Future<void> deleteBlogPost(int id) async {
|
||||||
await blogPostService.deleteBlogPost(id: id);
|
await blogPostService.deleteBlogPost(id: id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<FormData> _buildFormData({
|
||||||
|
String? title,
|
||||||
|
String? videoUrl,
|
||||||
|
String? description,
|
||||||
|
List<int> existingMediaIds = const [],
|
||||||
|
List<XFile> newImages = const [],
|
||||||
|
bool includeExistingMediaIds = false,
|
||||||
|
bool includeEmptyTextFields = false,
|
||||||
|
}) async {
|
||||||
|
final formData = FormData();
|
||||||
|
|
||||||
|
if (includeEmptyTextFields) {
|
||||||
|
formData.fields.add(MapEntry('title', title ?? ''));
|
||||||
|
formData.fields.add(MapEntry('description_md', description ?? ''));
|
||||||
|
formData.fields.add(MapEntry('video_url', videoUrl ?? ''));
|
||||||
|
} else {
|
||||||
|
if (title != null && title.isNotEmpty) {
|
||||||
|
formData.fields.add(MapEntry('title', title));
|
||||||
|
}
|
||||||
|
if (description != null && description.isNotEmpty) {
|
||||||
|
formData.fields.add(MapEntry('description_md', description));
|
||||||
|
}
|
||||||
|
if (videoUrl != null && videoUrl.isNotEmpty) {
|
||||||
|
formData.fields.add(MapEntry('video_url', videoUrl));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (includeExistingMediaIds) {
|
||||||
|
for (final mediaId in existingMediaIds) {
|
||||||
|
formData.fields.add(
|
||||||
|
MapEntry('existing_media_ids[]', mediaId.toString()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final image in newImages) {
|
||||||
|
final compressed = await ImageCompressUtility.compressFile(
|
||||||
|
File(image.path),
|
||||||
|
);
|
||||||
|
formData.files.add(
|
||||||
|
MapEntry(
|
||||||
|
'images[]',
|
||||||
|
await MultipartFile.fromFile(compressed.path, filename: image.name),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return formData;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,21 +7,23 @@ part 'blog_post_service.g.dart';
|
|||||||
abstract class BlogPostService {
|
abstract class BlogPostService {
|
||||||
factory BlogPostService(Dio dio, {String baseUrl}) = _BlogPostService;
|
factory BlogPostService(Dio dio, {String baseUrl}) = _BlogPostService;
|
||||||
|
|
||||||
@POST('/api/blogPosts/create/published')
|
@POST('/api/v2/blogPosts/create/published')
|
||||||
@MultiPart()
|
@MultiPart()
|
||||||
Future<void> createBlogPost({
|
Future<void> createBlogPost({@Body() required FormData formData});
|
||||||
@Body() required FormData formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
@POST('/api/blogPosts/update/published/{id}')
|
@PUT('/api/v2/blogPosts/update/published/{id}')
|
||||||
@MultiPart()
|
@MultiPart()
|
||||||
Future<void> updateBlogPost({
|
Future<void> updateBlogPost({
|
||||||
@Path('id') required int id,
|
@Path('id') required int id,
|
||||||
@Body() required FormData formData,
|
@Body() required FormData formData,
|
||||||
});
|
});
|
||||||
|
|
||||||
@DELETE('/api/blogPosts/delete/{id}')
|
@DELETE('/api/v2/blogPosts/{postId}/media/{mediaId}')
|
||||||
Future<void> deleteBlogPost({
|
Future<void> deleteBlogPostMedia({
|
||||||
@Path('id') required int id,
|
@Path('postId') required int postId,
|
||||||
|
@Path('mediaId') required int mediaId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@DELETE('/api/blogPosts/delete/{id}')
|
||||||
|
Future<void> deleteBlogPost({@Path('id') required int id});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
/// Client-side validation aligned with backend BlogPost V2 create/update rules.
|
||||||
|
abstract final class BlogPostFormValidator {
|
||||||
|
static const minTextLength = 3;
|
||||||
|
static const maxVideoUrlLength = 2048;
|
||||||
|
static const maxGalleryImages = 10;
|
||||||
|
static const allowedImageExtensions = {'jpg', 'jpeg', 'png'};
|
||||||
|
|
||||||
|
static const titleTooShort = 'Заголовок должен содержать минимум 3 символа';
|
||||||
|
static const descriptionTooShort =
|
||||||
|
'Описание должно содержать минимум 3 символа';
|
||||||
|
static const contentRequired =
|
||||||
|
'Добавьте заголовок, описание, изображение или ссылку на видео';
|
||||||
|
static const galleryTooLarge = 'Можно загрузить не более 10 изображений';
|
||||||
|
static const imageTypeInvalid = 'Допустимы только изображения JPG или PNG';
|
||||||
|
static const videoUrlTooLong = 'Ссылка на видео слишком длинная';
|
||||||
|
static const videoUrlInvalid = 'Укажите корректную ссылку на видео';
|
||||||
|
static const videoUrlUnsupportedHost =
|
||||||
|
'Поддерживаются только Rutube, VK и OK';
|
||||||
|
static const videoUrlRutubeFormat = 'Неверный формат ссылки Rutube';
|
||||||
|
static const videoUrlVkFormat = 'Неверный формат ссылки VK';
|
||||||
|
static const videoUrlOkFormat = 'Неверный формат ссылки OK';
|
||||||
|
|
||||||
|
/// Empty is allowed; non-empty must be at least [minTextLength] chars (after trim).
|
||||||
|
static String? title(String? value) {
|
||||||
|
final text = value?.trim() ?? '';
|
||||||
|
if (text.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (text.length < minTextLength) {
|
||||||
|
return titleTooShort;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Empty is allowed; non-empty must be at least [minTextLength] chars (after trim).
|
||||||
|
static String? description(String? value) {
|
||||||
|
final text = value?.trim() ?? '';
|
||||||
|
if (text.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (text.length < minTextLength) {
|
||||||
|
return descriptionTooShort;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Empty is allowed; non-empty must match allowed video hosts/paths.
|
||||||
|
static String? videoUrl(String? value) {
|
||||||
|
final url = value?.trim() ?? '';
|
||||||
|
if (url.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (url.length > maxVideoUrlLength) {
|
||||||
|
return videoUrlTooLong;
|
||||||
|
}
|
||||||
|
|
||||||
|
final uri = Uri.tryParse(url);
|
||||||
|
if (uri == null || !uri.hasScheme || uri.host.isEmpty) {
|
||||||
|
return videoUrlInvalid;
|
||||||
|
}
|
||||||
|
if (uri.scheme != 'http' && uri.scheme != 'https') {
|
||||||
|
return videoUrlInvalid;
|
||||||
|
}
|
||||||
|
|
||||||
|
final host = uri.host.toLowerCase();
|
||||||
|
if (!_hostMatchesRootAllowlist(host, const [
|
||||||
|
'vk.com',
|
||||||
|
'vkvideo.ru',
|
||||||
|
'ok.ru',
|
||||||
|
'rutube.ru',
|
||||||
|
])) {
|
||||||
|
return videoUrlUnsupportedHost;
|
||||||
|
}
|
||||||
|
|
||||||
|
final path = uri.path;
|
||||||
|
if (_hostMatchesRootAllowlist(host, const ['rutube.ru'])) {
|
||||||
|
if (!_rutubePath.hasMatch(path)) {
|
||||||
|
return videoUrlRutubeFormat;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (_hostMatchesRootAllowlist(host, const ['vk.com', 'vkvideo.ru'])) {
|
||||||
|
if (!_vkPath.hasMatch(path)) {
|
||||||
|
return videoUrlVkFormat;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (_hostMatchesRootAllowlist(host, const ['ok.ru'])) {
|
||||||
|
if (!_okPath.hasMatch(path)) {
|
||||||
|
return videoUrlOkFormat;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// At least one of title / description / images / video must be meaningfully filled.
|
||||||
|
static String? contentRequiredMessage({
|
||||||
|
required String title,
|
||||||
|
required String description,
|
||||||
|
required String videoUrl,
|
||||||
|
required int imageCount,
|
||||||
|
}) {
|
||||||
|
final hasTitle = title.trim().length >= minTextLength;
|
||||||
|
final hasDescription = description.trim().length >= minTextLength;
|
||||||
|
final trimmedVideo = videoUrl.trim();
|
||||||
|
final hasVideo =
|
||||||
|
trimmedVideo.isNotEmpty &&
|
||||||
|
BlogPostFormValidator.videoUrl(trimmedVideo) == null;
|
||||||
|
final hasImages = imageCount > 0;
|
||||||
|
|
||||||
|
if (hasTitle || hasDescription || hasVideo || hasImages) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return contentRequired;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? galleryCount(int count) {
|
||||||
|
if (count > maxGalleryImages) {
|
||||||
|
return galleryTooLarge;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns error if any path has a non-allowed extension. Empty path is ignored.
|
||||||
|
static String? imagePaths(Iterable<String> paths) {
|
||||||
|
for (final path in paths) {
|
||||||
|
final name = path.split(RegExp(r'[\\/]')).last;
|
||||||
|
final dot = name.lastIndexOf('.');
|
||||||
|
if (dot < 0 || dot == name.length - 1) {
|
||||||
|
return imageTypeInvalid;
|
||||||
|
}
|
||||||
|
final ext = name.substring(dot + 1).toLowerCase();
|
||||||
|
if (!allowedImageExtensions.contains(ext)) {
|
||||||
|
return imageTypeInvalid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static final _rutubePath = RegExp(
|
||||||
|
r'^/(video|shorts)/[a-z0-9]+/?$',
|
||||||
|
caseSensitive: false,
|
||||||
|
);
|
||||||
|
static final _vkPath = RegExp(r'^/video-?\d+_\d+/?$', caseSensitive: false);
|
||||||
|
static final _okPath = RegExp(r'^/video/\d+/?$');
|
||||||
|
|
||||||
|
static bool _hostMatchesRootAllowlist(
|
||||||
|
String host,
|
||||||
|
List<String> allowedRootHosts,
|
||||||
|
) {
|
||||||
|
for (final allowed in allowedRootHosts) {
|
||||||
|
if (host == allowed || host.endsWith('.$allowed')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import 'package:eatvio_app/core/app_config.dart';
|
|||||||
import 'package:eatvio_app/core/env_config.dart';
|
import 'package:eatvio_app/core/env_config.dart';
|
||||||
import 'package:eatvio_app/core/locator_service.dart';
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
import 'package:eatvio_app/core/utils/image_constant.dart';
|
import 'package:eatvio_app/core/utils/image_constant.dart';
|
||||||
|
import 'package:eatvio_app/core/utils/url_utils.dart';
|
||||||
import 'package:eatvio_app/features/dev_tools/presentation/dev_tools_screen.dart';
|
import 'package:eatvio_app/features/dev_tools/presentation/dev_tools_screen.dart';
|
||||||
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
|
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
|
||||||
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_state.dart';
|
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_state.dart';
|
||||||
@@ -26,11 +27,13 @@ class BottomNavigationBarWidget extends StatefulWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
final Widget child;
|
final Widget child;
|
||||||
|
|
||||||
/// Текущий путь из GoRouter (matchedLocation), чтобы подсветка таба совпадала с экраном при навигации через go() (диплинки, пуши).
|
/// Текущий путь из GoRouter (matchedLocation), чтобы подсветка таба совпадала с экраном при навигации через go() (диплинки, пуши).
|
||||||
final String currentLocation;
|
final String currentLocation;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<BottomNavigationBarWidget> createState() => _BottomNavigationBarWidgetState();
|
State<BottomNavigationBarWidget> createState() =>
|
||||||
|
_BottomNavigationBarWidgetState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||||
@@ -40,9 +43,24 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
|||||||
|
|
||||||
List<String> _getPages(bool isConsultant) {
|
List<String> _getPages(bool isConsultant) {
|
||||||
if (isConsultant) {
|
if (isConsultant) {
|
||||||
return ['/my-profile-screen', '/feed-screen', '/diary-screen', '/consultations-screen', '/chat-select-screen', '/notifications-screen', '/menu-screen'];
|
return [
|
||||||
|
'/my-profile-screen',
|
||||||
|
'/feed-screen',
|
||||||
|
'/diary-screen',
|
||||||
|
'/consultations-screen',
|
||||||
|
'/chat-select-screen',
|
||||||
|
'/notifications-screen',
|
||||||
|
'/menu-screen',
|
||||||
|
];
|
||||||
} else {
|
} else {
|
||||||
return ['/my-profile-screen', '/feed-screen', '/diary-screen', '/chat-select-screen', '/notifications-screen', '/menu-screen'];
|
return [
|
||||||
|
'/my-profile-screen',
|
||||||
|
'/feed-screen',
|
||||||
|
'/diary-screen',
|
||||||
|
'/chat-select-screen',
|
||||||
|
'/notifications-screen',
|
||||||
|
'/menu-screen',
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +116,9 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
|||||||
final logo = SvgPicture.asset(
|
final logo = SvgPicture.asset(
|
||||||
ImageConstant.imgLogo,
|
ImageConstant.imgLogo,
|
||||||
height: 32,
|
height: 32,
|
||||||
colorFilter: isProduction ? null : const ColorFilter.mode(_devAccentColor, BlendMode.srcIn),
|
colorFilter: isProduction
|
||||||
|
? null
|
||||||
|
: const ColorFilter.mode(_devAccentColor, BlendMode.srcIn),
|
||||||
);
|
);
|
||||||
if (isProduction) return logo;
|
if (isProduction) return logo;
|
||||||
return Stack(
|
return Stack(
|
||||||
@@ -109,7 +129,10 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
border: Border.all(color: _devAccentColor.withValues(alpha: 0.6), width: 1.5),
|
border: Border.all(
|
||||||
|
color: _devAccentColor.withValues(alpha: 0.6),
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
color: _devAccentColor.withValues(alpha: 0.08),
|
color: _devAccentColor.withValues(alpha: 0.08),
|
||||||
),
|
),
|
||||||
child: logo,
|
child: logo,
|
||||||
@@ -122,11 +145,23 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _devAccentColor,
|
color: _devAccentColor,
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
boxShadow: [BoxShadow(color: _devAccentColor.withValues(alpha: 0.4), blurRadius: 4, offset: const Offset(0, 1))],
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: _devAccentColor.withValues(alpha: 0.4),
|
||||||
|
blurRadius: 4,
|
||||||
|
offset: const Offset(0, 1),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
child: const Text(
|
child: const Text(
|
||||||
'DEV',
|
'DEV',
|
||||||
style: TextStyle(fontSize: 8, fontWeight: FontWeight.w700, color: Colors.white, letterSpacing: 0.3, height: 1.0),
|
style: TextStyle(
|
||||||
|
fontSize: 8,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Colors.white,
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
height: 1.0,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -155,10 +190,15 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
|||||||
// Подсветка таба по текущему маршруту (совпадает при навигации через go(), в т.ч. диплинки/пуши).
|
// Подсветка таба по текущему маршруту (совпадает при навигации через go(), в т.ч. диплинки/пуши).
|
||||||
final pathWithoutQuery = widget.currentLocation.split('?').first;
|
final pathWithoutQuery = widget.currentLocation.split('?').first;
|
||||||
final indexFromRoute = pages!.indexOf(pathWithoutQuery);
|
final indexFromRoute = pages!.indexOf(pathWithoutQuery);
|
||||||
final effectiveIndex = indexFromRoute >= 0 ? indexFromRoute : currentBottomNavBarIndex;
|
final effectiveIndex = indexFromRoute >= 0
|
||||||
if (indexFromRoute >= 0 && indexFromRoute != currentBottomNavBarIndex) {
|
? indexFromRoute
|
||||||
|
: currentBottomNavBarIndex;
|
||||||
|
if (indexFromRoute >= 0 &&
|
||||||
|
indexFromRoute != currentBottomNavBarIndex) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (mounted) setState(() => currentBottomNavBarIndex = indexFromRoute);
|
if (mounted) {
|
||||||
|
setState(() => currentBottomNavBarIndex = indexFromRoute);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,9 +216,14 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
|||||||
label: '',
|
label: '',
|
||||||
icon: CircleAvatar(
|
icon: CircleAvatar(
|
||||||
foregroundImage: NetworkImage(
|
foregroundImage: NetworkImage(
|
||||||
profileModel.profilePhotoUrl.contains('https') ? profileModel.profilePhotoUrl : sl<AppConfig>().baseUrl + profileModel.profilePhotoUrl,
|
joinBaseUrl(
|
||||||
|
sl<AppConfig>().baseUrl,
|
||||||
|
profileModel.profilePhotoUrl,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
backgroundImage: const AssetImage(
|
||||||
|
'assets/images/no_avatar.png',
|
||||||
),
|
),
|
||||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
|
||||||
radius: 16,
|
radius: 16,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -186,22 +231,54 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
|||||||
label: 'Лента',
|
label: 'Лента',
|
||||||
icon: Padding(
|
icon: Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 2),
|
padding: const EdgeInsets.only(bottom: 2),
|
||||||
child: SvgPicture.asset(colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn), width: 20, height: 20, ImageConstant.icnFeed),
|
child: SvgPicture.asset(
|
||||||
|
colorFilter: const ColorFilter.mode(
|
||||||
|
Colors.grey,
|
||||||
|
BlendMode.srcIn,
|
||||||
|
),
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
ImageConstant.icnFeed,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
activeIcon: Padding(
|
activeIcon: Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 2),
|
padding: const EdgeInsets.only(bottom: 2),
|
||||||
child: SvgPicture.asset(ImageConstant.icnFeed, width: 20, height: 20, colorFilter: ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn)),
|
child: SvgPicture.asset(
|
||||||
|
ImageConstant.icnFeed,
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
colorFilter: ColorFilter.mode(
|
||||||
|
appTheme.blueA400,
|
||||||
|
BlendMode.srcIn,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
BottomNavigationBarItem(
|
BottomNavigationBarItem(
|
||||||
label: s.diary,
|
label: s.diary,
|
||||||
icon: Padding(
|
icon: Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 2),
|
padding: const EdgeInsets.only(bottom: 2),
|
||||||
child: SvgPicture.asset(colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn), ImageConstant.icnDiary, width: 20, height: 20),
|
child: SvgPicture.asset(
|
||||||
|
colorFilter: const ColorFilter.mode(
|
||||||
|
Colors.grey,
|
||||||
|
BlendMode.srcIn,
|
||||||
|
),
|
||||||
|
ImageConstant.icnDiary,
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
activeIcon: Padding(
|
activeIcon: Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 2),
|
padding: const EdgeInsets.only(bottom: 2),
|
||||||
child: SvgPicture.asset(ImageConstant.icnDiary, width: 20, height: 20, colorFilter: ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn)),
|
child: SvgPicture.asset(
|
||||||
|
ImageConstant.icnDiary,
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
colorFilter: ColorFilter.mode(
|
||||||
|
appTheme.blueA400,
|
||||||
|
BlendMode.srcIn,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (profileModel.roles.contains('consultant'))
|
if (profileModel.roles.contains('consultant'))
|
||||||
@@ -210,7 +287,10 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
|||||||
icon: Padding(
|
icon: Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 2),
|
padding: const EdgeInsets.only(bottom: 2),
|
||||||
child: SvgPicture.asset(
|
child: SvgPicture.asset(
|
||||||
colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn),
|
colorFilter: const ColorFilter.mode(
|
||||||
|
Colors.grey,
|
||||||
|
BlendMode.srcIn,
|
||||||
|
),
|
||||||
'assets/icons/consultations.svg',
|
'assets/icons/consultations.svg',
|
||||||
width: 20,
|
width: 20,
|
||||||
height: 20,
|
height: 20,
|
||||||
@@ -222,7 +302,10 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
|||||||
'assets/icons/consultations.svg',
|
'assets/icons/consultations.svg',
|
||||||
width: 20,
|
width: 20,
|
||||||
height: 20,
|
height: 20,
|
||||||
colorFilter: ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn),
|
colorFilter: ColorFilter.mode(
|
||||||
|
appTheme.blueA400,
|
||||||
|
BlendMode.srcIn,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -230,14 +313,26 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
|||||||
label: s.chat,
|
label: s.chat,
|
||||||
icon: BlocBuilder<ChatsCubit, ChatsState>(
|
icon: BlocBuilder<ChatsCubit, ChatsState>(
|
||||||
builder: (BuildContext context, ChatsState state) {
|
builder: (BuildContext context, ChatsState state) {
|
||||||
final hasUnreadMessages = state.chatsWithUnreadMessagesCount > 0;
|
final hasUnreadMessages =
|
||||||
|
state.chatsWithUnreadMessagesCount > 0;
|
||||||
return badges.Badge(
|
return badges.Badge(
|
||||||
position: badges.BadgePosition.topEnd(),
|
position: badges.BadgePosition.topEnd(),
|
||||||
badgeAnimation: const badges.BadgeAnimation.fade(),
|
badgeAnimation: const badges.BadgeAnimation.fade(),
|
||||||
badgeStyle: badges.BadgeStyle(badgeColor: hasUnreadMessages ? Colors.red : Colors.transparent),
|
badgeStyle: badges.BadgeStyle(
|
||||||
|
badgeColor: hasUnreadMessages
|
||||||
|
? Colors.red
|
||||||
|
: Colors.transparent,
|
||||||
|
),
|
||||||
badgeContent: Text(
|
badgeContent: Text(
|
||||||
hasUnreadMessages ? state.chatsWithUnreadMessagesCount.toString() : '',
|
hasUnreadMessages
|
||||||
style: TextStyle(color: hasUnreadMessages ? Colors.white : Colors.transparent, fontSize: 8),
|
? state.chatsWithUnreadMessagesCount.toString()
|
||||||
|
: '',
|
||||||
|
style: TextStyle(
|
||||||
|
color: hasUnreadMessages
|
||||||
|
? Colors.white
|
||||||
|
: Colors.transparent,
|
||||||
|
fontSize: 8,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 2),
|
padding: const EdgeInsets.only(bottom: 2),
|
||||||
@@ -245,9 +340,16 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
|||||||
ImageConstant.icnChat,
|
ImageConstant.icnChat,
|
||||||
width: 20,
|
width: 20,
|
||||||
height: 20,
|
height: 20,
|
||||||
colorFilter: pages![effectiveIndex] == '/chat-select-screen'
|
colorFilter:
|
||||||
? ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn)
|
pages![effectiveIndex] == '/chat-select-screen'
|
||||||
: const ColorFilter.mode(Colors.grey, BlendMode.srcIn),
|
? ColorFilter.mode(
|
||||||
|
appTheme.blueA400,
|
||||||
|
BlendMode.srcIn,
|
||||||
|
)
|
||||||
|
: const ColorFilter.mode(
|
||||||
|
Colors.grey,
|
||||||
|
BlendMode.srcIn,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -261,15 +363,28 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
|||||||
return badges.Badge(
|
return badges.Badge(
|
||||||
position: badges.BadgePosition.topEnd(),
|
position: badges.BadgePosition.topEnd(),
|
||||||
badgeAnimation: const badges.BadgeAnimation.fade(),
|
badgeAnimation: const badges.BadgeAnimation.fade(),
|
||||||
showBadge: state is NotificationsStateLoaded && state.hasUnreadNotifications,
|
showBadge:
|
||||||
badgeContent: const Text('', style: TextStyle(color: Colors.white, fontSize: 8)),
|
state is NotificationsStateLoaded &&
|
||||||
|
state.hasUnreadNotifications,
|
||||||
|
badgeContent: const Text(
|
||||||
|
'',
|
||||||
|
style: TextStyle(color: Colors.white, fontSize: 8),
|
||||||
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 2),
|
padding: const EdgeInsets.only(bottom: 2),
|
||||||
child: SvgPicture.asset(
|
child: SvgPicture.asset(
|
||||||
ImageConstant.icnBell,
|
ImageConstant.icnBell,
|
||||||
colorFilter: pages![effectiveIndex] == '/notifications-screen'
|
colorFilter:
|
||||||
? ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn)
|
pages![effectiveIndex] ==
|
||||||
: const ColorFilter.mode(Colors.grey, BlendMode.srcIn),
|
'/notifications-screen'
|
||||||
|
? ColorFilter.mode(
|
||||||
|
appTheme.blueA400,
|
||||||
|
BlendMode.srcIn,
|
||||||
|
)
|
||||||
|
: const ColorFilter.mode(
|
||||||
|
Colors.grey,
|
||||||
|
BlendMode.srcIn,
|
||||||
|
),
|
||||||
width: 20,
|
width: 20,
|
||||||
height: 20,
|
height: 20,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ abstract class ChatMessageModel with _$ChatMessageModel {
|
|||||||
@JsonKey(name: 'author_id', fromJson: _parseAuthorId) required int authorId,
|
@JsonKey(name: 'author_id', fromJson: _parseAuthorId) required int authorId,
|
||||||
@JsonKey(name: 'updated_at', fromJson: _parseUpdatedAt) required DateTime updatedAt,
|
@JsonKey(name: 'updated_at', fromJson: _parseUpdatedAt) required DateTime updatedAt,
|
||||||
@JsonKey(name: 'read_by_to_user', defaultValue: false) required bool isRead,
|
@JsonKey(name: 'read_by_to_user', defaultValue: false) required bool isRead,
|
||||||
|
// @deprecated Backend BC-поле. В broadcast (ChatMessageUpdated) оно
|
||||||
|
// recipient-relative и для второго устройства отправителя всегда false,
|
||||||
|
// поэтому решать «моё/не моё» по нему нельзя. Источник истины — authorId:
|
||||||
|
// сравнивайте его со своим user_id (wsManager.userId). Поле оставлено
|
||||||
|
// только для обратной совместимости парсинга.
|
||||||
@JsonKey(name: 'is_author_message_auth_user') required bool? isCurrentUserAuthor,
|
@JsonKey(name: 'is_author_message_auth_user') required bool? isCurrentUserAuthor,
|
||||||
@JsonKey(name: 'room_id') required String? roomId,
|
@JsonKey(name: 'room_id') required String? roomId,
|
||||||
@JsonKey(name: 'type', unknownEnumValue: ChatMessageType.unknown) ChatMessageType? type,
|
@JsonKey(name: 'type', unknownEnumValue: ChatMessageType.unknown) ChatMessageType? type,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
|
|
||||||
StreamSubscription<ChannelReadEvent>? typingSubscription;
|
StreamSubscription<ChannelReadEvent>? typingSubscription;
|
||||||
StreamSubscription<ChannelReadEvent>? messagesSubscription;
|
StreamSubscription<ChannelReadEvent>? messagesSubscription;
|
||||||
|
StreamSubscription<void>? _reconnectSubscription;
|
||||||
int currentPage = 1;
|
int currentPage = 1;
|
||||||
late int lastPage;
|
late int lastPage;
|
||||||
bool isLoading = false;
|
bool isLoading = false;
|
||||||
@@ -39,11 +40,18 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
void Function(bool isOnline)? _presenceOnlineListener;
|
void Function(bool isOnline)? _presenceOnlineListener;
|
||||||
var uuid = const Uuid();
|
var uuid = const Uuid();
|
||||||
|
|
||||||
ChatRoomCubit({required this.chatRepository, required this.wsManager, required this.cardsRepository}) : super(const ChatRoomState.loading());
|
ChatRoomCubit({
|
||||||
|
required this.chatRepository,
|
||||||
|
required this.wsManager,
|
||||||
|
required this.cardsRepository,
|
||||||
|
}) : super(const ChatRoomState.loading());
|
||||||
|
|
||||||
void setTimer() {
|
void setTimer() {
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
_timer = Timer.periodic(const Duration(seconds: 5), (_) => updateChosenPlanState());
|
_timer = Timer.periodic(
|
||||||
|
const Duration(seconds: 5),
|
||||||
|
(_) => updateChosenPlanState(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void cancelTimer() {
|
void cancelTimer() {
|
||||||
@@ -54,16 +62,22 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
if (currentPage < lastPage && !isLoading) {
|
if (currentPage < lastPage && !isLoading) {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
|
|
||||||
emit((state as ChatRoomStateLoaded).copyWith(isLoading: isLoading));
|
_emitLoaded((current) => current.copyWith(isLoading: isLoading));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final chatMessages = await chatRepository.getChatMessages(uuid, currentPage + 1);
|
final chatMessages = await chatRepository.getChatMessages(
|
||||||
|
uuid,
|
||||||
|
currentPage + 1,
|
||||||
|
);
|
||||||
|
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
emit(
|
_emitLoaded(
|
||||||
(state as ChatRoomStateLoaded).copyWith(
|
(current) => current.copyWith(
|
||||||
isLoading: isLoading,
|
isLoading: isLoading,
|
||||||
chatMessages: [...(state as ChatRoomStateLoaded).chatMessages, ...chatMessages.messagesList],
|
chatMessages: [
|
||||||
|
...current.chatMessages,
|
||||||
|
...chatMessages.messagesList,
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -86,11 +100,8 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _setCard({required List<CardModel> cards}) {
|
void _setCard({required List<CardModel> cards}) {
|
||||||
final currentState = state;
|
if (cards.where((card) => card.isDefault).isNotEmpty) {
|
||||||
if (currentState is ChatRoomStateLoaded) {
|
_emitLoaded((current) => current.copyWith(hasCard: true));
|
||||||
if (cards.where((card) => card.isDefault).isNotEmpty) {
|
|
||||||
emit(currentState.copyWith(hasCard: true));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +111,11 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
/// `GET /api/consultation/resolve/order_id_to_conversation/{orderId}`.
|
/// `GET /api/consultation/resolve/order_id_to_conversation/{orderId}`.
|
||||||
///
|
///
|
||||||
/// Возвращает conversation uuid, которым инициализировали чат (для reload без повторного resolve).
|
/// Возвращает conversation uuid, которым инициализировали чат (для reload без повторного resolve).
|
||||||
Future<String?> initChat(String? uuid, {String? orderId, bool sendInitialMessage = false}) async {
|
Future<String?> initChat(
|
||||||
|
String? uuid, {
|
||||||
|
String? orderId,
|
||||||
|
bool sendInitialMessage = false,
|
||||||
|
}) async {
|
||||||
try {
|
try {
|
||||||
emit(const ChatRoomState.loading());
|
emit(const ChatRoomState.loading());
|
||||||
|
|
||||||
@@ -108,7 +123,9 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
int? resolvedChosenPlanId;
|
int? resolvedChosenPlanId;
|
||||||
|
|
||||||
if (orderId != null && orderId.isNotEmpty) {
|
if (orderId != null && orderId.isNotEmpty) {
|
||||||
final resolved = await chatRepository.resolveOrderIdToConversation(orderId: orderId);
|
final resolved = await chatRepository.resolveOrderIdToConversation(
|
||||||
|
orderId: orderId,
|
||||||
|
);
|
||||||
resolvedUuid = resolved.conversationUuid;
|
resolvedUuid = resolved.conversationUuid;
|
||||||
resolvedChosenPlanId = resolved.chosenPlanId;
|
resolvedChosenPlanId = resolved.chosenPlanId;
|
||||||
}
|
}
|
||||||
@@ -117,15 +134,23 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
throw ArgumentError('initChat requires uuid or orderId');
|
throw ArgumentError('initChat requires uuid or orderId');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
currentPage = 1;
|
||||||
|
isLoading = false;
|
||||||
|
_activeChatUuid = resolvedUuid;
|
||||||
|
|
||||||
final chatRoom = await chatRepository.getChatRoom(resolvedUuid);
|
final chatRoom = await chatRepository.getChatRoom(resolvedUuid);
|
||||||
ChosenPlanModel? chosenPlan;
|
ChosenPlanModel? chosenPlan;
|
||||||
final planId = resolvedChosenPlanId ?? chatRoom.chosenPlanId;
|
final planId = resolvedChosenPlanId ?? chatRoom.chosenPlanId;
|
||||||
if (planId != null) {
|
if (planId != null) {
|
||||||
chosenPlan = await chatRepository.getChosenPlan(id: planId);
|
chosenPlan = await chatRepository.getChosenPlan(id: planId);
|
||||||
}
|
}
|
||||||
final chatMessages = await chatRepository.getChatMessages(resolvedUuid, currentPage);
|
final chatMessages = await chatRepository.getChatMessages(
|
||||||
|
resolvedUuid,
|
||||||
|
currentPage,
|
||||||
|
);
|
||||||
lastPage = chatMessages.meta.lastPage;
|
lastPage = chatMessages.meta.lastPage;
|
||||||
await _subscribeToChatChannel(uuid: resolvedUuid, chatId: chatRoom.id);
|
await _subscribeToChatChannel(uuid: resolvedUuid, chatId: chatRoom.id);
|
||||||
|
_listenToReconnect();
|
||||||
emit(
|
emit(
|
||||||
ChatRoomState.loaded(
|
ChatRoomState.loaded(
|
||||||
chatRoom: chatRoom.copyWith(chosenPlan: chosenPlan),
|
chatRoom: chatRoom.copyWith(chosenPlan: chosenPlan),
|
||||||
@@ -135,6 +160,7 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
hasCard: false,
|
hasCard: false,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
await _subscribeToPresenceChannel(chatId: chatRoom.id);
|
||||||
|
|
||||||
if (chosenPlan?.consultantId == chatRoom.users.sender.id) {
|
if (chosenPlan?.consultantId == chatRoom.users.sender.id) {
|
||||||
await checkCards();
|
await checkCards();
|
||||||
@@ -145,22 +171,106 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
log(e.toString());
|
log(e.toString());
|
||||||
typingSubscription?.cancel();
|
typingSubscription?.cancel();
|
||||||
messagesSubscription?.cancel();
|
messagesSubscription?.cancel();
|
||||||
|
_reconnectSubscription?.cancel();
|
||||||
|
_reconnectSubscription = null;
|
||||||
wsManager.unsubscribeDirectChatChannel();
|
wsManager.unsubscribeDirectChatChannel();
|
||||||
emit(const ChatRoomState.error(errorMessage: 'Ошибка получеия данных'));
|
emit(const ChatRoomState.error(errorMessage: 'Ошибка получеия данных'));
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Подписывается на реконнект сокета, чтобы догрузить пропущенное (S5).
|
||||||
|
void _listenToReconnect() {
|
||||||
|
_reconnectSubscription?.cancel();
|
||||||
|
_reconnectSubscription = wsManager.reconnected.listen((_) {
|
||||||
|
unawaited(refreshCurrentChat());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Догружает первую страницу сообщений после восстановления соединения (S5).
|
||||||
|
///
|
||||||
|
/// Мерж по `id` сохраняет уже подгруженные старые страницы: пропущенные
|
||||||
|
/// сообщения добавляются, существующие не дублируются. Порядок — по
|
||||||
|
/// `updatedAt` по убыванию, как его ждёт UI и отдаёт backend (S1).
|
||||||
|
Future<void> refreshCurrentChat() async {
|
||||||
|
final uuid = _activeChatUuid;
|
||||||
|
if (uuid == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final page = await chatRepository.getChatMessages(uuid, 1);
|
||||||
|
lastPage = page.meta.lastPage;
|
||||||
|
_emitLoaded((current) {
|
||||||
|
final byId = {
|
||||||
|
for (final message in current.chatMessages) message.id: message,
|
||||||
|
};
|
||||||
|
for (final message in page.messagesList) {
|
||||||
|
byId.putIfAbsent(message.id, () => message);
|
||||||
|
}
|
||||||
|
final merged = byId.values.toList()
|
||||||
|
..sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
|
||||||
|
return current.copyWith(chatMessages: merged);
|
||||||
|
});
|
||||||
|
} catch (e, st) {
|
||||||
|
await reportFrom(e, st, this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Перезагружает сообщения, комнату и состояние плана без экрана загрузки.
|
||||||
|
Future<void> refreshChat() async {
|
||||||
|
final uuid = _activeChatUuid;
|
||||||
|
if (uuid == null || uuid.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final currentState = state;
|
||||||
|
if (currentState is! ChatRoomStateLoaded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
currentPage = 1;
|
||||||
|
isLoading = false;
|
||||||
|
|
||||||
|
final chatRoom = await chatRepository.getChatRoom(uuid);
|
||||||
|
ChosenPlanModel? chosenPlan;
|
||||||
|
final planId =
|
||||||
|
chatRoom.chosenPlanId ?? currentState.chatRoom.chosenPlanId;
|
||||||
|
if (planId != null) {
|
||||||
|
chosenPlan = await chatRepository.getChosenPlan(id: planId);
|
||||||
|
}
|
||||||
|
final chatMessages = await chatRepository.getChatMessages(
|
||||||
|
uuid,
|
||||||
|
currentPage,
|
||||||
|
);
|
||||||
|
lastPage = chatMessages.meta.lastPage;
|
||||||
|
|
||||||
|
_emitLoaded(
|
||||||
|
(latest) => latest.copyWith(
|
||||||
|
chatRoom: chatRoom.copyWith(chosenPlan: chosenPlan),
|
||||||
|
chatMessages: chatMessages.messagesList,
|
||||||
|
isLoading: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (chosenPlan?.consultantId == chatRoom.users.sender.id) {
|
||||||
|
await checkCards();
|
||||||
|
}
|
||||||
|
} catch (e, st) {
|
||||||
|
await reportFrom(e, st, this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> updateChosenPlanState() async {
|
Future<void> updateChosenPlanState() async {
|
||||||
try {
|
try {
|
||||||
final currentState = state;
|
final currentState = state;
|
||||||
if (currentState is ChatRoomStateLoaded) {
|
if (currentState is! ChatRoomStateLoaded) {
|
||||||
final chosenPlanId = currentState.chatRoom.chosenPlanId;
|
return;
|
||||||
if (chosenPlanId != null) {
|
|
||||||
final chosenPlan = await chatRepository.getChosenPlan(id: chosenPlanId);
|
|
||||||
emit(currentState.copyWith.chatRoom(chosenPlan: chosenPlan));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
final chosenPlanId = currentState.chatRoom.chosenPlanId;
|
||||||
|
if (chosenPlanId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final chosenPlan = await chatRepository.getChosenPlan(id: chosenPlanId);
|
||||||
|
_emitLoaded((latest) => latest.copyWith.chatRoom(chosenPlan: chosenPlan));
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
}
|
}
|
||||||
@@ -172,9 +282,16 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
final planId = (state as ChatRoomStateLoaded).chatRoom.chosenPlanId;
|
final planId = (state as ChatRoomStateLoaded).chatRoom.chosenPlanId;
|
||||||
if (planId == null) return false;
|
if (planId == null) return false;
|
||||||
final stringDate = date.format('yyyy-MM-dd');
|
final stringDate = date.format('yyyy-MM-dd');
|
||||||
await chatRepository.choseStartPlanDate(id: planId, date: {'start_date': stringDate});
|
await chatRepository.choseStartPlanDate(
|
||||||
|
id: planId,
|
||||||
|
date: {'start_date': stringDate},
|
||||||
|
);
|
||||||
final chosenPlan = await chatRepository.getChosenPlan(id: planId);
|
final chosenPlan = await chatRepository.getChosenPlan(id: planId);
|
||||||
emit((state as ChatRoomStateLoaded).copyWith.chatRoom(chosenPlan: chosenPlan));
|
emit(
|
||||||
|
(state as ChatRoomStateLoaded).copyWith.chatRoom(
|
||||||
|
chosenPlan: chosenPlan,
|
||||||
|
),
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
@@ -183,7 +300,10 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> cancelChosenPlan({required String chatId, required int planId}) async {
|
Future<void> cancelChosenPlan({
|
||||||
|
required String chatId,
|
||||||
|
required int planId,
|
||||||
|
}) async {
|
||||||
try {
|
try {
|
||||||
await chatRepository.cancelChosenPlan(id: planId);
|
await chatRepository.cancelChosenPlan(id: planId);
|
||||||
emit((state as ChatRoomStateLoaded).copyWith.chatRoom(chosenPlan: null));
|
emit((state as ChatRoomStateLoaded).copyWith.chatRoom(chosenPlan: null));
|
||||||
@@ -193,38 +313,129 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _subscribeToChatChannel({required String uuid, required String chatId}) async {
|
bool _emitLoaded(
|
||||||
|
ChatRoomStateLoaded Function(ChatRoomStateLoaded current) update,
|
||||||
|
) {
|
||||||
|
if (isClosed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final currentState = state;
|
||||||
|
if (currentState is! ChatRoomStateLoaded) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
emit(update(currentState));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _subscribeToChatChannel({
|
||||||
|
required String uuid,
|
||||||
|
required String chatId,
|
||||||
|
}) async {
|
||||||
|
// Активный чат запоминаем до проверки клиента: он нужен и для догрузки
|
||||||
|
// пропущенного после реконнекта (S5), даже если сокет появится позже.
|
||||||
|
_activeChatUuid = uuid;
|
||||||
final client = wsManager.client;
|
final client = wsManager.client;
|
||||||
if (client == null) return;
|
if (client == null) return;
|
||||||
_activeChatUuid = uuid;
|
await messagesSubscription?.cancel();
|
||||||
|
await typingSubscription?.cancel();
|
||||||
|
messagesSubscription = null;
|
||||||
|
typingSubscription = null;
|
||||||
await wsManager.initPrivateDirectChatChannel(chatId);
|
await wsManager.initPrivateDirectChatChannel(chatId);
|
||||||
wsManager.subscribeDirectChatChannel();
|
wsManager.subscribeDirectChatChannel();
|
||||||
await _subscribeToPresenceChannel(chatId: chatId);
|
messagesSubscription = wsManager.directChatStream?.listen(
|
||||||
messagesSubscription = wsManager.directChatStream?.listen((event) {
|
_onDirectChatEvent,
|
||||||
AudioPlayer().play((AssetSource('audio/recive_message.wav')));
|
onError: (Object e, StackTrace st) {
|
||||||
final message = ChatMessageModel.fromJson(jsonDecode(event.data));
|
unawaited(reportFrom(e, st, this));
|
||||||
final currentState = state as ChatRoomStateLoaded;
|
},
|
||||||
|
);
|
||||||
// Сообщения от текущего пользователя не приходят через WebSocket,
|
|
||||||
// они обрабатываются через ответ API в sendMessage
|
|
||||||
// Здесь обрабатываем только входящие сообщения от других пользователей
|
|
||||||
List<ChatMessageModel> messagesList = [message, ...currentState.chatMessages];
|
|
||||||
|
|
||||||
chatRepository.emitNewMessageToChats(message);
|
|
||||||
emit(currentState.copyWith(chatMessages: messagesList, isLoading: isLoading));
|
|
||||||
EasyDebounce.debounce('searchChatRoom', const Duration(milliseconds: 300), () => sl<ChatsCubit>().getChatRooms());
|
|
||||||
});
|
|
||||||
|
|
||||||
typingSubscription = wsManager.clientTypingStream?.listen((event) {
|
typingSubscription = wsManager.clientTypingStream?.listen((event) {
|
||||||
emit((state as ChatRoomStateLoaded).copyWith(isTyping: true, isLoading: isLoading));
|
_emitLoaded(
|
||||||
|
(current) => current.copyWith(isTyping: true, isLoading: isLoading),
|
||||||
|
);
|
||||||
EasyDebounce.debounce(
|
EasyDebounce.debounce(
|
||||||
'client-typing',
|
'client-typing',
|
||||||
const Duration(seconds: 3),
|
const Duration(seconds: 3),
|
||||||
() => emit((state as ChatRoomStateLoaded).copyWith(isTyping: false, isLoading: isLoading)),
|
() => _emitLoaded(
|
||||||
|
(current) => current.copyWith(isTyping: false, isLoading: isLoading),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onDirectChatEvent(ChannelReadEvent event) {
|
||||||
|
try {
|
||||||
|
if (addIncomingMessage(
|
||||||
|
ChatMessageModel.fromJson(
|
||||||
|
jsonDecode(event.data) as Map<String, dynamic>,
|
||||||
|
),
|
||||||
|
)) {
|
||||||
|
unawaited(_playIncomingSound());
|
||||||
|
}
|
||||||
|
} catch (e, st) {
|
||||||
|
unawaited(reportFrom(e, st, this));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _playIncomingSound() async {
|
||||||
|
try {
|
||||||
|
await AudioPlayer().play(AssetSource('audio/recive_message.wav'));
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Добавляет входящее сообщение в текущий список, не затирая более новый state.
|
||||||
|
///
|
||||||
|
/// Собственное сообщение, пришедшее по сокету, — это эхо с другого устройства
|
||||||
|
/// автора (`author_id == wsManager.userId`). Такое сообщение тоже добавляется
|
||||||
|
/// в открытый чат, но не считается входящим: звук не играет, а список чатов
|
||||||
|
/// обновляет S3 через `new-chat-messages.{senderId}`.
|
||||||
|
///
|
||||||
|
/// Возвращает `true`, только если нужно проиграть звук входящего (чужое
|
||||||
|
/// сообщение, реально добавленное).
|
||||||
|
bool addIncomingMessage(ChatMessageModel message) {
|
||||||
|
final currentState = state;
|
||||||
|
if (currentState is! ChatRoomStateLoaded) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final currentUserId = wsManager.userId;
|
||||||
|
final isOwn = currentUserId != null && message.authorId == currentUserId;
|
||||||
|
|
||||||
|
// Дедуп: по серверному id и по uuid (если broadcast вернул наш uuid, т.е.
|
||||||
|
// это эхо уже добавленного оптимистичного сообщения).
|
||||||
|
final alreadyExists = currentState.chatMessages.any(
|
||||||
|
(existing) =>
|
||||||
|
(message.id != 0 && existing.id == message.id) ||
|
||||||
|
(message.uuid != null && existing.uuid == message.uuid),
|
||||||
|
);
|
||||||
|
if (alreadyExists) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final added = _emitLoaded((current) {
|
||||||
|
if (current.chatMessages.any(
|
||||||
|
(existing) =>
|
||||||
|
(message.id != 0 && existing.id == message.id) ||
|
||||||
|
(message.uuid != null && existing.uuid == message.uuid),
|
||||||
|
)) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
return current.copyWith(
|
||||||
|
chatMessages: [message, ...current.chatMessages],
|
||||||
|
isLoading: isLoading,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (!added) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Список чатов за собственные сообщения обновляет S3 (new-chat-messages.{senderId}).
|
||||||
|
if (!isOwn) {
|
||||||
|
chatRepository.emitNewMessageToChats(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return !isOwn;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _subscribeToPresenceChannel({required String chatId}) async {
|
Future<void> _subscribeToPresenceChannel({required String chatId}) async {
|
||||||
final currentState = state;
|
final currentState = state;
|
||||||
if (currentState is! ChatRoomStateLoaded) return;
|
if (currentState is! ChatRoomStateLoaded) return;
|
||||||
@@ -236,12 +447,15 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
|
|
||||||
await wsManager.initPresenceDirectChatChannel(chatId);
|
await wsManager.initPresenceDirectChatChannel(chatId);
|
||||||
_presenceOnlineListener = (isOnline) {
|
_presenceOnlineListener = (isOnline) {
|
||||||
unawaited(_handleCounterpartOnlineChanged(
|
unawaited(
|
||||||
counterpartField: counterpart.id == currentState.chatRoom.users.receiver.id
|
_handleCounterpartOnlineChanged(
|
||||||
? _ChatCounterpartField.receiver
|
counterpartField:
|
||||||
: _ChatCounterpartField.sender,
|
counterpart.id == currentState.chatRoom.users.receiver.id
|
||||||
isOnline: isOnline,
|
? _ChatCounterpartField.receiver
|
||||||
));
|
: _ChatCounterpartField.sender,
|
||||||
|
isOnline: isOnline,
|
||||||
|
),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
wsManager.addPresenceDirectChatListener(
|
wsManager.addPresenceDirectChatListener(
|
||||||
counterpartUserId: counterpart.id,
|
counterpartUserId: counterpart.id,
|
||||||
@@ -260,7 +474,8 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
? currentState.chatRoom.users.receiver
|
? currentState.chatRoom.users.receiver
|
||||||
: currentState.chatRoom.users.sender;
|
: currentState.chatRoom.users.sender;
|
||||||
|
|
||||||
if (counterpart.isOnline == isOnline && (isOnline || counterpart.lastSeenText.isNotEmpty)) {
|
if (counterpart.isOnline == isOnline &&
|
||||||
|
(isOnline || counterpart.lastSeenText.isNotEmpty)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,63 +552,55 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> sendMessage({String? data, List<File>? files, required String userSlug}) async {
|
Future<bool> sendMessage({
|
||||||
|
String? data,
|
||||||
|
List<File>? files,
|
||||||
|
required String userSlug,
|
||||||
|
}) async {
|
||||||
try {
|
try {
|
||||||
final messageUuid = uuid.v4();
|
final messageUuid = uuid.v4();
|
||||||
final compressedFiles = files != null ? await ImageCompressUtility.compressFilesList(List<File>.from(files)) : null;
|
final compressedFiles = files != null
|
||||||
AudioPlayer().play((AssetSource('audio/send_message.wav')));
|
? await ImageCompressUtility.compressFilesList(List<File>.from(files))
|
||||||
|
: null;
|
||||||
|
try {
|
||||||
|
unawaited(AudioPlayer().play(AssetSource('audio/send_message.wav')));
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
// Создаем временное сообщение с состоянием loading
|
_emitLoaded(
|
||||||
emit(
|
(current) => current.copyWith(
|
||||||
(state as ChatRoomStateLoaded).copyWith(
|
|
||||||
chatMessages: [
|
chatMessages: [
|
||||||
ChatMessageModel(
|
ChatMessageModel(
|
||||||
id: 1,
|
id: -messageUuid.hashCode.abs(),
|
||||||
data: data ?? '',
|
data: data ?? '',
|
||||||
mediaData: compressedFiles,
|
mediaData: compressedFiles,
|
||||||
authorId: 1,
|
authorId: wsManager.userId ?? 0,
|
||||||
updatedAt: DateTime.now(),
|
updatedAt: DateTime.now(),
|
||||||
isRead: true,
|
isRead: true,
|
||||||
isCurrentUserAuthor: true,
|
isCurrentUserAuthor: true,
|
||||||
roomId: '',
|
roomId: current.chatRoom.id,
|
||||||
messageState: MessageState.loading,
|
messageState: MessageState.loading,
|
||||||
uuid: messageUuid,
|
uuid: messageUuid,
|
||||||
),
|
),
|
||||||
...(state as ChatRoomStateLoaded).chatMessages,
|
...current.chatMessages,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Отправляем сообщение и получаем ответ от сервера с правильными данными
|
final serverMessage = await chatRepository.sendMessage(
|
||||||
final serverMessage = await chatRepository.sendMessage(data: data, files: compressedFiles, slug: userSlug);
|
data: data,
|
||||||
|
files: compressedFiles,
|
||||||
// Заменяем локальное сообщение данными с сервера (с правильными URL файлов)
|
slug: userSlug,
|
||||||
emit(
|
);
|
||||||
(state as ChatRoomStateLoaded).copyWith(
|
_replaceOptimisticMessage(
|
||||||
chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) {
|
messageUuid: messageUuid,
|
||||||
if (elem.uuid == messageUuid) {
|
serverMessage: serverMessage,
|
||||||
// Сохраняем UUID, состояние и обновляем данные из ответа сервера
|
|
||||||
// Используем значения из локального сообщения для полей, которые могут отсутствовать в ответе
|
|
||||||
return serverMessage.copyWith(
|
|
||||||
uuid: messageUuid,
|
|
||||||
messageState: MessageState.sent,
|
|
||||||
isCurrentUserAuthor: true,
|
|
||||||
isRead: elem.isRead,
|
|
||||||
roomId: elem.roomId ?? serverMessage.roomId,
|
|
||||||
authorId: serverMessage.authorId,
|
|
||||||
updatedAt: serverMessage.updatedAt,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return elem;
|
|
||||||
}).toList(),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
emit(
|
_emitLoaded(
|
||||||
(state as ChatRoomStateLoaded).copyWith(
|
(current) => current.copyWith(
|
||||||
chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) {
|
chatMessages: current.chatMessages.map((elem) {
|
||||||
if (elem.uuid == messageUuid) {
|
if (elem.uuid == messageUuid) {
|
||||||
return elem.copyWith(messageState: MessageState.error);
|
return elem.copyWith(messageState: MessageState.error);
|
||||||
}
|
}
|
||||||
@@ -411,21 +618,25 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> markMessagesAsRead(String chatId, List<({int id, int index})> messagesIds) async {
|
Future<bool> markMessagesAsRead(String chatId, List<int> messageIds) async {
|
||||||
try {
|
try {
|
||||||
if (state is ChatRoomStateLoaded) {
|
if (state is! ChatRoomStateLoaded) {
|
||||||
final ids = messagesIds.map((e) => e.id).toList();
|
return false;
|
||||||
await chatRepository.markMessagesAsRead(chatId, ids);
|
|
||||||
final currentMessages = [...(state as ChatRoomStateLoaded).chatMessages];
|
|
||||||
for (var elem in messagesIds) {
|
|
||||||
currentMessages[elem.index] = currentMessages[elem.index].copyWith(isRead: true);
|
|
||||||
}
|
|
||||||
if (!isClosed) {
|
|
||||||
emit((state as ChatRoomStateLoaded).copyWith(chatMessages: currentMessages));
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
return false;
|
await chatRepository.markMessagesAsRead(chatId, messageIds);
|
||||||
|
final idSet = messageIds.toSet();
|
||||||
|
_emitLoaded(
|
||||||
|
(current) => current.copyWith(
|
||||||
|
chatMessages: current.chatMessages
|
||||||
|
.map(
|
||||||
|
(message) => idSet.contains(message.id)
|
||||||
|
? message.copyWith(isRead: true)
|
||||||
|
: message,
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
log(e.toString(), stackTrace: st);
|
log(e.toString(), stackTrace: st);
|
||||||
@@ -433,11 +644,52 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _replaceOptimisticMessage({
|
||||||
|
required String messageUuid,
|
||||||
|
required ChatMessageModel serverMessage,
|
||||||
|
}) {
|
||||||
|
_emitLoaded((current) {
|
||||||
|
final sentMessage = serverMessage.copyWith(
|
||||||
|
uuid: messageUuid,
|
||||||
|
messageState: MessageState.sent,
|
||||||
|
isCurrentUserAuthor: true,
|
||||||
|
);
|
||||||
|
final withoutDuplicate = current.chatMessages.where(
|
||||||
|
(message) =>
|
||||||
|
message.id != serverMessage.id || message.uuid == messageUuid,
|
||||||
|
);
|
||||||
|
final hasOptimistic = withoutDuplicate.any(
|
||||||
|
(message) => message.uuid == messageUuid,
|
||||||
|
);
|
||||||
|
if (!hasOptimistic) {
|
||||||
|
return current.copyWith(
|
||||||
|
chatMessages: [sentMessage, ...withoutDuplicate],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return current.copyWith(
|
||||||
|
chatMessages: withoutDuplicate.map((elem) {
|
||||||
|
if (elem.uuid == messageUuid) {
|
||||||
|
return sentMessage.copyWith(
|
||||||
|
isRead: elem.isRead,
|
||||||
|
roomId: elem.roomId?.isNotEmpty == true
|
||||||
|
? elem.roomId
|
||||||
|
: serverMessage.roomId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return elem;
|
||||||
|
}).toList(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<String?> getPaymentLink() async {
|
Future<String?> getPaymentLink() async {
|
||||||
try {
|
try {
|
||||||
final planId = (state as ChatRoomStateLoaded).chatRoom.chosenPlan?.id;
|
final planId = (state as ChatRoomStateLoaded).chatRoom.chosenPlan?.id;
|
||||||
if (planId == null) return null;
|
if (planId == null) return null;
|
||||||
final paymentModel = await chatRepository.getPaymentLink(id: planId, paymentProvider: 'alfa-bank');
|
final paymentModel = await chatRepository.getPaymentLink(
|
||||||
|
id: planId,
|
||||||
|
paymentProvider: 'alfa-bank',
|
||||||
|
);
|
||||||
return paymentModel.paymentUrl;
|
return paymentModel.paymentUrl;
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
@@ -477,91 +729,93 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> close() async {
|
Future<void> close() async {
|
||||||
|
EasyDebounce.cancel('client-typing');
|
||||||
|
EasyDebounce.cancel('searchChatRoom');
|
||||||
if (_presenceOnlineListener != null) {
|
if (_presenceOnlineListener != null) {
|
||||||
wsManager.removePresenceDirectChatListener(_presenceOnlineListener!);
|
wsManager.removePresenceDirectChatListener(_presenceOnlineListener!);
|
||||||
_presenceOnlineListener = null;
|
_presenceOnlineListener = null;
|
||||||
}
|
}
|
||||||
wsManager.unsubscribeDirectChatChannel();
|
wsManager.unsubscribeDirectChatChannel();
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
messagesSubscription?.cancel();
|
await messagesSubscription?.cancel();
|
||||||
typingSubscription?.cancel();
|
await typingSubscription?.cancel();
|
||||||
|
await _reconnectSubscription?.cancel();
|
||||||
super.close();
|
super.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
void deleteMessage(String uuid) {
|
void deleteMessage(String uuid) {
|
||||||
final currentState = state;
|
final currentState = state;
|
||||||
if (currentState is ChatRoomStateLoaded) {
|
if (currentState is ChatRoomStateLoaded) {
|
||||||
emit(currentState.copyWith(chatMessages: currentState.chatMessages.where((message) => message.uuid != uuid).toList()));
|
emit(
|
||||||
|
currentState.copyWith(
|
||||||
|
chatMessages: currentState.chatMessages
|
||||||
|
.where((message) => message.uuid != uuid)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> resendMessage({required String uuid, required String userSlug}) async {
|
Future<void> resendMessage({
|
||||||
|
required String uuid,
|
||||||
|
required String userSlug,
|
||||||
|
}) async {
|
||||||
final currentState = state;
|
final currentState = state;
|
||||||
if (currentState is ChatRoomStateLoaded) {
|
if (currentState is ChatRoomStateLoaded) {
|
||||||
final message = currentState.chatMessages.firstWhereOrNull((message) => message.uuid == uuid);
|
final message = currentState.chatMessages.firstWhereOrNull(
|
||||||
|
(message) => message.uuid == uuid,
|
||||||
|
);
|
||||||
if (message != null) {
|
if (message != null) {
|
||||||
try {
|
try {
|
||||||
AudioPlayer().play((AssetSource('audio/send_message.wav')));
|
try {
|
||||||
|
unawaited(
|
||||||
|
AudioPlayer().play(AssetSource('audio/send_message.wav')),
|
||||||
|
);
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
// Определяем, есть ли файлы для повторной отправки
|
|
||||||
List<File>? filesToResend;
|
List<File>? filesToResend;
|
||||||
if (message.mediaData != null && message.mediaData!.isNotEmpty) {
|
if (message.mediaData != null && message.mediaData!.isNotEmpty) {
|
||||||
// Если mediaData содержит File объекты, используем их
|
|
||||||
// Если это строки (URL), то файлы уже на сервере и повторная отправка не нужна
|
|
||||||
final firstItem = message.mediaData!.first;
|
final firstItem = message.mediaData!.first;
|
||||||
if (firstItem is File) {
|
if (firstItem is File) {
|
||||||
filesToResend = message.mediaData!.cast<File>();
|
filesToResend = message.mediaData!.cast<File>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
emit(
|
_emitLoaded(
|
||||||
(state as ChatRoomStateLoaded).copyWith(
|
(current) => current.copyWith(
|
||||||
chatMessages: [
|
chatMessages: [
|
||||||
ChatMessageModel(
|
ChatMessageModel(
|
||||||
id: 1,
|
id: -uuid.hashCode.abs(),
|
||||||
data: message.data,
|
data: message.data,
|
||||||
mediaData: message.mediaData,
|
mediaData: message.mediaData,
|
||||||
authorId: 1,
|
authorId: wsManager.userId ?? 0,
|
||||||
updatedAt: DateTime.now(),
|
updatedAt: DateTime.now(),
|
||||||
isRead: true,
|
isRead: true,
|
||||||
isCurrentUserAuthor: true,
|
isCurrentUserAuthor: true,
|
||||||
roomId: '',
|
roomId: current.chatRoom.id,
|
||||||
messageState: MessageState.loading,
|
messageState: MessageState.loading,
|
||||||
uuid: uuid,
|
uuid: uuid,
|
||||||
),
|
),
|
||||||
...(state as ChatRoomStateLoaded).chatMessages.where((message) => message.uuid != uuid),
|
...current.chatMessages.where((item) => item.uuid != uuid),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Отправляем сообщение и получаем ответ от сервера
|
final serverMessage = await chatRepository.sendMessage(
|
||||||
final serverMessage = await chatRepository.sendMessage(data: message.data, files: filesToResend, slug: userSlug);
|
data: message.data,
|
||||||
|
files: filesToResend,
|
||||||
// Заменяем локальное сообщение данными с сервера
|
slug: userSlug,
|
||||||
emit(
|
);
|
||||||
(state as ChatRoomStateLoaded).copyWith(
|
_replaceOptimisticMessage(
|
||||||
chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) {
|
messageUuid: uuid,
|
||||||
if (elem.uuid == uuid) {
|
serverMessage: serverMessage,
|
||||||
return serverMessage.copyWith(
|
|
||||||
uuid: uuid,
|
|
||||||
messageState: MessageState.sent,
|
|
||||||
isCurrentUserAuthor: true,
|
|
||||||
isRead: elem.isRead,
|
|
||||||
roomId: elem.roomId ?? serverMessage.roomId,
|
|
||||||
authorId: serverMessage.authorId,
|
|
||||||
updatedAt: serverMessage.updatedAt,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return elem;
|
|
||||||
}).toList(),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
emit(
|
_emitLoaded(
|
||||||
(state as ChatRoomStateLoaded).copyWith(
|
(current) => current.copyWith(
|
||||||
chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) {
|
chatMessages: current.chatMessages.map((elem) {
|
||||||
if (elem.uuid == uuid) {
|
if (elem.uuid == uuid) {
|
||||||
return elem.copyWith(messageState: MessageState.error);
|
return elem.copyWith(messageState: MessageState.error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,20 @@ import 'dart:async';
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
|
|
||||||
|
import 'package:eatvio_app/core/device/device_hw_id_provider.dart';
|
||||||
import 'package:eatvio_app/core/enums/base_state.dart';
|
import 'package:eatvio_app/core/enums/base_state.dart';
|
||||||
import 'package:eatvio_app/core/websockets/ws_manager.dart';
|
import 'package:eatvio_app/core/websockets/ws_manager.dart';
|
||||||
import 'package:eatvio_app/features/chat/models/chat_message_model.dart';
|
import 'package:eatvio_app/features/chat/models/chat_message_model.dart';
|
||||||
|
import 'package:eatvio_app/features/chat/models/chat_room_pagination_model.dart';
|
||||||
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_state.dart';
|
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_state.dart';
|
||||||
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
|
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
|
||||||
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
|
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/services/video_call_chat_signal.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/services/video_call_end_reason.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_socket_signal.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/services/video_call_incoming_push_presenter.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||||
|
|
||||||
@@ -17,13 +25,31 @@ class ChatsCubit extends Cubit<ChatsState> {
|
|||||||
final WsManager wsManager;
|
final WsManager wsManager;
|
||||||
StreamSubscription? newIncomingMessagesSubscription;
|
StreamSubscription? newIncomingMessagesSubscription;
|
||||||
StreamSubscription? newLastMessageSubscription;
|
StreamSubscription? newLastMessageSubscription;
|
||||||
|
StreamSubscription<void>? reconnectedSubscription;
|
||||||
|
StreamSubscription? videoCallDeclinedSubscription;
|
||||||
|
StreamSubscription? videoCallEndedSubscription;
|
||||||
|
StreamSubscription? videoCallAcceptedSubscription;
|
||||||
|
final Set<int> _presentedVideoCallMessageIds = {};
|
||||||
|
|
||||||
|
/// Test seam. Production uses CallKit.
|
||||||
|
Future<void> Function(VideoCallPushPayload payload)? presentIncomingVideoCall;
|
||||||
|
|
||||||
|
/// Test seam. Production reads [DeviceHwIdProvider.current].
|
||||||
|
Future<String?> Function()? currentDeviceHwId;
|
||||||
|
Future<void> Function(String? conversationUuid)? dismissIncomingVideoCall;
|
||||||
|
Future<void> Function({String? conversationUuid, String? callId})?
|
||||||
|
endActiveVideoCall;
|
||||||
|
|
||||||
ChatsCubit({
|
ChatsCubit({
|
||||||
required this.chatRepository,
|
required this.chatRepository,
|
||||||
required this.wsManager,
|
required this.wsManager,
|
||||||
required this.profileRepository,
|
required this.profileRepository,
|
||||||
|
this.presentIncomingVideoCall,
|
||||||
|
this.dismissIncomingVideoCall,
|
||||||
}) : super(ChatsState()) {
|
}) : super(ChatsState()) {
|
||||||
newLastMessageSubscription = chatRepository.newUserMessagesStream.listen((message) {
|
newLastMessageSubscription = chatRepository.newUserMessagesStream.listen((
|
||||||
|
message,
|
||||||
|
) {
|
||||||
_setNewLastMessage(message);
|
_setNewLastMessage(message);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -34,9 +60,47 @@ class ChatsCubit extends Cubit<ChatsState> {
|
|||||||
Future<void> connectToWebSocket() async {
|
Future<void> connectToWebSocket() async {
|
||||||
try {
|
try {
|
||||||
await newIncomingMessagesSubscription?.cancel();
|
await newIncomingMessagesSubscription?.cancel();
|
||||||
newIncomingMessagesSubscription = wsManager.newMessagesStream?.listen((event) {
|
newIncomingMessagesSubscription = wsManager.newMessagesStream?.listen((
|
||||||
|
event,
|
||||||
|
) {
|
||||||
_handleNewMessageViaSocket(event.data);
|
_handleNewMessageViaSocket(event.data);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await videoCallDeclinedSubscription?.cancel();
|
||||||
|
videoCallDeclinedSubscription = wsManager.videoCallDeclinedStream?.listen(
|
||||||
|
(event) {
|
||||||
|
unawaited(
|
||||||
|
_closeCallFromSocket(
|
||||||
|
VideoCallSocketSignal.tryParseDeclined(event.data),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await videoCallEndedSubscription?.cancel();
|
||||||
|
videoCallEndedSubscription = wsManager.videoCallEndedStream?.listen((
|
||||||
|
event,
|
||||||
|
) {
|
||||||
|
unawaited(
|
||||||
|
_closeCallFromSocket(VideoCallSocketSignal.tryParseEnded(event.data)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await videoCallAcceptedSubscription?.cancel();
|
||||||
|
videoCallAcceptedSubscription = wsManager.videoCallAcceptedStream?.listen(
|
||||||
|
(event) {
|
||||||
|
unawaited(
|
||||||
|
_closeCallFromSocket(
|
||||||
|
VideoCallSocketSignal.tryParseEnded(event.data),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await reconnectedSubscription?.cancel();
|
||||||
|
reconnectedSubscription = wsManager.reconnected.listen((_) {
|
||||||
|
unawaited(getChatRooms());
|
||||||
|
});
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
}
|
}
|
||||||
@@ -47,7 +111,9 @@ class ChatsCubit extends Cubit<ChatsState> {
|
|||||||
_query = query;
|
_query = query;
|
||||||
emit(state.copyWith(state: BaseState.loading));
|
emit(state.copyWith(state: BaseState.loading));
|
||||||
try {
|
try {
|
||||||
final chatRoomsPaginationModel = await chatRepository.getChatRooms(query: query);
|
final chatRoomsPaginationModel = await chatRepository.getChatRooms(
|
||||||
|
query: query,
|
||||||
|
);
|
||||||
final unreadChats = chatRoomsPaginationModel.unreadChatsCount;
|
final unreadChats = chatRoomsPaginationModel.unreadChatsCount;
|
||||||
lastPage = chatRoomsPaginationModel.meta.lastPage + 1;
|
lastPage = chatRoomsPaginationModel.meta.lastPage + 1;
|
||||||
emit(
|
emit(
|
||||||
@@ -60,9 +126,7 @@ class ChatsCubit extends Cubit<ChatsState> {
|
|||||||
page++;
|
page++;
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
emit(
|
emit(state.copyWith(errorMessage: e.toString(), state: BaseState.error));
|
||||||
state.copyWith(errorMessage: e.toString(), state: BaseState.error),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +134,9 @@ class ChatsCubit extends Cubit<ChatsState> {
|
|||||||
page = 1;
|
page = 1;
|
||||||
_query = query;
|
_query = query;
|
||||||
try {
|
try {
|
||||||
final chatRoomsPaginationModel = await chatRepository.getChatRooms(query: query);
|
final chatRoomsPaginationModel = await chatRepository.getChatRooms(
|
||||||
|
query: query,
|
||||||
|
);
|
||||||
final unreadChats = chatRoomsPaginationModel.unreadChatsCount;
|
final unreadChats = chatRoomsPaginationModel.unreadChatsCount;
|
||||||
lastPage = chatRoomsPaginationModel.meta.lastPage + 1;
|
lastPage = chatRoomsPaginationModel.meta.lastPage + 1;
|
||||||
emit(
|
emit(
|
||||||
@@ -84,9 +150,7 @@ class ChatsCubit extends Cubit<ChatsState> {
|
|||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
log(e.toString(), stackTrace: st);
|
log(e.toString(), stackTrace: st);
|
||||||
emit(
|
emit(state.copyWith(errorMessage: e.toString(), state: BaseState.error));
|
||||||
state.copyWith(errorMessage: e.toString(), state: BaseState.error),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +158,10 @@ class ChatsCubit extends Cubit<ChatsState> {
|
|||||||
try {
|
try {
|
||||||
if (state.isLoadingMoreChats || lastPage == page) return;
|
if (state.isLoadingMoreChats || lastPage == page) return;
|
||||||
emit(state.copyWith(isLoadingMoreChats: true));
|
emit(state.copyWith(isLoadingMoreChats: true));
|
||||||
final chatRoomsPaginationModel = await chatRepository.getChatRooms(query: _query, page: page);
|
final chatRoomsPaginationModel = await chatRepository.getChatRooms(
|
||||||
|
query: _query,
|
||||||
|
page: page,
|
||||||
|
);
|
||||||
final unreadChats = chatRoomsPaginationModel.unreadChatsCount;
|
final unreadChats = chatRoomsPaginationModel.unreadChatsCount;
|
||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
@@ -134,15 +201,21 @@ class ChatsCubit extends Cubit<ChatsState> {
|
|||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
chatRooms: updatedChatRooms,
|
chatRooms: updatedChatRooms,
|
||||||
chatsWithUnreadMessagesCount: isChatRead ? state.chatsWithUnreadMessagesCount - 1 : state.chatsWithUnreadMessagesCount,
|
chatsWithUnreadMessagesCount: isChatRead
|
||||||
|
? state.chatsWithUnreadMessagesCount - 1
|
||||||
|
: state.chatsWithUnreadMessagesCount,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleNewMessageViaSocket(dynamic data) async {
|
Future<void> _handleNewMessageViaSocket(dynamic data) async {
|
||||||
try {
|
try {
|
||||||
final json = jsonDecode(data);
|
final json = jsonDecode(data) as Map<String, dynamic>;
|
||||||
final message = ChatMessageModel.fromJson(json['message']);
|
final message = ChatMessageModel.fromJson(json);
|
||||||
|
final callId = _stringField(json, const ['call_id', 'callId']);
|
||||||
|
|
||||||
|
final currentUserId = wsManager.userId;
|
||||||
|
final isOwn = currentUserId != null && message.authorId == currentUserId;
|
||||||
|
|
||||||
final currentChatRooms = [...state.chatRooms];
|
final currentChatRooms = [...state.chatRooms];
|
||||||
|
|
||||||
@@ -151,10 +224,21 @@ class ChatsCubit extends Cubit<ChatsState> {
|
|||||||
final updatedChatRooms = currentChatRooms.map((chatRoom) {
|
final updatedChatRooms = currentChatRooms.map((chatRoom) {
|
||||||
if (chatRoom.id == message.roomId) {
|
if (chatRoom.id == message.roomId) {
|
||||||
wasChanged = true;
|
wasChanged = true;
|
||||||
if (chatRoom.unreadMessagesCount == 0) {
|
// Идемпотентность: повторная доставка того же message.id (реконнект,
|
||||||
|
// доставка по нескольким каналам) не начисляет непрочитанное дважды.
|
||||||
|
final isDuplicate =
|
||||||
|
message.id != 0 && chatRoom.lastMessage?.id == message.id;
|
||||||
|
// Собственное сообщение (второе устройство автора) обновляет
|
||||||
|
// lastMessage, но не создаёт непрочитанное (S3).
|
||||||
|
if (!isOwn && !isDuplicate && chatRoom.unreadMessagesCount == 0) {
|
||||||
willIncrementUnreadChats = true;
|
willIncrementUnreadChats = true;
|
||||||
}
|
}
|
||||||
return chatRoom.copyWith(lastMessage: message, unreadMessagesCount: chatRoom.unreadMessagesCount + 1);
|
return chatRoom.copyWith(
|
||||||
|
lastMessage: message,
|
||||||
|
unreadMessagesCount: isOwn || isDuplicate
|
||||||
|
? chatRoom.unreadMessagesCount
|
||||||
|
: chatRoom.unreadMessagesCount + 1,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return chatRoom;
|
return chatRoom;
|
||||||
}).toList();
|
}).toList();
|
||||||
@@ -165,15 +249,154 @@ class ChatsCubit extends Cubit<ChatsState> {
|
|||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
chatRooms: updatedChatRooms,
|
chatRooms: updatedChatRooms,
|
||||||
chatsWithUnreadMessagesCount: willIncrementUnreadChats ? state.chatsWithUnreadMessagesCount + 1 : state.chatsWithUnreadMessagesCount,
|
chatsWithUnreadMessagesCount: willIncrementUnreadChats
|
||||||
|
? state.chatsWithUnreadMessagesCount + 1
|
||||||
|
: state.chatsWithUnreadMessagesCount,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_presentVideoCallFromMessage(
|
||||||
|
message,
|
||||||
|
isOwn: isOwn,
|
||||||
|
chatRooms: currentChatRooms,
|
||||||
|
callId: callId,
|
||||||
|
);
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _presentVideoCallFromMessage(
|
||||||
|
ChatMessageModel message, {
|
||||||
|
required bool isOwn,
|
||||||
|
required List<ChatRoom> chatRooms,
|
||||||
|
String? callId,
|
||||||
|
}) {
|
||||||
|
final kind = VideoCallChatSignal.kindOf(message);
|
||||||
|
if (kind == VideoCallChatSignalKind.ended) {
|
||||||
|
unawaited(_dismissIncomingVideoCallUi(message.roomId));
|
||||||
|
unawaited(
|
||||||
|
VideoCallEndedPushHandler.endActiveCallIfMatches(
|
||||||
|
conversationUuid: message.roomId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isOwn || kind != VideoCallChatSignalKind.started) return;
|
||||||
|
if (message.id != 0 && !_presentedVideoCallMessageIds.add(message.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final caller = _callerFromRooms(message, chatRooms);
|
||||||
|
final payload = VideoCallChatSignal.incomingPayload(
|
||||||
|
message: message,
|
||||||
|
callerName: caller?.name,
|
||||||
|
callerAvatarUrl: caller?.profilePhotoUrl,
|
||||||
|
callId: callId,
|
||||||
|
);
|
||||||
|
if (payload == null) return;
|
||||||
|
unawaited(_showIncomingVideoCall(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showIncomingVideoCall(VideoCallPushPayload payload) async {
|
||||||
|
try {
|
||||||
|
await (presentIncomingVideoCall ??
|
||||||
|
VideoCallIncomingPushPresenter.showIncomingCall)(payload);
|
||||||
|
} catch (e, st) {
|
||||||
|
log('ChatsCubit: failed to show incoming video call: $e', stackTrace: st);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _closeCallFromSocket(VideoCallSocketSignal? signal) async {
|
||||||
|
if (signal == null) return;
|
||||||
|
if (await _acceptedOnThisDevice(signal)) return;
|
||||||
|
if (signal.endsActiveCall) {
|
||||||
|
final endActive = endActiveVideoCall;
|
||||||
|
if (endActive != null) {
|
||||||
|
await endActive(
|
||||||
|
conversationUuid: signal.conversationUuid,
|
||||||
|
callId: signal.callId,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await VideoCallEndedPushHandler.endActiveCallIfMatches(
|
||||||
|
conversationUuid: signal.conversationUuid,
|
||||||
|
callId: signal.callId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final dismiss = dismissIncomingVideoCall;
|
||||||
|
if (dismiss != null) {
|
||||||
|
await dismiss(signal.conversationUuid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await VideoCallEndedPushHandler.dismissIncomingCallUi(
|
||||||
|
conversationUuid: signal.conversationUuid,
|
||||||
|
callId: signal.callId,
|
||||||
|
reason: signal.reason,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _dismissIncomingVideoCallUi(String? conversationUuid) async {
|
||||||
|
try {
|
||||||
|
final dismiss = dismissIncomingVideoCall;
|
||||||
|
if (dismiss != null) {
|
||||||
|
await dismiss(conversationUuid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await VideoCallEndedPushHandler.dismissIncomingCallUi(
|
||||||
|
conversationUuid: conversationUuid,
|
||||||
|
);
|
||||||
|
} catch (e, st) {
|
||||||
|
log(
|
||||||
|
'ChatsCubit: failed to dismiss incoming video call: $e',
|
||||||
|
stackTrace: st,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `video-call-accepted` also reaches the device that just answered.
|
||||||
|
/// Closing CallKit there hangs up the call that is starting.
|
||||||
|
Future<bool> _acceptedOnThisDevice(VideoCallSocketSignal signal) async {
|
||||||
|
if (signal.reason != VideoCallEndReason.accepted) return false;
|
||||||
|
final acceptedId = signal.acceptedDeviceId?.trim();
|
||||||
|
if (acceptedId == null || acceptedId.isEmpty) return false;
|
||||||
|
try {
|
||||||
|
final current =
|
||||||
|
(await (currentDeviceHwId ?? DeviceHwIdProvider.current)())?.trim();
|
||||||
|
if (current == null || current.isEmpty) return false;
|
||||||
|
return current.toLowerCase() == acceptedId.toLowerCase();
|
||||||
|
} catch (e, st) {
|
||||||
|
log(
|
||||||
|
'ChatsCubit: failed to read hw id for accepted call: $e',
|
||||||
|
stackTrace: st,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _stringField(Map<String, dynamic> json, List<String> keys) {
|
||||||
|
for (final key in keys) {
|
||||||
|
final value = json[key];
|
||||||
|
if (value is String && value.trim().isNotEmpty) return value.trim();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
ChatUser? _callerFromRooms(
|
||||||
|
ChatMessageModel message,
|
||||||
|
List<ChatRoom> chatRooms,
|
||||||
|
) {
|
||||||
|
for (final room in chatRooms) {
|
||||||
|
if (room.id != message.roomId) continue;
|
||||||
|
final from = room.users.fromChatUser;
|
||||||
|
final to = room.users.toChatUser;
|
||||||
|
if (from.id == message.authorId) return from;
|
||||||
|
if (to.id == message.authorId) return to;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
void _setNewLastMessage(ChatMessageModel message) {
|
void _setNewLastMessage(ChatMessageModel message) {
|
||||||
final chats = state.chatRooms.map((chat) {
|
final chats = state.chatRooms.map((chat) {
|
||||||
if (chat.id == message.roomId) {
|
if (chat.id == message.roomId) {
|
||||||
@@ -188,6 +411,10 @@ class ChatsCubit extends Cubit<ChatsState> {
|
|||||||
@override
|
@override
|
||||||
Future<void> close() {
|
Future<void> close() {
|
||||||
newIncomingMessagesSubscription?.cancel();
|
newIncomingMessagesSubscription?.cancel();
|
||||||
|
videoCallDeclinedSubscription?.cancel();
|
||||||
|
videoCallEndedSubscription?.cancel();
|
||||||
|
videoCallAcceptedSubscription?.cancel();
|
||||||
|
reconnectedSubscription?.cancel();
|
||||||
return super.close();
|
return super.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
|||||||
import 'package:eatvio_app/features/order_info/presentation/info_by_order_screen.dart';
|
import 'package:eatvio_app/features/order_info/presentation/info_by_order_screen.dart';
|
||||||
import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart';
|
import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart';
|
||||||
import 'package:eatvio_app/features/user_details/presentation/user_details_screen.dart';
|
import 'package:eatvio_app/features/user_details/presentation/user_details_screen.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart';
|
||||||
import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart';
|
import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart';
|
||||||
import 'package:eatvio_app/generated/l10n.dart';
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
@@ -46,7 +47,12 @@ class ChatScreen extends StatefulWidget {
|
|||||||
final String? orderId;
|
final String? orderId;
|
||||||
final bool? sendInitialMessage;
|
final bool? sendInitialMessage;
|
||||||
|
|
||||||
const ChatScreen({this.uuid, this.orderId, super.key, required this.sendInitialMessage});
|
const ChatScreen({
|
||||||
|
this.uuid,
|
||||||
|
this.orderId,
|
||||||
|
super.key,
|
||||||
|
required this.sendInitialMessage,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ChatScreen> createState() => _ChatScreenState();
|
State<ChatScreen> createState() => _ChatScreenState();
|
||||||
@@ -56,9 +62,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
final scrollController = ScrollController();
|
final scrollController = ScrollController();
|
||||||
final TextEditingController textEditingController = TextEditingController();
|
final TextEditingController textEditingController = TextEditingController();
|
||||||
final ChatRoomCubit chatRoomCubit = sl<ChatRoomCubit>();
|
final ChatRoomCubit chatRoomCubit = sl<ChatRoomCubit>();
|
||||||
final List<({int id, int index})> unreadMessagesIds = [];
|
final List<int> unreadMessageIds = [];
|
||||||
final List<({int id, int index})> unreadMessagesIdsCopy = [];
|
final showableChatBadgeStates =
|
||||||
final showableChatBadgeStates = ChosenPlanStateEnum.getShowableChatBadgeStates();
|
ChosenPlanStateEnum.getShowableChatBadgeStates();
|
||||||
late AppLifecycleListener _listener;
|
late AppLifecycleListener _listener;
|
||||||
List<File> pickedFiles = [];
|
List<File> pickedFiles = [];
|
||||||
|
|
||||||
@@ -109,13 +115,15 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
void _onScroll() {
|
void _onScroll() {
|
||||||
final uuid = conversationUuid;
|
final uuid = conversationUuid;
|
||||||
if (uuid == null) return;
|
if (uuid == null) return;
|
||||||
if (scrollController.offset >= scrollController.position.maxScrollExtent && !scrollController.position.outOfRange) {
|
if (scrollController.offset >= scrollController.position.maxScrollExtent &&
|
||||||
|
!scrollController.position.outOfRange) {
|
||||||
chatRoomCubit.loadNextPage(uuid);
|
chatRoomCubit.loadNextPage(uuid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
EasyDebounce.cancel('mark_as_read');
|
||||||
scrollController.dispose();
|
scrollController.dispose();
|
||||||
_listener.dispose();
|
_listener.dispose();
|
||||||
chatRoomCubit.close();
|
chatRoomCubit.close();
|
||||||
@@ -168,9 +176,13 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Scaffold _buildChat(BuildContext context, ChatRoomStateLoaded state) {
|
Scaffold _buildChat(BuildContext context, ChatRoomStateLoaded state) {
|
||||||
final chatCounterpart = resolveChatCounterpart(users: state.chatRoom.users, currentUserId: sl<WsManager>().userId);
|
final chatCounterpart = resolveChatCounterpart(
|
||||||
|
users: state.chatRoom.users,
|
||||||
|
currentUserId: sl<WsManager>().userId,
|
||||||
|
);
|
||||||
final chosenPlan = state.chatRoom.chosenPlan;
|
final chosenPlan = state.chatRoom.chosenPlan;
|
||||||
final amIConsultant = chosenPlan?.consultantId == state.chatRoom.users.sender.id;
|
final amIConsultant =
|
||||||
|
chosenPlan?.consultantId == state.chatRoom.users.sender.id;
|
||||||
final showUserDetailsButton = [
|
final showUserDetailsButton = [
|
||||||
ChosenPlanStateEnum.stateStartDateHasBeenSet,
|
ChosenPlanStateEnum.stateStartDateHasBeenSet,
|
||||||
ChosenPlanStateEnum.statePendingPayment,
|
ChosenPlanStateEnum.statePendingPayment,
|
||||||
@@ -186,22 +198,37 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final showAboutOrderButton =
|
final showAboutOrderButton =
|
||||||
state.chatRoom.chosenPlan != null && state.chatRoom.chosenPlan!.chosenPlanState != ChosenPlanStateEnum.stateCanceledByTheInitiator;
|
state.chatRoom.chosenPlan != null &&
|
||||||
|
state.chatRoom.chosenPlan!.chosenPlanState !=
|
||||||
|
ChosenPlanStateEnum.stateCanceledByTheInitiator;
|
||||||
BannerType? bannerType;
|
BannerType? bannerType;
|
||||||
if (chosenPlan == null || chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateCanceledByTheInitiator) {
|
if (chosenPlan == null ||
|
||||||
|
chosenPlan.chosenPlanState ==
|
||||||
|
ChosenPlanStateEnum.stateCanceledByTheInitiator) {
|
||||||
bannerType = null;
|
bannerType = null;
|
||||||
} else if (amIConsultant && !state.hasCard && chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateInCoordination) {
|
} else if (amIConsultant &&
|
||||||
|
!state.hasCard &&
|
||||||
|
chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateInCoordination) {
|
||||||
bannerType = BannerType.card;
|
bannerType = BannerType.card;
|
||||||
} else if (amIConsultant && chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateInCoordination) {
|
} else if (amIConsultant &&
|
||||||
|
chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateInCoordination) {
|
||||||
bannerType = BannerType.date;
|
bannerType = BannerType.date;
|
||||||
} else if (!amIConsultant &&
|
} else if (!amIConsultant &&
|
||||||
(chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateStartDateHasBeenSet || chosenPlan.chosenPlanState == ChosenPlanStateEnum.statePendingPayment)) {
|
(chosenPlan.chosenPlanState ==
|
||||||
|
ChosenPlanStateEnum.stateStartDateHasBeenSet ||
|
||||||
|
chosenPlan.chosenPlanState ==
|
||||||
|
ChosenPlanStateEnum.statePendingPayment)) {
|
||||||
bannerType = BannerType.payment;
|
bannerType = BannerType.payment;
|
||||||
} else if (chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateDone && amIConsultant) {
|
} else if (chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateDone &&
|
||||||
|
amIConsultant) {
|
||||||
bannerType = BannerType.collectCharge;
|
bannerType = BannerType.collectCharge;
|
||||||
} else if (chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateLastDayFinished && !amIConsultant) {
|
} else if (chosenPlan.chosenPlanState ==
|
||||||
|
ChosenPlanStateEnum.stateLastDayFinished &&
|
||||||
|
!amIConsultant) {
|
||||||
bannerType = BannerType.finishPlan;
|
bannerType = BannerType.finishPlan;
|
||||||
} else if (chosenOption?.hasPersonalDiet == true && amIConsultant && chosenPlan.chosenPlanState != ChosenPlanStateEnum.stateDone) {
|
} else if (chosenOption?.hasPersonalDiet == true &&
|
||||||
|
amIConsultant &&
|
||||||
|
chosenPlan.chosenPlanState != ChosenPlanStateEnum.stateDone) {
|
||||||
bannerType = BannerType.setPersonalDiet;
|
bannerType = BannerType.setPersonalDiet;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,25 +250,40 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
|
|
||||||
title: GestureDetector(
|
title: GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
GoRouter.of(context).pushNamed('profileScreen', extra: state.chatRoom.users.receiver.slug);
|
GoRouter.of(context).pushNamed(
|
||||||
|
'profileScreen',
|
||||||
|
extra: state.chatRoom.users.receiver.slug,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
if (state.chatRoom.users.receiver.profilePhotoUrl != null)
|
if (state.chatRoom.users.receiver.profilePhotoUrl != null)
|
||||||
CircleAvatar(
|
CircleAvatar(
|
||||||
radius: 20,
|
radius: 20,
|
||||||
foregroundImage: NetworkImage(state.chatRoom.users.receiver.profilePhotoUrl!),
|
foregroundImage: NetworkImage(
|
||||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
state.chatRoom.users.receiver.profilePhotoUrl!,
|
||||||
|
),
|
||||||
|
backgroundImage: const AssetImage(
|
||||||
|
'assets/images/no_avatar.png',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (state.chatRoom.users.receiver.profilePhotoUrl == null)
|
if (state.chatRoom.users.receiver.profilePhotoUrl == null)
|
||||||
Container(
|
Container(
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
decoration: const BoxDecoration(color: Color(0xFFEBF4FF), shape: BoxShape.circle),
|
decoration: const BoxDecoration(
|
||||||
|
color: Color(0xFFEBF4FF),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
state.chatRoom.users.receiver.name.split(' ').first[0] + state.chatRoom.users.receiver.name.split(' ')[1][0],
|
state.chatRoom.users.receiver.name.split(' ').first[0] +
|
||||||
style: const TextStyle(color: Color(0xFF7F9CF5), fontWeight: FontWeight.w400, fontSize: 20),
|
state.chatRoom.users.receiver.name.split(' ')[1][0],
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Color(0xFF7F9CF5),
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
fontSize: 20,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
@@ -253,10 +295,17 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
state.chatRoom.users.receiver.name,
|
state.chatRoom.users.receiver.name,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: CustomTextStyles.titleSmallBold.copyWith(color: PrimaryColors().black),
|
style: CustomTextStyles.titleSmallBold.copyWith(
|
||||||
|
color: PrimaryColors().black,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
AnimatedCrossFade(
|
AnimatedCrossFade(
|
||||||
firstChild: Text('Печатает...', maxLines: 1, overflow: TextOverflow.ellipsis, style: CustomTextStyles.bodySmallMulishPrimary),
|
firstChild: Text(
|
||||||
|
'Печатает...',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: CustomTextStyles.bodySmallMulishPrimary,
|
||||||
|
),
|
||||||
firstCurve: Curves.easeIn,
|
firstCurve: Curves.easeIn,
|
||||||
secondCurve: Curves.easeOut,
|
secondCurve: Curves.easeOut,
|
||||||
secondChild: ChatUserOnlineStatusLine(
|
secondChild: ChatUserOnlineStatusLine(
|
||||||
@@ -265,7 +314,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
style: CustomTextStyles.bodySmallMulishPrimary,
|
style: CustomTextStyles.bodySmallMulishPrimary,
|
||||||
showOnlineDot: false,
|
showOnlineDot: false,
|
||||||
),
|
),
|
||||||
crossFadeState: state.isTyping ? CrossFadeState.showFirst : CrossFadeState.showSecond,
|
crossFadeState: state.isTyping
|
||||||
|
? CrossFadeState.showFirst
|
||||||
|
: CrossFadeState.showSecond,
|
||||||
duration: const Duration(milliseconds: 150),
|
duration: const Duration(milliseconds: 150),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -277,7 +328,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
actions: [
|
actions: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsetsDirectional.only(end: 8),
|
padding: const EdgeInsetsDirectional.only(end: 8),
|
||||||
child: _ChatHeaderVideoCallButton(onPressed: () => _openVideoCall(context, state, chatCounterpart)),
|
child: _ChatHeaderVideoCallButton(
|
||||||
|
onPressed: () => _openVideoCall(context, state, chatCounterpart),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -291,44 +344,133 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
child: Container(
|
child: Container(
|
||||||
color: appTheme.whiteA700,
|
color: appTheme.whiteA700,
|
||||||
width: double.maxFinite,
|
width: double.maxFinite,
|
||||||
child: SingleChildScrollView(
|
child: RefreshIndicator(
|
||||||
physics: const BouncingScrollPhysics(),
|
onRefresh: chatRoomCubit.refreshChat,
|
||||||
controller: scrollController,
|
child: SingleChildScrollView(
|
||||||
reverse: true,
|
physics: const AlwaysScrollableScrollPhysics(
|
||||||
child: Column(
|
parent: BouncingScrollPhysics(),
|
||||||
children: [
|
),
|
||||||
const SizedBox(height: 16),
|
controller: scrollController,
|
||||||
ListView.builder(
|
reverse: true,
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
child: Column(
|
||||||
reverse: true,
|
children: [
|
||||||
shrinkWrap: true,
|
const SizedBox(height: 16),
|
||||||
itemBuilder: (context, index) {
|
ListView.builder(
|
||||||
if (index == state.chatMessages.length) {
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
return const Padding(padding: EdgeInsets.all(8), child: Loader());
|
reverse: true,
|
||||||
}
|
shrinkWrap: true,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
if (index == state.chatMessages.length) {
|
||||||
|
return const Padding(
|
||||||
|
padding: EdgeInsets.all(8),
|
||||||
|
child: Loader(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if ((index + 1 != state.chatMessages.length &&
|
if ((index + 1 != state.chatMessages.length &&
|
||||||
state.chatMessages[index].updatedAt.day != state.chatMessages[index + 1].updatedAt.day) ||
|
state
|
||||||
index + 1 == state.chatMessages.length) {
|
.chatMessages[index]
|
||||||
final isCurrentUserAuthor = state.chatRoom.users.receiver.id != state.chatMessages[index].authorId;
|
.updatedAt
|
||||||
|
.day !=
|
||||||
|
state
|
||||||
|
.chatMessages[index + 1]
|
||||||
|
.updatedAt
|
||||||
|
.day) ||
|
||||||
|
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 == ChatMessageType.planOrder;
|
||||||
|
|
||||||
|
PacketOptions? messageOption;
|
||||||
|
if (isPlanOrderMessage &&
|
||||||
|
chosenPlan != null) {
|
||||||
|
if (chosenPlan.optionType == 'options1') {
|
||||||
|
messageOption =
|
||||||
|
chosenPlan.plan.packet1Options;
|
||||||
|
} else if (chosenPlan.optionType ==
|
||||||
|
'options2') {
|
||||||
|
messageOption =
|
||||||
|
chosenPlan.plan.packet2Options;
|
||||||
|
} else if (chosenPlan.optionType ==
|
||||||
|
'options3') {
|
||||||
|
messageOption =
|
||||||
|
chosenPlan.plan.packet3Options;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
_messageDate(message.updatedAt),
|
||||||
|
if (isPlanOrderMessage &&
|
||||||
|
chosenPlan != null &&
|
||||||
|
messageOption != null)
|
||||||
|
PlanOrderMessageWidget(
|
||||||
|
planName: chosenPlan.plan.name,
|
||||||
|
planSlug: chosenPlan.plan.slug,
|
||||||
|
price: messageOption.price,
|
||||||
|
sendTime: message.updatedAt,
|
||||||
|
isCurrentUser: isCurrentUserAuthor,
|
||||||
|
),
|
||||||
|
VisibilityDetector(
|
||||||
|
key: ValueKey(
|
||||||
|
'msg-${message.uuid ?? message.id}',
|
||||||
|
),
|
||||||
|
onVisibilityChanged:
|
||||||
|
(VisibilityInfo info) {
|
||||||
|
_queueMarkAsRead(
|
||||||
|
context: context,
|
||||||
|
chatId: state.chatRoom.id,
|
||||||
|
message: message,
|
||||||
|
isCurrentUserAuthor:
|
||||||
|
isCurrentUserAuthor,
|
||||||
|
visibleFraction:
|
||||||
|
info.visibleFraction,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: _buildMessageBubble(
|
||||||
|
context: context,
|
||||||
|
state: state,
|
||||||
|
chatCounterpart: chatCounterpart,
|
||||||
|
isCurrentUserAuthor:
|
||||||
|
isCurrentUserAuthor,
|
||||||
|
message: message,
|
||||||
|
amIConsultant: amIConsultant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final isCurrentUserAuthor =
|
||||||
|
state.chatRoom.users.receiver.id !=
|
||||||
|
state.chatMessages[index].authorId;
|
||||||
final message = state.chatMessages[index];
|
final message = state.chatMessages[index];
|
||||||
final isPlanOrderMessage = message.type == ChatMessageType.planOrder;
|
final isPlanOrderMessage =
|
||||||
|
message.type == ChatMessageType.planOrder;
|
||||||
|
|
||||||
PacketOptions? messageOption;
|
PacketOptions? messageOption;
|
||||||
if (isPlanOrderMessage && chosenPlan != null) {
|
if (isPlanOrderMessage && chosenPlan != null) {
|
||||||
if (chosenPlan.optionType == 'options1') {
|
if (chosenPlan.optionType == 'options1') {
|
||||||
messageOption = chosenPlan.plan.packet1Options;
|
messageOption =
|
||||||
} else if (chosenPlan.optionType == 'options2') {
|
chosenPlan.plan.packet1Options;
|
||||||
messageOption = chosenPlan.plan.packet2Options;
|
} else if (chosenPlan.optionType ==
|
||||||
} else if (chosenPlan.optionType == 'options3') {
|
'options2') {
|
||||||
messageOption = chosenPlan.plan.packet3Options;
|
messageOption =
|
||||||
|
chosenPlan.plan.packet2Options;
|
||||||
|
} else if (chosenPlan.optionType ==
|
||||||
|
'options3') {
|
||||||
|
messageOption =
|
||||||
|
chosenPlan.plan.packet3Options;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
_messageDate(message.updatedAt),
|
if (isPlanOrderMessage &&
|
||||||
if (isPlanOrderMessage && chosenPlan != null && messageOption != null)
|
chosenPlan != null &&
|
||||||
|
messageOption != null)
|
||||||
PlanOrderMessageWidget(
|
PlanOrderMessageWidget(
|
||||||
planName: chosenPlan.plan.name,
|
planName: chosenPlan.plan.name,
|
||||||
planSlug: chosenPlan.plan.slug,
|
planSlug: chosenPlan.plan.slug,
|
||||||
@@ -337,126 +479,94 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
isCurrentUser: isCurrentUserAuthor,
|
isCurrentUser: isCurrentUserAuthor,
|
||||||
),
|
),
|
||||||
VisibilityDetector(
|
VisibilityDetector(
|
||||||
key: Key(message.id.toString()),
|
key: ValueKey(
|
||||||
onVisibilityChanged: (VisibilityInfo info) {
|
'msg-${message.uuid ?? message.id}',
|
||||||
if (!message.isRead && !isCurrentUserAuthor && info.visibleFraction > 0) {
|
),
|
||||||
unreadMessagesIdsCopy.add((id: message.id, index: index));
|
onVisibilityChanged:
|
||||||
EasyDebounce.debounce('mark_as_read', const Duration(milliseconds: 500), () async {
|
(VisibilityInfo info) {
|
||||||
final result = await context.read<ChatRoomCubit>().markMessagesAsRead(state.chatRoom.id, unreadMessagesIdsCopy);
|
_queueMarkAsRead(
|
||||||
if (!result) return;
|
context: context,
|
||||||
if (!context.mounted) return;
|
|
||||||
context.read<ChatsCubit>().reduceUnreadMessagesCount(
|
|
||||||
chatId: state.chatRoom.id,
|
chatId: state.chatRoom.id,
|
||||||
count: unreadMessagesIdsCopy.length,
|
message: message,
|
||||||
|
isCurrentUserAuthor:
|
||||||
|
isCurrentUserAuthor,
|
||||||
|
visibleFraction:
|
||||||
|
info.visibleFraction,
|
||||||
);
|
);
|
||||||
unreadMessagesIdsCopy.clear();
|
},
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: _buildMessageBubble(
|
child: _buildMessageBubble(
|
||||||
context: context,
|
context: context,
|
||||||
state: state,
|
state: state,
|
||||||
chatCounterpart: chatCounterpart,
|
chatCounterpart: chatCounterpart,
|
||||||
isCurrentUserAuthor: isCurrentUserAuthor,
|
isCurrentUserAuthor:
|
||||||
|
isCurrentUserAuthor,
|
||||||
message: message,
|
message: message,
|
||||||
amIConsultant: amIConsultant,
|
amIConsultant: amIConsultant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
final isCurrentUserAuthor = state.chatRoom.users.receiver.id != state.chatMessages[index].authorId;
|
itemCount:
|
||||||
final message = state.chatMessages[index];
|
state.chatMessages.length +
|
||||||
final isPlanOrderMessage = message.type == ChatMessageType.planOrder;
|
(state.isLoading ? 1 : 0),
|
||||||
|
),
|
||||||
PacketOptions? messageOption;
|
const SizedBox(height: 16),
|
||||||
if (isPlanOrderMessage && chosenPlan != null) {
|
if (showUserDetailsButton) ...[
|
||||||
if (chosenPlan.optionType == 'options1') {
|
// OrderCard(
|
||||||
messageOption = chosenPlan.plan.packet1Options;
|
// plan: chosenPlan.plan,
|
||||||
} else if (chosenPlan.optionType == 'options2') {
|
// isCustomerView: !amIConsultant,
|
||||||
messageOption = chosenPlan.plan.packet2Options;
|
// optionType: chosenPlan.optionType,
|
||||||
} else if (chosenPlan.optionType == 'options3') {
|
// onCancel: () async {
|
||||||
messageOption = chosenPlan.plan.packet3Options;
|
// await context.read<ChatRoomCubit>().cancelChosenPlan(chatId: state.chatRoom.id, planId: chosenPlan.id);
|
||||||
}
|
// },
|
||||||
}
|
// ),
|
||||||
|
if (amIConsultant)
|
||||||
return Column(
|
Padding(
|
||||||
children: [
|
padding: const EdgeInsets.symmetric(
|
||||||
if (isPlanOrderMessage && chosenPlan != null && messageOption != null)
|
vertical: 12,
|
||||||
PlanOrderMessageWidget(
|
horizontal: 16,
|
||||||
planName: chosenPlan.plan.name,
|
),
|
||||||
planSlug: chosenPlan.plan.slug,
|
child: CustomOutlinedButton(
|
||||||
price: messageOption.price,
|
text: S.current.user_info,
|
||||||
sendTime: message.updatedAt,
|
height: 52,
|
||||||
isCurrentUser: isCurrentUserAuthor,
|
buttonTextStyle: TextStyles.textMSemibold
|
||||||
|
.copyWith(color: appTheme.whiteA700),
|
||||||
|
buttonStyle: ButtonStyle(
|
||||||
|
side: const WidgetStatePropertyAll(
|
||||||
|
BorderSide.none,
|
||||||
|
),
|
||||||
|
backgroundColor: WidgetStatePropertyAll(
|
||||||
|
appTheme.blueA400,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
VisibilityDetector(
|
onPressed: () async {
|
||||||
key: Key(message.id.toString()),
|
GoRouter.of(context).push(
|
||||||
onVisibilityChanged: (VisibilityInfo info) {
|
UserDetailsScreen.path,
|
||||||
if (!message.isRead && !isCurrentUserAuthor && info.visibleFraction > 0) {
|
extra:
|
||||||
unreadMessagesIds.add((id: message.id, index: index));
|
state.chatRoom.users.receiver.slug,
|
||||||
EasyDebounce.debounce('mark_as_read', const Duration(milliseconds: 500), () async {
|
);
|
||||||
final result = await context.read<ChatRoomCubit>().markMessagesAsRead(state.chatRoom.id, unreadMessagesIds);
|
|
||||||
if (!result) return;
|
|
||||||
if (!context.mounted) return;
|
|
||||||
context.read<ChatsCubit>().reduceUnreadMessagesCount(chatId: state.chatRoom.id, count: unreadMessagesIds.length);
|
|
||||||
unreadMessagesIds.clear();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
child: _buildMessageBubble(
|
|
||||||
context: context,
|
|
||||||
state: state,
|
|
||||||
chatCounterpart: chatCounterpart,
|
|
||||||
isCurrentUserAuthor: isCurrentUserAuthor,
|
|
||||||
message: message,
|
|
||||||
amIConsultant: amIConsultant,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
);
|
],
|
||||||
},
|
if (bannerType != null)
|
||||||
itemCount: state.chatMessages.length + (state.isLoading ? 1 : 0),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
if (showUserDetailsButton) ...[
|
|
||||||
// OrderCard(
|
|
||||||
// plan: chosenPlan.plan,
|
|
||||||
// isCustomerView: !amIConsultant,
|
|
||||||
// optionType: chosenPlan.optionType,
|
|
||||||
// onCancel: () async {
|
|
||||||
// await context.read<ChatRoomCubit>().cancelChosenPlan(chatId: state.chatRoom.id, planId: chosenPlan.id);
|
|
||||||
// },
|
|
||||||
// ),
|
|
||||||
if (amIConsultant)
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
padding: const EdgeInsets.symmetric(
|
||||||
child: CustomOutlinedButton(
|
vertical: 12,
|
||||||
text: S.current.user_info,
|
horizontal: 16,
|
||||||
height: 52,
|
),
|
||||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
child: NotificationBanner(
|
||||||
buttonStyle: ButtonStyle(
|
bannerType: bannerType,
|
||||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
chosenPlanId: chosenPlan?.id,
|
||||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
planName: chosenPlan?.plan.name,
|
||||||
),
|
planPrice: chosenOption?.price.toDouble(),
|
||||||
onPressed: () async {
|
hasPersonalDiet:
|
||||||
GoRouter.of(context).push(UserDetailsScreen.path, extra: state.chatRoom.users.receiver.slug);
|
chosenOption?.hasPersonalDiet ?? false,
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (bannerType != null)
|
),
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
|
||||||
child: NotificationBanner(
|
|
||||||
bannerType: bannerType,
|
|
||||||
chosenPlanId: chosenPlan?.id,
|
|
||||||
planName: chosenPlan?.plan.name,
|
|
||||||
planPrice: chosenOption?.price.toDouble(),
|
|
||||||
hasPersonalDiet: chosenOption?.hasPersonalDiet ?? false,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -481,19 +591,40 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
_isImageFile(pickedFiles[i].path)
|
_isImageFile(pickedFiles[i].path)
|
||||||
? GestureDetector(
|
? GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
final imageFiles = pickedFiles.where((f) => _isImageFile(f.path)).toList();
|
final imageFiles = pickedFiles
|
||||||
final imageIndex = _imageIndexAt(pickedFiles, i);
|
.where(
|
||||||
|
(f) => _isImageFile(f.path),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
final imageIndex = _imageIndexAt(
|
||||||
|
pickedFiles,
|
||||||
|
i,
|
||||||
|
);
|
||||||
|
|
||||||
context.pushTransparentRoute(
|
context.pushTransparentRoute(
|
||||||
PhotoViewScreen(imageUrls: imageFiles.map((e) => e.path).toList(), isLocal: true, index: imageIndex),
|
PhotoViewScreen(
|
||||||
|
imageUrls: imageFiles
|
||||||
|
.map((e) => e.path)
|
||||||
|
.toList(),
|
||||||
|
isLocal: true,
|
||||||
|
index: imageIndex,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: Hero(
|
child: Hero(
|
||||||
tag: pickedFiles[i].path,
|
tag: pickedFiles[i].path,
|
||||||
transitionOnUserGestures: true,
|
transitionOnUserGestures: true,
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius:
|
||||||
child: SizedBox(height: 104, width: 104, child: Image.file(pickedFiles[i], fit: BoxFit.cover)),
|
BorderRadius.circular(8),
|
||||||
|
child: SizedBox(
|
||||||
|
height: 104,
|
||||||
|
width: 104,
|
||||||
|
child: Image.file(
|
||||||
|
pickedFiles[i],
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -510,8 +641,15 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(4),
|
padding: const EdgeInsets.all(4),
|
||||||
decoration: BoxDecoration(color: appTheme.gray50, shape: BoxShape.circle),
|
decoration: BoxDecoration(
|
||||||
child: const Icon(size: 20, Icons.close, color: Colors.black),
|
color: appTheme.gray50,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
size: 20,
|
||||||
|
Icons.close,
|
||||||
|
color: Colors.black,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -528,14 +666,20 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
children: [
|
children: [
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
InkWell(
|
InkWell(
|
||||||
onTap: () => showImagePicker(state.chatRoom.users.receiver.slug),
|
onTap: () => showImagePicker(
|
||||||
|
state.chatRoom.users.receiver.slug,
|
||||||
|
),
|
||||||
customBorder: const CircleBorder(),
|
customBorder: const CircleBorder(),
|
||||||
child: Ink(
|
child: Ink(
|
||||||
height: 40,
|
height: 40,
|
||||||
width: 40,
|
width: 40,
|
||||||
child: Transform.rotate(
|
child: Transform.rotate(
|
||||||
angle: pi / 4,
|
angle: pi / 4,
|
||||||
child: const Icon(Icons.attach_file_rounded, size: 30, color: Color(0xFF858E99)),
|
child: const Icon(
|
||||||
|
Icons.attach_file_rounded,
|
||||||
|
size: 30,
|
||||||
|
color: Color(0xFF858E99),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -547,24 +691,46 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
maxLines: 3,
|
maxLines: 3,
|
||||||
minLines: 1,
|
minLines: 1,
|
||||||
keyboardType: TextInputType.multiline,
|
keyboardType: TextInputType.multiline,
|
||||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, color: Colors.black, height: 1),
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
color: Colors.black,
|
||||||
|
height: 1,
|
||||||
|
),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
isDense: true,
|
isDense: true,
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 10,
|
||||||
|
),
|
||||||
hintText: 'Сообщение',
|
hintText: 'Сообщение',
|
||||||
hintStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w400, height: 1, color: Color(0xFFAEAEB2)),
|
hintStyle: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
height: 1,
|
||||||
|
color: Color(0xFFAEAEB2),
|
||||||
|
),
|
||||||
filled: true,
|
filled: true,
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(100),
|
borderRadius: BorderRadius.circular(100),
|
||||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
borderSide: const BorderSide(
|
||||||
|
color: Color(0xFFD1D1D6),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(100),
|
borderRadius: BorderRadius.circular(100),
|
||||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
borderSide: const BorderSide(
|
||||||
|
color: Color(0xFFD1D1D6),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(100),
|
borderRadius: BorderRadius.circular(100),
|
||||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
borderSide: const BorderSide(
|
||||||
|
color: Color(0xFFD1D1D6),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -579,12 +745,18 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
final blockedState = blockedUsersCubit.state;
|
final blockedState = blockedUsersCubit.state;
|
||||||
|
|
||||||
if (blockedState is BlockedUsersStateLoaded) {
|
if (blockedState is BlockedUsersStateLoaded) {
|
||||||
final isBlocked = blockedState.profilesList.any((profile) => profile.slug == state.chatRoom.users.receiver.slug);
|
final isBlocked = blockedState.profilesList.any(
|
||||||
|
(profile) =>
|
||||||
|
profile.slug ==
|
||||||
|
state.chatRoom.users.receiver.slug,
|
||||||
|
);
|
||||||
if (isBlocked) {
|
if (isBlocked) {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
content: Text('Вы не можете отправить сообщение заблокированному пользователю'),
|
content: Text(
|
||||||
|
'Вы не можете отправить сообщение заблокированному пользователю',
|
||||||
|
),
|
||||||
backgroundColor: Colors.red,
|
backgroundColor: Colors.red,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -593,10 +765,15 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (textEditingController.text.isNotEmpty || pickedFiles.isNotEmpty) {
|
if (textEditingController.text.isNotEmpty ||
|
||||||
|
pickedFiles.isNotEmpty) {
|
||||||
chatRoomCubit.sendMessage(
|
chatRoomCubit.sendMessage(
|
||||||
data: textEditingController.text.isEmpty ? null : textEditingController.text.trim(),
|
data: textEditingController.text.isEmpty
|
||||||
files: pickedFiles.isEmpty ? null : pickedFiles,
|
? null
|
||||||
|
: textEditingController.text.trim(),
|
||||||
|
files: pickedFiles.isEmpty
|
||||||
|
? null
|
||||||
|
: pickedFiles,
|
||||||
userSlug: state.chatRoom.users.receiver.slug,
|
userSlug: state.chatRoom.users.receiver.slug,
|
||||||
);
|
);
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -606,10 +783,17 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Ink(
|
child: Ink(
|
||||||
decoration: BoxDecoration(color: appTheme.blueA400, shape: BoxShape.circle),
|
decoration: BoxDecoration(
|
||||||
|
color: appTheme.blueA400,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
height: 40,
|
height: 40,
|
||||||
width: 40,
|
width: 40,
|
||||||
child: const Icon(Icons.arrow_upward_rounded, size: 24, color: Colors.white),
|
child: const Icon(
|
||||||
|
Icons.arrow_upward_rounded,
|
||||||
|
size: 24,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
@@ -628,12 +812,19 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
child: AboutOrderButton(
|
child: AboutOrderButton(
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
if (amIConsultant) {
|
if (amIConsultant) {
|
||||||
GoRouter.of(
|
GoRouter.of(context).push(
|
||||||
context,
|
InfoByOrderScreen.path,
|
||||||
).push(InfoByOrderScreen.path, extra: {'planId': state.chatRoom.chosenPlan?.id, 'customerSlug': state.chatRoom.users.receiver.slug});
|
extra: {
|
||||||
|
'planId': state.chatRoom.chosenPlan?.id,
|
||||||
|
'customerSlug': state.chatRoom.users.receiver.slug,
|
||||||
|
},
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
GoRouter.of(context).push(InfoByOrderScreen.path, extra: {'planId': state.chatRoom.chosenPlan?.id});
|
GoRouter.of(context).push(
|
||||||
|
InfoByOrderScreen.path,
|
||||||
|
extra: {'planId': state.chatRoom.chosenPlan?.id},
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -658,7 +849,13 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
const Icon(Icons.attach_file, size: 32, color: Colors.black54),
|
const Icon(Icons.attach_file, size: 32, color: Colors.black54),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
child: Text(fileName, textAlign: TextAlign.center, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12)),
|
child: Text(
|
||||||
|
fileName,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -706,7 +903,13 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
// await chatRoomCubit.sendMessage(files: [File(image.path)], userSlug: slug);
|
// await chatRoomCubit.sendMessage(files: [File(image.path)], userSlug: slug);
|
||||||
// }
|
// }
|
||||||
},
|
},
|
||||||
child: const Row(children: [Icon(Icons.photo_camera), SizedBox(width: 8), Text('Фото')]),
|
child: const Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.photo_camera),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Фото'),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
CupertinoActionSheetAction(
|
CupertinoActionSheetAction(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
@@ -715,13 +918,21 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
List<XFile> images = await picker.pickMultiImage();
|
List<XFile> images = await picker.pickMultiImage();
|
||||||
if (images != []) {
|
if (images != []) {
|
||||||
setState(() {
|
setState(() {
|
||||||
pickedFiles.addAll(images.map((image) => File(image.path)).toList());
|
pickedFiles.addAll(
|
||||||
|
images.map((image) => File(image.path)).toList(),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
// List<File> files = images.map((image) => File(image.path)).toList();
|
// List<File> files = images.map((image) => File(image.path)).toList();
|
||||||
// await chatRoomCubit.sendMessage(files: files, userSlug: slug);
|
// await chatRoomCubit.sendMessage(files: files, userSlug: slug);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: const Row(children: [Icon(Icons.photo_library_rounded), SizedBox(width: 8), Text('Галерея')]),
|
child: const Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.photo_library_rounded),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Галерея'),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
CupertinoActionSheetAction(
|
CupertinoActionSheetAction(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
@@ -753,15 +964,27 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
if (result != null) {
|
if (result != null) {
|
||||||
// List<File> files = result.paths.map((path) => File(path!)).toList();
|
// List<File> files = result.paths.map((path) => File(path!)).toList();
|
||||||
setState(() {
|
setState(() {
|
||||||
pickedFiles.addAll(result.paths.map((path) => File(path!)).toList());
|
pickedFiles.addAll(
|
||||||
|
result.paths.map((path) => File(path!)).toList(),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
// await chatRoomCubit.sendMessage(files: files, userSlug: slug);
|
// await chatRoomCubit.sendMessage(files: files, userSlug: slug);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: const Row(children: [Icon(Icons.file_present_rounded), SizedBox(width: 8), Text('Файлы')]),
|
child: const Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.file_present_rounded),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Файлы'),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
cancelButton: CupertinoActionSheetAction(onPressed: () => Navigator.of(context).pop(), isDestructiveAction: true, child: const Text('Отмена')),
|
cancelButton: CupertinoActionSheetAction(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
isDestructiveAction: true,
|
||||||
|
child: const Text('Отмена'),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -772,13 +995,22 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
padding: const EdgeInsets.only(top: 32),
|
padding: const EdgeInsets.only(top: 32),
|
||||||
child: Text(
|
child: Text(
|
||||||
DateFormat.yMMMMd('RU').format(date),
|
DateFormat.yMMMMd('RU').format(date),
|
||||||
style: const TextStyle(height: 1, color: Color(0xFF8393A1), fontWeight: FontWeight.w500),
|
style: const TextStyle(
|
||||||
|
height: 1,
|
||||||
|
color: Color(0xFF8393A1),
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _openVideoCall(BuildContext context, ChatRoomStateLoaded state, ChatUserModel chatCounterpart) {
|
void _openVideoCall(
|
||||||
|
BuildContext context,
|
||||||
|
ChatRoomStateLoaded state,
|
||||||
|
ChatUserModel chatCounterpart,
|
||||||
|
) {
|
||||||
|
sl<VideoCallManager>().permitParticipantTokenRequest();
|
||||||
GoRouter.of(context).pushNamed(
|
GoRouter.of(context).pushNamed(
|
||||||
VideoCallScreen.name,
|
VideoCallScreen.name,
|
||||||
extra: <String, dynamic>{
|
extra: <String, dynamic>{
|
||||||
@@ -793,6 +1025,43 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _queueMarkAsRead({
|
||||||
|
required BuildContext context,
|
||||||
|
required String chatId,
|
||||||
|
required ChatMessageModel message,
|
||||||
|
required bool isCurrentUserAuthor,
|
||||||
|
required double visibleFraction,
|
||||||
|
}) {
|
||||||
|
if (message.isRead || isCurrentUserAuthor || visibleFraction <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!unreadMessageIds.contains(message.id)) {
|
||||||
|
unreadMessageIds.add(message.id);
|
||||||
|
}
|
||||||
|
EasyDebounce.debounce(
|
||||||
|
'mark_as_read',
|
||||||
|
const Duration(milliseconds: 500),
|
||||||
|
() async {
|
||||||
|
final ids = List<int>.from(unreadMessageIds);
|
||||||
|
if (ids.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unreadMessageIds.clear();
|
||||||
|
final result = await context.read<ChatRoomCubit>().markMessagesAsRead(
|
||||||
|
chatId,
|
||||||
|
ids,
|
||||||
|
);
|
||||||
|
if (!result || !context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.read<ChatsCubit>().reduceUnreadMessagesCount(
|
||||||
|
chatId: chatId,
|
||||||
|
count: ids.length,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildMessageBubble({
|
Widget _buildMessageBubble({
|
||||||
required BuildContext context,
|
required BuildContext context,
|
||||||
required ChatRoomStateLoaded state,
|
required ChatRoomStateLoaded state,
|
||||||
@@ -801,11 +1070,13 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
required ChatMessageModel message,
|
required ChatMessageModel message,
|
||||||
required bool? amIConsultant,
|
required bool? amIConsultant,
|
||||||
}) {
|
}) {
|
||||||
if (message.type == ChatMessageType.videoCallStarted && !isCurrentUserAuthor) {
|
if (message.type == ChatMessageType.videoCallStarted &&
|
||||||
|
!isCurrentUserAuthor) {
|
||||||
return VideoCallChatMessageWidget(
|
return VideoCallChatMessageWidget(
|
||||||
text: message.data.trim(),
|
text: message.data.trim(),
|
||||||
sendTime: message.updatedAt,
|
sendTime: message.updatedAt,
|
||||||
onVideoCallPressed: () => _openVideoCall(context, state, chatCounterpart),
|
onVideoCallPressed: () =>
|
||||||
|
_openVideoCall(context, state, chatCounterpart),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -844,7 +1115,13 @@ class _ChatHeaderVideoCallButton extends StatelessWidget {
|
|||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
child: Center(child: Icon(Icons.videocam_rounded, color: appTheme.blueA400, size: 18)),
|
child: Center(
|
||||||
|
child: Icon(
|
||||||
|
Icons.videocam_rounded,
|
||||||
|
color: appTheme.blueA400,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,11 +14,15 @@ class PhotoViewScreen extends StatefulWidget {
|
|||||||
final int index;
|
final int index;
|
||||||
final bool isLocal;
|
final bool isLocal;
|
||||||
|
|
||||||
|
/// When set, used instead of the image URL so source/destination tags stay unique.
|
||||||
|
final Object? heroTag;
|
||||||
|
|
||||||
const PhotoViewScreen({
|
const PhotoViewScreen({
|
||||||
super.key,
|
super.key,
|
||||||
required this.imageUrls,
|
required this.imageUrls,
|
||||||
required this.index,
|
required this.index,
|
||||||
this.isLocal = false,
|
this.isLocal = false,
|
||||||
|
this.heroTag,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -36,6 +40,23 @@ class _PhotoViewScreenState extends State<PhotoViewScreen> {
|
|||||||
super.initState();
|
super.initState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PhotoViewHeroAttributes? _heroAttributesFor(int index) {
|
||||||
|
if (widget.heroTag != null) {
|
||||||
|
if (index != widget.index) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return PhotoViewHeroAttributes(
|
||||||
|
tag: widget.heroTag!,
|
||||||
|
transitionOnUserGestures: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return PhotoViewHeroAttributes(
|
||||||
|
tag: widget.imageUrls[index],
|
||||||
|
transitionOnUserGestures: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return DismissiblePage(
|
return DismissiblePage(
|
||||||
@@ -44,23 +65,19 @@ class _PhotoViewScreenState extends State<PhotoViewScreen> {
|
|||||||
direction: DismissiblePageDismissDirection.vertical,
|
direction: DismissiblePageDismissDirection.vertical,
|
||||||
isFullScreen: true,
|
isFullScreen: true,
|
||||||
child: PhotoViewGallery.builder(
|
child: PhotoViewGallery.builder(
|
||||||
backgroundDecoration: const BoxDecoration(
|
backgroundDecoration: const BoxDecoration(color: Colors.transparent),
|
||||||
color: Colors.transparent,
|
|
||||||
),
|
|
||||||
scrollPhysics: const BouncingScrollPhysics(),
|
scrollPhysics: const BouncingScrollPhysics(),
|
||||||
builder: (BuildContext context, int index) {
|
builder: (BuildContext context, int index) {
|
||||||
final imageProvider = widget.isLocal
|
final imageProvider = widget.isLocal
|
||||||
? FileImage(widget.imageUrls[index]) as ImageProvider<Object>
|
? FileImage(widget.imageUrls[index]) as ImageProvider<Object>
|
||||||
: CachedNetworkImageProvider(widget.imageUrls[index]) as ImageProvider<Object>;
|
: CachedNetworkImageProvider(widget.imageUrls[index])
|
||||||
|
as ImageProvider<Object>;
|
||||||
|
|
||||||
return PhotoViewGalleryPageOptions(
|
return PhotoViewGalleryPageOptions(
|
||||||
imageProvider: imageProvider,
|
imageProvider: imageProvider,
|
||||||
initialScale: PhotoViewComputedScale.contained,
|
initialScale: PhotoViewComputedScale.contained,
|
||||||
minScale: PhotoViewComputedScale.contained,
|
minScale: PhotoViewComputedScale.contained,
|
||||||
heroAttributes: PhotoViewHeroAttributes(
|
heroAttributes: _heroAttributesFor(index),
|
||||||
tag: widget.imageUrls[index],
|
|
||||||
transitionOnUserGestures: true,
|
|
||||||
),
|
|
||||||
errorBuilder: (context, object, stackTrace) {
|
errorBuilder: (context, object, stackTrace) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.black,
|
backgroundColor: Colors.black,
|
||||||
@@ -76,8 +93,12 @@ class _PhotoViewScreenState extends State<PhotoViewScreen> {
|
|||||||
),
|
),
|
||||||
contentPadding: const EdgeInsets.all(16),
|
contentPadding: const EdgeInsets.all(16),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
if (!await launchUrl(Uri.parse(widget.imageUrls[index]))) {
|
if (!await launchUrl(
|
||||||
throw Exception('Could not launch ${widget.imageUrls[index]}');
|
Uri.parse(widget.imageUrls[index]),
|
||||||
|
)) {
|
||||||
|
throw Exception(
|
||||||
|
'Could not launch ${widget.imageUrls[index]}',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
leading: const Icon(
|
leading: const Icon(
|
||||||
@@ -89,7 +110,10 @@ class _PhotoViewScreenState extends State<PhotoViewScreen> {
|
|||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
widget.imageUrls[index].replaceAll('${sl<AppConfig>().baseUrl}/storage/chat_files/', ''),
|
widget.imageUrls[index].replaceAll(
|
||||||
|
'${sl<AppConfig>().baseUrl}/storage/chat_files/',
|
||||||
|
'',
|
||||||
|
),
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
@@ -109,7 +133,9 @@ class _PhotoViewScreenState extends State<PhotoViewScreen> {
|
|||||||
width: 20.0,
|
width: 20.0,
|
||||||
height: 20.0,
|
height: 20.0,
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
value: event == null ? 0 : event.cumulativeBytesLoaded / event.expectedTotalBytes!,
|
value: event == null
|
||||||
|
? 0
|
||||||
|
: event.cumulativeBytesLoaded / event.expectedTotalBytes!,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import 'package:eatvio_app/features/chat/models/chat_room_pagination_model.dart'
|
|||||||
import 'package:eatvio_app/features/chat/models/order_id_to_conversation_model.dart';
|
import 'package:eatvio_app/features/chat/models/order_id_to_conversation_model.dart';
|
||||||
import 'package:eatvio_app/features/chat/models/payment_model.dart';
|
import 'package:eatvio_app/features/chat/models/payment_model.dart';
|
||||||
import 'package:eatvio_app/features/chat/models/plan_complain_model.dart';
|
import 'package:eatvio_app/features/chat/models/plan_complain_model.dart';
|
||||||
|
import 'package:eatvio_app/features/payment/models/promo_applied_list_response.dart';
|
||||||
|
import 'package:eatvio_app/features/payment/models/promo_apply_response.dart';
|
||||||
|
import 'package:eatvio_app/features/payment/models/promo_remove_response.dart';
|
||||||
|
import 'package:eatvio_app/features/payment/models/promo_validate_response.dart';
|
||||||
import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart';
|
import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart';
|
||||||
import 'package:retrofit/retrofit.dart';
|
import 'package:retrofit/retrofit.dart';
|
||||||
|
|
||||||
@@ -69,6 +73,26 @@ abstract class ChatService {
|
|||||||
@Body() required Map<String, String> body,
|
@Body() required Map<String, String> body,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@GET('/api/chosen_plans/applied_promos')
|
||||||
|
Future<PromoAppliedListResponse> listAppliedPromoCodes();
|
||||||
|
|
||||||
|
@POST('/api/chosen_plans/{id}/validate_promo')
|
||||||
|
Future<PromoValidateResponse> validatePromoCode({
|
||||||
|
@Path() required int id,
|
||||||
|
@Body() required Map<String, String> body,
|
||||||
|
});
|
||||||
|
|
||||||
|
@POST('/api/chosen_plans/{id}/apply_promo')
|
||||||
|
Future<PromoApplyResponse> applyPromoCode({
|
||||||
|
@Path() required int id,
|
||||||
|
@Body() required Map<String, String> body,
|
||||||
|
});
|
||||||
|
|
||||||
|
@DELETE('/api/chosen_plans/{id}/remove_promo_code')
|
||||||
|
Future<PromoRemoveResponse> removePromoCode({
|
||||||
|
@Path() required int id,
|
||||||
|
});
|
||||||
|
|
||||||
@GET('/api/consultation/resolve/order_id_to_conversation/{orderId}')
|
@GET('/api/consultation/resolve/order_id_to_conversation/{orderId}')
|
||||||
Future<OrderIdToConversationModel> resolveOrderIdToConversation({
|
Future<OrderIdToConversationModel> resolveOrderIdToConversation({
|
||||||
@Path() required String orderId,
|
@Path() required String orderId,
|
||||||
|
|||||||
@@ -33,9 +33,7 @@ class _ComplainingBottomSheetState extends State<ComplainingBottomSheet> {
|
|||||||
create: (context) => sl<ComplainingCubit>()..setContentTypeAndIdentifier(contentType: widget.complainedContentType, slug: widget.slug, id: widget.id),
|
create: (context) => sl<ComplainingCubit>()..setContentTypeAndIdentifier(contentType: widget.complainedContentType, slug: widget.slug, id: widget.id),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.only(top: 8, bottom: 24, left: 16, right: 16),
|
padding: const EdgeInsets.only(top: 8, bottom: 24, left: 16, right: 16),
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(borderRadius: BorderRadius.vertical(top: Radius.circular(12))),
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
|
|
||||||
),
|
|
||||||
child: BlocBuilder<ComplainingCubit, ComplainingState>(
|
child: BlocBuilder<ComplainingCubit, ComplainingState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final category = state.category;
|
final category = state.category;
|
||||||
@@ -72,10 +70,7 @@ class _CategoryWidget extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
const BottomSheetHandlerBar(),
|
const BottomSheetHandlerBar(),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Text(
|
Text(s.complaint, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||||
s.complaint,
|
|
||||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
s.complaint_question,
|
s.complaint_question,
|
||||||
@@ -114,10 +109,7 @@ class _SpamWidget extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
const BottomSheetHandlerBar(),
|
const BottomSheetHandlerBar(),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Text(
|
Text(s.spam, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||||
s.spam,
|
|
||||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||||
@@ -145,10 +137,7 @@ class _ExplicitImageWidget extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
const BottomSheetHandlerBar(),
|
const BottomSheetHandlerBar(),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Text(
|
Text(s.explicitImages, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||||
s.explicitImages,
|
|
||||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||||
@@ -202,10 +191,7 @@ class _LawViolationWidgetState extends State<_LawViolationWidget> {
|
|||||||
children: [
|
children: [
|
||||||
const BottomSheetHandlerBar(),
|
const BottomSheetHandlerBar(),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Text(
|
Text(s.lawViolation, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||||
s.lawViolation,
|
|
||||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||||
@@ -235,10 +221,7 @@ class _LawViolationWidgetState extends State<_LawViolationWidget> {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
Text(
|
Text(element.$2, style: TextStyles.textLSemibold.copyWith(color: appTheme.black)),
|
||||||
element.$2,
|
|
||||||
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
@@ -254,12 +237,10 @@ class _LawViolationWidgetState extends State<_LawViolationWidget> {
|
|||||||
if ((state.complainInfo.description?.length ?? 10) < 10) ...[
|
if ((state.complainInfo.description?.length ?? 10) < 10) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
const ValidationErrorWidget(error: 'Минимум 10 символов'),
|
const ValidationErrorWidget(error: 'Минимум 10 символов'),
|
||||||
]
|
],
|
||||||
],
|
],
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
_ComplainButton(
|
_ComplainButton(isDisabled: tags.firstWhere((tag) => tag.$2 == s.other).$1 && (state.complainInfo.description?.length ?? 0) < 10),
|
||||||
isDisabled: (state.complainInfo.description?.length ?? 0) < 10,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -301,10 +282,7 @@ class _ViolenceAndHostilityState extends State<_ViolenceAndHostility> {
|
|||||||
children: [
|
children: [
|
||||||
const BottomSheetHandlerBar(),
|
const BottomSheetHandlerBar(),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Text(
|
Text(s.lawViolation, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||||
s.lawViolation,
|
|
||||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||||
@@ -332,10 +310,7 @@ class _ViolenceAndHostilityState extends State<_ViolenceAndHostility> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(element.$2, style: TextStyles.textLSemibold.copyWith(color: appTheme.black)),
|
||||||
element.$2,
|
|
||||||
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -363,15 +338,9 @@ class _DeceptionWidgetState extends State<_DeceptionWidget> {
|
|||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
final s = S.of(context);
|
final s = S.of(context);
|
||||||
if (tags.isEmpty && descriptions.isEmpty) {
|
if (tags.isEmpty && descriptions.isEmpty) {
|
||||||
tags.addAll([
|
tags.addAll([(false, s.misrepresentation), (false, s.fraud)]);
|
||||||
(false, s.misrepresentation),
|
|
||||||
(false, s.fraud),
|
|
||||||
]);
|
|
||||||
|
|
||||||
descriptions.addAll([
|
descriptions.addAll([s.misrepresentation_description, s.fraud_description]);
|
||||||
s.misrepresentation_description,
|
|
||||||
s.fraud_description,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,10 +352,7 @@ class _DeceptionWidgetState extends State<_DeceptionWidget> {
|
|||||||
children: [
|
children: [
|
||||||
const BottomSheetHandlerBar(),
|
const BottomSheetHandlerBar(),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Text(
|
Text(s.lawViolation, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||||
s.lawViolation,
|
|
||||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||||
@@ -419,16 +385,10 @@ class _DeceptionWidgetState extends State<_DeceptionWidget> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(element.$2, style: TextStyles.textLSemibold.copyWith(color: appTheme.black)),
|
||||||
element.$2,
|
|
||||||
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
|
|
||||||
),
|
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||||
child: Text(
|
child: Text(descriptions[index], style: TextStyles.textSSemibold.copyWith(color: appTheme.lightGray600)),
|
||||||
descriptions[index],
|
|
||||||
style: TextStyles.textSSemibold.copyWith(color: appTheme.lightGray600),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -453,10 +413,7 @@ class _ComplaintSentWidget extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
const BottomSheetHandlerBar(),
|
const BottomSheetHandlerBar(),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Text(
|
Text(s.complaint_sent, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||||
s.complaint_sent,
|
|
||||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||||
@@ -471,10 +428,7 @@ class _ComplaintSentWidget extends StatelessWidget {
|
|||||||
text: s.ok,
|
text: s.ok,
|
||||||
height: 52,
|
height: 52,
|
||||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||||
buttonStyle: ButtonStyle(
|
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
|
||||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
|
||||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
|
||||||
),
|
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
AppRouter.navigatorKey.currentContext?.pop();
|
AppRouter.navigatorKey.currentContext?.pop();
|
||||||
},
|
},
|
||||||
@@ -495,10 +449,7 @@ class _ComplaintFailedWidget extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
const BottomSheetHandlerBar(),
|
const BottomSheetHandlerBar(),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Text(
|
Text(s.could_not_send_complain, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||||
s.could_not_send_complain,
|
|
||||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||||
@@ -513,10 +464,7 @@ class _ComplaintFailedWidget extends StatelessWidget {
|
|||||||
text: s.ok,
|
text: s.ok,
|
||||||
height: 52,
|
height: 52,
|
||||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||||
buttonStyle: ButtonStyle(
|
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
|
||||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
|
||||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
|
||||||
),
|
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
AppRouter.navigatorKey.currentContext?.pop();
|
AppRouter.navigatorKey.currentContext?.pop();
|
||||||
},
|
},
|
||||||
@@ -535,16 +483,13 @@ class _ComplainButton extends StatelessWidget {
|
|||||||
return BlocBuilder<ComplainingCubit, ComplainingState>(
|
return BlocBuilder<ComplainingCubit, ComplainingState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
if (state.isLoading) {
|
if (state.isLoading) {
|
||||||
return const Loader();
|
return const SizedBox(height: 52, child: Loader());
|
||||||
}
|
}
|
||||||
return CustomOutlinedButton(
|
return CustomOutlinedButton(
|
||||||
text: s.send_complain,
|
text: s.send_complain,
|
||||||
height: 52,
|
height: 52,
|
||||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||||
buttonStyle: ButtonStyle(
|
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
|
||||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
|
||||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
|
||||||
),
|
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
if (isDisabled) {
|
if (isDisabled) {
|
||||||
context.read<ComplainingCubit>().setDescription('');
|
context.read<ComplainingCubit>().setDescription('');
|
||||||
@@ -570,10 +515,7 @@ class _CategoryButton extends StatelessWidget {
|
|||||||
child: Ink(
|
child: Ink(
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(category.getTranslatedText(), style: TextStyles.textLSemibold.copyWith(color: appTheme.black)),
|
||||||
category.getTranslatedText(),
|
|
||||||
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
SvgPicture.asset('assets/icons/right_arrow.svg'),
|
SvgPicture.asset('assets/icons/right_arrow.svg'),
|
||||||
],
|
],
|
||||||
@@ -591,18 +533,18 @@ enum ComplainType {
|
|||||||
explicitImage;
|
explicitImage;
|
||||||
|
|
||||||
String getTranslatedText() => switch (this) {
|
String getTranslatedText() => switch (this) {
|
||||||
ComplainType.spam => S.current.spam,
|
ComplainType.spam => S.current.spam,
|
||||||
ComplainType.lawViolation => S.current.lawViolation,
|
ComplainType.lawViolation => S.current.lawViolation,
|
||||||
ComplainType.deception => S.current.deception,
|
ComplainType.deception => S.current.deception,
|
||||||
ComplainType.violenceDndHostility => S.current.violenceAndHostility,
|
ComplainType.violenceDndHostility => S.current.violenceAndHostility,
|
||||||
ComplainType.explicitImage => S.current.explicitImage,
|
ComplainType.explicitImage => S.current.explicitImage,
|
||||||
};
|
};
|
||||||
|
|
||||||
String getValue() => switch (this) {
|
String getValue() => switch (this) {
|
||||||
ComplainType.spam => 'spam',
|
ComplainType.spam => 'spam',
|
||||||
ComplainType.lawViolation => 'legalViolation',
|
ComplainType.lawViolation => 'legalViolation',
|
||||||
ComplainType.deception => 'fraud',
|
ComplainType.deception => 'fraud',
|
||||||
ComplainType.violenceDndHostility => 'violenceAndHatred',
|
ComplainType.violenceDndHostility => 'violenceAndHatred',
|
||||||
ComplainType.explicitImage => 'explicitImage',
|
ComplainType.explicitImage => 'explicitImage',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ class ComplainingCubit extends Cubit<ComplainingState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> complain() async {
|
Future<void> complain() async {
|
||||||
|
if (state.isLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit(state.copyWith(isLoading: true));
|
||||||
try {
|
try {
|
||||||
final category = state.category;
|
final category = state.category;
|
||||||
final id = state.contentId;
|
final id = state.contentId;
|
||||||
@@ -46,37 +50,37 @@ class ComplainingCubit extends Cubit<ComplainingState> {
|
|||||||
case ComplainedContent.diet:
|
case ComplainedContent.diet:
|
||||||
if (id != null) {
|
if (id != null) {
|
||||||
await repository.sendDietComplain(id: id, category: category.getValue(), body: state.complainInfo);
|
await repository.sendDietComplain(id: id, category: category.getValue(), body: state.complainInfo);
|
||||||
emit(state.copyWith(wasSentSuccessfully: true));
|
emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case ComplainedContent.plan:
|
case ComplainedContent.plan:
|
||||||
if (slug != null) {
|
if (slug != null) {
|
||||||
await repository.sendPlanComplain(slug: slug, category: category.getValue(), body: state.complainInfo);
|
await repository.sendPlanComplain(slug: slug, category: category.getValue(), body: state.complainInfo);
|
||||||
emit(state.copyWith(wasSentSuccessfully: true));
|
emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case ComplainedContent.recipe:
|
case ComplainedContent.recipe:
|
||||||
if (slug != null) {
|
if (slug != null) {
|
||||||
await repository.sendRecipeComplain(slug: slug, category: category.getValue(), body: state.complainInfo);
|
await repository.sendRecipeComplain(slug: slug, category: category.getValue(), body: state.complainInfo);
|
||||||
emit(state.copyWith(wasSentSuccessfully: true));
|
emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case ComplainedContent.profile:
|
case ComplainedContent.profile:
|
||||||
if (slug != null) {
|
if (slug != null) {
|
||||||
await repository.sendProfileComplain(slug: slug, category: category.getValue(), body: state.complainInfo);
|
await repository.sendProfileComplain(slug: slug, category: category.getValue(), body: state.complainInfo);
|
||||||
emit(state.copyWith(wasSentSuccessfully: true));
|
emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case ComplainedContent.posts:
|
case ComplainedContent.posts:
|
||||||
if (id != null) {
|
if (id != null) {
|
||||||
await repository.sendBlogPostsComplain(id: id, category: category.getValue(), body: state.complainInfo);
|
await repository.sendBlogPostsComplain(id: id, category: category.getValue(), body: state.complainInfo);
|
||||||
emit(state.copyWith(wasSentSuccessfully: true));
|
emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case ComplainedContent.comment:
|
case ComplainedContent.comment:
|
||||||
if (id != null) {
|
if (id != null) {
|
||||||
await repository.sendCommentComplain(id: id, category: category.getValue(), body: state.complainInfo);
|
await repository.sendCommentComplain(id: id, category: category.getValue(), body: state.complainInfo);
|
||||||
emit(state.copyWith(wasSentSuccessfully: true));
|
emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,6 +89,6 @@ class ComplainingCubit extends Cubit<ComplainingState> {
|
|||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
log(e.toString(), stackTrace: st);
|
log(e.toString(), stackTrace: st);
|
||||||
}
|
}
|
||||||
emit(state.copyWith(wasSentSuccessfully: false));
|
emit(state.copyWith(wasSentSuccessfully: false, isLoading: false));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
|
enum AlfaScoringStatus {
|
||||||
|
@JsonValue('none')
|
||||||
|
none,
|
||||||
|
@JsonValue('beneficiary_creating')
|
||||||
|
beneficiaryCreating,
|
||||||
|
@JsonValue('beneficiary_failed')
|
||||||
|
beneficiaryFailed,
|
||||||
|
@JsonValue('scoring_pending')
|
||||||
|
scoringPending,
|
||||||
|
@JsonValue('scoring_complete')
|
||||||
|
scoringComplete,
|
||||||
|
@JsonValue('scoring_failed')
|
||||||
|
scoringFailed;
|
||||||
|
|
||||||
|
static AlfaScoringStatus fromString(String? value) {
|
||||||
|
switch (value) {
|
||||||
|
case 'beneficiary_creating':
|
||||||
|
return AlfaScoringStatus.beneficiaryCreating;
|
||||||
|
case 'beneficiary_failed':
|
||||||
|
return AlfaScoringStatus.beneficiaryFailed;
|
||||||
|
case 'scoring_pending':
|
||||||
|
return AlfaScoringStatus.scoringPending;
|
||||||
|
case 'scoring_complete':
|
||||||
|
return AlfaScoringStatus.scoringComplete;
|
||||||
|
case 'scoring_failed':
|
||||||
|
return AlfaScoringStatus.scoringFailed;
|
||||||
|
case 'none':
|
||||||
|
case null:
|
||||||
|
case '':
|
||||||
|
return AlfaScoringStatus.none;
|
||||||
|
default:
|
||||||
|
return AlfaScoringStatus.none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get isProcessing =>
|
||||||
|
this == AlfaScoringStatus.beneficiaryCreating ||
|
||||||
|
this == AlfaScoringStatus.scoringPending;
|
||||||
|
|
||||||
|
bool get isFailed =>
|
||||||
|
this == AlfaScoringStatus.beneficiaryFailed ||
|
||||||
|
this == AlfaScoringStatus.scoringFailed;
|
||||||
|
|
||||||
|
bool get canRetry => isFailed;
|
||||||
|
|
||||||
|
bool get isComplete => this == AlfaScoringStatus.scoringComplete;
|
||||||
|
|
||||||
|
bool get isTerminal => isComplete || isFailed;
|
||||||
|
}
|
||||||
@@ -18,7 +18,8 @@ abstract class ConsultantCertPhotoModel with _$ConsultantCertPhotoModel {
|
|||||||
|
|
||||||
const ConsultantCertPhotoModel._();
|
const ConsultantCertPhotoModel._();
|
||||||
|
|
||||||
factory ConsultantCertPhotoModel.fromJson(Map<String, dynamic> json) => _$ConsultantCertPhotoModelFromJson(json);
|
factory ConsultantCertPhotoModel.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$ConsultantCertPhotoModelFromJson(json);
|
||||||
|
|
||||||
static const String visibleGroup = 'user_certs';
|
static const String visibleGroup = 'user_certs';
|
||||||
static const String hiddenGroup = 'hidden_user_certs';
|
static const String hiddenGroup = 'hidden_user_certs';
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/utils/passport_data_validator.dart';
|
||||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
part 'consultant_profile_model.freezed.dart';
|
part 'consultant_profile_model.freezed.dart';
|
||||||
@@ -6,27 +7,70 @@ part 'consultant_profile_model.g.dart';
|
|||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
abstract class ConsultantProfileModel with _$ConsultantProfileModel {
|
abstract class ConsultantProfileModel with _$ConsultantProfileModel {
|
||||||
|
const ConsultantProfileModel._();
|
||||||
|
|
||||||
const factory ConsultantProfileModel({
|
const factory ConsultantProfileModel({
|
||||||
@JsonKey(name: 'has_medical_education', includeIfNull: false) bool? hasMedicalEducation,
|
@JsonKey(name: 'has_medical_education', includeIfNull: false)
|
||||||
@JsonKey(name: 'social_links', includeIfNull: false) SocialLinksModel? socialLinks,
|
bool? hasMedicalEducation,
|
||||||
@JsonKey(name: 'cert_photos', includeIfNull: false) List<String>? photos,
|
@JsonKey(name: 'social_links', includeIfNull: false)
|
||||||
@JsonKey(name: 'completed_courses', includeIfNull: false) List<CertificatesModel>? certificates,
|
SocialLinksModel? socialLinks,
|
||||||
|
@JsonKey(name: 'cert_photos', includeIfNull: false) List<String>? photos,
|
||||||
|
@JsonKey(name: 'completed_courses', includeIfNull: false)
|
||||||
|
List<CertificatesModel>? certificates,
|
||||||
|
@JsonKey(name: 'legal_type', includeIfNull: false) String? legalType,
|
||||||
|
@JsonKey(name: 'last_name', includeIfNull: false) String? lastName,
|
||||||
|
@JsonKey(name: 'first_name', includeIfNull: false) String? firstName,
|
||||||
|
@JsonKey(name: 'middle_name', includeIfNull: false) String? middleName,
|
||||||
|
@JsonKey(name: 'inn', includeIfNull: false) String? inn,
|
||||||
|
@JsonKey(name: 'snils', includeIfNull: false) String? snils,
|
||||||
|
@JsonKey(name: 'passport_series', includeIfNull: false)
|
||||||
|
String? passportSeries,
|
||||||
|
@JsonKey(name: 'passport_number', includeIfNull: false)
|
||||||
|
String? passportNumber,
|
||||||
|
@JsonKey(name: 'passport_issue_date', includeIfNull: false)
|
||||||
|
String? passportIssueDate,
|
||||||
|
@JsonKey(name: 'passport_issuer', includeIfNull: false)
|
||||||
|
String? passportIssuer,
|
||||||
|
@JsonKey(name: 'passport_issuer_code', includeIfNull: false)
|
||||||
|
String? passportIssuerCode,
|
||||||
|
@JsonKey(name: 'birth_date', includeIfNull: false) String? birthDate,
|
||||||
|
@JsonKey(name: 'birth_place', includeIfNull: false) String? birthPlace,
|
||||||
|
@JsonKey(name: 'registration_address', includeIfNull: false)
|
||||||
|
String? registrationAddress,
|
||||||
|
@JsonKey(name: 'residence_address', includeIfNull: false)
|
||||||
|
String? residenceAddress,
|
||||||
|
@JsonKey(name: 'citizenship', includeIfNull: false) String? citizenship,
|
||||||
|
@JsonKey(name: 'tax_resident_us', includeIfNull: false) bool? taxResidentUs,
|
||||||
|
@JsonKey(name: 'foreign_tax_resident', includeIfNull: false)
|
||||||
|
bool? foreignTaxResident,
|
||||||
|
@JsonKey(name: 'sbp_phone', includeIfNull: false) String? sbpPhone,
|
||||||
|
@JsonKey(name: 'sbp_bank_member_id', includeIfNull: false)
|
||||||
|
String? sbpBankMemberId,
|
||||||
|
@JsonKey(name: 'agreement_accepted', includeIfNull: false)
|
||||||
|
bool? agreementAccepted,
|
||||||
|
@JsonKey(name: 'frdo_agreement_accepted', includeIfNull: false)
|
||||||
|
bool? frdoAgreementAccepted,
|
||||||
}) = _ConsultantProfileModel;
|
}) = _ConsultantProfileModel;
|
||||||
|
|
||||||
factory ConsultantProfileModel.fromJson(Map<String, Object?> json) => _$ConsultantProfileModelFromJson(json);
|
factory ConsultantProfileModel.fromJson(Map<String, Object?> json) =>
|
||||||
|
_$ConsultantProfileModelFromJson(json);
|
||||||
|
|
||||||
|
bool get hasValidPassportData => PassportDataValidator.validate(this).isEmpty;
|
||||||
}
|
}
|
||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
abstract class SocialLinksModel with _$SocialLinksModel {
|
abstract class SocialLinksModel with _$SocialLinksModel {
|
||||||
const factory SocialLinksModel({
|
const factory SocialLinksModel({
|
||||||
@JsonKey(name: 'vk_link', includeIfNull: false) String? vkLink,
|
@JsonKey(name: 'vk_link', includeIfNull: false) String? vkLink,
|
||||||
@JsonKey(name: 'tg_link', includeIfNull: false) String? tgLink,
|
@JsonKey(name: 'tg_link', includeIfNull: false) String? tgLink,
|
||||||
@JsonKey(name: 'fb_link', includeIfNull: false) String? fbLink,
|
@JsonKey(name: 'fb_link', includeIfNull: false) String? fbLink,
|
||||||
@JsonKey(name: 'insta_link', includeIfNull: false) String? instaLink,
|
@JsonKey(name: 'insta_link', includeIfNull: false) String? instaLink,
|
||||||
@JsonKey(name: 'personal_site_link', includeIfNull: false) String? personalSiteLink,
|
@JsonKey(name: 'personal_site_link', includeIfNull: false)
|
||||||
|
String? personalSiteLink,
|
||||||
}) = _SocialLinksModel;
|
}) = _SocialLinksModel;
|
||||||
|
|
||||||
factory SocialLinksModel.fromJson(Map<String, Object?> json) => _$SocialLinksModelFromJson(json);
|
factory SocialLinksModel.fromJson(Map<String, Object?> json) =>
|
||||||
|
_$SocialLinksModelFromJson(json);
|
||||||
}
|
}
|
||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
@@ -36,5 +80,6 @@ abstract class CertificatesModel with _$CertificatesModel {
|
|||||||
required String date,
|
required String date,
|
||||||
}) = _CertificatesModel;
|
}) = _CertificatesModel;
|
||||||
|
|
||||||
factory CertificatesModel.fromJson(Map<String, Object?> json) => _$CertificatesModelFromJson(json);
|
factory CertificatesModel.fromJson(Map<String, Object?> json) =>
|
||||||
|
_$CertificatesModelFromJson(json);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart';
|
||||||
import 'package:eatvio_app/features/profile/models/profile_model.dart';
|
import 'package:eatvio_app/features/profile/models/profile_model.dart';
|
||||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
@@ -11,26 +12,81 @@ abstract class ConsultationRequestModel with _$ConsultationRequestModel {
|
|||||||
@JsonKey(name: 'rating_place') int? ratingPlace,
|
@JsonKey(name: 'rating_place') int? ratingPlace,
|
||||||
@JsonKey(name: 'education_resource') required EducationModel educationModel,
|
@JsonKey(name: 'education_resource') required EducationModel educationModel,
|
||||||
@JsonKey(name: 'cert_photos') required List<CertPhotosModel> photos,
|
@JsonKey(name: 'cert_photos') required List<CertPhotosModel> photos,
|
||||||
@JsonKey(name: 'canceled_moderation') CanceledModerationModel? canceledModeration,
|
@JsonKey(name: 'canceled_moderation')
|
||||||
|
CanceledModerationModel? canceledModeration,
|
||||||
|
@JsonKey(name: 'legal_entity_resource') LegalEntityModel? legalEntity,
|
||||||
}) = _ConsultationRequestModel;
|
}) = _ConsultationRequestModel;
|
||||||
|
|
||||||
factory ConsultationRequestModel.fromJson(Map<String, dynamic> json) => _$ConsultationRequestModelFromJson(json);
|
factory ConsultationRequestModel.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$ConsultationRequestModelFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class LegalEntityModel with _$LegalEntityModel {
|
||||||
|
const LegalEntityModel._();
|
||||||
|
|
||||||
|
const factory LegalEntityModel({
|
||||||
|
required int id,
|
||||||
|
required String type,
|
||||||
|
String? inn,
|
||||||
|
String? snils,
|
||||||
|
String? state,
|
||||||
|
@JsonKey(
|
||||||
|
name: 'alfa_scoring_status',
|
||||||
|
unknownEnumValue: AlfaScoringStatus.none,
|
||||||
|
defaultValue: AlfaScoringStatus.none,
|
||||||
|
)
|
||||||
|
@Default(AlfaScoringStatus.none)
|
||||||
|
AlfaScoringStatus alfaScoringStatus,
|
||||||
|
@JsonKey(name: 'alfa_scoring_request_id') String? alfaScoringRequestId,
|
||||||
|
@JsonKey(name: 'last_name') String? lastName,
|
||||||
|
@JsonKey(name: 'first_name') String? firstName,
|
||||||
|
@JsonKey(name: 'middle_name') String? middleName,
|
||||||
|
@JsonKey(name: 'passport_series') String? passportSeries,
|
||||||
|
@JsonKey(name: 'passport_number') String? passportNumber,
|
||||||
|
@JsonKey(name: 'passport_issue_date') String? passportIssueDate,
|
||||||
|
@JsonKey(name: 'passport_issuer') String? passportIssuer,
|
||||||
|
@JsonKey(name: 'passport_issuer_code') String? passportIssuerCode,
|
||||||
|
@JsonKey(name: 'birth_date') String? birthDate,
|
||||||
|
@JsonKey(name: 'birth_place') String? birthPlace,
|
||||||
|
@JsonKey(name: 'registration_address') String? registrationAddress,
|
||||||
|
@JsonKey(name: 'residence_address') String? residenceAddress,
|
||||||
|
String? citizenship,
|
||||||
|
@JsonKey(name: 'tax_resident_us') bool? taxResidentUs,
|
||||||
|
@JsonKey(name: 'foreign_tax_resident') bool? foreignTaxResident,
|
||||||
|
@JsonKey(name: 'sbp_phone') String? sbpPhone,
|
||||||
|
@JsonKey(name: 'sbp_bank_member_id') String? sbpBankMemberId,
|
||||||
|
}) = _LegalEntityModel;
|
||||||
|
|
||||||
|
factory LegalEntityModel.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$LegalEntityModelFromJson(json);
|
||||||
|
|
||||||
|
/// Normalizes API date (`Y-m-d` or ISO datetime) to `Y-m-d`.
|
||||||
|
static String? normalizeApiDate(String? value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (value.length >= 10 && RegExp(r'^\d{4}-\d{2}-\d{2}').hasMatch(value)) {
|
||||||
|
return value.substring(0, 10);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? get normalizedPassportIssueDate =>
|
||||||
|
normalizeApiDate(passportIssueDate);
|
||||||
|
|
||||||
|
String? get normalizedBirthDate => normalizeApiDate(birthDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
abstract class CertPhotosModel with _$CertPhotosModel {
|
abstract class CertPhotosModel with _$CertPhotosModel {
|
||||||
const factory CertPhotosModel({
|
const factory CertPhotosModel({required String url}) = _CertPhotosModel;
|
||||||
required String url,
|
|
||||||
}) = _CertPhotosModel;
|
|
||||||
|
|
||||||
factory CertPhotosModel.fromJson(Map<String, dynamic> json) => _$CertPhotosModelFromJson(json);
|
factory CertPhotosModel.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$CertPhotosModelFromJson(json);
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ModerationStatus {
|
enum ModerationStatus { approved, created, canceled }
|
||||||
approved,
|
|
||||||
created,
|
|
||||||
canceled
|
|
||||||
}
|
|
||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
abstract class CanceledModerationModel with _$CanceledModerationModel {
|
abstract class CanceledModerationModel with _$CanceledModerationModel {
|
||||||
@@ -40,5 +96,6 @@ abstract class CanceledModerationModel with _$CanceledModerationModel {
|
|||||||
required List<String>? additional,
|
required List<String>? additional,
|
||||||
}) = _CanceledModerationModel;
|
}) = _CanceledModerationModel;
|
||||||
|
|
||||||
factory CanceledModerationModel.fromJson(Map<String, dynamic> json) => _$CanceledModerationModelFromJson(json);
|
factory CanceledModerationModel.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$CanceledModerationModelFromJson(json);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
|
part 'sbp_bank_model.freezed.dart';
|
||||||
|
part 'sbp_bank_model.g.dart';
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class SbpBankModel with _$SbpBankModel {
|
||||||
|
const factory SbpBankModel({
|
||||||
|
@JsonKey(name: 'bank_member_id') required String bankMemberId,
|
||||||
|
@JsonKey(name: 'bank_name') required String bankName,
|
||||||
|
@JsonKey(name: 'short_name') String? shortName,
|
||||||
|
@JsonKey(name: 'logo_url') String? logoUrl,
|
||||||
|
String? bic,
|
||||||
|
}) = _SbpBankModel;
|
||||||
|
|
||||||
|
factory SbpBankModel.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$SbpBankModelFromJson(json);
|
||||||
|
}
|
||||||
@@ -1,42 +1,118 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||||
import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart';
|
import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/utils/laravel_validation_errors.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/utils/passport_data_validator.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
|
||||||
|
|
||||||
class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
||||||
final ConsultantRepository consultantRepository;
|
final ConsultantRepository consultantRepository;
|
||||||
|
final Duration scoringPollInterval;
|
||||||
bool _update = false;
|
bool _update = false;
|
||||||
|
String? _userSlug;
|
||||||
|
Timer? _scoringPollTimer;
|
||||||
|
|
||||||
RegisterConsultantCubit({required this.consultantRepository})
|
static const String individualPersonLegalType = 'individualPerson';
|
||||||
: super(
|
|
||||||
const RegisterConsultantState(
|
RegisterConsultantCubit({
|
||||||
consultant: ConsultantProfileModel(socialLinks: SocialLinksModel(), hasMedicalEducation: true),
|
required this.consultantRepository,
|
||||||
loading: true,
|
this.scoringPollInterval = const Duration(seconds: 3),
|
||||||
acceptOffer: false,
|
}) : super(
|
||||||
isUpdating: false,
|
const RegisterConsultantState(
|
||||||
),
|
consultant: ConsultantProfileModel(
|
||||||
);
|
socialLinks: SocialLinksModel(),
|
||||||
|
hasMedicalEducation: true,
|
||||||
|
legalType: individualPersonLegalType,
|
||||||
|
),
|
||||||
|
loading: true,
|
||||||
|
acceptOffer: false,
|
||||||
|
acceptFrdoAgreement: false,
|
||||||
|
isUpdating: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
String? get userSlug => _userSlug;
|
||||||
|
|
||||||
|
void setUserSlug(String? slug) {
|
||||||
|
if (slug != null && slug.isNotEmpty) {
|
||||||
|
_userSlug = slug;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<SbpBankModel>> _loadSbpBanks() async {
|
||||||
|
try {
|
||||||
|
return await consultantRepository.getSbpBanks();
|
||||||
|
} catch (e, st) {
|
||||||
|
await reportFrom(e, st, this);
|
||||||
|
log(e.toString());
|
||||||
|
return const <SbpBankModel>[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<SbpBankModel> searchSbpBanks(String query) {
|
||||||
|
final normalized = query.trim().toLowerCase();
|
||||||
|
if (normalized.isEmpty) {
|
||||||
|
return state.sbpBanks;
|
||||||
|
}
|
||||||
|
return state.sbpBanks.where((bank) {
|
||||||
|
final name = bank.bankName.toLowerCase();
|
||||||
|
final shortName = (bank.shortName ?? '').toLowerCase();
|
||||||
|
final bic = (bank.bic ?? '').toLowerCase();
|
||||||
|
return name.contains(normalized) ||
|
||||||
|
shortName.contains(normalized) ||
|
||||||
|
bic.contains(normalized);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
SbpBankModel? findSbpBankById(String? bankMemberId) {
|
||||||
|
if (bankMemberId == null || bankMemberId.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (final bank in state.sbpBanks) {
|
||||||
|
if (bank.bankMemberId == bankMemberId) {
|
||||||
|
return bank;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> getMyRequestStatus(String slug) async {
|
Future<void> getMyRequestStatus(String slug) async {
|
||||||
try {
|
try {
|
||||||
_update = true;
|
_update = true;
|
||||||
final myRequestStatus = await consultantRepository.getMyRequestStatus(slug);
|
_userSlug = slug;
|
||||||
|
final myRequestStatus = await consultantRepository.getMyRequestStatus(
|
||||||
|
slug,
|
||||||
|
);
|
||||||
final certPhotos = await consultantRepository.getConsultantCertPhotos();
|
final certPhotos = await consultantRepository.getConsultantCertPhotos();
|
||||||
|
final sbpBanks = await _loadSbpBanks();
|
||||||
|
|
||||||
List<CertificatesModel>? certificates = myRequestStatus.educationModel.completedCourses?.map((courseMap) {
|
List<CertificatesModel>? certificates = myRequestStatus
|
||||||
final course = courseMap.values.first;
|
.educationModel
|
||||||
|
.completedCourses
|
||||||
|
?.map((courseMap) {
|
||||||
|
final course = courseMap.values.first;
|
||||||
|
|
||||||
return CertificatesModel(title: course.title, date: course.date);
|
return CertificatesModel(title: course.title, date: course.date);
|
||||||
}).toList();
|
})
|
||||||
|
.toList();
|
||||||
|
|
||||||
String? sanitizeLink(String? link) {
|
String? sanitizeLink(String? link) {
|
||||||
return (link != null && link.isNotEmpty) ? link : null;
|
return (link != null && link.isNotEmpty) ? link : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final legal = myRequestStatus.legalEntity;
|
||||||
|
final scoringStatus = legal?.alfaScoringStatus ?? AlfaScoringStatus.none;
|
||||||
|
|
||||||
emit(
|
emit(
|
||||||
RegisterConsultantState(
|
RegisterConsultantState(
|
||||||
consultant: ConsultantProfileModel(
|
consultant: ConsultantProfileModel(
|
||||||
@@ -46,33 +122,107 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
|||||||
tgLink: sanitizeLink(myRequestStatus.educationModel.tgLink),
|
tgLink: sanitizeLink(myRequestStatus.educationModel.tgLink),
|
||||||
fbLink: sanitizeLink(myRequestStatus.educationModel.fbLink),
|
fbLink: sanitizeLink(myRequestStatus.educationModel.fbLink),
|
||||||
instaLink: sanitizeLink(myRequestStatus.educationModel.instaLink),
|
instaLink: sanitizeLink(myRequestStatus.educationModel.instaLink),
|
||||||
personalSiteLink: sanitizeLink(myRequestStatus.educationModel.personalSiteLink),
|
personalSiteLink: sanitizeLink(
|
||||||
|
myRequestStatus.educationModel.personalSiteLink,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
hasMedicalEducation: myRequestStatus.educationModel.hasMedicalEducation == 1,
|
hasMedicalEducation:
|
||||||
|
myRequestStatus.educationModel.hasMedicalEducation == 1,
|
||||||
|
legalType: legal?.type ?? individualPersonLegalType,
|
||||||
|
lastName: _emptyToNull(legal?.lastName),
|
||||||
|
firstName: _emptyToNull(legal?.firstName),
|
||||||
|
middleName: _emptyToNull(legal?.middleName),
|
||||||
|
inn: _emptyToNull(legal?.inn),
|
||||||
|
snils: _emptyToNull(legal?.snils),
|
||||||
|
passportSeries: _emptyToNull(legal?.passportSeries),
|
||||||
|
passportNumber: _emptyToNull(legal?.passportNumber),
|
||||||
|
passportIssueDate: legal?.normalizedPassportIssueDate,
|
||||||
|
passportIssuer: _emptyToNull(legal?.passportIssuer),
|
||||||
|
passportIssuerCode: _emptyToNull(legal?.passportIssuerCode),
|
||||||
|
birthDate: legal?.normalizedBirthDate,
|
||||||
|
birthPlace: _emptyToNull(legal?.birthPlace),
|
||||||
|
registrationAddress: _emptyToNull(legal?.registrationAddress),
|
||||||
|
residenceAddress: _emptyToNull(legal?.residenceAddress),
|
||||||
|
citizenship: _emptyToNull(legal?.citizenship),
|
||||||
|
taxResidentUs: legal?.taxResidentUs ?? false,
|
||||||
|
foreignTaxResident: legal?.foreignTaxResident ?? false,
|
||||||
|
sbpPhone: _emptyToNull(legal?.sbpPhone),
|
||||||
|
sbpBankMemberId: _emptyToNull(legal?.sbpBankMemberId),
|
||||||
),
|
),
|
||||||
acceptOffer: false,
|
acceptOffer: false,
|
||||||
|
acceptFrdoAgreement: false,
|
||||||
loading: false,
|
loading: false,
|
||||||
requestModel: myRequestStatus,
|
requestModel: myRequestStatus,
|
||||||
certPhotos: certPhotos,
|
certPhotos: certPhotos,
|
||||||
|
sbpBanks: sbpBanks,
|
||||||
isUpdating: _update,
|
isUpdating: _update,
|
||||||
|
alfaScoringStatus: scoringStatus,
|
||||||
|
isScoringPolling: scoringStatus.isProcessing,
|
||||||
|
scoringRetryInProgress: false,
|
||||||
|
awaitingScoringOutcome: false,
|
||||||
|
fieldErrors: const <String, String>{},
|
||||||
error: null,
|
error: null,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (scoringStatus.isProcessing) {
|
||||||
|
startScoringPoll();
|
||||||
|
}
|
||||||
|
} catch (e, st) {
|
||||||
|
await reportFrom(e, st, this);
|
||||||
|
log(e.toString());
|
||||||
|
final parsed = parseLaravelValidationErrors(e);
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
loading: false,
|
||||||
|
error: parsed.summary,
|
||||||
|
fieldErrors: parsed.fieldErrors,
|
||||||
|
awaitingScoringOutcome: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _emptyToNull(String? value) {
|
||||||
|
if (value == null || value.trim().isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> disableLoading({String? slug}) async {
|
||||||
|
try {
|
||||||
|
setUserSlug(slug);
|
||||||
|
final certPhotos = await consultantRepository.getConsultantCertPhotos();
|
||||||
|
final sbpBanks = await _loadSbpBanks();
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
loading: false,
|
||||||
|
certPhotos: certPhotos,
|
||||||
|
sbpBanks: sbpBanks,
|
||||||
|
error: null,
|
||||||
|
fieldErrors: const <String, String>{},
|
||||||
|
awaitingScoringOutcome: false,
|
||||||
|
consultant: state.consultant.copyWith(
|
||||||
|
legalType: individualPersonLegalType,
|
||||||
|
citizenship: state.consultant.citizenship,
|
||||||
|
taxResidentUs: state.consultant.taxResidentUs ?? false,
|
||||||
|
foreignTaxResident: state.consultant.foreignTaxResident ?? false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
log(e.toString());
|
log(e.toString());
|
||||||
emit(state.copyWith(loading: false, error: e.toString()));
|
final parsed = parseLaravelValidationErrors(e);
|
||||||
}
|
emit(
|
||||||
}
|
state.copyWith(
|
||||||
|
loading: false,
|
||||||
Future<void> disableLoading() async {
|
error: parsed.summary,
|
||||||
try {
|
fieldErrors: parsed.fieldErrors,
|
||||||
final certPhotos = await consultantRepository.getConsultantCertPhotos();
|
awaitingScoringOutcome: false,
|
||||||
emit(state.copyWith(loading: false, certPhotos: certPhotos, error: null));
|
),
|
||||||
} catch (e, st) {
|
);
|
||||||
await reportFrom(e, st, this);
|
|
||||||
log(e.toString());
|
|
||||||
emit(state.copyWith(loading: false, error: e.toString()));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,41 +232,326 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
|||||||
if (slug != null) {
|
if (slug != null) {
|
||||||
await getMyRequestStatus(slug);
|
await getMyRequestStatus(slug);
|
||||||
} else {
|
} else {
|
||||||
await disableLoading();
|
await disableLoading(slug: _userSlug);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateConsultantProfileModel(ConsultantProfileModel consultant, [bool acceptOffer = false]) {
|
void updateConsultantProfileModel(
|
||||||
emit(state.copyWith(consultant: consultant, acceptOffer: acceptOffer, loading: false, error: null));
|
ConsultantProfileModel consultant, [
|
||||||
|
bool? acceptOffer,
|
||||||
|
bool? acceptFrdoAgreement,
|
||||||
|
]) {
|
||||||
|
final nextFieldErrors = state.fieldErrors.isEmpty
|
||||||
|
? const <String, String>{}
|
||||||
|
: PassportDataValidator.validate(consultant);
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
consultant: consultant,
|
||||||
|
acceptOffer: acceptOffer ?? state.acceptOffer,
|
||||||
|
acceptFrdoAgreement: acceptFrdoAgreement ?? state.acceptFrdoAgreement,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
fieldErrors: nextFieldErrors,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String> validatePassportFields() {
|
||||||
|
final errors = PassportDataValidator.validate(state.consultant);
|
||||||
|
emit(state.copyWith(fieldErrors: errors, error: null));
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateAgreements({bool? acceptOffer, bool? acceptFrdoAgreement}) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
acceptOffer: acceptOffer ?? state.acceptOffer,
|
||||||
|
acceptFrdoAgreement: acceptFrdoAgreement ?? state.acceptFrdoAgreement,
|
||||||
|
error: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get hasAcceptedRequiredAgreements {
|
||||||
|
if (_update) {
|
||||||
|
return state.acceptOffer;
|
||||||
|
}
|
||||||
|
return state.acceptOffer && state.acceptFrdoAgreement;
|
||||||
|
}
|
||||||
|
|
||||||
|
ConsultantProfileModel buildSubmitPayload() {
|
||||||
|
final withLegalType = state.consultant.copyWith(
|
||||||
|
legalType: individualPersonLegalType,
|
||||||
|
citizenship: state.consultant.citizenship,
|
||||||
|
taxResidentUs: state.consultant.taxResidentUs ?? false,
|
||||||
|
foreignTaxResident: state.consultant.foreignTaxResident ?? false,
|
||||||
|
certificates: state.consultant.certificates
|
||||||
|
?.map(
|
||||||
|
(course) => CertificatesModel(
|
||||||
|
title: course.title,
|
||||||
|
date:
|
||||||
|
LegalEntityModel.normalizeApiDate(course.date) ?? course.date,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (_update) {
|
||||||
|
return ConsultantProfileModel(
|
||||||
|
hasMedicalEducation: withLegalType.hasMedicalEducation,
|
||||||
|
socialLinks: withLegalType.socialLinks,
|
||||||
|
photos: withLegalType.photos,
|
||||||
|
certificates: withLegalType.certificates,
|
||||||
|
legalType: withLegalType.legalType,
|
||||||
|
lastName: withLegalType.lastName,
|
||||||
|
firstName: withLegalType.firstName,
|
||||||
|
middleName: withLegalType.middleName,
|
||||||
|
inn: withLegalType.inn,
|
||||||
|
snils: withLegalType.snils,
|
||||||
|
passportSeries: withLegalType.passportSeries,
|
||||||
|
passportNumber: withLegalType.passportNumber,
|
||||||
|
passportIssueDate: withLegalType.passportIssueDate,
|
||||||
|
passportIssuer: withLegalType.passportIssuer,
|
||||||
|
passportIssuerCode: withLegalType.passportIssuerCode,
|
||||||
|
birthDate: withLegalType.birthDate,
|
||||||
|
birthPlace: withLegalType.birthPlace,
|
||||||
|
registrationAddress: withLegalType.registrationAddress,
|
||||||
|
residenceAddress: withLegalType.residenceAddress,
|
||||||
|
citizenship: withLegalType.citizenship,
|
||||||
|
taxResidentUs: withLegalType.taxResidentUs,
|
||||||
|
foreignTaxResident: withLegalType.foreignTaxResident,
|
||||||
|
sbpPhone: withLegalType.sbpPhone,
|
||||||
|
sbpBankMemberId: withLegalType.sbpBankMemberId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return withLegalType.copyWith(
|
||||||
|
agreementAccepted: state.acceptOffer,
|
||||||
|
frdoAgreementAccepted: state.acceptFrdoAgreement,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> registerConsultant() async {
|
Future<void> registerConsultant() async {
|
||||||
emit(state.copyWith(error: null));
|
final passportErrors = validatePassportFields();
|
||||||
|
if (passportErrors.isNotEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show scoring widget immediately while request / job are in progress.
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
error: null,
|
||||||
|
fieldErrors: const <String, String>{},
|
||||||
|
alfaScoringStatus: AlfaScoringStatus.beneficiaryCreating,
|
||||||
|
awaitingScoringOutcome: true,
|
||||||
|
scoringRetryInProgress: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (state.newAddedPhotos.isNotEmpty) {
|
if (state.newAddedPhotos.isNotEmpty) {
|
||||||
await consultantRepository.storeConsultantCertPhotos(files: state.newAddedPhotos);
|
await consultantRepository.storeConsultantCertPhotos(
|
||||||
|
files: state.newAddedPhotos,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (state.deletedPhotoIds.isNotEmpty) {
|
if (state.deletedPhotoIds.isNotEmpty) {
|
||||||
for (final int id in state.deletedPhotoIds) {
|
for (final int id in state.deletedPhotoIds) {
|
||||||
await consultantRepository.deletePhoto(id);
|
await consultantRepository.deletePhoto(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await consultantRepository.registerConsultant(consultant: state.consultant, update: _update);
|
await consultantRepository.registerConsultant(
|
||||||
|
consultant: buildSubmitPayload(),
|
||||||
|
update: _update,
|
||||||
|
);
|
||||||
|
_update = true;
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
isUpdating: true,
|
||||||
|
alfaScoringStatus: AlfaScoringStatus.beneficiaryCreating,
|
||||||
|
isScoringPolling: true,
|
||||||
|
scoringRetryInProgress: false,
|
||||||
|
awaitingScoringOutcome: true,
|
||||||
|
newAddedPhotos: const <File>[],
|
||||||
|
deletedPhotoIds: const <int>[],
|
||||||
|
fieldErrors: const <String, String>{},
|
||||||
|
error: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
startScoringPoll();
|
||||||
|
} on DioException catch (e, st) {
|
||||||
|
await reportFrom(e, st, this);
|
||||||
|
log(e.toString());
|
||||||
|
final parsed = parseLaravelValidationErrors(e);
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
error: parsed.summary,
|
||||||
|
fieldErrors: parsed.fieldErrors,
|
||||||
|
scoringRetryInProgress: false,
|
||||||
|
awaitingScoringOutcome: false,
|
||||||
|
isScoringPolling: false,
|
||||||
|
alfaScoringStatus: AlfaScoringStatus.none,
|
||||||
|
),
|
||||||
|
);
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
log(e.toString());
|
log(e.toString());
|
||||||
emit(state.copyWith(error: e.toString()));
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
error: e.toString(),
|
||||||
|
scoringRetryInProgress: false,
|
||||||
|
awaitingScoringOutcome: false,
|
||||||
|
isScoringPolling: false,
|
||||||
|
alfaScoringStatus: AlfaScoringStatus.none,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> retryScoring() async {
|
||||||
|
if (!state.alfaScoringStatus.canRetry || state.scoringRetryInProgress) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final passportErrors = validatePassportFields();
|
||||||
|
if (passportErrors.isNotEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
scoringRetryInProgress: true,
|
||||||
|
error: null,
|
||||||
|
fieldErrors: const <String, String>{},
|
||||||
|
alfaScoringStatus: AlfaScoringStatus.beneficiaryCreating,
|
||||||
|
isScoringPolling: true,
|
||||||
|
awaitingScoringOutcome: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_update = true;
|
||||||
|
try {
|
||||||
|
await consultantRepository.registerConsultant(
|
||||||
|
consultant: buildSubmitPayload(),
|
||||||
|
update: true,
|
||||||
|
);
|
||||||
|
startScoringPoll();
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
scoringRetryInProgress: false,
|
||||||
|
isUpdating: true,
|
||||||
|
awaitingScoringOutcome: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} on DioException catch (e, st) {
|
||||||
|
await reportFrom(e, st, this);
|
||||||
|
log(e.toString());
|
||||||
|
final parsed = parseLaravelValidationErrors(e);
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
scoringRetryInProgress: false,
|
||||||
|
isScoringPolling: false,
|
||||||
|
awaitingScoringOutcome: false,
|
||||||
|
alfaScoringStatus: AlfaScoringStatus.scoringFailed,
|
||||||
|
error: parsed.summary,
|
||||||
|
fieldErrors: parsed.fieldErrors,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e, st) {
|
||||||
|
await reportFrom(e, st, this);
|
||||||
|
log(e.toString());
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
scoringRetryInProgress: false,
|
||||||
|
isScoringPolling: false,
|
||||||
|
awaitingScoringOutcome: false,
|
||||||
|
alfaScoringStatus: AlfaScoringStatus.scoringFailed,
|
||||||
|
error: e.toString(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void startScoringPoll() {
|
||||||
|
final slug = _userSlug;
|
||||||
|
if (slug == null || slug.isEmpty) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(isScoringPolling: false, awaitingScoringOutcome: false),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_stopScoringPoll();
|
||||||
|
emit(state.copyWith(isScoringPolling: true));
|
||||||
|
_scoringPollTimer = Timer.periodic(
|
||||||
|
scoringPollInterval,
|
||||||
|
(_) => _pollScoringStatus(slug),
|
||||||
|
);
|
||||||
|
unawaited(_pollScoringStatus(slug));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pollScoringStatus(String slug) async {
|
||||||
|
if (isClosed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final status = await consultantRepository.getMyRequestStatus(slug);
|
||||||
|
if (isClosed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final scoringStatus =
|
||||||
|
status.legalEntity?.alfaScoringStatus ?? AlfaScoringStatus.none;
|
||||||
|
// API stays on `none` until ProcessConsultantScoringJob starts
|
||||||
|
// (ScoringStarted → beneficiary_creating). Keep optimistic processing
|
||||||
|
// UI and continue polling until a real terminal/processing status arrives.
|
||||||
|
if (scoringStatus == AlfaScoringStatus.none) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
requestModel: status,
|
||||||
|
alfaScoringStatus: AlfaScoringStatus.beneficiaryCreating,
|
||||||
|
isScoringPolling: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final isTerminal = scoringStatus.isTerminal;
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
requestModel: status,
|
||||||
|
alfaScoringStatus: scoringStatus,
|
||||||
|
isScoringPolling: scoringStatus.isProcessing,
|
||||||
|
// Clear session flag on terminal so success sheet can fire once
|
||||||
|
// (listenWhen: previous.awaiting && !current.awaiting && complete).
|
||||||
|
awaitingScoringOutcome: isTerminal
|
||||||
|
? false
|
||||||
|
: state.awaitingScoringOutcome,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!scoringStatus.isProcessing) {
|
||||||
|
_stopScoringPoll();
|
||||||
|
}
|
||||||
|
} catch (e, st) {
|
||||||
|
await reportFrom(e, st, this);
|
||||||
|
log(e.toString());
|
||||||
|
// Keep polling on transient errors while scoring is in progress.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _stopScoringPoll() {
|
||||||
|
_scoringPollTimer?.cancel();
|
||||||
|
_scoringPollTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> addCertPhotos(List<File> files) async {
|
Future<void> addCertPhotos(List<File> files) async {
|
||||||
if (files.isEmpty) {
|
if (files.isEmpty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
final compressedFiles = await ImageCompressUtility.compressFilesList(files);
|
final compressedFiles = await ImageCompressUtility.compressFilesList(
|
||||||
|
files,
|
||||||
|
);
|
||||||
|
|
||||||
emit(state.copyWith(newAddedPhotos: [...state.newAddedPhotos, ...compressedFiles], error: null));
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
newAddedPhotos: [...state.newAddedPhotos, ...compressedFiles],
|
||||||
|
error: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
log(e.toString());
|
log(e.toString());
|
||||||
@@ -130,7 +565,19 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (path != null) {
|
if (path != null) {
|
||||||
emit(state.copyWith(newAddedPhotos: state.newAddedPhotos.where((file) => file.uri.path != path).toList()));
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
newAddedPhotos: state.newAddedPhotos
|
||||||
|
.where((file) => file.uri.path != path)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> close() {
|
||||||
|
_stopScoringPoll();
|
||||||
|
return super.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart';
|
||||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
part 'register_consultant_state.freezed.dart';
|
part 'register_consultant_state.freezed.dart';
|
||||||
@@ -12,12 +14,22 @@ abstract class RegisterConsultantState with _$RegisterConsultantState {
|
|||||||
const factory RegisterConsultantState({
|
const factory RegisterConsultantState({
|
||||||
required ConsultantProfileModel consultant,
|
required ConsultantProfileModel consultant,
|
||||||
required bool acceptOffer,
|
required bool acceptOffer,
|
||||||
|
@Default(false) bool acceptFrdoAgreement,
|
||||||
required bool loading,
|
required bool loading,
|
||||||
required bool isUpdating,
|
required bool isUpdating,
|
||||||
ConsultationRequestModel? requestModel,
|
ConsultationRequestModel? requestModel,
|
||||||
@Default(<ConsultantCertPhotoModel>[]) List<ConsultantCertPhotoModel> certPhotos,
|
@Default(<ConsultantCertPhotoModel>[])
|
||||||
|
List<ConsultantCertPhotoModel> certPhotos,
|
||||||
|
@Default(<SbpBankModel>[]) List<SbpBankModel> sbpBanks,
|
||||||
@Default(<File>[]) List<File> newAddedPhotos,
|
@Default(<File>[]) List<File> newAddedPhotos,
|
||||||
@Default(<int>[]) List<int> deletedPhotoIds,
|
@Default(<int>[]) List<int> deletedPhotoIds,
|
||||||
|
@Default(AlfaScoringStatus.none) AlfaScoringStatus alfaScoringStatus,
|
||||||
|
@Default(false) bool isScoringPolling,
|
||||||
|
@Default(false) bool scoringRetryInProgress,
|
||||||
|
|
||||||
|
/// True only after successful register/retry in this session until scoring is terminal.
|
||||||
|
@Default(false) bool awaitingScoringOutcome,
|
||||||
|
@Default(<String, String>{}) Map<String, String> fieldErrors,
|
||||||
String? error,
|
String? error,
|
||||||
}) = _RegisterConsultantState;
|
}) = _RegisterConsultantState;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ class AdditionalSettingsScreen extends StatefulWidget {
|
|||||||
const AdditionalSettingsScreen({super.key});
|
const AdditionalSettingsScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AdditionalSettingsScreen> createState() => _AdditionalSettingsScreenState();
|
State<AdditionalSettingsScreen> createState() =>
|
||||||
|
_AdditionalSettingsScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AdditionalSettingsScreenState extends State<AdditionalSettingsScreen> {
|
class _AdditionalSettingsScreenState extends State<AdditionalSettingsScreen> {
|
||||||
@@ -17,7 +18,12 @@ class _AdditionalSettingsScreenState extends State<AdditionalSettingsScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
if (context.read<RegisterConsultantCubit>().state.consultant.hasMedicalEducation != null) {
|
if (context
|
||||||
|
.read<RegisterConsultantCubit>()
|
||||||
|
.state
|
||||||
|
.consultant
|
||||||
|
.hasMedicalEducation !=
|
||||||
|
null) {
|
||||||
haveMedicalDegree = context
|
haveMedicalDegree = context
|
||||||
.read<RegisterConsultantCubit>()
|
.read<RegisterConsultantCubit>()
|
||||||
.state
|
.state
|
||||||
@@ -52,7 +58,8 @@ class _AdditionalSettingsScreenState extends State<AdditionalSettingsScreen> {
|
|||||||
value: publishDiplomasAndCertificatesInProfile,
|
value: publishDiplomasAndCertificatesInProfile,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
setState(() {
|
setState(() {
|
||||||
publishDiplomasAndCertificatesInProfile = !publishDiplomasAndCertificatesInProfile;
|
publishDiplomasAndCertificatesInProfile =
|
||||||
|
!publishDiplomasAndCertificatesInProfile;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -64,11 +71,15 @@ class _AdditionalSettingsScreenState extends State<AdditionalSettingsScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
haveMedicalDegree = !haveMedicalDegree;
|
haveMedicalDegree = !haveMedicalDegree;
|
||||||
});
|
});
|
||||||
context.read<RegisterConsultantCubit>().updateConsultantProfileModel(
|
context
|
||||||
context.read<RegisterConsultantCubit>().state.consultant.copyWith(
|
.read<RegisterConsultantCubit>()
|
||||||
hasMedicalEducation: haveMedicalDegree,
|
.updateConsultantProfileModel(
|
||||||
),
|
context
|
||||||
);
|
.read<RegisterConsultantCubit>()
|
||||||
|
.state
|
||||||
|
.consultant
|
||||||
|
.copyWith(hasMedicalEducation: haveMedicalDegree),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
class BecomeConsultantScreen extends StatefulWidget {
|
class BecomeConsultantScreen extends StatefulWidget {
|
||||||
const BecomeConsultantScreen({super.key});
|
final String? slug;
|
||||||
|
|
||||||
|
const BecomeConsultantScreen({super.key, this.slug});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<BecomeConsultantScreen> createState() => _BecomeConsultantScreenState();
|
State<BecomeConsultantScreen> createState() => _BecomeConsultantScreenState();
|
||||||
@@ -17,7 +19,10 @@ class _BecomeConsultantScreenState extends State<BecomeConsultantScreen> {
|
|||||||
title: const Text('Стать консультантом'),
|
title: const Text('Стать консультантом'),
|
||||||
automaticallyImplyLeading: false,
|
automaticallyImplyLeading: false,
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
leading: IconButton(onPressed: () => GoRouter.of(context).pop(), icon: const Icon(Icons.close)),
|
leading: IconButton(
|
||||||
|
onPressed: () => GoRouter.of(context).pop(),
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
body: const Padding(
|
body: const Padding(
|
||||||
padding: EdgeInsets.all(16),
|
padding: EdgeInsets.all(16),
|
||||||
@@ -32,16 +37,27 @@ class _BecomeConsultantScreenState extends State<BecomeConsultantScreen> {
|
|||||||
|
|
||||||
InkWell _startRegistrationButton() {
|
InkWell _startRegistrationButton() {
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: () => GoRouter.of(context).pushReplacementNamed('stepperBecomeConsultantScreen', extra: {'slug': null}),
|
onTap: () => GoRouter.of(context).pushReplacementNamed(
|
||||||
|
'stepperBecomeConsultantScreen',
|
||||||
|
extra: {'slug': widget.slug, 'isNewRegistration': true},
|
||||||
|
),
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
child: Ink(
|
child: Ink(
|
||||||
height: 52,
|
height: 52,
|
||||||
width: MediaQuery.of(context).size.width - 32,
|
width: MediaQuery.of(context).size.width - 32,
|
||||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
color: appTheme.blueA400,
|
||||||
|
),
|
||||||
child: const Center(
|
child: const Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Начать регистрацию',
|
'Начать регистрацию',
|
||||||
style: TextStyle(color: Colors.white, height: 1, fontWeight: FontWeight.w600, fontSize: 18),
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
height: 1,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 18,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -7,15 +7,18 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:eatvio_app/core/utils/date_time_utils.dart';
|
import 'package:eatvio_app/core/utils/date_time_utils.dart';
|
||||||
import 'package:flutter/scheduler.dart';
|
import 'package:flutter/scheduler.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
class CertificatesAndDiplomasScreen extends StatefulWidget {
|
class CertificatesAndDiplomasScreen extends StatefulWidget {
|
||||||
const CertificatesAndDiplomasScreen({super.key});
|
const CertificatesAndDiplomasScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<CertificatesAndDiplomasScreen> createState() => _CertificatesAndDiplomasScreenState();
|
State<CertificatesAndDiplomasScreen> createState() =>
|
||||||
|
_CertificatesAndDiplomasScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasScreen> {
|
class _CertificatesAndDiplomasScreenState
|
||||||
|
extends State<CertificatesAndDiplomasScreen> {
|
||||||
final scrollController = ScrollController();
|
final scrollController = ScrollController();
|
||||||
|
|
||||||
List<DiplomaEntity> diplomasList = [
|
List<DiplomaEntity> diplomasList = [
|
||||||
@@ -29,9 +32,15 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
if (context.read<RegisterConsultantCubit>().state.consultant.certificates != null) {
|
if (context.read<RegisterConsultantCubit>().state.consultant.certificates !=
|
||||||
|
null) {
|
||||||
diplomasList = [
|
diplomasList = [
|
||||||
...context.read<RegisterConsultantCubit>().state.consultant.certificates!.map(
|
...context
|
||||||
|
.read<RegisterConsultantCubit>()
|
||||||
|
.state
|
||||||
|
.consultant
|
||||||
|
.certificates!
|
||||||
|
.map(
|
||||||
(e) => DiplomaEntity(
|
(e) => DiplomaEntity(
|
||||||
date: DateTime.tryParse(e.date),
|
date: DateTime.tryParse(e.date),
|
||||||
textEditingController: TextEditingController(text: e.title),
|
textEditingController: TextEditingController(text: e.title),
|
||||||
@@ -70,7 +79,8 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
|
|||||||
ListView.separated(
|
ListView.separated(
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
itemBuilder: (context, index) => _diplomaInfo(diploma: diplomasList[index]),
|
itemBuilder: (context, index) =>
|
||||||
|
_diplomaInfo(diploma: diplomasList[index]),
|
||||||
separatorBuilder: (context, index) => Padding(
|
separatorBuilder: (context, index) => Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
child: _divider(context),
|
child: _divider(context),
|
||||||
@@ -93,9 +103,7 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Column _diplomaInfo({
|
Column _diplomaInfo({required DiplomaEntity diploma}) {
|
||||||
required DiplomaEntity diploma,
|
|
||||||
}) {
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
CustomTextFormField(
|
CustomTextFormField(
|
||||||
@@ -111,7 +119,9 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: MediaQuery.of(context).size.width - (diploma.enableDeleteButton ? 92 : 32),
|
width:
|
||||||
|
MediaQuery.of(context).size.width -
|
||||||
|
(diploma.enableDeleteButton ? 92 : 32),
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
onTap: onTapCalendar(context, diploma),
|
onTap: onTapCalendar(context, diploma),
|
||||||
trailing: Icon(
|
trailing: Icon(
|
||||||
@@ -122,7 +132,9 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
|
|||||||
title: Text(
|
title: Text(
|
||||||
diploma.date?.format('dd MMMM yyyy', 'ru') ?? 'Дата выдачи',
|
diploma.date?.format('dd MMMM yyyy', 'ru') ?? 'Дата выдачи',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: diploma.date == null ? Colors.grey.shade400 : Colors.black,
|
color: diploma.date == null
|
||||||
|
? Colors.grey.shade400
|
||||||
|
: Colors.black,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
tileColor: const Color(0xFFF6F6F6),
|
tileColor: const Color(0xFFF6F6F6),
|
||||||
@@ -148,9 +160,7 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
|
|||||||
color: const Color(0xFFF6F6F6),
|
color: const Color(0xFFF6F6F6),
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: const Icon(Icons.delete_outline_rounded),
|
||||||
Icons.delete_outline_rounded,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -190,16 +200,14 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
|
|||||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||||
),
|
),
|
||||||
color: CupertinoColors.systemBackground.resolveFrom(context),
|
color: CupertinoColors.systemBackground.resolveFrom(context),
|
||||||
child: SafeArea(
|
child: SafeArea(top: false, child: child),
|
||||||
top: false,
|
|
||||||
child: child,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void checkLast() {
|
void checkLast() {
|
||||||
if (diplomasList.last.textEditingController.text.isNotEmpty || diplomasList.last.date != null) {
|
if (diplomasList.last.textEditingController.text.isNotEmpty ||
|
||||||
|
diplomasList.last.date != null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
diplomasList.add(
|
diplomasList.add(
|
||||||
DiplomaEntity(
|
DiplomaEntity(
|
||||||
@@ -230,12 +238,19 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
|
|||||||
|
|
||||||
void _saveState() {
|
void _saveState() {
|
||||||
context.read<RegisterConsultantCubit>().updateConsultantProfileModel(
|
context.read<RegisterConsultantCubit>().updateConsultantProfileModel(
|
||||||
context.read<RegisterConsultantCubit>().state.consultant.copyWith(
|
context.read<RegisterConsultantCubit>().state.consultant.copyWith(
|
||||||
certificates: diplomasList.map((e) => CertificatesModel(title: e.textEditingController.text, date: e.date.toString())).toList().sublist(
|
certificates: diplomasList
|
||||||
0,
|
.map(
|
||||||
diplomasList.length - 1,
|
(e) => CertificatesModel(
|
||||||
),
|
title: e.textEditingController.text,
|
||||||
|
date: e.date == null
|
||||||
|
? ''
|
||||||
|
: DateFormat('yyyy-MM-dd').format(e.date!),
|
||||||
),
|
),
|
||||||
);
|
)
|
||||||
|
.toList()
|
||||||
|
.sublist(0, diplomasList.length - 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import 'package:eatvio_app/core/app_config.dart';
|
import 'package:eatvio_app/core/app_config.dart';
|
||||||
import 'package:eatvio_app/core/const.dart';
|
import 'package:eatvio_app/core/const.dart';
|
||||||
import 'package:eatvio_app/core/locator_service.dart';
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
|
|
||||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/widgets/scoring_status_widget.dart';
|
||||||
|
import 'package:flutter/gestures.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
@@ -15,10 +18,29 @@ class LastStepScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _LastStepScreenState extends State<LastStepScreen> {
|
class _LastStepScreenState extends State<LastStepScreen> {
|
||||||
bool checkboxState = false;
|
static const _frdoUrl =
|
||||||
|
'https://obrnadzor.gov.ru/gosudarstvennye-uslugi-i-funkczii/7701537808-gosfunction/formirovanie-i-vedenie-federalnogo-reestra-svedenij-o-dokumentah-ob-obrazovanii-i-ili-o-kvalifikaczii-dokumentah-ob-obuchenii/';
|
||||||
|
|
||||||
|
late final TapGestureRecognizer _frdoTapRecognizer;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_frdoTapRecognizer = TapGestureRecognizer()
|
||||||
|
..onTap = () async => await launchUrl(Uri.parse(_frdoUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_frdoTapRecognizer.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final cubit = context.watch<RegisterConsultantCubit>();
|
||||||
|
final state = cubit.state;
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
padding: const EdgeInsets.only(bottom: 80, top: 16),
|
padding: const EdgeInsets.only(bottom: 80, top: 16),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -27,34 +49,138 @@ class _LastStepScreenState extends State<LastStepScreen> {
|
|||||||
children: [
|
children: [
|
||||||
const Padding(
|
const Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Text('Последний шаг', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, height: 1.1)),
|
child: Text(
|
||||||
|
'Последний шаг',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
height: 1.1,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
if (state.awaitingScoringOutcome ||
|
||||||
|
state.isScoringPolling ||
|
||||||
|
state.alfaScoringStatus !=
|
||||||
|
AlfaScoringStatus.none) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const ScoringStatusWidget(),
|
||||||
|
],
|
||||||
|
if (state.requestModel?.canceledModeration != null) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0x33FFCC00),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
state.requestModel!.canceledModeration!.title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
height: 1.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (state.requestModel!.canceledModeration!.additional !=
|
||||||
|
null) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
for (final item
|
||||||
|
in state
|
||||||
|
.requestModel!
|
||||||
|
.canceledModeration!
|
||||||
|
.additional!) ...[
|
||||||
|
Text(
|
||||||
|
'• $item',
|
||||||
|
style: const TextStyle(fontSize: 14, height: 1.3),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
CheckboxListTile(
|
CheckboxListTile(
|
||||||
value: checkboxState,
|
value: state.acceptOffer,
|
||||||
title: Text(
|
title: Text(
|
||||||
hidePlans
|
hidePlans
|
||||||
? 'Я ознакомлен и принимаю политику конфиденциальности и защиту персональных данных'
|
? 'Я ознакомлен и принимаю политику конфиденциальности и защиту персональных данных'
|
||||||
: 'Я ознакомлен и принимаю договор публичной офёрты',
|
: 'Я ознакомлен и принимаю публичную оферту и политику конфиденциальности',
|
||||||
style: const TextStyle(fontSize: 18, height: 1),
|
style: const TextStyle(fontSize: 18, height: 1.2),
|
||||||
),
|
),
|
||||||
onChanged: (_) {
|
onChanged: (value) {
|
||||||
setState(() {
|
cubit.updateAgreements(acceptOffer: value ?? false);
|
||||||
checkboxState = !checkboxState;
|
|
||||||
});
|
|
||||||
context.read<RegisterConsultantCubit>().updateConsultantProfileModel(context.read<RegisterConsultantCubit>().state.consultant, checkboxState);
|
|
||||||
},
|
},
|
||||||
activeColor: appTheme.blueA400,
|
activeColor: appTheme.blueA400,
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
|
),
|
||||||
|
if (!state.isUpdating) ...[
|
||||||
|
CheckboxListTile(
|
||||||
|
value: state.acceptFrdoAgreement,
|
||||||
|
title: Text.rich(
|
||||||
|
TextSpan(
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
height: 1.2,
|
||||||
|
color: Color(0xFF323F4B),
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
const TextSpan(
|
||||||
|
text:
|
||||||
|
'Обязательное требование: Один из моих документов об образовании есть в реестре ',
|
||||||
|
),
|
||||||
|
TextSpan(
|
||||||
|
text: 'ФРДО',
|
||||||
|
style: TextStyle(
|
||||||
|
color: appTheme.blueA400,
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
),
|
||||||
|
recognizer: _frdoTapRecognizer,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onChanged: (value) {
|
||||||
|
cubit.updateAgreements(acceptFrdoAgreement: value ?? false);
|
||||||
|
},
|
||||||
|
activeColor: appTheme.blueA400,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||||
|
child: _divider(context),
|
||||||
),
|
),
|
||||||
Padding(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), child: _divider(context)),
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
child: TextButton(
|
child: TextButton(
|
||||||
onPressed: () async => await launchUrl(Uri.parse(hidePlans ? '${sl<AppConfig>().baseUrl}/docs/privacy.pdf' : '${sl<AppConfig>().baseUrl}/oferta_bs.pdf')),
|
onPressed: () async => await launchUrl(
|
||||||
|
Uri.parse(
|
||||||
|
hidePlans
|
||||||
|
? '${sl<AppConfig>().baseUrl}/docs/privacy.pdf'
|
||||||
|
: '${sl<AppConfig>().baseUrl}/oferta_bs.pdf',
|
||||||
|
),
|
||||||
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
hidePlans ? 'Политика конфиденциальности и пользовательское соглашение' : 'Договор офёрты',
|
hidePlans
|
||||||
style: const TextStyle(height: 1, fontSize: 16, color: Color(0xFF323F4B), decoration: TextDecoration.underline),
|
? 'Политика конфиденциальности и пользовательское соглашение'
|
||||||
|
: 'Договор оферты',
|
||||||
|
style: const TextStyle(
|
||||||
|
height: 1,
|
||||||
|
fontSize: 16,
|
||||||
|
color: Color(0xFF323F4B),
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -67,7 +193,10 @@ class _LastStepScreenState extends State<LastStepScreen> {
|
|||||||
return Container(
|
return Container(
|
||||||
height: 0.4,
|
height: 0.4,
|
||||||
width: MediaQuery.of(context).size.width - 32,
|
width: MediaQuery.of(context).size.width - 32,
|
||||||
decoration: BoxDecoration(color: Colors.black26, borderRadius: BorderRadius.circular(16)),
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black26,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,608 @@
|
|||||||
|
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||||
|
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||||
|
import 'package:eatvio_app/core/widgets/custom_text_form_field.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/widgets/passport_field_with_error.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/widgets/passport_section_title.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flutter_typeahead/flutter_typeahead.dart';
|
||||||
|
import 'package:mask_text_input_formatter/mask_text_input_formatter.dart';
|
||||||
|
|
||||||
|
class PassportDataScreen extends StatefulWidget {
|
||||||
|
const PassportDataScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PassportDataScreen> createState() => _PassportDataScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PassportDataScreenState extends State<PassportDataScreen> {
|
||||||
|
late final TextEditingController lastNameController;
|
||||||
|
late final TextEditingController firstNameController;
|
||||||
|
late final TextEditingController middleNameController;
|
||||||
|
late final TextEditingController innController;
|
||||||
|
late final TextEditingController snilsController;
|
||||||
|
late final TextEditingController seriesController;
|
||||||
|
late final TextEditingController numberController;
|
||||||
|
late final TextEditingController issueDateController;
|
||||||
|
late final TextEditingController issuerController;
|
||||||
|
late final TextEditingController issuerCodeController;
|
||||||
|
late final TextEditingController birthDateController;
|
||||||
|
late final TextEditingController birthPlaceController;
|
||||||
|
late final TextEditingController citizenshipController;
|
||||||
|
late final TextEditingController registrationAddressController;
|
||||||
|
late final TextEditingController residenceAddressController;
|
||||||
|
late final TextEditingController sbpPhoneController;
|
||||||
|
late final TextEditingController sbpBankController;
|
||||||
|
final sbpBankSuggestionsController = SuggestionsController<SbpBankModel>();
|
||||||
|
|
||||||
|
bool taxResidentUs = false;
|
||||||
|
bool foreignTaxResident = false;
|
||||||
|
String? selectedSbpBankMemberId;
|
||||||
|
String? selectedSbpBankName;
|
||||||
|
|
||||||
|
final snilsMask = MaskTextInputFormatter(
|
||||||
|
mask: '###-###-### ##',
|
||||||
|
filter: {'#': RegExp(r'[0-9]')},
|
||||||
|
type: MaskAutoCompletionType.lazy,
|
||||||
|
);
|
||||||
|
final dateMaskIssue = MaskTextInputFormatter(
|
||||||
|
mask: '##.##.####',
|
||||||
|
filter: {'#': RegExp(r'[0-9]')},
|
||||||
|
type: MaskAutoCompletionType.lazy,
|
||||||
|
);
|
||||||
|
final dateMaskBirth = MaskTextInputFormatter(
|
||||||
|
mask: '##.##.####',
|
||||||
|
filter: {'#': RegExp(r'[0-9]')},
|
||||||
|
type: MaskAutoCompletionType.lazy,
|
||||||
|
);
|
||||||
|
final issuerCodeMask = MaskTextInputFormatter(
|
||||||
|
mask: '###-###',
|
||||||
|
filter: {'#': RegExp(r'[0-9]')},
|
||||||
|
type: MaskAutoCompletionType.lazy,
|
||||||
|
);
|
||||||
|
final phoneMask = MaskTextInputFormatter(
|
||||||
|
mask: '+7 (###) ### ## ##',
|
||||||
|
filter: {'#': RegExp(r'[0-9]')},
|
||||||
|
type: MaskAutoCompletionType.lazy,
|
||||||
|
);
|
||||||
|
final _alfaPersonNameFormatter = FilteringTextInputFormatter.allow(
|
||||||
|
RegExp(r'[a-zA-Zа-яА-ЯёЁ ,.-]'),
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final consultant = context.read<RegisterConsultantCubit>().state.consultant;
|
||||||
|
lastNameController = TextEditingController(text: consultant.lastName ?? '');
|
||||||
|
firstNameController = TextEditingController(
|
||||||
|
text: consultant.firstName ?? '',
|
||||||
|
);
|
||||||
|
middleNameController = TextEditingController(
|
||||||
|
text: consultant.middleName ?? '',
|
||||||
|
);
|
||||||
|
innController = TextEditingController(text: consultant.inn ?? '');
|
||||||
|
snilsController = TextEditingController(text: consultant.snils ?? '');
|
||||||
|
seriesController = TextEditingController(
|
||||||
|
text: consultant.passportSeries ?? '',
|
||||||
|
);
|
||||||
|
numberController = TextEditingController(
|
||||||
|
text: consultant.passportNumber ?? '',
|
||||||
|
);
|
||||||
|
issueDateController = TextEditingController(
|
||||||
|
text: _apiDateToUi(consultant.passportIssueDate),
|
||||||
|
);
|
||||||
|
issuerController = TextEditingController(
|
||||||
|
text: consultant.passportIssuer ?? '',
|
||||||
|
);
|
||||||
|
issuerCodeController = TextEditingController(
|
||||||
|
text: consultant.passportIssuerCode ?? '',
|
||||||
|
);
|
||||||
|
birthDateController = TextEditingController(
|
||||||
|
text: _apiDateToUi(consultant.birthDate),
|
||||||
|
);
|
||||||
|
birthPlaceController = TextEditingController(
|
||||||
|
text: consultant.birthPlace ?? '',
|
||||||
|
);
|
||||||
|
citizenshipController = TextEditingController(text: consultant.citizenship);
|
||||||
|
registrationAddressController = TextEditingController(
|
||||||
|
text: consultant.registrationAddress ?? '',
|
||||||
|
);
|
||||||
|
residenceAddressController = TextEditingController(
|
||||||
|
text: consultant.residenceAddress ?? '',
|
||||||
|
);
|
||||||
|
sbpPhoneController = TextEditingController(text: consultant.sbpPhone ?? '');
|
||||||
|
selectedSbpBankMemberId = consultant.sbpBankMemberId;
|
||||||
|
final selectedBank = context
|
||||||
|
.read<RegisterConsultantCubit>()
|
||||||
|
.findSbpBankById(consultant.sbpBankMemberId);
|
||||||
|
selectedSbpBankName = selectedBank?.bankName;
|
||||||
|
sbpBankController = TextEditingController(text: selectedSbpBankName ?? '');
|
||||||
|
taxResidentUs = consultant.taxResidentUs ?? false;
|
||||||
|
foreignTaxResident = consultant.foreignTaxResident ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _hydrateSbpBankNameIfNeeded() {
|
||||||
|
if (selectedSbpBankMemberId == null || selectedSbpBankMemberId!.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sbpBankController.text.trim().isNotEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final selectedBank = context
|
||||||
|
.read<RegisterConsultantCubit>()
|
||||||
|
.findSbpBankById(selectedSbpBankMemberId);
|
||||||
|
if (selectedBank == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectedSbpBankName = selectedBank.bankName;
|
||||||
|
sbpBankController.text = selectedBank.bankName;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
lastNameController.dispose();
|
||||||
|
firstNameController.dispose();
|
||||||
|
middleNameController.dispose();
|
||||||
|
innController.dispose();
|
||||||
|
snilsController.dispose();
|
||||||
|
seriesController.dispose();
|
||||||
|
numberController.dispose();
|
||||||
|
issueDateController.dispose();
|
||||||
|
issuerController.dispose();
|
||||||
|
issuerCodeController.dispose();
|
||||||
|
birthDateController.dispose();
|
||||||
|
birthPlaceController.dispose();
|
||||||
|
citizenshipController.dispose();
|
||||||
|
registrationAddressController.dispose();
|
||||||
|
residenceAddressController.dispose();
|
||||||
|
sbpPhoneController.dispose();
|
||||||
|
sbpBankController.dispose();
|
||||||
|
sbpBankSuggestionsController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _apiDateToUi(String? apiDate) {
|
||||||
|
if (apiDate == null || apiDate.isEmpty) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
final parts = apiDate.split('-');
|
||||||
|
if (parts.length != 3) {
|
||||||
|
return apiDate;
|
||||||
|
}
|
||||||
|
return '${parts[2]}.${parts[1]}.${parts[0]}';
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _uiDateToApi(String uiDate) {
|
||||||
|
final digits = uiDate.replaceAll(RegExp(r'\D'), '');
|
||||||
|
if (digits.length != 8) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return '${digits.substring(4, 8)}-${digits.substring(2, 4)}-${digits.substring(0, 2)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _uiPhoneToApi(String uiPhone) {
|
||||||
|
final digits = uiPhone.replaceAll(RegExp(r'\D'), '');
|
||||||
|
if (digits.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (digits.length == 11 && digits.startsWith('7')) {
|
||||||
|
return '+$digits';
|
||||||
|
}
|
||||||
|
if (digits.length == 10) {
|
||||||
|
return '+7$digits';
|
||||||
|
}
|
||||||
|
return uiPhone.trim().isEmpty ? null : uiPhone.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _syncToCubit() {
|
||||||
|
final cubit = context.read<RegisterConsultantCubit>();
|
||||||
|
final current = cubit.state.consultant;
|
||||||
|
cubit.updateConsultantProfileModel(
|
||||||
|
current.copyWith(
|
||||||
|
legalType: RegisterConsultantCubit.individualPersonLegalType,
|
||||||
|
lastName: lastNameController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: lastNameController.text.trim(),
|
||||||
|
firstName: firstNameController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: firstNameController.text.trim(),
|
||||||
|
middleName: middleNameController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: middleNameController.text.trim(),
|
||||||
|
inn: innController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: innController.text.trim(),
|
||||||
|
snils: snilsController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: snilsController.text.trim(),
|
||||||
|
passportSeries: seriesController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: seriesController.text.trim(),
|
||||||
|
passportNumber: numberController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: numberController.text.trim(),
|
||||||
|
passportIssueDate: _uiDateToApi(issueDateController.text),
|
||||||
|
passportIssuer: issuerController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: issuerController.text.trim(),
|
||||||
|
passportIssuerCode: issuerCodeController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: issuerCodeController.text.trim(),
|
||||||
|
birthDate: _uiDateToApi(birthDateController.text),
|
||||||
|
birthPlace: birthPlaceController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: birthPlaceController.text.trim(),
|
||||||
|
citizenship: citizenshipController.text.trim().isEmpty
|
||||||
|
? 'RU'
|
||||||
|
: citizenshipController.text.trim().toUpperCase(),
|
||||||
|
registrationAddress: registrationAddressController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: registrationAddressController.text.trim(),
|
||||||
|
residenceAddress: residenceAddressController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: residenceAddressController.text.trim(),
|
||||||
|
taxResidentUs: taxResidentUs,
|
||||||
|
foreignTaxResident: foreignTaxResident,
|
||||||
|
sbpPhone: _uiPhoneToApi(sbpPhoneController.text),
|
||||||
|
sbpBankMemberId: selectedSbpBankMemberId,
|
||||||
|
),
|
||||||
|
cubit.state.acceptOffer,
|
||||||
|
cubit.state.acceptFrdoAgreement,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _selectSbpBank(SbpBankModel bank) {
|
||||||
|
setState(() {
|
||||||
|
selectedSbpBankMemberId = bank.bankMemberId;
|
||||||
|
selectedSbpBankName = bank.bankName;
|
||||||
|
sbpBankController.text = bank.bankName;
|
||||||
|
});
|
||||||
|
_syncToCubit();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearSbpBankIfNeeded(String value) {
|
||||||
|
if (selectedSbpBankMemberId != null && value != selectedSbpBankName) {
|
||||||
|
setState(() {
|
||||||
|
selectedSbpBankMemberId = null;
|
||||||
|
selectedSbpBankName = null;
|
||||||
|
});
|
||||||
|
_syncToCubit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return BlocListener<RegisterConsultantCubit, RegisterConsultantState>(
|
||||||
|
listenWhen: (previous, current) => previous.sbpBanks != current.sbpBanks,
|
||||||
|
listener: (context, state) => _hydrateSbpBankNameIfNeeded(),
|
||||||
|
child: BlocBuilder<RegisterConsultantCubit, RegisterConsultantState>(
|
||||||
|
buildWhen: (previous, current) =>
|
||||||
|
previous.fieldErrors != current.fieldErrors,
|
||||||
|
builder: (context, state) {
|
||||||
|
final errors = state.fieldErrors;
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
bottom: 80,
|
||||||
|
right: 16,
|
||||||
|
left: 16,
|
||||||
|
top: 16,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const PassportScreenTitle(title: 'Паспортные данные'),
|
||||||
|
const PassportSectionTitle(
|
||||||
|
title: 'Персональные данные (как в паспорте)',
|
||||||
|
),
|
||||||
|
PassportFieldWithError(
|
||||||
|
error: errors['last_name'],
|
||||||
|
child: CustomTextFormField(
|
||||||
|
hintText: 'Фамилия',
|
||||||
|
controller: lastNameController,
|
||||||
|
maxLength: 50,
|
||||||
|
inputFormatters: [_alfaPersonNameFormatter],
|
||||||
|
hasError: errors.containsKey('last_name'),
|
||||||
|
onChanged: (_) => _syncToCubit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
PassportFieldWithError(
|
||||||
|
error: errors['first_name'],
|
||||||
|
child: CustomTextFormField(
|
||||||
|
hintText: 'Имя',
|
||||||
|
controller: firstNameController,
|
||||||
|
maxLength: 50,
|
||||||
|
inputFormatters: [_alfaPersonNameFormatter],
|
||||||
|
hasError: errors.containsKey('first_name'),
|
||||||
|
onChanged: (_) => _syncToCubit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
PassportFieldWithError(
|
||||||
|
error: errors['middle_name'],
|
||||||
|
child: CustomTextFormField(
|
||||||
|
hintText: 'Отчество',
|
||||||
|
controller: middleNameController,
|
||||||
|
maxLength: 50,
|
||||||
|
inputFormatters: [_alfaPersonNameFormatter],
|
||||||
|
hasError: errors.containsKey('middle_name'),
|
||||||
|
onChanged: (_) => _syncToCubit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PassportSectionTitle(title: 'Идентификация'),
|
||||||
|
PassportFieldWithError(
|
||||||
|
error: errors['inn'],
|
||||||
|
child: CustomTextFormField(
|
||||||
|
hintText: 'ИНН (12 цифр)',
|
||||||
|
controller: innController,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
maxLength: 12,
|
||||||
|
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||||
|
hasError: errors.containsKey('inn'),
|
||||||
|
onChanged: (_) => _syncToCubit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
PassportFieldWithError(
|
||||||
|
error: errors['snils'],
|
||||||
|
child: CustomTextFormField(
|
||||||
|
hintText: 'СНИЛС',
|
||||||
|
controller: snilsController,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
inputFormatters: [snilsMask],
|
||||||
|
hasError: errors.containsKey('snils'),
|
||||||
|
onChanged: (_) => _syncToCubit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
PassportFieldWithError(
|
||||||
|
error: errors['birth_date'],
|
||||||
|
child: CustomTextFormField(
|
||||||
|
hintText: 'Дата рождения (ДД.ММ.ГГГГ)',
|
||||||
|
controller: birthDateController,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
inputFormatters: [dateMaskBirth],
|
||||||
|
hasError: errors.containsKey('birth_date'),
|
||||||
|
onChanged: (_) => _syncToCubit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
PassportFieldWithError(
|
||||||
|
error: errors['birth_place'],
|
||||||
|
child: CustomTextFormField(
|
||||||
|
hintText: 'Место рождения',
|
||||||
|
controller: birthPlaceController,
|
||||||
|
hasError: errors.containsKey('birth_place'),
|
||||||
|
onChanged: (_) => _syncToCubit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
PassportFieldWithError(
|
||||||
|
error: errors['citizenship'],
|
||||||
|
child: CustomTextFormField(
|
||||||
|
hintText: 'Гражданство (ISO, например RU)',
|
||||||
|
controller: citizenshipController,
|
||||||
|
maxLength: 2,
|
||||||
|
hasError: errors.containsKey('citizenship'),
|
||||||
|
onChanged: (_) => _syncToCubit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
PassportFieldWithError(
|
||||||
|
error: errors['registration_address'],
|
||||||
|
child: CustomTextFormField(
|
||||||
|
hintText: 'Адрес регистрации',
|
||||||
|
controller: registrationAddressController,
|
||||||
|
hasError: errors.containsKey('registration_address'),
|
||||||
|
onChanged: (_) => _syncToCubit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
PassportFieldWithError(
|
||||||
|
error: errors['residence_address'],
|
||||||
|
child: CustomTextFormField(
|
||||||
|
hintText: 'Адрес проживания',
|
||||||
|
controller: residenceAddressController,
|
||||||
|
hasError: errors.containsKey('residence_address'),
|
||||||
|
onChanged: (_) => _syncToCubit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
CheckboxListTile(
|
||||||
|
value: taxResidentUs,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
|
activeColor: appTheme.blueA400,
|
||||||
|
title: Text(
|
||||||
|
'Я являюсь налоговым резидентом США',
|
||||||
|
style: TextStyles.textMMediumAuto.copyWith(
|
||||||
|
color: appTheme.black,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() => taxResidentUs = value ?? false);
|
||||||
|
_syncToCubit();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
CheckboxListTile(
|
||||||
|
value: foreignTaxResident,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
|
activeColor: appTheme.blueA400,
|
||||||
|
title: Text(
|
||||||
|
'Я являюсь иностранным налоговым резидентом',
|
||||||
|
style: TextStyles.textMMediumAuto.copyWith(
|
||||||
|
color: appTheme.black,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() => foreignTaxResident = value ?? false);
|
||||||
|
_syncToCubit();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const PassportSectionTitle(title: 'Паспорт'),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: PassportFieldWithError(
|
||||||
|
error: errors['passport_series'],
|
||||||
|
child: CustomTextFormField(
|
||||||
|
hintText: 'Серия',
|
||||||
|
controller: seriesController,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
maxLength: 4,
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
|
],
|
||||||
|
hasError: errors.containsKey('passport_series'),
|
||||||
|
onChanged: (_) => _syncToCubit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: PassportFieldWithError(
|
||||||
|
error: errors['passport_number'],
|
||||||
|
child: CustomTextFormField(
|
||||||
|
hintText: 'Номер',
|
||||||
|
controller: numberController,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
maxLength: 6,
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
|
],
|
||||||
|
hasError: errors.containsKey('passport_number'),
|
||||||
|
onChanged: (_) => _syncToCubit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
PassportFieldWithError(
|
||||||
|
error: errors['passport_issue_date'],
|
||||||
|
child: CustomTextFormField(
|
||||||
|
hintText: 'Дата выдачи (ДД.ММ.ГГГГ)',
|
||||||
|
controller: issueDateController,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
inputFormatters: [dateMaskIssue],
|
||||||
|
hasError: errors.containsKey('passport_issue_date'),
|
||||||
|
onChanged: (_) => _syncToCubit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
PassportFieldWithError(
|
||||||
|
error: errors['passport_issuer_code'],
|
||||||
|
child: CustomTextFormField(
|
||||||
|
hintText: 'Код подразделения',
|
||||||
|
controller: issuerCodeController,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
inputFormatters: [issuerCodeMask],
|
||||||
|
hasError: errors.containsKey('passport_issuer_code'),
|
||||||
|
onChanged: (_) => _syncToCubit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
PassportFieldWithError(
|
||||||
|
error: errors['passport_issuer'],
|
||||||
|
child: CustomTextFormField(
|
||||||
|
hintText: 'Кем выдан',
|
||||||
|
controller: issuerController,
|
||||||
|
hasError: errors.containsKey('passport_issuer'),
|
||||||
|
onChanged: (_) => _syncToCubit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PassportSectionTitle(title: 'Реквизиты СБП'),
|
||||||
|
PassportFieldWithError(
|
||||||
|
error: errors['sbp_phone'],
|
||||||
|
child: CustomTextFormField(
|
||||||
|
hintText: 'Телефон для выплат через СБП',
|
||||||
|
controller: sbpPhoneController,
|
||||||
|
keyboardType: TextInputType.phone,
|
||||||
|
inputFormatters: [phoneMask],
|
||||||
|
hasError: errors.containsKey('sbp_phone'),
|
||||||
|
onChanged: (_) => _syncToCubit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
PassportFieldWithError(
|
||||||
|
error: errors['sbp_bank_member_id'],
|
||||||
|
child: TypeAheadField<SbpBankModel>(
|
||||||
|
debounceDuration: const Duration(milliseconds: 250),
|
||||||
|
controller: sbpBankController,
|
||||||
|
suggestionsController: sbpBankSuggestionsController,
|
||||||
|
builder: (context, controller, focusNode) =>
|
||||||
|
CustomTextFormField(
|
||||||
|
controller: controller,
|
||||||
|
focusNode: focusNode,
|
||||||
|
hintText: 'Банк получатель (поиск)',
|
||||||
|
hasError: errors.containsKey('sbp_bank_member_id'),
|
||||||
|
onChanged: _clearSbpBankIfNeeded,
|
||||||
|
),
|
||||||
|
suggestionsCallback: (query) async {
|
||||||
|
return context
|
||||||
|
.read<RegisterConsultantCubit>()
|
||||||
|
.searchSbpBanks(query);
|
||||||
|
},
|
||||||
|
itemBuilder: (context, bank) {
|
||||||
|
return ListTile(
|
||||||
|
dense: true,
|
||||||
|
title: Text(
|
||||||
|
bank.bankName,
|
||||||
|
style: TextStyles.textMMedium.copyWith(
|
||||||
|
color: appTheme.black900,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle:
|
||||||
|
bank.shortName == null || bank.shortName!.isEmpty
|
||||||
|
? null
|
||||||
|
: Text(
|
||||||
|
bank.shortName!,
|
||||||
|
style: TextStyles.textSMedium.copyWith(
|
||||||
|
color: appTheme.grey7B,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
decorationBuilder: (context, child) => Material(
|
||||||
|
type: MaterialType.card,
|
||||||
|
elevation: 4,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
color: appTheme.whiteA700,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
emptyBuilder: (context) => Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Text(
|
||||||
|
'Ничего не найдено',
|
||||||
|
style: TextStyles.textSMedium.copyWith(
|
||||||
|
color: appTheme.grey7B,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onSelected: _selectSbpBank,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Divider(height: 0.4, thickness: 0.4, color: appTheme.greyD9),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Данные используются для проверки и скоринга. Заполните поля в соответствии с паспортом РФ.',
|
||||||
|
style: TextStyles.textSMedium.copyWith(
|
||||||
|
color: appTheme.blueA400,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,32 +29,48 @@ class _PhotoOrScansScreenState extends State<PhotoOrScansScreen> {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Text('Загрузите фото или сканы дипломов и сертификатов', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, height: 1.1)),
|
const Text(
|
||||||
|
'Загрузите фото или сканы дипломов и сертификатов',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
height: 1.1,
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
BlocBuilder<RegisterConsultantCubit, RegisterConsultantState>(
|
BlocBuilder<RegisterConsultantCubit, RegisterConsultantState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final photos = state.certPhotos.where((photo) => !state.deletedPhotoIds.contains(photo.id)).toList();
|
final photos = state.certPhotos
|
||||||
|
.where((photo) => !state.deletedPhotoIds.contains(photo.id))
|
||||||
|
.toList();
|
||||||
final addedPhotos = state.newAddedPhotos;
|
final addedPhotos = state.newAddedPhotos;
|
||||||
final totalPhotosCount = photos.length + addedPhotos.length;
|
final totalPhotosCount = photos.length + addedPhotos.length;
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
if (isUploading) ...[const LinearProgressIndicator(), const SizedBox(height: 16)],
|
if (isUploading) ...[
|
||||||
|
const LinearProgressIndicator(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
GridView.builder(
|
GridView.builder(
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate:
|
||||||
crossAxisCount: 3,
|
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
childAspectRatio: 3 / 4,
|
crossAxisCount: 3,
|
||||||
crossAxisSpacing: 8,
|
childAspectRatio: 3 / 4,
|
||||||
mainAxisSpacing: 8,
|
crossAxisSpacing: 8,
|
||||||
),
|
mainAxisSpacing: 8,
|
||||||
|
),
|
||||||
itemBuilder: (BuildContext context, int index) {
|
itemBuilder: (BuildContext context, int index) {
|
||||||
if (index == totalPhotosCount) {
|
if (index == totalPhotosCount) {
|
||||||
return InkWell(
|
return InkWell(
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
onTap: () => _pickAndUploadPhotos(),
|
onTap: () => _pickAndUploadPhotos(),
|
||||||
child: Ink(
|
child: Ink(
|
||||||
decoration: BoxDecoration(color: const Color(0xFFF6F6F6), borderRadius: BorderRadius.circular(20)),
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFF6F6F6),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
child: Icon(Icons.add, color: appTheme.blueA400),
|
child: Icon(Icons.add, color: appTheme.blueA400),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -72,7 +88,8 @@ class _PhotoOrScansScreenState extends State<PhotoOrScansScreen> {
|
|||||||
return _LocalCertPhotoCard(
|
return _LocalCertPhotoCard(
|
||||||
photo: localPhoto,
|
photo: localPhoto,
|
||||||
allPhotos: addedPhotos,
|
allPhotos: addedPhotos,
|
||||||
onRemove: () => _removePhoto(path: localPhoto.uri.path),
|
onRemove: () =>
|
||||||
|
_removePhoto(path: localPhoto.uri.path),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,7 +116,9 @@ class _PhotoOrScansScreenState extends State<PhotoOrScansScreen> {
|
|||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
// ignore: use_build_context_synchronously
|
// ignore: use_build_context_synchronously
|
||||||
await context.read<RegisterConsultantCubit>().addCertPhotos(newImages.map((file) => File(file.path)).toList());
|
await context.read<RegisterConsultantCubit>().addCertPhotos(
|
||||||
|
newImages.map((file) => File(file.path)).toList(),
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -119,7 +138,11 @@ class _CertPhotoCard extends StatelessWidget {
|
|||||||
final List<ConsultantCertPhotoModel> allPhotos;
|
final List<ConsultantCertPhotoModel> allPhotos;
|
||||||
final VoidCallback onRemove;
|
final VoidCallback onRemove;
|
||||||
|
|
||||||
const _CertPhotoCard({required this.photo, required this.allPhotos, required this.onRemove});
|
const _CertPhotoCard({
|
||||||
|
required this.photo,
|
||||||
|
required this.allPhotos,
|
||||||
|
required this.onRemove,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -131,7 +154,11 @@ class _CertPhotoCard extends StatelessWidget {
|
|||||||
onTap: () {
|
onTap: () {
|
||||||
context.pushTransparentRoute(
|
context.pushTransparentRoute(
|
||||||
rootNavigator: true,
|
rootNavigator: true,
|
||||||
PhotoViewScreen(imageUrls: allPhotos.map((e) => e.url).toList(), isLocal: false, index: allPhotos.indexOf(photo)),
|
PhotoViewScreen(
|
||||||
|
imageUrls: allPhotos.map((e) => e.url).toList(),
|
||||||
|
isLocal: false,
|
||||||
|
index: allPhotos.indexOf(photo),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
@@ -141,7 +168,10 @@ class _CertPhotoCard extends StatelessWidget {
|
|||||||
Ink(
|
Ink(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(20)),
|
borderRadius: const BorderRadius.all(Radius.circular(20)),
|
||||||
image: DecorationImage(fit: BoxFit.cover, image: NetworkImage(photo.url)),
|
image: DecorationImage(
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
image: NetworkImage(photo.url),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
@@ -152,8 +182,15 @@ class _CertPhotoCard extends StatelessWidget {
|
|||||||
customBorder: const CircleBorder(),
|
customBorder: const CircleBorder(),
|
||||||
child: Ink(
|
child: Ink(
|
||||||
padding: const EdgeInsets.all(4),
|
padding: const EdgeInsets.all(4),
|
||||||
decoration: const BoxDecoration(color: Color(0xFFF1F2F6), shape: BoxShape.circle),
|
decoration: const BoxDecoration(
|
||||||
child: const Icon(size: 20, Icons.close, color: Colors.black),
|
color: Color(0xFFF1F2F6),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
size: 20,
|
||||||
|
Icons.close,
|
||||||
|
color: Colors.black,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -170,7 +207,11 @@ class _LocalCertPhotoCard extends StatelessWidget {
|
|||||||
final List<File> allPhotos;
|
final List<File> allPhotos;
|
||||||
final VoidCallback onRemove;
|
final VoidCallback onRemove;
|
||||||
|
|
||||||
const _LocalCertPhotoCard({required this.photo, required this.allPhotos, required this.onRemove});
|
const _LocalCertPhotoCard({
|
||||||
|
required this.photo,
|
||||||
|
required this.allPhotos,
|
||||||
|
required this.onRemove,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -210,8 +251,15 @@ class _LocalCertPhotoCard extends StatelessWidget {
|
|||||||
customBorder: const CircleBorder(),
|
customBorder: const CircleBorder(),
|
||||||
child: Ink(
|
child: Ink(
|
||||||
padding: const EdgeInsets.all(4),
|
padding: const EdgeInsets.all(4),
|
||||||
decoration: const BoxDecoration(color: Color(0xFFF1F2F6), shape: BoxShape.circle),
|
decoration: const BoxDecoration(
|
||||||
child: const Icon(size: 20, Icons.close, color: Colors.black),
|
color: Color(0xFFF1F2F6),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
size: 20,
|
||||||
|
Icons.close,
|
||||||
|
color: Colors.black,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
class RegistrationAsScreen extends StatefulWidget {
|
class RegistrationAsScreen extends StatefulWidget {
|
||||||
const RegistrationAsScreen({super.key});
|
const RegistrationAsScreen({super.key});
|
||||||
@@ -11,7 +13,26 @@ class RegistrationAsScreen extends StatefulWidget {
|
|||||||
enum RegistrationAsType { physical, selfBusy, iP, ooo }
|
enum RegistrationAsType { physical, selfBusy, iP, ooo }
|
||||||
|
|
||||||
class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
|
class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
|
||||||
RegistrationAsType registrationAsType = RegistrationAsType.physical;
|
static const RegistrationAsType registrationAsType =
|
||||||
|
RegistrationAsType.physical;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final cubit = context.read<RegisterConsultantCubit>();
|
||||||
|
cubit.updateConsultantProfileModel(
|
||||||
|
cubit.state.consultant.copyWith(
|
||||||
|
legalType: RegisterConsultantCubit.individualPersonLegalType,
|
||||||
|
),
|
||||||
|
cubit.state.acceptOffer,
|
||||||
|
cubit.state.acceptFrdoAgreement,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -23,7 +44,14 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
|
|||||||
children: [
|
children: [
|
||||||
const Padding(
|
const Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Text('Регистрация в качестве', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, height: 1.1)),
|
child: Text(
|
||||||
|
'Регистрация в качестве',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
height: 1.1,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Column(
|
Column(
|
||||||
@@ -36,16 +64,15 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
|
|||||||
groupValue: registrationAsType,
|
groupValue: registrationAsType,
|
||||||
activeColor: appTheme.blueA400,
|
activeColor: appTheme.blueA400,
|
||||||
// ignore: deprecated_member_use
|
// ignore: deprecated_member_use
|
||||||
onChanged: (value) {
|
onChanged: (_) {},
|
||||||
setState(() {
|
|
||||||
registrationAsType = value!;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
const Expanded(
|
const Expanded(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||||
child: Text('Физического лица', style: TextStyle(fontSize: 18, height: 1)),
|
child: Text(
|
||||||
|
'Физического лица',
|
||||||
|
style: TextStyle(fontSize: 18, height: 1),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -58,16 +85,19 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
|
|||||||
groupValue: registrationAsType,
|
groupValue: registrationAsType,
|
||||||
activeColor: appTheme.blueA400,
|
activeColor: appTheme.blueA400,
|
||||||
// ignore: deprecated_member_use
|
// ignore: deprecated_member_use
|
||||||
onChanged: (value) {
|
onChanged: null,
|
||||||
setState(() {
|
|
||||||
registrationAsType = value!;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
const Expanded(
|
const Expanded(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||||
child: Text('Самозанятого', style: TextStyle(fontSize: 18, height: 1)),
|
child: Text(
|
||||||
|
'Самозанятого',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
height: 1,
|
||||||
|
color: Colors.black38,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -80,16 +110,19 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
|
|||||||
groupValue: registrationAsType,
|
groupValue: registrationAsType,
|
||||||
activeColor: appTheme.blueA400,
|
activeColor: appTheme.blueA400,
|
||||||
// ignore: deprecated_member_use
|
// ignore: deprecated_member_use
|
||||||
onChanged: (value) {
|
onChanged: null,
|
||||||
setState(() {
|
|
||||||
registrationAsType = value!;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
const Expanded(
|
const Expanded(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||||
child: Text('Индивидуального предпринимателя', style: TextStyle(fontSize: 18, height: 1)),
|
child: Text(
|
||||||
|
'Индивидуального предпринимателя',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
height: 1,
|
||||||
|
color: Colors.black38,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -102,26 +135,34 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
|
|||||||
groupValue: registrationAsType,
|
groupValue: registrationAsType,
|
||||||
activeColor: appTheme.blueA400,
|
activeColor: appTheme.blueA400,
|
||||||
// ignore: deprecated_member_use
|
// ignore: deprecated_member_use
|
||||||
onChanged: (value) {
|
onChanged: null,
|
||||||
setState(() {
|
|
||||||
registrationAsType = value!;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
const Expanded(
|
const Expanded(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||||
child: Text('Общества с ограниченной ответственностью', style: TextStyle(fontSize: 18, height: 1)),
|
child: Text(
|
||||||
|
'Общества с ограниченной ответственностью',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
height: 1,
|
||||||
|
color: Colors.black38,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Padding(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), child: _divider(context)),
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||||
|
child: _divider(context),
|
||||||
|
),
|
||||||
const Padding(
|
const Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Text('* На данный момент регистрация доступна только в качестве физического лица. Остальные варианты скоро появятся'),
|
child: Text(
|
||||||
|
'* На данный момент регистрация доступна только в качестве физического лица. Остальные варианты скоро появятся',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -132,7 +173,10 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
|
|||||||
return Container(
|
return Container(
|
||||||
height: 0.4,
|
height: 0.4,
|
||||||
width: MediaQuery.of(context).size.width - 32,
|
width: MediaQuery.of(context).size.width - 32,
|
||||||
decoration: BoxDecoration(color: Colors.black26, borderRadius: BorderRadius.circular(16)),
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black26,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,32 +19,47 @@ class _SocialNetworksScreenState extends State<SocialNetworksScreen> {
|
|||||||
|
|
||||||
var maskFormatter = MaskTextInputFormatter(
|
var maskFormatter = MaskTextInputFormatter(
|
||||||
mask: 'https://########################################',
|
mask: 'https://########################################',
|
||||||
filter: {
|
filter: {'#': RegExp(r"[a-zA-Z. ]+")},
|
||||||
'#': RegExp(r"[a-zA-Z. ]+"),
|
|
||||||
},
|
|
||||||
type: MaskAutoCompletionType.lazy,
|
type: MaskAutoCompletionType.lazy,
|
||||||
);
|
);
|
||||||
var maskTgFormatter = MaskTextInputFormatter(
|
var maskTgFormatter = MaskTextInputFormatter(
|
||||||
mask: 'https://tg.me/########################################',
|
mask: 'https://tg.me/########################################',
|
||||||
filter: {
|
filter: {'#': RegExp(r"[a-zA-Z. ]+")},
|
||||||
'#': RegExp(r"[a-zA-Z. ]+"),
|
|
||||||
},
|
|
||||||
type: MaskAutoCompletionType.lazy,
|
type: MaskAutoCompletionType.lazy,
|
||||||
);
|
);
|
||||||
var maskVkFormatter = MaskTextInputFormatter(
|
var maskVkFormatter = MaskTextInputFormatter(
|
||||||
mask: 'https://vk.com/########################################',
|
mask: 'https://vk.com/########################################',
|
||||||
filter: {
|
filter: {'#': RegExp(r"[a-zA-Z. ]+")},
|
||||||
'#': RegExp(r"[a-zA-Z. ]+"),
|
|
||||||
},
|
|
||||||
type: MaskAutoCompletionType.lazy,
|
type: MaskAutoCompletionType.lazy,
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
vkController.text = context.read<RegisterConsultantCubit>().state.consultant.socialLinks!.vkLink ?? '';
|
vkController.text =
|
||||||
telegramController.text = context.read<RegisterConsultantCubit>().state.consultant.socialLinks!.tgLink ?? '';
|
context
|
||||||
webSiteController.text = context.read<RegisterConsultantCubit>().state.consultant.socialLinks!.personalSiteLink ?? '';
|
.read<RegisterConsultantCubit>()
|
||||||
|
.state
|
||||||
|
.consultant
|
||||||
|
.socialLinks!
|
||||||
|
.vkLink ??
|
||||||
|
'';
|
||||||
|
telegramController.text =
|
||||||
|
context
|
||||||
|
.read<RegisterConsultantCubit>()
|
||||||
|
.state
|
||||||
|
.consultant
|
||||||
|
.socialLinks!
|
||||||
|
.tgLink ??
|
||||||
|
'';
|
||||||
|
webSiteController.text =
|
||||||
|
context
|
||||||
|
.read<RegisterConsultantCubit>()
|
||||||
|
.state
|
||||||
|
.consultant
|
||||||
|
.socialLinks!
|
||||||
|
.personalSiteLink ??
|
||||||
|
'';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -75,9 +90,7 @@ class _SocialNetworksScreenState extends State<SocialNetworksScreen> {
|
|||||||
color: const Color(0xFFF6F6F6),
|
color: const Color(0xFFF6F6F6),
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
child: SvgPicture.asset(
|
child: SvgPicture.asset('assets/icons/vk.svg'),
|
||||||
'assets/icons/vk.svg',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
@@ -105,9 +118,7 @@ class _SocialNetworksScreenState extends State<SocialNetworksScreen> {
|
|||||||
color: const Color(0xFFF6F6F6),
|
color: const Color(0xFFF6F6F6),
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
child: SvgPicture.asset(
|
child: SvgPicture.asset('assets/icons/telegram.svg'),
|
||||||
'assets/icons/telegram.svg',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
@@ -135,9 +146,7 @@ class _SocialNetworksScreenState extends State<SocialNetworksScreen> {
|
|||||||
color: const Color(0xFFF6F6F6),
|
color: const Color(0xFFF6F6F6),
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
child: SvgPicture.asset(
|
child: SvgPicture.asset('assets/icons/web.svg'),
|
||||||
'assets/icons/web.svg',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
@@ -164,25 +173,45 @@ class _SocialNetworksScreenState extends State<SocialNetworksScreen> {
|
|||||||
String? personalSiteLink,
|
String? personalSiteLink,
|
||||||
}) {
|
}) {
|
||||||
context.read<RegisterConsultantCubit>().updateConsultantProfileModel(
|
context.read<RegisterConsultantCubit>().updateConsultantProfileModel(
|
||||||
context.read<RegisterConsultantCubit>().state.consultant.copyWith(
|
context.read<RegisterConsultantCubit>().state.consultant.copyWith(
|
||||||
socialLinks: context.read<RegisterConsultantCubit>().state.consultant.socialLinks?.copyWith(
|
socialLinks: context
|
||||||
tgLink: tgLink == null
|
.read<RegisterConsultantCubit>()
|
||||||
? context.read<RegisterConsultantCubit>().state.consultant.socialLinks?.tgLink
|
.state
|
||||||
: tgLink.isEmpty
|
.consultant
|
||||||
? null
|
.socialLinks
|
||||||
: tgLink,
|
?.copyWith(
|
||||||
vkLink: vkLink == null
|
tgLink: tgLink == null
|
||||||
? context.read<RegisterConsultantCubit>().state.consultant.socialLinks?.vkLink
|
? context
|
||||||
: vkLink.isEmpty
|
.read<RegisterConsultantCubit>()
|
||||||
? null
|
.state
|
||||||
: vkLink,
|
.consultant
|
||||||
personalSiteLink: personalSiteLink == null
|
.socialLinks
|
||||||
? context.read<RegisterConsultantCubit>().state.consultant.socialLinks?.personalSiteLink
|
?.tgLink
|
||||||
: personalSiteLink.isEmpty
|
: tgLink.isEmpty
|
||||||
? null
|
? null
|
||||||
: personalSiteLink,
|
: tgLink,
|
||||||
),
|
vkLink: vkLink == null
|
||||||
),
|
? context
|
||||||
);
|
.read<RegisterConsultantCubit>()
|
||||||
|
.state
|
||||||
|
.consultant
|
||||||
|
.socialLinks
|
||||||
|
?.vkLink
|
||||||
|
: vkLink.isEmpty
|
||||||
|
? null
|
||||||
|
: vkLink,
|
||||||
|
personalSiteLink: personalSiteLink == null
|
||||||
|
? context
|
||||||
|
.read<RegisterConsultantCubit>()
|
||||||
|
.state
|
||||||
|
.consultant
|
||||||
|
.socialLinks
|
||||||
|
?.personalSiteLink
|
||||||
|
: personalSiteLink.isEmpty
|
||||||
|
? null
|
||||||
|
: personalSiteLink,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import 'package:eatvio_app/core/utils/image_constant.dart';
|
import 'package:eatvio_app/core/utils/image_constant.dart';
|
||||||
import 'package:eatvio_app/core/widgets/data_error_widget.dart';
|
import 'package:eatvio_app/core/widgets/data_error_widget.dart';
|
||||||
import 'package:eatvio_app/core/widgets/loader.dart';
|
import 'package:eatvio_app/core/widgets/loader.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/additional_settings_screen.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/additional_settings_screen.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/last_step_screen.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/last_step_screen.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/social_networks_screen.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/social_networks_screen.dart';
|
||||||
@@ -21,67 +23,196 @@ import 'package:lottie/lottie.dart';
|
|||||||
class StepperBecomeConsultantScreen extends StatefulWidget {
|
class StepperBecomeConsultantScreen extends StatefulWidget {
|
||||||
final String? slug;
|
final String? slug;
|
||||||
final bool? isCanceled;
|
final bool? isCanceled;
|
||||||
const StepperBecomeConsultantScreen({super.key, this.slug, this.isCanceled});
|
final bool isNewRegistration;
|
||||||
|
const StepperBecomeConsultantScreen({
|
||||||
|
super.key,
|
||||||
|
this.slug,
|
||||||
|
this.isCanceled,
|
||||||
|
this.isNewRegistration = false,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<StepperBecomeConsultantScreen> createState() => _StepperBecomeConsultantScreenState();
|
State<StepperBecomeConsultantScreen> createState() =>
|
||||||
|
_StepperBecomeConsultantScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantScreen> {
|
class _StepperBecomeConsultantScreenState
|
||||||
double progressValue = 1 / 6;
|
extends State<StepperBecomeConsultantScreen> {
|
||||||
|
double progressValue = 0;
|
||||||
int step = 1;
|
int step = 1;
|
||||||
final pageController = PageController();
|
final pageController = PageController();
|
||||||
late final RegisterConsultantCubit registrationConsultantCubit;
|
late final RegisterConsultantCubit registrationConsultantCubit;
|
||||||
bool isLoading = false;
|
bool isLoading = false;
|
||||||
|
bool _successSheetShown = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
if (widget.slug == null) {
|
if (widget.isNewRegistration || widget.slug == null) {
|
||||||
registrationConsultantCubit = sl<RegisterConsultantCubit>()..disableLoading();
|
registrationConsultantCubit = sl<RegisterConsultantCubit>()
|
||||||
|
..disableLoading(slug: widget.slug);
|
||||||
} else {
|
} else {
|
||||||
registrationConsultantCubit = sl<RegisterConsultantCubit>()..getMyRequestStatus(widget.slug!);
|
registrationConsultantCubit = sl<RegisterConsultantCubit>()
|
||||||
|
..getMyRequestStatus(widget.slug!);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static const int totalSteps = 7;
|
||||||
|
|
||||||
|
List<Widget> _pages() {
|
||||||
|
return const [
|
||||||
|
CertificatesAndDiplomasScreen(),
|
||||||
|
PhotoOrScansScreen(),
|
||||||
|
AdditionalSettingsScreen(),
|
||||||
|
SocialNetworksScreen(),
|
||||||
|
RegistrationAsScreen(),
|
||||||
|
PassportDataScreen(),
|
||||||
|
LastStepScreen(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showSuccessSheet(BuildContext context) async {
|
||||||
|
if (_successSheetShown) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_successSheetShown = true;
|
||||||
|
await showModalBottomSheet(
|
||||||
|
useRootNavigator: true,
|
||||||
|
showDragHandle: true,
|
||||||
|
context: context,
|
||||||
|
builder: (context) => Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
width: MediaQuery.of(context).size.width,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Спасибо!\nВаши данные отправлены на модерацию',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
height: 1,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Lottie.asset(
|
||||||
|
'assets/lottie_animations/success.json',
|
||||||
|
width: 160,
|
||||||
|
height: 160,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text(
|
||||||
|
'В течении трех рабочих дней Вам придет уведомление о смене статуса',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
height: 1,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
InkWell(
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
GoRouter.of(context).pop();
|
||||||
|
},
|
||||||
|
child: Ink(
|
||||||
|
width: MediaQuery.of(context).size.width - 32,
|
||||||
|
height: 52,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
color: appTheme.blueA400,
|
||||||
|
),
|
||||||
|
child: const Center(
|
||||||
|
child: Text(
|
||||||
|
'Закрыть',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocProvider<RegisterConsultantCubit>(
|
return BlocProvider<RegisterConsultantCubit>(
|
||||||
create: (context) => registrationConsultantCubit,
|
create: (context) => registrationConsultantCubit,
|
||||||
child: BlocBuilder<RegisterConsultantCubit, RegisterConsultantState>(
|
child: BlocListener<RegisterConsultantCubit, RegisterConsultantState>(
|
||||||
builder: (context, state) {
|
listenWhen: (previous, current) =>
|
||||||
if (state.loading) {
|
previous.fieldErrors.isEmpty &&
|
||||||
return const Scaffold(body: Loader());
|
current.fieldErrors.isNotEmpty &&
|
||||||
}
|
current.error == null,
|
||||||
if (state.error != null) {
|
listener: (context, state) {
|
||||||
|
pageController.jumpToPage(5);
|
||||||
|
},
|
||||||
|
child: BlocConsumer<RegisterConsultantCubit, RegisterConsultantState>(
|
||||||
|
listenWhen: (previous, current) =>
|
||||||
|
previous.awaitingScoringOutcome &&
|
||||||
|
!current.awaitingScoringOutcome &&
|
||||||
|
current.alfaScoringStatus.isComplete,
|
||||||
|
listener: (context, state) {
|
||||||
|
_showSuccessSheet(context);
|
||||||
|
},
|
||||||
|
builder: (context, state) {
|
||||||
|
if (state.loading) {
|
||||||
|
return const Scaffold(body: Loader());
|
||||||
|
}
|
||||||
|
if (state.error != null &&
|
||||||
|
state.requestModel == null &&
|
||||||
|
widget.slug != null &&
|
||||||
|
state.alfaScoringStatus == AlfaScoringStatus.none) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
centerTitle: true,
|
||||||
|
leading: BackButton(
|
||||||
|
onPressed: () {
|
||||||
|
if (context.canPop()) {
|
||||||
|
GoRouter.of(context).pop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
GoRouter.of(context).go('/feed-screen');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
'',
|
||||||
|
maxLines: 2,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
softWrap: true,
|
||||||
|
style: TextStyles.headingXLSemibold.copyWith(
|
||||||
|
color: appTheme.black,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: DataErrorWidget(
|
||||||
|
onReload: () => context
|
||||||
|
.read<RegisterConsultantCubit>()
|
||||||
|
.reloadData(widget.slug),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progressValue == 0) {
|
||||||
|
progressValue = 1 / totalSteps;
|
||||||
|
}
|
||||||
|
|
||||||
|
final submitDisabled =
|
||||||
|
isLoading ||
|
||||||
|
state.isScoringPolling ||
|
||||||
|
state.scoringRetryInProgress;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
floatingActionButtonLocation:
|
||||||
centerTitle: true,
|
FloatingActionButtonLocation.centerFloat,
|
||||||
leading: BackButton(
|
|
||||||
onPressed: () {
|
|
||||||
if (context.canPop()) {
|
|
||||||
GoRouter.of(context).pop();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
GoRouter.of(context).go('/feed-screen');
|
|
||||||
},
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
'',
|
|
||||||
maxLines: 2,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
softWrap: true,
|
|
||||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
body: DataErrorWidget(
|
|
||||||
onReload: () => context.read<RegisterConsultantCubit>().reloadData(widget.slug),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return Scaffold(
|
|
||||||
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
|
|
||||||
floatingActionButton: Row(
|
floatingActionButton: Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
@@ -93,33 +224,51 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
|
|||||||
highlightElevation: 0,
|
highlightElevation: 0,
|
||||||
focusElevation: 0,
|
focusElevation: 0,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
backgroundColor: const Color(0xFFEAF2FD),
|
backgroundColor: const Color(0xFFEAF2FD),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
setState(() {
|
setState(() {
|
||||||
pageController.previousPage(duration: const Duration(milliseconds: 350), curve: Curves.easeInOut);
|
pageController.previousPage(
|
||||||
|
duration: const Duration(milliseconds: 350),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
label: Icon(Icons.arrow_back_ios_new_rounded, color: appTheme.blueA400),
|
label: Icon(
|
||||||
|
Icons.arrow_back_ios_new_rounded,
|
||||||
|
color: appTheme.blueA400,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (widget.isCanceled == true)
|
if (widget.isCanceled == true && step != totalSteps)
|
||||||
InkWell(
|
InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
final canceledModeration =
|
||||||
|
state.requestModel?.canceledModeration;
|
||||||
|
if (canceledModeration == null) return;
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: Text(state.requestModel!.canceledModeration!.title),
|
title: Text(canceledModeration.title),
|
||||||
content: Column(
|
content: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
for (int i = 0; i < state.requestModel!.canceledModeration!.additional!.length; i++) ...[
|
if (canceledModeration.additional != null)
|
||||||
Text('• ${state.requestModel!.canceledModeration!.additional![i]}'),
|
for (final item
|
||||||
],
|
in canceledModeration.additional!) ...[
|
||||||
|
Text('• $item'),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))],
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('OK'),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -127,18 +276,26 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
|
|||||||
child: Ink(
|
child: Ink(
|
||||||
height: 48,
|
height: 48,
|
||||||
width: MediaQuery.of(context).size.width * 0.6,
|
width: MediaQuery.of(context).size.width * 0.6,
|
||||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: const Color(0x33FFCC00)),
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
color: const Color(0x33FFCC00),
|
||||||
|
),
|
||||||
child: const Center(
|
child: const Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Комментарий',
|
'Комментарий',
|
||||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF323F4B), height: 1),
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Color(0xFF323F4B),
|
||||||
|
height: 1,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Container(
|
Container(
|
||||||
height: 48,
|
height: 48,
|
||||||
width: widget.isCanceled == true
|
width: widget.isCanceled == true && step != totalSteps
|
||||||
? MediaQuery.of(context).size.width * 0.2
|
? MediaQuery.of(context).size.width * 0.2
|
||||||
: MediaQuery.of(context).size.width * 0.8,
|
: MediaQuery.of(context).size.width * 0.8,
|
||||||
padding: const EdgeInsets.only(left: 4, right: 16),
|
padding: const EdgeInsets.only(left: 4, right: 16),
|
||||||
@@ -147,85 +304,77 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
|
|||||||
highlightElevation: 0,
|
highlightElevation: 0,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
focusElevation: 0,
|
focusElevation: 0,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
shape: RoundedRectangleBorder(
|
||||||
backgroundColor: isLoading
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
backgroundColor: submitDisabled
|
||||||
? Colors.grey
|
? Colors.grey
|
||||||
: widget.isCanceled == true
|
: widget.isCanceled == true && step != totalSteps
|
||||||
? const Color(0xFFEAF2FD)
|
? const Color(0xFFEAF2FD)
|
||||||
: appTheme.blueA400,
|
: appTheme.blueA400,
|
||||||
onPressed: isLoading
|
onPressed: submitDisabled
|
||||||
? null
|
? null
|
||||||
: () async {
|
: () async {
|
||||||
if (step == 6) {
|
if (step == totalSteps) {
|
||||||
if (widget.isCanceled == true) {
|
final passportErrors =
|
||||||
|
registrationConsultantCubit
|
||||||
|
.validatePassportFields();
|
||||||
|
if (passportErrors.isNotEmpty) {
|
||||||
|
pageController.jumpToPage(5);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (registrationConsultantCubit.state.acceptOffer) {
|
if (registrationConsultantCubit
|
||||||
|
.hasAcceptedRequiredAgreements) {
|
||||||
setState(() {
|
setState(() {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await registrationConsultantCubit.registerConsultant();
|
await registrationConsultantCubit
|
||||||
|
.registerConsultant();
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
if (registrationConsultantCubit.state.error != null) {
|
if (registrationConsultantCubit
|
||||||
return;
|
.state
|
||||||
}
|
.error !=
|
||||||
await showModalBottomSheet(
|
null) {
|
||||||
useRootNavigator: true,
|
showDialog(
|
||||||
showDragHandle: true,
|
context: context,
|
||||||
context: context,
|
builder: (context) => AlertDialog(
|
||||||
builder: (context) => Container(
|
actionsPadding:
|
||||||
padding: const EdgeInsets.all(16),
|
const EdgeInsets.symmetric(
|
||||||
width: MediaQuery.of(context).size.width,
|
horizontal: 4,
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'Спасибо!\nВаши данные отправлены на модерацию',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: TextStyle(fontSize: 20, height: 1, fontWeight: FontWeight.w600),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Lottie.asset('assets/lottie_animations/success.json', width: 160, height: 160),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
const Text(
|
|
||||||
'В течении трех рабочих дней Вам придет уведомление о смене статуса',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: TextStyle(fontSize: 16, height: 1, fontWeight: FontWeight.w500),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
InkWell(
|
|
||||||
onTap: () {
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
GoRouter.of(context).pop();
|
|
||||||
},
|
|
||||||
child: Ink(
|
|
||||||
width: MediaQuery.of(context).size.width - 32,
|
|
||||||
height: 52,
|
|
||||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
|
|
||||||
child: const Center(
|
|
||||||
child: Text(
|
|
||||||
'Закрыть',
|
|
||||||
style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
content: Text(
|
||||||
|
registrationConsultantCubit
|
||||||
|
.state
|
||||||
|
.error!,
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
child: const Text('Ок'),
|
||||||
|
onPressed: () =>
|
||||||
|
Navigator.of(context).pop(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
return;
|
||||||
|
}
|
||||||
|
// Success sheet is shown by BlocListener when scoring completes.
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
showDialog<void>(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
actionsPadding: const EdgeInsets.symmetric(horizontal: 4),
|
actionsPadding:
|
||||||
|
const EdgeInsets.symmetric(
|
||||||
|
horizontal: 4,
|
||||||
|
),
|
||||||
content: Text(e.toString()),
|
content: Text(e.toString()),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
child: const Text('Ок'),
|
child: const Text('Ок'),
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
onPressed: () =>
|
||||||
|
Navigator.of(context).pop(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -239,8 +388,15 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
|
|||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
actionsPadding: const EdgeInsets.symmetric(horizontal: 4),
|
actionsPadding:
|
||||||
content: const Text('Примите договор публичной офёрты'),
|
const EdgeInsets.symmetric(
|
||||||
|
horizontal: 4,
|
||||||
|
),
|
||||||
|
content: Text(
|
||||||
|
state.isUpdating
|
||||||
|
? 'Примите договор публичной оферты'
|
||||||
|
: 'Примите оферту и согласие ФРДО',
|
||||||
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
child: const Text('Ок'),
|
child: const Text('Ок'),
|
||||||
@@ -255,15 +411,26 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
|
|||||||
} else {
|
} else {
|
||||||
FocusManager.instance.primaryFocus?.unfocus();
|
FocusManager.instance.primaryFocus?.unfocus();
|
||||||
setState(() {
|
setState(() {
|
||||||
pageController.nextPage(duration: const Duration(milliseconds: 350), curve: Curves.easeInOut);
|
pageController.nextPage(
|
||||||
|
duration: const Duration(milliseconds: 350),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
label: isLoading
|
label: submitDisabled && step == totalSteps
|
||||||
? const CircularProgressIndicator()
|
? const CircularProgressIndicator()
|
||||||
: widget.isCanceled == true
|
: widget.isCanceled == true && step != totalSteps
|
||||||
? Icon(Icons.arrow_forward_ios_outlined, color: appTheme.blueA400)
|
? Icon(
|
||||||
: Text('Продолжить', style: theme.textTheme.titleSmall!.copyWith(color: Colors.white)),
|
Icons.arrow_forward_ios_outlined,
|
||||||
|
color: appTheme.blueA400,
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
step == totalSteps ? 'Отправить' : 'Продолжить',
|
||||||
|
style: theme.textTheme.titleSmall!.copyWith(
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -271,11 +438,23 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
|
|||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
automaticallyImplyLeading: false,
|
automaticallyImplyLeading: false,
|
||||||
leading: Builder(
|
leading: Builder(
|
||||||
builder: (context) => IconButton(icon: const Icon(Icons.close_rounded), onPressed: () => GoRouter.of(context).pop()),
|
builder: (context) => IconButton(
|
||||||
|
icon: const Icon(Icons.close_rounded),
|
||||||
|
onPressed: () => GoRouter.of(context).pop(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
title: SvgPicture.asset(ImageConstant.imgLogoEatvio, height: 48, fit: BoxFit.contain),
|
title: SvgPicture.asset(
|
||||||
actions: [Padding(padding: const EdgeInsets.only(right: 16), child: Text('шаг $step из 6'))],
|
ImageConstant.imgLogoEatvio,
|
||||||
|
height: 48,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 16),
|
||||||
|
child: Text('шаг $step из $totalSteps'),
|
||||||
|
),
|
||||||
|
],
|
||||||
bottom: PreferredSize(
|
bottom: PreferredSize(
|
||||||
preferredSize: Size(MediaQuery.of(context).size.width, 32),
|
preferredSize: Size(MediaQuery.of(context).size.width, 32),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -283,13 +462,18 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
|
|||||||
child: TweenAnimationBuilder<double>(
|
child: TweenAnimationBuilder<double>(
|
||||||
duration: const Duration(milliseconds: 350),
|
duration: const Duration(milliseconds: 350),
|
||||||
curve: Curves.easeInOut,
|
curve: Curves.easeInOut,
|
||||||
tween: Tween<double>(begin: 1 / 6, end: progressValue),
|
tween: Tween<double>(
|
||||||
|
begin: 1 / totalSteps,
|
||||||
|
end: progressValue,
|
||||||
|
),
|
||||||
builder: (context, value, _) => LinearProgressIndicator(
|
builder: (context, value, _) => LinearProgressIndicator(
|
||||||
value: value,
|
value: value,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
minHeight: 8,
|
minHeight: 8,
|
||||||
backgroundColor: Colors.grey.shade100,
|
backgroundColor: Colors.grey.shade100,
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(appTheme.blueA400),
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
|
appTheme.blueA400,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -301,20 +485,14 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
|
|||||||
onPageChanged: (index) {
|
onPageChanged: (index) {
|
||||||
setState(() {
|
setState(() {
|
||||||
step = index + 1;
|
step = index + 1;
|
||||||
progressValue = step / 6;
|
progressValue = step / totalSteps;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
children: const [
|
children: _pages(),
|
||||||
CertificatesAndDiplomasScreen(),
|
|
||||||
PhotoOrScansScreen(),
|
|
||||||
AdditionalSettingsScreen(),
|
|
||||||
SocialNetworksScreen(),
|
|
||||||
RegistrationAsScreen(),
|
|
||||||
LastStepScreen(),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Wraps a form field with [ValidationErrorWidget] when [error] is present.
|
||||||
|
/// Pass [hasError] into [CustomTextFormField] from the parent.
|
||||||
|
class PassportFieldWithError extends StatelessWidget {
|
||||||
|
const PassportFieldWithError({required this.child, this.error, super.key});
|
||||||
|
|
||||||
|
final Widget child;
|
||||||
|
final String? error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final message = error?.trim();
|
||||||
|
final showError = message != null && message.isNotEmpty;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
child,
|
||||||
|
if (showError) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
ValidationErrorWidget(error: message),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||||
|
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class PassportSectionTitle extends StatelessWidget {
|
||||||
|
const PassportSectionTitle({required this.title, super.key});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8, bottom: 12),
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PassportScreenTitle extends StatelessWidget {
|
||||||
|
const PassportScreenTitle({required this.title, super.key});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Text(
|
||||||
|
title,
|
||||||
|
style: TextStyles.textFS24FW700.copyWith(color: appTheme.black),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||||
|
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart';
|
||||||
|
import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
|
class ScoringStatusWidget extends StatelessWidget {
|
||||||
|
const ScoringStatusWidget({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return BlocBuilder<RegisterConsultantCubit, RegisterConsultantState>(
|
||||||
|
buildWhen: (previous, current) =>
|
||||||
|
previous.alfaScoringStatus != current.alfaScoringStatus ||
|
||||||
|
previous.isScoringPolling != current.isScoringPolling ||
|
||||||
|
previous.scoringRetryInProgress != current.scoringRetryInProgress ||
|
||||||
|
previous.awaitingScoringOutcome != current.awaitingScoringOutcome,
|
||||||
|
builder: (context, state) {
|
||||||
|
final isWaiting =
|
||||||
|
state.awaitingScoringOutcome || state.isScoringPolling;
|
||||||
|
final status = state.alfaScoringStatus;
|
||||||
|
final showProcessing = status.isProcessing ||
|
||||||
|
(isWaiting && !status.isTerminal);
|
||||||
|
|
||||||
|
if (status == AlfaScoringStatus.none && !isWaiting) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
final displayStatus = showProcessing && !status.isProcessing
|
||||||
|
? AlfaScoringStatus.beneficiaryCreating
|
||||||
|
: status;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: appTheme.greyD9),
|
||||||
|
color: appTheme.whiteA700,
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ScoringStatusHeader(status: displayStatus),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (showProcessing) const ScoringProcessingBody(),
|
||||||
|
if (status.isComplete) const ScoringCompleteBody(),
|
||||||
|
if (status.isFailed)
|
||||||
|
ScoringFailedBody(
|
||||||
|
retryInProgress: state.scoringRetryInProgress,
|
||||||
|
onRetry: () => context
|
||||||
|
.read<RegisterConsultantCubit>()
|
||||||
|
.retryScoring(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ScoringStatusHeader extends StatelessWidget {
|
||||||
|
const ScoringStatusHeader({required this.status, super.key});
|
||||||
|
|
||||||
|
final AlfaScoringStatus status;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Проверка данных',
|
||||||
|
style: TextStyles.textMSemibold.copyWith(color: appTheme.black),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
ScoringStatusLabels.hintFor(status),
|
||||||
|
style: TextStyles.textSMedium.copyWith(
|
||||||
|
color: ScoringStatusLabels.colorFor(status),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ScoringProcessingBody extends StatelessWidget {
|
||||||
|
const ScoringProcessingBody({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: appTheme.blueA400,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Идёт проверка…',
|
||||||
|
style: TextStyles.textMMediumAuto.copyWith(color: appTheme.black),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ScoringCompleteBody extends StatelessWidget {
|
||||||
|
const ScoringCompleteBody({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.check_circle, color: appTheme.green, size: 22),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Проверка успешно завершена',
|
||||||
|
style: TextStyles.textMMediumAuto.copyWith(color: appTheme.black),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ScoringFailedBody extends StatelessWidget {
|
||||||
|
const ScoringFailedBody({
|
||||||
|
required this.retryInProgress,
|
||||||
|
required this.onRetry,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool retryInProgress;
|
||||||
|
final VoidCallback onRetry;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const ValidationErrorWidget(error: 'Не удалось пройти проверку'),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 44,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: retryInProgress ? null : onRetry,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: appTheme.blueA400,
|
||||||
|
disabledBackgroundColor: appTheme.greyD9,
|
||||||
|
foregroundColor: appTheme.whiteA700,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
child: retryInProgress
|
||||||
|
? SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: appTheme.whiteA700,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
'Повторить',
|
||||||
|
style: TextStyles.textMSemibold.copyWith(
|
||||||
|
color: appTheme.whiteA700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract final class ScoringStatusLabels {
|
||||||
|
static String hintFor(AlfaScoringStatus status) {
|
||||||
|
return switch (status) {
|
||||||
|
AlfaScoringStatus.beneficiaryCreating => 'Создание профиля',
|
||||||
|
AlfaScoringStatus.scoringPending => 'Ожидание результата',
|
||||||
|
AlfaScoringStatus.scoringComplete => 'Успешно',
|
||||||
|
AlfaScoringStatus.beneficiaryFailed => 'Ошибка',
|
||||||
|
AlfaScoringStatus.scoringFailed => 'Ошибка',
|
||||||
|
AlfaScoringStatus.none => '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static Color colorFor(AlfaScoringStatus status) {
|
||||||
|
return switch (status) {
|
||||||
|
AlfaScoringStatus.scoringComplete => appTheme.green,
|
||||||
|
AlfaScoringStatus.beneficiaryFailed ||
|
||||||
|
AlfaScoringStatus.scoringFailed => appTheme.red,
|
||||||
|
AlfaScoringStatus.beneficiaryCreating ||
|
||||||
|
AlfaScoringStatus.scoringPending => appTheme.blueA400,
|
||||||
|
AlfaScoringStatus.none => appTheme.grey7B,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import 'package:dio/dio.dart';
|
|||||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/services/consultant_service.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/services/consultant_service.dart';
|
||||||
import 'package:http_parser/http_parser.dart';
|
import 'package:http_parser/http_parser.dart';
|
||||||
|
|
||||||
@@ -11,36 +12,53 @@ class ConsultantRepository {
|
|||||||
|
|
||||||
const ConsultantRepository({required this.consultantService});
|
const ConsultantRepository({required this.consultantService});
|
||||||
|
|
||||||
Future<void> registerConsultant({required ConsultantProfileModel consultant, bool update = false}) async {
|
Future<void> registerConsultant({
|
||||||
|
required ConsultantProfileModel consultant,
|
||||||
|
bool update = false,
|
||||||
|
}) async {
|
||||||
if (update) {
|
if (update) {
|
||||||
return await consultantService.consultantUpdate(consultantModel: consultant);
|
return await consultantService.consultantUpdate(
|
||||||
|
consultantModel: consultant,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
return await consultantService.consultantRegistration(consultantModel: consultant);
|
return await consultantService.consultantRegistration(
|
||||||
|
consultantModel: consultant,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ConsultationRequestModel> getConsultantRequestStatus(String slug) async {
|
/// Профиль консультанта (свой или чужой) — публичные данные без паспорта.
|
||||||
|
Future<ConsultationRequestModel> getConsultantRequestStatus(
|
||||||
|
String slug,
|
||||||
|
) async {
|
||||||
return await consultantService.getConsultantRequestStatus(slug: slug);
|
return await consultantService.getConsultantRequestStatus(slug: slug);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Своя заявка — education + legal/passport для регистрации и update.
|
||||||
Future<ConsultationRequestModel> getMyRequestStatus(String slug) async {
|
Future<ConsultationRequestModel> getMyRequestStatus(String slug) async {
|
||||||
return await consultantService.getMyRequestStatus(slug: slug);
|
return await consultantService.getMyRequestStatus(slug: slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<SbpBankModel>> getSbpBanks() async {
|
||||||
|
return await consultantService.getSbpBanks();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<ConsultantCertPhotoModel>> getConsultantCertPhotos() async {
|
Future<List<ConsultantCertPhotoModel>> getConsultantCertPhotos() async {
|
||||||
return await consultantService.getConsultantCertPhotos();
|
return await consultantService.getConsultantCertPhotos();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<ConsultantCertPhotoModel>> storeConsultantCertPhotos({required List<File> files}) async {
|
Future<List<ConsultantCertPhotoModel>> storeConsultantCertPhotos({
|
||||||
|
required List<File> files,
|
||||||
|
}) async {
|
||||||
final multipartFiles = <MultipartFile>[];
|
final multipartFiles = <MultipartFile>[];
|
||||||
|
|
||||||
for (final file in files) {
|
for (final file in files) {
|
||||||
final fileBytes = await file.readAsBytes();
|
final fileBytes = await file.readAsBytes();
|
||||||
final multipartFile = MultipartFile.fromBytes(fileBytes, filename: file.path.split('/').last, contentType: MediaType('application', 'octet-stream'));
|
final multipartFile = MultipartFile.fromBytes(
|
||||||
|
fileBytes,
|
||||||
|
filename: file.path.split('/').last,
|
||||||
|
contentType: MediaType('application', 'octet-stream'),
|
||||||
|
);
|
||||||
multipartFiles.add(multipartFile);
|
multipartFiles.add(multipartFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,22 +66,24 @@ class ConsultantRepository {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return await consultantService.storeConsultantCertPhotos(photos: multipartFiles);
|
return await consultantService.storeConsultantCertPhotos(
|
||||||
|
photos: multipartFiles,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> hideConsultantCertPhoto(int id) async {
|
Future<void> hideConsultantCertPhoto(int id) async {
|
||||||
await consultantService.hideConsultantCertPhoto(payload: {'cert_photo_id': id});
|
await consultantService.hideConsultantCertPhoto(
|
||||||
|
payload: {'cert_photo_id': id},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> unHideConsultantCertPhoto(int id) async {
|
Future<void> unHideConsultantCertPhoto(int id) async {
|
||||||
await consultantService.unHideConsultantCertPhoto(payload: {'cert_photo_id': id});
|
await consultantService.unHideConsultantCertPhoto(
|
||||||
|
payload: {'cert_photo_id': id},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> deletePhoto(int id) async {
|
Future<void> deletePhoto(int id) async {
|
||||||
await consultantService.deletePhoto(id: id);
|
await consultantService.deletePhoto(id: id);
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
|
|||||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart';
|
||||||
import 'package:retrofit/retrofit.dart';
|
import 'package:retrofit/retrofit.dart';
|
||||||
|
|
||||||
part 'consultant_service.g.dart';
|
part 'consultant_service.g.dart';
|
||||||
@@ -10,21 +11,39 @@ part 'consultant_service.g.dart';
|
|||||||
abstract class ConsultantService {
|
abstract class ConsultantService {
|
||||||
factory ConsultantService(Dio dio, {String baseUrl}) = _ConsultantService;
|
factory ConsultantService(Dio dio, {String baseUrl}) = _ConsultantService;
|
||||||
|
|
||||||
@POST('/api/consultant_request/registration')
|
@POST('/api/v2/consultant_request/registration')
|
||||||
Future<void> consultantRegistration({@Body() required ConsultantProfileModel consultantModel});
|
Future<void> consultantRegistration({
|
||||||
|
@Body() required ConsultantProfileModel consultantModel,
|
||||||
|
});
|
||||||
|
|
||||||
@POST('/api/consultant_request/photos/upload')
|
@POST('/api/consultant_request/photos/upload')
|
||||||
@MultiPart()
|
@MultiPart()
|
||||||
Future<List<String>> uploadPhotos({@Part(name: 'photos[]', contentType: 'multipart/form-data') List<MultipartFile>? photos});
|
Future<List<String>> uploadPhotos({
|
||||||
|
@Part(name: 'photos[]', contentType: 'multipart/form-data')
|
||||||
|
List<MultipartFile>? photos,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Публичный статус консультанта для просмотра профиля.
|
||||||
|
/// Без паспортных данных: `/api/profile/consultant/{slug}/status`.
|
||||||
@GET('/api/profile/consultant/{slug}/status')
|
@GET('/api/profile/consultant/{slug}/status')
|
||||||
Future<ConsultationRequestModel> getConsultantRequestStatus({@Path() required String slug});
|
Future<ConsultationRequestModel> getConsultantRequestStatus({
|
||||||
|
@Path() required String slug,
|
||||||
|
});
|
||||||
|
|
||||||
@GET('/api/consultant_request/{slug}/status')
|
/// Свой статус заявки для редактирования/модерации.
|
||||||
Future<ConsultationRequestModel> getMyRequestStatus({@Path() required String slug});
|
/// С паспортными полями в `legal_entity_resource`: `/api/v2/consultant_request/{slug}/status`.
|
||||||
|
@GET('/api/v2/consultant_request/{slug}/status')
|
||||||
|
Future<ConsultationRequestModel> getMyRequestStatus({
|
||||||
|
@Path() required String slug,
|
||||||
|
});
|
||||||
|
|
||||||
@PUT('/api/consultant_request/update')
|
@PUT('/api/v2/consultant_request/update')
|
||||||
Future<void> consultantUpdate({@Body() required ConsultantProfileModel consultantModel});
|
Future<void> consultantUpdate({
|
||||||
|
@Body() required ConsultantProfileModel consultantModel,
|
||||||
|
});
|
||||||
|
|
||||||
|
@GET('/api/finances/sbp_banks')
|
||||||
|
Future<List<SbpBankModel>> getSbpBanks();
|
||||||
|
|
||||||
@GET('/api/v2/consultant_request/cert_photos')
|
@GET('/api/v2/consultant_request/cert_photos')
|
||||||
Future<List<ConsultantCertPhotoModel>> getConsultantCertPhotos();
|
Future<List<ConsultantCertPhotoModel>> getConsultantCertPhotos();
|
||||||
@@ -32,14 +51,19 @@ abstract class ConsultantService {
|
|||||||
@POST('/api/v2/consultant_request/cert_photos/store')
|
@POST('/api/v2/consultant_request/cert_photos/store')
|
||||||
@MultiPart()
|
@MultiPart()
|
||||||
Future<List<ConsultantCertPhotoModel>> storeConsultantCertPhotos({
|
Future<List<ConsultantCertPhotoModel>> storeConsultantCertPhotos({
|
||||||
@Part(name: 'photos[]', contentType: 'multipart/form-data') required List<MultipartFile> photos,
|
@Part(name: 'photos[]', contentType: 'multipart/form-data')
|
||||||
|
required List<MultipartFile> photos,
|
||||||
});
|
});
|
||||||
|
|
||||||
@POST('/api/v2/consultant_request/cert_photos/hide')
|
@POST('/api/v2/consultant_request/cert_photos/hide')
|
||||||
Future<void> hideConsultantCertPhoto({@Body() required Map<String, dynamic> payload});
|
Future<void> hideConsultantCertPhoto({
|
||||||
|
@Body() required Map<String, dynamic> payload,
|
||||||
|
});
|
||||||
|
|
||||||
@POST('/api/v2/consultant_request/cert_photos/un_hide')
|
@POST('/api/v2/consultant_request/cert_photos/un_hide')
|
||||||
Future<void> unHideConsultantCertPhoto({@Body() required Map<String, dynamic> payload});
|
Future<void> unHideConsultantCertPhoto({
|
||||||
|
@Body() required Map<String, dynamic> payload,
|
||||||
|
});
|
||||||
|
|
||||||
@POST('/api/v2/consultant_request/cert_photos/{id}/queue-for-deletion')
|
@POST('/api/v2/consultant_request/cert_photos/{id}/queue-for-deletion')
|
||||||
Future<void> deletePhoto({@Path() required int id});
|
Future<void> deletePhoto({@Path() required int id});
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
/// Parsed Laravel 422 validation payload for consultant registration UI.
|
||||||
|
class LaravelValidationErrors {
|
||||||
|
const LaravelValidationErrors({
|
||||||
|
required this.summary,
|
||||||
|
this.fieldErrors = const <String, String>{},
|
||||||
|
});
|
||||||
|
|
||||||
|
final String summary;
|
||||||
|
final Map<String, String> fieldErrors;
|
||||||
|
|
||||||
|
static const LaravelValidationErrors empty = LaravelValidationErrors(
|
||||||
|
summary: '',
|
||||||
|
);
|
||||||
|
|
||||||
|
bool get hasFieldErrors => fieldErrors.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _genericValidationSummary = 'Проверьте введённые данные';
|
||||||
|
const _genericNetworkSummary =
|
||||||
|
'Не удалось отправить данные. Попробуйте ещё раз';
|
||||||
|
|
||||||
|
/// Parses Laravel `errors` map and maps them to user-facing Russian texts.
|
||||||
|
/// Raw backend messages are never shown in the UI.
|
||||||
|
LaravelValidationErrors parseLaravelValidationErrors(Object error) {
|
||||||
|
if (error is! DioException) {
|
||||||
|
return const LaravelValidationErrors(summary: _genericNetworkSummary);
|
||||||
|
}
|
||||||
|
|
||||||
|
final data = error.response?.data;
|
||||||
|
if (error.response?.statusCode == 422 && data is Map) {
|
||||||
|
final fieldErrors = <String, String>{};
|
||||||
|
final errors = data['errors'];
|
||||||
|
|
||||||
|
if (errors is Map) {
|
||||||
|
errors.forEach((key, value) {
|
||||||
|
final fieldKey = key.toString();
|
||||||
|
final raw = _rawErrorText(value);
|
||||||
|
fieldErrors[fieldKey] = mapConsultantValidationMessage(
|
||||||
|
fieldKey: fieldKey,
|
||||||
|
rawMessage: raw,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldErrors.isNotEmpty) {
|
||||||
|
final uniqueMessages = fieldErrors.values.toSet().toList();
|
||||||
|
return LaravelValidationErrors(
|
||||||
|
summary: uniqueMessages.join('\n'),
|
||||||
|
fieldErrors: fieldErrors,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return const LaravelValidationErrors(summary: _genericValidationSummary);
|
||||||
|
}
|
||||||
|
|
||||||
|
return const LaravelValidationErrors(summary: _genericNetworkSummary);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _rawErrorText(Object? value) {
|
||||||
|
if (value is List && value.isNotEmpty) {
|
||||||
|
return value.map((e) => e.toString()).join(' ');
|
||||||
|
}
|
||||||
|
if (value is String) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps a backend validation message for [fieldKey] to a short Russian UI string.
|
||||||
|
String mapConsultantValidationMessage({
|
||||||
|
required String fieldKey,
|
||||||
|
required String rawMessage,
|
||||||
|
}) {
|
||||||
|
final field = fieldKey.split('.').last.toLowerCase();
|
||||||
|
final raw = rawMessage.toLowerCase();
|
||||||
|
|
||||||
|
final isUnique =
|
||||||
|
raw.contains('taken') ||
|
||||||
|
raw.contains('unique') ||
|
||||||
|
raw.contains('already') ||
|
||||||
|
raw.contains('уже существ') ||
|
||||||
|
raw.contains('уже занят');
|
||||||
|
final isRequired =
|
||||||
|
raw.contains('required') ||
|
||||||
|
raw.contains('обязательн') ||
|
||||||
|
raw.contains('required_if');
|
||||||
|
final isAccepted = raw.contains('accepted') || field.contains('agreement');
|
||||||
|
final isUrl = raw.contains('url') || field.contains('link');
|
||||||
|
final isFormat =
|
||||||
|
raw.contains('format') ||
|
||||||
|
raw.contains('invalid') ||
|
||||||
|
raw.contains('regex') ||
|
||||||
|
raw.contains('digits') ||
|
||||||
|
raw.contains('size') ||
|
||||||
|
raw.contains('date') ||
|
||||||
|
raw.contains('before');
|
||||||
|
|
||||||
|
if (isAccepted) {
|
||||||
|
if (field.contains('frdo')) {
|
||||||
|
return 'Подтвердите согласие ФРДО';
|
||||||
|
}
|
||||||
|
return 'Примите условия оферты';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUnique) {
|
||||||
|
return switch (field) {
|
||||||
|
'inn' => 'Такой ИНН существует',
|
||||||
|
'snils' => 'Такой СНИЛС существует',
|
||||||
|
'passport_number' || 'passport_series' => 'Такой паспорт существует',
|
||||||
|
'sbp_phone' => 'Такой телефон СБП существует',
|
||||||
|
_ => 'Такие данные уже существуют',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRequired) {
|
||||||
|
return switch (field) {
|
||||||
|
'inn' => 'Укажите ИНН',
|
||||||
|
'snils' => 'Укажите СНИЛС',
|
||||||
|
'last_name' => 'Укажите фамилию',
|
||||||
|
'first_name' => 'Укажите имя',
|
||||||
|
'middle_name' => 'Укажите отчество',
|
||||||
|
'passport_series' => 'Укажите серию паспорта',
|
||||||
|
'passport_number' => 'Укажите номер паспорта',
|
||||||
|
'passport_issue_date' => 'Укажите дату выдачи паспорта',
|
||||||
|
'passport_issuer' => 'Укажите, кем выдан паспорт',
|
||||||
|
'passport_issuer_code' => 'Укажите код подразделения',
|
||||||
|
'birth_date' => 'Укажите дату рождения',
|
||||||
|
'birth_place' => 'Укажите место рождения',
|
||||||
|
'registration_address' => 'Укажите адрес регистрации',
|
||||||
|
'residence_address' => 'Укажите адрес проживания',
|
||||||
|
'citizenship' => 'Укажите гражданство',
|
||||||
|
'sbp_phone' => 'Укажите телефон СБП',
|
||||||
|
'sbp_bank_member_id' => 'Выберите банк СБП',
|
||||||
|
'completed_courses' => 'Добавьте хотя бы один курс',
|
||||||
|
'title' => 'Укажите название курса',
|
||||||
|
'date' => 'Укажите дату курса',
|
||||||
|
'agreement_accepted' => 'Примите условия оферты',
|
||||||
|
'frdo_agreement_accepted' => 'Подтвердите согласие ФРДО',
|
||||||
|
_ => 'Заполните поле',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUrl) {
|
||||||
|
return 'Некорректная ссылка';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFormat || raw.isNotEmpty) {
|
||||||
|
return switch (field) {
|
||||||
|
'inn' => 'Некорректный ИНН',
|
||||||
|
'snils' => 'Некорректный СНИЛС',
|
||||||
|
'passport_series' => 'Некорректная серия паспорта',
|
||||||
|
'passport_number' => 'Некорректный номер паспорта',
|
||||||
|
'passport_issue_date' => 'Некорректная дата выдачи',
|
||||||
|
'passport_issuer_code' => 'Некорректный код подразделения',
|
||||||
|
'last_name' => 'Некорректная фамилия',
|
||||||
|
'first_name' => 'Некорректное имя',
|
||||||
|
'middle_name' => 'Некорректное отчество',
|
||||||
|
'birth_date' => 'Некорректная дата рождения',
|
||||||
|
'citizenship' => 'Некорректный код гражданства',
|
||||||
|
'sbp_phone' => 'Некорректный телефон СБП',
|
||||||
|
'title' => 'Слишком короткое название курса',
|
||||||
|
'date' => 'Некорректная дата курса',
|
||||||
|
_ => 'Некорректное значение',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Проверьте поле';
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
|
||||||
|
|
||||||
|
/// Client-side validation for individual-person passport fields.
|
||||||
|
///
|
||||||
|
/// Messages match [mapConsultantValidationMessage] so 422 and local errors
|
||||||
|
/// look the same in the UI. Dates also reject non-calendar values and
|
||||||
|
/// dates after today.
|
||||||
|
abstract final class PassportDataValidator {
|
||||||
|
static const futureDateMessage = 'Дата не может быть в будущем';
|
||||||
|
|
||||||
|
/// Alfa Bank `personalInformation.{last,first,middle}Name` pattern.
|
||||||
|
static final alfaPersonNamePattern = RegExp(r'^[a-zA-Zа-яА-ЯёЁ ,.-]{1,50}$');
|
||||||
|
|
||||||
|
static Map<String, String> validate(
|
||||||
|
ConsultantProfileModel consultant, {
|
||||||
|
DateTime? now,
|
||||||
|
}) {
|
||||||
|
final today = now ?? DateTime.now();
|
||||||
|
final errors = <String, String>{};
|
||||||
|
|
||||||
|
_requirePattern(
|
||||||
|
errors,
|
||||||
|
key: 'last_name',
|
||||||
|
value: consultant.lastName,
|
||||||
|
pattern: alfaPersonNamePattern,
|
||||||
|
emptyMessage: 'Укажите фамилию',
|
||||||
|
invalidMessage: 'Некорректная фамилия',
|
||||||
|
);
|
||||||
|
_requirePattern(
|
||||||
|
errors,
|
||||||
|
key: 'first_name',
|
||||||
|
value: consultant.firstName,
|
||||||
|
pattern: alfaPersonNamePattern,
|
||||||
|
emptyMessage: 'Укажите имя',
|
||||||
|
invalidMessage: 'Некорректное имя',
|
||||||
|
);
|
||||||
|
_optionalPattern(
|
||||||
|
errors,
|
||||||
|
key: 'middle_name',
|
||||||
|
value: consultant.middleName,
|
||||||
|
pattern: alfaPersonNamePattern,
|
||||||
|
invalidMessage: 'Некорректное отчество',
|
||||||
|
);
|
||||||
|
_requirePattern(
|
||||||
|
errors,
|
||||||
|
key: 'inn',
|
||||||
|
value: consultant.inn,
|
||||||
|
pattern: RegExp(r'^\d{12}$'),
|
||||||
|
emptyMessage: 'Укажите ИНН',
|
||||||
|
invalidMessage: 'Некорректный ИНН',
|
||||||
|
);
|
||||||
|
_requirePattern(
|
||||||
|
errors,
|
||||||
|
key: 'snils',
|
||||||
|
value: consultant.snils,
|
||||||
|
pattern: RegExp(r'^\d{3}-\d{3}-\d{3} \d{2}$'),
|
||||||
|
emptyMessage: 'Укажите СНИЛС',
|
||||||
|
invalidMessage: 'Некорректный СНИЛС',
|
||||||
|
);
|
||||||
|
_requirePattern(
|
||||||
|
errors,
|
||||||
|
key: 'passport_series',
|
||||||
|
value: consultant.passportSeries,
|
||||||
|
pattern: RegExp(r'^\d{4}$'),
|
||||||
|
emptyMessage: 'Укажите серию паспорта',
|
||||||
|
invalidMessage: 'Некорректная серия паспорта',
|
||||||
|
);
|
||||||
|
_requirePattern(
|
||||||
|
errors,
|
||||||
|
key: 'passport_number',
|
||||||
|
value: consultant.passportNumber,
|
||||||
|
pattern: RegExp(r'^\d{6}$'),
|
||||||
|
emptyMessage: 'Укажите номер паспорта',
|
||||||
|
invalidMessage: 'Некорректный номер паспорта',
|
||||||
|
);
|
||||||
|
_requireText(
|
||||||
|
errors,
|
||||||
|
key: 'passport_issuer',
|
||||||
|
value: consultant.passportIssuer,
|
||||||
|
emptyMessage: 'Укажите, кем выдан паспорт',
|
||||||
|
);
|
||||||
|
_requirePattern(
|
||||||
|
errors,
|
||||||
|
key: 'passport_issuer_code',
|
||||||
|
value: consultant.passportIssuerCode,
|
||||||
|
pattern: RegExp(r'^\d{3}-\d{3}$'),
|
||||||
|
emptyMessage: 'Укажите код подразделения',
|
||||||
|
invalidMessage: 'Некорректный код подразделения',
|
||||||
|
);
|
||||||
|
_requireDate(
|
||||||
|
errors,
|
||||||
|
key: 'passport_issue_date',
|
||||||
|
value: consultant.passportIssueDate,
|
||||||
|
emptyMessage: 'Укажите дату выдачи паспорта',
|
||||||
|
invalidMessage: 'Некорректная дата выдачи',
|
||||||
|
today: today,
|
||||||
|
);
|
||||||
|
_requireDate(
|
||||||
|
errors,
|
||||||
|
key: 'birth_date',
|
||||||
|
value: consultant.birthDate,
|
||||||
|
emptyMessage: 'Укажите дату рождения',
|
||||||
|
invalidMessage: 'Некорректная дата рождения',
|
||||||
|
today: today,
|
||||||
|
);
|
||||||
|
_requireText(
|
||||||
|
errors,
|
||||||
|
key: 'birth_place',
|
||||||
|
value: consultant.birthPlace,
|
||||||
|
emptyMessage: 'Укажите место рождения',
|
||||||
|
);
|
||||||
|
_requireText(
|
||||||
|
errors,
|
||||||
|
key: 'registration_address',
|
||||||
|
value: consultant.registrationAddress,
|
||||||
|
emptyMessage: 'Укажите адрес регистрации',
|
||||||
|
);
|
||||||
|
_requireText(
|
||||||
|
errors,
|
||||||
|
key: 'residence_address',
|
||||||
|
value: consultant.residenceAddress,
|
||||||
|
emptyMessage: 'Укажите адрес проживания',
|
||||||
|
);
|
||||||
|
_requirePattern(
|
||||||
|
errors,
|
||||||
|
key: 'citizenship',
|
||||||
|
value: consultant.citizenship,
|
||||||
|
pattern: RegExp(r'^[A-Za-z]{2}$'),
|
||||||
|
emptyMessage: 'Укажите гражданство',
|
||||||
|
invalidMessage: 'Некорректный код гражданства',
|
||||||
|
);
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _requireText(
|
||||||
|
Map<String, String> errors, {
|
||||||
|
required String key,
|
||||||
|
required String? value,
|
||||||
|
required String emptyMessage,
|
||||||
|
}) {
|
||||||
|
if (value == null || value.trim().isEmpty) {
|
||||||
|
errors[key] = emptyMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _requirePattern(
|
||||||
|
Map<String, String> errors, {
|
||||||
|
required String key,
|
||||||
|
required String? value,
|
||||||
|
required RegExp pattern,
|
||||||
|
required String emptyMessage,
|
||||||
|
required String invalidMessage,
|
||||||
|
}) {
|
||||||
|
final raw = value?.trim() ?? '';
|
||||||
|
if (raw.isEmpty) {
|
||||||
|
errors[key] = emptyMessage;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!pattern.hasMatch(raw)) {
|
||||||
|
errors[key] = invalidMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _optionalPattern(
|
||||||
|
Map<String, String> errors, {
|
||||||
|
required String key,
|
||||||
|
required String? value,
|
||||||
|
required RegExp pattern,
|
||||||
|
required String invalidMessage,
|
||||||
|
}) {
|
||||||
|
final raw = value?.trim() ?? '';
|
||||||
|
if (raw.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!pattern.hasMatch(raw)) {
|
||||||
|
errors[key] = invalidMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _requireDate(
|
||||||
|
Map<String, String> errors, {
|
||||||
|
required String key,
|
||||||
|
required String? value,
|
||||||
|
required String emptyMessage,
|
||||||
|
required String invalidMessage,
|
||||||
|
required DateTime today,
|
||||||
|
}) {
|
||||||
|
final raw = value?.trim() ?? '';
|
||||||
|
if (raw.isEmpty) {
|
||||||
|
errors[key] = emptyMessage;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final match = RegExp(r'^(\d{4})-(\d{2})-(\d{2})$').firstMatch(raw);
|
||||||
|
if (match == null) {
|
||||||
|
errors[key] = invalidMessage;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final year = int.parse(match.group(1)!);
|
||||||
|
final month = int.parse(match.group(2)!);
|
||||||
|
final day = int.parse(match.group(3)!);
|
||||||
|
final parsed = DateTime(year, month, day);
|
||||||
|
if (parsed.year != year || parsed.month != month || parsed.day != day) {
|
||||||
|
errors[key] = invalidMessage;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final todayDate = DateTime(today.year, today.month, today.day);
|
||||||
|
if (parsed.isAfter(todayDate)) {
|
||||||
|
errors[key] = futureDateMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,43 +59,47 @@ class _AddMealWidgetState extends State<AddMealWidget> {
|
|||||||
Row(
|
Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
Expanded(
|
||||||
width: MediaQuery.of(context).size.width * 0.8 - 32,
|
flex: 2,
|
||||||
child: CustomTextFormField(
|
child: SizedBox(
|
||||||
controller: productNameController,
|
child: CustomTextFormField(
|
||||||
autofocus: true,
|
controller: productNameController,
|
||||||
hintText: 'Введите название продукта/блюда',
|
autofocus: true,
|
||||||
onChanged: (query) {
|
hintText: 'Введите название продукта/блюда',
|
||||||
EasyDebounce.debounce('glossary-search', const Duration(milliseconds: 250), () async {
|
onChanged: (query) {
|
||||||
selectedGlossary = null;
|
EasyDebounce.debounce('glossary-search', const Duration(milliseconds: 250), () async {
|
||||||
if (query.isEmpty) {
|
|
||||||
glossariesList = [];
|
|
||||||
selectedGlossary = null;
|
selectedGlossary = null;
|
||||||
|
if (query.isEmpty) {
|
||||||
|
glossariesList = [];
|
||||||
|
selectedGlossary = null;
|
||||||
|
setState(() {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
glossariesList = await widget.createDietCubit.getGlossaryListByQuery(query);
|
||||||
setState(() {});
|
setState(() {});
|
||||||
return;
|
});
|
||||||
}
|
},
|
||||||
glossariesList = await widget.createDietCubit.getGlossaryListByQuery(query);
|
),
|
||||||
setState(() {});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
SizedBox(
|
Expanded(
|
||||||
width: MediaQuery.of(context).size.width * 0.2 - 4,
|
flex: 1,
|
||||||
child: CustomTextFormField(
|
child: SizedBox(
|
||||||
controller: quantityController,
|
child: CustomTextFormField(
|
||||||
keyboardType: TextInputType.number,
|
controller: quantityController,
|
||||||
hintText: selectedGlossary == null
|
keyboardType: TextInputType.number,
|
||||||
? '100'
|
hintText: selectedGlossary == null
|
||||||
: selectedGlossary!.grInPortion == 0
|
? '100'
|
||||||
? '100'
|
: selectedGlossary!.grInPortion == 0
|
||||||
: '1',
|
? '100'
|
||||||
suffixText: selectedGlossary == null
|
: '1',
|
||||||
? 'гр'
|
suffixText: selectedGlossary == null
|
||||||
: selectedGlossary?.grInPortion == 0
|
? 'гр'
|
||||||
? 'гр'
|
: selectedGlossary?.grInPortion == 0
|
||||||
: 'шт',
|
? 'гр'
|
||||||
|
: 'шт',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -149,17 +153,19 @@ class _AddMealWidgetState extends State<AddMealWidget> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (widget.portionIndex != null) const SizedBox(width: 8),
|
if (widget.portionIndex != null) const SizedBox(width: 8),
|
||||||
InkWell(
|
Expanded(
|
||||||
onTap: addPortion,
|
child: InkWell(
|
||||||
borderRadius: BorderRadius.circular(20),
|
onTap: addPortion,
|
||||||
child: Ink(
|
borderRadius: BorderRadius.circular(20),
|
||||||
width: MediaQuery.of(context).size.width - (widget.portionIndex == null ? 32 : 92),
|
child: Ink(
|
||||||
height: 52,
|
width: MediaQuery.of(context).size.width - (widget.portionIndex == null ? 32 : 92),
|
||||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
|
height: 52,
|
||||||
child: Center(
|
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
|
||||||
child: Text(
|
child: Center(
|
||||||
widget.portionIndex == null ? 'Добавить' : 'Сохранить',
|
child: Text(
|
||||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white),
|
widget.portionIndex == null ? 'Добавить' : 'Сохранить',
|
||||||
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import 'package:eatvio_app/core/widgets/success_published_diet_bottom_sheet.dart
|
|||||||
import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart';
|
import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart';
|
||||||
import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_cubit.dart';
|
import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_cubit.dart';
|
||||||
import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_state.dart';
|
import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_state.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||||
import 'package:eatvio_app/features/create_plan/presentation/widgets/option_widget.dart';
|
import 'package:eatvio_app/features/create_plan/presentation/widgets/option_widget.dart';
|
||||||
import 'package:eatvio_app/features/create_plan/presentation/widgets/options_count_button.dart';
|
import 'package:eatvio_app/features/create_plan/presentation/widgets/options_count_button.dart';
|
||||||
import 'package:eatvio_app/features/create_plan/presentation/widgets/plan_duration_drop_down_widget.dart';
|
import 'package:eatvio_app/features/create_plan/presentation/widgets/plan_duration_drop_down_widget.dart';
|
||||||
@@ -25,11 +26,23 @@ import 'package:go_router/go_router.dart';
|
|||||||
class CreatePlanScreen extends StatelessWidget {
|
class CreatePlanScreen extends StatelessWidget {
|
||||||
static const name = 'createPlanScreen';
|
static const name = 'createPlanScreen';
|
||||||
static const path = '/create-Plan-screen';
|
static const path = '/create-Plan-screen';
|
||||||
const CreatePlanScreen({super.key});
|
const CreatePlanScreen({super.key, this.plan});
|
||||||
|
|
||||||
|
final PlanModel? plan;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocProvider(create: (context) => sl<CreatePlanCubit>(), child: const CreatePlanScreenContent());
|
return BlocProvider(
|
||||||
|
create: (context) {
|
||||||
|
final cubit = sl<CreatePlanCubit>();
|
||||||
|
final existingPlan = plan;
|
||||||
|
if (existingPlan != null) {
|
||||||
|
cubit.loadExisting(existingPlan);
|
||||||
|
}
|
||||||
|
return cubit;
|
||||||
|
},
|
||||||
|
child: const CreatePlanScreenContent(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,17 +60,19 @@ class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final s = S.of(context);
|
final s = S.of(context);
|
||||||
|
final isEditing = context.read<CreatePlanCubit>().sourcePlan != null;
|
||||||
return BlocBuilder<CreatePlanCubit, CreatePlanState>(
|
return BlocBuilder<CreatePlanCubit, CreatePlanState>(
|
||||||
buildWhen: (previous, current) => previous.planCreated != current.planCreated || previous.isLoading != current.isLoading,
|
buildWhen: (previous, current) => previous.planCreated != current.planCreated || previous.isLoading != current.isLoading,
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
if (state.planCreated) {
|
if (state.planCreated) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
context.pop();
|
final isEditing = context.read<CreatePlanCubit>().sourcePlan != null;
|
||||||
|
context.pop(true);
|
||||||
showBottomSheetWithBlur(
|
showBottomSheetWithBlur(
|
||||||
userRootNavigator: true,
|
userRootNavigator: true,
|
||||||
context: context,
|
context: context,
|
||||||
child: SuccessPublishedDietOrPlanBottomSheet(title: state.planData.name ?? 'План', isPlan: true),
|
child: SuccessPublishedDietOrPlanBottomSheet(title: state.planData.name ?? 'План', isPlan: true, isUpdated: isEditing),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -68,7 +83,7 @@ class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
|
|||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
title: Text(
|
title: Text(
|
||||||
s.adding_plan,
|
isEditing ? 'Редактирование плана' : s.adding_plan,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
softWrap: true,
|
softWrap: true,
|
||||||
@@ -102,7 +117,9 @@ class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
|
|||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
CustomTextFormField(
|
CustomTextFormField(
|
||||||
|
key: const ValueKey('plan-name'),
|
||||||
hintText: s.plan_label_desc,
|
hintText: s.plan_label_desc,
|
||||||
|
initialValue: state.planData.name,
|
||||||
hasError: state.validationState.titleHasError,
|
hasError: state.validationState.titleHasError,
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
context.read<CreatePlanCubit>().setName(value);
|
context.read<CreatePlanCubit>().setName(value);
|
||||||
@@ -144,6 +161,7 @@ class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
|
|||||||
children: [
|
children: [
|
||||||
MarkdownEditor(
|
MarkdownEditor(
|
||||||
key: _mdKey,
|
key: _mdKey,
|
||||||
|
initialText: state.planData.details,
|
||||||
hintText: s.plan_desc_example,
|
hintText: s.plan_desc_example,
|
||||||
hasError: state.validationState.descriptionHasError,
|
hasError: state.validationState.descriptionHasError,
|
||||||
minLines: 6,
|
minLines: 6,
|
||||||
@@ -230,7 +248,7 @@ class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
|
|||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
await context.read<CreatePlanCubit>().publishPlan();
|
await context.read<CreatePlanCubit>().publishPlan();
|
||||||
},
|
},
|
||||||
text: s.publish,
|
text: isEditing ? 'Обновить' : s.publish,
|
||||||
height: 52,
|
height: 52,
|
||||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||||
buttonStyle: ButtonStyle(
|
buttonStyle: ButtonStyle(
|
||||||
|
|||||||
@@ -4,16 +4,57 @@ import 'dart:io';
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:eatvio_app/core/const.dart';
|
import 'package:eatvio_app/core/const.dart';
|
||||||
import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart';
|
import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart';
|
||||||
|
import 'package:eatvio_app/core/utils/toasts.dart';
|
||||||
import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_state.dart';
|
import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_state.dart';
|
||||||
import 'package:eatvio_app/features/create_plan/repositories/create_plan_repository.dart';
|
import 'package:eatvio_app/features/create_plan/repositories/create_plan_repository.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||||
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||||
|
|
||||||
class CreatePlanCubit extends Cubit<CreatePlanState> {
|
class CreatePlanCubit extends Cubit<CreatePlanState> {
|
||||||
final CreatePlanRepository createPlanRepository;
|
final CreatePlanRepository createPlanRepository;
|
||||||
|
PlanModel? sourcePlan;
|
||||||
|
|
||||||
CreatePlanCubit({required this.createPlanRepository}) : super(CreatePlanState.initial());
|
CreatePlanCubit({required this.createPlanRepository}) : super(CreatePlanState.initial());
|
||||||
|
|
||||||
|
void loadExisting(PlanModel plan) {
|
||||||
|
sourcePlan = plan;
|
||||||
|
final durationIndex = durationValues.indexOf(plan.duration);
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
durationIndex: durationIndex >= 0 ? durationIndex : null,
|
||||||
|
selectedOptionIndex: plan.packetsCount == 3 ? 0 : null,
|
||||||
|
planData: state.planData.copyWith(
|
||||||
|
name: plan.name,
|
||||||
|
details: plan.details,
|
||||||
|
duration: plan.duration,
|
||||||
|
state: plan.state,
|
||||||
|
type: [
|
||||||
|
plan.type.slim ?? false,
|
||||||
|
plan.type.fit ?? false,
|
||||||
|
plan.type.wellness ?? false,
|
||||||
|
plan.type.behavior ?? false,
|
||||||
|
],
|
||||||
|
options: [
|
||||||
|
_packetOrEmpty(plan.packet1Options),
|
||||||
|
plan.packetsCount == 3 ? _packetOrEmpty(plan.packet2Options) : PacketOptions.empty(),
|
||||||
|
plan.packetsCount == 3 ? _packetOrEmpty(plan.packet3Options) : PacketOptions.empty(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
PacketOptions _packetOrEmpty(PacketOptions? option) {
|
||||||
|
if (option == null) {
|
||||||
|
return PacketOptions.empty();
|
||||||
|
}
|
||||||
|
final price = option.price;
|
||||||
|
final normalizedPrice = price is num ? price.toDouble() : double.tryParse('$price') ?? 1200;
|
||||||
|
return option.copyWith(price: normalizedPrice);
|
||||||
|
}
|
||||||
|
|
||||||
void setDetails(String text) {
|
void setDetails(String text) {
|
||||||
emit(state.copyWith(planData: state.planData.copyWith(details: text)));
|
emit(state.copyWith(planData: state.planData.copyWith(details: text)));
|
||||||
validateDetails();
|
validateDetails();
|
||||||
@@ -147,7 +188,7 @@ class CreatePlanCubit extends Cubit<CreatePlanState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool validateImage() {
|
bool validateImage() {
|
||||||
if (state.planData.image == null) {
|
if (state.planData.image == null && sourcePlan == null) {
|
||||||
emit(state.copyWith.validationState(imageHasError: true));
|
emit(state.copyWith.validationState(imageHasError: true));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -413,31 +454,34 @@ class CreatePlanCubit extends Cubit<CreatePlanState> {
|
|||||||
'type[fit]': state.planData.type[1] ? '1' : '0',
|
'type[fit]': state.planData.type[1] ? '1' : '0',
|
||||||
'type[wellness]': state.planData.type[2] ? '1' : '0',
|
'type[wellness]': state.planData.type[2] ? '1' : '0',
|
||||||
'type[behavior]': state.planData.type[3] ? '1' : '0',
|
'type[behavior]': state.planData.type[3] ? '1' : '0',
|
||||||
'image': state.planData.image != null
|
|
||||||
? await MultipartFile.fromFile((await ImageCompressUtility.compressFile(File(state.planData.image!.path))).path,
|
|
||||||
filename: state.planData.image!.name)
|
|
||||||
: null
|
|
||||||
});
|
});
|
||||||
|
if (state.planData.image != null) {
|
||||||
|
final image = state.planData.image!;
|
||||||
|
planMap['image'] = await MultipartFile.fromFile(
|
||||||
|
(await ImageCompressUtility.compressFile(File(image.path))).path,
|
||||||
|
filename: image.name,
|
||||||
|
);
|
||||||
|
}
|
||||||
if (state.planData.options[1].short.isNotEmpty) {
|
if (state.planData.options[1].short.isNotEmpty) {
|
||||||
planMap.addAll({
|
planMap.addAll({
|
||||||
'packets_count': 3,
|
'packets_count': 3,
|
||||||
'packet1_options': {
|
'packet1_options': {
|
||||||
'short': state.planData.options[0].short,
|
'short': state.planData.options[0].short,
|
||||||
'price': int.parse((state.planData.options[0].price as double).toStringAsFixed(0)),
|
'price': _optionPrice(state.planData.options[0].price),
|
||||||
'description_md': state.planData.options[0].description,
|
'description_md': state.planData.options[0].description,
|
||||||
'items': Map.fromIterable(state.planData.options[0].items),
|
'items': Map.fromIterable(state.planData.options[0].items),
|
||||||
'has_personal_diet': state.planData.options[0].hasPersonalDiet ? '1' : '0',
|
'has_personal_diet': state.planData.options[0].hasPersonalDiet ? '1' : '0',
|
||||||
},
|
},
|
||||||
'packet2_options': {
|
'packet2_options': {
|
||||||
'short': state.planData.options[1].short,
|
'short': state.planData.options[1].short,
|
||||||
'price': int.parse((state.planData.options[1].price as double).toStringAsFixed(0)),
|
'price': _optionPrice(state.planData.options[1].price),
|
||||||
'description_md': state.planData.options[1].description,
|
'description_md': state.planData.options[1].description,
|
||||||
'items': Map.fromIterable(state.planData.options[1].items),
|
'items': Map.fromIterable(state.planData.options[1].items),
|
||||||
'has_personal_diet': state.planData.options[1].hasPersonalDiet ? '1' : '0',
|
'has_personal_diet': state.planData.options[1].hasPersonalDiet ? '1' : '0',
|
||||||
},
|
},
|
||||||
'packet3_options': {
|
'packet3_options': {
|
||||||
'short': state.planData.options[2].short,
|
'short': state.planData.options[2].short,
|
||||||
'price': int.parse((state.planData.options[2].price as double).toStringAsFixed(0)),
|
'price': _optionPrice(state.planData.options[2].price),
|
||||||
'description_md': state.planData.options[2].description,
|
'description_md': state.planData.options[2].description,
|
||||||
'items': Map.fromIterable(state.planData.options[2].items),
|
'items': Map.fromIterable(state.planData.options[2].items),
|
||||||
'has_personal_diet': state.planData.options[2].hasPersonalDiet ? '1' : '0',
|
'has_personal_diet': state.planData.options[2].hasPersonalDiet ? '1' : '0',
|
||||||
@@ -448,20 +492,34 @@ class CreatePlanCubit extends Cubit<CreatePlanState> {
|
|||||||
'packets_count': 1,
|
'packets_count': 1,
|
||||||
'packet1_options': {
|
'packet1_options': {
|
||||||
'short': state.planData.options[0].short,
|
'short': state.planData.options[0].short,
|
||||||
'price': int.parse((state.planData.options[0].price as double).toStringAsFixed(0)),
|
'price': _optionPrice(state.planData.options[0].price),
|
||||||
'description_md': state.planData.options[0].description,
|
'description_md': state.planData.options[0].description,
|
||||||
'items': Map.fromIterable(state.planData.options[0].items),
|
'items': Map.fromIterable(state.planData.options[0].items),
|
||||||
'has_personal_diet': state.planData.options[0].hasPersonalDiet ? '1' : '0',
|
'has_personal_diet': state.planData.options[0].hasPersonalDiet ? '1' : '0',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await createPlanRepository.createPlan(model: planMap);
|
final editingPlan = sourcePlan;
|
||||||
|
if (editingPlan == null) {
|
||||||
|
await createPlanRepository.createPlan(model: planMap);
|
||||||
|
} else {
|
||||||
|
await createPlanRepository.updatePublishedPlan(slug: editingPlan.slug, model: planMap);
|
||||||
|
}
|
||||||
emit(state.copyWith(isLoading: false, planCreated: true));
|
emit(state.copyWith(isLoading: false, planCreated: true));
|
||||||
}
|
}
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
log(e.toString());
|
log(e.toString());
|
||||||
emit(state.copyWith(isLoading: false, planCreated: true));
|
emit(state.copyWith(isLoading: false, planCreated: false));
|
||||||
|
final isForbidden = e is DioException && e.response?.statusCode == 403;
|
||||||
|
showErrorToast(isForbidden ? 'Редактирование недоступно: клиент уже начал проходить план' : 'Не удалось сохранить план');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int _optionPrice(dynamic price) {
|
||||||
|
if (price is num) {
|
||||||
|
return price.round();
|
||||||
|
}
|
||||||
|
return int.parse(price.toString());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ class AddItemWidget extends StatefulWidget {
|
|||||||
class _AddItemWidgetState extends State<AddItemWidget> {
|
class _AddItemWidgetState extends State<AddItemWidget> {
|
||||||
final controller = TextEditingController();
|
final controller = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(oldWidget) {
|
void didUpdateWidget(oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
@@ -38,14 +44,24 @@ class _AddItemWidgetState extends State<AddItemWidget> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final s = S.of(context);
|
final s = S.of(context);
|
||||||
if (widget.items.length > widget.index && controller.text != widget.items[widget.index]) {
|
if (widget.items.length > widget.index &&
|
||||||
|
controller.text != widget.items[widget.index]) {
|
||||||
controller.text = widget.items[widget.index];
|
controller.text = widget.items[widget.index];
|
||||||
}
|
}
|
||||||
return BlocBuilder<CreatePlanCubit, CreatePlanState>(
|
return BlocBuilder<CreatePlanCubit, CreatePlanState>(
|
||||||
buildWhen: (previous, current) => previous.validationState != current.validationState,
|
buildWhen: (previous, current) =>
|
||||||
|
previous.validationState != current.validationState,
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final hasError = state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].isItemsEmpty ||
|
final hasError =
|
||||||
(state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].itemsErrors[widget.index] ?? false);
|
state
|
||||||
|
.validationState
|
||||||
|
.optionsValidationState[state.selectedOptionIndex ?? 0]
|
||||||
|
.isItemsEmpty ||
|
||||||
|
(state
|
||||||
|
.validationState
|
||||||
|
.optionsValidationState[state.selectedOptionIndex ?? 0]
|
||||||
|
.itemsErrors[widget.index] ??
|
||||||
|
false);
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
@@ -54,9 +70,14 @@ class _AddItemWidgetState extends State<AddItemWidget> {
|
|||||||
child: CustomTextFormField(
|
child: CustomTextFormField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
hasError: hasError,
|
hasError: hasError,
|
||||||
hintText: widget.enableHint ? s.option_content_hint_text : null,
|
hintText: widget.enableHint
|
||||||
|
? s.option_content_hint_text
|
||||||
|
: null,
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
context.read<CreatePlanCubit>().setOptionContent(value, widget.index);
|
context.read<CreatePlanCubit>().setOptionContent(
|
||||||
|
value,
|
||||||
|
widget.index,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -64,7 +85,9 @@ class _AddItemWidgetState extends State<AddItemWidget> {
|
|||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
context.read<CreatePlanCubit>().removeOptionContentItem(widget.index);
|
context.read<CreatePlanCubit>().removeOptionContentItem(
|
||||||
|
widget.index,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(14),
|
padding: const EdgeInsets.all(14),
|
||||||
@@ -72,19 +95,30 @@ class _AddItemWidgetState extends State<AddItemWidget> {
|
|||||||
color: appTheme.lightGray0,
|
color: appTheme.lightGray0,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
child: SvgPicture.asset('assets/icons/trash_can.svg', width: 20, height: 24),
|
child: SvgPicture.asset(
|
||||||
|
'assets/icons/trash_can.svg',
|
||||||
|
width: 20,
|
||||||
|
height: 24,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].isItemsEmpty) ...[
|
if (state
|
||||||
|
.validationState
|
||||||
|
.optionsValidationState[state.selectedOptionIndex ?? 0]
|
||||||
|
.isItemsEmpty) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
ValidationErrorWidget(error: s.addAtLeastOneItem)
|
ValidationErrorWidget(error: s.addAtLeastOneItem),
|
||||||
] else if (state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].itemsErrors[widget.index] ?? false) ...[
|
] else if (state
|
||||||
|
.validationState
|
||||||
|
.optionsValidationState[state.selectedOptionIndex ?? 0]
|
||||||
|
.itemsErrors[widget.index] ??
|
||||||
|
false) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
ValidationErrorWidget(error: s.atLeast3Symbols)
|
ValidationErrorWidget(error: s.atLeast3Symbols),
|
||||||
]
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widge
|
|||||||
import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_cubit.dart';
|
import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_cubit.dart';
|
||||||
import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_state.dart';
|
import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_state.dart';
|
||||||
import 'package:eatvio_app/features/create_plan/presentation/widgets/add_item_widget.dart';
|
import 'package:eatvio_app/features/create_plan/presentation/widgets/add_item_widget.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||||
import 'package:eatvio_app/generated/l10n.dart';
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
@@ -21,47 +22,107 @@ class OptionWidget extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _OptionWidgetState extends State<OptionWidget> {
|
class _OptionWidgetState extends State<OptionWidget> {
|
||||||
final firstOptionCustomerPriceController = TextEditingController()..text = '1200₽';
|
static const _optionsCount = 3;
|
||||||
final secondOptionCustomerPriceController = TextEditingController()..text = '1200₽';
|
|
||||||
final thirdOptionCustomerPriceController = TextEditingController()..text = '1200₽';
|
|
||||||
|
|
||||||
final creatorPriceController = TextEditingController()..text = '1000₽';
|
final List<TextEditingController> _customerPriceControllers = List.generate(
|
||||||
|
_optionsCount,
|
||||||
final firstOptionTitleController = TextEditingController();
|
(_) => TextEditingController(text: '1200₽'),
|
||||||
final secondOptionTitleController = TextEditingController();
|
);
|
||||||
final thirdOptionTitleController = TextEditingController();
|
final List<TextEditingController> _titleControllers = List.generate(
|
||||||
|
_optionsCount,
|
||||||
final firstOptionDescriptionController = TextEditingController();
|
(_) => TextEditingController(),
|
||||||
final secondOptionDescriptionController = TextEditingController();
|
);
|
||||||
final thirdOptionDescriptionController = TextEditingController();
|
final List<TextEditingController> _descriptionControllers = List.generate(
|
||||||
|
_optionsCount,
|
||||||
|
(_) => TextEditingController(),
|
||||||
|
);
|
||||||
|
final creatorPriceController = TextEditingController(text: '1000₽');
|
||||||
|
|
||||||
|
int? _syncedOptionIndex;
|
||||||
bool firstExplanationVisible = false;
|
bool firstExplanationVisible = false;
|
||||||
bool isChecked = false;
|
bool isChecked = false;
|
||||||
bool secondExplanationVisible = false;
|
bool secondExplanationVisible = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
firstOptionCustomerPriceController.addListener(() {
|
for (final controller in _customerPriceControllers) {
|
||||||
if (!firstOptionCustomerPriceController.text.endsWith('₽')) {
|
controller.addListener(() => _ensureRubleSuffix(controller));
|
||||||
final text = firstOptionCustomerPriceController.text.replaceAll('₽', '');
|
}
|
||||||
firstOptionCustomerPriceController.text = '$text₽';
|
}
|
||||||
firstOptionCustomerPriceController.selection = TextSelection.collapsed(offset: text.length);
|
|
||||||
}
|
@override
|
||||||
});
|
void dispose() {
|
||||||
secondOptionCustomerPriceController.addListener(() {
|
for (final controller in _customerPriceControllers) {
|
||||||
if (!secondOptionCustomerPriceController.text.endsWith('₽')) {
|
controller.dispose();
|
||||||
final text = secondOptionCustomerPriceController.text.replaceAll('₽', '');
|
}
|
||||||
secondOptionCustomerPriceController.text = '$text₽';
|
for (final controller in _titleControllers) {
|
||||||
secondOptionCustomerPriceController.selection = TextSelection.collapsed(offset: text.length);
|
controller.dispose();
|
||||||
}
|
}
|
||||||
});
|
for (final controller in _descriptionControllers) {
|
||||||
thirdOptionCustomerPriceController.addListener(() {
|
controller.dispose();
|
||||||
if (!thirdOptionCustomerPriceController.text.endsWith('₽')) {
|
}
|
||||||
final text = thirdOptionCustomerPriceController.text.replaceAll('₽', '');
|
creatorPriceController.dispose();
|
||||||
thirdOptionCustomerPriceController.text = '$text₽';
|
super.dispose();
|
||||||
thirdOptionCustomerPriceController.selection = TextSelection.collapsed(offset: text.length);
|
}
|
||||||
}
|
|
||||||
});
|
void _ensureRubleSuffix(TextEditingController controller) {
|
||||||
|
if (!controller.text.endsWith('₽')) {
|
||||||
|
final text = controller.text.replaceAll('₽', '');
|
||||||
|
controller.text = '$text₽';
|
||||||
|
controller.selection = TextSelection.collapsed(offset: text.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _syncController(TextEditingController controller, String value) {
|
||||||
|
if (controller.text == value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
controller.value = TextEditingValue(
|
||||||
|
text: value,
|
||||||
|
selection: TextSelection.collapsed(offset: value.length),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatCustomerPrice(dynamic price) {
|
||||||
|
if (price is num) {
|
||||||
|
return '${price.toStringAsFixed(0)}₽';
|
||||||
|
}
|
||||||
|
final parsed = double.tryParse(price.toString().replaceAll('₽', ''));
|
||||||
|
return '${(parsed ?? 0).toStringAsFixed(0)}₽';
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applyOptionToControllers(int index, PacketOptions option) {
|
||||||
|
_syncController(_titleControllers[index], option.short);
|
||||||
|
_syncController(_descriptionControllers[index], option.description ?? '');
|
||||||
|
_syncController(
|
||||||
|
_customerPriceControllers[index],
|
||||||
|
_formatCustomerPrice(option.price),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _syncSelectedOption(CreatePlanState state) {
|
||||||
|
final selectedOptionIndex = state.selectedOptionIndex ?? 0;
|
||||||
|
final option = state.planData.options[selectedOptionIndex];
|
||||||
|
final price = double.tryParse(
|
||||||
|
_customerPriceControllers[selectedOptionIndex].text.replaceAll('₽', ''),
|
||||||
|
);
|
||||||
|
final creatorPrice = '${((price ?? 0) * 0.8).toStringAsFixed(2)}₽';
|
||||||
|
|
||||||
|
if (_syncedOptionIndex != selectedOptionIndex) {
|
||||||
|
_syncedOptionIndex = selectedOptionIndex;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_applyOptionToControllers(selectedOptionIndex, option);
|
||||||
|
creatorPriceController.text = creatorPrice;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (creatorPriceController.text != creatorPrice) {
|
||||||
|
creatorPriceController.text = creatorPrice;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -70,31 +131,21 @@ class _OptionWidgetState extends State<OptionWidget> {
|
|||||||
return BlocBuilder<CreatePlanCubit, CreatePlanState>(
|
return BlocBuilder<CreatePlanCubit, CreatePlanState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final selectedOptionIndex = state.selectedOptionIndex ?? 0;
|
final selectedOptionIndex = state.selectedOptionIndex ?? 0;
|
||||||
|
_syncSelectedOption(state);
|
||||||
final optionNameHintText = switch (selectedOptionIndex) {
|
final optionNameHintText = switch (selectedOptionIndex) {
|
||||||
0 => s.economy,
|
0 => s.economy,
|
||||||
2 => s.comfort,
|
2 => s.comfort,
|
||||||
_ => s.standard,
|
_ => s.standard,
|
||||||
};
|
};
|
||||||
final optionTitleController = switch (selectedOptionIndex) {
|
final optionTitleController = _titleControllers[selectedOptionIndex];
|
||||||
0 => firstOptionTitleController,
|
final optionDescriptionController =
|
||||||
2 => thirdOptionTitleController,
|
_descriptionControllers[selectedOptionIndex];
|
||||||
_ => secondOptionTitleController,
|
final customerPriceController =
|
||||||
};
|
_customerPriceControllers[selectedOptionIndex];
|
||||||
final optionDescriptionController = switch (selectedOptionIndex) {
|
final optionItems = state.planData.options[selectedOptionIndex].items;
|
||||||
0 => firstOptionDescriptionController,
|
final optionValidation =
|
||||||
2 => thirdOptionDescriptionController,
|
state.validationState.optionsValidationState[selectedOptionIndex];
|
||||||
_ => secondOptionDescriptionController,
|
|
||||||
};
|
|
||||||
final customerPriceController = switch (selectedOptionIndex) {
|
|
||||||
0 => firstOptionCustomerPriceController,
|
|
||||||
2 => thirdOptionCustomerPriceController,
|
|
||||||
_ => secondOptionCustomerPriceController,
|
|
||||||
};
|
|
||||||
|
|
||||||
final price = double.tryParse(customerPriceController.text.replaceAll('₽', ''));
|
|
||||||
creatorPriceController.text = '${((price ?? 0) * 0.8).toStringAsFixed(2)}₽';
|
|
||||||
firstOptionTitleController.text = state.planData.options[selectedOptionIndex].short;
|
|
||||||
firstOptionDescriptionController.text = state.planData.options[selectedOptionIndex].description ?? '';
|
|
||||||
return MainBackgroundBouble(
|
return MainBackgroundBouble(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -110,14 +161,14 @@ class _OptionWidgetState extends State<OptionWidget> {
|
|||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
CustomTextFormField(
|
CustomTextFormField(
|
||||||
hintText: optionNameHintText,
|
hintText: optionNameHintText,
|
||||||
hasError: state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].titleHasError,
|
hasError: optionValidation.titleHasError,
|
||||||
controller: optionTitleController,
|
controller: optionTitleController,
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
context.read<CreatePlanCubit>().setOptionTitle(value);
|
context.read<CreatePlanCubit>().setOptionTitle(value);
|
||||||
},
|
},
|
||||||
maxLength: 150,
|
maxLength: 150,
|
||||||
),
|
),
|
||||||
if (state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].titleHasError) ...[
|
if (optionValidation.titleHasError) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
ValidationErrorWidget(error: s.atLeast3Symbols),
|
ValidationErrorWidget(error: s.atLeast3Symbols),
|
||||||
],
|
],
|
||||||
@@ -135,7 +186,7 @@ class _OptionWidgetState extends State<OptionWidget> {
|
|||||||
child: CustomTextFormField(
|
child: CustomTextFormField(
|
||||||
controller: optionDescriptionController,
|
controller: optionDescriptionController,
|
||||||
textAlignVertical: TextAlignVertical.top,
|
textAlignVertical: TextAlignVertical.top,
|
||||||
hasError: state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].descriptionHasError,
|
hasError: optionValidation.descriptionHasError,
|
||||||
maxLength: 1000,
|
maxLength: 1000,
|
||||||
minLines: null,
|
minLines: null,
|
||||||
maxLines: null,
|
maxLines: null,
|
||||||
@@ -146,7 +197,7 @@ class _OptionWidgetState extends State<OptionWidget> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].descriptionHasError) ...[
|
if (optionValidation.descriptionHasError) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
ValidationErrorWidget(error: s.atLeast3Symbols),
|
ValidationErrorWidget(error: s.atLeast3Symbols),
|
||||||
],
|
],
|
||||||
@@ -158,7 +209,9 @@ class _OptionWidgetState extends State<OptionWidget> {
|
|||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
softWrap: true,
|
softWrap: true,
|
||||||
style: TextStyles.textSMedium.copyWith(color: appTheme.black),
|
style: TextStyles.textSMedium.copyWith(
|
||||||
|
color: appTheme.black,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
const _PriceExplanationWidget(),
|
const _PriceExplanationWidget(),
|
||||||
@@ -179,7 +232,9 @@ class _OptionWidgetState extends State<OptionWidget> {
|
|||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
softWrap: true,
|
softWrap: true,
|
||||||
style: TextStyles.textSMedium.copyWith(color: appTheme.black),
|
style: TextStyles.textSMedium.copyWith(
|
||||||
|
color: appTheme.black,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
const _PriceExplanationWidget(),
|
const _PriceExplanationWidget(),
|
||||||
@@ -188,8 +243,10 @@ class _OptionWidgetState extends State<OptionWidget> {
|
|||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
CustomTextFormField(
|
CustomTextFormField(
|
||||||
controller: customerPriceController,
|
controller: customerPriceController,
|
||||||
textInputType: const TextInputType.numberWithOptions(decimal: true),
|
textInputType: const TextInputType.numberWithOptions(
|
||||||
hasError: state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].priceHasError,
|
decimal: true,
|
||||||
|
),
|
||||||
|
hasError: optionValidation.priceHasError,
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
final price = double.tryParse(value.replaceAll('₽', ''));
|
final price = double.tryParse(value.replaceAll('₽', ''));
|
||||||
if (price != null) {
|
if (price != null) {
|
||||||
@@ -222,34 +279,41 @@ class _OptionWidgetState extends State<OptionWidget> {
|
|||||||
separatorBuilder: (context, index) => const SizedBox(height: 8),
|
separatorBuilder: (context, index) => const SizedBox(height: 8),
|
||||||
reverse: false,
|
reverse: false,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final items = state.planData.options[selectedOptionIndex].items;
|
if (optionItems.isEmpty) {
|
||||||
|
|
||||||
if (items.isEmpty) {
|
|
||||||
return AddItemWidget(
|
return AddItemWidget(
|
||||||
|
key: ValueKey(
|
||||||
|
'plan-option-$selectedOptionIndex-item-$index',
|
||||||
|
),
|
||||||
index: index,
|
index: index,
|
||||||
canBeDeleted: false,
|
canBeDeleted: false,
|
||||||
enableHint: true,
|
enableHint: true,
|
||||||
items: items,
|
items: optionItems,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
if (index < items.length) {
|
if (index < optionItems.length) {
|
||||||
return AddItemWidget(
|
return AddItemWidget(
|
||||||
|
key: ValueKey(
|
||||||
|
'plan-option-$selectedOptionIndex-item-$index',
|
||||||
|
),
|
||||||
canBeDeleted: true,
|
canBeDeleted: true,
|
||||||
enableHint: false,
|
enableHint: false,
|
||||||
index: index,
|
index: index,
|
||||||
items: items,
|
items: optionItems,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return AddItemWidget(
|
return AddItemWidget(
|
||||||
|
key: ValueKey(
|
||||||
|
'plan-option-$selectedOptionIndex-item-$index',
|
||||||
|
),
|
||||||
canBeDeleted: false,
|
canBeDeleted: false,
|
||||||
enableHint: false,
|
enableHint: false,
|
||||||
index: index,
|
index: index,
|
||||||
items: items,
|
items: optionItems,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
itemCount: state.planData.options[selectedOptionIndex].items.length + 1,
|
itemCount: optionItems.length + 1,
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
),
|
),
|
||||||
@@ -259,23 +323,30 @@ class _OptionWidgetState extends State<OptionWidget> {
|
|||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Checkbox(
|
Checkbox(
|
||||||
value: state.planData.options[selectedOptionIndex].hasPersonalDiet,
|
value: state
|
||||||
|
.planData
|
||||||
|
.options[selectedOptionIndex]
|
||||||
|
.hasPersonalDiet,
|
||||||
activeColor: appTheme.gray,
|
activeColor: appTheme.gray,
|
||||||
checkColor: appTheme.gray,
|
checkColor: appTheme.gray,
|
||||||
fillColor: const WidgetStatePropertyAll(Colors.transparent),
|
fillColor: const WidgetStatePropertyAll(Colors.transparent),
|
||||||
side: WidgetStateBorderSide.resolveWith(
|
side: WidgetStateBorderSide.resolveWith((
|
||||||
(Set<WidgetState> states) {
|
Set<WidgetState> states,
|
||||||
return BorderSide(width: 1, color: appTheme.gray);
|
) {
|
||||||
},
|
return BorderSide(width: 1, color: appTheme.gray);
|
||||||
),
|
}),
|
||||||
onChanged: (bool? value) {
|
onChanged: (bool? value) {
|
||||||
context.read<CreatePlanCubit>().togglePersonalizedDietOptionState();
|
context
|
||||||
|
.read<CreatePlanCubit>()
|
||||||
|
.togglePersonalizedDietOptionState();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
s.option_personal_diet_desc,
|
s.option_personal_diet_desc,
|
||||||
style: TextStyles.textMMedium.copyWith(color: appTheme.black),
|
style: TextStyles.textMMedium.copyWith(
|
||||||
|
color: appTheme.black,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -292,7 +363,8 @@ class _PriceExplanationWidget extends StatefulWidget {
|
|||||||
const _PriceExplanationWidget();
|
const _PriceExplanationWidget();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_PriceExplanationWidget> createState() => _PriceExplanationWidgetState();
|
State<_PriceExplanationWidget> createState() =>
|
||||||
|
_PriceExplanationWidgetState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PriceExplanationWidgetState extends State<_PriceExplanationWidget> {
|
class _PriceExplanationWidgetState extends State<_PriceExplanationWidget> {
|
||||||
@@ -314,9 +386,7 @@ class _PriceExplanationWidgetState extends State<_PriceExplanationWidget> {
|
|||||||
color: visible ? appTheme.whiteA700 : Colors.transparent,
|
color: visible ? appTheme.whiteA700 : Colors.transparent,
|
||||||
),
|
),
|
||||||
duration: const Duration(milliseconds: 300),
|
duration: const Duration(milliseconds: 300),
|
||||||
child: Text(
|
child: Text(s.price_explanation),
|
||||||
s.price_explanation,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
visible: visible,
|
visible: visible,
|
||||||
@@ -331,7 +401,11 @@ class _PriceExplanationWidgetState extends State<_PriceExplanationWidget> {
|
|||||||
visible = !visible;
|
visible = !visible;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
child: SvgPicture.asset('assets/icons/question_mark.svg', height: 12, width: 12),
|
child: SvgPicture.asset(
|
||||||
|
'assets/icons/question_mark.svg',
|
||||||
|
height: 12,
|
||||||
|
width: 12,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:dotted_border/dotted_border.dart';
|
import 'package:dotted_border/dotted_border.dart';
|
||||||
|
import 'package:eatvio_app/core/app_config.dart';
|
||||||
import 'package:eatvio_app/core/app_router.dart';
|
import 'package:eatvio_app/core/app_router.dart';
|
||||||
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
import 'package:eatvio_app/core/permission_handler/permission_handler.dart';
|
import 'package:eatvio_app/core/permission_handler/permission_handler.dart';
|
||||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||||
@@ -91,6 +93,18 @@ class _PlanImagePickerWidgetState extends State<PlanImagePickerWidget> {
|
|||||||
final s = S.of(context);
|
final s = S.of(context);
|
||||||
return BlocBuilder<CreatePlanCubit, CreatePlanState>(
|
return BlocBuilder<CreatePlanCubit, CreatePlanState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
|
final selectedImage = state.planData.image;
|
||||||
|
final existingImage = context.read<CreatePlanCubit>().sourcePlan?.image;
|
||||||
|
final hasCover = selectedImage != null || (existingImage != null && existingImage.isNotEmpty);
|
||||||
|
final ImageProvider? coverImage;
|
||||||
|
if (selectedImage != null) {
|
||||||
|
coverImage = FileImage(File(selectedImage.path));
|
||||||
|
} else if (existingImage != null && existingImage.isNotEmpty) {
|
||||||
|
final url = existingImage.startsWith('http') ? existingImage : '${sl<AppConfig>().baseUrl}/storage/$existingImage';
|
||||||
|
coverImage = NetworkImage(url);
|
||||||
|
} else {
|
||||||
|
coverImage = null;
|
||||||
|
}
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
@@ -101,12 +115,12 @@ class _PlanImagePickerWidgetState extends State<PlanImagePickerWidget> {
|
|||||||
height: 240,
|
height: 240,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
image: state.planData.image != null ? DecorationImage(fit: BoxFit.cover, image: FileImage(File(state.planData.image?.path ?? ''))) : null,
|
image: coverImage != null ? DecorationImage(fit: BoxFit.cover, image: coverImage) : null,
|
||||||
color: appTheme.gray50,
|
color: appTheme.gray50,
|
||||||
),
|
),
|
||||||
width: MediaQuery.sizeOf(context).width,
|
width: MediaQuery.sizeOf(context).width,
|
||||||
padding: state.planData.image != null ? null : const EdgeInsets.all(8),
|
padding: hasCover ? null : const EdgeInsets.all(8),
|
||||||
child: state.planData.image != null
|
child: selectedImage != null
|
||||||
? Padding(
|
? Padding(
|
||||||
padding: const EdgeInsets.only(top: 6, right: 6),
|
padding: const EdgeInsets.only(top: 6, right: 6),
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
@@ -131,6 +145,8 @@ class _PlanImagePickerWidgetState extends State<PlanImagePickerWidget> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
: hasCover
|
||||||
|
? const SizedBox.shrink()
|
||||||
: Stack(
|
: Stack(
|
||||||
children: [
|
children: [
|
||||||
DottedBorder(
|
DottedBorder(
|
||||||
|
|||||||
@@ -11,7 +11,13 @@ class CreatePlanRepository {
|
|||||||
}) async {
|
}) async {
|
||||||
final formData = FormData.fromMap(model);
|
final formData = FormData.fromMap(model);
|
||||||
return await createPlanService.createPlan(formData: formData);
|
return await createPlanService.createPlan(formData: formData);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> updatePublishedPlan({
|
||||||
|
required String slug,
|
||||||
|
required Map<String, dynamic> model,
|
||||||
|
}) async {
|
||||||
|
final formData = FormData.fromMap(model);
|
||||||
|
return await createPlanService.updatePublishedPlan(slug: slug, formData: formData);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,5 +12,11 @@ abstract class CreatePlanService {
|
|||||||
Future<void> createPlan({
|
Future<void> createPlan({
|
||||||
@Body() required FormData formData,
|
@Body() required FormData formData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@POST('/api/plans/update/published/{slug}')
|
||||||
|
@MultiPart()
|
||||||
|
Future<void> updatePublishedPlan({
|
||||||
|
@Path() required String slug,
|
||||||
|
@Body() required FormData formData,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user