Compare commits

..

102 Commits

Author SHA1 Message Date
misha
6d2935400a feat(video_calls): ios voip push, mix and crushes fix 2026-10-16 13:49:48 +03:00
misha
ff17b813ac feat(ios): env migration for ios 27 2026-10-16 08:26:26 +03:00
misha
9288a5c849 version changed 2026-10-15 15:22:04 +03:00
misha
4ef174ebde feat(video_calls): repeated calls after call termination fix 2026-10-15 15:21:38 +03:00
misha
1901248539 feat(video_calls): ios mic fix, android call fix. feat(dev_tools): base url caching 2026-10-15 14:06:37 +03:00
b2e700a4eb Merge pull request 'fix: ci/cd' (#1074) from misha/eatvio_app:issue-1062 into main
Reviewed-on: eatvio/eatvio_app#1074
2026-10-14 10:17:37 +00:00
misha
1bbb08f2a2 fix: ci/cd 2026-10-14 13:16:57 +03:00
61f1b76f99 Merge pull request 'issue-1062' (#1068) from misha/eatvio_app:issue-1062 into main
Reviewed-on: eatvio/eatvio_app#1068
Reviewed-by: Анатолий Полоз <t0ly.od@ya.ru>
2026-10-14 09:53:01 +00:00
misha
ef4df92f58 fix: analyzer fixes 2026-10-14 10:53:38 +03:00
misha
dcd343e765 feat(push_notifications): issue-660, fix: issue-908 2026-10-14 08:16:20 +03:00
misha
98c175df44 fix: test fixed 2026-10-12 11:19:18 +03:00
misha
cb312037b2 feat(UGPC): issue-777 2026-10-12 08:50:33 +03:00
572a6f1943 Merge pull request 'update(doc): bug report issue template, desc field added' (#1073) from apoloz/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1073
2026-10-09 09:47:54 +00:00
Anatoliy Poloz
a6ae9abb57 update(doc): bug report issue template, desc field added 2026-10-09 12:47:22 +03:00
431354170e Merge pull request 'description field added to feature issue template' (#1072) from apoloz/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1072
2026-10-09 09:43:28 +00:00
Anatoliy Poloz
c38e82f558 description field added to feature issue template 2026-10-09 12:43:00 +03:00
afd6f9a93a Merge pull request 'update bug issue template' (#1071) from apoloz/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1071
2026-10-09 09:34:46 +00:00
Anatoliy Poloz
641eb4d2e2 update bug issue template 2026-10-09 12:34:08 +03:00
74c66c9600 Merge pull request 'feat(doc): issue templates' (#1070) from apoloz/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1070
2026-10-09 09:12:18 +00:00
Anatoliy Poloz
73e89ff7a9 feat(doc): issue templates 2026-10-09 12:11:30 +03:00
misha
f8e06b6d7a version changed 2026-10-08 07:20:00 +03:00
misha
866ea2fe3b Merge remote-tracking branch 'upstream/main' into issue-1062 2026-10-08 07:18:53 +03:00
d73f68e24b Merge pull request 'video_calls_fixes' (#1066) from misha/eatvio_app:video_calls_fixes into main
Reviewed-on: eatvio/eatvio_app#1066
2026-10-05 11:56:40 +00:00
misha
61cded0081 version cnhanged 2026-10-05 13:13:25 +03:00
misha
df634fedd8 feat(video_calls): incommig call handling fix for ios and anroid 2026-10-05 13:12:55 +03:00
misha
603a158cf2 feat(diary): editing of previous and current finished days 2026-10-01 10:44:52 +03:00
a1bae68307 Merge pull request 'feat(video_calls, welcome_screen): ios mic fix, welcome scrin layoute fix' (#1064) from misha/eatvio_app:video_calls_fixes into main
Reviewed-on: eatvio/eatvio_app#1064
2026-09-28 09:01:24 +00:00
misha
2dce28b196 feat(video_calls, welcome_screen): ios mic fix, welcome scrin layoute fix 2026-09-28 11:20:52 +03:00
ae4f9d2e9b Merge pull request 'fix(chat): ws manager' (#1060) from apoloz/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1060
Reviewed-by: misha <misha@noreply.localhost>
2026-09-25 07:31:28 +00:00
Anatoliy
a00aae36cf update version 2026-09-16 13:50:12 +03:00
Anatoliy
27e98bdb44 fix(chat): ws manager 2026-09-16 00:10:23 +03:00
Anatoliy
8ec6d5b799 refactor(chat): dedup 2026-09-15 23:20:00 +03:00
Anatoliy
077cfb3a67 refactor(chat): message dedup 2026-09-15 23:06:12 +03:00
c4f5cae36d Merge pull request 'fix: issue-942, issue-967, fixed images positioning for blog posts' (#1055) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1055
Reviewed-by: Анатолий Полоз <t0ly.od@ya.ru>
2026-09-15 19:31:06 +00:00
misha
b040c6e020 fix: tests fix 2026-09-02 15:42:17 +03:00
misha
745e77f948 fix: analyer 2026-09-02 09:34:25 +03:00
misha
c6ea1d414d fix: issue-372, issue-227, issue-406 2026-09-02 09:32:14 +03:00
misha
10af279d87 fix: issue-942, issue-967, fixed images positioning for blog posts 2026-08-28 10:05:40 +03:00
d0879e556f Merge pull request 'version changed' (#1054) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1054
2026-08-27 07:21:39 +00:00
misha
8f933118a3 version changed 2026-08-27 08:58:09 +03:00
7dc7eab774 Merge pull request 'issue-1049, issue-1039, issue-1035, issue-1032, issue-995, issue-993' (#1053) from misha/eatvio_app:1-3-42_bug_fixes into main
Reviewed-on: eatvio/eatvio_app#1053
2026-08-26 08:07:39 +00:00
misha
16a13488d1 version changed 2026-08-26 10:27:48 +03:00
misha
de7bf8dc19 fix: issue-1049, issue-1039, issue-1035, issue-1032, issue-995, issue-993 2026-08-26 08:42:47 +03:00
eeb261df0a Merge pull request 'feat(image_validation)' (#1052) from misha/eatvio_app:issue-1037 into main
Reviewed-on: eatvio/eatvio_app#1052
2026-08-24 12:04:37 +00:00
misha
1b03a0d5e1 version changed 2026-08-24 11:55:39 +03:00
misha
0208ed753a feat(image_validation): image validation fix 2026-08-24 11:38:01 +03:00
184f0417a7 Merge pull request 'version changed' (#1051) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1051
2026-08-21 11:22:11 +00:00
misha
6265e865e9 version changed 2026-08-21 14:20:54 +03:00
1550b7ea0b Merge pull request 'fix: fixed domain matching for deeplinks' (#1050) from misha/eatvio_app:marketing_domains into main
Reviewed-on: eatvio/eatvio_app#1050
Reviewed-by: Анатолий Полоз <t0ly.od@ya.ru>
2026-08-21 10:55:11 +00:00
misha
db10895ebb fix: removed path matching to handle links like https://{domain} 2026-08-21 10:25:09 +03:00
misha
a7361d3ab1 fix: fixed domain matching for deeplinks 2026-08-20 13:45:16 +03:00
1c54063a35 Merge pull request 'feat(chat,consultant_registration, portion_input)' (#1045) from chat_messages into main
Reviewed-on: eatvio/eatvio_app#1045
Reviewed-by: Анатолий Полоз <t0ly.od@ya.ru>
2026-08-14 11:51:01 +00:00
efae7fc958 Merge pull request 'portion_input' (#1044) from misha/eatvio_app:chat_messages into chat_messages
Reviewed-on: eatvio/eatvio_app#1044
2026-08-14 10:48:00 +00:00
misha
90ac4e750d feat(portion_input): fixed portion input sizes 2026-08-14 13:46:00 +03:00
misha
6d7419c283 version changed 2026-08-14 12:59:04 +03:00
caf45a9050 Merge pull request 'Scoring validation' (#1043) from misha/eatvio_app:chat_messages into chat_messages
Reviewed-on: eatvio/eatvio_app#1043
2026-08-14 09:20:03 +00:00
misha
0a5e7d95c4 Merge branch 'chat_messages' of ssh://10.55.5.123:222/misha/eatvio_app into chat_messages 2026-08-14 12:16:46 +03:00
misha
85482173ad feat(consultant_scoring): added validation for last first and middle name for scoring 2026-08-14 12:16:03 +03:00
65f7992d40 Merge pull request 'feat(chat_messages): fixed data race for chat mesages state' (#1042) from misha/eatvio_app:chat_messages into chat_messages
Reviewed-on: eatvio/eatvio_app#1042
2026-08-14 08:07:56 +00:00
misha
52a01bee12 feat(chat_messages): fixed data race for chat mesages state 2026-08-14 11:05:03 +03:00
179fc0ad99 Merge pull request 'pasport_data_validation' (#1041) from misha/eatvio_app:pasport_data_validation into main
Reviewed-on: eatvio/eatvio_app#1041
2026-08-13 12:18:35 +00:00
misha
8494e61eec version changed 2026-08-13 12:29:18 +03:00
misha
5c2458ecad feat(consultant_refistration): added explicit validation for scoring data 2026-08-13 12:28:34 +03:00
b63bab8bf5 Merge pull request 'feat(post_creation_with_images)' (#1026) from post_creation_with_images into main
Reviewed-on: eatvio/eatvio_app#1026
2026-08-05 14:07:55 +00:00
7790014726 Merge pull request 'Version changed' (#1029) from misha/eatvio_app:post_creation_with_images into post_creation_with_images
Reviewed-on: eatvio/eatvio_app#1029
2026-08-05 13:33:20 +00:00
misha
0d588bcd57 Version changed 2026-08-05 16:32:14 +03:00
b679e90ba2 Merge pull request 'fix: analyzer fix' (#1027) from misha/eatvio_app:post_creation_with_images into post_creation_with_images
Reviewed-on: eatvio/eatvio_app#1027
2026-08-05 12:34:27 +00:00
misha
6b5442eb87 fix: analyzer fix 2026-08-05 15:33:27 +03:00
2474c5bea3 Merge pull request 'post_creation_with_images' (#1025) from misha/eatvio_app:post_creation_with_images into post_creation_with_images
Reviewed-on: eatvio/eatvio_app#1025
2026-08-05 12:31:23 +00:00
misha
478a53674a feat(post_creatin): fixed animation widget for profile feed 2026-08-05 15:21:23 +03:00
7f278d0f59 Merge pull request 'fix: fixed script run time location' (#1024) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1024
2026-08-04 15:36:07 +00:00
misha
53426c858c fix: fixed script run time location 2026-08-04 18:35:32 +03:00
88f0cde88f Merge pull request 'fix: added pods updating' (#1023) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1023
2026-08-04 15:19:44 +00:00
misha
a5fcf62524 fix: added pods updating 2026-08-04 18:19:10 +03:00
a2e41a2ff5 Merge pull request 'fix: removed SPM' (#1022) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1022
2026-08-04 15:13:32 +00:00
misha
5b1beafbab fix: removed SPM 2026-08-04 18:12:55 +03:00
4eadecd6f2 Merge pull request 'fix: removed spm flag' (#1021) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1021
2026-08-04 14:52:33 +00:00
misha
6292db07a1 fix: removed spm flag 2026-08-04 17:51:58 +03:00
ab29b6b329 Merge pull request 'fix: ci/cd fix' (#1020) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1020
2026-08-04 14:48:54 +00:00
misha
af5d182a79 fix: ci/cd fix 2026-08-04 17:48:08 +03:00
misha
04de956769 feat(post_creation): added multiple image posts creation and validation 2026-08-04 17:47:43 +03:00
191bb74aa4 Merge pull request 'Version changed' (#1018) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1018
2026-08-04 14:35:33 +00:00
misha
3210f38d4a Version changed 2026-08-04 14:22:33 +03:00
27a98a496d Merge pull request 'promo_codes' (#1017) from promo_codes into main
Reviewed-on: eatvio/eatvio_app#1017
2026-08-04 11:14:47 +00:00
d8ecc7f3b1 Merge pull request 'feat(promo_codes)' (#1015) from misha/eatvio_app:promo_codes into promo_codes
Reviewed-on: eatvio/eatvio_app#1015
2026-08-04 11:13:45 +00:00
063da03d7e merge upstream 2026-08-04 10:17:32 +00:00
6b5927a99e Merge pull request 'sync with main' (#1016) from main into promo_codes
Reviewed-on: eatvio/eatvio_app#1016
2026-08-04 10:17:26 +00:00
afab72f7bc merge upstream 2026-08-04 10:16:20 +00:00
2770ff0e80 Merge pull request 'feat(consultant_scoring)' (#1012) from consultant_scoring into main
Reviewed-on: eatvio/eatvio_app#1012
2026-08-04 10:16:09 +00:00
1200021bb6 Merge pull request 'personalized_feed' (#1004) from personalized_feed into main
Reviewed-on: eatvio/eatvio_app#1004
2026-08-04 10:15:25 +00:00
misha
9e8b8b89fc feat(promo_codes): added promo codes handling to app 2026-08-03 18:12:05 +03:00
a24d052193 Merge pull request 'fix: test fixed' (#1014) from misha/eatvio_app:personalized_feed into personalized_feed
Reviewed-on: eatvio/eatvio_app#1014
2026-08-03 12:32:07 +00:00
37504aa292 Merge pull request 'fix: removed unused import' (#1013) from misha/eatvio_app:personalized_feed into personalized_feed
Reviewed-on: eatvio/eatvio_app#1013
2026-08-03 08:46:16 +00:00
c8588a2e1f Merge pull request 'consultant_scoring' (#1011) from misha/eatvio_app:consultant_scoring into consultant_scoring
Reviewed-on: eatvio/eatvio_app#1011
2026-07-31 10:45:21 +00:00
misha
e0cab89126 Merge branch 'consultant_scoring' of ssh://10.55.5.123:222/misha/eatvio_app into consultant_scoring 2026-07-30 17:42:30 +03:00
misha
954d4955da feat(consultant_scoring): added validation errors, fixed scoring flow, updating flow 2026-07-30 17:41:30 +03:00
e5086d7fbc Merge pull request 'feat(consultant_scoring)' (#1005) from misha/eatvio_app:consultant_scoring into consultant_scoring
Reviewed-on: eatvio/eatvio_app#1005
2026-07-29 11:09:30 +00:00
0d93768dc3 Merge branch 'consultant_scoring' into consultant_scoring 2026-07-29 11:09:10 +00:00
misha
8c5fc8d827 feat(consultant_scoring): fixed registration update flow 2026-07-29 14:08:13 +03:00
d56aa45c92 Merge pull request 'personalized_feed' (#1009) from misha/eatvio_app:personalized_feed into personalized_feed
Reviewed-on: eatvio/eatvio_app#1009
2026-07-23 13:37:00 +00:00
misha
037f153273 feat(consultant_scoring): adjusted data for new registration, added sbp banks endpoint 2026-07-22 17:58:02 +03:00
6a17bf3a95 Merge pull request 'feat(personalized_feed): added video and multiple images handling to detailed blog post screen' (#1003) from misha/eatvio_app:personalized_feed into personalized_feed
Reviewed-on: eatvio/eatvio_app#1003
2026-07-22 11:53:41 +00:00
253 changed files with 21850 additions and 5078 deletions

View 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

View 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"

View 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"]

View File

@@ -8,8 +8,16 @@ jobs:
build:
runs-on: [macos-arm64]
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
run: fastlane --version
run: |
git --version
fastlane --version
- name: Checkout
uses: https://github.com/actions/checkout@v2
@@ -59,12 +67,16 @@ jobs:
- name: Flutter build
run: |
flutter --version
flutter config --no-enable-swift-package-manager
rm -rf ios/Podfile.lock
flutter clean
flutter pub get
flutter pub run build_runner build --delete-conflicting-outputs
flutter build ios --config-only --release
cd ios
pod install
pod install --repo-update
cd ..
flutter build ios --config-only --release
cd ios
fastlane beta
env:
LANG: en_US.UTF-8

3
.gitignore vendored
View File

@@ -24,4 +24,5 @@ coverage/
backend/
.fvm/
.fvmrc
/eatvio
/eatvio
*.code-workspace

View File

@@ -69,6 +69,7 @@ android {
}
dependencies {
testImplementation 'junit:junit:4.13.2'
implementation platform('com.google.firebase:firebase-bom:34.4.0')
implementation 'com.google.firebase:firebase-messaging'
implementation 'com.huawei.agconnect:agconnect-core:1.9.5.300'

View File

@@ -78,12 +78,7 @@
<data android:host="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>
</activity>
<!-- Don't delete the meta-data below.

View File

@@ -8,6 +8,10 @@ import com.hiennv.flutter_callkit_incoming.CallkitIncomingBroadcastReceiver
object CallKitIntentBridge {
fun dispatchCallKitAction(context: Context, action: String, data: Bundle?) {
if (IncomingCallRingtonePolicy.actionStopsRingtone(action)) {
VideoCallPushBridge.silenceIncomingCall(context)
}
if (data != null) {
CallKitAcceptIntentMapper.payloadFromBundle(data, action)?.let { payload ->
PendingCallKitAcceptBridge.store(payload)

View File

@@ -24,11 +24,9 @@ class EatvioFcmMessagingService : FirebaseMessagingService() {
return
}
if (MainActivity.isInForeground) {
Log.i(TAG, "App in foreground, deferring incoming call to Flutter handler")
return
}
// Data-only call pushes are delivered only to this service.
// FlutterFirebaseMessagingService is removed, so the Dart listener does
// not see them. Show the incoming UI here for both foreground and background.
VideoCallPushBridge.showIncomingCall(this, message.data)
}

View File

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

View File

@@ -13,6 +13,9 @@ import io.flutter.plugin.common.MethodChannel
import com.hiennv.flutter_callkit_incoming.FlutterCallkitIncomingPlugin
import com.huawei.agconnect.config.AGConnectServicesConfig
import com.huawei.hms.aaid.HmsInstanceId
import com.huawei.hms.api.ConnectionResult
import com.huawei.hms.api.HuaweiApiAvailability
import com.huawei.hms.common.ApiException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -82,6 +85,20 @@ class MainActivity: FlutterActivity() {
}
methodChannel?.setMethodCallHandler { call, result ->
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" -> {
CoroutineScope(Dispatchers.IO).launch {
try {
@@ -121,10 +138,30 @@ class MainActivity: FlutterActivity() {
runOnUiThread {
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) {
android.util.Log.e("HMS", "Error getting HMS token: ${e.message}", e)
runOnUiThread {
result.error("HMS_TOKEN_ERROR", e.message, null)
val message = e.message.orEmpty()
if (message.contains("907135003") || message.contains("client api invalid", ignoreCase = true)) {
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)
}
}
}
}

View File

@@ -8,6 +8,7 @@ import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.hiennv.flutter_callkit_incoming.CallkitConstants
import com.hiennv.flutter_callkit_incoming.CallkitEventCallback
import com.hiennv.flutter_callkit_incoming.CallkitNotificationManager
import com.hiennv.flutter_callkit_incoming.CallkitNotificationService
import com.hiennv.flutter_callkit_incoming.CallkitSoundPlayerManager
@@ -17,14 +18,36 @@ import com.hiennv.flutter_callkit_incoming.addCall
import com.hiennv.flutter_callkit_incoming.getDataActiveCalls
import com.hiennv.flutter_callkit_incoming.removeAllCalls
import java.util.HashMap
import java.util.concurrent.atomic.AtomicInteger
object VideoCallPushBridge {
private const val TAG = "VideoCallPushBridge"
private const val ANDROID_ACCENT_COLOR = "#2787F5"
private const val MISSED_CALL_CLEANUP_DELAY_MS = 250L
private val mainHandler = Handler(Looper.getMainLooper())
private val playbackLock = Any()
private val playbackGeneration = AtomicInteger(0)
private var activeSoundManager: CallkitSoundPlayerManager? = null
private var activeNotificationManager: CallkitNotificationManager? = null
private var activeCallBundle: Bundle? = null
private var storedAppContext: Context? = null
private var ringtoneTimeout: Runnable? = 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 {
val rawType = data["type"]
@@ -89,18 +112,114 @@ object VideoCallPushBridge {
fun showIncomingCall(context: Context, data: Map<String, String>) {
val appContext = context.applicationContext
storedAppContext = appContext
ensureIncomingCallCallbackRegistered()
val callData = buildCallDataBundle(data)
if (callData == null) {
Log.w(TAG, "showIncomingCall: missing conversation uuid, data=$data")
return
}
val generation = playbackGeneration.incrementAndGet()
Log.i(TAG, "showIncomingCall: conversation=${callData.getString(CallkitConstants.EXTRA_CALLKIT_ID)}")
// Ringtone.play() and stop() must run on the same thread. FCM delivers
// pushes on a background thread, while Answer runs on the main thread.
mainHandler.post {
synchronized(playbackLock) {
if (generation != playbackGeneration.get()) {
Log.i(TAG, "showIncomingCall: skipped stale playback generation=$generation")
return@post
}
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)
}
}
fun dismissAllIncomingCalls(context: Context) {
val appContext = context.applicationContext
val calls = getDataActiveCalls(appContext)
Log.i(TAG, "dismissAllIncomingCalls: active calls=${calls.size}")
playbackGeneration.incrementAndGet()
val dismiss = Runnable {
synchronized(playbackLock) {
val notificationManager = resolveNotificationManager(appContext)
val bundles = calls.map { it.toBundle() }
stopPlaybackLocked(appContext)
for (bundle in bundles) {
try {
dismissIncomingCallNotification(appContext, notificationManager, bundle)
} catch (error: Exception) {
Log.w(
TAG,
"dismissAllIncomingCalls: failed for call ${bundle.getString(CallkitConstants.EXTRA_CALLKIT_ID)}",
error,
)
}
}
try {
CallkitNotificationService.stopService(appContext)
} catch (error: Exception) {
Log.w(TAG, "dismissAllIncomingCalls: failed to stop callkit service", error)
}
try {
removeAllCalls(appContext)
} catch (error: Exception) {
Log.w(TAG, "dismissAllIncomingCalls: failed to remove persisted calls", error)
}
stopIncomingRingtone()
scheduleMissedCallCleanup(notificationManager, bundles)
}
}
if (Looper.myLooper() == Looper.getMainLooper()) {
dismiss.run()
} else {
mainHandler.post(dismiss)
}
}
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 notificationManager = CallkitNotificationManager(appContext, soundManager)
activeSoundManager = soundManager
activeNotificationManager = notificationManager
activeCallBundle = callData
notificationManager.createNotificationChanel(callData)
notificationManager.showIncomingNotification(callData)
@@ -109,46 +228,41 @@ object VideoCallPushBridge {
} catch (error: Exception) {
Log.w(TAG, "showIncomingCall: failed to persist active call", error)
}
val durationMs = callData.getLong(CallkitConstants.EXTRA_CALLKIT_DURATION, 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
val calls = getDataActiveCalls(appContext)
Log.i(TAG, "dismissAllIncomingCalls: active calls=${calls.size}")
stopIncomingRingtone(appContext)
val notificationManager = resolveNotificationManager(appContext)
val bundles = calls.map { it.toBundle() }
for (bundle in bundles) {
private fun stopPlaybackLocked(appContext: Context) {
cancelRingtoneTimeoutLocked()
val notificationManager = activeNotificationManager
val bundle = activeCallBundle
stopIncomingRingtone()
if (notificationManager != null && bundle != null) {
try {
dismissIncomingCallNotification(appContext, notificationManager, bundle)
} catch (error: Exception) {
Log.w(
TAG,
"dismissAllIncomingCalls: failed for call ${bundle.getString(CallkitConstants.EXTRA_CALLKIT_ID)}",
error,
)
Log.w(TAG, "stopPlayback: failed to dismiss incoming notification", error)
}
}
try {
CallkitNotificationService.stopService(appContext)
} catch (error: Exception) {
Log.w(TAG, "dismissAllIncomingCalls: failed to stop callkit service", error)
}
try {
removeAllCalls(appContext)
} catch (error: Exception) {
Log.w(TAG, "dismissAllIncomingCalls: failed to remove persisted calls", error)
}
stopIncomingRingtone(appContext)
scheduleMissedCallCleanup(notificationManager, bundles)
activeSoundManager = null
activeNotificationManager = null
activeCallBundle = null
}
private fun cancelRingtoneTimeoutLocked() {
ringtoneTimeout?.let { mainHandler.removeCallbacks(it) }
ringtoneTimeout = null
}
private fun resolveNotificationManager(context: Context): CallkitNotificationManager {
@@ -161,11 +275,19 @@ object VideoCallPushBridge {
return CallkitNotificationManager(context, soundManager)
}
private fun stopIncomingRingtone(context: Context) {
activeSoundManager?.stop()
FlutterCallkitIncomingPlugin.getInstance()
?.getCallkitSoundPlayerManager()
?.stop()
private fun stopIncomingRingtone() {
try {
activeSoundManager?.stop()
} catch (error: Exception) {
Log.w(TAG, "stopIncomingRingtone: active player", error)
}
try {
FlutterCallkitIncomingPlugin.getInstance()
?.getCallkitSoundPlayerManager()
?.stop()
} catch (error: Exception) {
Log.w(TAG, "stopIncomingRingtone: plugin player", error)
}
}
/**
@@ -264,7 +386,7 @@ object VideoCallPushBridge {
putString(CallkitConstants.EXTRA_CALLKIT_APP_NAME, "Итвио")
putString(CallkitConstants.EXTRA_CALLKIT_HANDLE, conversationUuid)
putString(CallkitConstants.EXTRA_CALLKIT_AVATAR, avatarUrl)
putInt(CallkitConstants.EXTRA_CALLKIT_TYPE, 0)
putInt(CallkitConstants.EXTRA_CALLKIT_TYPE, 1)
putLong(CallkitConstants.EXTRA_CALLKIT_DURATION, 45000L)
putString(CallkitConstants.EXTRA_CALLKIT_TEXT_ACCEPT, "Answer")
putString(CallkitConstants.EXTRA_CALLKIT_TEXT_DECLINE, "Decline")

View File

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

View File

@@ -44,6 +44,8 @@ post_install do |installer|
'$(inherited)',
## dart: PermissionGroup.camera
'PERMISSION_CAMERA=1',
## dart: PermissionGroup.microphone
'PERMISSION_MICROPHONE=1',
## dart: PermissionGroup.photos
'PERMISSION_PHOTOS=1',
'PERMISSION_NOTIFICATIONS=1',

View File

@@ -11,6 +11,7 @@
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
46B43B96F809CEC6058EFDD3 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 15ADDB2A35F334AF0AE50011 /* Pods_Runner.framework */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
C4A8E2022E9A4B1000123456 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A8E2012E9A4B1000123456 /* SceneDelegate.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
@@ -37,6 +38,7 @@
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>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.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>"; };
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>"; };
@@ -115,6 +117,7 @@
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
C4A8E2012E9A4B1000123456 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
FEE92A0E2D772EA300E6E989 /* Runner.entitlements */,
);
@@ -300,6 +303,7 @@
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
C4A8E2022E9A4B1000123456 /* SceneDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;

View File

@@ -4,6 +4,7 @@ import Flutter
import PushKit
import UIKit
import UserNotifications
import WebRTC
import YandexLoginSDK
import flutter_callkit_incoming
import flutter_foreground_task
@@ -15,7 +16,29 @@ import flutter_foreground_task
private let voipDiagnosticsStorageKey = "EatvioVoipDiagnostics"
private let pendingAcceptedVoipCallKey = "EatvioPendingAcceptedVoipCall"
private let voipDiagnosticsChannelName = "com.eatvioapp/voip_diagnostics"
private let callAudioSessionChannelName = "com.eatvioapp/call_audio_session"
private let appUrlScheme = "eatvio"
/// CallKit is only the ringing UI. After accept the in-app call continues,
/// so the audio session must be handed back to WebRTC when CallKit deactivates.
private var resumeAudioAfterCallKitEnds = false
/// The push handler and the Flutter channel touch this map on different
/// queues. The push writes the id before CallKit has the call, so the
/// in-app path can see it and not report the same call again.
private var reservedIncomingCallIds: [String: Date] = [:]
private let incomingCallClaimLock = NSLock()
private let incomingCallClaimTtl: TimeInterval = 90
private let voipPushQueue = DispatchQueue(label: "com.eatvioapp.voip")
private var voipCallReporterStorage: VoipCallReporter?
private var audioRestartGeneration = 0
private var voipCallReporter: VoipCallReporter {
if let voipCallReporterStorage {
return voipCallReporterStorage
}
let reporter = VoipCallReporter()
voipCallReporterStorage = reporter
return reporter
}
override func application(
_ application: UIApplication,
@@ -26,10 +49,12 @@ import flutter_foreground_task
GeneratedPluginRegistrant.register(with: registry)
}
let mainQueue = DispatchQueue.main
voipRegistry = PKPushRegistry(queue: mainQueue)
// Not the main queue. Flutter startup blocks main, and PushKit aborts the
// process if this push is not reported before that returns.
voipRegistry = PKPushRegistry(queue: voipPushQueue)
voipRegistry?.delegate = self
voipRegistry?.desiredPushTypes = [PKPushType.voIP]
configureWebRTCAudioSession()
if #available(iOS 10.0, *) {
UNUserNotificationCenter.current().delegate = self
@@ -37,29 +62,81 @@ import flutter_foreground_task
let launched = super.application(application, didFinishLaunchingWithOptions: launchOptions)
if let controller = window?.rootViewController as? FlutterViewController {
let channel = FlutterMethodChannel(
name: voipDiagnosticsChannelName,
binaryMessenger: controller.binaryMessenger
)
voipDiagnosticsChannel = channel
channel.setMethodCallHandler { [weak self] call, result in
guard let self else {
result(nil)
return
}
switch call.method {
case "drainVoipDiagnostics":
result(self.drainVoipDiagnostics())
case "drainPendingAcceptedVoipCall":
result(self.drainPendingAcceptedVoipCall())
default:
result(FlutterMethodNotImplemented)
}
// WebRTC must not activate the session itself. CallKit and the in-app
// call both own AVAudioSession, and a second activation drops the mic.
let rtcSession = RTCAudioSession.sharedInstance()
rtcSession.useManualAudio = true
rtcSession.isAudioEnabled = false
registerCallChannels(from: window?.rootViewController as? FlutterViewController)
return launched
}
func registerCallChannels(from controller: FlutterViewController?) {
guard voipDiagnosticsChannel == nil else { return }
let flutterController = controller ?? flutterViewControllerFromScenes()
guard let flutterController else { return }
let channel = FlutterMethodChannel(
name: voipDiagnosticsChannelName,
binaryMessenger: flutterController.binaryMessenger
)
voipDiagnosticsChannel = channel
channel.setMethodCallHandler { [weak self] call, result in
guard let self else {
result(nil)
return
}
switch call.method {
case "drainVoipDiagnostics":
result(self.drainVoipDiagnostics())
case "drainPendingAcceptedVoipCall":
result(self.drainPendingAcceptedVoipCall())
case "isIncomingCallClaimed":
result(self.isIncomingCallClaimed(self.incomingCallId(from: call.arguments)))
case "reserveIncomingCall":
result(self.reserveIncomingCall(self.incomingCallId(from: call.arguments)))
default:
result(FlutterMethodNotImplemented)
}
}
return launched
let callAudioChannel = FlutterMethodChannel(
name: callAudioSessionChannelName,
binaryMessenger: flutterController.binaryMessenger
)
callAudioChannel.setMethodCallHandler { [weak self] call, result in
guard let self else {
result(nil)
return
}
switch call.method {
case "activate":
self.activateInAppCallAudioSession()
result(nil)
case "restart":
self.restartInAppCallAudioUnit()
result(nil)
case "deactivate":
self.deactivateInAppCallAudioSession()
result(nil)
default:
result(FlutterMethodNotImplemented)
}
}
}
private func flutterViewControllerFromScenes() -> FlutterViewController? {
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
}
@available(iOS 8.0, *)
@@ -83,6 +160,14 @@ import flutter_foreground_task
open url: URL,
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
) -> Bool {
handleOpenURL(
url,
sourceApplication: options[UIApplication.OpenURLOptionsKey.sourceApplication] as? String
)
}
@discardableResult
func handleOpenURL(_ url: URL, sourceApplication: String?) -> Bool {
if url.scheme == appUrlScheme {
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
let queryItems = components?.queryItems ?? []
@@ -116,10 +201,39 @@ import flutter_foreground_task
return YXLSdk.shared.handleOpen(
url,
sourceApplication: options[UIApplication.OpenURLOptionsKey.sourceApplication] as? String
sourceApplication: sourceApplication
)
}
/// Same derivation as [VideoCallKitId.iosUuid]: one CallKit UUID per message,
/// so the next call in this chat is not rejected as a duplicate UUID.
private func derivedCallKitId(conversationUuid: String, messageId: UInt64?) -> String? {
guard let messageId, messageId > 0 else { return nil }
let pattern =
#"^([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}$"#
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
let range = NSRange(conversationUuid.startIndex..., in: conversationUuid)
guard let match = regex.firstMatch(in: conversationUuid, range: range),
let prefixRange = Range(match.range(at: 1), in: conversationUuid)
else { return nil }
let suffix = String(format: "%012llx", messageId)
guard suffix.count == 12 else { return nil }
return (String(conversationUuid[prefixRange]) + suffix).lowercased()
}
private func voipMessageId(_ value: Any?) -> UInt64? {
if let number = value as? NSNumber {
let parsed = number.uint64Value
return parsed > 0 ? parsed : nil
}
if let text = value as? String {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard let parsed = UInt64(trimmed), parsed > 0 else { return nil }
return parsed
}
return nil
}
private func conversationUuid(fromActionUrl actionUrl: String?) -> String? {
guard
let actionUrl = actionUrl,
@@ -283,7 +397,9 @@ import flutter_foreground_task
events = Array(events.suffix(20))
}
defaults.set(events, forKey: voipDiagnosticsStorageKey)
voipDiagnosticsChannel?.invokeMethod("voipDiagnosticsUpdated", arguments: nil)
DispatchQueue.main.async { [weak self] in
self?.voipDiagnosticsChannel?.invokeMethod("voipDiagnosticsUpdated", arguments: nil)
}
}
private func drainVoipDiagnostics() -> [[String: Any]] {
@@ -293,6 +409,160 @@ import flutter_foreground_task
return events
}
private func isEndedVideoCallMarker(_ marker: String) -> Bool {
if marker == "video_call_ended" || marker == "video_call_end" {
return true
}
return marker.contains("video_call")
&& (marker.contains("ended") || marker.hasSuffix("_end") || marker.hasSuffix("_cancel"))
}
private func incomingCallId(from arguments: Any?) -> String {
let raw: String?
if let id = arguments as? String {
raw = id
} else if let map = arguments as? [String: Any] {
raw = map["id"] as? String
} else {
raw = nil
}
return raw?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? ""
}
/// Caller must hold `incomingCallClaimLock`.
private func pruneIncomingCallClaims(now: Date = Date()) {
reservedIncomingCallIds = reservedIncomingCallIds.filter {
now.timeIntervalSince($0.value) < incomingCallClaimTtl
}
}
private func activeIncomingCallIds() -> Set<String> {
let calls = SwiftFlutterCallkitIncomingPlugin.sharedInstance?.activeCalls() ?? []
return Set(calls.compactMap { ($0["id"] as? String)?.lowercased() })
}
private func isIncomingCallClaimed(_ id: String) -> Bool {
let normalized = id.lowercased()
guard !normalized.isEmpty else { return false }
let reserved = incomingCallClaimLock.withLock { () -> Bool in
pruneIncomingCallClaims()
return reservedIncomingCallIds[normalized] != nil
}
if reserved {
return true
}
return activeIncomingCallIds().contains(normalized)
}
/// Returns false when this id is already reserved. The VoIP push still
/// reports the call; this only stops the in-app path from reporting it too.
private func reserveIncomingCall(_ id: String) -> Bool {
let normalized = id.lowercased()
guard !normalized.isEmpty else { return false }
return incomingCallClaimLock.withLock {
pruneIncomingCallClaims()
if reservedIncomingCallIds[normalized] != nil {
return false
}
reservedIncomingCallIds[normalized] = Date()
return true
}
}
private func releaseIncomingCallClaim(_ id: String) {
incomingCallClaimLock.withLock {
_ = reservedIncomingCallIds.removeValue(forKey: id.lowercased())
}
}
/// An ended VoIP push has no message id, so its CallKit id is the
/// conversation uuid, not the ringing call. End that conversation only.
/// The first endCall while the push flag is set closes the call this push
/// just reported; the next ones close the call that is actually ringing.
private func endCallsForEndedPush(reportedId: String, conversationUuid: String) {
guard let plugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance else {
releaseIncomingCallClaim(reportedId)
return
}
let conversation = conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines)
var ids: [String] = []
func append(_ id: String) {
let normalized = id.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalized.isEmpty else { return }
if ids.contains(where: { $0.caseInsensitiveCompare(normalized) == .orderedSame }) {
return
}
ids.append(normalized)
}
append(reportedId)
for call in plugin.activeCalls() {
let callId = (call["id"] as? String) ?? ""
let handle = (call["handle"] as? String) ?? ""
let extraUuid = extraConversationUuid(call["extra"])
let sameId = callId.caseInsensitiveCompare(reportedId) == .orderedSame
let sameConversation = !conversation.isEmpty && (
handle.caseInsensitiveCompare(conversation) == .orderedSame
|| extraUuid.caseInsensitiveCompare(conversation) == .orderedSame
)
if sameId || sameConversation {
append(callId)
}
}
for id in ids {
let endData = flutter_callkit_incoming.Data(
id: id,
nameCaller: "Итвио",
handle: conversation,
type: 1
)
plugin.endCall(endData)
releaseIncomingCallClaim(id)
}
}
private func activeCallId(forConversation conversationUuid: String) -> String? {
let conversation = conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines)
guard !conversation.isEmpty else { return nil }
let calls = SwiftFlutterCallkitIncomingPlugin.sharedInstance?.activeCalls() ?? []
for call in calls {
let callId = (call["id"] as? String) ?? ""
guard !callId.isEmpty else { continue }
let handle = (call["handle"] as? String) ?? ""
let extraUuid = extraConversationUuid(call["extra"])
if handle.caseInsensitiveCompare(conversation) == .orderedSame
|| extraUuid.caseInsensitiveCompare(conversation) == .orderedSame
{
return callId
}
}
return nil
}
private func extraConversationUuid(_ extra: Any?) -> String {
let map: [String: Any]
if let typed = extra as? [String: Any] {
map = typed
} else if let object = extra as? NSDictionary {
var converted: [String: Any] = [:]
for (key, value) in object {
if let key = key as? String {
converted[key] = value
}
}
map = converted
} else {
return ""
}
for key in ["conversationUuid", "conversation_uuid"] {
if let value = map[key] as? String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
return trimmed
}
}
}
return ""
}
func pushRegistry(
_ registry: PKPushRegistry,
@@ -300,11 +570,15 @@ import flutter_foreground_task
for type: PKPushType
) {
let deviceToken = credentials.token.map { String(format: "%02x", $0) }.joined()
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP(deviceToken)
DispatchQueue.main.async {
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP(deviceToken)
}
}
func pushRegistry(_ registry: PKPushRegistry, didInvalidatePushTokenFor type: PKPushType) {
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP("")
DispatchQueue.main.async {
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP("")
}
}
func pushRegistry(
@@ -329,8 +603,8 @@ import flutter_foreground_task
]
.compactMap { $0?.lowercased().replacingOccurrences(of: "-", with: "_") }
if pushMarkers.contains(where: { $0.contains("video_call") })
&& pushMarkers.contains(where: { $0.contains("end") || $0.contains("cancel") }) {
let isEndedPush = pushMarkers.contains(where: { self.isEndedVideoCallMarker($0) })
if isEndedPush {
recordVoipDiagnostic(
"voip_end_or_cancel_received",
data: [
@@ -338,18 +612,18 @@ import flutter_foreground_task
"payload": dict,
]
)
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.endAllCalls()
completion()
return
}
let nameCaller = (dict["nameCaller"] as? String)
let rawNameCaller = (dict["nameCaller"] as? String)
?? (dict["counterpartName"] as? String)
?? (dict["counterpart_name"] as? String)
?? (dict["caller_name"] as? String)
?? (dict["callerName"] as? String)
?? (dict["caller"] as? String)
?? ""
let nameCaller = rawNameCaller.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? "Итвио"
: rawNameCaller.trimmingCharacters(in: .whitespacesAndNewlines)
let conversationUuid = (dict["conversation_uuid"] as? String)
?? (dict["conversationUuid"] as? String)
?? (dict["conversation_id"] as? String)
@@ -358,26 +632,33 @@ import flutter_foreground_task
?? conversationUuid(fromActionUrl: dict["action_url"] as? String)
?? (dict["handle"] as? String)
?? ""
let callKitIdSource = [
(key: "call_id", value: dict["call_id"] as? String),
(key: "id", value: dict["id"] as? String),
(key: "message_id", value: dict["message_id"] as? String),
]
.first { item in
guard let trimmed = item.value?.trimmingCharacters(in: .whitespacesAndNewlines) else {
return false
}
return !trimmed.isEmpty && UUID(uuidString: trimmed) != nil
}
let id = callKitIdSource?.value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? UUID().uuidString
let callKitIdSourceName = callKitIdSource?.key ?? "generated"
let trimmedConversationUuid = conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines)
let parsedMessageId = voipMessageId(dict["message_id"])
let id: String
let callKitIdSourceName: String
if isEndedPush, let existing = activeCallId(forConversation: trimmedConversationUuid) {
id = existing
callKitIdSourceName = "active_conversation"
} else if let derived = derivedCallKitId(
conversationUuid: trimmedConversationUuid,
messageId: parsedMessageId
) {
id = derived
callKitIdSourceName = "conversation_and_message"
} else if UUID(uuidString: trimmedConversationUuid) != nil {
id = trimmedConversationUuid.lowercased()
callKitIdSourceName = "conversation"
} else {
id = UUID().uuidString.lowercased()
callKitIdSourceName = "generated"
}
let isVideo = (dict["isVideo"] as? Bool)
?? (dict["is_video"] as? Bool)
?? true
var extra: [String: Any] = [:]
if !conversationUuid.isEmpty {
extra["conversationUuid"] = conversationUuid
if !trimmedConversationUuid.isEmpty {
extra["conversationUuid"] = trimmedConversationUuid
}
if let avatar = dict["caller_avatar"] as? String ?? dict["avatar"] as? String
?? dict["avatarUrl"] as? String
@@ -409,10 +690,21 @@ import flutter_foreground_task
let data = flutter_callkit_incoming.Data(
id: id,
nameCaller: nameCaller,
handle: conversationUuid.isEmpty ? nameCaller : conversationUuid,
handle: trimmedConversationUuid.isEmpty ? nameCaller : trimmedConversationUuid,
type: isVideo ? 1 : 0
)
data.duration = 45000
data.extra = extra as NSDictionary
// The plugin activates AVAudioSession itself on answer and again 1.2s
// later. That drops the ringing UI and leaves one side silent. CallKit
// and WebRTC own the session instead.
data.configureAudioSession = false
data.audioSessionActive = false
data.audioSessionMode = "videoChat"
data.ringtonePath = "system_ringtone_default"
// Remember the id so the in-app socket path does not report it again.
// The push itself must still be reported, or iOS kills the process.
_ = reserveIncomingCall(id)
NSLog(
"[EatvioVoIP] report incoming call id=\(id) conversation=\(conversationUuid) state=\(UIApplication.shared.applicationState.rawValue) markers=\(pushMarkers.joined(separator: ",")) payload=\(dict)"
@@ -430,58 +722,96 @@ import flutter_foreground_task
]
)
guard let callkitPlugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance else {
NSLog("[EatvioVoIP] CallKit plugin missing id=\(id)")
recordVoipDiagnostic(
"callkit_plugin_missing",
data: [
"callKitId": id,
"conversationUuid": conversationUuid,
]
)
completion()
return
reportVoipPushToCallKit(
data,
callKitId: id,
callKitIdSourceName: callKitIdSourceName,
conversationUuid: conversationUuid,
endAfterReport: isEndedPush,
completion: completion
)
}
/// PushKit kills the app unless every VoIP push is reported to CallKit and
/// its completion handler runs. Both happen here, including an "ended" push.
private func reportVoipPushToCallKit(
_ data: flutter_callkit_incoming.Data,
callKitId: String,
callKitIdSourceName: String,
conversationUuid: String,
endAfterReport: Bool,
completion: @escaping () -> Void
) {
let gate = VoipPushCompletion(completion)
let finish = { self.voipPushQueue.async { gate.call() } }
voipPushQueue.asyncAfter(deadline: .now() + 1.5) {
finish()
}
callkitPlugin.showCallkitIncoming(
data,
fromPushKit: true
) {
let activeCalls = callkitPlugin.activeCalls()
let activeIds = activeCalls.compactMap { $0["id"] as? String }.joined(separator: ",")
NSLog(
"[EatvioVoIP] report incoming completed id=\(id) activeCallIds=\(activeIds)"
)
self.recordVoipDiagnostic(
"callkit_report_completed",
data: [
"callKitId": id,
"callKitIdSource": callKitIdSourceName,
"conversationUuid": conversationUuid,
"activeCallIds": activeIds,
"activeCallsCount": activeCalls.count,
]
)
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
let delayedActiveCalls = callkitPlugin.activeCalls()
let delayedActiveIds = delayedActiveCalls.compactMap { $0["id"] as? String }.joined(separator: ",")
let afterReport = {
if endAfterReport {
DispatchQueue.main.async {
self.endCallsForEndedPush(
reportedId: callKitId,
conversationUuid: conversationUuid
)
}
}
if let plugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance {
let activeCalls = plugin.activeCalls()
let activeIds = activeCalls.compactMap { $0["id"] as? String }.joined(separator: ",")
self.recordVoipDiagnostic(
"callkit_active_calls_delayed",
"callkit_report_completed",
data: [
"callKitId": id,
"callKitId": callKitId,
"callKitIdSource": callKitIdSourceName,
"conversationUuid": conversationUuid,
"activeCallIds": delayedActiveIds,
"activeCallsCount": delayedActiveCalls.count,
"activeCallIds": activeIds,
"activeCallsCount": activeCalls.count,
"endAfterReport": endAfterReport,
]
)
}
completion()
finish()
}
if let plugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance {
plugin.showCallkitIncoming(data, fromPushKit: true, completion: afterReport)
return
}
NSLog("[EatvioVoIP] CallKit plugin missing id=\(callKitId)")
recordVoipDiagnostic(
"callkit_plugin_missing",
data: [
"callKitId": callKitId,
"conversationUuid": conversationUuid,
]
)
guard let uuid = UUID(uuidString: data.uuid) else {
finish()
return
}
voipCallReporter.report(
uuid: uuid,
callerName: data.nameCaller,
hasVideo: data.type > 0
) { _ in
if endAfterReport {
self.voipCallReporter.provider.reportCall(
with: uuid,
endedAt: Date(),
reason: .remoteEnded
)
}
afterReport()
}
}
func onAccept(_ call: Call, _ action: CXAnswerCallAction) {
resumeAudioAfterCallKitEnds = true
prepareCallAudioCategory()
storePendingAcceptedCall(call)
recordVoipDiagnostic(
"callkit_accept_native",
@@ -500,6 +830,8 @@ import flutter_foreground_task
}
func onDecline(_ call: Call, _ action: CXEndCallAction) {
resumeAudioAfterCallKitEnds = false
audioRestartGeneration += 1
action.fulfill()
}
@@ -509,9 +841,151 @@ import flutter_foreground_task
func onTimeOut(_ call: Call) {}
func didActivateAudioSession(_ audioSession: AVAudioSession) {}
func didActivateAudioSession(_ audioSession: AVAudioSession) {
let rtcSession = RTCAudioSession.sharedInstance()
rtcSession.useManualAudio = true
rtcSession.audioSessionDidActivate(audioSession)
applyCallAudioConfiguration(active: false)
rtcSession.isAudioEnabled = true
}
func didDeactivateAudioSession(_ audioSession: AVAudioSession) {}
func didDeactivateAudioSession(_ audioSession: AVAudioSession) {
let rtcSession = RTCAudioSession.sharedInstance()
rtcSession.audioSessionDidDeactivate(audioSession)
// The audio unit is stopped, but isAudioEnabled stays true. A later
// assignment of true is then a no-op and the microphone never restarts.
rtcSession.isAudioEnabled = false
guard resumeAudioAfterCallKitEnds else { return }
DispatchQueue.main.async { [weak self] in
guard let self, self.resumeAudioAfterCallKitEnds else { return }
self.restartInAppCallAudioUnit()
}
}
func providerDidReset() {}
func providerDidReset() {
resumeAudioAfterCallKitEnds = false
audioRestartGeneration += 1
}
private func configureWebRTCAudioSession() {
let configuration = callAudioConfiguration()
RTCAudioSessionConfiguration.setWebRTC(configuration)
}
private func prepareCallAudioCategory() {
applyCallAudioConfiguration(active: false)
}
private func activateInAppCallAudioSession() {
let rtcSession = RTCAudioSession.sharedInstance()
rtcSession.useManualAudio = true
applyCallAudioConfiguration(active: true)
// Starting the audio unit while the configuration lock is held deadlocks
// the capture thread, so the mic stays silent.
rtcSession.isAudioEnabled = true
}
/// Restarts capture after the previous audio graph has disconnected.
/// Toggling the unit in the same turn makes voice processing throw.
private func restartInAppCallAudioUnit() {
audioRestartGeneration += 1
let generation = audioRestartGeneration
let rtcSession = RTCAudioSession.sharedInstance()
rtcSession.useManualAudio = true
rtcSession.isAudioEnabled = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
guard let self, generation == self.audioRestartGeneration else { return }
self.activateInAppCallAudioSession()
}
}
private func applyCallAudioConfiguration(active: Bool) {
let rtcSession = RTCAudioSession.sharedInstance()
rtcSession.lockForConfiguration()
defer { rtcSession.unlockForConfiguration() }
do {
try rtcSession.setConfiguration(callAudioConfiguration(), active: active)
} catch {
NSLog("[EatvioVoIP] apply call audio configuration failed: \(error)")
}
}
private func deactivateInAppCallAudioSession() {
resumeAudioAfterCallKitEnds = false
audioRestartGeneration += 1
let rtcSession = RTCAudioSession.sharedInstance()
rtcSession.isAudioEnabled = false
rtcSession.lockForConfiguration()
defer { rtcSession.unlockForConfiguration() }
do {
try rtcSession.setActive(false)
} catch {
NSLog("[EatvioVoIP] deactivate call audio session failed: \(error)")
}
}
private func callAudioConfiguration() -> RTCAudioSessionConfiguration {
let configuration = RTCAudioSessionConfiguration.webRTC()
configuration.category = AVAudioSession.Category.playAndRecord.rawValue
configuration.categoryOptions = [
.allowBluetooth,
.defaultToSpeaker,
]
// videoChat is the speakerphone mode for a video call. voiceChat keeps
// the input on the receiver, so the mic is silent once audio goes to
// the loudspeaker.
configuration.mode = AVAudioSession.Mode.videoChat.rawValue
// Voice processing rejects 44.1 kHz and throws from AVAudioEngine.
configuration.sampleRate = 48000
configuration.ioBufferDuration = 0.02
configuration.inputNumberOfChannels = 1
configuration.outputNumberOfChannels = 1
return configuration
}
}
/// Reports a VoIP push when the Flutter CallKit plugin is not registered yet.
private final class VoipPushCompletion {
private var finished = false
private let completion: () -> Void
init(_ completion: @escaping () -> Void) {
self.completion = completion
}
func call() {
if finished { return }
finished = true
completion()
}
}
private final class VoipCallReporter: NSObject, CXProviderDelegate {
let provider: CXProvider
override init() {
let configuration = CXProviderConfiguration(localizedName: "Итвио")
configuration.supportsVideo = true
configuration.maximumCallGroups = 1
configuration.maximumCallsPerCallGroup = 1
configuration.supportedHandleTypes = [.generic]
provider = CXProvider(configuration: configuration)
super.init()
provider.setDelegate(self, queue: nil)
}
func report(
uuid: UUID,
callerName: String,
hasVideo: Bool,
completion: @escaping (Error?) -> Void
) {
let update = CXCallUpdate()
update.remoteHandle = CXHandle(type: .generic, value: callerName)
update.localizedCallerName = callerName
update.hasVideo = hasVideo
provider.reportNewIncomingCall(with: uuid, update: update, completion: completion)
}
func providerDidReset(_ provider: CXProvider) {}
}

View File

@@ -41,6 +41,27 @@
<string>Может использоваться для стабильного поддержания связи во время звонка.</string>
<key>NSPhotoLibraryUsageDescription</key>
<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>
<true/>
<key>UIBackgroundModes</key>

View File

@@ -8,6 +8,8 @@
<array>
<string>applinks: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>
</array>
</dict>

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

View File

@@ -1,8 +1,12 @@
import 'dart:async';
import 'package:eatvio_app/core/app_initializer.dart';
import 'package:eatvio_app/core/cubit/marketing/marketing_cubit.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/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/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';
@@ -31,20 +35,50 @@ class AppConfig {
void setBaseUrl(String 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));
}
/// Список глобальных BlocProvider'ов
/// Используются lazy: true для отложенной инициализации, чтобы не блокировать старт приложения
List<BlocProvider> get globalBlocProviders => [
BlocProvider<VideoCallSessionCubit>.value(value: sl<VideoCallSessionCubit>()),
BlocProvider<NewMessagesCubit>(lazy: true, create: (context) => sl<NewMessagesCubit>()),
BlocProvider<NotificationsCubit>(lazy: true, create: (context) => sl<NotificationsCubit>()),
BlocProvider<VideoCallSessionCubit>.value(
value: sl<VideoCallSessionCubit>(),
),
BlocProvider<NewMessagesCubit>(
lazy: true,
create: (context) => sl<NewMessagesCubit>(),
),
BlocProvider<NotificationsCubit>(
lazy: true,
create: (context) => sl<NotificationsCubit>(),
),
BlocProvider<FeedCubit>(lazy: true, create: (context) => sl<FeedCubit>()),
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<ViewsCubit>(lazy: true, create: (context) => sl<ViewsCubit>()),
BlocProvider<InternetConnectionCubit>(lazy: false, create: (context) => sl<InternetConnectionCubit>()),
BlocProvider<MarketingCubit>(lazy: true, create: (context) => sl<MarketingCubit>()),
BlocProvider<InternetConnectionCubit>(
lazy: false,
create: (context) => sl<InternetConnectionCubit>(),
),
BlocProvider<MarketingCubit>(
lazy: true,
create: (context) => sl<MarketingCubit>(),
),
];
/// Делегаты локализации
@@ -56,5 +90,7 @@ class AppConfig {
];
/// Поддерживаемые локали
List<Locale> get supportedLocales => [const Locale.fromSubtags(languageCode: 'ru')];
List<Locale> get supportedLocales => [
const Locale.fromSubtags(languageCode: 'ru'),
];
}

View File

@@ -1,15 +1,11 @@
import 'dart:async';
import 'dart:developer';
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/error_reporting/error_report_policy.dart';
import 'package:eatvio_app/core/locator_service.dart' as di;
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_fcm_handler.dart';
import 'package:eatvio_app/firebase_options.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_native_splash/flutter_native_splash.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
@@ -18,7 +14,6 @@ import 'package:sentry_flutter/sentry_flutter.dart';
/// Класс для инициализации приложения
class AppInitializer {
/// Инициализация всех сервисов приложения
static Future<void> initialize({required AppBuildType buildType}) async {
await _initializeEnvironment();
@@ -42,25 +37,21 @@ class AppInitializer {
/// Инициализация Firebase
static Future<void> _initializeFirebase() async {
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform).timeout(
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
).timeout(
const Duration(seconds: 10),
onTimeout: () {
throw TimeoutException('Firebase initialization timeout');
},
);
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);
}

View File

@@ -0,0 +1,16 @@
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-клиента.
void applyCachedBaseUrl() {
if (!sl.isRegistered<Prefs>() || !sl.isRegistered<AppConfig>()) {
return;
}
final cached = sl<Prefs>().getString(PrefsKeys.customBaseUrl)?.trim();
if (cached == null || cached.isEmpty) {
return;
}
sl<AppConfig>().setBaseUrl(cached);
}

View File

@@ -122,7 +122,7 @@ class CubitRegistrations {
// Content viewing cubits
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<BlogPostCubit>(() => BlogPostCubit(sl()));

View File

@@ -1,6 +1,7 @@
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:eatvio_app/core/push/hms_client_error.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
/// Rules for which errors are sent to crash reporting.
@@ -15,6 +16,7 @@ class ErrorReportPolicy {
if (error == null) return false;
if (error is String) return false;
if (error is SocketException) return false;
if (isHmsClientUnavailable(error)) return false;
if (error is DioException) {
return _shouldReportDio(error);

View File

@@ -3,59 +3,57 @@ import 'dart:developer';
import 'dart:io';
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/app_initializer.dart';
import 'package:eatvio_app/core/utils/toasts.dart';
import 'package:flutter/services.dart';
import 'package: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:dio/dio.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/token_storage.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/services.dart' show MethodChannel, PlatformException;
import 'package:flutter/services.dart';
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
class FirebaseApi {
final ErrorReporter _errorReporter;
final NotificationPermissionRequester _notificationPermissionRequester;
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 {
String? token;
final credentials = await _resolvePushCredentials();
return credentials.token;
}
if (await _isHuaweiDevice()) {
token = await _getHuaweiPushTokenNative().timeout(
const Duration(seconds: 5),
onTimeout: () => null,
);
} 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,
Future<void> _requestNotificationPermission() async {
if (sl<AppConfig>().buildType != AppBuildType.prod) return;
try {
await _notificationPermissionRequester.requestOnce(
() => _firebaseMessaging.requestPermission(),
);
} catch (e) {
log('Failed to request Firebase messaging permission: $e');
}
return token;
}
Future<void> registerAnonymousPushToken() async {
try {
await _requestNotificationPermission();
final deviceInfo = DeviceInfoPlugin();
final credentials = await _resolvePushCredentials();
final token = credentials.token;
if (token == null || token.isEmpty) return;
await sl<Dio>().post(
'/api/anonymous_token',
data: {
'platform': Platform.isIOS ? 'ios' : (await _isHuaweiDevice()) ? 'huawei' : 'android',
'platform': credentials.platform,
'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id,
'token': await getToken(),
'token': token,
},
).timeout(
const Duration(seconds: 10),
@@ -73,14 +71,17 @@ class FirebaseApi {
Future<void> registerPushToken() async {
if (await TokenStorage.haveTokenInStorage()) {
try {
await _requestNotificationPermission();
final deviceInfo = DeviceInfoPlugin();
final isHuawei = Platform.isAndroid ? await _isHuaweiDevice() : false;
final credentials = await _resolvePushCredentials();
final token = credentials.token;
if (token == null || token.isEmpty) return;
await sl<Dio>().post(
'/api/profile/notification/token/register',
data: {
'platform': Platform.isIOS ? 'ios' : (isHuawei ? 'huawei' : 'android'),
'platform': credentials.platform,
'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id,
'token': await getToken(),
'token': token,
},
).timeout(
const Duration(seconds: 10),
@@ -115,13 +116,13 @@ class FirebaseApi {
Future<void> unregisterPushToken() async {
if (!await TokenStorage.haveTokenInStorage()) return;
try {
final isHuawei = Platform.isAndroid ? await _isHuaweiDevice() : false;
final platform = Platform.isIOS ? 'ios' : (isHuawei ? 'huawei' : 'android');
final token = (await getToken()) ?? '';
final credentials = await _resolvePushCredentials();
final token = credentials.token ?? '';
if (token.isEmpty) return;
await sl<Dio>().post(
'/api/profile/notification/token/unregister',
data: {
'platform': platform,
'platform': credentials.platform,
'token': token,
},
).timeout(
@@ -181,30 +182,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 {
if (!Platform.isAndroid) return false;
try {
final info = await DeviceInfoPlugin().androidInfo;
final brand = info.brand.toLowerCase();
final manufacturer = info.manufacturer.toLowerCase();
return brand.contains('huawei') || brand.contains('honor') || manufacturer.contains('huawei') || manufacturer.contains('honor');
return isHuaweiFamilyBrand(brand: info.brand, manufacturer: info.manufacturer);
} catch (_) {
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 {
try {
const channel = MethodChannel('com.eatvioapp/hms_push');
final String? token = await channel.invokeMethod<String>('getHuaweiToken');
return token;
} on PlatformException catch (e, st) {
showErrorToast('$e $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;
} catch (e, st) {
showErrorToast('$e $st');
log(e.toString(), stackTrace: st);
await _errorReporter.report(e, st, origin: 'FirebaseApi');
return null;

View File

@@ -1,4 +1,5 @@
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/cubit_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>();
// До создания Dio и любых запросов к бэкенду.
applyCachedBaseUrl();
// Регистрация сервисов
ServiceRegistrations.registerAll();
@@ -32,5 +35,7 @@ Future<void> init({required AppBuildType buildType}) async {
CubitRegistrations.registerAll();
// Регистрация WebSocket менеджера
sl.registerLazySingleton<WsManager>(() => WsManager(wsService: sl(), errorReporter: sl()));
sl.registerLazySingleton<WsManager>(
() => WsManager(wsService: sl(), errorReporter: sl()),
);
}

View File

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

View File

@@ -65,7 +65,11 @@ class PushNavigationHandler {
'PushNavigationHandler: dismiss incoming call UI, source=$source',
name: 'PushNavigationHandler',
);
unawaited(VideoCallEndedPushHandler.dismissIncomingCallUi());
unawaited(
VideoCallEndedPushHandler.dismissIncomingCallUi(
conversationUuid: endedPayload.conversationUuid,
),
);
return;
}

View File

@@ -1 +1,8 @@
enum PrefsKeys { hasAnonymousTokenBeenSent, welcomeScreenWasShowed, registrationDate, marketingBottomSheetHasBeenShown, isEmailVerificationSkipped }
enum PrefsKeys {
hasAnonymousTokenBeenSent,
welcomeScreenWasShowed,
registrationDate,
marketingBottomSheetHasBeenShown,
isEmailVerificationSkipped,
customBaseUrl,
}

View 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';
}

View File

@@ -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_recipe/presentation/pages/recipe_create_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/glossary/presentation/pages/glossary_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);
},
),
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: MyPlansScreen.path, name: MyPlansScreen.name, builder: (context, state) => const MyPlansScreen()),
GoRoute(path: MyRecipesScreen.path, name: MyRecipesScreen.name, builder: (context, state) => const MyRecipesScreen()),
@@ -265,7 +273,15 @@ class OtherRoutes {
),
// Consultant routes
GoRoute(path: RouteConstants.becomeConsultantScreen, name: 'becomeConsultantScreen', builder: (context, state) => const BecomeConsultantScreen()),
GoRoute(
path: RouteConstants.becomeConsultantScreen,
name: 'becomeConsultantScreen',
builder: (context, state) {
final data = state.extra as Map<String, dynamic>?;
final slug = data?['slug'] as String?;
return BecomeConsultantScreen(slug: slug);
},
),
GoRoute(
path: RouteConstants.stepperBecomeConsultantScreen,
name: 'stepperBecomeConsultantScreen',
@@ -273,7 +289,12 @@ class OtherRoutes {
final data = state.extra as Map<String, dynamic>;
final slug = data['slug'] as String?;
final isCanceled = data['isCanceled'] as bool?;
return StepperBecomeConsultantScreen(slug: slug, isCanceled: isCanceled);
final isNewRegistration = data['isNewRegistration'] as bool? ?? false;
return StepperBecomeConsultantScreen(
slug: slug,
isCanceled: isCanceled,
isNewRegistration: isNewRegistration,
);
},
),
GoRoute(path: RouteConstants.calendarScreen, name: 'calendarScreen', builder: (context, state) => const CalendarScreen()),

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

View 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';
}

View File

@@ -38,15 +38,35 @@ class WsManager {
StreamSubscription? _eventSubscription;
StreamSubscription? _connectionEstablishedSubscription;
/// Широковещательный сигнал о восстановлении соединения после обрыва (S5).
///
/// Контроллер создаётся один раз и не закрывается в [clear]: подписчики
/// (чаты, окно чата) переживают логаут/повторный вход и продолжают получать
/// сигналы. Если контроллер всё же закрыт, он пересоздаётся в [initialize].
StreamController<void> _reconnectController = StreamController<void>.broadcast();
Stream<ChannelReadEvent>? _directChatStream;
Stream<ChannelReadEvent>? _clientTypingStream;
Stream<ChannelReadEvent>? get newMessagesStream => _privateMessagesChannel?.bind('App\\Events\\ChatMessageUpdated');
Stream<ChannelReadEvent>? get newNotificationsStream => _privateNotificationChannel?.bind('Illuminate\\Notifications\\Events\\BroadcastNotificationCreated');
Stream<ChannelReadEvent>? get directChatStream => _privateDirectChatChannel?.bind('App\\Events\\ChatMessageUpdated');
Stream<ChannelReadEvent>? get directChatStream => _directChatStream;
Stream<ChannelReadEvent>? get clientTypingStream => _privateDirectChatChannel?.bind('client-typing');
Stream<ChannelReadEvent>? get clientTypingStream => _clientTypingStream;
/// Сигнал о реконнекте сокета: эмитится после повторной подписки на каналы.
///
/// Подписчики используют его, чтобы догрузить пропущенные за время обрыва
/// сообщения (S5).
Stream<void> get reconnected => _reconnectController.stream;
Future<void> initialize() async {
if (_reconnectController.isClosed) {
_reconnectController = StreamController<void>.broadcast();
}
if (client == null) {
try {
if (!await TokenStorage.haveTokenInStorage()) return;
@@ -105,8 +125,7 @@ class WsManager {
_lifecycleSubscription = client?.lifecycleStream.listen((data) {
if (data == PusherChannelsClientLifeCycleState.establishedConnection) {
_unsubscribe();
_subscribe();
handleConnectionEstablished();
}
});
_eventSubscription = client?.eventStream.listen((event) {
@@ -170,6 +189,7 @@ class WsManager {
}
Future<void> initPrivateDirectChatChannel(String chatId) async {
_privateDirectChatChannel?.unsubscribe();
final token = await TokenStorage.getTokenFromStorage();
_privateDirectChatChannel = client?.privateChannel(
'$directChatChannelName.$chatId',
@@ -180,6 +200,8 @@ class WsManager {
},
),
);
_directChatStream = _privateDirectChatChannel?.bind('App\\Events\\ChatMessageUpdated');
_clientTypingStream = _privateDirectChatChannel?.bind('client-typing');
}
Future<void> initPresenceDirectChatChannel(String chatId) async {
@@ -293,12 +315,27 @@ class WsManager {
_privateNotificationChannel?.unsubscribe();
}
/// Повторно подписывается на каналы и сигналит подписчикам о реконнекте.
///
/// Вызывается при `establishedConnection` — в том числе после обрыва сети.
/// Публичный, чтобы сигнал [reconnected] покрывался тестами без реального
/// Pusher-клиента.
void handleConnectionEstablished() {
_unsubscribe();
_subscribe();
_privateDirectChatChannel?.subscribe();
_presenceDirectChatChannel?.subscribe();
_reconnectController.add(null);
}
void subscribeDirectChatChannel() {
_privateDirectChatChannel?.subscribe();
}
void unsubscribeDirectChatChannel() {
_privateDirectChatChannel?.unsubscribe();
_directChatStream = null;
_clientTypingStream = null;
}
void unsubscribePresenceDirectChatChannel() {
@@ -331,6 +368,8 @@ class WsManager {
_privateDirectChatChannel = null;
_privateMessagesChannel = null;
_privateNotificationChannel = null;
_directChatStream = null;
_clientTypingStream = null;
}
}

View File

@@ -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/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/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/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/presentation/bloc/diets_cubit/diets_cubit.dart';
import 'package:eatvio_app/features/feed/presentation/bloc/recipes_cubit/recipes_cubit.dart';
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_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/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:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:go_router/go_router.dart';
import 'package:share_plus/share_plus.dart';
class CustomPopup extends StatelessWidget {
final bool isAuthor;
final bool isAuthorAdmin;
final ComplainedContent? complainedContentType;
final String path;
final String? slug;
@@ -29,11 +41,14 @@ class CustomPopup extends StatelessWidget {
final FeedBlogPostModel? feedBlogPostModel;
final Recipe? recipe;
final Diet? diet;
final PlanModel? plan;
final String userSlug;
final VoidCallback? onPostsChanged;
const CustomPopup({
super.key,
required this.isAuthor,
this.isAuthorAdmin = false,
this.complainedContentType,
required this.path,
this.id,
@@ -41,9 +56,14 @@ class CustomPopup extends StatelessWidget {
this.feedBlogPostModel,
this.recipe,
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
Widget build(BuildContext 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>[];
void addItem(String icon, String text, Function() onTap) {
@@ -100,11 +216,9 @@ class CustomPopup extends StatelessWidget {
),
);
});
if (complainedContentType == ComplainedContent.posts) {
addItem('assets/icons/edit_paint.svg', 'Редактировать', () async {
if (complainedContentType == ComplainedContent.posts) {
GoRouter.of(context).pushNamed('blogPostCreateScreen', extra: feedBlogPostModel);
}
if (_showsEditAction()) {
addItem('assets/icons/edit_paint.svg', s.edit, () {
_openEditor(context);
});
}
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);
return;
}
showSuccessToast('Пост удален, обновите ленту');
onPostsChanged?.call();
ProfileCubit.myProfileCubit?.reloadBlogPosts();
if (context.mounted) {
try {
context.read<FeedCubit>().loadFeedBlogPostList();
} catch (_) {}
}
showSuccessToast('Пост удален');
} else if (complainedContentType == ComplainedContent.recipe) {
final result = await sl<RecipesCubit>().deleteRecipe(slug!);
if (!result && context.mounted) {
@@ -154,19 +275,21 @@ class CustomPopup extends StatelessWidget {
),
);
});
addItem('assets/icons/complain.svg', 'Пожаловаться', () {
if (complainedContentType != null) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => ComplainingBottomSheet(
complainedContentType: complainedContentType!,
id: id,
slug: slug,
),
);
}
});
if (!_isAdminAuthor) {
addItem('assets/icons/complain.svg', 'Пожаловаться', () {
if (complainedContentType != null) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => ComplainingBottomSheet(
complainedContentType: complainedContentType!,
id: id,
slug: slug,
),
);
}
});
}
addItem(
'assets/icons/complain.svg',
'Заблокировать автора',

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

View File

@@ -20,7 +20,7 @@ class SuccessPublishedDietOrPlanBottomSheet extends StatelessWidget {
if (isDrafted == true) {
text = 'cохранена в черновиках';
} else if (isUpdated == true) {
text = 'успешно обновлена';
text = isPlan ? 'успешно обновлён' : 'успешно обновлена';
} else if (isPlan == true) {
text = 'опубликован';
} else {

View File

@@ -2,6 +2,7 @@ import 'package:dismissible_page/dismissible_page.dart';
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/enums/complained_content.dart';
import 'package:eatvio_app/core/utils/date_time_utils.dart';
import 'package:eatvio_app/core/utils/url_utils.dart';
import 'package:eatvio_app/core/widgets/custom_popup.dart';
import 'package:eatvio_app/core/widgets/loader.dart';
import 'package:eatvio_app/core/widgets/markdown_html_widget.dart';
@@ -26,7 +27,11 @@ class BlogPostScreen extends StatefulWidget {
final int id;
final int? highlightedCommentId;
const BlogPostScreen({super.key, required this.id, this.highlightedCommentId});
const BlogPostScreen({
super.key,
required this.id,
this.highlightedCommentId,
});
@override
State<BlogPostScreen> createState() => _BlogPostScreenState();
@@ -44,8 +49,11 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
super.initState();
}
void _openGallery(List<String> imageUrls, int index) {
context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: imageUrls, index: index));
void _openGallery(List<String> imageUrls, int index, {Object? heroTag}) {
context.pushTransparentRoute(
rootNavigator: true,
PhotoViewScreen(imageUrls: imageUrls, index: index, heroTag: heroTag),
);
}
@override
@@ -53,7 +61,8 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
return BlocProvider(
create: (context) => sl<BlogPostCubit>()..getBlogPost(widget.id),
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>(
builder: (context, blogPostState) {
return BlocConsumer<CommentsCubit, CommentsState>(
@@ -81,15 +90,23 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
body: const Loader(),
);
}
final bool isLiked = blogPostState.blogPostModel?.wasLikedByMe ?? false;
final int totalLikes = blogPostState.blogPostModel?.totalLikes ?? 0;
final bool isLiked =
blogPostState.blogPostModel?.wasLikedByMe ?? false;
final int totalLikes =
blogPostState.blogPostModel?.totalLikes ?? 0;
return Scaffold(
resizeToAvoidBottomInset: true,
bottomSheet: Container(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom + 8),
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom + 8,
),
decoration: BoxDecoration(
color: Colors.white,
border: Border(top: BorderSide(color: Colors.black.withValues(alpha: 0.1))),
border: Border(
top: BorderSide(
color: Colors.black.withValues(alpha: 0.1),
),
),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
@@ -107,10 +124,17 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
FocusManager.instance.primaryFocus?.unfocus();
},
child: Container(
decoration: BoxDecoration(color: appTheme.blueA400, shape: BoxShape.circle),
decoration: BoxDecoration(
color: appTheme.blueA400,
shape: BoxShape.circle,
),
height: 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),
@@ -118,7 +142,8 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: 52),
child: TextFormField(
textCapitalization: TextCapitalization.sentences,
textCapitalization:
TextCapitalization.sentences,
onChanged: (_) {
setState(() {});
},
@@ -126,26 +151,48 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
maxLines: 5,
minLines: 1,
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(
isDense: false,
// убираем true, чтобы padding учитывался корректно
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
// вертикальный паддинг увеличен
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,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
borderSide: const BorderSide(
color: Color(0xFFD1D1D6),
width: 1,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
borderSide: const BorderSide(
color: Color(0xFFD1D1D6),
width: 1,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
borderSide: const BorderSide(
color: Color(0xFFD1D1D6),
width: 1,
),
),
),
),
@@ -163,7 +210,11 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
);
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);
setState(() {
@@ -174,10 +225,19 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
}
},
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,
width: 32,
child: const Icon(Icons.send_rounded, size: 18, color: Colors.white),
child: const Icon(
Icons.send_rounded,
size: 18,
color: Colors.white,
),
),
),
],
@@ -197,23 +257,36 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
),
actions: [
CustomPopup(
isAuthor: blogPostState.blogPostModel?.author.authorIsMe ?? false,
isAuthor:
blogPostState.blogPostModel?.author.authorIsMe ??
false,
isAuthorAdmin:
blogPostState.blogPostModel?.author.isAdmin ??
false,
complainedContentType: ComplainedContent.posts,
path: 'blog_post/${(widget.id)}',
id: widget.id,
feedBlogPostModel: blogPostState.blogPostModel,
userSlug: blogPostState.blogPostModel?.author.authorSlug ?? '',
userSlug:
blogPostState.blogPostModel?.author.authorSlug ??
'',
),
const SizedBox(width: 16),
],
),
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(),
child: Column(
children: [
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(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
@@ -222,21 +295,48 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
children: [
CircleAvatar(
radius: 16,
foregroundImage: NetworkImage(sl<AppConfig>().baseUrl + (blogPostState.blogPostModel?.author.authorProfileIcon ?? '')),
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
foregroundImage: NetworkImage(
joinBaseUrl(
sl<AppConfig>().baseUrl,
blogPostState
.blogPostModel
?.author
.authorProfileIcon ??
'',
),
),
backgroundImage: const AssetImage(
'assets/images/no_avatar.png',
),
),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
blogPostState.blogPostModel?.author.authorName ?? '',
style: const TextStyle(color: Colors.black, fontSize: 14, fontWeight: FontWeight.w400, height: 1),
blogPostState
.blogPostModel
?.author
.authorName ??
'',
style: const TextStyle(
color: Colors.black,
fontSize: 14,
fontWeight: FontWeight.w400,
height: 1,
),
),
const SizedBox(height: 2),
Text(
(blogPostState.blogPostModel?.createdAt ?? DateTime.now()).format('dd MMMM', 'ru'),
style: TextStyle(color: Colors.grey.shade600, fontSize: 12, fontWeight: FontWeight.w400, height: 1),
(blogPostState.blogPostModel?.createdAt ??
DateTime.now())
.format('dd MMMM', 'ru'),
style: TextStyle(
color: Colors.grey.shade600,
fontSize: 12,
fontWeight: FontWeight.w400,
height: 1,
),
),
],
),
@@ -244,10 +344,20 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
),
),
),
if (blogPostState.blogPostModel?.description.isNotEmpty ?? false)
if (blogPostState
.blogPostModel
?.description
.isNotEmpty ??
false)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: MarkdownHtmlWidget(markdownText: blogPostState.blogPostModel!.description),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: MarkdownHtmlWidget(
markdownText:
blogPostState.blogPostModel!.description,
),
),
const SizedBox(height: 8),
Builder(
@@ -256,15 +366,31 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
if (post == null) {
return const SizedBox.shrink();
}
final imageUrl = PostFeedMapper.resolveMediaUrl(post.image);
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,
videoEmbedUrl:
embedUrl != null && embedUrl.isNotEmpty
? embedUrl
: null,
videoUrlThumb: post.videoUrlThumb != null
? PostFeedMapper.resolveMediaUrl(
post.videoUrlThumb,
)
: null,
currentGalleryIndex: currentGalleryIndex,
onOpenGallery: _openGallery,
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;
@@ -274,22 +400,43 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
},
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: Row(
children: [
_iconButton(
icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
icon: isLiked
? Icons.favorite_rounded
: Icons.favorite_border_rounded,
text: totalLikes.toString(),
iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null,
textColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
iconColor: isLiked
? Colors.red.withValues(alpha: 0.8)
: null,
backgroundColor: isLiked
? Colors.red.withValues(alpha: 0.1)
: null,
textColor: isLiked
? Colors.red.withValues(alpha: 0.8)
: null,
onTap: () async {
await context.read<BlogPostCubit>().toggleLike();
await context
.read<BlogPostCubit>()
.toggleLike();
// ignore: use_build_context_synchronously
final updated = context.read<BlogPostCubit>().state.blogPostModel;
final updated = context
.read<BlogPostCubit>()
.state
.blogPostModel;
if (updated != null) {
// ignore: use_build_context_synchronously
context.read<FeedCubit>().updateFeedBlogPostList(updated, updated.wasLikedByMe);
context
.read<FeedCubit>()
.updateFeedBlogPostList(
updated,
updated.wasLikedByMe,
);
}
},
),
@@ -297,21 +444,36 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
_iconButton(
icon: Icons.reply_rounded,
onTap: () {
final box = context.findRenderObject() as RenderBox?;
final box =
context.findRenderObject() as RenderBox?;
SharePlus.instance.share(
ShareParams(
uri: Uri.parse('${sl<AppConfig>().baseUrl}/ru/blog_post/${widget.id}'),
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
uri: Uri.parse(
'${sl<AppConfig>().baseUrl}/ru/blog_post/${widget.id}',
),
sharePositionOrigin:
box!.localToGlobal(Offset.zero) &
box.size,
),
);
},
),
const Spacer(),
Icon(Icons.visibility_rounded, size: 16, color: appTheme.gray500),
Icon(
Icons.visibility_rounded,
size: 16,
color: appTheme.gray500,
),
const SizedBox(width: 4),
Text(
blogPostState.blogPostModel?.totalViews.toString() ?? '',
style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400),
blogPostState.blogPostModel?.totalViews
.toString() ??
'',
style: TextStyle(
fontSize: 12,
color: appTheme.gray500,
fontWeight: FontWeight.w400,
),
),
],
),
@@ -319,7 +481,10 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
Container(
width: MediaQuery.of(context).size.width - 32,
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),
Container(
@@ -327,7 +492,11 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
alignment: Alignment.centerLeft,
child: Text(
'${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),
@@ -352,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(
onTap: onTap ?? () {},
borderRadius: BorderRadius.circular(16),
child: Ink(
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,
child: Row(
mainAxisSize: MainAxisSize.min,
@@ -368,7 +547,10 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
if (text != null)
Text(
text,
style: TextStyle(color: textColor ?? appTheme.gray600, fontWeight: FontWeight.w500),
style: TextStyle(
color: textColor ?? appTheme.gray600,
fontWeight: FontWeight.w500,
),
),
],
),

View File

@@ -3,38 +3,195 @@ import 'dart:developer';
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/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_image_compress/flutter_image_compress.dart';
class BlogPostCreateCubit extends Cubit<BlogPostCreateState> {
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 {
emit(const BlogPostCreateState.loading());
await blogPostRepository.createBlogPost(title, videoUrl, description, image);
emit(const BlogPostCreateState.loaded());
emit(
state.copyWith(isLoading: true, isSuccess: false, errorMessage: null),
);
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;
} catch (error, stackTrace) {
log('BlogPostCreateCubit: $error', stackTrace: stackTrace);
await reportFrom(error, stackTrace, this);
emit(BlogPostCreateState.error(errorMessage: error.toString()));
emit(BlogPostCreateState(errorMessage: error.toString()));
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 {
emit(const BlogPostCreateState.loading());
await blogPostRepository.updateBlogPost(id, title, videoUrl, description, image, oldImageUrl);
emit(const BlogPostCreateState.loaded());
emit(
state.copyWith(isLoading: true, isSuccess: false, errorMessage: null),
);
await blogPostRepository.updateBlogPost(
id: id,
title: trimmedTitle,
videoUrl: trimmedVideoUrl,
description: trimmedDescription,
existingMediaIds: existingMediaIds,
newImages: newImages,
mediaIdsToDelete: mediaIdsToDelete,
);
emit(const BlogPostCreateState(isSuccess: true));
return true;
} catch (error, stackTrace) {
log('BlogPostCreateCubit: $error', stackTrace: stackTrace);
await reportFrom(error, stackTrace, this);
emit(BlogPostCreateState.error(errorMessage: error.toString()));
emit(BlogPostCreateState(errorMessage: error.toString()));
return false;
}
}

View File

@@ -4,13 +4,23 @@ part 'blog_post_create_state.freezed.dart';
@freezed
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;
const factory BlogPostCreateState.error({
required String errorMessage,
}) = BlogPostCreateStateError;
bool get hasValidationErrors =>
titleError != null ||
descriptionError != null ||
videoUrlError != null ||
imagesError != null ||
formError != null;
}

View File

@@ -1,79 +1,122 @@
import 'dart:io';
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/features/blog_post_create/services/blog_post_service.dart';
import 'package:flutter_image_compress/flutter_image_compress.dart';
import 'package:path_provider/path_provider.dart';
class BlogPostRepository {
static const maxGalleryImages = 10;
final BlogPostService blogPostService;
BlogPostRepository({
required this.blogPostService,
});
BlogPostRepository({required this.blogPostService});
Future<void> createBlogPost(
Future<void> createBlogPost({
String? title,
String? videoUrl,
String? description,
XFile? image,
) async {
final formData = FormData.fromMap({
if (title != null && title.isNotEmpty) 'title': title,
if (description != null && description.isNotEmpty) 'description_md': description,
if (videoUrl != null && videoUrl.isNotEmpty) 'video_url': videoUrl,
if (image != null) 'image': await MultipartFile.fromFile((await ImageCompressUtility.compressFile(File(image.path))).path, filename: image.name),
});
return blogPostService.createBlogPost(
formData: formData,
List<XFile> images = const [],
}) async {
final formData = await _buildFormData(
title: title,
videoUrl: videoUrl,
description: description,
newImages: images,
);
return blogPostService.createBlogPost(formData: formData);
}
Future<XFile?> downloadImage(String imageUrl) async {
final response = await http.get(Uri.parse('${sl<AppConfig>().baseUrl}/storage/$imageUrl'));
if (response.statusCode == 200) {
final directory = await getTemporaryDirectory();
final filePath = '${directory.path}/image.jpg';
final file = File(filePath);
await file.writeAsBytes(response.bodyBytes);
return XFile(file.path);
Future<void> updateBlogPost({
required int id,
String? title,
String? videoUrl,
String? description,
List<int> existingMediaIds = const [],
List<XFile> newImages = const [],
List<int> mediaIdsToDelete = const [],
}) async {
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(
int id,
String? title,
String? videoUrl,
String? description,
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> deleteBlogPostMedia({
required int postId,
required int mediaId,
}) async {
await blogPostService.deleteBlogPostMedia(postId: postId, mediaId: mediaId);
}
Future<void> deleteBlogPost(int id) async {
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;
}
}

View File

@@ -7,21 +7,23 @@ part 'blog_post_service.g.dart';
abstract class BlogPostService {
factory BlogPostService(Dio dio, {String baseUrl}) = _BlogPostService;
@POST('/api/blogPosts/create/published')
@POST('/api/v2/blogPosts/create/published')
@MultiPart()
Future<void> createBlogPost({
@Body() required FormData formData,
});
Future<void> createBlogPost({@Body() required FormData formData});
@POST('/api/blogPosts/update/published/{id}')
@PUT('/api/v2/blogPosts/update/published/{id}')
@MultiPart()
Future<void> updateBlogPost({
@Path('id') required int id,
@Body() required FormData formData,
});
@DELETE('/api/blogPosts/delete/{id}')
Future<void> deleteBlogPost({
@Path('id') required int id,
@DELETE('/api/v2/blogPosts/{postId}/media/{mediaId}')
Future<void> deleteBlogPostMedia({
@Path('postId') required int postId,
@Path('mediaId') required int mediaId,
});
@DELETE('/api/blogPosts/delete/{id}')
Future<void> deleteBlogPost({@Path('id') required int id});
}

View File

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

View File

@@ -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/locator_service.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/notifications/presentation/notifications_cubit/notifications_cubit.dart';
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_state.dart';
@@ -26,11 +27,13 @@ class BottomNavigationBarWidget extends StatefulWidget {
});
final Widget child;
/// Текущий путь из GoRouter (matchedLocation), чтобы подсветка таба совпадала с экраном при навигации через go() (диплинки, пуши).
final String currentLocation;
@override
State<BottomNavigationBarWidget> createState() => _BottomNavigationBarWidgetState();
State<BottomNavigationBarWidget> createState() =>
_BottomNavigationBarWidgetState();
}
class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
@@ -40,9 +43,24 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
List<String> _getPages(bool isConsultant) {
if (isConsultant) {
return ['/my-profile-screen', '/feed-screen', '/diary-screen', '/consultations-screen', '/chat-select-screen', '/notifications-screen', '/menu-screen'];
return [
'/my-profile-screen',
'/feed-screen',
'/diary-screen',
'/consultations-screen',
'/chat-select-screen',
'/notifications-screen',
'/menu-screen',
];
} else {
return ['/my-profile-screen', '/feed-screen', '/diary-screen', '/chat-select-screen', '/notifications-screen', '/menu-screen'];
return [
'/my-profile-screen',
'/feed-screen',
'/diary-screen',
'/chat-select-screen',
'/notifications-screen',
'/menu-screen',
];
}
}
@@ -98,7 +116,9 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
final logo = SvgPicture.asset(
ImageConstant.imgLogo,
height: 32,
colorFilter: isProduction ? null : const ColorFilter.mode(_devAccentColor, BlendMode.srcIn),
colorFilter: isProduction
? null
: const ColorFilter.mode(_devAccentColor, BlendMode.srcIn),
);
if (isProduction) return logo;
return Stack(
@@ -109,7 +129,10 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(color: _devAccentColor.withValues(alpha: 0.6), width: 1.5),
border: Border.all(
color: _devAccentColor.withValues(alpha: 0.6),
width: 1.5,
),
color: _devAccentColor.withValues(alpha: 0.08),
),
child: logo,
@@ -122,11 +145,23 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
decoration: BoxDecoration(
color: _devAccentColor,
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(
'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(), в т.ч. диплинки/пуши).
final pathWithoutQuery = widget.currentLocation.split('?').first;
final indexFromRoute = pages!.indexOf(pathWithoutQuery);
final effectiveIndex = indexFromRoute >= 0 ? indexFromRoute : currentBottomNavBarIndex;
if (indexFromRoute >= 0 && indexFromRoute != currentBottomNavBarIndex) {
final effectiveIndex = indexFromRoute >= 0
? indexFromRoute
: currentBottomNavBarIndex;
if (indexFromRoute >= 0 &&
indexFromRoute != currentBottomNavBarIndex) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() => currentBottomNavBarIndex = indexFromRoute);
if (mounted) {
setState(() => currentBottomNavBarIndex = indexFromRoute);
}
});
}
@@ -176,9 +216,14 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
label: '',
icon: CircleAvatar(
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,
),
),
@@ -186,22 +231,54 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
label: 'Лента',
icon: Padding(
padding: const EdgeInsets.only(bottom: 2),
child: SvgPicture.asset(colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn), width: 20, height: 20, ImageConstant.icnFeed),
child: SvgPicture.asset(
colorFilter: const ColorFilter.mode(
Colors.grey,
BlendMode.srcIn,
),
width: 20,
height: 20,
ImageConstant.icnFeed,
),
),
activeIcon: Padding(
padding: const EdgeInsets.only(bottom: 2),
child: SvgPicture.asset(ImageConstant.icnFeed, width: 20, height: 20, colorFilter: ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn)),
child: SvgPicture.asset(
ImageConstant.icnFeed,
width: 20,
height: 20,
colorFilter: ColorFilter.mode(
appTheme.blueA400,
BlendMode.srcIn,
),
),
),
),
BottomNavigationBarItem(
label: s.diary,
icon: Padding(
padding: const EdgeInsets.only(bottom: 2),
child: SvgPicture.asset(colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn), ImageConstant.icnDiary, width: 20, height: 20),
child: SvgPicture.asset(
colorFilter: const ColorFilter.mode(
Colors.grey,
BlendMode.srcIn,
),
ImageConstant.icnDiary,
width: 20,
height: 20,
),
),
activeIcon: Padding(
padding: const EdgeInsets.only(bottom: 2),
child: SvgPicture.asset(ImageConstant.icnDiary, width: 20, height: 20, colorFilter: ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn)),
child: SvgPicture.asset(
ImageConstant.icnDiary,
width: 20,
height: 20,
colorFilter: ColorFilter.mode(
appTheme.blueA400,
BlendMode.srcIn,
),
),
),
),
if (profileModel.roles.contains('consultant'))
@@ -210,7 +287,10 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
icon: Padding(
padding: const EdgeInsets.only(bottom: 2),
child: SvgPicture.asset(
colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn),
colorFilter: const ColorFilter.mode(
Colors.grey,
BlendMode.srcIn,
),
'assets/icons/consultations.svg',
width: 20,
height: 20,
@@ -222,7 +302,10 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
'assets/icons/consultations.svg',
width: 20,
height: 20,
colorFilter: ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn),
colorFilter: ColorFilter.mode(
appTheme.blueA400,
BlendMode.srcIn,
),
),
),
),
@@ -230,14 +313,26 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
label: s.chat,
icon: BlocBuilder<ChatsCubit, ChatsState>(
builder: (BuildContext context, ChatsState state) {
final hasUnreadMessages = state.chatsWithUnreadMessagesCount > 0;
final hasUnreadMessages =
state.chatsWithUnreadMessagesCount > 0;
return badges.Badge(
position: badges.BadgePosition.topEnd(),
badgeAnimation: const badges.BadgeAnimation.fade(),
badgeStyle: badges.BadgeStyle(badgeColor: hasUnreadMessages ? Colors.red : Colors.transparent),
badgeStyle: badges.BadgeStyle(
badgeColor: hasUnreadMessages
? Colors.red
: Colors.transparent,
),
badgeContent: Text(
hasUnreadMessages ? state.chatsWithUnreadMessagesCount.toString() : '',
style: TextStyle(color: hasUnreadMessages ? Colors.white : Colors.transparent, fontSize: 8),
hasUnreadMessages
? state.chatsWithUnreadMessagesCount.toString()
: '',
style: TextStyle(
color: hasUnreadMessages
? Colors.white
: Colors.transparent,
fontSize: 8,
),
),
child: Padding(
padding: const EdgeInsets.only(bottom: 2),
@@ -245,9 +340,16 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
ImageConstant.icnChat,
width: 20,
height: 20,
colorFilter: pages![effectiveIndex] == '/chat-select-screen'
? ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn)
: const ColorFilter.mode(Colors.grey, BlendMode.srcIn),
colorFilter:
pages![effectiveIndex] == '/chat-select-screen'
? 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(
position: badges.BadgePosition.topEnd(),
badgeAnimation: const badges.BadgeAnimation.fade(),
showBadge: state is NotificationsStateLoaded && state.hasUnreadNotifications,
badgeContent: const Text('', style: TextStyle(color: Colors.white, fontSize: 8)),
showBadge:
state is NotificationsStateLoaded &&
state.hasUnreadNotifications,
badgeContent: const Text(
'',
style: TextStyle(color: Colors.white, fontSize: 8),
),
child: Padding(
padding: const EdgeInsets.only(bottom: 2),
child: SvgPicture.asset(
ImageConstant.icnBell,
colorFilter: pages![effectiveIndex] == '/notifications-screen'
? ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn)
: const ColorFilter.mode(Colors.grey, BlendMode.srcIn),
colorFilter:
pages![effectiveIndex] ==
'/notifications-screen'
? ColorFilter.mode(
appTheme.blueA400,
BlendMode.srcIn,
)
: const ColorFilter.mode(
Colors.grey,
BlendMode.srcIn,
),
width: 20,
height: 20,
),

View File

@@ -31,6 +31,11 @@ abstract class ChatMessageModel with _$ChatMessageModel {
@JsonKey(name: 'author_id', fromJson: _parseAuthorId) required int authorId,
@JsonKey(name: 'updated_at', fromJson: _parseUpdatedAt) required DateTime updatedAt,
@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: 'room_id') required String? roomId,
@JsonKey(name: 'type', unknownEnumValue: ChatMessageType.unknown) ChatMessageType? type,

View File

@@ -31,6 +31,7 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
StreamSubscription<ChannelReadEvent>? typingSubscription;
StreamSubscription<ChannelReadEvent>? messagesSubscription;
StreamSubscription<void>? _reconnectSubscription;
int currentPage = 1;
late int lastPage;
bool isLoading = false;
@@ -39,11 +40,18 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
void Function(bool isOnline)? _presenceOnlineListener;
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() {
_timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 5), (_) => updateChosenPlanState());
_timer = Timer.periodic(
const Duration(seconds: 5),
(_) => updateChosenPlanState(),
);
}
void cancelTimer() {
@@ -54,16 +62,22 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
if (currentPage < lastPage && !isLoading) {
isLoading = true;
emit((state as ChatRoomStateLoaded).copyWith(isLoading: isLoading));
_emitLoaded((current) => current.copyWith(isLoading: isLoading));
try {
final chatMessages = await chatRepository.getChatMessages(uuid, currentPage + 1);
final chatMessages = await chatRepository.getChatMessages(
uuid,
currentPage + 1,
);
isLoading = false;
emit(
(state as ChatRoomStateLoaded).copyWith(
_emitLoaded(
(current) => current.copyWith(
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}) {
final currentState = state;
if (currentState is ChatRoomStateLoaded) {
if (cards.where((card) => card.isDefault).isNotEmpty) {
emit(currentState.copyWith(hasCard: true));
}
if (cards.where((card) => card.isDefault).isNotEmpty) {
_emitLoaded((current) => current.copyWith(hasCard: true));
}
}
@@ -100,7 +111,11 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
/// `GET /api/consultation/resolve/order_id_to_conversation/{orderId}`.
///
/// Возвращает 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 {
emit(const ChatRoomState.loading());
@@ -108,7 +123,9 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
int? resolvedChosenPlanId;
if (orderId != null && orderId.isNotEmpty) {
final resolved = await chatRepository.resolveOrderIdToConversation(orderId: orderId);
final resolved = await chatRepository.resolveOrderIdToConversation(
orderId: orderId,
);
resolvedUuid = resolved.conversationUuid;
resolvedChosenPlanId = resolved.chosenPlanId;
}
@@ -117,15 +134,23 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
throw ArgumentError('initChat requires uuid or orderId');
}
currentPage = 1;
isLoading = false;
_activeChatUuid = resolvedUuid;
final chatRoom = await chatRepository.getChatRoom(resolvedUuid);
ChosenPlanModel? chosenPlan;
final planId = resolvedChosenPlanId ?? chatRoom.chosenPlanId;
if (planId != null) {
chosenPlan = await chatRepository.getChosenPlan(id: planId);
}
final chatMessages = await chatRepository.getChatMessages(resolvedUuid, currentPage);
final chatMessages = await chatRepository.getChatMessages(
resolvedUuid,
currentPage,
);
lastPage = chatMessages.meta.lastPage;
await _subscribeToChatChannel(uuid: resolvedUuid, chatId: chatRoom.id);
_listenToReconnect();
emit(
ChatRoomState.loaded(
chatRoom: chatRoom.copyWith(chosenPlan: chosenPlan),
@@ -135,6 +160,7 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
hasCard: false,
),
);
await _subscribeToPresenceChannel(chatId: chatRoom.id);
if (chosenPlan?.consultantId == chatRoom.users.sender.id) {
await checkCards();
@@ -145,22 +171,106 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
log(e.toString());
typingSubscription?.cancel();
messagesSubscription?.cancel();
_reconnectSubscription?.cancel();
_reconnectSubscription = null;
wsManager.unsubscribeDirectChatChannel();
emit(const ChatRoomState.error(errorMessage: 'Ошибка получеия данных'));
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 {
try {
final currentState = state;
if (currentState is ChatRoomStateLoaded) {
final chosenPlanId = currentState.chatRoom.chosenPlanId;
if (chosenPlanId != null) {
final chosenPlan = await chatRepository.getChosenPlan(id: chosenPlanId);
emit(currentState.copyWith.chatRoom(chosenPlan: chosenPlan));
}
if (currentState is! ChatRoomStateLoaded) {
return;
}
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) {
await reportFrom(e, st, this);
}
@@ -172,9 +282,16 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
final planId = (state as ChatRoomStateLoaded).chatRoom.chosenPlanId;
if (planId == null) return false;
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);
emit((state as ChatRoomStateLoaded).copyWith.chatRoom(chosenPlan: chosenPlan));
emit(
(state as ChatRoomStateLoaded).copyWith.chatRoom(
chosenPlan: chosenPlan,
),
);
return true;
} catch (e, st) {
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 {
await chatRepository.cancelChosenPlan(id: planId);
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;
if (client == null) return;
_activeChatUuid = uuid;
await messagesSubscription?.cancel();
await typingSubscription?.cancel();
messagesSubscription = null;
typingSubscription = null;
await wsManager.initPrivateDirectChatChannel(chatId);
wsManager.subscribeDirectChatChannel();
await _subscribeToPresenceChannel(chatId: chatId);
messagesSubscription = wsManager.directChatStream?.listen((event) {
AudioPlayer().play((AssetSource('audio/recive_message.wav')));
final message = ChatMessageModel.fromJson(jsonDecode(event.data));
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());
});
messagesSubscription = wsManager.directChatStream?.listen(
_onDirectChatEvent,
onError: (Object e, StackTrace st) {
unawaited(reportFrom(e, st, this));
},
);
typingSubscription = wsManager.clientTypingStream?.listen((event) {
emit((state as ChatRoomStateLoaded).copyWith(isTyping: true, isLoading: isLoading));
_emitLoaded(
(current) => current.copyWith(isTyping: true, isLoading: isLoading),
);
EasyDebounce.debounce(
'client-typing',
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 {
final currentState = state;
if (currentState is! ChatRoomStateLoaded) return;
@@ -236,12 +447,15 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
await wsManager.initPresenceDirectChatChannel(chatId);
_presenceOnlineListener = (isOnline) {
unawaited(_handleCounterpartOnlineChanged(
counterpartField: counterpart.id == currentState.chatRoom.users.receiver.id
? _ChatCounterpartField.receiver
: _ChatCounterpartField.sender,
isOnline: isOnline,
));
unawaited(
_handleCounterpartOnlineChanged(
counterpartField:
counterpart.id == currentState.chatRoom.users.receiver.id
? _ChatCounterpartField.receiver
: _ChatCounterpartField.sender,
isOnline: isOnline,
),
);
};
wsManager.addPresenceDirectChatListener(
counterpartUserId: counterpart.id,
@@ -260,7 +474,8 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
? currentState.chatRoom.users.receiver
: currentState.chatRoom.users.sender;
if (counterpart.isOnline == isOnline && (isOnline || counterpart.lastSeenText.isNotEmpty)) {
if (counterpart.isOnline == isOnline &&
(isOnline || counterpart.lastSeenText.isNotEmpty)) {
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 {
final messageUuid = uuid.v4();
final compressedFiles = files != null ? await ImageCompressUtility.compressFilesList(List<File>.from(files)) : null;
AudioPlayer().play((AssetSource('audio/send_message.wav')));
final compressedFiles = files != null
? await ImageCompressUtility.compressFilesList(List<File>.from(files))
: null;
try {
unawaited(AudioPlayer().play(AssetSource('audio/send_message.wav')));
} catch (_) {}
// Создаем временное сообщение с состоянием loading
emit(
(state as ChatRoomStateLoaded).copyWith(
_emitLoaded(
(current) => current.copyWith(
chatMessages: [
ChatMessageModel(
id: 1,
id: -messageUuid.hashCode.abs(),
data: data ?? '',
mediaData: compressedFiles,
authorId: 1,
authorId: wsManager.userId ?? 0,
updatedAt: DateTime.now(),
isRead: true,
isCurrentUserAuthor: true,
roomId: '',
roomId: current.chatRoom.id,
messageState: MessageState.loading,
uuid: messageUuid,
),
...(state as ChatRoomStateLoaded).chatMessages,
...current.chatMessages,
],
),
);
try {
// Отправляем сообщение и получаем ответ от сервера с правильными данными
final serverMessage = await chatRepository.sendMessage(data: data, files: compressedFiles, slug: userSlug);
// Заменяем локальное сообщение данными с сервера (с правильными URL файлов)
emit(
(state as ChatRoomStateLoaded).copyWith(
chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) {
if (elem.uuid == messageUuid) {
// Сохраняем UUID, состояние и обновляем данные из ответа сервера
// Используем значения из локального сообщения для полей, которые могут отсутствовать в ответе
return serverMessage.copyWith(
uuid: messageUuid,
messageState: MessageState.sent,
isCurrentUserAuthor: true,
isRead: elem.isRead,
roomId: elem.roomId ?? serverMessage.roomId,
authorId: serverMessage.authorId,
updatedAt: serverMessage.updatedAt,
);
}
return elem;
}).toList(),
),
final serverMessage = await chatRepository.sendMessage(
data: data,
files: compressedFiles,
slug: userSlug,
);
_replaceOptimisticMessage(
messageUuid: messageUuid,
serverMessage: serverMessage,
);
} catch (e, st) {
await reportFrom(e, st, this);
emit(
(state as ChatRoomStateLoaded).copyWith(
chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) {
_emitLoaded(
(current) => current.copyWith(
chatMessages: current.chatMessages.map((elem) {
if (elem.uuid == messageUuid) {
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 {
if (state is ChatRoomStateLoaded) {
final ids = messagesIds.map((e) => e.id).toList();
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;
if (state is! ChatRoomStateLoaded) {
return false;
}
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) {
await reportFrom(e, st, this);
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 {
try {
final planId = (state as ChatRoomStateLoaded).chatRoom.chosenPlan?.id;
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;
} catch (e, st) {
await reportFrom(e, st, this);
@@ -477,91 +729,93 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
@override
Future<void> close() async {
EasyDebounce.cancel('client-typing');
EasyDebounce.cancel('searchChatRoom');
if (_presenceOnlineListener != null) {
wsManager.removePresenceDirectChatListener(_presenceOnlineListener!);
_presenceOnlineListener = null;
}
wsManager.unsubscribeDirectChatChannel();
_timer?.cancel();
messagesSubscription?.cancel();
typingSubscription?.cancel();
await messagesSubscription?.cancel();
await typingSubscription?.cancel();
await _reconnectSubscription?.cancel();
super.close();
}
void deleteMessage(String uuid) {
final currentState = state;
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;
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) {
try {
AudioPlayer().play((AssetSource('audio/send_message.wav')));
try {
unawaited(
AudioPlayer().play(AssetSource('audio/send_message.wav')),
);
} catch (_) {}
// Определяем, есть ли файлы для повторной отправки
List<File>? filesToResend;
if (message.mediaData != null && message.mediaData!.isNotEmpty) {
// Если mediaData содержит File объекты, используем их
// Если это строки (URL), то файлы уже на сервере и повторная отправка не нужна
final firstItem = message.mediaData!.first;
if (firstItem is File) {
filesToResend = message.mediaData!.cast<File>();
}
}
emit(
(state as ChatRoomStateLoaded).copyWith(
_emitLoaded(
(current) => current.copyWith(
chatMessages: [
ChatMessageModel(
id: 1,
id: -uuid.hashCode.abs(),
data: message.data,
mediaData: message.mediaData,
authorId: 1,
authorId: wsManager.userId ?? 0,
updatedAt: DateTime.now(),
isRead: true,
isCurrentUserAuthor: true,
roomId: '',
roomId: current.chatRoom.id,
messageState: MessageState.loading,
uuid: uuid,
),
...(state as ChatRoomStateLoaded).chatMessages.where((message) => message.uuid != uuid),
...current.chatMessages.where((item) => item.uuid != uuid),
],
),
);
try {
// Отправляем сообщение и получаем ответ от сервера
final serverMessage = await chatRepository.sendMessage(data: message.data, files: filesToResend, slug: userSlug);
// Заменяем локальное сообщение данными с сервера
emit(
(state as ChatRoomStateLoaded).copyWith(
chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) {
if (elem.uuid == uuid) {
return serverMessage.copyWith(
uuid: uuid,
messageState: MessageState.sent,
isCurrentUserAuthor: true,
isRead: elem.isRead,
roomId: elem.roomId ?? serverMessage.roomId,
authorId: serverMessage.authorId,
updatedAt: serverMessage.updatedAt,
);
}
return elem;
}).toList(),
),
final serverMessage = await chatRepository.sendMessage(
data: message.data,
files: filesToResend,
slug: userSlug,
);
_replaceOptimisticMessage(
messageUuid: uuid,
serverMessage: serverMessage,
);
} catch (e, st) {
await reportFrom(e, st, this);
emit(
(state as ChatRoomStateLoaded).copyWith(
chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) {
_emitLoaded(
(current) => current.copyWith(
chatMessages: current.chatMessages.map((elem) {
if (elem.uuid == uuid) {
return elem.copyWith(messageState: MessageState.error);
}

View File

@@ -5,9 +5,14 @@ import 'dart:developer';
import 'package:eatvio_app/core/enums/base_state.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_room_pagination_model.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/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_ended_push_handler.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:eatvio_app/core/error_reporting/report_from.dart';
@@ -17,13 +22,23 @@ class ChatsCubit extends Cubit<ChatsState> {
final WsManager wsManager;
StreamSubscription? newIncomingMessagesSubscription;
StreamSubscription? newLastMessageSubscription;
StreamSubscription<void>? reconnectedSubscription;
final Set<int> _presentedVideoCallMessageIds = {};
/// Test seam. Production uses CallKit.
Future<void> Function(VideoCallPushPayload payload)? presentIncomingVideoCall;
Future<void> Function(String? conversationUuid)? dismissIncomingVideoCall;
ChatsCubit({
required this.chatRepository,
required this.wsManager,
required this.profileRepository,
this.presentIncomingVideoCall,
this.dismissIncomingVideoCall,
}) : super(ChatsState()) {
newLastMessageSubscription = chatRepository.newUserMessagesStream.listen((message) {
newLastMessageSubscription = chatRepository.newUserMessagesStream.listen((
message,
) {
_setNewLastMessage(message);
});
}
@@ -34,9 +49,16 @@ class ChatsCubit extends Cubit<ChatsState> {
Future<void> connectToWebSocket() async {
try {
await newIncomingMessagesSubscription?.cancel();
newIncomingMessagesSubscription = wsManager.newMessagesStream?.listen((event) {
newIncomingMessagesSubscription = wsManager.newMessagesStream?.listen((
event,
) {
_handleNewMessageViaSocket(event.data);
});
await reconnectedSubscription?.cancel();
reconnectedSubscription = wsManager.reconnected.listen((_) {
unawaited(getChatRooms());
});
} catch (e, st) {
await reportFrom(e, st, this);
}
@@ -47,7 +69,9 @@ class ChatsCubit extends Cubit<ChatsState> {
_query = query;
emit(state.copyWith(state: BaseState.loading));
try {
final chatRoomsPaginationModel = await chatRepository.getChatRooms(query: query);
final chatRoomsPaginationModel = await chatRepository.getChatRooms(
query: query,
);
final unreadChats = chatRoomsPaginationModel.unreadChatsCount;
lastPage = chatRoomsPaginationModel.meta.lastPage + 1;
emit(
@@ -60,9 +84,7 @@ class ChatsCubit extends Cubit<ChatsState> {
page++;
} catch (e, st) {
await reportFrom(e, st, this);
emit(
state.copyWith(errorMessage: e.toString(), state: BaseState.error),
);
emit(state.copyWith(errorMessage: e.toString(), state: BaseState.error));
}
}
@@ -70,7 +92,9 @@ class ChatsCubit extends Cubit<ChatsState> {
page = 1;
_query = query;
try {
final chatRoomsPaginationModel = await chatRepository.getChatRooms(query: query);
final chatRoomsPaginationModel = await chatRepository.getChatRooms(
query: query,
);
final unreadChats = chatRoomsPaginationModel.unreadChatsCount;
lastPage = chatRoomsPaginationModel.meta.lastPage + 1;
emit(
@@ -84,9 +108,7 @@ class ChatsCubit extends Cubit<ChatsState> {
} catch (e, st) {
await reportFrom(e, st, this);
log(e.toString(), stackTrace: st);
emit(
state.copyWith(errorMessage: e.toString(), state: BaseState.error),
);
emit(state.copyWith(errorMessage: e.toString(), state: BaseState.error));
}
}
@@ -94,7 +116,10 @@ class ChatsCubit extends Cubit<ChatsState> {
try {
if (state.isLoadingMoreChats || lastPage == page) return;
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;
emit(
state.copyWith(
@@ -134,15 +159,20 @@ class ChatsCubit extends Cubit<ChatsState> {
emit(
state.copyWith(
chatRooms: updatedChatRooms,
chatsWithUnreadMessagesCount: isChatRead ? state.chatsWithUnreadMessagesCount - 1 : state.chatsWithUnreadMessagesCount,
chatsWithUnreadMessagesCount: isChatRead
? state.chatsWithUnreadMessagesCount - 1
: state.chatsWithUnreadMessagesCount,
),
);
}
Future<void> _handleNewMessageViaSocket(dynamic data) async {
try {
final json = jsonDecode(data);
final message = ChatMessageModel.fromJson(json['message']);
final json = jsonDecode(data) as Map<String, dynamic>;
final message = ChatMessageModel.fromJson(json);
final currentUserId = wsManager.userId;
final isOwn = currentUserId != null && message.authorId == currentUserId;
final currentChatRooms = [...state.chatRooms];
@@ -151,10 +181,21 @@ class ChatsCubit extends Cubit<ChatsState> {
final updatedChatRooms = currentChatRooms.map((chatRoom) {
if (chatRoom.id == message.roomId) {
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;
}
return chatRoom.copyWith(lastMessage: message, unreadMessagesCount: chatRoom.unreadMessagesCount + 1);
return chatRoom.copyWith(
lastMessage: message,
unreadMessagesCount: isOwn || isDuplicate
? chatRoom.unreadMessagesCount
: chatRoom.unreadMessagesCount + 1,
);
}
return chatRoom;
}).toList();
@@ -165,15 +206,92 @@ class ChatsCubit extends Cubit<ChatsState> {
emit(
state.copyWith(
chatRooms: updatedChatRooms,
chatsWithUnreadMessagesCount: willIncrementUnreadChats ? state.chatsWithUnreadMessagesCount + 1 : state.chatsWithUnreadMessagesCount,
chatsWithUnreadMessagesCount: willIncrementUnreadChats
? state.chatsWithUnreadMessagesCount + 1
: state.chatsWithUnreadMessagesCount,
),
);
}
_presentVideoCallFromMessage(
message,
isOwn: isOwn,
chatRooms: currentChatRooms,
);
} catch (e, st) {
await reportFrom(e, st, this);
}
}
void _presentVideoCallFromMessage(
ChatMessageModel message, {
required bool isOwn,
required List<ChatRoom> chatRooms,
}) {
final kind = VideoCallChatSignal.kindOf(message);
if (kind == VideoCallChatSignalKind.ended) {
unawaited(_dismissIncomingVideoCallUi(message.roomId));
unawaited(
VideoCallEndedPushHandler.endActiveCallIfMatches(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,
);
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> _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,
);
}
}
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) {
final chats = state.chatRooms.map((chat) {
if (chat.id == message.roomId) {
@@ -188,6 +306,7 @@ class ChatsCubit extends Cubit<ChatsState> {
@override
Future<void> close() {
newIncomingMessagesSubscription?.cancel();
reconnectedSubscription?.cancel();
return super.close();
}
}

View File

@@ -46,7 +46,12 @@ class ChatScreen extends StatefulWidget {
final String? orderId;
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
State<ChatScreen> createState() => _ChatScreenState();
@@ -56,9 +61,9 @@ class _ChatScreenState extends State<ChatScreen> {
final scrollController = ScrollController();
final TextEditingController textEditingController = TextEditingController();
final ChatRoomCubit chatRoomCubit = sl<ChatRoomCubit>();
final List<({int id, int index})> unreadMessagesIds = [];
final List<({int id, int index})> unreadMessagesIdsCopy = [];
final showableChatBadgeStates = ChosenPlanStateEnum.getShowableChatBadgeStates();
final List<int> unreadMessageIds = [];
final showableChatBadgeStates =
ChosenPlanStateEnum.getShowableChatBadgeStates();
late AppLifecycleListener _listener;
List<File> pickedFiles = [];
@@ -109,13 +114,15 @@ class _ChatScreenState extends State<ChatScreen> {
void _onScroll() {
final uuid = conversationUuid;
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);
}
}
@override
void dispose() {
EasyDebounce.cancel('mark_as_read');
scrollController.dispose();
_listener.dispose();
chatRoomCubit.close();
@@ -168,9 +175,13 @@ class _ChatScreenState extends State<ChatScreen> {
}
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 amIConsultant = chosenPlan?.consultantId == state.chatRoom.users.sender.id;
final amIConsultant =
chosenPlan?.consultantId == state.chatRoom.users.sender.id;
final showUserDetailsButton = [
ChosenPlanStateEnum.stateStartDateHasBeenSet,
ChosenPlanStateEnum.statePendingPayment,
@@ -186,22 +197,37 @@ class _ChatScreenState extends State<ChatScreen> {
}
final showAboutOrderButton =
state.chatRoom.chosenPlan != null && state.chatRoom.chosenPlan!.chosenPlanState != ChosenPlanStateEnum.stateCanceledByTheInitiator;
state.chatRoom.chosenPlan != null &&
state.chatRoom.chosenPlan!.chosenPlanState !=
ChosenPlanStateEnum.stateCanceledByTheInitiator;
BannerType? bannerType;
if (chosenPlan == null || chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateCanceledByTheInitiator) {
if (chosenPlan == null ||
chosenPlan.chosenPlanState ==
ChosenPlanStateEnum.stateCanceledByTheInitiator) {
bannerType = null;
} else if (amIConsultant && !state.hasCard && chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateInCoordination) {
} else if (amIConsultant &&
!state.hasCard &&
chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateInCoordination) {
bannerType = BannerType.card;
} else if (amIConsultant && chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateInCoordination) {
} else if (amIConsultant &&
chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateInCoordination) {
bannerType = BannerType.date;
} else if (!amIConsultant &&
(chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateStartDateHasBeenSet || chosenPlan.chosenPlanState == ChosenPlanStateEnum.statePendingPayment)) {
(chosenPlan.chosenPlanState ==
ChosenPlanStateEnum.stateStartDateHasBeenSet ||
chosenPlan.chosenPlanState ==
ChosenPlanStateEnum.statePendingPayment)) {
bannerType = BannerType.payment;
} else if (chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateDone && amIConsultant) {
} else if (chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateDone &&
amIConsultant) {
bannerType = BannerType.collectCharge;
} else if (chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateLastDayFinished && !amIConsultant) {
} else if (chosenPlan.chosenPlanState ==
ChosenPlanStateEnum.stateLastDayFinished &&
!amIConsultant) {
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;
}
@@ -223,25 +249,40 @@ class _ChatScreenState extends State<ChatScreen> {
title: GestureDetector(
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(
children: [
if (state.chatRoom.users.receiver.profilePhotoUrl != null)
CircleAvatar(
radius: 20,
foregroundImage: NetworkImage(state.chatRoom.users.receiver.profilePhotoUrl!),
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
foregroundImage: NetworkImage(
state.chatRoom.users.receiver.profilePhotoUrl!,
),
backgroundImage: const AssetImage(
'assets/images/no_avatar.png',
),
),
if (state.chatRoom.users.receiver.profilePhotoUrl == null)
Container(
width: 40,
height: 40,
alignment: Alignment.center,
decoration: const BoxDecoration(color: Color(0xFFEBF4FF), shape: BoxShape.circle),
decoration: const BoxDecoration(
color: Color(0xFFEBF4FF),
shape: BoxShape.circle,
),
child: Text(
state.chatRoom.users.receiver.name.split(' ').first[0] + state.chatRoom.users.receiver.name.split(' ')[1][0],
style: const TextStyle(color: Color(0xFF7F9CF5), fontWeight: FontWeight.w400, fontSize: 20),
state.chatRoom.users.receiver.name.split(' ').first[0] +
state.chatRoom.users.receiver.name.split(' ')[1][0],
style: const TextStyle(
color: Color(0xFF7F9CF5),
fontWeight: FontWeight.w400,
fontSize: 20,
),
),
),
const SizedBox(width: 16),
@@ -253,10 +294,17 @@ class _ChatScreenState extends State<ChatScreen> {
state.chatRoom.users.receiver.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: CustomTextStyles.titleSmallBold.copyWith(color: PrimaryColors().black),
style: CustomTextStyles.titleSmallBold.copyWith(
color: PrimaryColors().black,
),
),
AnimatedCrossFade(
firstChild: Text('Печатает...', maxLines: 1, overflow: TextOverflow.ellipsis, style: CustomTextStyles.bodySmallMulishPrimary),
firstChild: Text(
'Печатает...',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: CustomTextStyles.bodySmallMulishPrimary,
),
firstCurve: Curves.easeIn,
secondCurve: Curves.easeOut,
secondChild: ChatUserOnlineStatusLine(
@@ -265,7 +313,9 @@ class _ChatScreenState extends State<ChatScreen> {
style: CustomTextStyles.bodySmallMulishPrimary,
showOnlineDot: false,
),
crossFadeState: state.isTyping ? CrossFadeState.showFirst : CrossFadeState.showSecond,
crossFadeState: state.isTyping
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
duration: const Duration(milliseconds: 150),
),
],
@@ -277,7 +327,9 @@ class _ChatScreenState extends State<ChatScreen> {
actions: [
Padding(
padding: const EdgeInsetsDirectional.only(end: 8),
child: _ChatHeaderVideoCallButton(onPressed: () => _openVideoCall(context, state, chatCounterpart)),
child: _ChatHeaderVideoCallButton(
onPressed: () => _openVideoCall(context, state, chatCounterpart),
),
),
],
),
@@ -291,44 +343,133 @@ class _ChatScreenState extends State<ChatScreen> {
child: Container(
color: appTheme.whiteA700,
width: double.maxFinite,
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
controller: scrollController,
reverse: true,
child: Column(
children: [
const SizedBox(height: 16),
ListView.builder(
physics: const NeverScrollableScrollPhysics(),
reverse: true,
shrinkWrap: true,
itemBuilder: (context, index) {
if (index == state.chatMessages.length) {
return const Padding(padding: EdgeInsets.all(8), child: Loader());
}
child: RefreshIndicator(
onRefresh: chatRoomCubit.refreshChat,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(
parent: BouncingScrollPhysics(),
),
controller: scrollController,
reverse: true,
child: Column(
children: [
const SizedBox(height: 16),
ListView.builder(
physics: const NeverScrollableScrollPhysics(),
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 &&
state.chatMessages[index].updatedAt.day != state.chatMessages[index + 1].updatedAt.day) ||
index + 1 == state.chatMessages.length) {
final isCurrentUserAuthor = state.chatRoom.users.receiver.id != state.chatMessages[index].authorId;
if ((index + 1 != state.chatMessages.length &&
state
.chatMessages[index]
.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 isPlanOrderMessage = message.type == ChatMessageType.planOrder;
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;
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)
if (isPlanOrderMessage &&
chosenPlan != null &&
messageOption != null)
PlanOrderMessageWidget(
planName: chosenPlan.plan.name,
planSlug: chosenPlan.plan.slug,
@@ -337,126 +478,94 @@ class _ChatScreenState extends State<ChatScreen> {
isCurrentUser: isCurrentUserAuthor,
),
VisibilityDetector(
key: Key(message.id.toString()),
onVisibilityChanged: (VisibilityInfo info) {
if (!message.isRead && !isCurrentUserAuthor && info.visibleFraction > 0) {
unreadMessagesIdsCopy.add((id: message.id, index: index));
EasyDebounce.debounce('mark_as_read', const Duration(milliseconds: 500), () async {
final result = await context.read<ChatRoomCubit>().markMessagesAsRead(state.chatRoom.id, unreadMessagesIdsCopy);
if (!result) return;
if (!context.mounted) return;
context.read<ChatsCubit>().reduceUnreadMessagesCount(
key: ValueKey(
'msg-${message.uuid ?? message.id}',
),
onVisibilityChanged:
(VisibilityInfo info) {
_queueMarkAsRead(
context: context,
chatId: state.chatRoom.id,
count: unreadMessagesIdsCopy.length,
message: message,
isCurrentUserAuthor:
isCurrentUserAuthor,
visibleFraction:
info.visibleFraction,
);
unreadMessagesIdsCopy.clear();
});
}
},
},
child: _buildMessageBubble(
context: context,
state: state,
chatCounterpart: chatCounterpart,
isCurrentUserAuthor: isCurrentUserAuthor,
isCurrentUserAuthor:
isCurrentUserAuthor,
message: message,
amIConsultant: amIConsultant,
),
),
],
);
}
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: [
if (isPlanOrderMessage && chosenPlan != null && messageOption != null)
PlanOrderMessageWidget(
planName: chosenPlan.plan.name,
planSlug: chosenPlan.plan.slug,
price: messageOption.price,
sendTime: message.updatedAt,
isCurrentUser: isCurrentUserAuthor,
},
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: const EdgeInsets.symmetric(
vertical: 12,
horizontal: 16,
),
child: CustomOutlinedButton(
text: S.current.user_info,
height: 52,
buttonTextStyle: TextStyles.textMSemibold
.copyWith(color: appTheme.whiteA700),
buttonStyle: ButtonStyle(
side: const WidgetStatePropertyAll(
BorderSide.none,
),
backgroundColor: WidgetStatePropertyAll(
appTheme.blueA400,
),
),
VisibilityDetector(
key: Key(message.id.toString()),
onVisibilityChanged: (VisibilityInfo info) {
if (!message.isRead && !isCurrentUserAuthor && info.visibleFraction > 0) {
unreadMessagesIds.add((id: message.id, index: index));
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();
});
}
onPressed: () async {
GoRouter.of(context).push(
UserDetailsScreen.path,
extra:
state.chatRoom.users.receiver.slug,
);
},
child: _buildMessageBubble(
context: context,
state: state,
chatCounterpart: chatCounterpart,
isCurrentUserAuthor: isCurrentUserAuthor,
message: message,
amIConsultant: amIConsultant,
),
),
],
);
},
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)
),
],
if (bannerType != null)
Padding(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
child: CustomOutlinedButton(
text: S.current.user_info,
height: 52,
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
buttonStyle: ButtonStyle(
side: const WidgetStatePropertyAll(BorderSide.none),
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
),
onPressed: () async {
GoRouter.of(context).push(UserDetailsScreen.path, extra: state.chatRoom.users.receiver.slug);
},
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,
),
),
],
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 +590,40 @@ class _ChatScreenState extends State<ChatScreen> {
_isImageFile(pickedFiles[i].path)
? GestureDetector(
onTap: () {
final imageFiles = pickedFiles.where((f) => _isImageFile(f.path)).toList();
final imageIndex = _imageIndexAt(pickedFiles, i);
final imageFiles = pickedFiles
.where(
(f) => _isImageFile(f.path),
)
.toList();
final imageIndex = _imageIndexAt(
pickedFiles,
i,
);
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(
tag: pickedFiles[i].path,
transitionOnUserGestures: true,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: SizedBox(height: 104, width: 104, child: Image.file(pickedFiles[i], fit: BoxFit.cover)),
borderRadius:
BorderRadius.circular(8),
child: SizedBox(
height: 104,
width: 104,
child: Image.file(
pickedFiles[i],
fit: BoxFit.cover,
),
),
),
),
)
@@ -510,8 +640,15 @@ class _ChatScreenState extends State<ChatScreen> {
},
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(color: appTheme.gray50, shape: BoxShape.circle),
child: const Icon(size: 20, Icons.close, color: Colors.black),
decoration: BoxDecoration(
color: appTheme.gray50,
shape: BoxShape.circle,
),
child: const Icon(
size: 20,
Icons.close,
color: Colors.black,
),
),
),
),
@@ -528,14 +665,20 @@ class _ChatScreenState extends State<ChatScreen> {
children: [
const SizedBox(width: 8),
InkWell(
onTap: () => showImagePicker(state.chatRoom.users.receiver.slug),
onTap: () => showImagePicker(
state.chatRoom.users.receiver.slug,
),
customBorder: const CircleBorder(),
child: Ink(
height: 40,
width: 40,
child: Transform.rotate(
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 +690,46 @@ class _ChatScreenState extends State<ChatScreen> {
maxLines: 3,
minLines: 1,
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(
isDense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
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,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(100),
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
borderSide: const BorderSide(
color: Color(0xFFD1D1D6),
width: 1,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(100),
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
borderSide: const BorderSide(
color: Color(0xFFD1D1D6),
width: 1,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(100),
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
borderSide: const BorderSide(
color: Color(0xFFD1D1D6),
width: 1,
),
),
),
),
@@ -579,12 +744,18 @@ class _ChatScreenState extends State<ChatScreen> {
final blockedState = blockedUsersCubit.state;
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 (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Вы не можете отправить сообщение заблокированному пользователю'),
content: Text(
'Вы не можете отправить сообщение заблокированному пользователю',
),
backgroundColor: Colors.red,
),
);
@@ -593,10 +764,15 @@ class _ChatScreenState extends State<ChatScreen> {
}
}
if (textEditingController.text.isNotEmpty || pickedFiles.isNotEmpty) {
if (textEditingController.text.isNotEmpty ||
pickedFiles.isNotEmpty) {
chatRoomCubit.sendMessage(
data: textEditingController.text.isEmpty ? null : textEditingController.text.trim(),
files: pickedFiles.isEmpty ? null : pickedFiles,
data: textEditingController.text.isEmpty
? null
: textEditingController.text.trim(),
files: pickedFiles.isEmpty
? null
: pickedFiles,
userSlug: state.chatRoom.users.receiver.slug,
);
setState(() {
@@ -606,10 +782,17 @@ class _ChatScreenState extends State<ChatScreen> {
}
},
child: Ink(
decoration: BoxDecoration(color: appTheme.blueA400, shape: BoxShape.circle),
decoration: BoxDecoration(
color: appTheme.blueA400,
shape: BoxShape.circle,
),
height: 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),
@@ -628,12 +811,19 @@ class _ChatScreenState extends State<ChatScreen> {
child: AboutOrderButton(
onTap: () async {
if (amIConsultant) {
GoRouter.of(
context,
).push(InfoByOrderScreen.path, extra: {'planId': state.chatRoom.chosenPlan?.id, 'customerSlug': state.chatRoom.users.receiver.slug});
GoRouter.of(context).push(
InfoByOrderScreen.path,
extra: {
'planId': state.chatRoom.chosenPlan?.id,
'customerSlug': state.chatRoom.users.receiver.slug,
},
);
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 +848,13 @@ class _ChatScreenState extends State<ChatScreen> {
const Icon(Icons.attach_file, size: 32, color: Colors.black54),
Padding(
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 +902,13 @@ class _ChatScreenState extends State<ChatScreen> {
// 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(
onPressed: () async {
@@ -715,13 +917,21 @@ class _ChatScreenState extends State<ChatScreen> {
List<XFile> images = await picker.pickMultiImage();
if (images != []) {
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();
// 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(
onPressed: () async {
@@ -753,15 +963,27 @@ class _ChatScreenState extends State<ChatScreen> {
if (result != null) {
// List<File> files = result.paths.map((path) => File(path!)).toList();
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);
}
},
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 +994,21 @@ class _ChatScreenState extends State<ChatScreen> {
padding: const EdgeInsets.only(top: 32),
child: Text(
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,
) {
GoRouter.of(context).pushNamed(
VideoCallScreen.name,
extra: <String, dynamic>{
@@ -793,6 +1023,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({
required BuildContext context,
required ChatRoomStateLoaded state,
@@ -801,11 +1068,13 @@ class _ChatScreenState extends State<ChatScreen> {
required ChatMessageModel message,
required bool? amIConsultant,
}) {
if (message.type == ChatMessageType.videoCallStarted && !isCurrentUserAuthor) {
if (message.type == ChatMessageType.videoCallStarted &&
!isCurrentUserAuthor) {
return VideoCallChatMessageWidget(
text: message.data.trim(),
sendTime: message.updatedAt,
onVideoCallPressed: () => _openVideoCall(context, state, chatCounterpart),
onVideoCallPressed: () =>
_openVideoCall(context, state, chatCounterpart),
);
}
@@ -844,7 +1113,13 @@ class _ChatHeaderVideoCallButton extends StatelessWidget {
child: SizedBox(
width: 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,
),
),
),
),
);

View File

@@ -14,11 +14,15 @@ class PhotoViewScreen extends StatefulWidget {
final int index;
final bool isLocal;
/// When set, used instead of the image URL so source/destination tags stay unique.
final Object? heroTag;
const PhotoViewScreen({
super.key,
required this.imageUrls,
required this.index,
this.isLocal = false,
this.heroTag,
});
@override
@@ -36,6 +40,23 @@ class _PhotoViewScreenState extends State<PhotoViewScreen> {
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
Widget build(BuildContext context) {
return DismissiblePage(
@@ -44,23 +65,19 @@ class _PhotoViewScreenState extends State<PhotoViewScreen> {
direction: DismissiblePageDismissDirection.vertical,
isFullScreen: true,
child: PhotoViewGallery.builder(
backgroundDecoration: const BoxDecoration(
color: Colors.transparent,
),
backgroundDecoration: const BoxDecoration(color: Colors.transparent),
scrollPhysics: const BouncingScrollPhysics(),
builder: (BuildContext context, int index) {
final imageProvider = widget.isLocal
? FileImage(widget.imageUrls[index]) as ImageProvider<Object>
: CachedNetworkImageProvider(widget.imageUrls[index]) as ImageProvider<Object>;
: CachedNetworkImageProvider(widget.imageUrls[index])
as ImageProvider<Object>;
return PhotoViewGalleryPageOptions(
imageProvider: imageProvider,
initialScale: PhotoViewComputedScale.contained,
minScale: PhotoViewComputedScale.contained,
heroAttributes: PhotoViewHeroAttributes(
tag: widget.imageUrls[index],
transitionOnUserGestures: true,
),
heroAttributes: _heroAttributesFor(index),
errorBuilder: (context, object, stackTrace) {
return Scaffold(
backgroundColor: Colors.black,
@@ -76,8 +93,12 @@ class _PhotoViewScreenState extends State<PhotoViewScreen> {
),
contentPadding: const EdgeInsets.all(16),
onTap: () async {
if (!await launchUrl(Uri.parse(widget.imageUrls[index]))) {
throw Exception('Could not launch ${widget.imageUrls[index]}');
if (!await launchUrl(
Uri.parse(widget.imageUrls[index]),
)) {
throw Exception(
'Could not launch ${widget.imageUrls[index]}',
);
}
},
leading: const Icon(
@@ -89,7 +110,10 @@ class _PhotoViewScreenState extends State<PhotoViewScreen> {
color: Colors.white,
),
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(
color: Colors.white,
fontSize: 12,
@@ -109,7 +133,9 @@ class _PhotoViewScreenState extends State<PhotoViewScreen> {
width: 20.0,
height: 20.0,
child: CircularProgressIndicator(
value: event == null ? 0 : event.cumulativeBytesLoaded / event.expectedTotalBytes!,
value: event == null
? 0
: event.cumulativeBytesLoaded / event.expectedTotalBytes!,
),
),
),

View File

@@ -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/payment_model.dart';
import 'package:eatvio_app/features/chat/models/plan_complain_model.dart';
import 'package:eatvio_app/features/payment/models/promo_applied_list_response.dart';
import 'package:eatvio_app/features/payment/models/promo_apply_response.dart';
import 'package:eatvio_app/features/payment/models/promo_remove_response.dart';
import 'package:eatvio_app/features/payment/models/promo_validate_response.dart';
import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart';
import 'package:retrofit/retrofit.dart';
@@ -69,6 +73,26 @@ abstract class ChatService {
@Body() required Map<String, String> body,
});
@GET('/api/chosen_plans/applied_promos')
Future<PromoAppliedListResponse> listAppliedPromoCodes();
@POST('/api/chosen_plans/{id}/validate_promo')
Future<PromoValidateResponse> validatePromoCode({
@Path() required int id,
@Body() required Map<String, String> body,
});
@POST('/api/chosen_plans/{id}/apply_promo')
Future<PromoApplyResponse> applyPromoCode({
@Path() required int id,
@Body() required Map<String, String> body,
});
@DELETE('/api/chosen_plans/{id}/remove_promo_code')
Future<PromoRemoveResponse> removePromoCode({
@Path() required int id,
});
@GET('/api/consultation/resolve/order_id_to_conversation/{orderId}')
Future<OrderIdToConversationModel> resolveOrderIdToConversation({
@Path() required String orderId,

View File

@@ -33,9 +33,7 @@ class _ComplainingBottomSheetState extends State<ComplainingBottomSheet> {
create: (context) => sl<ComplainingCubit>()..setContentTypeAndIdentifier(contentType: widget.complainedContentType, slug: widget.slug, id: widget.id),
child: Container(
padding: const EdgeInsets.only(top: 8, bottom: 24, left: 16, right: 16),
decoration: const BoxDecoration(
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
),
decoration: const BoxDecoration(borderRadius: BorderRadius.vertical(top: Radius.circular(12))),
child: BlocBuilder<ComplainingCubit, ComplainingState>(
builder: (context, state) {
final category = state.category;
@@ -72,10 +70,7 @@ class _CategoryWidget extends StatelessWidget {
children: [
const BottomSheetHandlerBar(),
const SizedBox(height: 20),
Text(
s.complaint,
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
),
Text(s.complaint, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
const SizedBox(height: 8),
Text(
s.complaint_question,
@@ -114,10 +109,7 @@ class _SpamWidget extends StatelessWidget {
children: [
const BottomSheetHandlerBar(),
const SizedBox(height: 20),
Text(
s.spam,
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
),
Text(s.spam, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
const SizedBox(height: 8),
SizedBox(
width: MediaQuery.sizeOf(context).width * 0.7,
@@ -145,10 +137,7 @@ class _ExplicitImageWidget extends StatelessWidget {
children: [
const BottomSheetHandlerBar(),
const SizedBox(height: 20),
Text(
s.explicitImages,
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
),
Text(s.explicitImages, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
const SizedBox(height: 8),
SizedBox(
width: MediaQuery.sizeOf(context).width * 0.7,
@@ -202,10 +191,7 @@ class _LawViolationWidgetState extends State<_LawViolationWidget> {
children: [
const BottomSheetHandlerBar(),
const SizedBox(height: 20),
Text(
s.lawViolation,
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
),
Text(s.lawViolation, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
const SizedBox(height: 8),
SizedBox(
width: MediaQuery.sizeOf(context).width * 0.7,
@@ -235,10 +221,7 @@ class _LawViolationWidgetState extends State<_LawViolationWidget> {
});
},
),
Text(
element.$2,
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
),
Text(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) ...[
const SizedBox(height: 4),
const ValidationErrorWidget(error: 'Минимум 10 символов'),
]
],
],
const SizedBox(height: 20),
_ComplainButton(
isDisabled: (state.complainInfo.description?.length ?? 0) < 10,
),
_ComplainButton(isDisabled: tags.firstWhere((tag) => tag.$2 == s.other).$1 && (state.complainInfo.description?.length ?? 0) < 10),
],
);
},
@@ -301,10 +282,7 @@ class _ViolenceAndHostilityState extends State<_ViolenceAndHostility> {
children: [
const BottomSheetHandlerBar(),
const SizedBox(height: 20),
Text(
s.lawViolation,
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
),
Text(s.lawViolation, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
const SizedBox(height: 8),
SizedBox(
width: MediaQuery.sizeOf(context).width * 0.7,
@@ -332,10 +310,7 @@ class _ViolenceAndHostilityState extends State<_ViolenceAndHostility> {
},
),
Expanded(
child: Text(
element.$2,
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
),
child: Text(element.$2, style: TextStyles.textLSemibold.copyWith(color: appTheme.black)),
),
],
);
@@ -363,15 +338,9 @@ class _DeceptionWidgetState extends State<_DeceptionWidget> {
super.didChangeDependencies();
final s = S.of(context);
if (tags.isEmpty && descriptions.isEmpty) {
tags.addAll([
(false, s.misrepresentation),
(false, s.fraud),
]);
tags.addAll([(false, s.misrepresentation), (false, s.fraud)]);
descriptions.addAll([
s.misrepresentation_description,
s.fraud_description,
]);
descriptions.addAll([s.misrepresentation_description, s.fraud_description]);
}
}
@@ -383,10 +352,7 @@ class _DeceptionWidgetState extends State<_DeceptionWidget> {
children: [
const BottomSheetHandlerBar(),
const SizedBox(height: 20),
Text(
s.lawViolation,
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
),
Text(s.lawViolation, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
const SizedBox(height: 8),
SizedBox(
width: MediaQuery.sizeOf(context).width * 0.7,
@@ -419,16 +385,10 @@ class _DeceptionWidgetState extends State<_DeceptionWidget> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 8),
Text(
element.$2,
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
),
Text(element.$2, style: TextStyles.textLSemibold.copyWith(color: appTheme.black)),
SizedBox(
width: MediaQuery.sizeOf(context).width * 0.7,
child: Text(
descriptions[index],
style: TextStyles.textSSemibold.copyWith(color: appTheme.lightGray600),
),
child: Text(descriptions[index], style: TextStyles.textSSemibold.copyWith(color: appTheme.lightGray600)),
),
],
),
@@ -453,10 +413,7 @@ class _ComplaintSentWidget extends StatelessWidget {
children: [
const BottomSheetHandlerBar(),
const SizedBox(height: 20),
Text(
s.complaint_sent,
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
),
Text(s.complaint_sent, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
const SizedBox(height: 8),
SizedBox(
width: MediaQuery.sizeOf(context).width * 0.7,
@@ -471,10 +428,7 @@ class _ComplaintSentWidget extends StatelessWidget {
text: s.ok,
height: 52,
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
buttonStyle: ButtonStyle(
side: const WidgetStatePropertyAll(BorderSide.none),
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
),
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
onPressed: () {
AppRouter.navigatorKey.currentContext?.pop();
},
@@ -495,10 +449,7 @@ class _ComplaintFailedWidget extends StatelessWidget {
children: [
const BottomSheetHandlerBar(),
const SizedBox(height: 20),
Text(
s.could_not_send_complain,
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
),
Text(s.could_not_send_complain, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
const SizedBox(height: 8),
SizedBox(
width: MediaQuery.sizeOf(context).width * 0.7,
@@ -513,10 +464,7 @@ class _ComplaintFailedWidget extends StatelessWidget {
text: s.ok,
height: 52,
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
buttonStyle: ButtonStyle(
side: const WidgetStatePropertyAll(BorderSide.none),
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
),
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
onPressed: () {
AppRouter.navigatorKey.currentContext?.pop();
},
@@ -535,16 +483,13 @@ class _ComplainButton extends StatelessWidget {
return BlocBuilder<ComplainingCubit, ComplainingState>(
builder: (context, state) {
if (state.isLoading) {
return const Loader();
return const SizedBox(height: 52, child: Loader());
}
return CustomOutlinedButton(
text: s.send_complain,
height: 52,
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
buttonStyle: ButtonStyle(
side: const WidgetStatePropertyAll(BorderSide.none),
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
),
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
onPressed: () async {
if (isDisabled) {
context.read<ComplainingCubit>().setDescription('');
@@ -570,10 +515,7 @@ class _CategoryButton extends StatelessWidget {
child: Ink(
child: Row(
children: [
Text(
category.getTranslatedText(),
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
),
Text(category.getTranslatedText(), style: TextStyles.textLSemibold.copyWith(color: appTheme.black)),
const Spacer(),
SvgPicture.asset('assets/icons/right_arrow.svg'),
],
@@ -591,18 +533,18 @@ enum ComplainType {
explicitImage;
String getTranslatedText() => switch (this) {
ComplainType.spam => S.current.spam,
ComplainType.lawViolation => S.current.lawViolation,
ComplainType.deception => S.current.deception,
ComplainType.violenceDndHostility => S.current.violenceAndHostility,
ComplainType.explicitImage => S.current.explicitImage,
};
ComplainType.spam => S.current.spam,
ComplainType.lawViolation => S.current.lawViolation,
ComplainType.deception => S.current.deception,
ComplainType.violenceDndHostility => S.current.violenceAndHostility,
ComplainType.explicitImage => S.current.explicitImage,
};
String getValue() => switch (this) {
ComplainType.spam => 'spam',
ComplainType.lawViolation => 'legalViolation',
ComplainType.deception => 'fraud',
ComplainType.violenceDndHostility => 'violenceAndHatred',
ComplainType.explicitImage => 'explicitImage',
};
ComplainType.spam => 'spam',
ComplainType.lawViolation => 'legalViolation',
ComplainType.deception => 'fraud',
ComplainType.violenceDndHostility => 'violenceAndHatred',
ComplainType.explicitImage => 'explicitImage',
};
}

View File

@@ -36,6 +36,10 @@ class ComplainingCubit extends Cubit<ComplainingState> {
}
Future<void> complain() async {
if (state.isLoading) {
return;
}
emit(state.copyWith(isLoading: true));
try {
final category = state.category;
final id = state.contentId;
@@ -46,37 +50,37 @@ class ComplainingCubit extends Cubit<ComplainingState> {
case ComplainedContent.diet:
if (id != null) {
await repository.sendDietComplain(id: id, category: category.getValue(), body: state.complainInfo);
emit(state.copyWith(wasSentSuccessfully: true));
emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
return;
}
case ComplainedContent.plan:
if (slug != null) {
await repository.sendPlanComplain(slug: slug, category: category.getValue(), body: state.complainInfo);
emit(state.copyWith(wasSentSuccessfully: true));
emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
return;
}
case ComplainedContent.recipe:
if (slug != null) {
await repository.sendRecipeComplain(slug: slug, category: category.getValue(), body: state.complainInfo);
emit(state.copyWith(wasSentSuccessfully: true));
emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
return;
}
case ComplainedContent.profile:
if (slug != null) {
await repository.sendProfileComplain(slug: slug, category: category.getValue(), body: state.complainInfo);
emit(state.copyWith(wasSentSuccessfully: true));
emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
return;
}
case ComplainedContent.posts:
if (id != null) {
await repository.sendBlogPostsComplain(id: id, category: category.getValue(), body: state.complainInfo);
emit(state.copyWith(wasSentSuccessfully: true));
emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
return;
}
case ComplainedContent.comment:
if (id != null) {
await repository.sendCommentComplain(id: id, category: category.getValue(), body: state.complainInfo);
emit(state.copyWith(wasSentSuccessfully: true));
emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
return;
}
}
@@ -85,6 +89,6 @@ class ComplainingCubit extends Cubit<ComplainingState> {
await reportFrom(e, st, this);
log(e.toString(), stackTrace: st);
}
emit(state.copyWith(wasSentSuccessfully: false));
emit(state.copyWith(wasSentSuccessfully: false, isLoading: false));
}
}

View File

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

View File

@@ -18,7 +18,8 @@ abstract class ConsultantCertPhotoModel with _$ConsultantCertPhotoModel {
const ConsultantCertPhotoModel._();
factory ConsultantCertPhotoModel.fromJson(Map<String, dynamic> json) => _$ConsultantCertPhotoModelFromJson(json);
factory ConsultantCertPhotoModel.fromJson(Map<String, dynamic> json) =>
_$ConsultantCertPhotoModelFromJson(json);
static const String visibleGroup = 'user_certs';
static const String hiddenGroup = 'hidden_user_certs';

View File

@@ -1,3 +1,4 @@
import 'package:eatvio_app/features/consultant/become_consultant/utils/passport_data_validator.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'consultant_profile_model.freezed.dart';
@@ -6,27 +7,70 @@ part 'consultant_profile_model.g.dart';
@freezed
abstract class ConsultantProfileModel with _$ConsultantProfileModel {
const ConsultantProfileModel._();
const factory ConsultantProfileModel({
@JsonKey(name: 'has_medical_education', includeIfNull: false) bool? hasMedicalEducation,
@JsonKey(name: 'social_links', includeIfNull: false) SocialLinksModel? socialLinks,
@JsonKey(name: 'cert_photos', includeIfNull: false) List<String>? photos,
@JsonKey(name: 'completed_courses', includeIfNull: false) List<CertificatesModel>? certificates,
@JsonKey(name: 'has_medical_education', includeIfNull: false)
bool? hasMedicalEducation,
@JsonKey(name: 'social_links', includeIfNull: false)
SocialLinksModel? socialLinks,
@JsonKey(name: 'cert_photos', includeIfNull: false) List<String>? photos,
@JsonKey(name: 'completed_courses', includeIfNull: false)
List<CertificatesModel>? certificates,
@JsonKey(name: 'legal_type', includeIfNull: false) String? legalType,
@JsonKey(name: 'last_name', includeIfNull: false) String? lastName,
@JsonKey(name: 'first_name', includeIfNull: false) String? firstName,
@JsonKey(name: 'middle_name', includeIfNull: false) String? middleName,
@JsonKey(name: 'inn', includeIfNull: false) String? inn,
@JsonKey(name: 'snils', includeIfNull: false) String? snils,
@JsonKey(name: 'passport_series', includeIfNull: false)
String? passportSeries,
@JsonKey(name: 'passport_number', includeIfNull: false)
String? passportNumber,
@JsonKey(name: 'passport_issue_date', includeIfNull: false)
String? passportIssueDate,
@JsonKey(name: 'passport_issuer', includeIfNull: false)
String? passportIssuer,
@JsonKey(name: 'passport_issuer_code', includeIfNull: false)
String? passportIssuerCode,
@JsonKey(name: 'birth_date', includeIfNull: false) String? birthDate,
@JsonKey(name: 'birth_place', includeIfNull: false) String? birthPlace,
@JsonKey(name: 'registration_address', includeIfNull: false)
String? registrationAddress,
@JsonKey(name: 'residence_address', includeIfNull: false)
String? residenceAddress,
@JsonKey(name: 'citizenship', includeIfNull: false) String? citizenship,
@JsonKey(name: 'tax_resident_us', includeIfNull: false) bool? taxResidentUs,
@JsonKey(name: 'foreign_tax_resident', includeIfNull: false)
bool? foreignTaxResident,
@JsonKey(name: 'sbp_phone', includeIfNull: false) String? sbpPhone,
@JsonKey(name: 'sbp_bank_member_id', includeIfNull: false)
String? sbpBankMemberId,
@JsonKey(name: 'agreement_accepted', includeIfNull: false)
bool? agreementAccepted,
@JsonKey(name: 'frdo_agreement_accepted', includeIfNull: false)
bool? frdoAgreementAccepted,
}) = _ConsultantProfileModel;
factory ConsultantProfileModel.fromJson(Map<String, Object?> json) => _$ConsultantProfileModelFromJson(json);
factory ConsultantProfileModel.fromJson(Map<String, Object?> json) =>
_$ConsultantProfileModelFromJson(json);
bool get hasValidPassportData => PassportDataValidator.validate(this).isEmpty;
}
@freezed
abstract class SocialLinksModel with _$SocialLinksModel {
const factory SocialLinksModel({
@JsonKey(name: 'vk_link', includeIfNull: false) String? vkLink,
@JsonKey(name: 'tg_link', includeIfNull: false) String? tgLink,
@JsonKey(name: 'fb_link', includeIfNull: false) String? fbLink,
@JsonKey(name: 'insta_link', includeIfNull: false) String? instaLink,
@JsonKey(name: 'personal_site_link', includeIfNull: false) String? personalSiteLink,
@JsonKey(name: 'vk_link', includeIfNull: false) String? vkLink,
@JsonKey(name: 'tg_link', includeIfNull: false) String? tgLink,
@JsonKey(name: 'fb_link', includeIfNull: false) String? fbLink,
@JsonKey(name: 'insta_link', includeIfNull: false) String? instaLink,
@JsonKey(name: 'personal_site_link', includeIfNull: false)
String? personalSiteLink,
}) = _SocialLinksModel;
factory SocialLinksModel.fromJson(Map<String, Object?> json) => _$SocialLinksModelFromJson(json);
factory SocialLinksModel.fromJson(Map<String, Object?> json) =>
_$SocialLinksModelFromJson(json);
}
@freezed
@@ -36,5 +80,6 @@ abstract class CertificatesModel with _$CertificatesModel {
required String date,
}) = _CertificatesModel;
factory CertificatesModel.fromJson(Map<String, Object?> json) => _$CertificatesModelFromJson(json);
factory CertificatesModel.fromJson(Map<String, Object?> json) =>
_$CertificatesModelFromJson(json);
}

View File

@@ -1,3 +1,4 @@
import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart';
import 'package:eatvio_app/features/profile/models/profile_model.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
@@ -11,26 +12,81 @@ abstract class ConsultationRequestModel with _$ConsultationRequestModel {
@JsonKey(name: 'rating_place') int? ratingPlace,
@JsonKey(name: 'education_resource') required EducationModel educationModel,
@JsonKey(name: 'cert_photos') required List<CertPhotosModel> photos,
@JsonKey(name: 'canceled_moderation') CanceledModerationModel? canceledModeration,
@JsonKey(name: 'canceled_moderation')
CanceledModerationModel? canceledModeration,
@JsonKey(name: 'legal_entity_resource') LegalEntityModel? legalEntity,
}) = _ConsultationRequestModel;
factory ConsultationRequestModel.fromJson(Map<String, dynamic> json) => _$ConsultationRequestModelFromJson(json);
factory ConsultationRequestModel.fromJson(Map<String, dynamic> json) =>
_$ConsultationRequestModelFromJson(json);
}
@freezed
abstract class LegalEntityModel with _$LegalEntityModel {
const LegalEntityModel._();
const factory LegalEntityModel({
required int id,
required String type,
String? inn,
String? snils,
String? state,
@JsonKey(
name: 'alfa_scoring_status',
unknownEnumValue: AlfaScoringStatus.none,
defaultValue: AlfaScoringStatus.none,
)
@Default(AlfaScoringStatus.none)
AlfaScoringStatus alfaScoringStatus,
@JsonKey(name: 'alfa_scoring_request_id') String? alfaScoringRequestId,
@JsonKey(name: 'last_name') String? lastName,
@JsonKey(name: 'first_name') String? firstName,
@JsonKey(name: 'middle_name') String? middleName,
@JsonKey(name: 'passport_series') String? passportSeries,
@JsonKey(name: 'passport_number') String? passportNumber,
@JsonKey(name: 'passport_issue_date') String? passportIssueDate,
@JsonKey(name: 'passport_issuer') String? passportIssuer,
@JsonKey(name: 'passport_issuer_code') String? passportIssuerCode,
@JsonKey(name: 'birth_date') String? birthDate,
@JsonKey(name: 'birth_place') String? birthPlace,
@JsonKey(name: 'registration_address') String? registrationAddress,
@JsonKey(name: 'residence_address') String? residenceAddress,
String? citizenship,
@JsonKey(name: 'tax_resident_us') bool? taxResidentUs,
@JsonKey(name: 'foreign_tax_resident') bool? foreignTaxResident,
@JsonKey(name: 'sbp_phone') String? sbpPhone,
@JsonKey(name: 'sbp_bank_member_id') String? sbpBankMemberId,
}) = _LegalEntityModel;
factory LegalEntityModel.fromJson(Map<String, dynamic> json) =>
_$LegalEntityModelFromJson(json);
/// Normalizes API date (`Y-m-d` or ISO datetime) to `Y-m-d`.
static String? normalizeApiDate(String? value) {
if (value == null || value.isEmpty) {
return null;
}
if (value.length >= 10 && RegExp(r'^\d{4}-\d{2}-\d{2}').hasMatch(value)) {
return value.substring(0, 10);
}
return value;
}
String? get normalizedPassportIssueDate =>
normalizeApiDate(passportIssueDate);
String? get normalizedBirthDate => normalizeApiDate(birthDate);
}
@freezed
abstract class CertPhotosModel with _$CertPhotosModel {
const factory CertPhotosModel({
required String url,
}) = _CertPhotosModel;
const factory CertPhotosModel({required String url}) = _CertPhotosModel;
factory CertPhotosModel.fromJson(Map<String, dynamic> json) => _$CertPhotosModelFromJson(json);
factory CertPhotosModel.fromJson(Map<String, dynamic> json) =>
_$CertPhotosModelFromJson(json);
}
enum ModerationStatus {
approved,
created,
canceled
}
enum ModerationStatus { approved, created, canceled }
@freezed
abstract class CanceledModerationModel with _$CanceledModerationModel {
@@ -40,5 +96,6 @@ abstract class CanceledModerationModel with _$CanceledModerationModel {
required List<String>? additional,
}) = _CanceledModerationModel;
factory CanceledModerationModel.fromJson(Map<String, dynamic> json) => _$CanceledModerationModelFromJson(json);
factory CanceledModerationModel.fromJson(Map<String, dynamic> json) =>
_$CanceledModerationModelFromJson(json);
}

View File

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

View File

@@ -1,42 +1,118 @@
import 'dart:async';
import 'dart:developer';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:eatvio_app/core/error_reporting/report_from.dart';
import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart';
import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart';
import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart';
import 'package:eatvio_app/features/consultant/become_consultant/utils/laravel_validation_errors.dart';
import 'package:eatvio_app/features/consultant/become_consultant/utils/passport_data_validator.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:eatvio_app/core/error_reporting/report_from.dart';
class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
final ConsultantRepository consultantRepository;
final Duration scoringPollInterval;
bool _update = false;
String? _userSlug;
Timer? _scoringPollTimer;
RegisterConsultantCubit({required this.consultantRepository})
: super(
const RegisterConsultantState(
consultant: ConsultantProfileModel(socialLinks: SocialLinksModel(), hasMedicalEducation: true),
loading: true,
acceptOffer: false,
isUpdating: false,
),
);
static const String individualPersonLegalType = 'individualPerson';
RegisterConsultantCubit({
required this.consultantRepository,
this.scoringPollInterval = const Duration(seconds: 3),
}) : super(
const RegisterConsultantState(
consultant: ConsultantProfileModel(
socialLinks: SocialLinksModel(),
hasMedicalEducation: true,
legalType: individualPersonLegalType,
),
loading: true,
acceptOffer: false,
acceptFrdoAgreement: false,
isUpdating: false,
),
);
String? get userSlug => _userSlug;
void setUserSlug(String? slug) {
if (slug != null && slug.isNotEmpty) {
_userSlug = slug;
}
}
Future<List<SbpBankModel>> _loadSbpBanks() async {
try {
return await consultantRepository.getSbpBanks();
} catch (e, st) {
await reportFrom(e, st, this);
log(e.toString());
return const <SbpBankModel>[];
}
}
List<SbpBankModel> searchSbpBanks(String query) {
final normalized = query.trim().toLowerCase();
if (normalized.isEmpty) {
return state.sbpBanks;
}
return state.sbpBanks.where((bank) {
final name = bank.bankName.toLowerCase();
final shortName = (bank.shortName ?? '').toLowerCase();
final bic = (bank.bic ?? '').toLowerCase();
return name.contains(normalized) ||
shortName.contains(normalized) ||
bic.contains(normalized);
}).toList();
}
SbpBankModel? findSbpBankById(String? bankMemberId) {
if (bankMemberId == null || bankMemberId.isEmpty) {
return null;
}
for (final bank in state.sbpBanks) {
if (bank.bankMemberId == bankMemberId) {
return bank;
}
}
return null;
}
Future<void> getMyRequestStatus(String slug) async {
try {
_update = true;
final myRequestStatus = await consultantRepository.getMyRequestStatus(slug);
_userSlug = slug;
final myRequestStatus = await consultantRepository.getMyRequestStatus(
slug,
);
final certPhotos = await consultantRepository.getConsultantCertPhotos();
final sbpBanks = await _loadSbpBanks();
List<CertificatesModel>? certificates = myRequestStatus.educationModel.completedCourses?.map((courseMap) {
final course = courseMap.values.first;
List<CertificatesModel>? certificates = myRequestStatus
.educationModel
.completedCourses
?.map((courseMap) {
final course = courseMap.values.first;
return CertificatesModel(title: course.title, date: course.date);
}).toList();
return CertificatesModel(title: course.title, date: course.date);
})
.toList();
String? sanitizeLink(String? link) {
return (link != null && link.isNotEmpty) ? link : null;
}
final legal = myRequestStatus.legalEntity;
final scoringStatus = legal?.alfaScoringStatus ?? AlfaScoringStatus.none;
emit(
RegisterConsultantState(
consultant: ConsultantProfileModel(
@@ -46,33 +122,107 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
tgLink: sanitizeLink(myRequestStatus.educationModel.tgLink),
fbLink: sanitizeLink(myRequestStatus.educationModel.fbLink),
instaLink: sanitizeLink(myRequestStatus.educationModel.instaLink),
personalSiteLink: sanitizeLink(myRequestStatus.educationModel.personalSiteLink),
personalSiteLink: sanitizeLink(
myRequestStatus.educationModel.personalSiteLink,
),
),
hasMedicalEducation: myRequestStatus.educationModel.hasMedicalEducation == 1,
hasMedicalEducation:
myRequestStatus.educationModel.hasMedicalEducation == 1,
legalType: legal?.type ?? individualPersonLegalType,
lastName: _emptyToNull(legal?.lastName),
firstName: _emptyToNull(legal?.firstName),
middleName: _emptyToNull(legal?.middleName),
inn: _emptyToNull(legal?.inn),
snils: _emptyToNull(legal?.snils),
passportSeries: _emptyToNull(legal?.passportSeries),
passportNumber: _emptyToNull(legal?.passportNumber),
passportIssueDate: legal?.normalizedPassportIssueDate,
passportIssuer: _emptyToNull(legal?.passportIssuer),
passportIssuerCode: _emptyToNull(legal?.passportIssuerCode),
birthDate: legal?.normalizedBirthDate,
birthPlace: _emptyToNull(legal?.birthPlace),
registrationAddress: _emptyToNull(legal?.registrationAddress),
residenceAddress: _emptyToNull(legal?.residenceAddress),
citizenship: _emptyToNull(legal?.citizenship),
taxResidentUs: legal?.taxResidentUs ?? false,
foreignTaxResident: legal?.foreignTaxResident ?? false,
sbpPhone: _emptyToNull(legal?.sbpPhone),
sbpBankMemberId: _emptyToNull(legal?.sbpBankMemberId),
),
acceptOffer: false,
acceptFrdoAgreement: false,
loading: false,
requestModel: myRequestStatus,
certPhotos: certPhotos,
sbpBanks: sbpBanks,
isUpdating: _update,
alfaScoringStatus: scoringStatus,
isScoringPolling: scoringStatus.isProcessing,
scoringRetryInProgress: false,
awaitingScoringOutcome: false,
fieldErrors: const <String, String>{},
error: null,
),
);
if (scoringStatus.isProcessing) {
startScoringPoll();
}
} catch (e, st) {
await reportFrom(e, st, this);
log(e.toString());
final parsed = parseLaravelValidationErrors(e);
emit(
state.copyWith(
loading: false,
error: parsed.summary,
fieldErrors: parsed.fieldErrors,
awaitingScoringOutcome: false,
),
);
}
}
String? _emptyToNull(String? value) {
if (value == null || value.trim().isEmpty) {
return null;
}
return value.trim();
}
Future<void> disableLoading({String? slug}) async {
try {
setUserSlug(slug);
final certPhotos = await consultantRepository.getConsultantCertPhotos();
final sbpBanks = await _loadSbpBanks();
emit(
state.copyWith(
loading: false,
certPhotos: certPhotos,
sbpBanks: sbpBanks,
error: null,
fieldErrors: const <String, String>{},
awaitingScoringOutcome: false,
consultant: state.consultant.copyWith(
legalType: individualPersonLegalType,
citizenship: state.consultant.citizenship,
taxResidentUs: state.consultant.taxResidentUs ?? false,
foreignTaxResident: state.consultant.foreignTaxResident ?? false,
),
),
);
} catch (e, st) {
await reportFrom(e, st, this);
log(e.toString());
emit(state.copyWith(loading: false, error: e.toString()));
}
}
Future<void> disableLoading() async {
try {
final certPhotos = await consultantRepository.getConsultantCertPhotos();
emit(state.copyWith(loading: false, certPhotos: certPhotos, error: null));
} catch (e, st) {
await reportFrom(e, st, this);
log(e.toString());
emit(state.copyWith(loading: false, error: e.toString()));
final parsed = parseLaravelValidationErrors(e);
emit(
state.copyWith(
loading: false,
error: parsed.summary,
fieldErrors: parsed.fieldErrors,
awaitingScoringOutcome: false,
),
);
}
}
@@ -82,41 +232,326 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
if (slug != null) {
await getMyRequestStatus(slug);
} else {
await disableLoading();
await disableLoading(slug: _userSlug);
}
}
void updateConsultantProfileModel(ConsultantProfileModel consultant, [bool acceptOffer = false]) {
emit(state.copyWith(consultant: consultant, acceptOffer: acceptOffer, loading: false, error: null));
void updateConsultantProfileModel(
ConsultantProfileModel consultant, [
bool? acceptOffer,
bool? acceptFrdoAgreement,
]) {
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 {
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 {
if (state.newAddedPhotos.isNotEmpty) {
await consultantRepository.storeConsultantCertPhotos(files: state.newAddedPhotos);
await consultantRepository.storeConsultantCertPhotos(
files: state.newAddedPhotos,
);
}
if (state.deletedPhotoIds.isNotEmpty) {
for (final int id in state.deletedPhotoIds) {
await consultantRepository.deletePhoto(id);
}
}
await consultantRepository.registerConsultant(consultant: state.consultant, update: _update);
await consultantRepository.registerConsultant(
consultant: buildSubmitPayload(),
update: _update,
);
_update = true;
emit(
state.copyWith(
isUpdating: true,
alfaScoringStatus: AlfaScoringStatus.beneficiaryCreating,
isScoringPolling: true,
scoringRetryInProgress: false,
awaitingScoringOutcome: true,
newAddedPhotos: const <File>[],
deletedPhotoIds: const <int>[],
fieldErrors: const <String, String>{},
error: null,
),
);
startScoringPoll();
} on DioException catch (e, st) {
await reportFrom(e, st, this);
log(e.toString());
final parsed = parseLaravelValidationErrors(e);
emit(
state.copyWith(
error: parsed.summary,
fieldErrors: parsed.fieldErrors,
scoringRetryInProgress: false,
awaitingScoringOutcome: false,
isScoringPolling: false,
alfaScoringStatus: AlfaScoringStatus.none,
),
);
} catch (e, st) {
await reportFrom(e, st, this);
log(e.toString());
emit(state.copyWith(error: e.toString()));
emit(
state.copyWith(
error: e.toString(),
scoringRetryInProgress: false,
awaitingScoringOutcome: false,
isScoringPolling: false,
alfaScoringStatus: AlfaScoringStatus.none,
),
);
}
}
Future<void> retryScoring() async {
if (!state.alfaScoringStatus.canRetry || state.scoringRetryInProgress) {
return;
}
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 {
if (files.isEmpty) {
return;
}
try {
final compressedFiles = await ImageCompressUtility.compressFilesList(files);
final compressedFiles = await ImageCompressUtility.compressFilesList(
files,
);
emit(state.copyWith(newAddedPhotos: [...state.newAddedPhotos, ...compressedFiles], error: null));
emit(
state.copyWith(
newAddedPhotos: [...state.newAddedPhotos, ...compressedFiles],
error: null,
),
);
} catch (e, st) {
await reportFrom(e, st, this);
log(e.toString());
@@ -130,7 +565,19 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
return;
}
if (path != null) {
emit(state.copyWith(newAddedPhotos: state.newAddedPhotos.where((file) => file.uri.path != path).toList()));
emit(
state.copyWith(
newAddedPhotos: state.newAddedPhotos
.where((file) => file.uri.path != path)
.toList(),
),
);
}
}
@override
Future<void> close() {
_stopScoringPoll();
return super.close();
}
}

View File

@@ -1,8 +1,10 @@
import 'dart:io';
import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'register_consultant_state.freezed.dart';
@@ -12,12 +14,22 @@ abstract class RegisterConsultantState with _$RegisterConsultantState {
const factory RegisterConsultantState({
required ConsultantProfileModel consultant,
required bool acceptOffer,
@Default(false) bool acceptFrdoAgreement,
required bool loading,
required bool isUpdating,
ConsultationRequestModel? requestModel,
@Default(<ConsultantCertPhotoModel>[]) List<ConsultantCertPhotoModel> certPhotos,
@Default(<ConsultantCertPhotoModel>[])
List<ConsultantCertPhotoModel> certPhotos,
@Default(<SbpBankModel>[]) List<SbpBankModel> sbpBanks,
@Default(<File>[]) List<File> newAddedPhotos,
@Default(<int>[]) List<int> deletedPhotoIds,
@Default(AlfaScoringStatus.none) AlfaScoringStatus alfaScoringStatus,
@Default(false) bool isScoringPolling,
@Default(false) bool scoringRetryInProgress,
/// True only after successful register/retry in this session until scoring is terminal.
@Default(false) bool awaitingScoringOutcome,
@Default(<String, String>{}) Map<String, String> fieldErrors,
String? error,
}) = _RegisterConsultantState;
}

View File

@@ -8,7 +8,8 @@ class AdditionalSettingsScreen extends StatefulWidget {
const AdditionalSettingsScreen({super.key});
@override
State<AdditionalSettingsScreen> createState() => _AdditionalSettingsScreenState();
State<AdditionalSettingsScreen> createState() =>
_AdditionalSettingsScreenState();
}
class _AdditionalSettingsScreenState extends State<AdditionalSettingsScreen> {
@@ -17,7 +18,12 @@ class _AdditionalSettingsScreenState extends State<AdditionalSettingsScreen> {
@override
void initState() {
if (context.read<RegisterConsultantCubit>().state.consultant.hasMedicalEducation != null) {
if (context
.read<RegisterConsultantCubit>()
.state
.consultant
.hasMedicalEducation !=
null) {
haveMedicalDegree = context
.read<RegisterConsultantCubit>()
.state
@@ -52,7 +58,8 @@ class _AdditionalSettingsScreenState extends State<AdditionalSettingsScreen> {
value: publishDiplomasAndCertificatesInProfile,
onTap: () {
setState(() {
publishDiplomasAndCertificatesInProfile = !publishDiplomasAndCertificatesInProfile;
publishDiplomasAndCertificatesInProfile =
!publishDiplomasAndCertificatesInProfile;
});
},
),
@@ -64,11 +71,15 @@ class _AdditionalSettingsScreenState extends State<AdditionalSettingsScreen> {
setState(() {
haveMedicalDegree = !haveMedicalDegree;
});
context.read<RegisterConsultantCubit>().updateConsultantProfileModel(
context.read<RegisterConsultantCubit>().state.consultant.copyWith(
hasMedicalEducation: haveMedicalDegree,
),
);
context
.read<RegisterConsultantCubit>()
.updateConsultantProfileModel(
context
.read<RegisterConsultantCubit>()
.state
.consultant
.copyWith(hasMedicalEducation: haveMedicalDegree),
);
},
),
],

View File

@@ -3,7 +3,9 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class BecomeConsultantScreen extends StatefulWidget {
const BecomeConsultantScreen({super.key});
final String? slug;
const BecomeConsultantScreen({super.key, this.slug});
@override
State<BecomeConsultantScreen> createState() => _BecomeConsultantScreenState();
@@ -17,7 +19,10 @@ class _BecomeConsultantScreenState extends State<BecomeConsultantScreen> {
title: const Text('Стать консультантом'),
automaticallyImplyLeading: false,
centerTitle: false,
leading: IconButton(onPressed: () => GoRouter.of(context).pop(), icon: const Icon(Icons.close)),
leading: IconButton(
onPressed: () => GoRouter.of(context).pop(),
icon: const Icon(Icons.close),
),
),
body: const Padding(
padding: EdgeInsets.all(16),
@@ -32,16 +37,27 @@ class _BecomeConsultantScreenState extends State<BecomeConsultantScreen> {
InkWell _startRegistrationButton() {
return InkWell(
onTap: () => GoRouter.of(context).pushReplacementNamed('stepperBecomeConsultantScreen', extra: {'slug': null}),
onTap: () => GoRouter.of(context).pushReplacementNamed(
'stepperBecomeConsultantScreen',
extra: {'slug': widget.slug, 'isNewRegistration': true},
),
borderRadius: BorderRadius.circular(20),
child: Ink(
height: 52,
width: MediaQuery.of(context).size.width - 32,
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: appTheme.blueA400,
),
child: const Center(
child: Text(
'Начать регистрацию',
style: TextStyle(color: Colors.white, height: 1, fontWeight: FontWeight.w600, fontSize: 18),
style: TextStyle(
color: Colors.white,
height: 1,
fontWeight: FontWeight.w600,
fontSize: 18,
),
),
),
),

View File

@@ -7,15 +7,18 @@ import 'package:flutter/material.dart';
import 'package:eatvio_app/core/utils/date_time_utils.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
class CertificatesAndDiplomasScreen extends StatefulWidget {
const CertificatesAndDiplomasScreen({super.key});
@override
State<CertificatesAndDiplomasScreen> createState() => _CertificatesAndDiplomasScreenState();
State<CertificatesAndDiplomasScreen> createState() =>
_CertificatesAndDiplomasScreenState();
}
class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasScreen> {
class _CertificatesAndDiplomasScreenState
extends State<CertificatesAndDiplomasScreen> {
final scrollController = ScrollController();
List<DiplomaEntity> diplomasList = [
@@ -29,9 +32,15 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
@override
void initState() {
super.initState();
if (context.read<RegisterConsultantCubit>().state.consultant.certificates != null) {
if (context.read<RegisterConsultantCubit>().state.consultant.certificates !=
null) {
diplomasList = [
...context.read<RegisterConsultantCubit>().state.consultant.certificates!.map(
...context
.read<RegisterConsultantCubit>()
.state
.consultant
.certificates!
.map(
(e) => DiplomaEntity(
date: DateTime.tryParse(e.date),
textEditingController: TextEditingController(text: e.title),
@@ -70,7 +79,8 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, index) => _diplomaInfo(diploma: diplomasList[index]),
itemBuilder: (context, index) =>
_diplomaInfo(diploma: diplomasList[index]),
separatorBuilder: (context, index) => Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: _divider(context),
@@ -93,9 +103,7 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
);
}
Column _diplomaInfo({
required DiplomaEntity diploma,
}) {
Column _diplomaInfo({required DiplomaEntity diploma}) {
return Column(
children: [
CustomTextFormField(
@@ -111,7 +119,9 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: MediaQuery.of(context).size.width - (diploma.enableDeleteButton ? 92 : 32),
width:
MediaQuery.of(context).size.width -
(diploma.enableDeleteButton ? 92 : 32),
child: ListTile(
onTap: onTapCalendar(context, diploma),
trailing: Icon(
@@ -122,7 +132,9 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
title: Text(
diploma.date?.format('dd MMMM yyyy', 'ru') ?? 'Дата выдачи',
style: TextStyle(
color: diploma.date == null ? Colors.grey.shade400 : Colors.black,
color: diploma.date == null
? Colors.grey.shade400
: Colors.black,
),
),
tileColor: const Color(0xFFF6F6F6),
@@ -148,9 +160,7 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
color: const Color(0xFFF6F6F6),
borderRadius: BorderRadius.circular(20),
),
child: const Icon(
Icons.delete_outline_rounded,
),
child: const Icon(Icons.delete_outline_rounded),
),
),
],
@@ -190,16 +200,14 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
bottom: MediaQuery.of(context).viewInsets.bottom,
),
color: CupertinoColors.systemBackground.resolveFrom(context),
child: SafeArea(
top: false,
child: child,
),
child: SafeArea(top: false, child: child),
),
);
}
void checkLast() {
if (diplomasList.last.textEditingController.text.isNotEmpty || diplomasList.last.date != null) {
if (diplomasList.last.textEditingController.text.isNotEmpty ||
diplomasList.last.date != null) {
setState(() {
diplomasList.add(
DiplomaEntity(
@@ -230,12 +238,19 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
void _saveState() {
context.read<RegisterConsultantCubit>().updateConsultantProfileModel(
context.read<RegisterConsultantCubit>().state.consultant.copyWith(
certificates: diplomasList.map((e) => CertificatesModel(title: e.textEditingController.text, date: e.date.toString())).toList().sublist(
0,
diplomasList.length - 1,
),
context.read<RegisterConsultantCubit>().state.consultant.copyWith(
certificates: diplomasList
.map(
(e) => CertificatesModel(
title: e.textEditingController.text,
date: e.date == null
? ''
: DateFormat('yyyy-MM-dd').format(e.date!),
),
);
)
.toList()
.sublist(0, diplomasList.length - 1),
),
);
}
}

View File

@@ -1,8 +1,11 @@
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/const.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/widgets/scoring_status_widget.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:url_launcher/url_launcher.dart';
@@ -15,10 +18,29 @@ class LastStepScreen extends StatefulWidget {
}
class _LastStepScreenState extends State<LastStepScreen> {
bool checkboxState = false;
static const _frdoUrl =
'https://obrnadzor.gov.ru/gosudarstvennye-uslugi-i-funkczii/7701537808-gosfunction/formirovanie-i-vedenie-federalnogo-reestra-svedenij-o-dokumentah-ob-obrazovanii-i-ili-o-kvalifikaczii-dokumentah-ob-obuchenii/';
late final TapGestureRecognizer _frdoTapRecognizer;
@override
void initState() {
super.initState();
_frdoTapRecognizer = TapGestureRecognizer()
..onTap = () async => await launchUrl(Uri.parse(_frdoUrl));
}
@override
void dispose() {
_frdoTapRecognizer.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final cubit = context.watch<RegisterConsultantCubit>();
final state = cubit.state;
return SingleChildScrollView(
padding: const EdgeInsets.only(bottom: 80, top: 16),
child: Column(
@@ -27,34 +49,138 @@ class _LastStepScreenState extends State<LastStepScreen> {
children: [
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Text('Последний шаг', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, height: 1.1)),
child: Text(
'Последний шаг',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
height: 1.1,
),
),
),
if (state.awaitingScoringOutcome ||
state.isScoringPolling ||
state.alfaScoringStatus !=
AlfaScoringStatus.none) ...[
const SizedBox(height: 16),
const ScoringStatusWidget(),
],
if (state.requestModel?.canceledModeration != null) ...[
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0x33FFCC00),
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
state.requestModel!.canceledModeration!.title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
height: 1.2,
),
),
if (state.requestModel!.canceledModeration!.additional !=
null) ...[
const SizedBox(height: 8),
for (final item
in state
.requestModel!
.canceledModeration!
.additional!) ...[
Text(
'• $item',
style: const TextStyle(fontSize: 14, height: 1.3),
),
],
],
],
),
),
),
],
const SizedBox(height: 16),
CheckboxListTile(
value: checkboxState,
value: state.acceptOffer,
title: Text(
hidePlans
? 'Я ознакомлен и принимаю политику конфиденциальности и защиту персональных данных'
: 'Я ознакомлен и принимаю договор публичной офёрты',
style: const TextStyle(fontSize: 18, height: 1),
: 'Я ознакомлен и принимаю публичную оферту и политику конфиденциальности',
style: const TextStyle(fontSize: 18, height: 1.2),
),
onChanged: (_) {
setState(() {
checkboxState = !checkboxState;
});
context.read<RegisterConsultantCubit>().updateConsultantProfileModel(context.read<RegisterConsultantCubit>().state.consultant, checkboxState);
onChanged: (value) {
cubit.updateAgreements(acceptOffer: value ?? false);
},
activeColor: appTheme.blueA400,
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
controlAffinity: ListTileControlAffinity.leading,
),
if (!state.isUpdating) ...[
CheckboxListTile(
value: state.acceptFrdoAgreement,
title: Text.rich(
TextSpan(
style: const TextStyle(
fontSize: 18,
height: 1.2,
color: Color(0xFF323F4B),
),
children: [
const TextSpan(
text:
'Обязательное требование: Один из моих документов об образовании есть в реестре ',
),
TextSpan(
text: 'ФРДО',
style: TextStyle(
color: appTheme.blueA400,
decoration: TextDecoration.underline,
),
recognizer: _frdoTapRecognizer,
),
],
),
),
onChanged: (value) {
cubit.updateAgreements(acceptFrdoAgreement: value ?? false);
},
activeColor: appTheme.blueA400,
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
controlAffinity: ListTileControlAffinity.leading,
),
],
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
child: _divider(context),
),
Padding(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), child: _divider(context)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: TextButton(
onPressed: () async => await launchUrl(Uri.parse(hidePlans ? '${sl<AppConfig>().baseUrl}/docs/privacy.pdf' : '${sl<AppConfig>().baseUrl}/oferta_bs.pdf')),
onPressed: () async => await launchUrl(
Uri.parse(
hidePlans
? '${sl<AppConfig>().baseUrl}/docs/privacy.pdf'
: '${sl<AppConfig>().baseUrl}/oferta_bs.pdf',
),
),
child: Text(
hidePlans ? 'Политика конфиденциальности и пользовательское соглашение' : 'Договор офёрты',
style: const TextStyle(height: 1, fontSize: 16, color: Color(0xFF323F4B), decoration: TextDecoration.underline),
hidePlans
? 'Политика конфиденциальности и пользовательское соглашение'
: 'Договор оферты',
style: const TextStyle(
height: 1,
fontSize: 16,
color: Color(0xFF323F4B),
decoration: TextDecoration.underline,
),
),
),
),
@@ -67,7 +193,10 @@ class _LastStepScreenState extends State<LastStepScreen> {
return Container(
height: 0.4,
width: MediaQuery.of(context).size.width - 32,
decoration: BoxDecoration(color: Colors.black26, borderRadius: BorderRadius.circular(16)),
decoration: BoxDecoration(
color: Colors.black26,
borderRadius: BorderRadius.circular(16),
),
);
}
}

View File

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

View File

@@ -29,32 +29,48 @@ class _PhotoOrScansScreenState extends State<PhotoOrScansScreen> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Загрузите фото или сканы дипломов и сертификатов', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, height: 1.1)),
const Text(
'Загрузите фото или сканы дипломов и сертификатов',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
height: 1.1,
),
),
const SizedBox(height: 16),
BlocBuilder<RegisterConsultantCubit, RegisterConsultantState>(
builder: (context, state) {
final photos = state.certPhotos.where((photo) => !state.deletedPhotoIds.contains(photo.id)).toList();
final photos = state.certPhotos
.where((photo) => !state.deletedPhotoIds.contains(photo.id))
.toList();
final addedPhotos = state.newAddedPhotos;
final totalPhotosCount = photos.length + addedPhotos.length;
return Column(
children: [
if (isUploading) ...[const LinearProgressIndicator(), const SizedBox(height: 16)],
if (isUploading) ...[
const LinearProgressIndicator(),
const SizedBox(height: 16),
],
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
childAspectRatio: 3 / 4,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
childAspectRatio: 3 / 4,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemBuilder: (BuildContext context, int index) {
if (index == totalPhotosCount) {
return InkWell(
borderRadius: BorderRadius.circular(20),
onTap: () => _pickAndUploadPhotos(),
child: Ink(
decoration: BoxDecoration(color: const Color(0xFFF6F6F6), borderRadius: BorderRadius.circular(20)),
decoration: BoxDecoration(
color: const Color(0xFFF6F6F6),
borderRadius: BorderRadius.circular(20),
),
child: Icon(Icons.add, color: appTheme.blueA400),
),
);
@@ -72,7 +88,8 @@ class _PhotoOrScansScreenState extends State<PhotoOrScansScreen> {
return _LocalCertPhotoCard(
photo: localPhoto,
allPhotos: addedPhotos,
onRemove: () => _removePhoto(path: localPhoto.uri.path),
onRemove: () =>
_removePhoto(path: localPhoto.uri.path),
);
}
}
@@ -99,7 +116,9 @@ class _PhotoOrScansScreenState extends State<PhotoOrScansScreen> {
});
try {
// ignore: use_build_context_synchronously
await context.read<RegisterConsultantCubit>().addCertPhotos(newImages.map((file) => File(file.path)).toList());
await context.read<RegisterConsultantCubit>().addCertPhotos(
newImages.map((file) => File(file.path)).toList(),
);
} finally {
if (mounted) {
setState(() {
@@ -119,7 +138,11 @@ class _CertPhotoCard extends StatelessWidget {
final List<ConsultantCertPhotoModel> allPhotos;
final VoidCallback onRemove;
const _CertPhotoCard({required this.photo, required this.allPhotos, required this.onRemove});
const _CertPhotoCard({
required this.photo,
required this.allPhotos,
required this.onRemove,
});
@override
Widget build(BuildContext context) {
@@ -131,7 +154,11 @@ class _CertPhotoCard extends StatelessWidget {
onTap: () {
context.pushTransparentRoute(
rootNavigator: true,
PhotoViewScreen(imageUrls: allPhotos.map((e) => e.url).toList(), isLocal: false, index: allPhotos.indexOf(photo)),
PhotoViewScreen(
imageUrls: allPhotos.map((e) => e.url).toList(),
isLocal: false,
index: allPhotos.indexOf(photo),
),
);
},
borderRadius: BorderRadius.circular(20),
@@ -141,7 +168,10 @@ class _CertPhotoCard extends StatelessWidget {
Ink(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(20)),
image: DecorationImage(fit: BoxFit.cover, image: NetworkImage(photo.url)),
image: DecorationImage(
fit: BoxFit.cover,
image: NetworkImage(photo.url),
),
),
),
Positioned(
@@ -152,8 +182,15 @@ class _CertPhotoCard extends StatelessWidget {
customBorder: const CircleBorder(),
child: Ink(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(color: Color(0xFFF1F2F6), shape: BoxShape.circle),
child: const Icon(size: 20, Icons.close, color: Colors.black),
decoration: const BoxDecoration(
color: Color(0xFFF1F2F6),
shape: BoxShape.circle,
),
child: const Icon(
size: 20,
Icons.close,
color: Colors.black,
),
),
),
),
@@ -170,7 +207,11 @@ class _LocalCertPhotoCard extends StatelessWidget {
final List<File> allPhotos;
final VoidCallback onRemove;
const _LocalCertPhotoCard({required this.photo, required this.allPhotos, required this.onRemove});
const _LocalCertPhotoCard({
required this.photo,
required this.allPhotos,
required this.onRemove,
});
@override
Widget build(BuildContext context) {
@@ -210,8 +251,15 @@ class _LocalCertPhotoCard extends StatelessWidget {
customBorder: const CircleBorder(),
child: Ink(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(color: Color(0xFFF1F2F6), shape: BoxShape.circle),
child: const Icon(size: 20, Icons.close, color: Colors.black),
decoration: const BoxDecoration(
color: Color(0xFFF1F2F6),
shape: BoxShape.circle,
),
child: const Icon(
size: 20,
Icons.close,
color: Colors.black,
),
),
),
),

View File

@@ -1,5 +1,7 @@
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class RegistrationAsScreen extends StatefulWidget {
const RegistrationAsScreen({super.key});
@@ -11,7 +13,26 @@ class RegistrationAsScreen extends StatefulWidget {
enum RegistrationAsType { physical, selfBusy, iP, ooo }
class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
RegistrationAsType registrationAsType = RegistrationAsType.physical;
static const RegistrationAsType registrationAsType =
RegistrationAsType.physical;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) {
return;
}
final cubit = context.read<RegisterConsultantCubit>();
cubit.updateConsultantProfileModel(
cubit.state.consultant.copyWith(
legalType: RegisterConsultantCubit.individualPersonLegalType,
),
cubit.state.acceptOffer,
cubit.state.acceptFrdoAgreement,
);
});
}
@override
Widget build(BuildContext context) {
@@ -23,7 +44,14 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
children: [
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Text('Регистрация в качестве', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, height: 1.1)),
child: Text(
'Регистрация в качестве',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
height: 1.1,
),
),
),
const SizedBox(height: 16),
Column(
@@ -36,16 +64,15 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
groupValue: registrationAsType,
activeColor: appTheme.blueA400,
// ignore: deprecated_member_use
onChanged: (value) {
setState(() {
registrationAsType = value!;
});
},
onChanged: (_) {},
),
const Expanded(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: Text('Физического лица', style: TextStyle(fontSize: 18, height: 1)),
child: Text(
'Физического лица',
style: TextStyle(fontSize: 18, height: 1),
),
),
),
],
@@ -58,16 +85,19 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
groupValue: registrationAsType,
activeColor: appTheme.blueA400,
// ignore: deprecated_member_use
onChanged: (value) {
setState(() {
registrationAsType = value!;
});
},
onChanged: null,
),
const Expanded(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: Text('Самозанятого', style: TextStyle(fontSize: 18, height: 1)),
child: Text(
'Самозанятого',
style: TextStyle(
fontSize: 18,
height: 1,
color: Colors.black38,
),
),
),
),
],
@@ -80,16 +110,19 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
groupValue: registrationAsType,
activeColor: appTheme.blueA400,
// ignore: deprecated_member_use
onChanged: (value) {
setState(() {
registrationAsType = value!;
});
},
onChanged: null,
),
const Expanded(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: Text('Индивидуального предпринимателя', style: TextStyle(fontSize: 18, height: 1)),
child: Text(
'Индивидуального предпринимателя',
style: TextStyle(
fontSize: 18,
height: 1,
color: Colors.black38,
),
),
),
),
],
@@ -102,26 +135,34 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
groupValue: registrationAsType,
activeColor: appTheme.blueA400,
// ignore: deprecated_member_use
onChanged: (value) {
setState(() {
registrationAsType = value!;
});
},
onChanged: null,
),
const Expanded(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: Text('Общества с ограниченной ответственностью', style: TextStyle(fontSize: 18, height: 1)),
child: Text(
'Общества с ограниченной ответственностью',
style: TextStyle(
fontSize: 18,
height: 1,
color: Colors.black38,
),
),
),
),
],
),
],
),
Padding(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), child: _divider(context)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
child: _divider(context),
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Text('* На данный момент регистрация доступна только в качестве физического лица. Остальные варианты скоро появятся'),
child: Text(
'* На данный момент регистрация доступна только в качестве физического лица. Остальные варианты скоро появятся',
),
),
],
),
@@ -132,7 +173,10 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
return Container(
height: 0.4,
width: MediaQuery.of(context).size.width - 32,
decoration: BoxDecoration(color: Colors.black26, borderRadius: BorderRadius.circular(16)),
decoration: BoxDecoration(
color: Colors.black26,
borderRadius: BorderRadius.circular(16),
),
);
}
}

View File

@@ -19,32 +19,47 @@ class _SocialNetworksScreenState extends State<SocialNetworksScreen> {
var maskFormatter = MaskTextInputFormatter(
mask: 'https://########################################',
filter: {
'#': RegExp(r"[a-zA-Z. ]+"),
},
filter: {'#': RegExp(r"[a-zA-Z. ]+")},
type: MaskAutoCompletionType.lazy,
);
var maskTgFormatter = MaskTextInputFormatter(
mask: 'https://tg.me/########################################',
filter: {
'#': RegExp(r"[a-zA-Z. ]+"),
},
filter: {'#': RegExp(r"[a-zA-Z. ]+")},
type: MaskAutoCompletionType.lazy,
);
var maskVkFormatter = MaskTextInputFormatter(
mask: 'https://vk.com/########################################',
filter: {
'#': RegExp(r"[a-zA-Z. ]+"),
},
filter: {'#': RegExp(r"[a-zA-Z. ]+")},
type: MaskAutoCompletionType.lazy,
);
@override
void initState() {
super.initState();
vkController.text = context.read<RegisterConsultantCubit>().state.consultant.socialLinks!.vkLink ?? '';
telegramController.text = context.read<RegisterConsultantCubit>().state.consultant.socialLinks!.tgLink ?? '';
webSiteController.text = context.read<RegisterConsultantCubit>().state.consultant.socialLinks!.personalSiteLink ?? '';
vkController.text =
context
.read<RegisterConsultantCubit>()
.state
.consultant
.socialLinks!
.vkLink ??
'';
telegramController.text =
context
.read<RegisterConsultantCubit>()
.state
.consultant
.socialLinks!
.tgLink ??
'';
webSiteController.text =
context
.read<RegisterConsultantCubit>()
.state
.consultant
.socialLinks!
.personalSiteLink ??
'';
}
@override
@@ -75,9 +90,7 @@ class _SocialNetworksScreenState extends State<SocialNetworksScreen> {
color: const Color(0xFFF6F6F6),
borderRadius: BorderRadius.circular(20),
),
child: SvgPicture.asset(
'assets/icons/vk.svg',
),
child: SvgPicture.asset('assets/icons/vk.svg'),
),
const SizedBox(width: 8),
SizedBox(
@@ -105,9 +118,7 @@ class _SocialNetworksScreenState extends State<SocialNetworksScreen> {
color: const Color(0xFFF6F6F6),
borderRadius: BorderRadius.circular(20),
),
child: SvgPicture.asset(
'assets/icons/telegram.svg',
),
child: SvgPicture.asset('assets/icons/telegram.svg'),
),
const SizedBox(width: 8),
SizedBox(
@@ -135,9 +146,7 @@ class _SocialNetworksScreenState extends State<SocialNetworksScreen> {
color: const Color(0xFFF6F6F6),
borderRadius: BorderRadius.circular(20),
),
child: SvgPicture.asset(
'assets/icons/web.svg',
),
child: SvgPicture.asset('assets/icons/web.svg'),
),
const SizedBox(width: 8),
SizedBox(
@@ -164,25 +173,45 @@ class _SocialNetworksScreenState extends State<SocialNetworksScreen> {
String? personalSiteLink,
}) {
context.read<RegisterConsultantCubit>().updateConsultantProfileModel(
context.read<RegisterConsultantCubit>().state.consultant.copyWith(
socialLinks: context.read<RegisterConsultantCubit>().state.consultant.socialLinks?.copyWith(
tgLink: tgLink == null
? context.read<RegisterConsultantCubit>().state.consultant.socialLinks?.tgLink
: tgLink.isEmpty
? null
: tgLink,
vkLink: vkLink == null
? context.read<RegisterConsultantCubit>().state.consultant.socialLinks?.vkLink
: vkLink.isEmpty
? null
: vkLink,
personalSiteLink: personalSiteLink == null
? context.read<RegisterConsultantCubit>().state.consultant.socialLinks?.personalSiteLink
: personalSiteLink.isEmpty
? null
: personalSiteLink,
),
),
);
context.read<RegisterConsultantCubit>().state.consultant.copyWith(
socialLinks: context
.read<RegisterConsultantCubit>()
.state
.consultant
.socialLinks
?.copyWith(
tgLink: tgLink == null
? context
.read<RegisterConsultantCubit>()
.state
.consultant
.socialLinks
?.tgLink
: tgLink.isEmpty
? null
: tgLink,
vkLink: vkLink == null
? context
.read<RegisterConsultantCubit>()
.state
.consultant
.socialLinks
?.vkLink
: vkLink.isEmpty
? null
: vkLink,
personalSiteLink: personalSiteLink == null
? context
.read<RegisterConsultantCubit>()
.state
.consultant
.socialLinks
?.personalSiteLink
: personalSiteLink.isEmpty
? null
: personalSiteLink,
),
),
);
}
}

View File

@@ -1,11 +1,13 @@
import 'package:eatvio_app/core/utils/image_constant.dart';
import 'package:eatvio_app/core/widgets/data_error_widget.dart';
import 'package:eatvio_app/core/widgets/loader.dart';
import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/additional_settings_screen.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/last_step_screen.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/social_networks_screen.dart';
@@ -21,67 +23,196 @@ import 'package:lottie/lottie.dart';
class StepperBecomeConsultantScreen extends StatefulWidget {
final String? slug;
final bool? isCanceled;
const StepperBecomeConsultantScreen({super.key, this.slug, this.isCanceled});
final bool isNewRegistration;
const StepperBecomeConsultantScreen({
super.key,
this.slug,
this.isCanceled,
this.isNewRegistration = false,
});
@override
State<StepperBecomeConsultantScreen> createState() => _StepperBecomeConsultantScreenState();
State<StepperBecomeConsultantScreen> createState() =>
_StepperBecomeConsultantScreenState();
}
class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantScreen> {
double progressValue = 1 / 6;
class _StepperBecomeConsultantScreenState
extends State<StepperBecomeConsultantScreen> {
double progressValue = 0;
int step = 1;
final pageController = PageController();
late final RegisterConsultantCubit registrationConsultantCubit;
bool isLoading = false;
bool _successSheetShown = false;
@override
void initState() {
super.initState();
if (widget.slug == null) {
registrationConsultantCubit = sl<RegisterConsultantCubit>()..disableLoading();
if (widget.isNewRegistration || widget.slug == null) {
registrationConsultantCubit = sl<RegisterConsultantCubit>()
..disableLoading(slug: widget.slug);
} else {
registrationConsultantCubit = sl<RegisterConsultantCubit>()..getMyRequestStatus(widget.slug!);
registrationConsultantCubit = sl<RegisterConsultantCubit>()
..getMyRequestStatus(widget.slug!);
}
}
static const int totalSteps = 7;
List<Widget> _pages() {
return const [
CertificatesAndDiplomasScreen(),
PhotoOrScansScreen(),
AdditionalSettingsScreen(),
SocialNetworksScreen(),
RegistrationAsScreen(),
PassportDataScreen(),
LastStepScreen(),
];
}
Future<void> _showSuccessSheet(BuildContext context) async {
if (_successSheetShown) {
return;
}
_successSheetShown = true;
await showModalBottomSheet(
useRootNavigator: true,
showDragHandle: true,
context: context,
builder: (context) => Container(
padding: const EdgeInsets.all(16),
width: MediaQuery.of(context).size.width,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Спасибо!\nВаши данные отправлены на модерацию',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 20,
height: 1,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
Lottie.asset(
'assets/lottie_animations/success.json',
width: 160,
height: 160,
),
const SizedBox(height: 16),
const Text(
'В течении трех рабочих дней Вам придет уведомление о смене статуса',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
height: 1,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 16),
InkWell(
onTap: () {
Navigator.of(context).pop();
GoRouter.of(context).pop();
},
child: Ink(
width: MediaQuery.of(context).size.width - 32,
height: 52,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: appTheme.blueA400,
),
child: const Center(
child: Text(
'Закрыть',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
),
),
),
],
),
),
);
}
@override
Widget build(BuildContext context) {
return BlocProvider<RegisterConsultantCubit>(
create: (context) => registrationConsultantCubit,
child: BlocBuilder<RegisterConsultantCubit, RegisterConsultantState>(
builder: (context, state) {
if (state.loading) {
return const Scaffold(body: Loader());
}
if (state.error != null) {
child: BlocListener<RegisterConsultantCubit, RegisterConsultantState>(
listenWhen: (previous, current) =>
previous.fieldErrors.isEmpty &&
current.fieldErrors.isNotEmpty &&
current.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(
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),
),
);
}
return Scaffold(
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
floatingActionButtonLocation:
FloatingActionButtonLocation.centerFloat,
floatingActionButton: Row(
children: [
Container(
@@ -93,33 +224,51 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
highlightElevation: 0,
focusElevation: 0,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
backgroundColor: const Color(0xFFEAF2FD),
onPressed: () async {
setState(() {
pageController.previousPage(duration: const Duration(milliseconds: 350), curve: Curves.easeInOut);
pageController.previousPage(
duration: const Duration(milliseconds: 350),
curve: Curves.easeInOut,
);
});
},
label: Icon(Icons.arrow_back_ios_new_rounded, color: appTheme.blueA400),
label: Icon(
Icons.arrow_back_ios_new_rounded,
color: appTheme.blueA400,
),
),
),
if (widget.isCanceled == true)
if (widget.isCanceled == true && step != totalSteps)
InkWell(
onTap: () {
final canceledModeration =
state.requestModel?.canceledModeration;
if (canceledModeration == null) return;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(state.requestModel!.canceledModeration!.title),
title: Text(canceledModeration.title),
content: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
for (int i = 0; i < state.requestModel!.canceledModeration!.additional!.length; i++) ...[
Text('• ${state.requestModel!.canceledModeration!.additional![i]}'),
],
if (canceledModeration.additional != null)
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(
height: 48,
width: MediaQuery.of(context).size.width * 0.6,
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: const Color(0x33FFCC00)),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: const Color(0x33FFCC00),
),
child: const Center(
child: Text(
'Комментарий',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF323F4B), height: 1),
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Color(0xFF323F4B),
height: 1,
),
),
),
),
),
Container(
height: 48,
width: widget.isCanceled == true
width: widget.isCanceled == true && step != totalSteps
? MediaQuery.of(context).size.width * 0.2
: MediaQuery.of(context).size.width * 0.8,
padding: const EdgeInsets.only(left: 4, right: 16),
@@ -147,85 +304,77 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
highlightElevation: 0,
elevation: 0,
focusElevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
backgroundColor: isLoading
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
backgroundColor: submitDisabled
? Colors.grey
: widget.isCanceled == true
: widget.isCanceled == true && step != totalSteps
? const Color(0xFFEAF2FD)
: appTheme.blueA400,
onPressed: isLoading
onPressed: submitDisabled
? null
: () async {
if (step == 6) {
if (widget.isCanceled == true) {
if (step == totalSteps) {
final passportErrors =
registrationConsultantCubit
.validatePassportFields();
if (passportErrors.isNotEmpty) {
pageController.jumpToPage(5);
return;
}
if (registrationConsultantCubit.state.acceptOffer) {
if (registrationConsultantCubit
.hasAcceptedRequiredAgreements) {
setState(() {
isLoading = true;
});
try {
await registrationConsultantCubit.registerConsultant();
await registrationConsultantCubit
.registerConsultant();
if (!context.mounted) return;
if (registrationConsultantCubit.state.error != null) {
return;
}
await showModalBottomSheet(
useRootNavigator: true,
showDragHandle: true,
context: context,
builder: (context) => Container(
padding: const EdgeInsets.all(16),
width: MediaQuery.of(context).size.width,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Спасибо!\nВаши данные отправлены на модерацию',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 20, height: 1, fontWeight: FontWeight.w600),
),
const SizedBox(height: 16),
Lottie.asset('assets/lottie_animations/success.json', width: 160, height: 160),
const SizedBox(height: 16),
const Text(
'В течении трех рабочих дней Вам придет уведомление о смене статуса',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 16, height: 1, fontWeight: FontWeight.w500),
),
const SizedBox(height: 16),
InkWell(
onTap: () {
Navigator.of(context).pop();
GoRouter.of(context).pop();
},
child: Ink(
width: MediaQuery.of(context).size.width - 32,
height: 52,
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
child: const Center(
child: Text(
'Закрыть',
style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600),
),
),
if (registrationConsultantCubit
.state
.error !=
null) {
showDialog(
context: context,
builder: (context) => AlertDialog(
actionsPadding:
const EdgeInsets.symmetric(
horizontal: 4,
),
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) {
if (!context.mounted) return;
showDialog<void>(
context: context,
builder: (context) => AlertDialog(
actionsPadding: const EdgeInsets.symmetric(horizontal: 4),
actionsPadding:
const EdgeInsets.symmetric(
horizontal: 4,
),
content: Text(e.toString()),
actions: [
TextButton(
child: const Text('Ок'),
onPressed: () => Navigator.of(context).pop(),
onPressed: () =>
Navigator.of(context).pop(),
),
],
),
@@ -239,8 +388,15 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
showDialog(
context: context,
builder: (context) => AlertDialog(
actionsPadding: const EdgeInsets.symmetric(horizontal: 4),
content: const Text('Примите договор публичной офёрты'),
actionsPadding:
const EdgeInsets.symmetric(
horizontal: 4,
),
content: Text(
state.isUpdating
? 'Примите договор публичной оферты'
: 'Примите оферту и согласие ФРДО',
),
actions: [
TextButton(
child: const Text('Ок'),
@@ -255,15 +411,26 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
} else {
FocusManager.instance.primaryFocus?.unfocus();
setState(() {
pageController.nextPage(duration: const Duration(milliseconds: 350), curve: Curves.easeInOut);
pageController.nextPage(
duration: const Duration(milliseconds: 350),
curve: Curves.easeInOut,
);
});
}
},
label: isLoading
label: submitDisabled && step == totalSteps
? const CircularProgressIndicator()
: widget.isCanceled == true
? Icon(Icons.arrow_forward_ios_outlined, color: appTheme.blueA400)
: Text('Продолжить', style: theme.textTheme.titleSmall!.copyWith(color: Colors.white)),
: widget.isCanceled == true && step != totalSteps
? Icon(
Icons.arrow_forward_ios_outlined,
color: appTheme.blueA400,
)
: Text(
step == totalSteps ? 'Отправить' : 'Продолжить',
style: theme.textTheme.titleSmall!.copyWith(
color: Colors.white,
),
),
),
),
],
@@ -271,11 +438,23 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
appBar: AppBar(
automaticallyImplyLeading: false,
leading: Builder(
builder: (context) => IconButton(icon: const Icon(Icons.close_rounded), onPressed: () => GoRouter.of(context).pop()),
builder: (context) => IconButton(
icon: const Icon(Icons.close_rounded),
onPressed: () => GoRouter.of(context).pop(),
),
),
centerTitle: true,
title: SvgPicture.asset(ImageConstant.imgLogoEatvio, height: 48, fit: BoxFit.contain),
actions: [Padding(padding: const EdgeInsets.only(right: 16), child: Text('шаг $step из 6'))],
title: SvgPicture.asset(
ImageConstant.imgLogoEatvio,
height: 48,
fit: BoxFit.contain,
),
actions: [
Padding(
padding: const EdgeInsets.only(right: 16),
child: Text('шаг $step из $totalSteps'),
),
],
bottom: PreferredSize(
preferredSize: Size(MediaQuery.of(context).size.width, 32),
child: Padding(
@@ -283,13 +462,18 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
child: TweenAnimationBuilder<double>(
duration: const Duration(milliseconds: 350),
curve: Curves.easeInOut,
tween: Tween<double>(begin: 1 / 6, end: progressValue),
tween: Tween<double>(
begin: 1 / totalSteps,
end: progressValue,
),
builder: (context, value, _) => LinearProgressIndicator(
value: value,
borderRadius: BorderRadius.circular(16),
minHeight: 8,
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) {
setState(() {
step = index + 1;
progressValue = step / 6;
progressValue = step / totalSteps;
});
},
children: const [
CertificatesAndDiplomasScreen(),
PhotoOrScansScreen(),
AdditionalSettingsScreen(),
SocialNetworksScreen(),
RegistrationAsScreen(),
LastStepScreen(),
],
children: _pages(),
),
);
},
},
),
),
);
}

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ import 'package:dio/dio.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/services/consultant_service.dart';
import 'package:http_parser/http_parser.dart';
@@ -11,36 +12,53 @@ class ConsultantRepository {
const ConsultantRepository({required this.consultantService});
Future<void> registerConsultant({required ConsultantProfileModel consultant, bool update = false}) async {
Future<void> registerConsultant({
required ConsultantProfileModel consultant,
bool update = false,
}) async {
if (update) {
return await consultantService.consultantUpdate(consultantModel: consultant);
return await consultantService.consultantUpdate(
consultantModel: consultant,
);
} else {
return await consultantService.consultantRegistration(consultantModel: consultant);
return await consultantService.consultantRegistration(
consultantModel: consultant,
);
}
}
Future<ConsultationRequestModel> getConsultantRequestStatus(String slug) async {
/// Профиль консультанта (свой или чужой) — публичные данные без паспорта.
Future<ConsultationRequestModel> getConsultantRequestStatus(
String slug,
) async {
return await consultantService.getConsultantRequestStatus(slug: slug);
}
/// Своя заявка — education + legal/passport для регистрации и update.
Future<ConsultationRequestModel> getMyRequestStatus(String slug) async {
return await consultantService.getMyRequestStatus(slug: slug);
}
Future<List<SbpBankModel>> getSbpBanks() async {
return await consultantService.getSbpBanks();
}
Future<List<ConsultantCertPhotoModel>> getConsultantCertPhotos() async {
return await consultantService.getConsultantCertPhotos();
}
Future<List<ConsultantCertPhotoModel>> storeConsultantCertPhotos({required List<File> files}) async {
Future<List<ConsultantCertPhotoModel>> storeConsultantCertPhotos({
required List<File> files,
}) async {
final multipartFiles = <MultipartFile>[];
for (final file in files) {
final fileBytes = await file.readAsBytes();
final multipartFile = MultipartFile.fromBytes(fileBytes, filename: file.path.split('/').last, contentType: MediaType('application', 'octet-stream'));
final multipartFile = MultipartFile.fromBytes(
fileBytes,
filename: file.path.split('/').last,
contentType: MediaType('application', 'octet-stream'),
);
multipartFiles.add(multipartFile);
}
@@ -48,22 +66,24 @@ class ConsultantRepository {
return [];
}
return await consultantService.storeConsultantCertPhotos(photos: multipartFiles);
return await consultantService.storeConsultantCertPhotos(
photos: multipartFiles,
);
}
Future<void> hideConsultantCertPhoto(int id) async {
await consultantService.hideConsultantCertPhoto(payload: {'cert_photo_id': id});
await consultantService.hideConsultantCertPhoto(
payload: {'cert_photo_id': id},
);
}
Future<void> unHideConsultantCertPhoto(int id) async {
await consultantService.unHideConsultantCertPhoto(payload: {'cert_photo_id': id});
await consultantService.unHideConsultantCertPhoto(
payload: {'cert_photo_id': id},
);
}
Future<void> deletePhoto(int id) async {
await consultantService.deletePhoto(id: id);
}
}

View File

@@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart';
import 'package:retrofit/retrofit.dart';
part 'consultant_service.g.dart';
@@ -10,21 +11,39 @@ part 'consultant_service.g.dart';
abstract class ConsultantService {
factory ConsultantService(Dio dio, {String baseUrl}) = _ConsultantService;
@POST('/api/consultant_request/registration')
Future<void> consultantRegistration({@Body() required ConsultantProfileModel consultantModel});
@POST('/api/v2/consultant_request/registration')
Future<void> consultantRegistration({
@Body() required ConsultantProfileModel consultantModel,
});
@POST('/api/consultant_request/photos/upload')
@MultiPart()
Future<List<String>> uploadPhotos({@Part(name: 'photos[]', contentType: 'multipart/form-data') List<MultipartFile>? photos});
Future<List<String>> uploadPhotos({
@Part(name: 'photos[]', contentType: 'multipart/form-data')
List<MultipartFile>? photos,
});
/// Публичный статус консультанта для просмотра профиля.
/// Без паспортных данных: `/api/profile/consultant/{slug}/status`.
@GET('/api/profile/consultant/{slug}/status')
Future<ConsultationRequestModel> getConsultantRequestStatus({@Path() required String slug});
Future<ConsultationRequestModel> getConsultantRequestStatus({
@Path() required String slug,
});
@GET('/api/consultant_request/{slug}/status')
Future<ConsultationRequestModel> getMyRequestStatus({@Path() required String slug});
/// Свой статус заявки для редактирования/модерации.
/// С паспортными полями в `legal_entity_resource`: `/api/v2/consultant_request/{slug}/status`.
@GET('/api/v2/consultant_request/{slug}/status')
Future<ConsultationRequestModel> getMyRequestStatus({
@Path() required String slug,
});
@PUT('/api/consultant_request/update')
Future<void> consultantUpdate({@Body() required ConsultantProfileModel consultantModel});
@PUT('/api/v2/consultant_request/update')
Future<void> consultantUpdate({
@Body() required ConsultantProfileModel consultantModel,
});
@GET('/api/finances/sbp_banks')
Future<List<SbpBankModel>> getSbpBanks();
@GET('/api/v2/consultant_request/cert_photos')
Future<List<ConsultantCertPhotoModel>> getConsultantCertPhotos();
@@ -32,14 +51,19 @@ abstract class ConsultantService {
@POST('/api/v2/consultant_request/cert_photos/store')
@MultiPart()
Future<List<ConsultantCertPhotoModel>> storeConsultantCertPhotos({
@Part(name: 'photos[]', contentType: 'multipart/form-data') required List<MultipartFile> photos,
@Part(name: 'photos[]', contentType: 'multipart/form-data')
required List<MultipartFile> photos,
});
@POST('/api/v2/consultant_request/cert_photos/hide')
Future<void> hideConsultantCertPhoto({@Body() required Map<String, dynamic> payload});
Future<void> hideConsultantCertPhoto({
@Body() required Map<String, dynamic> payload,
});
@POST('/api/v2/consultant_request/cert_photos/un_hide')
Future<void> unHideConsultantCertPhoto({@Body() required Map<String, dynamic> payload});
Future<void> unHideConsultantCertPhoto({
@Body() required Map<String, dynamic> payload,
});
@POST('/api/v2/consultant_request/cert_photos/{id}/queue-for-deletion')
Future<void> deletePhoto({@Path() required int id});

View File

@@ -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 'Проверьте поле';
}

View File

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

View File

@@ -59,43 +59,47 @@ class _AddMealWidgetState extends State<AddMealWidget> {
Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: MediaQuery.of(context).size.width * 0.8 - 32,
child: CustomTextFormField(
controller: productNameController,
autofocus: true,
hintText: 'Введите название продукта/блюда',
onChanged: (query) {
EasyDebounce.debounce('glossary-search', const Duration(milliseconds: 250), () async {
selectedGlossary = null;
if (query.isEmpty) {
glossariesList = [];
Expanded(
flex: 2,
child: SizedBox(
child: CustomTextFormField(
controller: productNameController,
autofocus: true,
hintText: 'Введите название продукта/блюда',
onChanged: (query) {
EasyDebounce.debounce('glossary-search', const Duration(milliseconds: 250), () async {
selectedGlossary = null;
if (query.isEmpty) {
glossariesList = [];
selectedGlossary = null;
setState(() {});
return;
}
glossariesList = await widget.createDietCubit.getGlossaryListByQuery(query);
setState(() {});
return;
}
glossariesList = await widget.createDietCubit.getGlossaryListByQuery(query);
setState(() {});
});
},
});
},
),
),
),
const SizedBox(width: 4),
SizedBox(
width: MediaQuery.of(context).size.width * 0.2 - 4,
child: CustomTextFormField(
controller: quantityController,
keyboardType: TextInputType.number,
hintText: selectedGlossary == null
? '100'
: selectedGlossary!.grInPortion == 0
? '100'
: '1',
suffixText: selectedGlossary == null
? 'гр'
: selectedGlossary?.grInPortion == 0
? 'гр'
: 'шт',
Expanded(
flex: 1,
child: SizedBox(
child: CustomTextFormField(
controller: quantityController,
keyboardType: TextInputType.number,
hintText: selectedGlossary == null
? '100'
: selectedGlossary!.grInPortion == 0
? '100'
: '1',
suffixText: selectedGlossary == null
? 'гр'
: selectedGlossary?.grInPortion == 0
? 'гр'
: 'шт',
),
),
),
],
@@ -149,17 +153,19 @@ class _AddMealWidgetState extends State<AddMealWidget> {
),
),
if (widget.portionIndex != null) const SizedBox(width: 8),
InkWell(
onTap: addPortion,
borderRadius: BorderRadius.circular(20),
child: Ink(
width: MediaQuery.of(context).size.width - (widget.portionIndex == null ? 32 : 92),
height: 52,
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
child: Center(
child: Text(
widget.portionIndex == null ? 'Добавить' : 'Сохранить',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white),
Expanded(
child: InkWell(
onTap: addPortion,
borderRadius: BorderRadius.circular(20),
child: Ink(
width: MediaQuery.of(context).size.width - (widget.portionIndex == null ? 32 : 92),
height: 52,
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
child: Center(
child: Text(
widget.portionIndex == null ? 'Добавить' : 'Сохранить',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white),
),
),
),
),

View File

@@ -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_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/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/options_count_button.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 {
static const name = 'createPlanScreen';
static const path = '/create-Plan-screen';
const CreatePlanScreen({super.key});
const CreatePlanScreen({super.key, this.plan});
final PlanModel? plan;
@override
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
Widget build(BuildContext context) {
final s = S.of(context);
final isEditing = context.read<CreatePlanCubit>().sourcePlan != null;
return BlocBuilder<CreatePlanCubit, CreatePlanState>(
buildWhen: (previous, current) => previous.planCreated != current.planCreated || previous.isLoading != current.isLoading,
builder: (context, state) {
if (state.planCreated) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted) return;
context.pop();
final isEditing = context.read<CreatePlanCubit>().sourcePlan != null;
context.pop(true);
showBottomSheetWithBlur(
userRootNavigator: true,
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(
centerTitle: true,
title: Text(
s.adding_plan,
isEditing ? 'Редактирование плана' : s.adding_plan,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
softWrap: true,
@@ -102,7 +117,9 @@ class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
return Column(
children: [
CustomTextFormField(
key: const ValueKey('plan-name'),
hintText: s.plan_label_desc,
initialValue: state.planData.name,
hasError: state.validationState.titleHasError,
onChanged: (value) {
context.read<CreatePlanCubit>().setName(value);
@@ -144,6 +161,7 @@ class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
children: [
MarkdownEditor(
key: _mdKey,
initialText: state.planData.details,
hintText: s.plan_desc_example,
hasError: state.validationState.descriptionHasError,
minLines: 6,
@@ -230,7 +248,7 @@ class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
onPressed: () async {
await context.read<CreatePlanCubit>().publishPlan();
},
text: s.publish,
text: isEditing ? 'Обновить' : s.publish,
height: 52,
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
buttonStyle: ButtonStyle(

View File

@@ -4,16 +4,57 @@ import 'dart:io';
import 'package:dio/dio.dart';
import 'package:eatvio_app/core/const.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/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:eatvio_app/core/error_reporting/report_from.dart';
import 'package:flutter_image_compress/flutter_image_compress.dart';
class CreatePlanCubit extends Cubit<CreatePlanState> {
final CreatePlanRepository createPlanRepository;
PlanModel? sourcePlan;
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) {
emit(state.copyWith(planData: state.planData.copyWith(details: text)));
validateDetails();
@@ -147,7 +188,7 @@ class CreatePlanCubit extends Cubit<CreatePlanState> {
}
bool validateImage() {
if (state.planData.image == null) {
if (state.planData.image == null && sourcePlan == null) {
emit(state.copyWith.validationState(imageHasError: true));
return false;
}
@@ -413,31 +454,34 @@ class CreatePlanCubit extends Cubit<CreatePlanState> {
'type[fit]': state.planData.type[1] ? '1' : '0',
'type[wellness]': state.planData.type[2] ? '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) {
planMap.addAll({
'packets_count': 3,
'packet1_options': {
'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,
'items': Map.fromIterable(state.planData.options[0].items),
'has_personal_diet': state.planData.options[0].hasPersonalDiet ? '1' : '0',
},
'packet2_options': {
'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,
'items': Map.fromIterable(state.planData.options[1].items),
'has_personal_diet': state.planData.options[1].hasPersonalDiet ? '1' : '0',
},
'packet3_options': {
'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,
'items': Map.fromIterable(state.planData.options[2].items),
'has_personal_diet': state.planData.options[2].hasPersonalDiet ? '1' : '0',
@@ -448,20 +492,34 @@ class CreatePlanCubit extends Cubit<CreatePlanState> {
'packets_count': 1,
'packet1_options': {
'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,
'items': Map.fromIterable(state.planData.options[0].items),
'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));
}
} catch (e, st) {
await reportFrom(e, st, this);
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());
}
}

View File

@@ -27,6 +27,12 @@ class AddItemWidget extends StatefulWidget {
class _AddItemWidgetState extends State<AddItemWidget> {
final controller = TextEditingController();
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
void didUpdateWidget(oldWidget) {
super.didUpdateWidget(oldWidget);
@@ -38,14 +44,24 @@ class _AddItemWidgetState extends State<AddItemWidget> {
@override
Widget build(BuildContext 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];
}
return BlocBuilder<CreatePlanCubit, CreatePlanState>(
buildWhen: (previous, current) => previous.validationState != current.validationState,
buildWhen: (previous, current) =>
previous.validationState != current.validationState,
builder: (context, state) {
final hasError = state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].isItemsEmpty ||
(state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].itemsErrors[widget.index] ?? false);
final hasError =
state
.validationState
.optionsValidationState[state.selectedOptionIndex ?? 0]
.isItemsEmpty ||
(state
.validationState
.optionsValidationState[state.selectedOptionIndex ?? 0]
.itemsErrors[widget.index] ??
false);
return Column(
children: [
Row(
@@ -54,9 +70,14 @@ class _AddItemWidgetState extends State<AddItemWidget> {
child: CustomTextFormField(
controller: controller,
hasError: hasError,
hintText: widget.enableHint ? s.option_content_hint_text : null,
hintText: widget.enableHint
? s.option_content_hint_text
: null,
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),
GestureDetector(
onTap: () {
context.read<CreatePlanCubit>().removeOptionContentItem(widget.index);
context.read<CreatePlanCubit>().removeOptionContentItem(
widget.index,
);
},
child: Container(
padding: const EdgeInsets.all(14),
@@ -72,19 +95,30 @@ class _AddItemWidgetState extends State<AddItemWidget> {
color: appTheme.lightGray0,
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),
ValidationErrorWidget(error: s.addAtLeastOneItem)
] else if (state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].itemsErrors[widget.index] ?? false) ...[
ValidationErrorWidget(error: s.addAtLeastOneItem),
] else if (state
.validationState
.optionsValidationState[state.selectedOptionIndex ?? 0]
.itemsErrors[widget.index] ??
false) ...[
const SizedBox(height: 4),
ValidationErrorWidget(error: s.atLeast3Symbols)
]
ValidationErrorWidget(error: s.atLeast3Symbols),
],
],
);
},

View File

@@ -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_state.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:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -21,47 +22,107 @@ class OptionWidget extends StatefulWidget {
}
class _OptionWidgetState extends State<OptionWidget> {
final firstOptionCustomerPriceController = TextEditingController()..text = '1200₽';
final secondOptionCustomerPriceController = TextEditingController()..text = '1200₽';
final thirdOptionCustomerPriceController = TextEditingController()..text = '1200₽';
static const _optionsCount = 3;
final creatorPriceController = TextEditingController()..text = '1000₽';
final firstOptionTitleController = TextEditingController();
final secondOptionTitleController = TextEditingController();
final thirdOptionTitleController = TextEditingController();
final firstOptionDescriptionController = TextEditingController();
final secondOptionDescriptionController = TextEditingController();
final thirdOptionDescriptionController = TextEditingController();
final List<TextEditingController> _customerPriceControllers = List.generate(
_optionsCount,
(_) => TextEditingController(text: '1200₽'),
);
final List<TextEditingController> _titleControllers = List.generate(
_optionsCount,
(_) => TextEditingController(),
);
final List<TextEditingController> _descriptionControllers = List.generate(
_optionsCount,
(_) => TextEditingController(),
);
final creatorPriceController = TextEditingController(text: '1000₽');
int? _syncedOptionIndex;
bool firstExplanationVisible = false;
bool isChecked = false;
bool secondExplanationVisible = false;
@override
void initState() {
super.initState();
firstOptionCustomerPriceController.addListener(() {
if (!firstOptionCustomerPriceController.text.endsWith('₽')) {
final text = firstOptionCustomerPriceController.text.replaceAll('₽', '');
firstOptionCustomerPriceController.text = '$text₽';
firstOptionCustomerPriceController.selection = TextSelection.collapsed(offset: text.length);
}
});
secondOptionCustomerPriceController.addListener(() {
if (!secondOptionCustomerPriceController.text.endsWith('₽')) {
final text = secondOptionCustomerPriceController.text.replaceAll('₽', '');
secondOptionCustomerPriceController.text = '$text₽';
secondOptionCustomerPriceController.selection = TextSelection.collapsed(offset: text.length);
}
});
thirdOptionCustomerPriceController.addListener(() {
if (!thirdOptionCustomerPriceController.text.endsWith('₽')) {
final text = thirdOptionCustomerPriceController.text.replaceAll('₽', '');
thirdOptionCustomerPriceController.text = '$text₽';
thirdOptionCustomerPriceController.selection = TextSelection.collapsed(offset: text.length);
}
});
for (final controller in _customerPriceControllers) {
controller.addListener(() => _ensureRubleSuffix(controller));
}
}
@override
void dispose() {
for (final controller in _customerPriceControllers) {
controller.dispose();
}
for (final controller in _titleControllers) {
controller.dispose();
}
for (final controller in _descriptionControllers) {
controller.dispose();
}
creatorPriceController.dispose();
super.dispose();
}
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
@@ -70,31 +131,21 @@ class _OptionWidgetState extends State<OptionWidget> {
return BlocBuilder<CreatePlanCubit, CreatePlanState>(
builder: (context, state) {
final selectedOptionIndex = state.selectedOptionIndex ?? 0;
_syncSelectedOption(state);
final optionNameHintText = switch (selectedOptionIndex) {
0 => s.economy,
2 => s.comfort,
_ => s.standard,
};
final optionTitleController = switch (selectedOptionIndex) {
0 => firstOptionTitleController,
2 => thirdOptionTitleController,
_ => secondOptionTitleController,
};
final optionDescriptionController = switch (selectedOptionIndex) {
0 => firstOptionDescriptionController,
2 => thirdOptionDescriptionController,
_ => secondOptionDescriptionController,
};
final customerPriceController = switch (selectedOptionIndex) {
0 => firstOptionCustomerPriceController,
2 => thirdOptionCustomerPriceController,
_ => secondOptionCustomerPriceController,
};
final optionTitleController = _titleControllers[selectedOptionIndex];
final optionDescriptionController =
_descriptionControllers[selectedOptionIndex];
final customerPriceController =
_customerPriceControllers[selectedOptionIndex];
final optionItems = state.planData.options[selectedOptionIndex].items;
final optionValidation =
state.validationState.optionsValidationState[selectedOptionIndex];
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(
padding: const EdgeInsets.all(16),
child: Column(
@@ -110,14 +161,14 @@ class _OptionWidgetState extends State<OptionWidget> {
const SizedBox(height: 4),
CustomTextFormField(
hintText: optionNameHintText,
hasError: state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].titleHasError,
hasError: optionValidation.titleHasError,
controller: optionTitleController,
onChanged: (value) {
context.read<CreatePlanCubit>().setOptionTitle(value);
},
maxLength: 150,
),
if (state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].titleHasError) ...[
if (optionValidation.titleHasError) ...[
const SizedBox(height: 4),
ValidationErrorWidget(error: s.atLeast3Symbols),
],
@@ -135,7 +186,7 @@ class _OptionWidgetState extends State<OptionWidget> {
child: CustomTextFormField(
controller: optionDescriptionController,
textAlignVertical: TextAlignVertical.top,
hasError: state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].descriptionHasError,
hasError: optionValidation.descriptionHasError,
maxLength: 1000,
minLines: 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),
ValidationErrorWidget(error: s.atLeast3Symbols),
],
@@ -158,7 +209,9 @@ class _OptionWidgetState extends State<OptionWidget> {
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
softWrap: true,
style: TextStyles.textSMedium.copyWith(color: appTheme.black),
style: TextStyles.textSMedium.copyWith(
color: appTheme.black,
),
),
const SizedBox(width: 4),
const _PriceExplanationWidget(),
@@ -179,7 +232,9 @@ class _OptionWidgetState extends State<OptionWidget> {
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
softWrap: true,
style: TextStyles.textSMedium.copyWith(color: appTheme.black),
style: TextStyles.textSMedium.copyWith(
color: appTheme.black,
),
),
const SizedBox(width: 4),
const _PriceExplanationWidget(),
@@ -188,8 +243,10 @@ class _OptionWidgetState extends State<OptionWidget> {
const SizedBox(height: 4),
CustomTextFormField(
controller: customerPriceController,
textInputType: const TextInputType.numberWithOptions(decimal: true),
hasError: state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].priceHasError,
textInputType: const TextInputType.numberWithOptions(
decimal: true,
),
hasError: optionValidation.priceHasError,
onChanged: (value) {
final price = double.tryParse(value.replaceAll('₽', ''));
if (price != null) {
@@ -222,34 +279,41 @@ class _OptionWidgetState extends State<OptionWidget> {
separatorBuilder: (context, index) => const SizedBox(height: 8),
reverse: false,
itemBuilder: (context, index) {
final items = state.planData.options[selectedOptionIndex].items;
if (items.isEmpty) {
if (optionItems.isEmpty) {
return AddItemWidget(
key: ValueKey(
'plan-option-$selectedOptionIndex-item-$index',
),
index: index,
canBeDeleted: false,
enableHint: true,
items: items,
items: optionItems,
);
} else {
if (index < items.length) {
if (index < optionItems.length) {
return AddItemWidget(
key: ValueKey(
'plan-option-$selectedOptionIndex-item-$index',
),
canBeDeleted: true,
enableHint: false,
index: index,
items: items,
items: optionItems,
);
} else {
return AddItemWidget(
key: ValueKey(
'plan-option-$selectedOptionIndex-item-$index',
),
canBeDeleted: false,
enableHint: false,
index: index,
items: items,
items: optionItems,
);
}
}
},
itemCount: state.planData.options[selectedOptionIndex].items.length + 1,
itemCount: optionItems.length + 1,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
),
@@ -259,23 +323,30 @@ class _OptionWidgetState extends State<OptionWidget> {
Row(
children: [
Checkbox(
value: state.planData.options[selectedOptionIndex].hasPersonalDiet,
value: state
.planData
.options[selectedOptionIndex]
.hasPersonalDiet,
activeColor: appTheme.gray,
checkColor: appTheme.gray,
fillColor: const WidgetStatePropertyAll(Colors.transparent),
side: WidgetStateBorderSide.resolveWith(
(Set<WidgetState> states) {
return BorderSide(width: 1, color: appTheme.gray);
},
),
side: WidgetStateBorderSide.resolveWith((
Set<WidgetState> states,
) {
return BorderSide(width: 1, color: appTheme.gray);
}),
onChanged: (bool? value) {
context.read<CreatePlanCubit>().togglePersonalizedDietOptionState();
context
.read<CreatePlanCubit>()
.togglePersonalizedDietOptionState();
},
),
Expanded(
child: Text(
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();
@override
State<_PriceExplanationWidget> createState() => _PriceExplanationWidgetState();
State<_PriceExplanationWidget> createState() =>
_PriceExplanationWidgetState();
}
class _PriceExplanationWidgetState extends State<_PriceExplanationWidget> {
@@ -314,9 +386,7 @@ class _PriceExplanationWidgetState extends State<_PriceExplanationWidget> {
color: visible ? appTheme.whiteA700 : Colors.transparent,
),
duration: const Duration(milliseconds: 300),
child: Text(
s.price_explanation,
),
child: Text(s.price_explanation),
),
),
visible: visible,
@@ -331,7 +401,11 @@ class _PriceExplanationWidgetState extends State<_PriceExplanationWidget> {
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,
),
),
);
}

View File

@@ -1,7 +1,9 @@
import 'dart:io';
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/locator_service.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/theme_helper.dart';
@@ -91,6 +93,18 @@ class _PlanImagePickerWidgetState extends State<PlanImagePickerWidget> {
final s = S.of(context);
return BlocBuilder<CreatePlanCubit, CreatePlanState>(
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(
children: [
GestureDetector(
@@ -101,12 +115,12 @@ class _PlanImagePickerWidgetState extends State<PlanImagePickerWidget> {
height: 240,
decoration: BoxDecoration(
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,
),
width: MediaQuery.sizeOf(context).width,
padding: state.planData.image != null ? null : const EdgeInsets.all(8),
child: state.planData.image != null
padding: hasCover ? null : const EdgeInsets.all(8),
child: selectedImage != null
? Padding(
padding: const EdgeInsets.only(top: 6, right: 6),
child: GestureDetector(
@@ -131,6 +145,8 @@ class _PlanImagePickerWidgetState extends State<PlanImagePickerWidget> {
),
),
)
: hasCover
? const SizedBox.shrink()
: Stack(
children: [
DottedBorder(

View File

@@ -11,7 +11,13 @@ class CreatePlanRepository {
}) async {
final formData = FormData.fromMap(model);
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);
}
}

View File

@@ -12,5 +12,11 @@ abstract class CreatePlanService {
Future<void> createPlan({
@Body() required FormData formData,
});
@POST('/api/plans/update/published/{slug}')
@MultiPart()
Future<void> updatePublishedPlan({
@Path() required String slug,
@Body() required FormData formData,
});
}

View File

@@ -2,6 +2,7 @@ import 'package:eatvio_app/features/diary/entities/diary_day_entity.dart';
import 'package:eatvio_app/features/diary/entities/diary_future_diet_day_entity.dart';
import 'package:eatvio_app/features/diary/models/consultant_review_model.dart';
import 'package:eatvio_app/features/diary/models/nutrition_model.dart';
import 'package:eatvio_app/features/diary/presentation/diary_edit_access.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'diary_state.freezed.dart';
@@ -12,7 +13,8 @@ abstract class DiaryState with _$DiaryState {
const factory DiaryState.loading() = DiaryStateLoading;
const factory DiaryState.error({required String errorMessage}) = DiaryStateError;
const factory DiaryState.error({required String errorMessage}) =
DiaryStateError;
const factory DiaryState.loaded({
required DiaryDayEntity diary,
@@ -31,6 +33,19 @@ abstract class DiaryState with _$DiaryState {
@Default(false) bool isLoadingAddingProductInMeal,
}) = DiaryStateLoaded;
const factory DiaryState.loadedTomorrowWithDiet({required DiaryFutureDietDayEntity diary, required DateTime selectedDay, required int selectedMeal}) =
DiaryStateLoadedTomorrowWithDiet;
const factory DiaryState.loadedTomorrowWithDiet({
required DiaryFutureDietDayEntity diary,
required DateTime selectedDay,
required int selectedMeal,
}) = DiaryStateLoadedTomorrowWithDiet;
}
extension DiaryStateLoadedEditAccess on DiaryStateLoaded {
bool get canEditDiary => canEditDiaryDay(
isConsultantView: isConsultantView,
isDiaryCommented: isDiaryCommented,
isDiaryFinished: isDiaryFinished,
closed: diary.closed,
diaryDate: diary.date,
);
}

View File

@@ -0,0 +1,38 @@
/// Finished diaries stay editable for this many calendar days back from today,
/// including today. Older finished days stay read-only.
const int diaryFinishedEditWindowInDays = 7;
bool canEditDiaryDay({
required bool isConsultantView,
required bool isDiaryCommented,
required bool isDiaryFinished,
required int closed,
required String diaryDate,
DateTime? now,
}) {
if (isConsultantView || isDiaryCommented || closed != 0) {
return false;
}
if (!isDiaryFinished) {
return true;
}
final day = _calendarDate(diaryDate);
if (day == null) {
return false;
}
final clock = now ?? DateTime.now();
final today = DateTime(clock.year, clock.month, clock.day);
final oldestEditableDay = today.subtract(
const Duration(days: diaryFinishedEditWindowInDays),
);
return !day.isBefore(oldestEditableDay) && !day.isAfter(today);
}
DateTime? _calendarDate(String raw) {
final parsed = DateTime.tryParse(raw);
if (parsed == null) {
return null;
}
final local = parsed.toLocal();
return DateTime(local.year, local.month, local.day);
}

View File

@@ -74,7 +74,12 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
}
if (state is DiaryStateError) {
return Scaffold(
body: DataErrorWidget(onReload: () => context.read<DiaryCubit>().fetchDiaryV2(needLoading: false, diaryId: widget.diaryId)),
body: DataErrorWidget(
onReload: () => context.read<DiaryCubit>().fetchDiaryV2(
needLoading: false,
diaryId: widget.diaryId,
),
),
);
}
@@ -84,8 +89,14 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
? AppBar(toolbarHeight: 0)
: AppBar(
title: Text(
state is DiaryStateLoaded ? DateTime.parse(state.diary.date).toLocal().format('d MMMM') : '',
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
state is DiaryStateLoaded
? DateTime.parse(
state.diary.date,
).toLocal().format('d MMMM')
: '',
style: TextStyles.headingXLSemibold.copyWith(
color: appTheme.black,
),
),
centerTitle: true,
),
@@ -95,26 +106,39 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
child: Column(
children: [
if (widget.diaryId == null) CalendarWidget(date: widget.date),
if (state is DiaryStateLoading) ...[const SizedBox(height: 60), const Loader()],
if (state is DiaryStateLoading) ...[
const SizedBox(height: 60),
const Loader(),
],
if (state is DiaryStateLoaded)
WakeupAndRetireWidget(
isEnabled: state.diary.typeOfDiary.hasChosenPlan || state.isConsultantView || state.isWakeUpAndRetireEnabled,
disable: state.isConsultantView || state.isDiaryFinished || state.isDiaryCommented,
isEnabled:
state.diary.typeOfDiary.hasChosenPlan ||
state.isConsultantView ||
state.isWakeUpAndRetireEnabled,
disable: !state.canEditDiary,
),
const SizedBox(height: 12),
if (state is DiaryStateLoaded)
MealWidget(
showMood: state.diary.typeOfDiary.hasChosenPlan || state.isConsultantView || state.isMoodEnabled,
showAddButton: !state.isConsultantView && !state.isDiaryFinished && !state.isDiaryCommented,
showMood:
state.diary.typeOfDiary.hasChosenPlan ||
state.isConsultantView ||
state.isMoodEnabled,
showAddButton: state.canEditDiary,
),
if (state is DiaryStateLoadedTomorrowWithDiet) const MealWidget(showMood: false, showAddButton: false),
if (state is DiaryStateLoadedTomorrowWithDiet)
const MealWidget(showMood: false, showAddButton: false),
if (state is DiaryStateLoaded) const CaloriesChartWidget(),
const SizedBox(height: 12),
Builder(
builder: (context) {
if (state is DiaryStateLoaded && state.diary.typeOfDiary.hasChosenPlan) {
if (state is DiaryStateLoaded &&
state.diary.typeOfDiary.hasChosenPlan) {
if (state.diary.comment.isNotEmpty) {
return CommentForConsultantWidget(comment: state.diary.comment);
return CommentForConsultantWidget(
comment: state.diary.comment,
);
}
if (state.isDiaryCommented || state.isDiaryFinished) {
return const SizedBox.shrink();
@@ -124,7 +148,10 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
return const SizedBox.shrink();
},
),
if (state is DiaryStateLoaded && !state.isConsultantView && !state.isDiaryFinished && !state.isDiaryCommented)
if (state is DiaryStateLoaded &&
!state.isConsultantView &&
!state.isDiaryFinished &&
!state.isDiaryCommented)
Padding(
padding: const EdgeInsets.only(top: 12),
child: _isDiaryFinishing
@@ -135,11 +162,13 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
setState(() {
_isDiaryFinishing = true;
});
final isFinished = await context.read<DiaryCubit>().finishDiaryDay(
diaryId: state.diary.id,
wakeUp: state.diary.wakeup.toString(),
retire: state.diary.retire.toString(),
);
final isFinished = await context
.read<DiaryCubit>()
.finishDiaryDay(
diaryId: state.diary.id,
wakeUp: state.diary.wakeup.toString(),
retire: state.diary.retire.toString(),
);
if (!isFinished && context.mounted) {
showErrorDialog(context);
}
@@ -149,33 +178,64 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
},
),
),
if (state is DiaryStateLoaded && state.isConsultantView && !state.isDiaryCommented) ...[
if (state is DiaryStateLoaded &&
state.isConsultantView &&
!state.isDiaryCommented) ...[
const SizedBox(height: 16),
const MainBackgroundBouble(padding: EdgeInsets.all(16), child: DayCommentWidget()),
const MainBackgroundBouble(
padding: EdgeInsets.all(16),
child: DayCommentWidget(),
),
],
if (state is DiaryStateLoaded && state.consultantReview?.consultantComments.dayComment != null) ...[
if (state is DiaryStateLoaded &&
state.consultantReview?.consultantComments.dayComment !=
null) ...[
const SizedBox(height: 12),
MainBackgroundBouble(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Text(s.dayComment, style: TextStyles.textLSemibold.copyWith(color: appTheme.black)),
Text(
s.dayComment,
style: TextStyles.textLSemibold.copyWith(
color: appTheme.black,
),
),
const SizedBox(height: 12),
CommentContainer(comment: state.consultantReview?.consultantComments.dayComment ?? ''),
CommentContainer(
comment:
state
.consultantReview
?.consultantComments
.dayComment ??
'',
),
],
),
),
const SizedBox(height: 28),
],
if (state is DiaryStateLoaded && state.isConsultantView && state.isDiaryFinished && !state.isDiaryCommented) ...[
if (state is DiaryStateLoaded &&
state.isConsultantView &&
state.isDiaryFinished &&
!state.isDiaryCommented) ...[
const SizedBox(height: 16),
CustomOutlinedButton(
text: s.send,
height: 52,
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
buttonTextStyle: TextStyles.textMSemibold.copyWith(
color: appTheme.whiteA700,
),
buttonStyle: ButtonStyle(
side: const WidgetStatePropertyAll(BorderSide.none),
backgroundColor: WidgetStatePropertyAll(
appTheme.blueA400,
),
),
onPressed: () async {
await context.read<ConsultantCommentsCubit>().submitComments(state.diary.id);
await context
.read<ConsultantCommentsCubit>()
.submitComments(state.diary.id);
},
),
const SizedBox(height: 16),

View File

@@ -70,42 +70,46 @@ class _AddProductModalState extends State<AddProductModal> {
Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: MediaQuery.of(context).size.width * 0.76 - 32,
child: CustomTextFormField(
controller: productNameController,
autofocus: true,
hintText: 'Введите название продукта/блюда',
onChanged: (query) {
EasyDebounce.debounce('glossary-search', const Duration(milliseconds: 250), () async {
selectedGlossary = null;
if (query.isEmpty) {
glossariesList = [];
Expanded(
flex: 2,
child: SizedBox(
child: CustomTextFormField(
controller: productNameController,
autofocus: true,
hintText: 'Введите название продукта/блюда',
onChanged: (query) {
EasyDebounce.debounce('glossary-search', const Duration(milliseconds: 250), () async {
selectedGlossary = null;
if (query.isEmpty) {
glossariesList = [];
setState(() {});
return;
}
glossariesList = await widget.diaryCubit.getGlossaryListByQuery(query);
setState(() {});
return;
}
glossariesList = await widget.diaryCubit.getGlossaryListByQuery(query);
setState(() {});
});
},
});
},
),
),
),
const SizedBox(width: 4),
SizedBox(
width: MediaQuery.of(context).size.width * 0.24 - 4,
child: CustomTextFormField(
controller: quantityController,
keyboardType: TextInputType.number,
hintText: selectedGlossary == null
? '100'
: selectedGlossary!.grInPortion == 0
? '100'
: '1',
suffixText: selectedGlossary == null
? 'гр'
: selectedGlossary?.grInPortion == 0
? 'гр'
: 'шт',
Expanded(
flex: 1,
child: SizedBox(
child: CustomTextFormField(
controller: quantityController,
keyboardType: TextInputType.number,
hintText: selectedGlossary == null
? '100'
: selectedGlossary!.grInPortion == 0
? '100'
: '1',
suffixText: selectedGlossary == null
? 'гр'
: selectedGlossary?.grInPortion == 0
? 'гр'
: 'шт',
),
),
),
],
@@ -168,66 +172,68 @@ class _AddProductModalState extends State<AddProductModal> {
),
),
if (widget.portion != null) const SizedBox(width: 8),
InkWell(
onTap: () {
// Автоматически выбираем первый элемент, если текст точно совпадает
if (selectedGlossary == null &&
glossariesList.isNotEmpty &&
productNameController.text.trim().toLowerCase() == glossariesList[0].name.toLowerCase()) {
selectedGlossary = glossariesList[0];
}
Expanded(
child: InkWell(
onTap: () {
// Автоматически выбираем первый элемент, если текст точно совпадает
if (selectedGlossary == null &&
glossariesList.isNotEmpty &&
productNameController.text.trim().toLowerCase() == glossariesList[0].name.toLowerCase()) {
selectedGlossary = glossariesList[0];
}
if (widget.portion == null) {
widget.diaryCubit.addProductToMeal(
diaryId: widget.state.diary.id,
mealId: widget.state.selectedMeal,
productName: productNameController.text,
portion: selectedGlossary == null
? 'gr'
: selectedGlossary!.grInPortion == 0
? 'gr'
: 'pcs',
quantity: int.parse(quantityController.text),
productNutritionalValueId: selectedGlossary?.id,
);
} else {
widget.diaryCubit.updateProductInMeal(
diaryId: widget.state.diary.id,
mealId: widget.state.selectedMeal,
portionId: widget.portion!.id,
productName: productNameController.text,
portion: selectedGlossary == null
? 'gr'
: selectedGlossary!.grInPortion == 0
? 'gr'
: 'pcs',
quantity: int.parse(quantityController.text),
productNutritionalValueId: selectedGlossary?.id,
portionState: widget.portion!.portionState,
);
}
Navigator.of(context).pop();
if (selectedGlossary == null && _previousProductName != productNameController.text) {
showModalBottomSheet(
isScrollControlled: true,
useRootNavigator: true,
context: context,
builder: (context) => Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: GlossaryModerationRequestModalWidget(productName: productNameController.text, diaryCubit: widget.diaryCubit),
if (widget.portion == null) {
widget.diaryCubit.addProductToMeal(
diaryId: widget.state.diary.id,
mealId: widget.state.selectedMeal,
productName: productNameController.text,
portion: selectedGlossary == null
? 'gr'
: selectedGlossary!.grInPortion == 0
? 'gr'
: 'pcs',
quantity: int.parse(quantityController.text),
productNutritionalValueId: selectedGlossary?.id,
);
} else {
widget.diaryCubit.updateProductInMeal(
diaryId: widget.state.diary.id,
mealId: widget.state.selectedMeal,
portionId: widget.portion!.id,
productName: productNameController.text,
portion: selectedGlossary == null
? 'gr'
: selectedGlossary!.grInPortion == 0
? 'gr'
: 'pcs',
quantity: int.parse(quantityController.text),
productNutritionalValueId: selectedGlossary?.id,
portionState: widget.portion!.portionState,
);
}
Navigator.of(context).pop();
if (selectedGlossary == null && _previousProductName != productNameController.text) {
showModalBottomSheet(
isScrollControlled: true,
useRootNavigator: true,
context: context,
builder: (context) => Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: GlossaryModerationRequestModalWidget(productName: productNameController.text, diaryCubit: widget.diaryCubit),
),
);
}
},
borderRadius: BorderRadius.circular(20),
child: Ink(
width: MediaQuery.of(context).size.width - (widget.portion == null ? 32 : 92),
height: 52,
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
child: Center(
child: Text(
widget.portion == null ? 'Добавить' : 'Сохранить',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white),
),
);
}
},
borderRadius: BorderRadius.circular(20),
child: Ink(
width: MediaQuery.of(context).size.width - (widget.portion == null ? 32 : 92),
height: 52,
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
child: Center(
child: Text(
widget.portion == null ? 'Добавить' : 'Сохранить',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white),
),
),
),

View File

@@ -16,6 +16,9 @@ class CalendarWidget extends StatefulWidget {
}
class _CalendarWidgetState extends State<CalendarWidget> {
static const _kDaysOfWeekHeight = 16.0;
static const _kRowHeight = 52.0;
CalendarFormat _calendarFormat = CalendarFormat.week;
DateTime _focusedDay = DateTime.now();
DateTime _selectedDay = DateTime.now();
@@ -40,6 +43,8 @@ class _CalendarWidgetState extends State<CalendarWidget> {
@override
Widget build(BuildContext context) {
final s = S.of(context);
final textScaler = MediaQuery.textScalerOf(context);
final daysOfWeekHeight = _scaledDaysOfWeekHeight(context);
return BlocConsumer<DiaryCubit, DiaryState>(
listener: (BuildContext context, DiaryState state) {
if (state is DiaryStateLoaded) {
@@ -61,6 +66,8 @@ class _CalendarWidgetState extends State<CalendarWidget> {
locale: 'ru_RU',
startingDayOfWeek: StartingDayOfWeek.monday,
calendarFormat: _calendarFormat,
daysOfWeekHeight: daysOfWeekHeight,
rowHeight: textScaler.scale(_kRowHeight),
headerStyle: const HeaderStyle(titleCentered: true, formatButtonVisible: false, leftChevronVisible: false, rightChevronVisible: false),
onDaySelected: (selectedDay, focusedDay) async {
if (!isSameDay(_selectedDay, selectedDay)) {
@@ -116,6 +123,18 @@ class _CalendarWidgetState extends State<CalendarWidget> {
);
}
double _scaledDaysOfWeekHeight(BuildContext context) {
final painter = TextPainter(
text: TextSpan(text: 'Щy', style: DefaultTextStyle.of(context).style),
textDirection: Directionality.of(context),
textScaler: MediaQuery.textScalerOf(context),
maxLines: 1,
)..layout();
final height = painter.height;
painter.dispose();
return height.clamp(_kDaysOfWeekHeight, double.infinity);
}
void _nextCalendarFormat() {
setState(() {
_calendarFormat = CalendarFormat.values[(_calendarFormat.index - 1) % CalendarFormat.values.length];

View File

@@ -26,7 +26,8 @@ class _MoodRatingWidgetState extends State<MoodRatingWidget> {
final meals = state.diary.meals;
if (selectedIndex >= 0 && selectedIndex < meals.length) {
if (isFirstFetch[selectedIndex]) {
currentSelectedRating[selectedIndex] = meals[selectedIndex].mood.toDouble() + 1;
currentSelectedRating[selectedIndex] =
meals[selectedIndex].mood.toDouble() + 1;
isFirstFetch[selectedIndex] = false;
}
}
@@ -36,21 +37,30 @@ class _MoodRatingWidgetState extends State<MoodRatingWidget> {
mainAxisSize: MainAxisSize.min,
children: [
Text(
state.isConsultantView ? 'Настроение пользователя после приема пищи' : 'Ваше настроение после приёма пищи',
state.isConsultantView
? 'Настроение пользователя после приема пищи'
: 'Ваше настроение после приёма пищи',
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 16),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
SvgPicture.asset('assets/icons/mood_1.svg', height: 32, width: 32),
SvgPicture.asset(
'assets/icons/mood_1.svg',
height: 32,
width: 32,
),
RatingBar.builder(
ignoreGestures: state.isConsultantView || state.isDiaryFinished || state.isDiaryCommented,
initialRating: (selectedIndex >= 0 && selectedIndex < state.diary.meals.length)
ignoreGestures: !state.canEditDiary,
initialRating:
(selectedIndex >= 0 &&
selectedIndex < state.diary.meals.length)
? state.diary.meals[selectedIndex].mood.toDouble() + 1
: 3,
itemCount: 6,
itemSize: MediaQuery.of(context).size.width / 6 - (124 / 6),
itemSize:
MediaQuery.of(context).size.width / 6 - (124 / 6),
updateOnDrag: true,
itemBuilder: (context, index) {
switch (index) {
@@ -58,82 +68,154 @@ class _MoodRatingWidgetState extends State<MoodRatingWidget> {
return Container(
width: MediaQuery.of(context).size.width / 6,
height: 24,
margin: currentSelectedRating[selectedIndex] == 1 ? const EdgeInsets.symmetric(horizontal: 2) : null,
margin: currentSelectedRating[selectedIndex] == 1
? const EdgeInsets.symmetric(horizontal: 2)
: null,
decoration: BoxDecoration(
color: const Color(0xFFC82222),
border: currentSelectedRating[selectedIndex] == 1
? Border.all(width: 2, color: const Color(0xFFAA3333), strokeAlign: BorderSide.strokeAlignOutside)
border:
currentSelectedRating[selectedIndex] == 1
? Border.all(
width: 2,
color: const Color(0xFFAA3333),
strokeAlign:
BorderSide.strokeAlignOutside,
)
: null,
borderRadius: currentSelectedRating[selectedIndex] == 1
? const BorderRadius.horizontal(left: Radius.circular(8), right: Radius.circular(2))
: const BorderRadius.horizontal(left: Radius.circular(8)),
borderRadius:
currentSelectedRating[selectedIndex] == 1
? const BorderRadius.horizontal(
left: Radius.circular(8),
right: Radius.circular(2),
)
: const BorderRadius.horizontal(
left: Radius.circular(8),
),
),
);
case 1:
return Container(
width: MediaQuery.of(context).size.width / 6,
height: 24,
margin: currentSelectedRating[selectedIndex] == 2 ? const EdgeInsets.symmetric(horizontal: 2) : null,
margin: currentSelectedRating[selectedIndex] == 2
? const EdgeInsets.symmetric(horizontal: 2)
: null,
decoration: BoxDecoration(
color: const Color(0xFFFF3E3E),
border: currentSelectedRating[selectedIndex] == 2
? Border.all(width: 2, color: const Color(0xFF550000), strokeAlign: BorderSide.strokeAlignOutside)
border:
currentSelectedRating[selectedIndex] == 2
? Border.all(
width: 2,
color: const Color(0xFF550000),
strokeAlign:
BorderSide.strokeAlignOutside,
)
: null,
borderRadius:
currentSelectedRating[selectedIndex] == 2
? BorderRadius.circular(2)
: null,
borderRadius: currentSelectedRating[selectedIndex] == 2 ? BorderRadius.circular(2) : null,
),
);
case 2:
return Container(
width: MediaQuery.of(context).size.width / 6,
height: 24,
margin: currentSelectedRating[selectedIndex] == 3 ? const EdgeInsets.symmetric(horizontal: 2) : null,
margin: currentSelectedRating[selectedIndex] == 3
? const EdgeInsets.symmetric(horizontal: 2)
: null,
decoration: BoxDecoration(
color: const Color(0xFFF8C926),
border: currentSelectedRating[selectedIndex] == 3
? Border.all(width: 2, color: const Color(0xFF8C5D00), strokeAlign: BorderSide.strokeAlignOutside)
border:
currentSelectedRating[selectedIndex] == 3
? Border.all(
width: 2,
color: const Color(0xFF8C5D00),
strokeAlign:
BorderSide.strokeAlignOutside,
)
: null,
borderRadius:
currentSelectedRating[selectedIndex] == 3
? BorderRadius.circular(2)
: null,
borderRadius: currentSelectedRating[selectedIndex] == 3 ? BorderRadius.circular(2) : null,
),
);
case 3:
return Container(
width: MediaQuery.of(context).size.width / 6,
height: 24,
margin: currentSelectedRating[selectedIndex] == 4 ? const EdgeInsets.symmetric(horizontal: 2) : null,
margin: currentSelectedRating[selectedIndex] == 4
? const EdgeInsets.symmetric(horizontal: 2)
: null,
decoration: BoxDecoration(
color: const Color(0xFFFFF3BF),
border: currentSelectedRating[selectedIndex] == 4
? Border.all(width: 2, color: const Color(0xFFB5911B), strokeAlign: BorderSide.strokeAlignOutside)
border:
currentSelectedRating[selectedIndex] == 4
? Border.all(
width: 2,
color: const Color(0xFFB5911B),
strokeAlign:
BorderSide.strokeAlignOutside,
)
: null,
borderRadius:
currentSelectedRating[selectedIndex] == 4
? BorderRadius.circular(2)
: null,
borderRadius: currentSelectedRating[selectedIndex] == 4 ? BorderRadius.circular(2) : null,
),
);
case 4:
return Container(
width: MediaQuery.of(context).size.width / 6,
height: 24,
margin: currentSelectedRating[selectedIndex] == 5 ? const EdgeInsets.symmetric(horizontal: 2) : null,
margin: currentSelectedRating[selectedIndex] == 5
? const EdgeInsets.symmetric(horizontal: 2)
: null,
decoration: BoxDecoration(
color: const Color(0xFF62E833),
border: currentSelectedRating[selectedIndex] == 5
? Border.all(width: 2, color: const Color(0xFF276918), strokeAlign: BorderSide.strokeAlignOutside)
border:
currentSelectedRating[selectedIndex] == 5
? Border.all(
width: 2,
color: const Color(0xFF276918),
strokeAlign:
BorderSide.strokeAlignOutside,
)
: null,
borderRadius:
currentSelectedRating[selectedIndex] == 5
? BorderRadius.circular(2)
: null,
borderRadius: currentSelectedRating[selectedIndex] == 5 ? BorderRadius.circular(2) : null,
),
);
default:
return Container(
width: MediaQuery.of(context).size.width / 6,
height: 24,
margin: currentSelectedRating[selectedIndex] == 6 ? const EdgeInsets.symmetric(horizontal: 2) : null,
margin: currentSelectedRating[selectedIndex] == 6
? const EdgeInsets.symmetric(horizontal: 2)
: null,
decoration: BoxDecoration(
color: const Color(0xFF4DCE20),
border: currentSelectedRating[selectedIndex] == 6
? Border.all(width: 2, color: const Color(0xFF184D12), strokeAlign: BorderSide.strokeAlignOutside)
border:
currentSelectedRating[selectedIndex] == 6
? Border.all(
width: 2,
color: const Color(0xFF184D12),
strokeAlign:
BorderSide.strokeAlignOutside,
)
: null,
borderRadius: currentSelectedRating[selectedIndex] == 6
? const BorderRadius.horizontal(left: Radius.circular(2), right: Radius.circular(8))
: const BorderRadius.horizontal(right: Radius.circular(8)),
borderRadius:
currentSelectedRating[selectedIndex] == 6
? const BorderRadius.horizontal(
left: Radius.circular(2),
right: Radius.circular(8),
)
: const BorderRadius.horizontal(
right: Radius.circular(8),
),
),
);
}
@@ -142,12 +224,25 @@ class _MoodRatingWidgetState extends State<MoodRatingWidget> {
setState(() {
currentSelectedRating[selectedIndex] = value;
});
EasyDebounce.debounce('change-satiety', const Duration(milliseconds: 250), () {
context.read<DiaryCubit>().fillMood(diaryId: state.diary.id, mood: currentSelectedRating[selectedIndex].toInt() - 1);
});
EasyDebounce.debounce(
'change-satiety',
const Duration(milliseconds: 250),
() {
context.read<DiaryCubit>().fillMood(
diaryId: state.diary.id,
mood:
currentSelectedRating[selectedIndex].toInt() -
1,
);
},
);
},
),
SvgPicture.asset('assets/icons/mood_2.svg', height: 32, width: 32),
SvgPicture.asset(
'assets/icons/mood_2.svg',
height: 32,
width: 32,
),
],
),
],

View File

@@ -52,7 +52,9 @@ class _PhotoGalleryWidgetState extends State<PhotoGalleryWidget> {
AppRouter.navigatorKey.currentContext?.pop();
final picker = ImagePicker();
XFile? image = await picker.pickImage(source: ImageSource.camera);
XFile? image = await picker.pickImage(
source: ImageSource.camera,
);
if (image != null) {
diaryCubit.uploadPhotos(
files: [File(image.path)],
@@ -116,7 +118,8 @@ class _PhotoGalleryWidgetState extends State<PhotoGalleryWidget> {
builder: (context, state) {
if (state is DiaryStateLoaded) {
int mealIndex = state.selectedMeal - 1;
if (state.diary.meals[mealIndex].photos.isEmpty && !state.isConsultantView && !state.isDiaryFinished && !state.isDiaryCommented) {
if (state.diary.meals[mealIndex].photos.isEmpty &&
state.canEditDiary) {
return GestureDetector(
onTap: () async {
showImagePicker(state.diary);
@@ -140,10 +143,11 @@ class _PhotoGalleryWidgetState extends State<PhotoGalleryWidget> {
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: CachedNetworkImage(
imageUrl: '${sl<AppConfig>().baseUrl}/storage/${state.diary.meals[mealIndex].photos[currentSelectedPhotoIndex].path}',
imageUrl:
'${sl<AppConfig>().baseUrl}/storage/${state.diary.meals[mealIndex].photos[currentSelectedPhotoIndex].path}',
),
),
if (!state.isConsultantView && !state.isDiaryFinished && !state.isDiaryCommented)
if (state.canEditDiary)
Padding(
padding: const EdgeInsets.all(12),
child: IconButton.filled(
@@ -153,12 +157,24 @@ class _PhotoGalleryWidgetState extends State<PhotoGalleryWidget> {
),
onPressed: () async {
await diaryCubit.deletePhoto(
photo: state.diary.meals[mealIndex].photos[currentSelectedPhotoIndex],
photo: state
.diary
.meals[mealIndex]
.photos[currentSelectedPhotoIndex],
diaryId: state.diary.id,
);
setState(() {
if (currentSelectedPhotoIndex == state.diary.meals[mealIndex].photos.length - 1 && currentSelectedPhotoIndex != 0) {
currentSelectedPhotoIndex = state.diary.meals[mealIndex].photos.length - 2;
if (currentSelectedPhotoIndex ==
state
.diary
.meals[mealIndex]
.photos
.length -
1 &&
currentSelectedPhotoIndex != 0) {
currentSelectedPhotoIndex =
state.diary.meals[mealIndex].photos.length -
2;
}
});
},
@@ -178,8 +194,13 @@ class _PhotoGalleryWidgetState extends State<PhotoGalleryWidget> {
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
children: [
for (int i = 0; i < state.diary.meals[mealIndex].photos.length + 1; i++) ...[
if (i == state.diary.meals[mealIndex].photos.length && !state.isConsultantView && !state.isDiaryFinished && !state.isDiaryCommented)
for (
int i = 0;
i < state.diary.meals[mealIndex].photos.length + 1;
i++
) ...[
if (i == state.diary.meals[mealIndex].photos.length &&
state.canEditDiary)
Padding(
padding: const EdgeInsets.only(left: 8),
child: InkWell(
@@ -212,13 +233,15 @@ class _PhotoGalleryWidgetState extends State<PhotoGalleryWidget> {
height: 52,
width: 52,
child: CachedNetworkImage(
imageUrl: '${sl<AppConfig>().baseUrl}/storage/${state.diary.meals[mealIndex].photos[i].path}',
imageUrl:
'${sl<AppConfig>().baseUrl}/storage/${state.diary.meals[mealIndex].photos[i].path}',
fit: BoxFit.fitWidth,
),
),
),
),
if (i < state.diary.meals[mealIndex].photos.length - 1) const SizedBox(width: 8),
if (i < state.diary.meals[mealIndex].photos.length - 1)
const SizedBox(width: 8),
],
],
),

View File

@@ -1,8 +1,6 @@
import 'package:collection/collection.dart';
import 'package:dotted_border/dotted_border.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/consultations/models/consultation_model.dart';
import 'package:eatvio_app/features/diary/models/consultant_review_model.dart';
import 'package:eatvio_app/features/diary/models/diary_day_model.dart';
import 'package:eatvio_app/features/diary/presentation/bloc/diary_cubit.dart';
@@ -32,17 +30,26 @@ class _ProductWidgetState extends State<ProductWidget> {
super.initState();
diaryCubit = context.read<DiaryCubit>();
portionName = widget.portion.portion == 'gr' ? 'гр' : 'шт';
isEaten = widget.portion.portionState == PortionState.eatenFromDiet ? true : false;
isEaten = widget.portion.portionState == PortionState.eatenFromDiet
? true
: false;
}
Widget _buildNutritionalInfo() {
if (widget.portion.productNutritionalValueId == null) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: const Text(
'Продукт отправлен на модерацию',
style: TextStyle(color: Color(0xFF2F80ED), fontSize: 12, fontWeight: FontWeight.w500),
style: TextStyle(
color: Color(0xFF2F80ED),
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
);
}
@@ -57,7 +64,8 @@ class _ProductWidgetState extends State<ProductWidget> {
calories = (caloriesInfo.calories * widget.portion.quantity) / 100;
proteins = (caloriesInfo.proteins * widget.portion.quantity) / 100;
fats = (caloriesInfo.fats * widget.portion.quantity) / 100;
carbohydrates = (caloriesInfo.carbohydrates * widget.portion.quantity) / 100;
carbohydrates =
(caloriesInfo.carbohydrates * widget.portion.quantity) / 100;
} else {
calories = caloriesInfo.energyOnPortion;
proteins = caloriesInfo.proteinsOnPortion;
@@ -81,10 +89,17 @@ class _ProductWidgetState extends State<ProductWidget> {
Widget _buildNutritionChip(String label, int value) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(8)),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'$label $value',
style: const TextStyle(color: Color(0xFF6D6F71), fontSize: 12, fontWeight: FontWeight.w500),
style: const TextStyle(
color: Color(0xFF6D6F71),
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
);
}
@@ -93,16 +108,24 @@ class _ProductWidgetState extends State<ProductWidget> {
void didUpdateWidget(ProductWidget oldWidget) {
super.didUpdateWidget(oldWidget);
portionName = widget.portion.portion == 'gr' ? 'гр' : 'шт';
isEaten = widget.portion.portionState == PortionState.eatenFromDiet ? true : false;
isEaten = widget.portion.portionState == PortionState.eatenFromDiet
? true
: false;
}
Future updateProductInMeal(DiaryStateLoaded state, PortionModel portion) {
return showModalBottomSheet(
useRootNavigator: true,
context: context,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(12))),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
),
isScrollControlled: true,
builder: (context) => AddProductModal(state: state, portion: portion, diaryCubit: diaryCubit),
builder: (context) => AddProductModal(
state: state,
portion: portion,
diaryCubit: diaryCubit,
),
);
}
@@ -113,33 +136,36 @@ class _ProductWidgetState extends State<ProductWidget> {
if (state is DiaryStateLoadedTomorrowWithDiet) {
return ListTile(
horizontalTitleGap: 0,
contentPadding: widget.portion.fromDiet ? const EdgeInsets.only(right: 24, left: 12) : null,
contentPadding: widget.portion.fromDiet
? const EdgeInsets.only(right: 24, left: 12)
: null,
title: Text(widget.portion.productName),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${widget.portion.quantity} ${widget.portion.portion == 'gr' ? 'гр' : 'шт'}'),
Text(
'${widget.portion.quantity} ${widget.portion.portion == 'gr' ? 'гр' : 'шт'}',
),
const SizedBox(height: 4),
_buildNutritionalInfo(),
],
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
tileColor: const Color(0xFFF6FAFF),
);
}
if (state is DiaryStateLoaded) {
final isDiaryCommented =
state.diary.typeOfDiary.diaryState == DiaryStateEnum.commented ||
state.diary.typeOfDiary.diaryState == DiaryStateEnum.lastDayCommented ||
state.diary.typeOfDiary.diaryState == DiaryStateEnum.reviewedByUser;
final isDiaryFinished =
state.diary.typeOfDiary.diaryState == DiaryStateEnum.finished || state.diary.typeOfDiary.diaryState == DiaryStateEnum.lastDayFinished;
final canEditPortion = !state.isConsultantView && !isDiaryCommented && !isDiaryFinished && !widget.portion.fromDiet;
final canEditPortion = state.canEditDiary && !widget.portion.fromDiet;
final consultantComments = state.consultantReview?.consultantComments;
final canMarkAsEaten = !state.isConsultantView && !isDiaryCommented && !isDiaryFinished;
final canMarkAsEaten = state.canEditDiary;
PortionComment? portionComment;
if (consultantComments != null) {
portionComment = consultantComments.portionsComments.firstWhereOrNull((elem) => elem.portionId == widget.portion.id);
portionComment = consultantComments.portionsComments
.firstWhereOrNull(
(elem) => elem.portionId == widget.portion.id,
);
}
if (widget.portion.portionState == PortionState.eatenDeleted) {
return DottedBorder(
@@ -151,27 +177,41 @@ class _ProductWidgetState extends State<ProductWidget> {
color: Color(0xFF6D6F71),
),
child: ListTile(
onTap: () => diaryCubit.restore(diaryId: state.diary.id, mealId: state.selectedMeal, portionId: widget.portion.id),
trailing: SvgPicture.asset('assets/icons/revert_product.svg'),
onTap: state.canEditDiary
? () => diaryCubit.restore(
diaryId: state.diary.id,
mealId: state.selectedMeal,
portionId: widget.portion.id,
)
: null,
trailing: state.canEditDiary
? SvgPicture.asset('assets/icons/revert_product.svg')
: null,
horizontalTitleGap: 0,
contentPadding: widget.portion.fromDiet ? const EdgeInsets.only(right: 24, left: 4) : null,
contentPadding: widget.portion.fromDiet
? const EdgeInsets.only(right: 24, left: 4)
: null,
title: Text(widget.portion.productName),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${widget.portion.quantity} ${widget.portion.portion == 'gr' ? 'гр' : 'шт'}'),
Text(
'${widget.portion.quantity} ${widget.portion.portion == 'gr' ? 'гр' : 'шт'}',
),
const SizedBox(height: 4),
_buildNutritionalInfo(),
],
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
// ignore: use_full_hex_values_for_flutter_colors
tileColor: const Color(0xfff6faff03).withValues(alpha: 0.01),
),
);
} else {
Widget? trailing;
if (state.isConsultantView && !isDiaryCommented) {
if (state.isConsultantView && !state.isDiaryCommented) {
trailing = GestureDetector(
onTap: () {
setState(() {
@@ -180,13 +220,15 @@ class _ProductWidgetState extends State<ProductWidget> {
},
child: SvgPicture.asset('assets/icons/edit.svg'),
);
} else if (!isDiaryCommented && !widget.portion.fromDiet) {
} else if (canEditPortion) {
trailing = const Icon(Icons.more_vert_rounded);
}
return Column(
children: [
ListTile(
onTap: canEditPortion ? () => updateProductInMeal(state, widget.portion) : null,
onTap: canEditPortion
? () => updateProductInMeal(state, widget.portion)
: null,
trailing: trailing,
leading: widget.portion.fromDiet
? Transform.scale(
@@ -199,35 +241,55 @@ class _ProductWidgetState extends State<ProductWidget> {
isEaten = value!;
});
if (isEaten) {
diaryCubit.markEaten(diaryId: state.diary.id, mealId: state.selectedMeal, portionId: widget.portion.id);
diaryCubit.markEaten(
diaryId: state.diary.id,
mealId: state.selectedMeal,
portionId: widget.portion.id,
);
} else {
diaryCubit.unmarkEaten(diaryId: state.diary.id, mealId: state.selectedMeal, portionId: widget.portion.id);
diaryCubit.unmarkEaten(
diaryId: state.diary.id,
mealId: state.selectedMeal,
portionId: widget.portion.id,
);
}
}
: null,
activeColor: appTheme.blueA400,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
side: const BorderSide(color: Color(0xFF809CB9)),
),
)
: null,
horizontalTitleGap: 0,
contentPadding: widget.portion.fromDiet ? const EdgeInsets.only(right: 24, left: 4) : null,
contentPadding: widget.portion.fromDiet
? const EdgeInsets.only(right: 24, left: 4)
: null,
title: Text(widget.portion.productName),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${widget.portion.quantity} ${widget.portion.portion == 'gr' ? 'гр' : 'шт'}'),
Text(
'${widget.portion.quantity} ${widget.portion.portion == 'gr' ? 'гр' : 'шт'}',
),
const SizedBox(height: 4),
_buildNutritionalInfo(),
],
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
tileColor: const Color(0xFFF6FAFF),
),
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: PortionCommentWidget(enabled: showCommentWidget, portionId: widget.portion.id, comment: portionComment?.comment),
child: PortionCommentWidget(
enabled: showCommentWidget,
portionId: widget.portion.id,
comment: portionComment?.comment,
),
),
],
);

View File

@@ -26,7 +26,8 @@ class _SatietyRatingWidgetState extends State<SatietyRatingWidget> {
final meals = state.diary.meals;
if (selectedIndex >= 0 && selectedIndex < meals.length) {
if (isFirstFetch[selectedIndex]) {
currentSelectedRating[selectedIndex] = meals[selectedIndex].satiety.toDouble() + 1;
currentSelectedRating[selectedIndex] =
meals[selectedIndex].satiety.toDouble() + 1;
isFirstFetch[selectedIndex] = false;
}
}
@@ -36,21 +37,32 @@ class _SatietyRatingWidgetState extends State<SatietyRatingWidget> {
mainAxisSize: MainAxisSize.min,
children: [
Text(
state.isConsultantView ? 'Сытость пользователя после приема пищи' : 'Ваша сытость после приёма пищи',
state.isConsultantView
? 'Сытость пользователя после приема пищи'
: 'Ваша сытость после приёма пищи',
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 16),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
SvgPicture.asset('assets/icons/satiety_emoji_left.svg', height: 32, width: 32),
SvgPicture.asset(
'assets/icons/satiety_emoji_left.svg',
height: 32,
width: 32,
),
RatingBar.builder(
ignoreGestures: state.isConsultantView || state.isDiaryFinished || state.isDiaryCommented,
initialRating: (selectedIndex >= 0 && selectedIndex < state.diary.meals.length)
? state.diary.meals[selectedIndex].satiety.toDouble() + 1
ignoreGestures: !state.canEditDiary,
initialRating:
(selectedIndex >= 0 &&
selectedIndex < state.diary.meals.length)
? state.diary.meals[selectedIndex].satiety
.toDouble() +
1
: 3,
itemCount: 6,
itemSize: MediaQuery.of(context).size.width / 6 - (124 / 6),
itemSize:
MediaQuery.of(context).size.width / 6 - (124 / 6),
updateOnDrag: true,
itemBuilder: (context, index) {
switch (index) {
@@ -58,82 +70,154 @@ class _SatietyRatingWidgetState extends State<SatietyRatingWidget> {
return Container(
width: MediaQuery.of(context).size.width / 6,
height: 24,
margin: currentSelectedRating[selectedIndex] == 1 ? const EdgeInsets.symmetric(horizontal: 2) : null,
margin: currentSelectedRating[selectedIndex] == 1
? const EdgeInsets.symmetric(horizontal: 2)
: null,
decoration: BoxDecoration(
color: const Color(0xFFC82222),
border: currentSelectedRating[selectedIndex] == 1
? Border.all(width: 2, color: const Color(0xFFAA3333), strokeAlign: BorderSide.strokeAlignOutside)
border:
currentSelectedRating[selectedIndex] == 1
? Border.all(
width: 2,
color: const Color(0xFFAA3333),
strokeAlign:
BorderSide.strokeAlignOutside,
)
: null,
borderRadius: currentSelectedRating[selectedIndex] == 1
? const BorderRadius.horizontal(left: Radius.circular(8), right: Radius.circular(2))
: const BorderRadius.horizontal(left: Radius.circular(8)),
borderRadius:
currentSelectedRating[selectedIndex] == 1
? const BorderRadius.horizontal(
left: Radius.circular(8),
right: Radius.circular(2),
)
: const BorderRadius.horizontal(
left: Radius.circular(8),
),
),
);
case 1:
return Container(
width: MediaQuery.of(context).size.width / 6,
height: 24,
margin: currentSelectedRating[selectedIndex] == 2 ? const EdgeInsets.symmetric(horizontal: 2) : null,
margin: currentSelectedRating[selectedIndex] == 2
? const EdgeInsets.symmetric(horizontal: 2)
: null,
decoration: BoxDecoration(
color: const Color(0xFFFF3E3E),
border: currentSelectedRating[selectedIndex] == 2
? Border.all(width: 2, color: const Color(0xFF550000), strokeAlign: BorderSide.strokeAlignOutside)
border:
currentSelectedRating[selectedIndex] == 2
? Border.all(
width: 2,
color: const Color(0xFF550000),
strokeAlign:
BorderSide.strokeAlignOutside,
)
: null,
borderRadius:
currentSelectedRating[selectedIndex] == 2
? BorderRadius.circular(2)
: null,
borderRadius: currentSelectedRating[selectedIndex] == 2 ? BorderRadius.circular(2) : null,
),
);
case 2:
return Container(
width: MediaQuery.of(context).size.width / 6,
height: 24,
margin: currentSelectedRating[selectedIndex] == 3 ? const EdgeInsets.symmetric(horizontal: 2) : null,
margin: currentSelectedRating[selectedIndex] == 3
? const EdgeInsets.symmetric(horizontal: 2)
: null,
decoration: BoxDecoration(
color: const Color(0xFFF8C926),
border: currentSelectedRating[selectedIndex] == 3
? Border.all(width: 2, color: const Color(0xFF8C5D00), strokeAlign: BorderSide.strokeAlignOutside)
border:
currentSelectedRating[selectedIndex] == 3
? Border.all(
width: 2,
color: const Color(0xFF8C5D00),
strokeAlign:
BorderSide.strokeAlignOutside,
)
: null,
borderRadius:
currentSelectedRating[selectedIndex] == 3
? BorderRadius.circular(2)
: null,
borderRadius: currentSelectedRating[selectedIndex] == 3 ? BorderRadius.circular(2) : null,
),
);
case 3:
return Container(
width: MediaQuery.of(context).size.width / 6,
height: 24,
margin: currentSelectedRating[selectedIndex] == 4 ? const EdgeInsets.symmetric(horizontal: 2) : null,
margin: currentSelectedRating[selectedIndex] == 4
? const EdgeInsets.symmetric(horizontal: 2)
: null,
decoration: BoxDecoration(
color: const Color(0xFFFFF3BF),
border: currentSelectedRating[selectedIndex] == 4
? Border.all(width: 2, color: const Color(0xFFB5911B), strokeAlign: BorderSide.strokeAlignOutside)
border:
currentSelectedRating[selectedIndex] == 4
? Border.all(
width: 2,
color: const Color(0xFFB5911B),
strokeAlign:
BorderSide.strokeAlignOutside,
)
: null,
borderRadius:
currentSelectedRating[selectedIndex] == 4
? BorderRadius.circular(2)
: null,
borderRadius: currentSelectedRating[selectedIndex] == 4 ? BorderRadius.circular(2) : null,
),
);
case 4:
return Container(
width: MediaQuery.of(context).size.width / 6,
height: 24,
margin: currentSelectedRating[selectedIndex] == 5 ? const EdgeInsets.symmetric(horizontal: 2) : null,
margin: currentSelectedRating[selectedIndex] == 5
? const EdgeInsets.symmetric(horizontal: 2)
: null,
decoration: BoxDecoration(
color: const Color(0xFF62E833),
border: currentSelectedRating[selectedIndex] == 5
? Border.all(width: 2, color: const Color(0xFF276918), strokeAlign: BorderSide.strokeAlignOutside)
border:
currentSelectedRating[selectedIndex] == 5
? Border.all(
width: 2,
color: const Color(0xFF276918),
strokeAlign:
BorderSide.strokeAlignOutside,
)
: null,
borderRadius:
currentSelectedRating[selectedIndex] == 5
? BorderRadius.circular(2)
: null,
borderRadius: currentSelectedRating[selectedIndex] == 5 ? BorderRadius.circular(2) : null,
),
);
default:
return Container(
width: MediaQuery.of(context).size.width / 6,
height: 24,
margin: currentSelectedRating[selectedIndex] == 6 ? const EdgeInsets.symmetric(horizontal: 2) : null,
margin: currentSelectedRating[selectedIndex] == 6
? const EdgeInsets.symmetric(horizontal: 2)
: null,
decoration: BoxDecoration(
color: const Color(0xFF4DCE20),
border: currentSelectedRating[selectedIndex] == 6
? Border.all(width: 2, color: const Color(0xFF184D12), strokeAlign: BorderSide.strokeAlignOutside)
border:
currentSelectedRating[selectedIndex] == 6
? Border.all(
width: 2,
color: const Color(0xFF184D12),
strokeAlign:
BorderSide.strokeAlignOutside,
)
: null,
borderRadius: currentSelectedRating[selectedIndex] == 6
? const BorderRadius.horizontal(left: Radius.circular(2), right: Radius.circular(8))
: const BorderRadius.horizontal(right: Radius.circular(8)),
borderRadius:
currentSelectedRating[selectedIndex] == 6
? const BorderRadius.horizontal(
left: Radius.circular(2),
right: Radius.circular(8),
)
: const BorderRadius.horizontal(
right: Radius.circular(8),
),
),
);
}
@@ -143,12 +227,25 @@ class _SatietyRatingWidgetState extends State<SatietyRatingWidget> {
setState(() {
currentSelectedRating[selectedIndex] = value;
});
EasyDebounce.debounce('change-satiety', const Duration(milliseconds: 250), () {
context.read<DiaryCubit>().fillSatiety(diaryId: state.diary.id, satiety: currentSelectedRating[selectedIndex].toInt() - 1);
});
EasyDebounce.debounce(
'change-satiety',
const Duration(milliseconds: 250),
() {
context.read<DiaryCubit>().fillSatiety(
diaryId: state.diary.id,
satiety:
currentSelectedRating[selectedIndex].toInt() -
1,
);
},
);
},
),
SvgPicture.asset('assets/icons/satiety_emoji_right.svg', height: 32, width: 32),
SvgPicture.asset(
'assets/icons/satiety_emoji_right.svg',
height: 32,
width: 32,
),
],
),
],

View File

@@ -46,7 +46,8 @@ class _TimeOfMealPickerWidgetState extends State<TimeOfMealPickerWidget> {
Navigator.of(context).pop();
diaryCubit.fillTime(
diaryId: diaryId,
time: '${value.hour.toString().padLeft(2, '0')}:${value.minute.toString().padLeft(2, '0')}:00',
time:
'${value.hour.toString().padLeft(2, '0')}:${value.minute.toString().padLeft(2, '0')}:00',
);
},
borderRadius: BorderRadius.circular(20),
@@ -69,7 +70,7 @@ class _TimeOfMealPickerWidgetState extends State<TimeOfMealPickerWidget> {
),
),
),
const SizedBox(height: 30)
const SizedBox(height: 30),
],
),
),
@@ -98,7 +99,9 @@ class _TimeOfMealPickerWidgetState extends State<TimeOfMealPickerWidget> {
),
),
InkWell(
onTap: state.isConsultantView || state.isDiaryFinished || state.isDiaryCommented ? null : () => showTimePicker(state.diary.id),
onTap: state.canEditDiary
? () => showTimePicker(state.diary.id)
: null,
borderRadius: BorderRadius.circular(100),
child: Ink(
height: 32,
@@ -110,7 +113,16 @@ class _TimeOfMealPickerWidgetState extends State<TimeOfMealPickerWidget> {
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
state.diary.meals[state.selectedMeal - 1].time.substring(0, state.diary.meals[state.selectedMeal - 1].time.length - 3),
state.diary.meals[state.selectedMeal - 1].time
.substring(
0,
state
.diary
.meals[state.selectedMeal - 1]
.time
.length -
3,
),
style: const TextStyle(
color: Color(0xFF6D6F71),
fontSize: 17,

View File

@@ -296,6 +296,7 @@ class _AboutDietScreenState extends State<AboutDietScreen> {
actions: [
CustomPopup(
isAuthor: dietState.diet.author.authorIsMe,
isAuthorAdmin: dietState.diet.author.isAdmin,
path: 'diet/${dietState.diet.slug}',
complainedContentType: ComplainedContent.diet,
id: dietState.diet.id,

View File

@@ -3,6 +3,7 @@ import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/features/diets/presentation/widgets/info_bouble.dart';
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/utils/url_utils.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/profile/models/profile_model.dart';
import 'package:eatvio_app/features/user_details/presentation/user_details_screen.dart';
@@ -11,7 +12,14 @@ import 'package:flutter_svg/svg.dart';
import 'package:go_router/go_router.dart';
class AuthorCard extends StatelessWidget {
const AuthorCard({super.key, required this.author, required this.title, this.backgroundColor, this.titleStyle, this.customerProfile});
const AuthorCard({
super.key,
required this.author,
required this.title,
this.backgroundColor,
this.titleStyle,
this.customerProfile,
});
final Author author;
final String title;
final Color? backgroundColor;
@@ -21,15 +29,28 @@ class AuthorCard extends StatelessWidget {
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
GoRouter.of(context).pushNamed('profileScreen', extra: customerProfile != null ? customerProfile!.slug : author.authorSlug);
GoRouter.of(context).pushNamed(
'profileScreen',
extra: customerProfile != null
? customerProfile!.slug
: author.authorSlug,
);
},
child: Container(
width: MediaQuery.sizeOf(context).width,
decoration: BoxDecoration(borderRadius: BorderRadius.circular(12), color: backgroundColor ?? appTheme.cardBackground),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: backgroundColor ?? appTheme.cardBackground,
),
child: Stack(
children: [
Padding(
padding: const EdgeInsets.only(left: 16, top: 16, bottom: 24, right: 16),
padding: const EdgeInsets.only(
left: 16,
top: 16,
bottom: 24,
right: 16,
),
child: Column(
children: [
ListTile(
@@ -41,28 +62,43 @@ class AuthorCard extends StatelessWidget {
shape: BoxShape.circle,
image: DecorationImage(
image: NetworkImage(
customerProfile != null
? customerProfile!.profilePhotoUrl.contains('https')
? customerProfile!.profilePhotoUrl
: sl<AppConfig>().baseUrl + customerProfile!.profilePhotoUrl
: author.authorProfileIcon.contains('https')
? author.authorProfileIcon
: sl<AppConfig>().baseUrl + author.authorProfileIcon,
joinBaseUrl(
sl<AppConfig>().baseUrl,
customerProfile != null
? customerProfile!.profilePhotoUrl
: author.authorProfileIcon,
),
),
fit: BoxFit.cover,
),
),
),
subtitle: Text(
customerProfile != null ? customerProfile!.name : author.authorName,
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
customerProfile != null
? customerProfile!.name
: author.authorName,
style: TextStyles.textLSemibold.copyWith(
color: appTheme.black,
),
),
title: Text(
title,
style:
titleStyle ??
TextStyles.textSSemibold.copyWith(
color: appTheme.gray,
),
),
title: Text(title, style: titleStyle ?? TextStyles.textSSemibold.copyWith(color: appTheme.gray)),
),
Row(
children: [
if (customerProfile == null) ...[
Text('Консультант', style: TextStyles.textMMedium.copyWith(color: appTheme.black)),
Text(
'Консультант',
style: TextStyles.textMMedium.copyWith(
color: appTheme.black,
),
),
const SizedBox(width: 10),
if (author.authorPlaceInRating != null)
@@ -70,20 +106,35 @@ class AuthorCard extends StatelessWidget {
radius: 12,
backgroundColor: appTheme.cardBackground,
children: [
Text('${author.authorPlaceInRating}-й в рейтинге', style: TextStyles.textMMediumAuto.copyWith(color: appTheme.blueA400)),
Text(
'${author.authorPlaceInRating}-й в рейтинге',
style: TextStyles.textMMediumAuto.copyWith(
color: appTheme.blueA400,
),
),
],
),
] else
Expanded(
child: GestureDetector(
onTap: () {
GoRouter.of(context).pushNamed(UserDetailsScreen.name, extra: customerProfile!.slug);
GoRouter.of(context).pushNamed(
UserDetailsScreen.name,
extra: customerProfile!.slug,
);
},
child: InfoBouble(
radius: 12,
backgroundColor: appTheme.cardBackground,
mainAxisAlignment: MainAxisAlignment.center,
children: [Text('Анкета пользователя', style: TextStyles.textMMediumAuto.copyWith(color: appTheme.blueA400))],
children: [
Text(
'Анкета пользователя',
style: TextStyles.textMMediumAuto.copyWith(
color: appTheme.blueA400,
),
),
],
),
),
),
@@ -98,8 +149,18 @@ class AuthorCard extends StatelessWidget {
child: Container(
width: 28,
height: 28,
decoration: BoxDecoration(color: appTheme.whiteA700, borderRadius: BorderRadius.circular(8)),
child: Center(child: SvgPicture.asset('assets/icons/arrow.svg', width: 12, height: 12, fit: BoxFit.none)),
decoration: BoxDecoration(
color: appTheme.whiteA700,
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: SvgPicture.asset(
'assets/icons/arrow.svg',
width: 12,
height: 12,
fit: BoxFit.none,
),
),
),
),
],

View File

@@ -14,12 +14,21 @@ import 'package:flutter_svg/flutter_svg.dart';
import 'package:share_plus/share_plus.dart';
class MoreOptions extends StatelessWidget {
const MoreOptions({super.key, this.disableActions, required this.path, this.complainedContentType, this.slug, this.id});
const MoreOptions({
super.key,
this.disableActions,
required this.path,
this.complainedContentType,
this.slug,
this.id,
this.isAuthorAdmin = false,
});
final ComplainedContent? complainedContentType;
final int? id;
final String? slug;
final String path;
final bool? disableActions;
final bool isAuthorAdmin;
@override
Widget build(BuildContext context) {
@@ -83,40 +92,42 @@ class MoreOptions extends StatelessWidget {
],
),
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 12.0),
child: CustomDivider(),
),
GestureDetector(
onTap: disableActions == true
? null
: () {
if (complainedContentType != null) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => ComplainingBottomSheet(
complainedContentType: complainedContentType!,
id: id,
slug: slug,
),
);
}
},
child: Row(
children: [
Expanded(
child: Text(
s.complain,
style: TextStyles.textMMedium.copyWith(
color: appTheme.black,
if (!isAuthorAdmin) ...[
const Padding(
padding: EdgeInsets.symmetric(vertical: 12.0),
child: CustomDivider(),
),
GestureDetector(
onTap: disableActions == true
? null
: () {
if (complainedContentType != null) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => ComplainingBottomSheet(
complainedContentType: complainedContentType!,
id: id,
slug: slug,
),
);
}
},
child: Row(
children: [
Expanded(
child: Text(
s.complain,
style: TextStyles.textMMedium.copyWith(
color: appTheme.black,
),
),
),
),
SvgPicture.asset('assets/icons/complain.svg', width: 16, height: 20),
],
SvgPicture.asset('assets/icons/complain.svg', width: 16, height: 20),
],
),
),
),
],
],
),
),

View File

@@ -86,6 +86,8 @@ int parseDuration(dynamic duration) {
@freezed
abstract class Author with _$Author {
const Author._();
const factory Author({
@JsonKey(name: "author_slug") required String authorSlug,
@JsonKey(name: "author_name") required String authorName,
@@ -93,9 +95,12 @@ abstract class Author with _$Author {
@JsonKey(name: "author_is_me") required bool authorIsMe,
@JsonKey(name: "is_entity_subscribed_by_me") bool? isEntitySubscribedByMe,
@JsonKey(name: "author_place_in_rating") int? authorPlaceInRating,
@JsonKey(name: "author_roles") @Default([]) List<String> authorRoles,
}) = _Author;
factory Author.fromJson(Map<String, dynamic> json) => _$AuthorFromJson(json);
bool get isAdmin => authorRoles.contains('admin');
}
@freezed

View File

@@ -26,16 +26,47 @@ class PostFeedMapper {
totalViews: item.postViewsCount,
totalComments: item.postCommentsCount,
wasLikedByMe: item.postIsLikedByUser,
images: item.images,
images: item.images.where((image) => hasRenderableImage(image.url)).toList(),
videoEmbedUrl: item.videoEmbedUrl,
videoUrlThumb: item.videoUrlThumb,
);
}
static String resolveMediaUrl(String? url) {
if (url == null || url.isEmpty) return '';
if (url.startsWith('http://') || url.startsWith('https://')) return url;
if (url.startsWith('/')) return '${sl<AppConfig>().baseUrl}$url';
if (url == null || url.isEmpty || isPlaceholderMediaUrl(url)) {
return '';
}
if (url.startsWith('http://') || url.startsWith('https://')) {
return url;
}
if (url.startsWith('/')) {
return '${sl<AppConfig>().baseUrl}$url';
}
return '${sl<AppConfig>().baseUrl}/storage/$url';
}
static bool isPlaceholderMediaUrl(String url) {
return url.toLowerCase().contains('element-image-placeholder');
}
static bool hasRenderableImage(String? url) {
if (url == null || url.trim().isEmpty) {
return false;
}
return !isPlaceholderMediaUrl(url);
}
static bool hasVisibleMedia({
required String image,
required List<FeedGalleryImage> images,
String? videoEmbedUrl,
}) {
if (videoEmbedUrl != null && videoEmbedUrl.trim().isNotEmpty) {
return true;
}
if (hasRenderableImage(image)) {
return true;
}
return images.any((image) => hasRenderableImage(image.url));
}
}

View File

@@ -92,6 +92,7 @@ class DietsCubit extends Cubit<DietsState> {
}) async {
try {
_query = query;
currentPage = 1;
final type = _types[state.filterState.typeIndex];
final duration = _durationValues[state.filterState.durationIndex];
final sorting = _sortingValues[state.filterState.sortingIndex];

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