Compare commits

...

96 Commits

Author SHA1 Message Date
misha
508c321913 version changed 2026-10-29 13:56:06 +03:00
misha
4a5c67632e feat(video_calls): app opening from terminated state fix 2026-10-29 13:55:38 +03:00
misha
5e6e114d5d version changed 2026-10-27 14:08:05 +03:00
misha
e9cfcb9637 feat(video_calls): video calls tokens registration fix 2026-10-27 14:06:55 +03:00
misha
1c8709fc50 fix: analyzer fix 2026-10-23 15:29:38 +03:00
misha
d30c63be85 version changed 2026-10-23 14:11:52 +03:00
misha
0ddd4c6b14 feat(video_calls): call accepting and declingn handling 2026-10-23 14:11:20 +03:00
misha
2779f79464 feat(video_calls): ios call declining notification for other devices fix 2026-10-22 17:44:46 +03:00
misha
d8d7a211bd feat(video_calls): ios calle mic fix, infinite loading after accepting call on an oppened app fix, incaming call declining after declining on a different device 2026-10-22 14:56:15 +03:00
misha
992573aead feat(video_calls): ios calle mic fix 2026-10-22 12:16:09 +03:00
misha
3122394037 feat(video_calls): ios calle mic fix 2026-10-21 17:48:53 +03:00
misha
9916828956 feat(video_calls): ios mic fix 2026-10-21 17:11:25 +03:00
misha
8cb3a184b0 feat(video_calls): ios mic fix, tokens clearing fix 2026-10-21 16:24:23 +03:00
misha
592f387221 Merge remote-tracking branch 'upstream/main' into issue-1089 2026-10-21 15:03:59 +03:00
misha
dcf695777a version changed 2026-10-21 14:58:11 +03:00
misha
141cf5543d feat(video_calls): continous ringing on a defferent device fix 2026-10-21 14:25:59 +03:00
40ba5f304e Merge pull request 'fix(verification): show email confirmation when 405 reports missing email' (#1088) from apoloz/eatvio_app:fix/1087-email-verification-channel into main
Reviewed-on: eatvio/eatvio_app#1088
Reviewed-by: misha <misha@noreply.localhost>
2026-10-20 11:42:45 +00:00
Anatoliy Poloz
996cf07fb8 merege from main + version bump 2026-10-20 13:05:57 +03:00
Anatoliy Poloz
1766314bbe bump version 2026-10-20 13:03:23 +03:00
d1575d7ac4 fix(verification): show email confirmation when 405 reports missing email (#1087) 2026-10-19 18:11:34 +03:00
94bca6a793 Merge pull request 'feat(video_calls): screen waking up' (#1083) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1083
2026-10-19 13:15:30 +00:00
misha
a6b94f835c feat(video_calls): ios voip push handling fix 2026-10-19 16:09:20 +03:00
misha
4c38026748 Merge remote-tracking branch 'upstream/main' 2026-10-19 12:50:07 +03:00
misha
355ec2df48 feat(video_calls): added phone waking up after it gets call push 2026-10-19 12:49:09 +03:00
04195dd95b Merge pull request 'bump version for TF' (#1082) from apoloz/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1082
2026-10-19 07:55:43 +00:00
Anatoliy Poloz
d3336b1263 bump version for TF 2026-10-19 10:48:32 +03:00
82f5c9a7fe Merge pull request 'fix(diary): parse commented_from_diet/reviewed_from_diet portion states, fallback unknown states to PortionState.unknown' (#1081) from apoloz/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1081
Reviewed-by: misha <misha@noreply.localhost>
2026-10-19 07:32:51 +00:00
Anatoliy Poloz
39a512193e fix(diary): parse commented_from_diet/reviewed_from_diet portion states, fallback unknown states to PortionState.unknown 2026-10-16 21:12:40 +03:00
4dc5839317 Merge pull request 'feat(video_calls): android repeated call after hanging up fix and ios repeated calls after call initiation fix' (#1079) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1079
2026-10-16 15:21:13 +00:00
misha
da6158625f fix: tests 2026-10-16 16:30:56 +03:00
misha
5a8f8aee3e feat(video_calls): ios repeated call fix 2026-10-16 15:41:13 +03:00
misha
86e9d0cc31 feat(video_calls): android repeated call after hanging up fix and ios repeated calls after call initiation fix 2026-10-16 15:40:41 +03:00
60080c7407 Merge pull request 'feat(video_calls): ios voip push, mix and crushes fix' (#1078) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1078
2026-10-16 10:57:37 +00:00
misha
6d2935400a feat(video_calls): ios voip push, mix and crushes fix 2026-10-16 13:49:48 +03:00
293601f1e1 Merge pull request 'feat(ios): env migration for ios 27' (#1077) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1077
2026-10-16 05:27:39 +00:00
misha
ff17b813ac feat(ios): env migration for ios 27 2026-10-16 08:26:26 +03:00
6a259e5452 Merge pull request 'feat(video_calls): ios mic fix, android call fix. feat(dev_tools): base url caching' (#1076) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1076
Reviewed-by: Анатолий Полоз <t0ly.od@ya.ru>
2026-10-15 13:32:29 +00: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
224 changed files with 18370 additions and 4088 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: build:
runs-on: [macos-arm64] runs-on: [macos-arm64]
steps: steps:
# launchd does not load the login-shell PATH, so Homebrew git is invisible to act.
- name: Add Homebrew to PATH
run: |
echo "/opt/homebrew/bin" >> "$GITHUB_PATH"
echo "/opt/homebrew/sbin" >> "$GITHUB_PATH"
- name: Fastlane version - name: Fastlane version
run: fastlane --version run: |
git --version
fastlane --version
- name: Checkout - name: Checkout
uses: https://github.com/actions/checkout@v2 uses: https://github.com/actions/checkout@v2

View File

@@ -39,7 +39,7 @@ android {
versionName = flutter.versionName versionName = flutter.versionName
manifestPlaceholders = [ manifestPlaceholders = [
YANDEX_CLIENT_ID: yandexClientId, YANDEX_CLIENT_ID: yandexClientId,
applicationName: "io.flutter.app.FlutterApplication" applicationName: "com.eatvioapp.app.EatvioApplication"
] ]
} }
@@ -69,6 +69,7 @@ android {
} }
dependencies { dependencies {
testImplementation 'junit:junit:4.13.2'
implementation platform('com.google.firebase:firebase-bom:34.4.0') implementation platform('com.google.firebase:firebase-bom:34.4.0')
implementation 'com.google.firebase:firebase-messaging' implementation 'com.google.firebase:firebase-messaging'
implementation 'com.huawei.agconnect:agconnect-core:1.9.5.300' implementation 'com.huawei.agconnect:agconnect-core:1.9.5.300'

View File

@@ -78,12 +78,7 @@
<data android:host="eatvio.com" /> <data android:host="eatvio.com" />
<data android:host="www.eatvio.com" /> <data android:host="www.eatvio.com" />
<!-- Development/Subdomains -->
<data android:host="*.eatvio.ru" />
<data android:host="*.eatvio.com" />
<!-- Simplify patterns to allow navigation within the app for all matching hosts -->
<data android:pathPattern="/.*" />
</intent-filter> </intent-filter>
</activity> </activity>
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
@@ -92,8 +87,11 @@
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> android:value="2" />
<!-- Tap on notification body opens CallkitIncomingActivity via PendingIntent. <!-- The incoming notification's content intent and full-screen intent both open
Replace it with a bridge that routes to the same accept flow as Answer. --> CallkitIncomingActivity. Replace it with a bridge: a tap on an unlocked phone
routes to the same accept flow as Answer; on a locked/dark screen it shows a
ringing screen that wakes the device. Own task affinity keeps the plugin's
CLEAR_TASK flag from killing MainActivity. -->
<activity <activity
android:name="com.hiennv.flutter_callkit_incoming.CallkitIncomingActivity" android:name="com.hiennv.flutter_callkit_incoming.CallkitIncomingActivity"
tools:node="remove" /> tools:node="remove" />
@@ -102,20 +100,26 @@
android:name=".EatvioCallkitIncomingBridgeActivity" android:name=".EatvioCallkitIncomingBridgeActivity"
android:exported="false" android:exported="false"
android:theme="@android:style/Theme.Translucent.NoTitleBar" android:theme="@android:style/Theme.Translucent.NoTitleBar"
android:taskAffinity=""
android:allowTaskReparenting="false"
android:excludeFromRecents="true" android:excludeFromRecents="true"
android:noHistory="true" android:noHistory="true"
android:showWhenLocked="true"
android:turnScreenOn="true" android:turnScreenOn="true"
android:launchMode="singleTop" android:launchMode="singleInstance"
android:configChanges="orientation" /> android:configChanges="orientation" />
<activity-alias <activity-alias
android:name="com.hiennv.flutter_callkit_incoming.CallkitIncomingActivity" android:name="com.hiennv.flutter_callkit_incoming.CallkitIncomingActivity"
android:targetActivity=".EatvioCallkitIncomingBridgeActivity" android:targetActivity=".EatvioCallkitIncomingBridgeActivity"
android:exported="true" android:exported="true"
android:taskAffinity=""
android:allowTaskReparenting="false"
android:excludeFromRecents="true" android:excludeFromRecents="true"
android:noHistory="true" android:noHistory="true"
android:showWhenLocked="true"
android:turnScreenOn="true" android:turnScreenOn="true"
android:launchMode="singleTop" android:launchMode="singleInstance"
android:configChanges="orientation" android:configChanges="orientation"
android:permission="${applicationId}.PERMISSION_CALL"> android:permission="${applicationId}.PERMISSION_CALL">
<intent-filter> <intent-filter>

View File

@@ -13,14 +13,12 @@ object CallKitAcceptIntentMapper {
return null return null
} }
return hashMapOf( return payloadMap(data, action)
"action" to action, }
"id" to data.getString(CallkitConstants.EXTRA_CALLKIT_ID, ""),
"handle" to data.getString(CallkitConstants.EXTRA_CALLKIT_HANDLE, ""), fun declinePayloadFromBundle(data: Bundle, action: String): Map<String, Any?>? {
"nameCaller" to data.getString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, ""), if (!action.contains(CallkitConstants.ACTION_CALL_DECLINE)) return null
"avatar" to data.getString(CallkitConstants.EXTRA_CALLKIT_AVATAR, ""), return payloadMap(data, action)
"extra" to data.getSerializable(CallkitConstants.EXTRA_CALLKIT_EXTRA),
)
} }
fun payloadFromIntent(intent: Intent?): Map<String, Any?>? { fun payloadFromIntent(intent: Intent?): Map<String, Any?>? {
@@ -35,6 +33,16 @@ object CallKitAcceptIntentMapper {
return null return null
} }
return payloadMap(data, action)
}
fun clearCallKitLaunchIntent(intent: Intent?) {
if (intent == null) return
intent.removeExtra(FlutterCallkitIncomingPlugin.EXTRA_CALLKIT_CALL_DATA)
intent.action = null
}
private fun payloadMap(data: Bundle, action: String): Map<String, Any?> {
return hashMapOf( return hashMapOf(
"action" to action, "action" to action,
"id" to data.getString(CallkitConstants.EXTRA_CALLKIT_ID, ""), "id" to data.getString(CallkitConstants.EXTRA_CALLKIT_ID, ""),
@@ -44,10 +52,4 @@ object CallKitAcceptIntentMapper {
"extra" to data.getSerializable(CallkitConstants.EXTRA_CALLKIT_EXTRA), "extra" to data.getSerializable(CallkitConstants.EXTRA_CALLKIT_EXTRA),
) )
} }
fun clearCallKitLaunchIntent(intent: Intent?) {
if (intent == null) return
intent.removeExtra(FlutterCallkitIncomingPlugin.EXTRA_CALLKIT_CALL_DATA)
intent.action = null
}
} }

View File

@@ -8,10 +8,20 @@ import com.hiennv.flutter_callkit_incoming.CallkitIncomingBroadcastReceiver
object CallKitIntentBridge { object CallKitIntentBridge {
fun dispatchCallKitAction(context: Context, action: String, data: Bundle?) { fun dispatchCallKitAction(context: Context, action: String, data: Bundle?) {
if (IncomingCallRingtonePolicy.actionStopsRingtone(action)) {
VideoCallPushBridge.silenceIncomingCall(context)
}
if (data != null) { if (data != null) {
CallKitAcceptIntentMapper.payloadFromBundle(data, action)?.let { payload -> CallKitAcceptIntentMapper.payloadFromBundle(data, action)?.let { payload ->
PendingCallKitAcceptBridge.store(payload) PendingCallKitAcceptBridge.store(payload)
} }
CallKitAcceptIntentMapper.declinePayloadFromBundle(data, action)?.let { payload ->
PendingCallKitDeclineBridge.store(payload)
if (MainActivity.isInForeground) {
MainActivity.onPendingDecline?.invoke()
}
}
} }
val broadcastIntent = CallkitIncomingBroadcastReceiver.getIntent(context, action, data) val broadcastIntent = CallkitIncomingBroadcastReceiver.getIntent(context, action, data)

View File

@@ -0,0 +1,10 @@
package com.eatvioapp.app
import android.app.Application
class EatvioApplication : Application() {
override fun onCreate() {
super.onCreate()
VideoCallDeclineReporter.install(this)
}
}

View File

@@ -1,12 +1,71 @@
package com.eatvioapp.app package com.eatvioapp.app
import android.app.Activity import android.app.Activity
import android.app.KeyguardManager
import android.content.Context
import android.content.Intent
import android.content.pm.ActivityInfo
import android.graphics.Color
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.PowerManager
import android.util.Log
import android.view.View
import android.view.WindowManager
import android.view.ViewGroup.MarginLayoutParams
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import com.hiennv.flutter_callkit_incoming.AppUtils
import com.hiennv.flutter_callkit_incoming.CallkitConstants import com.hiennv.flutter_callkit_incoming.CallkitConstants
import com.hiennv.flutter_callkit_incoming.CallkitNotificationManager
import com.hiennv.flutter_callkit_incoming.ImageLoaderProvider
import com.hiennv.flutter_callkit_incoming.Utils
import com.hiennv.flutter_callkit_incoming.widgets.RippleRelativeLayout
import java.lang.ref.WeakReference
import kotlin.math.abs
import com.hiennv.flutter_callkit_incoming.R as CallkitR
/// Redirects notification body / full-screen intent taps to the same accept path as /// Target of the incoming notification's content intent and full-screen intent.
/// the Answer button, instead of showing the native Callkit incoming UI. ///
/// Unlocked, lit screen: the user tapped the notification body, route it to the same
/// accept flow as the Answer button.
///
/// Locked or dark screen: Android fired the full-screen intent on its own, so show a
/// ringing screen that wakes the device and lets the user answer or decline.
class EatvioCallkitIncomingBridgeActivity : Activity() { class EatvioCallkitIncomingBridgeActivity : Activity() {
companion object {
private const val TAG = "EatvioIncomingBridge"
private const val WAKE_LOCK_TAG = "eatvio:incoming_call"
private const val DEFAULT_RING_DURATION_MS = 45_000L
private const val MIN_RING_SCREEN_MS = 1_000L
private val mainHandler = Handler(Looper.getMainLooper())
private var ringingScreen: WeakReference<EatvioCallkitIncomingBridgeActivity>? = null
/**
* Closes the ringing screen once the call stopped ringing: answered or declined
* from the notification, timed out, or ended by the caller.
*/
fun dismissRingingScreen() {
mainHandler.post {
ringingScreen?.get()?.closeRingingScreen()
ringingScreen = null
}
}
fun shouldShowRingingScreen(keyguardLocked: Boolean, screenInteractive: Boolean): Boolean {
return keyguardLocked || !screenInteractive
}
}
private var callData: Bundle? = null
private var wakeLock: PowerManager.WakeLock? = null
private var ringTimeout: Runnable? = null
private var actionTaken = false
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -15,15 +74,256 @@ class EatvioCallkitIncomingBridgeActivity : Activity() {
finish() finish()
return return
} }
callData = data
CallKitIntentBridge.dispatchCallKitAction( if (!launchedOnLockedOrDarkScreen()) {
this, Log.i(TAG, "tap on notification body: accept")
CallkitConstants.ACTION_CALL_ACCEPT, CallKitIntentBridge.dispatchCallKitAction(this, CallkitConstants.ACTION_CALL_ACCEPT, data)
data, finish()
@Suppress("DEPRECATION")
overridePendingTransition(0, 0)
return
}
Log.i(TAG, "full-screen intent on locked/dark screen: show ringing screen")
showRingingScreen(data)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
val data = intent.extras?.getBundle(CallkitConstants.EXTRA_CALLKIT_INCOMING_DATA) ?: return
if (ringingScreen?.get() !== this) return
callData = data
bindCallData(data)
scheduleRingTimeout(data)
}
override fun onDestroy() {
cancelRingTimeout()
releaseWakeLock()
if (ringingScreen?.get() === this) {
ringingScreen = null
}
super.onDestroy()
}
@Deprecated("Deprecated in Java")
override fun onBackPressed() {
// The ringing screen is dismissed by Answer / Decline / timeout only.
}
private fun launchedOnLockedOrDarkScreen(): Boolean {
val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
return shouldShowRingingScreen(
keyguardLocked = keyguardManager.isKeyguardLocked,
screenInteractive = powerManager.isInteractive,
)
}
private fun showRingingScreen(data: Bundle) {
ringingScreen = WeakReference(this)
setTheme(CallkitR.style.CallkitIncomingTheme)
requestedOrientation = if (Utils.isTablet(this)) {
ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
} else {
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setTurnScreenOn(true)
setShowWhenLocked(true)
} else {
@Suppress("DEPRECATION")
window.addFlags(
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON or
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED,
)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
window.statusBarColor = Color.TRANSPARENT
window.navigationBarColor = Color.TRANSPARENT
}
acquireWakeLock(ringDuration(data))
VideoCallPushBridge.keepRingingOnFullScreen()
setContentView(CallkitR.layout.activity_callkit_incoming)
bindCallData(data)
scheduleRingTimeout(data)
}
private fun bindCallData(data: Bundle) {
val background: ImageView = findViewById(CallkitR.id.ivBackground)
val ripple: RippleRelativeLayout = findViewById(CallkitR.id.llBackgroundAnimation)
val callerName: TextView = findViewById(CallkitR.id.tvNameCaller)
val callerNumber: TextView = findViewById(CallkitR.id.tvNumber)
val logo: ImageView = findViewById(CallkitR.id.ivLogo)
val avatar: ImageView = findViewById(CallkitR.id.ivAvatar)
val actions: LinearLayout = findViewById(CallkitR.id.llAction)
val acceptButton: ImageView = findViewById(CallkitR.id.ivAcceptCall)
val acceptLabel: TextView = findViewById(CallkitR.id.tvAccept)
val declineButton: ImageView = findViewById(CallkitR.id.ivDeclineCall)
val declineLabel: TextView = findViewById(CallkitR.id.tvDecline)
ripple.layoutParams.height = Utils.getScreenWidth() + Utils.getStatusBarHeight(this)
ripple.startRippleAnimation()
(actions.layoutParams as? MarginLayoutParams)?.let { params ->
params.setMargins(0, 0, 0, Utils.getNavigationBarHeight(this))
actions.layoutParams = params
}
callerName.text = data.getString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, "")
callerNumber.visibility = View.INVISIBLE
logo.visibility = View.INVISIBLE
val textColor = parseColor(data.getString(CallkitConstants.EXTRA_CALLKIT_TEXT_COLOR), Color.WHITE)
callerName.setTextColor(textColor)
acceptLabel.setTextColor(textColor)
declineLabel.setTextColor(textColor)
background.setBackgroundColor(
parseColor(data.getString(CallkitConstants.EXTRA_CALLKIT_BACKGROUND_COLOR), Color.parseColor("#0955fa")),
) )
finish() val avatarUrl = data.getString(CallkitConstants.EXTRA_CALLKIT_AVATAR, "")
@Suppress("DEPRECATION") if (!avatarUrl.isNullOrEmpty()) {
overridePendingTransition(0, 0) avatar.visibility = View.VISIBLE
@Suppress("DEPRECATION", "UNCHECKED_CAST")
val headers = data.getSerializable(CallkitConstants.EXTRA_CALLKIT_HEADERS) as? HashMap<String, Any?>
try {
ImageLoaderProvider.loadImage(this, avatarUrl, headers, CallkitR.drawable.ic_default_avatar, avatar)
} catch (error: Exception) {
Log.w(TAG, "failed to load caller avatar", error)
}
}
if (data.getInt(CallkitConstants.EXTRA_CALLKIT_TYPE, 0) > 0) {
acceptButton.setImageResource(CallkitR.drawable.ic_video)
}
acceptButton.background = AppUtils.createCircleDrawable(
parseColor(data.getString(CallkitConstants.EXTRA_CALLKIT_ACCEPT_COLOR), Color.parseColor("#4CAF50")),
)
declineButton.background = AppUtils.createCircleDrawable(
parseColor(data.getString(CallkitConstants.EXTRA_CALLKIT_DECLINE_COLOR), Color.parseColor("#F44336")),
)
val acceptText = data.getString(CallkitConstants.EXTRA_CALLKIT_TEXT_ACCEPT, "")
acceptLabel.text = if (acceptText.isNullOrEmpty()) getString(CallkitR.string.text_accept) else acceptText
val declineText = data.getString(CallkitConstants.EXTRA_CALLKIT_TEXT_DECLINE, "")
declineLabel.text = if (declineText.isNullOrEmpty()) getString(CallkitR.string.text_decline) else declineText
acceptButton.setOnClickListener { onAcceptClick() }
declineButton.setOnClickListener { onDeclineClick() }
}
private fun onAcceptClick() {
val data = callData ?: return
if (actionTaken) return
actionTaken = true
cancelRingTimeout()
Log.i(TAG, "ringing screen: accept")
// Same order as the plugin's own incoming activity: dispatch the accept, then ask
// the keyguard to unlock so MainActivity (which cannot show over the keyguard)
// becomes visible as soon as the user unlocks.
CallKitIntentBridge.dispatchCallKitAction(this, CallkitConstants.ACTION_CALL_ACCEPT, data)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
if (keyguardManager.isKeyguardLocked) {
keyguardManager.requestDismissKeyguard(this, null)
}
}
finishRingingTask()
}
private fun onDeclineClick() {
val data = callData ?: return
if (actionTaken) return
actionTaken = true
cancelRingTimeout()
Log.i(TAG, "ringing screen: decline")
CallKitIntentBridge.dispatchCallKitAction(this, CallkitConstants.ACTION_CALL_DECLINE, data)
finishRingingTask()
}
private fun closeRingingScreen() {
if (isFinishing) return
cancelRingTimeout()
finishRingingTask()
}
private fun finishRingingTask() {
if (isFinishing) return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
finishAndRemoveTask()
} else {
finish()
}
}
private fun ringDuration(data: Bundle): Long {
val duration = data.getLong(CallkitConstants.EXTRA_CALLKIT_DURATION, DEFAULT_RING_DURATION_MS)
return if (duration > 0L) duration else DEFAULT_RING_DURATION_MS
}
/**
* The notification was posted before this screen appeared, so ring only for the
* remaining part of the call duration.
*/
private fun scheduleRingTimeout(data: Bundle) {
cancelRingTimeout()
val now = System.currentTimeMillis()
val startedAt = data.getLong(CallkitNotificationManager.EXTRA_TIME_START_CALL, now)
val remaining = (ringDuration(data) - abs(now - startedAt)).coerceAtLeast(MIN_RING_SCREEN_MS)
val timeout = Runnable {
Log.i(TAG, "ringing screen: timed out")
closeRingingScreen()
}
ringTimeout = timeout
mainHandler.postDelayed(timeout, remaining)
}
private fun cancelRingTimeout() {
ringTimeout?.let { mainHandler.removeCallbacks(it) }
ringTimeout = null
}
@Suppress("DEPRECATION")
private fun acquireWakeLock(durationMs: Long) {
try {
val powerManager = applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager
val lock = powerManager.newWakeLock(
PowerManager.SCREEN_BRIGHT_WAKE_LOCK or
PowerManager.ACQUIRE_CAUSES_WAKEUP or
PowerManager.ON_AFTER_RELEASE,
WAKE_LOCK_TAG,
)
lock.acquire(durationMs)
wakeLock = lock
} catch (error: Exception) {
Log.w(TAG, "failed to acquire wake lock", error)
}
}
private fun releaseWakeLock() {
val lock = wakeLock ?: return
wakeLock = null
try {
if (lock.isHeld) lock.release()
} catch (error: Exception) {
Log.w(TAG, "failed to release wake lock", error)
}
}
private fun parseColor(value: String?, fallback: Int): Int {
if (value.isNullOrBlank()) return fallback
return try {
Color.parseColor(value)
} catch (error: IllegalArgumentException) {
fallback
}
} }
} }

View File

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

View File

@@ -21,7 +21,7 @@ class HmsPushService : HmsMessageService() {
if (message == null) return if (message == null) return
if (VideoCallPushBridge.isEndedVideoCall(message.dataOfMap)) { if (VideoCallPushBridge.isEndedVideoCall(message.dataOfMap)) {
VideoCallPushBridge.dismissAllIncomingCalls(this) VideoCallPushBridge.dismissEndedVideoCall(this, message.dataOfMap)
return return
} }

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.hiennv.flutter_callkit_incoming.FlutterCallkitIncomingPlugin
import com.huawei.agconnect.config.AGConnectServicesConfig import com.huawei.agconnect.config.AGConnectServicesConfig
import com.huawei.hms.aaid.HmsInstanceId import com.huawei.hms.aaid.HmsInstanceId
import com.huawei.hms.api.ConnectionResult
import com.huawei.hms.api.HuaweiApiAvailability
import com.huawei.hms.common.ApiException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -22,6 +25,9 @@ class MainActivity: FlutterActivity() {
@JvmStatic @JvmStatic
var isInForeground: Boolean = false var isInForeground: Boolean = false
private set private set
@JvmStatic
var onPendingDecline: (() -> Unit)? = null
} }
private val channelName = "com.eatvioapp/hms_push" private val channelName = "com.eatvioapp/hms_push"
@@ -35,13 +41,18 @@ class MainActivity: FlutterActivity() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
isInForeground = true isInForeground = true
onPendingDecline = { scheduleDeliverPendingCallKitDecline() }
captureCallKitLaunchIntent(intent) captureCallKitLaunchIntent(intent)
if (PendingCallKitAcceptBridge.peek() != null) { if (PendingCallKitAcceptBridge.peek() != null) {
scheduleDeliverPendingCallKitAccept() scheduleDeliverPendingCallKitAccept()
} }
if (PendingCallKitDeclineBridge.peek() != null) {
scheduleDeliverPendingCallKitDecline()
}
} }
override fun onPause() { override fun onPause() {
onPendingDecline = null
isInForeground = false isInForeground = false
super.onPause() super.onPause()
} }
@@ -52,22 +63,50 @@ class MainActivity: FlutterActivity() {
methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName) methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName)
videoCallPipChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, videoCallPipChannelName) videoCallPipChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, videoCallPipChannelName)
videoCallPushChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, videoCallPushChannelName) videoCallPushChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, videoCallPushChannelName)
onPendingDecline = { scheduleDeliverPendingCallKitDecline() }
videoCallPushChannel?.setMethodCallHandler { call, result -> videoCallPushChannel?.setMethodCallHandler { call, result ->
when (call.method) { when (call.method) {
"dismissIncomingCalls" -> { "dismissIncomingCalls" -> {
try { try {
VideoCallPushBridge.dismissAllIncomingCalls(applicationContext) val args = call.arguments as? Map<*, *>
val callId = args?.get("call_id")?.toString()
?: args?.get("callId")?.toString()
val conversationUuid = args?.get("conversation_uuid")?.toString()
?: args?.get("conversationUuid")?.toString()
VideoCallPushBridge.dismissIncomingCalls(
applicationContext,
callId = callId,
conversationUuid = conversationUuid,
)
result.success(null) result.success(null)
} catch (e: Exception) { } catch (e: Exception) {
result.error("DISMISS_INCOMING_CALLS_FAILED", e.message, null) result.error("DISMISS_INCOMING_CALLS_FAILED", e.message, null)
} }
} }
"showIncomingCall" -> {
try {
val args = call.arguments as? Map<*, *> ?: emptyMap<Any, Any>()
val data = HashMap<String, String>()
for ((key, value) in args) {
val name = key as? String ?: continue
if (value == null) continue
data[name] = value.toString()
}
VideoCallPushBridge.showIncomingCall(applicationContext, data)
result.success(null)
} catch (e: Exception) {
result.error("SHOW_INCOMING_CALL_FAILED", e.message, null)
}
}
"drainPendingCallKitAccept" -> { "drainPendingCallKitAccept" -> {
result.success(PendingCallKitAcceptBridge.drain()) result.success(PendingCallKitAcceptBridge.drain())
} }
"peekPendingCallKitAccept" -> { "peekPendingCallKitAccept" -> {
result.success(PendingCallKitAcceptBridge.peekPending()) result.success(PendingCallKitAcceptBridge.peekPending())
} }
"drainPendingCallKitDecline" -> {
result.success(PendingCallKitDeclineBridge.drain())
}
else -> result.notImplemented() else -> result.notImplemented()
} }
} }
@@ -82,6 +121,20 @@ class MainActivity: FlutterActivity() {
} }
methodChannel?.setMethodCallHandler { call, result -> methodChannel?.setMethodCallHandler { call, result ->
when (call.method) { when (call.method) {
"isHmsAvailable" -> {
CoroutineScope(Dispatchers.IO).launch {
val available = try {
HuaweiApiAvailability.getInstance()
.isHuaweiMobileServicesAvailable(applicationContext) == ConnectionResult.SUCCESS
} catch (e: Exception) {
android.util.Log.w("HMS", "HMS availability check failed: ${e.message}")
false
}
runOnUiThread {
result.success(available)
}
}
}
"getHuaweiToken" -> { "getHuaweiToken" -> {
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
try { try {
@@ -121,10 +174,30 @@ class MainActivity: FlutterActivity() {
runOnUiThread { runOnUiThread {
result.success(token) result.success(token)
} }
} catch (e: ApiException) {
if (e.statusCode == 907135003) {
android.util.Log.w("HMS", "HMS client API invalid (${e.statusCode}), falling back to FCM")
runOnUiThread {
result.success(null)
}
} else {
android.util.Log.e("HMS", "Error getting HMS token: ${e.statusCode}: ${e.message}", e)
runOnUiThread {
result.error("HMS_TOKEN_ERROR", "${e.statusCode}: ${e.message}", null)
}
}
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.e("HMS", "Error getting HMS token: ${e.message}", e) val message = e.message.orEmpty()
runOnUiThread { if (message.contains("907135003") || message.contains("client api invalid", ignoreCase = true)) {
result.error("HMS_TOKEN_ERROR", e.message, null) android.util.Log.w("HMS", "HMS client API invalid, falling back to FCM")
runOnUiThread {
result.success(null)
}
} else {
android.util.Log.e("HMS", "Error getting HMS token: $message", e)
runOnUiThread {
result.error("HMS_TOKEN_ERROR", e.message, null)
}
} }
} }
} }
@@ -189,6 +262,30 @@ class MainActivity: FlutterActivity() {
channel.invokeMethod("onCallKitLaunchIntent", payload) channel.invokeMethod("onCallKitLaunchIntent", payload)
} }
private fun scheduleDeliverPendingCallKitDecline() {
PendingCallKitDeclineBridge.scheduleDeliverToFlutter(
deliver = { deliverPendingCallKitDeclineToFlutter() },
)
}
private fun deliverPendingCallKitDeclineToFlutter() {
val payload = PendingCallKitDeclineBridge.peek() ?: return
val channel = videoCallPushChannel
if (channel == null) {
android.util.Log.w(
"VideoCallPush",
"deliverPendingCallKitDeclineToFlutter: channel not ready",
)
return
}
android.util.Log.i(
"VideoCallPush",
"deliverPendingCallKitDeclineToFlutter: id=${payload["id"]}",
)
channel.invokeMethod("onCallKitDecline", payload)
}
private fun handleIntentForActionUrl(intent: Intent?) { private fun handleIntentForActionUrl(intent: Intent?) {
if (intent == null) return if (intent == null) return

View File

@@ -0,0 +1,44 @@
package com.eatvioapp.app
import android.os.Handler
import android.os.Looper
import android.util.Log
/// Stores a decline until Flutter can POST /video-call/decline.
///
/// The plugin broadcasts the decline. If the Flutter engine is not attached,
/// that broadcast never reaches the backend and the call keeps ringing on the
/// user's other devices.
object PendingCallKitDeclineBridge {
private const val TAG = "PendingCallKitDecline"
@Volatile
private var pendingPayload: Map<String, Any?>? = null
private val mainHandler = Handler(Looper.getMainLooper())
fun store(payload: Map<String, Any?>) {
pendingPayload = payload
Log.i(TAG, "stored pending decline id=${payload["id"]}")
}
fun peek(): Map<String, Any?>? = pendingPayload
fun drain(): Map<String, Any?>? {
val payload = pendingPayload
pendingPayload = null
if (payload != null) {
Log.i(TAG, "drained pending decline id=${payload["id"]}")
}
return payload
}
fun scheduleDeliverToFlutter(
deliver: () -> Unit,
delaysMs: LongArray = longArrayOf(0L, 250L, 700L, 1500L, 2800L, 4200L, 6000L),
) {
for (delayMs in delaysMs) {
mainHandler.postDelayed({ deliver() }, delayMs)
}
}
}

View File

@@ -0,0 +1,73 @@
package com.eatvioapp.app
import android.content.Context
import android.util.Log
import java.net.HttpURLConnection
import java.net.URL
internal object FlutterSharedPrefs {
const val NAME = "FlutterSharedPreferences"
private const val PREFIX = "flutter."
fun string(context: Context, key: String): String? {
return context
.getSharedPreferences(NAME, Context.MODE_PRIVATE)
.getString(PREFIX + key, null)
?.trim()
?.takeIf { it.isNotEmpty() }
}
}
internal object VideoCallDeclineClient {
private const val TAG = "VideoCallDecline"
private const val TIMEOUT_MS = 4_000
fun send(context: Context, handle: String?, extra: Map<*, *>?) {
val fields = VideoCallDeclineRequestFactory.fields(handle, extra)
val baseUrl = FlutterSharedPrefs.string(context, "apiBaseUrl")
?: FlutterSharedPrefs.string(context, "customBaseUrl")
val request = VideoCallDeclineRequestFactory.build(
baseUrl = baseUrl,
token = FlutterSharedPrefs.string(context, "token"),
hwId = FlutterSharedPrefs.string(context, "eatvio.android_install_hw_id"),
fields = fields,
)
if (request == null) {
Log.w(
TAG,
"skip decline POST conversation=${fields.conversationUuid} ended=${fields.endedPush}",
)
return
}
try {
val code = post(request)
Log.i(TAG, "decline POST status=$code conversation=${fields.conversationUuid}")
} catch (error: Exception) {
Log.w(TAG, "decline POST failed conversation=${fields.conversationUuid}", error)
}
}
private fun post(request: VideoCallDeclineRequest): Int {
val connection = (URL(request.url).openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
connectTimeout = TIMEOUT_MS
readTimeout = TIMEOUT_MS
doOutput = true
setRequestProperty("Authorization", "Bearer ${request.token}")
setRequestProperty("Accept", "application/json")
setRequestProperty("Content-Type", "application/json; charset=utf-8")
}
try {
connection.outputStream.use { stream ->
stream.write(request.body.toByteArray(Charsets.UTF_8))
}
val code = connection.responseCode
val response = if (code in 200..299) connection.inputStream else connection.errorStream
response?.use { stream -> stream.readBytes() }
return code
} finally {
connection.disconnect()
}
}
}

View File

@@ -0,0 +1,74 @@
package com.eatvioapp.app
import android.content.Context
import android.os.Bundle
import android.util.Log
import androidx.core.os.BundleCompat
import com.hiennv.flutter_callkit_incoming.CallkitConstants
import com.hiennv.flutter_callkit_incoming.CallkitEventCallback
import com.hiennv.flutter_callkit_incoming.FlutterCallkitIncomingPlugin
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
/**
* Sends `POST /video-call/decline` from the process that handled the tap.
*
* The notification Decline button is an explicit broadcast into the CallKit
* plugin. That broadcast starts the process when the app was killed and does
* not start Flutter. The plugin notifies this callback before it returns, so
* the request can finish while the process is still alive.
*/
object VideoCallDeclineReporter : CallkitEventCallback {
private const val TAG = "VideoCallDecline"
private const val WAIT_FOR_HTTP_MS = 9_000L
@Volatile
private var appContext: Context? = null
@Volatile
private var installed = false
fun install(context: Context) {
if (installed) return
installed = true
appContext = context.applicationContext
FlutterCallkitIncomingPlugin.registerEventCallback(this)
Log.i(TAG, "installed")
}
override fun onCallEvent(event: CallkitEventCallback.CallEvent, callData: Bundle) {
if (event != CallkitEventCallback.CallEvent.DECLINE) return
val context = appContext ?: return
val handle = callData.getString(CallkitConstants.EXTRA_CALLKIT_HANDLE)
val extra = copyExtra(callData)
if (MainActivity.isInForeground) {
CoroutineScope(Dispatchers.IO).launch {
VideoCallDeclineClient.send(context, handle, extra)
}
return
}
// The Decline button starts this process and then the broadcast ends.
// Wait here so Android does not kill the process before the POST returns.
runBlocking(Dispatchers.IO) {
withTimeoutOrNull(WAIT_FOR_HTTP_MS) {
VideoCallDeclineClient.send(context, handle, extra)
}
}
}
private fun copyExtra(callData: Bundle): Map<String, Any?> {
val raw = BundleCompat.getSerializable(
callData,
CallkitConstants.EXTRA_CALLKIT_EXTRA,
HashMap::class.java,
) ?: return emptyMap()
return buildMap {
for ((key, value) in raw) {
if (key is String) put(key, value)
}
}
}
}

View File

@@ -0,0 +1,136 @@
package com.eatvioapp.app
/**
* Body of `POST /api/v2/chat/{conversation}/video-call/decline`.
*
* [callId] is omitted when it is missing, not a uuid, or equal to the
* conversation uuid: the server treats `call_id` as the session uuid.
*/
internal data class VideoCallDeclineRequest(
val url: String,
val token: String,
val body: String,
)
internal data class VideoCallDeclineFields(
val conversationUuid: String,
val callId: String?,
val endedPush: Boolean,
)
internal object VideoCallDeclineRequestFactory {
private val HEX = "0123456789ABCDEF".toCharArray()
private val uuidPattern =
Regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
fun fields(handle: String?, extra: Map<*, *>?): VideoCallDeclineFields {
val conversation = firstString(
extra,
listOf("conversationUuid", "conversation_uuid"),
) ?: handle?.trim().orEmpty()
val rawCallId = firstString(extra, listOf("call_id", "callId"))
return VideoCallDeclineFields(
conversationUuid = conversation,
callId = backendCallId(rawCallId, conversation),
endedPush = marksEndedPush(extra),
)
}
fun build(
baseUrl: String?,
token: String?,
hwId: String?,
fields: VideoCallDeclineFields,
): VideoCallDeclineRequest? {
if (fields.endedPush) return null
val conversation = fields.conversationUuid.trim()
val authToken = token?.trim().orEmpty()
val deviceId = hwId?.trim().orEmpty()
val root = baseUrl?.trim()?.trimEnd('/').orEmpty()
if (conversation.isEmpty() || authToken.isEmpty()) return null
if (deviceId.length < 8 || deviceId.length > 64) return null
if (!root.startsWith("http://") && !root.startsWith("https://")) return null
val encoded = encodePathSegment(conversation)
return VideoCallDeclineRequest(
url = "$root/api/v2/chat/$encoded/video-call/decline",
token = authToken,
body = jsonBody(deviceId, fields.callId),
)
}
private fun backendCallId(callId: String?, conversationUuid: String): String? {
val id = callId?.trim().orEmpty()
if (!uuidPattern.matches(id)) return null
if (id.equals(conversationUuid.trim(), ignoreCase = true)) return null
return id
}
private fun firstString(extra: Map<*, *>?, keys: List<String>): String? {
if (extra == null) return null
for (key in keys) {
val value = extra[key] as? String ?: continue
val trimmed = value.trim()
if (trimmed.isNotEmpty()) return trimmed
}
return null
}
private fun marksEndedPush(extra: Map<*, *>?): Boolean {
val value = extra?.get("endedPush") ?: return false
return when (value) {
is Boolean -> value
is Number -> value.toInt() != 0
is String -> {
val normalized = value.trim().lowercase()
normalized == "true" || normalized == "1"
}
else -> false
}
}
private fun jsonBody(hwId: String, callId: String?): String {
val hw = jsonString(hwId)
if (callId == null) return """{"hw_id":$hw}"""
return """{"hw_id":$hw,"call_id":${jsonString(callId)}}"""
}
private fun encodePathSegment(value: String): String {
val bytes = value.encodeToByteArray()
return buildString(bytes.size) {
for (byte in bytes) {
val code = byte.toInt() and 0xFF
val allowed = code in 'A'.code..'Z'.code ||
code in 'a'.code..'z'.code ||
code in '0'.code..'9'.code ||
code == '-'.code ||
code == '_'.code ||
code == '.'.code ||
code == '~'.code
if (allowed) {
append(code.toChar())
} else {
append('%')
append(HEX[code ushr 4])
append(HEX[code and 0x0F])
}
}
}
}
private fun jsonString(value: String): String {
val escaped = buildString {
for (char in value) {
when (char) {
'\\' -> append("\\\\")
'"' -> append("\\\"")
'\n' -> append("\\n")
'\r' -> append("\\r")
'\t' -> append("\\t")
else -> append(char)
}
}
}
return "\"$escaped\""
}
}

View File

@@ -4,10 +4,12 @@ import android.content.Context
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.os.PowerManager
import android.util.Log import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import com.hiennv.flutter_callkit_incoming.CallkitConstants import com.hiennv.flutter_callkit_incoming.CallkitConstants
import com.hiennv.flutter_callkit_incoming.CallkitEventCallback
import com.hiennv.flutter_callkit_incoming.CallkitNotificationManager import com.hiennv.flutter_callkit_incoming.CallkitNotificationManager
import com.hiennv.flutter_callkit_incoming.CallkitNotificationService import com.hiennv.flutter_callkit_incoming.CallkitNotificationService
import com.hiennv.flutter_callkit_incoming.CallkitSoundPlayerManager import com.hiennv.flutter_callkit_incoming.CallkitSoundPlayerManager
@@ -16,15 +18,42 @@ import com.hiennv.flutter_callkit_incoming.FlutterCallkitIncomingPlugin
import com.hiennv.flutter_callkit_incoming.addCall import com.hiennv.flutter_callkit_incoming.addCall
import com.hiennv.flutter_callkit_incoming.getDataActiveCalls import com.hiennv.flutter_callkit_incoming.getDataActiveCalls
import com.hiennv.flutter_callkit_incoming.removeAllCalls import com.hiennv.flutter_callkit_incoming.removeAllCalls
import com.hiennv.flutter_callkit_incoming.removeCall
import java.util.HashMap import java.util.HashMap
import java.util.concurrent.atomic.AtomicInteger
object VideoCallPushBridge { object VideoCallPushBridge {
private const val TAG = "VideoCallPushBridge" private const val TAG = "VideoCallPushBridge"
private const val ANDROID_ACCENT_COLOR = "#2787F5" private const val ANDROID_ACCENT_COLOR = "#2787F5"
private const val MISSED_CALL_CLEANUP_DELAY_MS = 250L private const val MISSED_CALL_CLEANUP_DELAY_MS = 250L
private const val TOMBSTONE_TTL_MS = 2 * 60 * 1000L
private const val WAKE_LOCK_TAG = "eatvio:incoming_call_ring"
private val mainHandler = Handler(Looper.getMainLooper())
private val playbackLock = Any()
private val playbackGeneration = AtomicInteger(0)
private val tombstonedCallIds = HashMap<String, Long>()
private var activeSoundManager: CallkitSoundPlayerManager? = null private var activeSoundManager: CallkitSoundPlayerManager? = null
private var activeNotificationManager: CallkitNotificationManager? = null private var activeNotificationManager: CallkitNotificationManager? = null
private var activeCallBundle: Bundle? = null
private var storedAppContext: Context? = null
private var ringtoneTimeout: Runnable? = null
private var ringWakeLock: PowerManager.WakeLock? = null
private var callbackRegistered = false
/**
* The plugin stops only the sound player owned by [FlutterCallkitIncomingPlugin].
* Incoming pushes start a different player, so accept/decline never reached it
* and the ringtone kept looping until the process died.
*/
private val incomingCallEventCallback = object : CallkitEventCallback {
override fun onCallEvent(event: CallkitEventCallback.CallEvent, callData: Bundle) {
val context = storedAppContext ?: return
Log.i(TAG, "incoming call event=$event, stopping ringtone")
silenceIncomingCall(context)
}
}
fun isIncomingVideoCall(data: Map<String, String>): Boolean { fun isIncomingVideoCall(data: Map<String, String>): Boolean {
val rawType = data["type"] val rawType = data["type"]
@@ -89,18 +118,191 @@ object VideoCallPushBridge {
fun showIncomingCall(context: Context, data: Map<String, String>) { fun showIncomingCall(context: Context, data: Map<String, String>) {
val appContext = context.applicationContext val appContext = context.applicationContext
storedAppContext = appContext
ensureIncomingCallCallbackRegistered()
val serverCallId = callId(data)
if (isTombstoned(serverCallId)) {
Log.i(TAG, "showIncomingCall: skip tombstoned call_id=$serverCallId")
return
}
val callData = buildCallDataBundle(data) val callData = buildCallDataBundle(data)
if (callData == null) { if (callData == null) {
Log.w(TAG, "showIncomingCall: missing conversation uuid, data=$data") Log.w(TAG, "showIncomingCall: missing conversation uuid, data=$data")
return return
} }
Log.i(TAG, "showIncomingCall: conversation=${callData.getString(CallkitConstants.EXTRA_CALLKIT_ID)}") val callId = callData.getString(CallkitConstants.EXTRA_CALLKIT_ID)
val seenGeneration = playbackGeneration.get()
Log.i(TAG, "showIncomingCall: id=$callId conversation=${conversationUuid(data)}")
// Ringtone.play() and stop() must run on the same thread. FCM delivers
// pushes on a background thread, while Answer runs on the main thread.
// A second show for the same conversation must not bump the generation:
// that would cancel the timeout of the call that is already ringing.
mainHandler.post {
synchronized(playbackLock) {
if (seenGeneration != playbackGeneration.get()) {
Log.i(TAG, "showIncomingCall: skipped after silence")
return@post
}
val alreadyRinging = activeCallBundle?.getString(CallkitConstants.EXTRA_CALLKIT_ID)
if (!callId.isNullOrBlank() && callId == alreadyRinging) {
Log.i(TAG, "showIncomingCall: already ringing $callId")
return@post
}
val generation = playbackGeneration.incrementAndGet()
startIncomingCallLocked(appContext, callData, generation)
}
}
}
/**
* Stops the ringtone that [showIncomingCall] started and removes the incoming
* notification. Safe to call more than once.
*/
fun silenceIncomingCall(context: Context) {
val appContext = context.applicationContext
storedAppContext = appContext
Log.i(TAG, "silenceIncomingCall")
playbackGeneration.incrementAndGet()
val stop = Runnable {
synchronized(playbackLock) {
stopPlaybackLocked(appContext)
}
}
if (Looper.myLooper() == Looper.getMainLooper()) {
stop.run()
} else {
mainHandler.post(stop)
}
}
/**
* The plugin's sound player stops on ACTION_SCREEN_OFF. The ringing screen turns
* the display on itself, so keep ringing through that transition.
*/
fun keepRingingOnFullScreen() {
synchronized(playbackLock) {
activeSoundManager?.keepRingingOnFullScreen()
}
}
fun dismissIncomingCalls(
context: Context,
callId: String? = null,
conversationUuid: String? = null,
) {
val normalizedCallId = callId?.trim()?.takeIf { it.isNotEmpty() }
val normalizedConversation = conversationUuid?.trim()?.takeIf { it.isNotEmpty() }
if (normalizedCallId != null) {
markTombstone(normalizedCallId)
}
val appContext = context.applicationContext
val calls = getDataActiveCalls(appContext)
Log.i(
TAG,
"dismissIncomingCalls: active=${calls.size} call_id=${normalizedCallId ?: "-"} " +
"conversation=${normalizedConversation ?: "-"}",
)
playbackGeneration.incrementAndGet()
val dismiss = Runnable {
synchronized(playbackLock) {
val notificationManager = resolveNotificationManager(appContext)
val allBundles = calls.map { it.toBundle() }
val targeted = hasDismissTarget(normalizedCallId, normalizedConversation)
val bundles = if (targeted) {
allBundles.filter { bundleMatches(it, normalizedCallId, normalizedConversation) }
} else {
allBundles
}
val activeMatches = activeCallBundle?.let { bundle ->
!targeted || bundleMatches(bundle, normalizedCallId, normalizedConversation)
} == true
if (activeMatches || !targeted) {
stopPlaybackLocked(appContext)
}
for (bundle in bundles) {
try {
dismissIncomingCallNotification(appContext, notificationManager, bundle)
} catch (error: Exception) {
Log.w(
TAG,
"dismissIncomingCalls: failed for call ${bundle.getString(CallkitConstants.EXTRA_CALLKIT_ID)}",
error,
)
}
}
if (!targeted || bundles.isNotEmpty()) {
try {
CallkitNotificationService.stopService(appContext)
} catch (error: Exception) {
Log.w(TAG, "dismissIncomingCalls: failed to stop callkit service", error)
}
}
if (!targeted) {
try {
removeAllCalls(appContext)
} catch (error: Exception) {
Log.w(TAG, "dismissIncomingCalls: failed to remove persisted calls", error)
}
} else {
for (bundle in bundles) {
try {
removeCall(appContext, Data.fromBundle(bundle))
} catch (error: Exception) {
Log.w(TAG, "dismissIncomingCalls: failed to remove targeted call", error)
}
}
}
if (activeMatches || !targeted) {
stopIncomingRingtone()
}
scheduleMissedCallCleanup(notificationManager, bundles)
}
}
if (Looper.myLooper() == Looper.getMainLooper()) {
dismiss.run()
} else {
mainHandler.post(dismiss)
}
}
fun dismissAllIncomingCalls(context: Context) {
dismissIncomingCalls(context)
}
fun dismissEndedVideoCall(context: Context, data: Map<String, String>) {
dismissIncomingCalls(
context = context,
callId = callId(data),
conversationUuid = conversationUuid(data),
)
}
private fun ensureIncomingCallCallbackRegistered() {
if (callbackRegistered) return
FlutterCallkitIncomingPlugin.registerEventCallback(incomingCallEventCallback)
callbackRegistered = true
}
private fun startIncomingCallLocked(appContext: Context, callData: Bundle, generation: Int) {
stopPlaybackLocked(appContext)
val soundManager = CallkitSoundPlayerManager(appContext) val soundManager = CallkitSoundPlayerManager(appContext)
val notificationManager = CallkitNotificationManager(appContext, soundManager) val notificationManager = CallkitNotificationManager(appContext, soundManager)
activeSoundManager = soundManager activeSoundManager = soundManager
activeNotificationManager = notificationManager activeNotificationManager = notificationManager
activeCallBundle = callData
notificationManager.createNotificationChanel(callData) notificationManager.createNotificationChanel(callData)
notificationManager.showIncomingNotification(callData) notificationManager.showIncomingNotification(callData)
@@ -109,46 +311,78 @@ object VideoCallPushBridge {
} catch (error: Exception) { } catch (error: Exception) {
Log.w(TAG, "showIncomingCall: failed to persist active call", error) Log.w(TAG, "showIncomingCall: failed to persist active call", error)
} }
val durationMs = callData.getLong(CallkitConstants.EXTRA_CALLKIT_DURATION, 45_000L)
wakeScreenLocked(appContext, if (durationMs > 0L) durationMs else 45_000L)
if (durationMs > 0L) {
val timeout = Runnable {
synchronized(playbackLock) {
if (generation != playbackGeneration.get()) return@Runnable
Log.i(TAG, "incoming ringtone timed out")
stopPlaybackLocked(appContext)
}
}
ringtoneTimeout = timeout
mainHandler.postDelayed(timeout, durationMs)
}
} }
fun dismissAllIncomingCalls(context: Context) { /**
val appContext = context.applicationContext * Turns the screen on while the call rings. The full-screen intent of the
val calls = getDataActiveCalls(appContext) * notification does the same only when the system grants it; the wake lock
Log.i(TAG, "dismissAllIncomingCalls: active calls=${calls.size}") * works without any grant, so the lock screen with the call card lights up.
*/
@Suppress("DEPRECATION")
private fun wakeScreenLocked(appContext: Context, durationMs: Long) {
releaseScreenWakeLockLocked()
try {
val powerManager = appContext.getSystemService(Context.POWER_SERVICE) as PowerManager
val lock = powerManager.newWakeLock(
PowerManager.SCREEN_BRIGHT_WAKE_LOCK or
PowerManager.ACQUIRE_CAUSES_WAKEUP or
PowerManager.ON_AFTER_RELEASE,
WAKE_LOCK_TAG,
)
lock.acquire(durationMs)
ringWakeLock = lock
Log.i(TAG, "wakeScreen: screen on for ${durationMs}ms, interactive=${powerManager.isInteractive}")
} catch (error: Exception) {
Log.w(TAG, "wakeScreen: failed to acquire wake lock", error)
}
}
stopIncomingRingtone(appContext) private fun releaseScreenWakeLockLocked() {
val lock = ringWakeLock ?: return
ringWakeLock = null
try {
if (lock.isHeld) lock.release()
} catch (error: Exception) {
Log.w(TAG, "wakeScreen: failed to release wake lock", error)
}
}
val notificationManager = resolveNotificationManager(appContext) private fun stopPlaybackLocked(appContext: Context) {
val bundles = calls.map { it.toBundle() } cancelRingtoneTimeoutLocked()
releaseScreenWakeLockLocked()
for (bundle in bundles) { val notificationManager = activeNotificationManager
val bundle = activeCallBundle
stopIncomingRingtone()
if (notificationManager != null && bundle != null) {
try { try {
dismissIncomingCallNotification(appContext, notificationManager, bundle) dismissIncomingCallNotification(appContext, notificationManager, bundle)
} catch (error: Exception) { } catch (error: Exception) {
Log.w( Log.w(TAG, "stopPlayback: failed to dismiss incoming notification", error)
TAG,
"dismissAllIncomingCalls: failed for call ${bundle.getString(CallkitConstants.EXTRA_CALLKIT_ID)}",
error,
)
} }
} }
try {
CallkitNotificationService.stopService(appContext)
} catch (error: Exception) {
Log.w(TAG, "dismissAllIncomingCalls: failed to stop callkit service", error)
}
try {
removeAllCalls(appContext)
} catch (error: Exception) {
Log.w(TAG, "dismissAllIncomingCalls: failed to remove persisted calls", error)
}
stopIncomingRingtone(appContext)
scheduleMissedCallCleanup(notificationManager, bundles)
activeSoundManager = null activeSoundManager = null
activeNotificationManager = null activeNotificationManager = null
activeCallBundle = null
EatvioCallkitIncomingBridgeActivity.dismissRingingScreen()
}
private fun cancelRingtoneTimeoutLocked() {
ringtoneTimeout?.let { mainHandler.removeCallbacks(it) }
ringtoneTimeout = null
} }
private fun resolveNotificationManager(context: Context): CallkitNotificationManager { private fun resolveNotificationManager(context: Context): CallkitNotificationManager {
@@ -161,11 +395,19 @@ object VideoCallPushBridge {
return CallkitNotificationManager(context, soundManager) return CallkitNotificationManager(context, soundManager)
} }
private fun stopIncomingRingtone(context: Context) { private fun stopIncomingRingtone() {
activeSoundManager?.stop() try {
FlutterCallkitIncomingPlugin.getInstance() activeSoundManager?.stop()
?.getCallkitSoundPlayerManager() } catch (error: Exception) {
?.stop() Log.w(TAG, "stopIncomingRingtone: active player", error)
}
try {
FlutterCallkitIncomingPlugin.getInstance()
?.getCallkitSoundPlayerManager()
?.stop()
} catch (error: Exception) {
Log.w(TAG, "stopIncomingRingtone: plugin player", error)
}
} }
/** /**
@@ -222,6 +464,8 @@ object VideoCallPushBridge {
private fun buildCallDataBundle(data: Map<String, String>): Bundle? { private fun buildCallDataBundle(data: Map<String, String>): Bundle? {
val conversationUuid = conversationUuid(data) ?: return null val conversationUuid = conversationUuid(data) ?: return null
val serverCallId = callId(data)
val callKitId = serverCallId ?: conversationUuid
val callerName = data["counterpartName"] val callerName = data["counterpartName"]
?: data["counterpart_name"] ?: data["counterpart_name"]
?: data["caller_name"] ?: data["caller_name"]
@@ -247,6 +491,9 @@ object VideoCallPushBridge {
val extra = HashMap<String, Any?>() val extra = HashMap<String, Any?>()
extra["conversationUuid"] = conversationUuid extra["conversationUuid"] = conversationUuid
if (!serverCallId.isNullOrBlank()) {
extra["call_id"] = serverCallId
}
extra["counterpartName"] = callerName extra["counterpartName"] = callerName
if (avatarUrl.isNotBlank()) { if (avatarUrl.isNotBlank()) {
extra["counterpartAvatarUrl"] = avatarUrl extra["counterpartAvatarUrl"] = avatarUrl
@@ -257,14 +504,18 @@ object VideoCallPushBridge {
if (counterpartIsConsultant) { if (counterpartIsConsultant) {
extra["counterpartIsConsultant"] = true extra["counterpartIsConsultant"] = true
} }
val messageId = data["message_id"] ?: data["messageId"]
if (!messageId.isNullOrBlank()) {
extra["message_id"] = messageId
}
return Bundle().apply { return Bundle().apply {
putString(CallkitConstants.EXTRA_CALLKIT_ID, conversationUuid) putString(CallkitConstants.EXTRA_CALLKIT_ID, callKitId)
putString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, callerName) putString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, callerName)
putString(CallkitConstants.EXTRA_CALLKIT_APP_NAME, "Итвио") putString(CallkitConstants.EXTRA_CALLKIT_APP_NAME, "Итвио")
putString(CallkitConstants.EXTRA_CALLKIT_HANDLE, conversationUuid) putString(CallkitConstants.EXTRA_CALLKIT_HANDLE, conversationUuid)
putString(CallkitConstants.EXTRA_CALLKIT_AVATAR, avatarUrl) putString(CallkitConstants.EXTRA_CALLKIT_AVATAR, avatarUrl)
putInt(CallkitConstants.EXTRA_CALLKIT_TYPE, 0) putInt(CallkitConstants.EXTRA_CALLKIT_TYPE, 1)
putLong(CallkitConstants.EXTRA_CALLKIT_DURATION, 45000L) putLong(CallkitConstants.EXTRA_CALLKIT_DURATION, 45000L)
putString(CallkitConstants.EXTRA_CALLKIT_TEXT_ACCEPT, "Answer") putString(CallkitConstants.EXTRA_CALLKIT_TEXT_ACCEPT, "Answer")
putString(CallkitConstants.EXTRA_CALLKIT_TEXT_DECLINE, "Decline") putString(CallkitConstants.EXTRA_CALLKIT_TEXT_DECLINE, "Decline")
@@ -284,6 +535,77 @@ object VideoCallPushBridge {
} }
} }
private fun callId(data: Map<String, String>): String? {
return data["call_id"]?.trim()?.takeIf { it.isNotEmpty() }
?: data["callId"]?.trim()?.takeIf { it.isNotEmpty() }
}
private fun hasDismissTarget(callId: String?, conversationUuid: String?): Boolean {
return !callId.isNullOrBlank() || !conversationUuid.isNullOrBlank()
}
private fun bundleMatches(
bundle: Bundle,
callId: String?,
conversationUuid: String?,
): Boolean {
val normalizedCallId = callId?.trim()?.lowercase()
if (!normalizedCallId.isNullOrEmpty()) {
val id = bundle.getString(CallkitConstants.EXTRA_CALLKIT_ID)?.trim()?.lowercase()
if (id != null && id == normalizedCallId) return true
val extraCallId = extraString(bundle, "call_id")
?: extraString(bundle, "callId")
if (extraCallId != null && extraCallId.lowercase() == normalizedCallId) {
return true
}
// A ring that already stores its own call id must not be closed by
// another call in the same chat. Legacy rings have no call id and
// still match the conversation.
if (!extraCallId.isNullOrBlank()) return false
}
val normalizedConversation = conversationUuid?.trim()?.lowercase() ?: return false
val handle = bundle.getString(CallkitConstants.EXTRA_CALLKIT_HANDLE)?.trim()?.lowercase()
if (handle != null && handle == normalizedConversation) return true
val extraConversation = extraString(bundle, "conversationUuid")
?: extraString(bundle, "conversation_uuid")
return extraConversation?.lowercase() == normalizedConversation
}
@Suppress("UNCHECKED_CAST")
private fun extraString(bundle: Bundle, key: String): String? {
val extra = bundle.getSerializable(CallkitConstants.EXTRA_CALLKIT_EXTRA) as? HashMap<String, Any?>
?: return null
val value = extra[key] ?: return null
val text = value.toString().trim()
return text.takeIf { it.isNotEmpty() }
}
private fun markTombstone(callId: String) {
val normalized = callId.trim().lowercase()
if (normalized.isEmpty()) return
pruneTombstones()
tombstonedCallIds[normalized] = System.currentTimeMillis() + TOMBSTONE_TTL_MS
}
private fun isTombstoned(callId: String?): Boolean {
val normalized = callId?.trim()?.lowercase() ?: return false
if (normalized.isEmpty()) return false
pruneTombstones()
return tombstonedCallIds.containsKey(normalized)
}
private fun pruneTombstones() {
val now = System.currentTimeMillis()
val iterator = tombstonedCallIds.entries.iterator()
while (iterator.hasNext()) {
val entry = iterator.next()
if (entry.value <= now) {
iterator.remove()
}
}
}
private fun conversationUuid(data: Map<String, String>): String? { private fun conversationUuid(data: Map<String, String>): String? {
val fromData = data["conversationUuid"] val fromData = data["conversationUuid"]
?: data["conversation_uuid"] ?: data["conversation_uuid"]

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

@@ -0,0 +1,103 @@
package com.eatvioapp.app
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class VideoCallDeclineRequestTest {
private val baseUrl = "https://eatvio.example"
private val token = "sanctum-token"
private val hwId = "install-hw-id-01"
private val conversation = "11111111-1111-1111-1111-111111111111"
private val callId = "22222222-2222-2222-2222-222222222222"
@Test
fun buildsDeclinePostWithCallId() {
val request = request(callId = callId)
assertEquals(
"https://eatvio.example/api/v2/chat/$conversation/video-call/decline",
request?.url,
)
assertEquals(token, request?.token)
assertEquals(
"""{"hw_id":"$hwId","call_id":"$callId"}""",
request?.body,
)
}
@Test
fun omitsCallIdWhenItIsTheConversationUuid() {
val request = request(callId = conversation.uppercase())
assertEquals("""{"hw_id":"$hwId"}""", request?.body)
}
@Test
fun omitsCallIdThatIsNotAUuid() {
val request = request(callId = "message-15")
assertEquals("""{"hw_id":"$hwId"}""", request?.body)
}
@Test
fun readsConversationFromHandleWhenExtraHasNone() {
val fields = VideoCallDeclineRequestFactory.fields(conversation, emptyMap<String, Any?>())
val request = VideoCallDeclineRequestFactory.build(baseUrl, token, hwId, fields)
assertTrue(request?.url?.contains(conversation) == true)
}
@Test
fun skipsEndedPush() {
val fields = VideoCallDeclineRequestFactory.fields(
conversation,
mapOf("conversationUuid" to conversation, "endedPush" to true),
)
assertNull(VideoCallDeclineRequestFactory.build(baseUrl, token, hwId, fields))
}
@Test
fun skipsMissingTokenBaseUrlOrShortHwId() {
val fields = VideoCallDeclineRequestFactory.fields(
null,
mapOf("conversationUuid" to conversation, "call_id" to callId),
)
assertNull(VideoCallDeclineRequestFactory.build(null, token, hwId, fields))
assertNull(VideoCallDeclineRequestFactory.build(baseUrl, " ", hwId, fields))
assertNull(VideoCallDeclineRequestFactory.build(baseUrl, token, "short", fields))
assertNull(VideoCallDeclineRequestFactory.build("eatvio.example", token, hwId, fields))
}
@Test
fun trimsATrailingSlashOnTheBaseUrl() {
val request = VideoCallDeclineRequestFactory.build(
"$baseUrl/",
token,
hwId,
VideoCallDeclineRequestFactory.fields(
null,
mapOf("conversationUuid" to conversation),
),
)
assertEquals(
"https://eatvio.example/api/v2/chat/$conversation/video-call/decline",
request?.url,
)
}
private fun request(callId: String?): VideoCallDeclineRequest? {
val fields = VideoCallDeclineRequestFactory.fields(
null,
mapOf(
"conversationUuid" to conversation,
"call_id" to callId,
),
)
return VideoCallDeclineRequestFactory.build(baseUrl, token, hwId, fields)
}
}

View File

@@ -650,7 +650,7 @@ code + .copy-button {
<script type="text/javascript"> <script type="text/javascript">
function configurationCacheProblems() { return ( function configurationCacheProblems() { return (
// begin-report-data // begin-report-data
{"diagnostics":[{"problem":[{"text":"Properties should be assigned using the 'propName = value' syntax. Setting a property via the Gradle-generated 'propName value' or 'propName(value)' syntax in Groovy DSL has been deprecated."}],"severity":"WARNING","problemDetails":[{"text":"This is scheduled to be removed in Gradle 10."}],"contextualLabel":"Properties should be assigned using the 'propName = value' syntax. Setting a property via the Gradle-generated 'propName value' or 'propName(value)' syntax in Groovy DSL has been deprecated.","documentationLink":"https://docs.gradle.org/9.0.0/userguide/upgrading_version_8.html#groovy_space_assignment_syntax","problemId":[{"name":"deprecation","displayName":"Deprecation"},{"name":"properties-should-be-assigned-using-the-propname-value-syntax-setting-a-property-via-the-gradle-generated-propname-value-or-propname-value-syntax-in-groovy-dsl","displayName":"Properties should be assigned using the 'propName = value' syntax. Setting a property via the Gradle-generated 'propName value' or 'propName(value)' syntax in Groovy DSL has been deprecated."}],"solutions":[[{"text":"Use assignment ('url = <value>') instead."}]]}],"problemsReport":{"totalProblemCount":1,"requestedTasks":":app:compileDebugKotlin","documentationLink":"https://docs.gradle.org/9.0.0/userguide/reporting_problems.html","documentationLinkCaption":"Problem report","summaries":[]}} {"diagnostics":[{"problem":[{"text":"Properties should be assigned using the 'propName = value' syntax. Setting a property via the Gradle-generated 'propName value' or 'propName(value)' syntax in Groovy DSL has been deprecated."}],"severity":"WARNING","problemDetails":[{"text":"This is scheduled to be removed in Gradle 10."}],"contextualLabel":"Properties should be assigned using the 'propName = value' syntax. Setting a property via the Gradle-generated 'propName value' or 'propName(value)' syntax in Groovy DSL has been deprecated.","documentationLink":"https://docs.gradle.org/9.0.0/userguide/upgrading_version_8.html#groovy_space_assignment_syntax","problemId":[{"name":"deprecation","displayName":"Deprecation"},{"name":"properties-should-be-assigned-using-the-propname-value-syntax-setting-a-property-via-the-gradle-generated-propname-value-or-propname-value-syntax-in-groovy-dsl","displayName":"Properties should be assigned using the 'propName = value' syntax. Setting a property via the Gradle-generated 'propName value' or 'propName(value)' syntax in Groovy DSL has been deprecated."}],"solutions":[[{"text":"Use assignment ('url = <value>') instead."}]]}],"problemsReport":{"totalProblemCount":1,"requestedTasks":":app:testDebugUnitTest --tests com.eatvioapp.app.VideoCallDeclineRequestTest","documentationLink":"https://docs.gradle.org/9.0.0/userguide/reporting_problems.html","documentationLinkCaption":"Problem report","summaries":[]}}
// end-report-data // end-report-data
);} );}
</script> </script>

View File

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

View File

@@ -11,6 +11,8 @@
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
46B43B96F809CEC6058EFDD3 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 15ADDB2A35F334AF0AE50011 /* Pods_Runner.framework */; }; 46B43B96F809CEC6058EFDD3 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 15ADDB2A35F334AF0AE50011 /* Pods_Runner.framework */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
E47D0C1B2F0B4D1000EA7A01 /* VideoCallDeclineClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = E47D0C1A2F0B4D1000EA7A01 /* VideoCallDeclineClient.swift */; };
C4A8E2022E9A4B1000123456 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A8E2012E9A4B1000123456 /* SceneDelegate.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
@@ -37,6 +39,8 @@
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
E47D0C1A2F0B4D1000EA7A01 /* VideoCallDeclineClient.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoCallDeclineClient.swift; sourceTree = "<group>"; };
C4A8E2012E9A4B1000123456 /* SceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
938EE8FB5BAA359AAEDEFFF8 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; }; 938EE8FB5BAA359AAEDEFFF8 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
@@ -115,6 +119,8 @@
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
E47D0C1A2F0B4D1000EA7A01 /* VideoCallDeclineClient.swift */,
C4A8E2012E9A4B1000123456 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
FEE92A0E2D772EA300E6E989 /* Runner.entitlements */, FEE92A0E2D772EA300E6E989 /* Runner.entitlements */,
); );
@@ -300,6 +306,8 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
E47D0C1B2F0B4D1000EA7A01 /* VideoCallDeclineClient.swift in Sources */,
C4A8E2022E9A4B1000123456 /* SceneDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;

File diff suppressed because it is too large Load Diff

View File

@@ -41,6 +41,27 @@
<string>Может использоваться для стабильного поддержания связи во время звонка.</string> <string>Может использоваться для стабильного поддержания связи во время звонка.</string>
<key>NSPhotoLibraryUsageDescription</key> <key>NSPhotoLibraryUsageDescription</key>
<string>Предоставление доступа позволит Вам добавлять фото к записям</string> <string>Предоставление доступа позволит Вам добавлять фото к записям</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key> <key>UIApplicationSupportsIndirectInputEvents</key>
<true/> <true/>
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>

View File

@@ -8,6 +8,8 @@
<array> <array>
<string>applinks:eatvio.ru</string> <string>applinks:eatvio.ru</string>
<string>applinks:www.eatvio.ru</string> <string>applinks:www.eatvio.ru</string>
<string>applinks:eatvio.com</string>
<string>applinks:www.eatvio.com</string>
<string>applinks:5399b03636104c6d920b87c1ad329254.oauth.yandex.ru</string> <string>applinks:5399b03636104c6d920b87c1ad329254.oauth.yandex.ru</string>
</array> </array>
</dict> </dict>

View File

@@ -0,0 +1,90 @@
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()
guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else {
return
}
appDelegate.deliverStoredAcceptedCall()
}
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

@@ -0,0 +1,163 @@
import Foundation
import UIKit
enum VideoCallDeclineClient {
private static let uuidPattern =
#"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"#
/// `POST /video-call/decline` from the CallKit callback. The process is
/// awake here even when the user never opens the app afterwards.
static func send(extra: [String: Any], handle: String) {
post(action: "decline", extra: extra, handle: handle)
}
/// `POST /video-call/accept` from the CallKit answer callback. Flutter may
/// still be frozen on the splash snapshot, so the other device must be
/// told from here. A later Dart retry with the same hw_id is idempotent.
static func sendAccept(extra: [String: Any], handle: String) {
post(action: "accept", extra: extra, handle: handle)
}
private static func post(action: String, extra: [String: Any], handle: String) {
let fields = declineFields(extra: extra, handle: handle)
guard let request = makeRequest(
baseUrl: prefsString("apiBaseUrl") ?? prefsString("customBaseUrl"),
token: prefsString("token"),
hwId: UIDevice.current.identifierForVendor?.uuidString,
fields: fields,
action: action
) else {
NSLog(
"[EatvioVoIP] \(action) POST skipped conversation=\(fields.conversationUuid) ended=\(fields.endedPush)"
)
return
}
let task = EatvioBackgroundTask()
task.begin(name: "video-call-\(action)")
let urlTask = URLSession.shared.dataTask(with: request) { _, response, error in
let code = (response as? HTTPURLResponse)?.statusCode ?? -1
NSLog(
"[EatvioVoIP] \(action) POST status=\(code) conversation=\(fields.conversationUuid) error=\(error?.localizedDescription ?? "-")"
)
task.end()
}
urlTask.resume()
}
static func makeRequest(
baseUrl: String?,
token: String?,
hwId: String?,
fields: VideoCallDeclineFields,
action: String = "decline"
) -> URLRequest? {
if fields.endedPush { return nil }
let conversation = fields.conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines)
let authToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let deviceId = hwId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
var root = baseUrl?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
while root.hasSuffix("/") {
root.removeLast()
}
guard !conversation.isEmpty, !authToken.isEmpty else { return nil }
guard deviceId.count >= 8, deviceId.count <= 64 else { return nil }
guard root.hasPrefix("http://") || root.hasPrefix("https://") else { return nil }
guard action == "accept" || action == "decline" else { return nil }
guard let encoded = conversation.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed),
let url = URL(string: "\(root)/api/v2/chat/\(encoded)/video-call/\(action)")
else { return nil }
var body: [String: String] = ["hw_id": deviceId]
if let callId = fields.callId {
body["call_id"] = callId
}
guard let data = try? JSONSerialization.data(withJSONObject: body) else { return nil }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.timeoutInterval = 10
request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
request.httpBody = data
return request
}
private static func declineFields(extra: [String: Any], handle: String) -> VideoCallDeclineFields {
let conversation = firstString(extra, keys: ["conversationUuid", "conversation_uuid"])
?? handle.trimmingCharacters(in: .whitespacesAndNewlines)
let rawCallId = firstString(extra, keys: ["call_id", "callId"])
return VideoCallDeclineFields(
conversationUuid: conversation,
callId: backendCallId(rawCallId, conversation: conversation),
endedPush: marksEndedPush(extra["endedPush"])
)
}
private static func backendCallId(_ callId: String?, conversation: String) -> String? {
let id = callId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard let regex = try? NSRegularExpression(pattern: uuidPattern),
regex.firstMatch(in: id, range: NSRange(id.startIndex..., in: id)) != nil
else { return nil }
if id.compare(conversation.trimmingCharacters(in: .whitespacesAndNewlines), options: .caseInsensitive) == .orderedSame {
return nil
}
return id
}
private static func firstString(_ extra: [String: Any], keys: [String]) -> String? {
for key in keys {
guard let value = extra[key] as? String else { continue }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { return trimmed }
}
return nil
}
private static func marksEndedPush(_ value: Any?) -> Bool {
if let bool = value as? Bool { return bool }
if let number = value as? NSNumber { return number.intValue != 0 }
if let text = value as? String {
let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return normalized == "true" || normalized == "1"
}
return false
}
private static func prefsString(_ key: String) -> String? {
let value = UserDefaults.standard.string(forKey: "flutter.\(key)")?
.trimmingCharacters(in: .whitespacesAndNewlines)
if let value, !value.isEmpty { return value }
return nil
}
}
struct VideoCallDeclineFields {
var conversationUuid: String
var callId: String?
var endedPush: Bool
}
private final class EatvioBackgroundTask {
private let lock = NSLock()
private var id: UIBackgroundTaskIdentifier = .invalid
func begin(name: String) {
let taskId = UIApplication.shared.beginBackgroundTask(withName: name) { [weak self] in
self?.end()
}
lock.lock()
id = taskId
lock.unlock()
}
func end() {
lock.lock()
let current = id
id = .invalid
lock.unlock()
guard current != .invalid else { return }
UIApplication.shared.endBackgroundTask(current)
}
}

View File

@@ -1,8 +1,12 @@
import 'dart:async';
import 'package:eatvio_app/core/app_initializer.dart'; import 'package:eatvio_app/core/app_initializer.dart';
import 'package:eatvio_app/core/cubit/marketing/marketing_cubit.dart'; import 'package:eatvio_app/core/cubit/marketing/marketing_cubit.dart';
import 'package:eatvio_app/core/env_config.dart'; import 'package:eatvio_app/core/env_config.dart';
import 'package:eatvio_app/core/internet_connection_cubit/internet_connection_cubit.dart'; import 'package:eatvio_app/core/internet_connection_cubit/internet_connection_cubit.dart';
import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/prefs.dart';
import 'package:eatvio_app/core/prefs_keys.dart';
import 'package:eatvio_app/core/views/cubit/views_cubit.dart'; import 'package:eatvio_app/core/views/cubit/views_cubit.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/new_messages_cubit/new_messages_cubit.dart'; import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/new_messages_cubit/new_messages_cubit.dart';
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart'; import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
@@ -31,20 +35,51 @@ class AppConfig {
void setBaseUrl(String baseUrls) { void setBaseUrl(String baseUrls) {
_baseUrl = baseUrls; _baseUrl = baseUrls;
_persistBaseUrl(baseUrls);
}
void _persistBaseUrl(String baseUrl) {
if (!sl.isRegistered<Prefs>()) {
return;
}
final normalized = baseUrl.trim();
if (normalized.isEmpty) {
unawaited(sl<Prefs>().remove(PrefsKeys.customBaseUrl));
return;
}
unawaited(sl<Prefs>().setString(PrefsKeys.customBaseUrl, normalized));
unawaited(sl<Prefs>().setString(PrefsKeys.apiBaseUrl, normalized));
} }
/// Список глобальных BlocProvider'ов /// Список глобальных BlocProvider'ов
/// Используются lazy: true для отложенной инициализации, чтобы не блокировать старт приложения /// Используются lazy: true для отложенной инициализации, чтобы не блокировать старт приложения
List<BlocProvider> get globalBlocProviders => [ List<BlocProvider> get globalBlocProviders => [
BlocProvider<VideoCallSessionCubit>.value(value: sl<VideoCallSessionCubit>()), BlocProvider<VideoCallSessionCubit>.value(
BlocProvider<NewMessagesCubit>(lazy: true, create: (context) => sl<NewMessagesCubit>()), value: sl<VideoCallSessionCubit>(),
BlocProvider<NotificationsCubit>(lazy: true, create: (context) => sl<NotificationsCubit>()), ),
BlocProvider<NewMessagesCubit>(
lazy: true,
create: (context) => sl<NewMessagesCubit>(),
),
BlocProvider<NotificationsCubit>(
lazy: true,
create: (context) => sl<NotificationsCubit>(),
),
BlocProvider<FeedCubit>(lazy: true, create: (context) => sl<FeedCubit>()), BlocProvider<FeedCubit>(lazy: true, create: (context) => sl<FeedCubit>()),
BlocProvider<ProfileAvatarCubit>(lazy: true, create: (context) => sl<ProfileAvatarCubit>()), BlocProvider<ProfileAvatarCubit>(
lazy: true,
create: (context) => sl<ProfileAvatarCubit>(),
),
BlocProvider<ChatsCubit>(lazy: true, create: (context) => sl<ChatsCubit>()), BlocProvider<ChatsCubit>(lazy: true, create: (context) => sl<ChatsCubit>()),
BlocProvider<ViewsCubit>(lazy: true, create: (context) => sl<ViewsCubit>()), BlocProvider<ViewsCubit>(lazy: true, create: (context) => sl<ViewsCubit>()),
BlocProvider<InternetConnectionCubit>(lazy: false, create: (context) => sl<InternetConnectionCubit>()), BlocProvider<InternetConnectionCubit>(
BlocProvider<MarketingCubit>(lazy: true, create: (context) => sl<MarketingCubit>()), lazy: false,
create: (context) => sl<InternetConnectionCubit>(),
),
BlocProvider<MarketingCubit>(
lazy: true,
create: (context) => sl<MarketingCubit>(),
),
]; ];
/// Делегаты локализации /// Делегаты локализации
@@ -56,5 +91,7 @@ class AppConfig {
]; ];
/// Поддерживаемые локали /// Поддерживаемые локали
List<Locale> get supportedLocales => [const Locale.fromSubtags(languageCode: 'ru')]; List<Locale> get supportedLocales => [
const Locale.fromSubtags(languageCode: 'ru'),
];
} }

View File

@@ -1,15 +1,11 @@
import 'dart:async'; import 'dart:async';
import 'dart:developer';
import 'dart:io' as io; import 'dart:io' as io;
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/env_config.dart'; import 'package:eatvio_app/core/env_config.dart';
import 'package:eatvio_app/core/error_reporting/error_report_policy.dart'; import 'package:eatvio_app/core/error_reporting/error_report_policy.dart';
import 'package:eatvio_app/core/locator_service.dart' as di; import 'package:eatvio_app/core/locator_service.dart' as di;
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_fcm_handler.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_fcm_handler.dart';
import 'package:eatvio_app/firebase_options.dart'; import 'package:eatvio_app/firebase_options.dart';
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_native_splash/flutter_native_splash.dart'; import 'package:flutter_native_splash/flutter_native_splash.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart';
@@ -18,7 +14,6 @@ import 'package:sentry_flutter/sentry_flutter.dart';
/// Класс для инициализации приложения /// Класс для инициализации приложения
class AppInitializer { class AppInitializer {
/// Инициализация всех сервисов приложения /// Инициализация всех сервисов приложения
static Future<void> initialize({required AppBuildType buildType}) async { static Future<void> initialize({required AppBuildType buildType}) async {
await _initializeEnvironment(); await _initializeEnvironment();
@@ -42,25 +37,21 @@ class AppInitializer {
/// Инициализация Firebase /// Инициализация Firebase
static Future<void> _initializeFirebase() async { static Future<void> _initializeFirebase() async {
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform).timeout( await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
).timeout(
const Duration(seconds: 10), const Duration(seconds: 10),
onTimeout: () { onTimeout: () {
throw TimeoutException('Firebase initialization timeout'); throw TimeoutException('Firebase initialization timeout');
}, },
); );
VideoCallFcmHandler.registerBackgroundHandler(); VideoCallFcmHandler.registerBackgroundHandler();
if (sl<AppConfig>().buildType == AppBuildType.prod) {
try {
await FirebaseMessaging.instance.requestPermission().timeout(const Duration(seconds: 5));
} catch (e) {
// Разрешение не критично, продолжаем работу
log('Failed to request Firebase messaging permission: $e');
}
}
} }
/// Инициализация системы внедрения зависимостей /// Инициализация системы внедрения зависимостей
static Future<void> _initializeDependencyInjection({required AppBuildType buildType}) async { static Future<void> _initializeDependencyInjection({
required AppBuildType buildType,
}) async {
await di.init(buildType: buildType); await di.init(buildType: buildType);
} }

View File

@@ -0,0 +1,30 @@
import 'dart:async';
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/prefs.dart';
import 'package:eatvio_app/core/prefs_keys.dart';
/// Подставляет сохранённый base URL до создания HTTP-клиента.
///
/// Действующий адрес также пишется в [PrefsKeys.apiBaseUrl], чтобы нативный
/// decline мог собрать `POST /video-call/decline`, когда Flutter не запущен.
void applyCachedBaseUrl() {
if (!sl.isRegistered<Prefs>() || !sl.isRegistered<AppConfig>()) {
return;
}
final cached = sl<Prefs>().getString(PrefsKeys.customBaseUrl)?.trim();
if (cached != null && cached.isNotEmpty) {
sl<AppConfig>().setBaseUrl(cached);
return;
}
_persistApiBaseUrl(sl<AppConfig>().baseUrl);
}
void _persistApiBaseUrl(String baseUrl) {
final normalized = baseUrl.trim();
if (normalized.isEmpty || !sl.isRegistered<Prefs>()) {
return;
}
unawaited(sl<Prefs>().setString(PrefsKeys.apiBaseUrl, normalized));
}

View File

@@ -1,16 +1,70 @@
import 'dart:io'; import 'dart:io';
import 'dart:math';
import 'package:device_info_plus/device_info_plus.dart'; import 'package:device_info_plus/device_info_plus.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Stable id of this install, sent as `hw_id` on push registration and on
/// video-call accept/decline.
///
/// Android `Build.ID` is the firmware id. Two phones on the same build share
/// it, so the backend treats them as one device and a decline on one does not
/// reach the other. A per-install id stays unique.
abstract final class DeviceHwIdProvider { abstract final class DeviceHwIdProvider {
static Future<String?> current() async { static const androidInstallIdKey = 'eatvio.android_install_hw_id';
final deviceInfo = DeviceInfoPlugin(); static const androidTokenMigratedKey = 'eatvio.android_hw_id_token_migrated';
static Future<String?> current({SharedPreferences? preferences}) async {
if (Platform.isIOS) { if (Platform.isIOS) {
return (await deviceInfo.iosInfo).identifierForVendor; return (await DeviceInfoPlugin().iosInfo).identifierForVendor;
} }
if (Platform.isAndroid) { if (Platform.isAndroid) {
return (await deviceInfo.androidInfo).id; return androidInstallId(preferences: preferences);
} }
return null; return null;
} }
static Future<String> androidInstallId({
SharedPreferences? preferences,
}) async {
final prefs = preferences ?? await SharedPreferences.getInstance();
final existing = prefs.getString(androidInstallIdKey)?.trim();
if (existing != null && existing.length >= 8) return existing;
final created = _newInstallId();
await prefs.setString(androidInstallIdKey, created);
return created;
}
/// The previous Android registration used `Build.ID`. Drop that token once
/// and register again with the install id.
static Future<bool> androidTokenNeedsReregister({
SharedPreferences? preferences,
}) async {
if (!Platform.isAndroid) return false;
final prefs = preferences ?? await SharedPreferences.getInstance();
return prefs.getBool(androidTokenMigratedKey) != true;
}
static Future<void> markAndroidTokenReregistered({
SharedPreferences? preferences,
}) async {
final prefs = preferences ?? await SharedPreferences.getInstance();
await prefs.setBool(androidTokenMigratedKey, true);
}
static String _newInstallId() {
final random = Random.secure();
final bytes = List<int>.generate(16, (_) => random.nextInt(256));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
final hex = bytes
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join();
return '${hex.substring(0, 8)}-'
'${hex.substring(8, 12)}-'
'${hex.substring(12, 16)}-'
'${hex.substring(16, 20)}-'
'${hex.substring(20)}';
}
} }

View File

@@ -42,7 +42,8 @@ import 'package:eatvio_app/features/order_info/cubit/order_info_cubit.dart';
import 'package:eatvio_app/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_cubit.dart'; import 'package:eatvio_app/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_cubit.dart';
import 'package:eatvio_app/features/personal_diet_suggestion/cubit/personal_diet_suggestion_cubit.dart'; import 'package:eatvio_app/features/personal_diet_suggestion/cubit/personal_diet_suggestion_cubit.dart';
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_cubit.dart'; import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_cubit.dart';
import 'package:eatvio_app/features/profile/presentation/bloc/plans_cubit/plans_cubit.dart' as profile_plans; import 'package:eatvio_app/features/profile/presentation/bloc/plans_cubit/plans_cubit.dart'
as profile_plans;
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart'; import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
import 'package:eatvio_app/features/profile/presentation/bloc/sub_cubit/sub_cubit.dart'; import 'package:eatvio_app/features/profile/presentation/bloc/sub_cubit/sub_cubit.dart';
import 'package:eatvio_app/features/recipe/presentation/cubit/about_recipe_cubit.dart'; import 'package:eatvio_app/features/recipe/presentation/cubit/about_recipe_cubit.dart';
@@ -70,16 +71,26 @@ class CubitRegistrations {
/// Регистрация Cubit'ов как синглтонов /// Регистрация Cubit'ов как синглтонов
static void _registerSingletonCubits() { static void _registerSingletonCubits() {
sl.registerLazySingleton<NewMessagesCubit>(() => NewMessagesCubit()); sl.registerLazySingleton<NewMessagesCubit>(() => NewMessagesCubit());
sl.registerLazySingleton<NotificationsCubit>(() => NotificationsCubit(wsManager: sl(), notificationsRepository: sl())); sl.registerLazySingleton<NotificationsCubit>(
sl.registerLazySingleton<ProfileAvatarCubit>(() => ProfileAvatarCubit(profileRepository: sl())); () => NotificationsCubit(wsManager: sl(), notificationsRepository: sl()),
sl.registerLazySingleton<ChatsCubit>(() => ChatsCubit(chatRepository: sl(), wsManager: sl(), profileRepository: sl())); );
sl.registerLazySingleton<InternetConnectionCubit>(() => InternetConnectionCubit()); sl.registerLazySingleton<ProfileAvatarCubit>(
sl.registerLazySingleton<VideoCallManager>( () => ProfileAvatarCubit(profileRepository: sl()),
() => VideoCallManager( );
videoCallRepository: sl(), sl.registerLazySingleton<ChatsCubit>(
() => ChatsCubit(
chatRepository: sl(),
wsManager: sl(),
profileRepository: sl(), profileRepository: sl(),
), ),
); );
sl.registerLazySingleton<InternetConnectionCubit>(
() => InternetConnectionCubit(),
);
sl.registerLazySingleton<VideoCallManager>(
() =>
VideoCallManager(videoCallRepository: sl(), profileRepository: sl()),
);
sl.registerLazySingleton<VideoCallSessionCubit>( sl.registerLazySingleton<VideoCallSessionCubit>(
() => VideoCallSessionCubit( () => VideoCallSessionCubit(
videoCallManager: sl(), videoCallManager: sl(),
@@ -91,48 +102,130 @@ class CubitRegistrations {
/// Регистрация Cubit'ов как фабрик /// Регистрация Cubit'ов как фабрик
static void _registerFactoryCubits() { static void _registerFactoryCubits() {
// Auth related cubits // Auth related cubits
sl.registerFactory<AuthCubit>(() => AuthCubit(authRepository: sl(), firebaseApi: sl(), prefs: sl(), profileRepository: sl())); sl.registerFactory<AuthCubit>(
sl.registerFactory<RegistrationCubit>(() => RegistrationCubit(registrationRepository: sl(), prefs: sl())); () => AuthCubit(
sl.registerFactory<ResetPasswordCubit>(() => ResetPasswordCubit(resetPasswordRepository: sl())); authRepository: sl(),
sl.registerFactory<VerifyEmailCubit>(() => VerifyEmailCubit(profileRepository: sl(), prefs: sl())); firebaseApi: sl(),
sl.registerFactory<OAuthCubit>(() => OAuthCubit(authRepository: sl(), prefs: sl())); prefs: sl(),
sl.registerFactory<RegistrationCubitV2>(() => RegistrationCubitV2(registrationRepository: sl())); profileRepository: sl(),
),
);
sl.registerFactory<RegistrationCubit>(
() => RegistrationCubit(registrationRepository: sl(), prefs: sl()),
);
sl.registerFactory<ResetPasswordCubit>(
() => ResetPasswordCubit(resetPasswordRepository: sl()),
);
sl.registerFactory<VerifyEmailCubit>(
() => VerifyEmailCubit(profileRepository: sl(), prefs: sl()),
);
sl.registerFactory<OAuthCubit>(
() => OAuthCubit(authRepository: sl(), firebaseApi: sl(), prefs: sl()),
);
sl.registerFactory<RegistrationCubitV2>(
() =>
RegistrationCubitV2(registrationRepository: sl(), firebaseApi: sl()),
);
// Feed related cubits // Feed related cubits
sl.registerFactory<FeedCubit>(() => FeedCubit(feedRepository: sl())); sl.registerFactory<FeedCubit>(() => FeedCubit(feedRepository: sl()));
sl.registerFactory<RecipesCubit>(() => RecipesCubit(feedRepository: sl(), recipeRepository: sl(), aboutRecipeRepository: sl())); sl.registerFactory<RecipesCubit>(
sl.registerFactory<DietsCubit>(() => DietsCubit(feedRepository: sl(), dietsRepository: sl())); () => RecipesCubit(
sl.registerFactory<PlansCubit>(() => PlansCubit(feedRepository: sl(), planRepository: sl())); feedRepository: sl(),
sl.registerFactory<ConsultantsCubit>(() => ConsultantsCubit(feedRepository: sl())); recipeRepository: sl(),
sl.registerFactory<CommentsCubit>(() => CommentsCubit(feedRepository: sl(), commentsRepository: sl())); aboutRecipeRepository: sl(),
sl.registerFactory<FeedProfileCubit>(() => FeedProfileCubit(profileRepository: sl())); ),
);
sl.registerFactory<DietsCubit>(
() => DietsCubit(feedRepository: sl(), dietsRepository: sl()),
);
sl.registerFactory<PlansCubit>(
() => PlansCubit(feedRepository: sl(), planRepository: sl()),
);
sl.registerFactory<ConsultantsCubit>(
() => ConsultantsCubit(feedRepository: sl()),
);
sl.registerFactory<CommentsCubit>(
() => CommentsCubit(feedRepository: sl(), commentsRepository: sl()),
);
sl.registerFactory<FeedProfileCubit>(
() => FeedProfileCubit(profileRepository: sl()),
);
// Chat related cubits // Chat related cubits
sl.registerFactory<ChatRoomCubit>(() => ChatRoomCubit(cardsRepository: sl(), chatRepository: sl(), wsManager: sl())); sl.registerFactory<ChatRoomCubit>(
() => ChatRoomCubit(
cardsRepository: sl(),
chatRepository: sl(),
wsManager: sl(),
),
);
// Profile related cubits // Profile related cubits
sl.registerFactory<ProfileCubit>(() => ProfileCubit(profileRepository: sl(), chatRepository: sl(), consultantRepository: sl())); sl.registerFactory<ProfileCubit>(
sl.registerFactory<profile_plans.PlansCubit>(() => profile_plans.PlansCubit(plansRepository: sl())); () => ProfileCubit(
profileRepository: sl(),
chatRepository: sl(),
consultantRepository: sl(),
),
);
sl.registerFactory<profile_plans.PlansCubit>(
() => profile_plans.PlansCubit(plansRepository: sl()),
);
sl.registerFactory<SubCubit>(() => SubCubit(profileRepository: sl())); sl.registerFactory<SubCubit>(() => SubCubit(profileRepository: sl()));
// Content creation cubits // Content creation cubits
sl.registerFactory<CreateDietCubit>(() => CreateDietCubit(profileRepository: sl(), createDietRepository: sl(), glossaryRepository: sl())); sl.registerFactory<CreateDietCubit>(
sl.registerFactory<CreatePlanCubit>(() => CreatePlanCubit(createPlanRepository: sl())); () => CreateDietCubit(
sl.registerFactory<CreateRecipeCubit>(() => CreateRecipeCubit(recipeRepository: sl(), glossaryRepository: sl())); profileRepository: sl(),
sl.registerFactory<BlogPostCreateCubit>(() => BlogPostCreateCubit(blogPostRepository: sl())); createDietRepository: sl(),
glossaryRepository: sl(),
),
);
sl.registerFactory<CreatePlanCubit>(
() => CreatePlanCubit(createPlanRepository: sl()),
);
sl.registerFactory<CreateRecipeCubit>(
() => CreateRecipeCubit(recipeRepository: sl(), glossaryRepository: sl()),
);
sl.registerFactory<BlogPostCreateCubit>(
() => BlogPostCreateCubit(blogPostRepository: sl()),
);
// Content viewing cubits // Content viewing cubits
sl.registerFactory<AboutDietCubit>(() => AboutDietCubit(dietsRepository: sl(), profileRepository: sl())); sl.registerFactory<AboutDietCubit>(
sl.registerFactory<AboutPlanCubit>(() => AboutPlanCubit(planRepository: sl())); () => AboutDietCubit(dietsRepository: sl(), profileRepository: sl()),
sl.registerFactory<AboutRecipeCubit>(() => AboutRecipeCubit(recipeRepository: sl(), profileRepository: sl())); );
sl.registerFactory<AboutPlanCubit>(
() => AboutPlanCubit(
planRepository: sl(),
profileRepository: sl(),
dietRepository: sl(),
),
);
sl.registerFactory<AboutRecipeCubit>(
() => AboutRecipeCubit(recipeRepository: sl(), profileRepository: sl()),
);
sl.registerFactory<BlogPostCubit>(() => BlogPostCubit(sl())); sl.registerFactory<BlogPostCubit>(() => BlogPostCubit(sl()));
// User content cubits // User content cubits
sl.registerFactory<MyDietsCubit>(() => MyDietsCubit(myDietsRepository: sl(), createDietRepository: sl())); sl.registerFactory<MyDietsCubit>(
sl.registerFactory<MyPlansCubit>(() => MyPlansCubit(myPlansRepository: sl())); () => MyDietsCubit(myDietsRepository: sl(), createDietRepository: sl()),
sl.registerFactory<MyRecipesCubit>(() => MyRecipesCubit(myRecipesRepository: sl(), aboutRecipeRepository: sl(), recipeRepository: sl())); );
sl.registerFactory<MyPlansCubit>(
() => MyPlansCubit(myPlansRepository: sl()),
);
sl.registerFactory<MyRecipesCubit>(
() => MyRecipesCubit(
myRecipesRepository: sl(),
aboutRecipeRepository: sl(),
recipeRepository: sl(),
),
);
// Other feature cubits // Other feature cubits
sl.registerFactory<CalendarCubit>(() => CalendarCubit(calendarRepository: sl())); sl.registerFactory<CalendarCubit>(
() => CalendarCubit(calendarRepository: sl()),
);
sl.registerFactory<DiaryCubit>( sl.registerFactory<DiaryCubit>(
() => DiaryCubit( () => DiaryCubit(
chatRepository: sl(), chatRepository: sl(),
@@ -144,28 +237,73 @@ class CubitRegistrations {
), ),
); );
sl.registerFactory<SettingsCubit>( sl.registerFactory<SettingsCubit>(
() => SettingsCubit(profileRepository: sl(), settingsRepository: sl(), consultantRepository: sl(), consultationsRepository: sl()), () => SettingsCubit(
profileRepository: sl(),
settingsRepository: sl(),
consultantRepository: sl(),
consultationsRepository: sl(),
),
);
sl.registerFactory<RegisterConsultantCubit>(
() => RegisterConsultantCubit(consultantRepository: sl()),
);
sl.registerFactory<ComplainingCubit>(
() => ComplainingCubit(repository: sl()),
);
sl.registerFactory<UserCardsCubit>(
() => UserCardsCubit(cardsRepository: sl()),
);
sl.registerFactory<OrderInfoCubit>(
() => OrderInfoCubit(chatRepository: sl(), profileRepository: sl()),
); );
sl.registerFactory<RegisterConsultantCubit>(() => RegisterConsultantCubit(consultantRepository: sl()));
sl.registerFactory<ComplainingCubit>(() => ComplainingCubit(repository: sl()));
sl.registerFactory<UserCardsCubit>(() => UserCardsCubit(cardsRepository: sl()));
sl.registerFactory<OrderInfoCubit>(() => OrderInfoCubit(chatRepository: sl(), profileRepository: sl()));
sl.registerFactory<ConsultationPaymentCubit>( sl.registerFactory<ConsultationPaymentCubit>(
() => ConsultationPaymentCubit(paymentRepository: sl(), sbpRepository: sl()), () => ConsultationPaymentCubit(
paymentRepository: sl(),
sbpRepository: sl(),
),
);
sl.registerFactory<ConsultationsCubit>(
() => ConsultationsCubit(consultationsRepository: sl()),
);
sl.registerFactory<ConsultantCommentsCubit>(
() => ConsultantCommentsCubit(diaryRepository: sl()),
);
sl.registerFactory<CancelPlanCubit>(
() => CancelPlanCubit(cancelPlanRepository: sl()),
);
sl.registerFactory<ConsultationsArchiveCubit>(
() => ConsultationsArchiveCubit(consultationsRepository: sl()),
);
sl.registerFactory<ConsultantFinancesCubit>(
() => ConsultantFinancesCubit(consultationsRepository: sl()),
);
sl.registerFactory<UserPurchasesCubit>(
() => UserPurchasesCubit(consultationsRepository: sl()),
);
sl.registerFactory<BlockedUsersCubit>(
() => BlockedUsersCubit(blockedUsersRepository: sl()),
);
sl.registerFactory<NotificationSettingsCubit>(
() => NotificationSettingsCubit(notificationSettingsRepository: sl()),
);
sl.registerFactory<ConsultantRatingCubit>(
() => ConsultantRatingCubit(
feedRepository: sl(),
repository: sl(),
profileRepository: sl(),
),
); );
sl.registerFactory<ConsultationsCubit>(() => ConsultationsCubit(consultationsRepository: sl()));
sl.registerFactory<ConsultantCommentsCubit>(() => ConsultantCommentsCubit(diaryRepository: sl()));
sl.registerFactory<CancelPlanCubit>(() => CancelPlanCubit(cancelPlanRepository: sl()));
sl.registerFactory<ConsultationsArchiveCubit>(() => ConsultationsArchiveCubit(consultationsRepository: sl()));
sl.registerFactory<ConsultantFinancesCubit>(() => ConsultantFinancesCubit(consultationsRepository: sl()));
sl.registerFactory<UserPurchasesCubit>(() => UserPurchasesCubit(consultationsRepository: sl()));
sl.registerFactory<BlockedUsersCubit>(() => BlockedUsersCubit(blockedUsersRepository: sl()));
sl.registerFactory<NotificationSettingsCubit>(() => NotificationSettingsCubit(notificationSettingsRepository: sl()));
sl.registerFactory<ConsultantRatingCubit>(() => ConsultantRatingCubit(feedRepository: sl(), repository: sl(), profileRepository: sl()));
sl.registerFactory<ViewsCubit>(() => ViewsCubit(viewsRepository: sl())); sl.registerFactory<ViewsCubit>(() => ViewsCubit(viewsRepository: sl()));
sl.registerFactory<UserDetailsCubit>(() => UserDetailsCubit(profileRepository: sl())); sl.registerFactory<UserDetailsCubit>(
() => UserDetailsCubit(profileRepository: sl()),
);
sl.registerFactory<MenuCubit>(() => MenuCubit(sl())); sl.registerFactory<MenuCubit>(() => MenuCubit(sl()));
sl.registerFactory<MarketingCubit>(() => MarketingCubit(sl())); sl.registerFactory<MarketingCubit>(() => MarketingCubit(sl()));
sl.registerFactory<PersonalDietSuggestionCubit>(() => PersonalDietSuggestionCubit(dietRepository: sl(), personalDietSuggestionService: sl())); sl.registerFactory<PersonalDietSuggestionCubit>(
() => PersonalDietSuggestionCubit(
dietRepository: sl(),
personalDietSuggestionService: sl(),
),
);
} }
} }

View File

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

View File

@@ -3,59 +3,59 @@ import 'dart:developer';
import 'dart:io'; import 'dart:io';
import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/app_initializer.dart'; import 'package:eatvio_app/core/app_initializer.dart';
import 'package:eatvio_app/core/utils/toasts.dart'; import 'package:eatvio_app/core/device/device_hw_id_provider.dart';
import 'package:flutter/services.dart'; import 'package:eatvio_app/core/notifications/notification_permission_requester.dart';
import 'package:eatvio_app/core/push/hms_client_error.dart';
import 'package:device_info_plus/device_info_plus.dart'; import 'package:device_info_plus/device_info_plus.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/token_storage.dart'; import 'package:eatvio_app/core/token_storage.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/services.dart' show MethodChannel, PlatformException; import 'package:flutter/services.dart';
import 'package:eatvio_app/core/error_reporting/error_reporter.dart'; import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
class FirebaseApi { class FirebaseApi {
final ErrorReporter _errorReporter; final ErrorReporter _errorReporter;
final NotificationPermissionRequester _notificationPermissionRequester;
final _firebaseMessaging = FirebaseMessaging.instance; final _firebaseMessaging = FirebaseMessaging.instance;
FirebaseApi({required ErrorReporter errorReporter}) : _errorReporter = errorReporter; FirebaseApi({
required ErrorReporter errorReporter,
NotificationPermissionRequester? notificationPermissionRequester,
}) : _errorReporter = errorReporter,
_notificationPermissionRequester = notificationPermissionRequester ?? NotificationPermissionRequester();
Future<String?> getToken() async { Future<String?> getToken() async {
String? token; final credentials = await _resolvePushCredentials();
return credentials.token;
}
if (await _isHuaweiDevice()) { Future<void> _requestNotificationPermission() async {
token = await _getHuaweiPushTokenNative().timeout( if (sl<AppConfig>().buildType != AppBuildType.prod) return;
const Duration(seconds: 5), try {
onTimeout: () => null, await _notificationPermissionRequester.requestOnce(
); () => _firebaseMessaging.requestPermission(),
} else {
if ((sl<AppConfig>().buildType == AppBuildType.prod)) {
try {
await _firebaseMessaging.requestPermission().timeout(
const Duration(seconds: 5),
);
} catch (e) {
// Разрешение не критично, продолжаем
log('Failed to request Firebase messaging permission: $e');
}
}
token = await _firebaseMessaging.getToken().timeout(
const Duration(seconds: 5),
onTimeout: () => null,
); );
} catch (e) {
log('Failed to request Firebase messaging permission: $e');
} }
return token;
} }
Future<void> registerAnonymousPushToken() async { Future<void> registerAnonymousPushToken() async {
try { try {
final deviceInfo = DeviceInfoPlugin(); await _requestNotificationPermission();
final credentials = await _resolvePushCredentials();
final token = credentials.token;
if (token == null || token.isEmpty) return;
final hwId = await DeviceHwIdProvider.current();
if (hwId == null || hwId.length < 8) return;
await sl<Dio>().post( await sl<Dio>().post(
'/api/anonymous_token', '/api/anonymous_token',
data: { data: {
'platform': Platform.isIOS ? 'ios' : (await _isHuaweiDevice()) ? 'huawei' : 'android', 'platform': credentials.platform,
'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id, 'hw_id': hwId,
'token': await getToken(), 'token': token,
}, },
).timeout( ).timeout(
const Duration(seconds: 10), const Duration(seconds: 10),
@@ -71,31 +71,52 @@ class FirebaseApi {
} }
Future<void> registerPushToken() async { Future<void> registerPushToken() async {
if (await TokenStorage.haveTokenInStorage()) { if (!await TokenStorage.haveTokenInStorage()) return;
try { try {
final deviceInfo = DeviceInfoPlugin(); await _requestNotificationPermission();
final isHuawei = Platform.isAndroid ? await _isHuaweiDevice() : false; final credentials = await _resolvePushCredentials();
await sl<Dio>().post( final token = credentials.token;
'/api/profile/notification/token/register', final hwId = await DeviceHwIdProvider.current();
data: { final reregisterAndroid =
'platform': Platform.isIOS ? 'ios' : (isHuawei ? 'huawei' : 'android'), await DeviceHwIdProvider.androidTokenNeedsReregister();
'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id, if (token != null &&
'token': await getToken(), token.isNotEmpty &&
}, hwId != null &&
).timeout( hwId.length >= 8) {
const Duration(seconds: 10), if (reregisterAndroid) {
onTimeout: () { try {
throw TimeoutException('registerPushToken timeout'); await unregisterPushToken();
}, } catch (_) {}
); }
if (Platform.isIOS) { await sl<Dio>()
await _registerIosVoipPushToken(); .post(
'/api/profile/notification/token/register',
data: {
'platform': credentials.platform,
'hw_id': hwId,
'token': token,
},
)
.timeout(
const Duration(seconds: 10),
onTimeout: () {
throw TimeoutException('registerPushToken timeout');
},
);
if (reregisterAndroid) {
await DeviceHwIdProvider.markAndroidTokenReregistered();
} }
} catch (e, st) {
log(e.toString(), stackTrace: st);
await _errorReporter.report(e, st, origin: 'FirebaseApi');
// Не пробрасываем ошибку дальше, так как это не критичная операция
} }
} catch (e, st) {
log(e.toString(), stackTrace: st);
await _errorReporter.report(e, st, origin: 'FirebaseApi');
// Не пробрасываем ошибку дальше, так как это не критичная операция
}
// Incoming calls on iOS come only through the VoIP token. It does not
// depend on the FCM token, so a missing or failed FCM registration must
// not leave the device without calls.
if (Platform.isIOS) {
await _registerIosVoipPushToken();
} }
} }
@@ -115,13 +136,13 @@ class FirebaseApi {
Future<void> unregisterPushToken() async { Future<void> unregisterPushToken() async {
if (!await TokenStorage.haveTokenInStorage()) return; if (!await TokenStorage.haveTokenInStorage()) return;
try { try {
final isHuawei = Platform.isAndroid ? await _isHuaweiDevice() : false; final credentials = await _resolvePushCredentials();
final platform = Platform.isIOS ? 'ios' : (isHuawei ? 'huawei' : 'android'); final token = credentials.token ?? '';
final token = (await getToken()) ?? ''; if (token.isEmpty) return;
await sl<Dio>().post( await sl<Dio>().post(
'/api/profile/notification/token/unregister', '/api/profile/notification/token/unregister',
data: { data: {
'platform': platform, 'platform': credentials.platform,
'token': token, 'token': token,
}, },
).timeout( ).timeout(
@@ -141,12 +162,13 @@ class FirebaseApi {
final token = (await FlutterCallkitIncoming.getDevicePushTokenVoIP())?.trim(); final token = (await FlutterCallkitIncoming.getDevicePushTokenVoIP())?.trim();
if (token == null || token.isEmpty) return; if (token == null || token.isEmpty) return;
final deviceInfo = DeviceInfoPlugin(); final hwId = await DeviceHwIdProvider.current();
if (hwId == null || hwId.length < 8) return;
await sl<Dio>().post( await sl<Dio>().post(
'/api/profile/notification/token/voip/register', '/api/profile/notification/token/voip/register',
data: { data: {
'platform': 'ios', 'platform': 'ios',
'hw_id': (await deviceInfo.iosInfo).identifierForVendor, 'hw_id': hwId,
'token': token, 'token': token,
}, },
).timeout( ).timeout(
@@ -181,30 +203,74 @@ class FirebaseApi {
} }
} }
Future<({String? token, String platform})> _resolvePushCredentials() async {
if (Platform.isIOS) {
return (
token: await _getFcmToken(),
platform: pushPlatform(isIos: true, huaweiFamily: false, hmsAvailable: false, hasHmsToken: false),
);
}
final huaweiFamily = await _isHuaweiDevice();
if (huaweiFamily && await _isHmsAvailable()) {
final token = await _getHuaweiPushTokenNative().timeout(
const Duration(seconds: 5),
onTimeout: () => null,
);
if (token != null && token.isNotEmpty) {
return (
token: token,
platform: pushPlatform(isIos: false, huaweiFamily: true, hmsAvailable: true, hasHmsToken: true),
);
}
}
return (
token: await _getFcmToken(),
platform: pushPlatform(isIos: false, huaweiFamily: huaweiFamily, hmsAvailable: false, hasHmsToken: false),
);
}
Future<String?> _getFcmToken() {
return _firebaseMessaging.getToken().timeout(
const Duration(seconds: 5),
onTimeout: () => null,
);
}
Future<bool> _isHuaweiDevice() async { Future<bool> _isHuaweiDevice() async {
if (!Platform.isAndroid) return false; if (!Platform.isAndroid) return false;
try { try {
final info = await DeviceInfoPlugin().androidInfo; final info = await DeviceInfoPlugin().androidInfo;
final brand = info.brand.toLowerCase(); return isHuaweiFamilyBrand(brand: info.brand, manufacturer: info.manufacturer);
final manufacturer = info.manufacturer.toLowerCase();
return brand.contains('huawei') || brand.contains('honor') || manufacturer.contains('huawei') || manufacturer.contains('honor');
} catch (_) { } catch (_) {
return false; return false;
} }
} }
Future<bool> _isHmsAvailable() async {
try {
const channel = MethodChannel('com.eatvioapp/hms_push');
final available = await channel.invokeMethod<bool>('isHmsAvailable').timeout(const Duration(seconds: 5));
return available ?? false;
} catch (e, st) {
log('HMS availability check failed: $e', stackTrace: st);
return false;
}
}
Future<String?> _getHuaweiPushTokenNative() async { Future<String?> _getHuaweiPushTokenNative() async {
try { try {
const channel = MethodChannel('com.eatvioapp/hms_push'); const channel = MethodChannel('com.eatvioapp/hms_push');
final String? token = await channel.invokeMethod<String>('getHuaweiToken'); final String? token = await channel.invokeMethod<String>('getHuaweiToken');
return token; return token;
} on PlatformException catch (e, st) { } on PlatformException catch (e, st) {
showErrorToast('$e $st');
log(e.toString(), stackTrace: st); log(e.toString(), stackTrace: st);
await _errorReporter.report(e, st, origin: 'FirebaseApi'); if (!isHmsClientUnavailable(e)) {
await _errorReporter.report(e, st, origin: 'FirebaseApi');
}
return null; return null;
} catch (e, st) { } catch (e, st) {
showErrorToast('$e $st');
log(e.toString(), stackTrace: st); log(e.toString(), stackTrace: st);
await _errorReporter.report(e, st, origin: 'FirebaseApi'); await _errorReporter.report(e, st, origin: 'FirebaseApi');
return null; return null;

View File

@@ -1,5 +1,6 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/core/interceptors/verification_requirement.dart';
import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/routing/route_constants.dart'; import 'package:eatvio_app/core/routing/route_constants.dart';
import 'package:eatvio_app/core/widgets/show_error_dialog.dart'; import 'package:eatvio_app/core/widgets/show_error_dialog.dart';
@@ -9,28 +10,77 @@ import 'package:go_router/go_router.dart';
class PhoneVerifyErrorInterceptor extends Interceptor { class PhoneVerifyErrorInterceptor extends Interceptor {
@override @override
void onError(DioException err, ErrorInterceptorHandler handler) async { void onError(DioException err, ErrorInterceptorHandler handler) async {
if (err.response?.statusCode == 405) { if (err.response?.statusCode != 405) {
final data = err.response?.data; return handler.next(err);
}
if (data is Map<String, dynamic> && data['type'] == 'unverificated_phone') {
final String phone = await sl<ProfileRepository>().getMyPhone(); final requirement = VerificationRequirement.tryParse(err.response?.data);
final String passKey = await sl<ProfileRepository>().getPassKey(routeName: 'user_detailz.change-phone-verify', routeParams: {"phone": phone}); if (requirement == null) {
await showErrorDialog( return handler.next(err);
AppRouter.navigatorKey.currentContext!, }
title: 'Привяжите номер телефона',
description: 'Для того, что бы выполнить это действие необходимо привязать номер телефона', // Бэкенд может требовать подтверждения почты, телефона или обоих каналов сразу.
); if (requirement.needsEmail) {
final currentContext = AppRouter.navigatorKey.currentContext; await _handleEmailVerificationRequired();
} else {
if (currentContext != null && currentContext.mounted && GoRouter.of(currentContext).state.path == RouteConstants.stepperBecomeConsultantScreen) { await _handlePhoneVerificationRequired();
GoRouter.of(currentContext).pop(); }
}
AppRouter.navigatorKey.currentContext?.pushNamed('verifyScreen', extra: passKey); return handler.reject(
return handler.reject( DioException(
DioException(requestOptions: err.requestOptions, response: err.response, error: 'Phone number not verified', type: DioExceptionType.badResponse), requestOptions: err.requestOptions,
); response: err.response,
} error: 'Phone number not verified',
type: DioExceptionType.badResponse,
),
);
}
/// Старый сценарий: показывает привязку телефона и открывает passkey-экран.
Future<void> _handlePhoneVerificationRequired() async {
final String phone = await sl<ProfileRepository>().getMyPhone();
final String passKey = await sl<ProfileRepository>().getPassKey(
routeName: 'user_detailz.change-phone-verify',
routeParams: {"phone": phone},
);
await showErrorDialog(
AppRouter.navigatorKey.currentContext!,
title: 'Привяжите номер телефона',
description:
'Для того, что бы выполнить это действие необходимо привязать номер телефона',
);
final currentContext = AppRouter.navigatorKey.currentContext;
if (currentContext != null &&
currentContext.mounted &&
GoRouter.of(currentContext).state.path ==
RouteConstants.stepperBecomeConsultantScreen) {
GoRouter.of(currentContext).pop();
}
AppRouter.navigatorKey.currentContext?.pushNamed(
'verifyScreen',
extra: passKey,
);
}
/// Новый сценарий: предлагает подтвердить e-mail и открывает экран ввода кода.
Future<void> _handleEmailVerificationRequired() async {
final context = AppRouter.navigatorKey.currentContext;
if (context == null || !context.mounted) {
// UI недоступен (например, в тестах) — просто отклоняем запрос, без падения.
return;
}
await showErrorDialog(
context,
title: 'Подтвердите e-mail',
description:
'Чтобы публиковать материалы, подтвердите адрес электронной почты. Мы отправим код на вашу почту.',
);
if (context.mounted) {
context.pushNamed('verifyEmailScreen', extra: true);
} }
return handler.next(err);
} }
} }

View File

@@ -0,0 +1,61 @@
/// Канал верификации, которого не хватает для публичного действия.
enum VerificationChannel { phone, email }
/// Разбор 405-контракта верификации (type/reason/missing) в список каналов.
///
/// Бэкенд отвечает на публичное действие без подтверждения так:
/// `{"type": "unverificated_phone", "reason": "verification_required", "missing": ["email"]}`.
/// Старые ответы не содержали поля `missing` — тогда не хватало только телефона.
class VerificationRequirement {
const VerificationRequirement({required this.missing});
/// Каналы, подтверждение которых требует бэкенд.
final List<VerificationChannel> missing;
bool get needsEmail => missing.contains(VerificationChannel.email);
bool get needsPhone => missing.contains(VerificationChannel.phone);
/// Возвращает null, если ответ не является отказом верификации.
static VerificationRequirement? tryParse(Object? data) {
if (data is! Map) {
return null;
}
if (data['type'] != 'unverificated_phone') {
return null;
}
final Object? rawMissing = data['missing'];
if (rawMissing is! List) {
// Legacy-контракт: поля missing не было, значит не подтверждён телефон.
return const VerificationRequirement(
missing: [VerificationChannel.phone],
);
}
var hasPhone = false;
var hasEmail = false;
for (final Object? value in rawMissing) {
// Неизвестные значения игнорируем, чтобы новые каналы бэкенда не ломали разбор.
if (value == 'phone') {
hasPhone = true;
} else if (value == 'email') {
hasEmail = true;
}
}
// Пустой или нераспознанный missing трактуем как legacy-контракт «телефон».
if (!hasPhone && !hasEmail) {
return const VerificationRequirement(
missing: [VerificationChannel.phone],
);
}
return VerificationRequirement(
missing: [
if (hasPhone) VerificationChannel.phone,
if (hasEmail) VerificationChannel.email,
],
);
}
}

View File

@@ -1,4 +1,5 @@
import 'package:eatvio_app/core/app_initializer.dart'; import 'package:eatvio_app/core/app_initializer.dart';
import 'package:eatvio_app/core/cached_base_url.dart';
import 'package:eatvio_app/core/di/app_config_registration.dart'; import 'package:eatvio_app/core/di/app_config_registration.dart';
import 'package:eatvio_app/core/di/cubit_registrations.dart'; import 'package:eatvio_app/core/di/cubit_registrations.dart';
import 'package:eatvio_app/core/di/external_registrations.dart'; import 'package:eatvio_app/core/di/external_registrations.dart';
@@ -21,6 +22,8 @@ Future<void> init({required AppBuildType buildType}) async {
// Ожидание инициализации асинхронных зависимостей // Ожидание инициализации асинхронных зависимостей
await sl.getAsync<Prefs>(); await sl.getAsync<Prefs>();
// До создания Dio и любых запросов к бэкенду.
applyCachedBaseUrl();
// Регистрация сервисов // Регистрация сервисов
ServiceRegistrations.registerAll(); ServiceRegistrations.registerAll();
@@ -32,5 +35,7 @@ Future<void> init({required AppBuildType buildType}) async {
CubitRegistrations.registerAll(); CubitRegistrations.registerAll();
// Регистрация WebSocket менеджера // Регистрация WebSocket менеджера
sl.registerLazySingleton<WsManager>(() => WsManager(wsService: sl(), errorReporter: sl())); sl.registerLazySingleton<WsManager>(
() => WsManager(wsService: sl(), errorReporter: sl()),
);
} }

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

@@ -62,10 +62,18 @@ class PushNavigationHandler {
final endedPayload = VideoCallEndedPushPayload.tryParse(data); final endedPayload = VideoCallEndedPushPayload.tryParse(data);
if (endedPayload != null) { if (endedPayload != null) {
developer.log( developer.log(
'PushNavigationHandler: dismiss incoming call UI, source=$source', 'PushNavigationHandler: dismiss incoming call UI, source=$source '
'call_id=${endedPayload.callId ?? '-'} '
'reason=${endedPayload.reason?.name ?? '-'}',
name: 'PushNavigationHandler', name: 'PushNavigationHandler',
); );
unawaited(VideoCallEndedPushHandler.dismissIncomingCallUi()); unawaited(
VideoCallEndedPushHandler.dismissIncomingCallUi(
conversationUuid: endedPayload.conversationUuid,
callId: endedPayload.callId,
reason: endedPayload.reason,
),
);
return; return;
} }

View File

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

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

@@ -34,7 +34,7 @@ class AuthRoutes {
name: 'verifyScreen', name: 'verifyScreen',
builder: (context, state) => VerifyScreen(passKey: state.extra as String), builder: (context, state) => VerifyScreen(passKey: state.extra as String),
), ),
GoRoute(path: RouteConstants.verifyEmailScreen, name: 'verifyEmailScreen', builder: (context, state) => const VerifyEmailScreen()), GoRoute(path: RouteConstants.verifyEmailScreen, name: 'verifyEmailScreen', builder: (context, state) => VerifyEmailScreen(returnOnSuccess: state.extra == true)),
GoRoute( GoRoute(
path: RouteConstants.registerViaSocialScreen, path: RouteConstants.registerViaSocialScreen,
name: 'registerViaSocialScreen', name: 'registerViaSocialScreen',

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_plan/presentation/create_plan_screen.dart';
import 'package:eatvio_app/features/create_recipe/presentation/pages/recipe_create_screen.dart'; import 'package:eatvio_app/features/create_recipe/presentation/pages/recipe_create_screen.dart';
import 'package:eatvio_app/features/dev_tools/presentation/dev_tools_screen.dart'; import 'package:eatvio_app/features/dev_tools/presentation/dev_tools_screen.dart';
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
import 'package:eatvio_app/features/feed/models/recipes_model.dart'; import 'package:eatvio_app/features/feed/models/recipes_model.dart';
import 'package:eatvio_app/features/glossary/presentation/pages/glossary_screen.dart'; import 'package:eatvio_app/features/glossary/presentation/pages/glossary_screen.dart';
import 'package:eatvio_app/features/menu/presentation/pages/blocked_users_screen.dart'; import 'package:eatvio_app/features/menu/presentation/pages/blocked_users_screen.dart';
@@ -183,7 +184,14 @@ class OtherRoutes {
return DietPreviewScreen(diet: diet, isImageInBase64: isImageInBase64); return DietPreviewScreen(diet: diet, isImageInBase64: isImageInBase64);
}, },
), ),
GoRoute(path: RouteConstants.createPlanScreen, name: 'createPlanScreen', builder: (context, state) => const CreatePlanScreen()), GoRoute(
path: RouteConstants.createPlanScreen,
name: 'createPlanScreen',
builder: (context, state) {
final plan = state.extra is PlanModel ? state.extra as PlanModel : null;
return CreatePlanScreen(plan: plan);
},
),
GoRoute(path: MyDietsScreen.path, name: MyDietsScreen.name, builder: (context, state) => const MyDietsScreen()), GoRoute(path: MyDietsScreen.path, name: MyDietsScreen.name, builder: (context, state) => const MyDietsScreen()),
GoRoute(path: MyPlansScreen.path, name: MyPlansScreen.name, builder: (context, state) => const MyPlansScreen()), GoRoute(path: MyPlansScreen.path, name: MyPlansScreen.name, builder: (context, state) => const MyPlansScreen()),
GoRoute(path: MyRecipesScreen.path, name: MyRecipesScreen.name, builder: (context, state) => const MyRecipesScreen()), GoRoute(path: MyRecipesScreen.path, name: MyRecipesScreen.name, builder: (context, state) => const MyRecipesScreen()),

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

@@ -18,7 +18,7 @@ class WsManager {
final ErrorReporter _errorReporter; final ErrorReporter _errorReporter;
WsManager({required this.wsService, required ErrorReporter errorReporter}) WsManager({required this.wsService, required ErrorReporter errorReporter})
: _errorReporter = errorReporter; : _errorReporter = errorReporter;
PusherChannelsClient? client; PusherChannelsClient? client;
int? userId; int? userId;
@@ -38,15 +38,53 @@ class WsManager {
StreamSubscription? _eventSubscription; StreamSubscription? _eventSubscription;
StreamSubscription? _connectionEstablishedSubscription; StreamSubscription? _connectionEstablishedSubscription;
Stream<ChannelReadEvent>? get newMessagesStream => _privateMessagesChannel?.bind('App\\Events\\ChatMessageUpdated'); /// Широковещательный сигнал о восстановлении соединения после обрыва (S5).
///
/// Контроллер создаётся один раз и не закрывается в [clear]: подписчики
/// (чаты, окно чата) переживают логаут/повторный вход и продолжают получать
/// сигналы. Если контроллер всё же закрыт, он пересоздаётся в [initialize].
StreamController<void> _reconnectController =
StreamController<void>.broadcast();
Stream<ChannelReadEvent>? get newNotificationsStream => _privateNotificationChannel?.bind('Illuminate\\Notifications\\Events\\BroadcastNotificationCreated'); Stream<ChannelReadEvent>? _directChatStream;
Stream<ChannelReadEvent>? _clientTypingStream;
Stream<ChannelReadEvent>? get directChatStream => _privateDirectChatChannel?.bind('App\\Events\\ChatMessageUpdated'); Stream<ChannelReadEvent>? get newMessagesStream =>
_privateMessagesChannel?.bind('App\\Events\\ChatMessageUpdated');
Stream<ChannelReadEvent>? get clientTypingStream => _privateDirectChatChannel?.bind('client-typing'); /// The callee rejected the ring. Broadcast to the caller and the callee.
Stream<ChannelReadEvent>? get videoCallDeclinedStream =>
_privateMessagesChannel?.bind('video-call-declined');
/// The call was cancelled or hung up. Same channel the web client uses.
Stream<ChannelReadEvent>? get videoCallEndedStream =>
_privateMessagesChannel?.bind('video-call-ended');
/// Another device accepted the ring. Ringing on this device must stop,
/// except on the device that accepted.
Stream<ChannelReadEvent>? get videoCallAcceptedStream =>
_privateMessagesChannel?.bind('video-call-accepted');
Stream<ChannelReadEvent>? get newNotificationsStream =>
_privateNotificationChannel?.bind(
'Illuminate\\Notifications\\Events\\BroadcastNotificationCreated',
);
Stream<ChannelReadEvent>? get directChatStream => _directChatStream;
Stream<ChannelReadEvent>? get clientTypingStream => _clientTypingStream;
/// Сигнал о реконнекте сокета: эмитится после повторной подписки на каналы.
///
/// Подписчики используют его, чтобы догрузить пропущенные за время обрыва
/// сообщения (S5).
Stream<void> get reconnected => _reconnectController.stream;
Future<void> initialize() async { Future<void> initialize() async {
if (_reconnectController.isClosed) {
_reconnectController = StreamController<void>.broadcast();
}
if (client == null) { if (client == null) {
try { try {
if (!await TokenStorage.haveTokenInStorage()) return; if (!await TokenStorage.haveTokenInStorage()) return;
@@ -66,15 +104,9 @@ class WsManager {
connectionErrorHandler: (exception, trace, refresh) { connectionErrorHandler: (exception, trace, refresh) {
refresh(); refresh();
}, },
minimumReconnectDelayDuration: const Duration( minimumReconnectDelayDuration: const Duration(seconds: 1),
seconds: 1, defaultActivityDuration: const Duration(seconds: 30),
), waitForPongDuration: const Duration(seconds: 30),
defaultActivityDuration: const Duration(
seconds: 30,
),
waitForPongDuration: const Duration(
seconds: 30,
),
); );
String? extractSocketId(String? data) { String? extractSocketId(String? data) {
@@ -87,7 +119,9 @@ class WsManager {
} }
} }
_connectionEstablishedSubscription = client?.eventStream.listen((event) async { _connectionEstablishedSubscription = client?.eventStream.listen((
event,
) async {
if (event.name == 'pusher:connection_established') { if (event.name == 'pusher:connection_established') {
final data = event.data; final data = event.data;
final socketId = extractSocketId(data); final socketId = extractSocketId(data);
@@ -104,9 +138,9 @@ class WsManager {
await _initPrivateMessagesChannel(); await _initPrivateMessagesChannel();
_lifecycleSubscription = client?.lifecycleStream.listen((data) { _lifecycleSubscription = client?.lifecycleStream.listen((data) {
if (data == PusherChannelsClientLifeCycleState.establishedConnection) { if (data ==
_unsubscribe(); PusherChannelsClientLifeCycleState.establishedConnection) {
_subscribe(); handleConnectionEstablished();
} }
}); });
_eventSubscription = client?.eventStream.listen((event) { _eventSubscription = client?.eventStream.listen((event) {
@@ -147,12 +181,13 @@ class WsManager {
final token = await TokenStorage.getTokenFromStorage(); final token = await TokenStorage.getTokenFromStorage();
_privateNotificationChannel = client?.privateChannel( _privateNotificationChannel = client?.privateChannel(
'$newNotificationsChannelName.$userId', '$newNotificationsChannelName.$userId',
authorizationDelegate: EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel( authorizationDelegate:
authorizationEndpoint: Uri.parse('${sl<AppConfig>().baseUrl}/broadcasting/auth'), EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel(
headers: { authorizationEndpoint: Uri.parse(
'Authorization': 'Bearer $token', '${sl<AppConfig>().baseUrl}/broadcasting/auth',
}, ),
), headers: {'Authorization': 'Bearer $token'},
),
); );
} }
@@ -160,38 +195,46 @@ class WsManager {
final token = await TokenStorage.getTokenFromStorage(); final token = await TokenStorage.getTokenFromStorage();
_privateMessagesChannel = client?.privateChannel( _privateMessagesChannel = client?.privateChannel(
'$newMessagesChannelName.$userId', '$newMessagesChannelName.$userId',
authorizationDelegate: EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel( authorizationDelegate:
authorizationEndpoint: Uri.parse('${sl<AppConfig>().baseUrl}/broadcasting/auth'), EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel(
headers: { authorizationEndpoint: Uri.parse(
'Authorization': 'Bearer $token', '${sl<AppConfig>().baseUrl}/broadcasting/auth',
}, ),
), headers: {'Authorization': 'Bearer $token'},
),
); );
} }
Future<void> initPrivateDirectChatChannel(String chatId) async { Future<void> initPrivateDirectChatChannel(String chatId) async {
_privateDirectChatChannel?.unsubscribe();
final token = await TokenStorage.getTokenFromStorage(); final token = await TokenStorage.getTokenFromStorage();
_privateDirectChatChannel = client?.privateChannel( _privateDirectChatChannel = client?.privateChannel(
'$directChatChannelName.$chatId', '$directChatChannelName.$chatId',
authorizationDelegate: EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel( authorizationDelegate:
authorizationEndpoint: Uri.parse('${sl<AppConfig>().baseUrl}/broadcasting/auth'), EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel(
headers: { authorizationEndpoint: Uri.parse(
'Authorization': 'Bearer $token', '${sl<AppConfig>().baseUrl}/broadcasting/auth',
}, ),
), headers: {'Authorization': 'Bearer $token'},
),
); );
_directChatStream = _privateDirectChatChannel?.bind(
'App\\Events\\ChatMessageUpdated',
);
_clientTypingStream = _privateDirectChatChannel?.bind('client-typing');
} }
Future<void> initPresenceDirectChatChannel(String chatId) async { Future<void> initPresenceDirectChatChannel(String chatId) async {
final token = await TokenStorage.getTokenFromStorage(); final token = await TokenStorage.getTokenFromStorage();
_presenceDirectChatChannel = client?.presenceChannel( _presenceDirectChatChannel = client?.presenceChannel(
'$presenceDirectChatChannelName.$chatId', '$presenceDirectChatChannelName.$chatId',
authorizationDelegate: EndpointAuthorizableChannelTokenAuthorizationDelegate.forPresenceChannel( authorizationDelegate:
authorizationEndpoint: Uri.parse('${sl<AppConfig>().baseUrl}/broadcasting/auth'), EndpointAuthorizableChannelTokenAuthorizationDelegate.forPresenceChannel(
headers: { authorizationEndpoint: Uri.parse(
'Authorization': 'Bearer $token', '${sl<AppConfig>().baseUrl}/broadcasting/auth',
}, ),
), headers: {'Authorization': 'Bearer $token'},
),
); );
} }
@@ -218,7 +261,8 @@ class WsManager {
void Function(bool isOnline) onCounterpartOnlineChanged, void Function(bool isOnline) onCounterpartOnlineChanged,
) { ) {
_presenceDirectChatListeners.removeWhere( _presenceDirectChatListeners.removeWhere(
(listener) => listener.onCounterpartOnlineChanged == onCounterpartOnlineChanged, (listener) =>
listener.onCounterpartOnlineChanged == onCounterpartOnlineChanged,
); );
if (_presenceDirectChatListeners.isEmpty) { if (_presenceDirectChatListeners.isEmpty) {
unsubscribePresenceDirectChatChannel(); unsubscribePresenceDirectChatChannel();
@@ -232,21 +276,26 @@ class WsManager {
_presenceChannelEventsBound = true; _presenceChannelEventsBound = true;
_presenceSubscriptionSucceededSubscription = _presenceSubscriptionSucceededSubscription = channel
channel.whenSubscriptionSucceeded().listen((_) { .whenSubscriptionSucceeded()
final counterpartIds = _presenceDirectChatListeners .listen((_) {
.map((listener) => listener.counterpartUserId) final counterpartIds = _presenceDirectChatListeners
.toSet(); .map((listener) => listener.counterpartUserId)
for (final counterpartUserId in counterpartIds) { .toSet();
_emitCounterpartPresence(counterpartUserId); for (final counterpartUserId in counterpartIds) {
} _emitCounterpartPresence(counterpartUserId);
}); }
_presenceMemberAddedSubscription = channel.whenMemberAdded().listen((event) { });
_presenceMemberAddedSubscription = channel.whenMemberAdded().listen((
event,
) {
final memberUserId = _parsePresenceMemberUserId(event.data); final memberUserId = _parsePresenceMemberUserId(event.data);
if (memberUserId == null) return; if (memberUserId == null) return;
_notifyPresenceListeners(memberUserId, isOnline: true); _notifyPresenceListeners(memberUserId, isOnline: true);
}); });
_presenceMemberRemovedSubscription = channel.whenMemberRemoved().listen((event) { _presenceMemberRemovedSubscription = channel.whenMemberRemoved().listen((
event,
) {
final memberUserId = _parsePresenceMemberUserId(event.data); final memberUserId = _parsePresenceMemberUserId(event.data);
if (memberUserId == null) return; if (memberUserId == null) return;
_notifyPresenceListeners(memberUserId, isOnline: false); _notifyPresenceListeners(memberUserId, isOnline: false);
@@ -258,11 +307,15 @@ class WsManager {
if (channel == null) return; if (channel == null) return;
final members = channel.state?.members; final members = channel.state?.members;
if (members == null) return; if (members == null) return;
final isOnline = members.getMemberInfo(counterpartUserId.toString()) != null; final isOnline =
members.getMemberInfo(counterpartUserId.toString()) != null;
_notifyPresenceListeners(counterpartUserId, isOnline: isOnline); _notifyPresenceListeners(counterpartUserId, isOnline: isOnline);
} }
void _notifyPresenceListeners(int counterpartUserId, {required bool isOnline}) { void _notifyPresenceListeners(
int counterpartUserId, {
required bool isOnline,
}) {
for (final listener in _presenceDirectChatListeners) { for (final listener in _presenceDirectChatListeners) {
if (listener.counterpartUserId == counterpartUserId) { if (listener.counterpartUserId == counterpartUserId) {
listener.onCounterpartOnlineChanged(isOnline); listener.onCounterpartOnlineChanged(isOnline);
@@ -293,12 +346,27 @@ class WsManager {
_privateNotificationChannel?.unsubscribe(); _privateNotificationChannel?.unsubscribe();
} }
/// Повторно подписывается на каналы и сигналит подписчикам о реконнекте.
///
/// Вызывается при `establishedConnection` — в том числе после обрыва сети.
/// Публичный, чтобы сигнал [reconnected] покрывался тестами без реального
/// Pusher-клиента.
void handleConnectionEstablished() {
_unsubscribe();
_subscribe();
_privateDirectChatChannel?.subscribe();
_presenceDirectChatChannel?.subscribe();
_reconnectController.add(null);
}
void subscribeDirectChatChannel() { void subscribeDirectChatChannel() {
_privateDirectChatChannel?.subscribe(); _privateDirectChatChannel?.subscribe();
} }
void unsubscribeDirectChatChannel() { void unsubscribeDirectChatChannel() {
_privateDirectChatChannel?.unsubscribe(); _privateDirectChatChannel?.unsubscribe();
_directChatStream = null;
_clientTypingStream = null;
} }
void unsubscribePresenceDirectChatChannel() { void unsubscribePresenceDirectChatChannel() {
@@ -331,6 +399,8 @@ class WsManager {
_privateDirectChatChannel = null; _privateDirectChatChannel = null;
_privateMessagesChannel = null; _privateMessagesChannel = null;
_privateNotificationChannel = null; _privateNotificationChannel = null;
_directChatStream = null;
_clientTypingStream = null;
} }
} }

View File

@@ -6,12 +6,20 @@ import 'package:eatvio_app/core/utils/toasts.dart';
import 'package:eatvio_app/core/widgets/show_error_dialog.dart'; import 'package:eatvio_app/core/widgets/show_error_dialog.dart';
import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_cubit.dart'; import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_cubit.dart';
import 'package:eatvio_app/features/complaining/presentation/complaining_bottom_sheet.dart'; import 'package:eatvio_app/features/complaining/presentation/complaining_bottom_sheet.dart';
import 'package:eatvio_app/features/create_diet/presentation/create_diet_screen.dart';
import 'package:eatvio_app/features/create_plan/presentation/create_plan_screen.dart';
import 'package:eatvio_app/features/create_recipe/presentation/pages/recipe_create_screen.dart';
import 'package:eatvio_app/features/diets/presentation/cubit/about_diet_cubit.dart';
import 'package:eatvio_app/features/feed/presentation/bloc/diets_cubit/diets_cubit.dart';
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
import 'package:eatvio_app/features/feed/presentation/bloc/plans_cubit/plans_cubit.dart';
import 'package:eatvio_app/features/feed/presentation/bloc/recipes_cubit/recipes_cubit.dart';
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart'; import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
import 'package:eatvio_app/features/feed/models/recipes_model.dart'; import 'package:eatvio_app/features/feed/models/recipes_model.dart';
import 'package:eatvio_app/features/feed/presentation/bloc/diets_cubit/diets_cubit.dart'; import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_cubit.dart';
import 'package:eatvio_app/features/feed/presentation/bloc/recipes_cubit/recipes_cubit.dart'; import 'package:eatvio_app/features/recipe/presentation/cubit/about_recipe_cubit.dart';
import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_cubit.dart'; import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_cubit.dart';
import 'package:eatvio_app/features/my_plans/presentation/cubit/my_plans_cubit.dart'; import 'package:eatvio_app/features/my_plans/presentation/cubit/my_plans_cubit.dart';
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart'; import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
@@ -25,6 +33,7 @@ import 'package:share_plus/share_plus.dart';
class CustomPopup extends StatelessWidget { class CustomPopup extends StatelessWidget {
final bool isAuthor; final bool isAuthor;
final bool isAuthorAdmin;
final ComplainedContent? complainedContentType; final ComplainedContent? complainedContentType;
final String path; final String path;
final String? slug; final String? slug;
@@ -32,12 +41,14 @@ class CustomPopup extends StatelessWidget {
final FeedBlogPostModel? feedBlogPostModel; final FeedBlogPostModel? feedBlogPostModel;
final Recipe? recipe; final Recipe? recipe;
final Diet? diet; final Diet? diet;
final PlanModel? plan;
final String userSlug; final String userSlug;
final VoidCallback? onPostsChanged; final VoidCallback? onPostsChanged;
const CustomPopup({ const CustomPopup({
super.key, super.key,
required this.isAuthor, required this.isAuthor,
this.isAuthorAdmin = false,
this.complainedContentType, this.complainedContentType,
required this.path, required this.path,
this.id, this.id,
@@ -45,10 +56,14 @@ class CustomPopup extends StatelessWidget {
this.feedBlogPostModel, this.feedBlogPostModel,
this.recipe, this.recipe,
this.diet, this.diet,
this.plan,
required this.userSlug, required this.userSlug,
this.onPostsChanged, this.onPostsChanged,
}); });
bool get _isAdminAuthor =>
isAuthorAdmin || (diet?.author.isAdmin ?? false) || (recipe?.author.isAdmin ?? false) || (feedBlogPostModel?.author.isAdmin ?? false);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final s = S.of(context); final s = S.of(context);
@@ -81,7 +96,103 @@ class CustomPopup extends StatelessWidget {
); );
} }
List<PopupMenuEntry> _buildMenuItems(S s,BuildContext context) { Future<void> _openEditor(BuildContext context) async {
final router = GoRouter.of(context);
switch (complainedContentType) {
case ComplainedContent.posts:
await router.pushNamed('blogPostCreateScreen', extra: feedBlogPostModel);
onPostsChanged?.call();
case ComplainedContent.recipe:
final currentRecipe = recipe;
if (currentRecipe == null) {
return;
}
final result = await router.pushNamed(RecipeCreateScreen.name, extra: currentRecipe);
if (result != null && context.mounted) {
_reloadEditedContent(context);
}
case ComplainedContent.diet:
final currentDiet = diet;
if (currentDiet == null) {
return;
}
final result = await router.pushNamed(CreateDietScreen.name, extra: {'diet': currentDiet, 'isEditing': true});
if (result == true && context.mounted) {
_reloadEditedContent(context);
}
case ComplainedContent.plan:
final currentPlan = plan;
if (currentPlan == null) {
return;
}
final result = await router.pushNamed(CreatePlanScreen.name, extra: currentPlan);
if (result == true && context.mounted) {
_reloadEditedContent(context);
}
default:
return;
}
}
void _reloadEditedContent(BuildContext context) {
switch (complainedContentType) {
case ComplainedContent.recipe:
final currentRecipe = recipe;
if (currentRecipe == null) {
return;
}
try {
context.read<AboutRecipeCubit>().getRecipe(currentRecipe.slug);
} catch (_) {}
try {
context.read<RecipesCubit>().loadRecipesList();
} catch (_) {}
case ComplainedContent.diet:
final currentDiet = diet;
if (currentDiet == null) {
return;
}
try {
context.read<AboutDietCubit>().getDiet(id: currentDiet.id, slug: currentDiet.slug);
} catch (_) {}
try {
context.read<DietsCubit>().loadDiets();
} catch (_) {}
case ComplainedContent.plan:
final currentPlan = plan;
if (currentPlan == null) {
return;
}
try {
context.read<AboutPlanCubit>().getPlan(slug: currentPlan.slug);
} catch (_) {}
try {
context.read<PlansCubit>().loadPlans();
} catch (_) {}
try {
context.read<MyPlansCubit>().loadPlans();
} catch (_) {}
default:
return;
}
}
bool _showsEditAction() {
switch (complainedContentType) {
case ComplainedContent.posts:
return feedBlogPostModel != null || id != null;
case ComplainedContent.recipe:
return recipe != null;
case ComplainedContent.diet:
return diet != null;
case ComplainedContent.plan:
return plan != null;
default:
return false;
}
}
List<PopupMenuEntry> _buildMenuItems(S s, BuildContext context) {
final entries = <PopupMenuEntry>[]; final entries = <PopupMenuEntry>[];
void addItem(String icon, String text, Function() onTap) { void addItem(String icon, String text, Function() onTap) {
@@ -105,12 +216,9 @@ class CustomPopup extends StatelessWidget {
), ),
); );
}); });
if (complainedContentType == ComplainedContent.posts) { if (_showsEditAction()) {
addItem('assets/icons/edit_paint.svg', 'Редактировать', () async { addItem('assets/icons/edit_paint.svg', s.edit, () {
if (complainedContentType == ComplainedContent.posts) { _openEditor(context);
await GoRouter.of(context).pushNamed('blogPostCreateScreen', extra: feedBlogPostModel);
onPostsChanged?.call();
}
}); });
} }
addItem('assets/icons/red_trash.svg', 'Удалить', () async { addItem('assets/icons/red_trash.svg', 'Удалить', () async {
@@ -167,19 +275,21 @@ class CustomPopup extends StatelessWidget {
), ),
); );
}); });
addItem('assets/icons/complain.svg', 'Пожаловаться', () { if (!_isAdminAuthor) {
if (complainedContentType != null) { addItem('assets/icons/complain.svg', 'Пожаловаться', () {
showModalBottomSheet( if (complainedContentType != null) {
context: context, showModalBottomSheet(
isScrollControlled: true, context: context,
builder: (context) => ComplainingBottomSheet( isScrollControlled: true,
complainedContentType: complainedContentType!, builder: (context) => ComplainingBottomSheet(
id: id, complainedContentType: complainedContentType!,
slug: slug, id: id,
), slug: slug,
); ),
} );
}); }
});
}
addItem( addItem(
'assets/icons/complain.svg', 'assets/icons/complain.svg',
'Заблокировать автора', 'Заблокировать автора',

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) { if (isDrafted == true) {
text = 'cохранена в черновиках'; text = 'cохранена в черновиках';
} else if (isUpdated == true) { } else if (isUpdated == true) {
text = 'успешно обновлена'; text = isPlan ? 'успешно обновлён' : 'успешно обновлена';
} else if (isPlan == true) { } else if (isPlan == true) {
text = 'опубликован'; text = 'опубликован';
} else { } else {

View File

@@ -1,5 +1,6 @@
import 'dart:developer'; import 'dart:developer';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:eatvio_app/core/firebase_api.dart';
import 'package:eatvio_app/core/mixins/registration_date_cache_mixin.dart'; import 'package:eatvio_app/core/mixins/registration_date_cache_mixin.dart';
import 'package:eatvio_app/core/prefs.dart'; import 'package:eatvio_app/core/prefs.dart';
import 'package:eatvio_app/core/token_storage.dart'; import 'package:eatvio_app/core/token_storage.dart';
@@ -11,12 +12,22 @@ import 'package:eatvio_app/core/error_reporting/report_from.dart';
class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin { class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
final AuthRepository authRepository; final AuthRepository authRepository;
final FirebaseApi firebaseApi;
OAuthCubit({required this.authRepository, required Prefs prefs}) : super(const OAuthState()) { OAuthCubit({
required this.authRepository,
required this.firebaseApi,
required Prefs prefs,
}) : super(const OAuthState()) {
this.prefs = prefs; this.prefs = prefs;
} }
Future<void> signInViaSocialMedia({required String provider, required String accessToken, required String refreshToken, required String clientId}) async { Future<void> signInViaSocialMedia({
required String provider,
required String accessToken,
required String refreshToken,
required String clientId,
}) async {
try { try {
emit(const OAuthState.loading()); emit(const OAuthState.loading());
@@ -28,7 +39,7 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
); );
log('OAuthCubit: Got token model: ${tokenModel.token}'); log('OAuthCubit: Got token model: ${tokenModel.token}');
TokenStorage.saveTokenInStorage(tokenModel.token); await TokenStorage.saveTokenInStorage(tokenModel.token);
emit(OAuthState.loaded(token: tokenModel.token)); emit(OAuthState.loaded(token: tokenModel.token));
} on DioException catch (e, st) { } on DioException catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
@@ -45,7 +56,8 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
if (errorStr.contains('Некорректный формат email')) { if (errorStr.contains('Некорректный формат email')) {
errorsList.add(RegistrationValidationErrors.incorrectEmailFormat); errorsList.add(RegistrationValidationErrors.incorrectEmailFormat);
} }
if (errorStr.contains('почта уже существует') || errorStr.contains('Эл. почта уже существует')) { if (errorStr.contains('почта уже существует') ||
errorStr.contains('Эл. почта уже существует')) {
errorsList.add(RegistrationValidationErrors.emailExist); errorsList.add(RegistrationValidationErrors.emailExist);
} }
} }
@@ -53,14 +65,20 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
if (phoneErrors != null && phoneErrors.isNotEmpty) { if (phoneErrors != null && phoneErrors.isNotEmpty) {
for (final error in phoneErrors) { for (final error in phoneErrors) {
final errorStr = error.toString(); final errorStr = error.toString();
if (errorStr.contains('phone уже существует') || errorStr.contains('телефон уже существует')) { if (errorStr.contains('phone уже существует') ||
errorStr.contains('телефон уже существует')) {
errorsList.add(RegistrationValidationErrors.phoneExist); errorsList.add(RegistrationValidationErrors.phoneExist);
} }
} }
} }
if (errorsList.isNotEmpty) { if (errorsList.isNotEmpty) {
emit(OAuthState.error(errorMessage: 'validation_error', registrationValidationErrors: errorsList)); emit(
OAuthState.error(
errorMessage: 'validation_error',
registrationValidationErrors: errorsList,
),
);
} else { } else {
emit(OAuthState.error(errorMessage: e.toString())); emit(OAuthState.error(errorMessage: e.toString()));
} }
@@ -103,7 +121,8 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
height: height, height: height,
clientId: clientId, clientId: clientId,
); );
TokenStorage.saveTokenInStorage(token); await TokenStorage.saveTokenInStorage(token);
await firebaseApi.registerPushToken();
cacheRegistrationDate(DateTime.now()); cacheRegistrationDate(DateTime.now());
emit(OAuthState.loaded(token: token)); emit(OAuthState.loaded(token: token));
} on DioException catch (e, st) { } on DioException catch (e, st) {
@@ -120,7 +139,8 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
if (errorStr.contains('Некорректный формат email')) { if (errorStr.contains('Некорректный формат email')) {
errorsList.add(RegistrationValidationErrors.incorrectEmailFormat); errorsList.add(RegistrationValidationErrors.incorrectEmailFormat);
} }
if (errorStr.contains('почта уже существует') || errorStr.contains('Эл. почта уже существует')) { if (errorStr.contains('почта уже существует') ||
errorStr.contains('Эл. почта уже существует')) {
errorsList.add(RegistrationValidationErrors.emailExist); errorsList.add(RegistrationValidationErrors.emailExist);
} }
} }
@@ -128,14 +148,17 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
if (phoneErrors != null && phoneErrors.isNotEmpty) { if (phoneErrors != null && phoneErrors.isNotEmpty) {
for (final error in phoneErrors) { for (final error in phoneErrors) {
final errorStr = error.toString(); final errorStr = error.toString();
if (errorStr.contains('phone уже существует') || errorStr.contains('телефон уже существует')) { if (errorStr.contains('phone уже существует') ||
errorStr.contains('телефон уже существует')) {
errorsList.add(RegistrationValidationErrors.phoneExist); errorsList.add(RegistrationValidationErrors.phoneExist);
} }
} }
} }
if (dateOfBirth != null && dateOfBirth.isNotEmpty) { if (dateOfBirth != null && dateOfBirth.isNotEmpty) {
if (dateOfBirth.contains('Некорректный формат даты рождения')) { if (dateOfBirth.contains('Некорректный формат даты рождения')) {
errorsList.add(RegistrationValidationErrors.incorrectDateOfBirthFormat); errorsList.add(
RegistrationValidationErrors.incorrectDateOfBirthFormat,
);
} }
} }
if (errorsList.isNotEmpty) { if (errorsList.isNotEmpty) {
@@ -146,13 +169,23 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
return null; return null;
} }
Future<OAuthVerificationRequestResponse?> requestOAuthVerification({String? email, String? phone}) async { Future<OAuthVerificationRequestResponse?> requestOAuthVerification({
String? email,
String? phone,
}) async {
try { try {
emit(const OAuthState.loading()); emit(const OAuthState.loading());
log('OAuthCubit: Requesting verification code for email: $email, phone: $phone'); log(
final response = await authRepository.requestOAuthVerification(email: email, phone: phone); 'OAuthCubit: Requesting verification code for email: $email, phone: $phone',
);
final response = await authRepository.requestOAuthVerification(
email: email,
phone: phone,
);
if (response != null) { if (response != null) {
log('OAuthCubit: Verification code requested successfully, masked email: ${response.email}'); log(
'OAuthCubit: Verification code requested successfully, masked email: ${response.email}',
);
} }
// Сбрасываем состояние после успешного запроса кода // Сбрасываем состояние после успешного запроса кода
emit(const OAuthState()); emit(const OAuthState());
@@ -184,7 +217,9 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
}) async { }) async {
try { try {
emit(const OAuthState.loading()); emit(const OAuthState.loading());
log('OAuthCubit: Confirming OAuth verification - provider: $provider, verificationType: $verificationType, code: $code'); log(
'OAuthCubit: Confirming OAuth verification - provider: $provider, verificationType: $verificationType, code: $code',
);
final response = await authRepository.confirmOAuthVerification( final response = await authRepository.confirmOAuthVerification(
provider: provider, provider: provider,
accessToken: accessToken, accessToken: accessToken,
@@ -202,8 +237,11 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
weight: weight, weight: weight,
height: height, height: height,
); );
log('OAuthCubit: OAuth verification confirmed successfully, token received'); log(
TokenStorage.saveTokenInStorage(response.token); 'OAuthCubit: OAuth verification confirmed successfully, token received',
);
await TokenStorage.saveTokenInStorage(response.token);
await firebaseApi.registerPushToken();
emit(OAuthState.loaded(token: response.token)); emit(OAuthState.loaded(token: response.token));
} catch (e, st) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
@@ -212,4 +250,3 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
} }
} }
} }

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

View File

@@ -37,6 +37,17 @@ class BlogPostCreateCubit extends Cubit<BlogPostCreateState> {
} }
} }
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() { void resetFormErrors() {
if (!state.hasValidationErrors && if (!state.hasValidationErrors &&
!state.isSuccess && !state.isSuccess &&

View File

@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'dart:ui' as ui;
import 'package:eatvio_app/app_keys.dart'; import 'package:eatvio_app/app_keys.dart';
import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/core/app_router.dart';
@@ -38,11 +39,15 @@ class BlogPostCreateScreen extends StatefulWidget {
} }
class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> { class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
static const _minImageWidth = 500;
static const _minImageHeight = 500;
final blogPostCubit = sl<BlogPostCreateCubit>(); final blogPostCubit = sl<BlogPostCreateCubit>();
bool shrinkImage = false; bool shrinkImage = false;
late StreamSubscription<bool> keyboardSubscription; late StreamSubscription<bool> keyboardSubscription;
final List<FeedGalleryImage> savedImages = []; final List<FeedGalleryImage> savedImages = [];
final List<XFile> pendingImages = []; final List<XFile> pendingImages = [];
final Set<String> _undersizedPendingPaths = {};
final List<int> mediaIdsToDelete = []; final List<int> mediaIdsToDelete = [];
final titleController = TextEditingController(); final titleController = TextEditingController();
final descriptionController = TextEditingController(); final descriptionController = TextEditingController();
@@ -531,10 +536,7 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
source: ImageSource.camera, source: ImageSource.camera,
); );
if (image != null && _remainingSlots > 0) { if (image != null && _remainingSlots > 0) {
setState(() { await _addPendingImages([image]);
pendingImages.add(image);
});
blogPostCubit.clearImagesError();
} }
AppRouter.navigatorKey.currentContext?.pop(); AppRouter.navigatorKey.currentContext?.pop();
}, },
@@ -561,10 +563,9 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
limit: _remainingSlots, limit: _remainingSlots,
); );
if (images.isNotEmpty) { if (images.isNotEmpty) {
setState(() { await _addPendingImages(
pendingImages.addAll(images.take(_remainingSlots)); images.take(_remainingSlots).toList(),
}); );
blogPostCubit.clearImagesError();
} }
if (!context.mounted) return; if (!context.mounted) return;
AppRouter.navigatorKey.currentContext?.pop(); AppRouter.navigatorKey.currentContext?.pop();
@@ -603,9 +604,12 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
} }
void _removePendingImage(int index) { void _removePendingImage(int index) {
final path = pendingImages[index].path;
setState(() { setState(() {
pendingImages.removeAt(index); pendingImages.removeAt(index);
_undersizedPendingPaths.remove(path);
}); });
_syncImagesSizeError();
} }
void _onReorderGallery(int oldIndex, int newIndex) { void _onReorderGallery(int oldIndex, int newIndex) {
@@ -670,164 +674,185 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
); );
} }
Scaffold _buildBlogPostCreate(BuildContext context) { Widget _buildEditorToolbar(BuildContext context) {
return Scaffold( return Row(
resizeToAvoidBottomInset: true, key: const ValueKey('blogPostCreateEditorToolbar'),
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, mainAxisAlignment: MainAxisAlignment.center,
floatingActionButton: enableMdPanel || isPreviewMode children: [
? Row( if (!isPreviewMode) ...[
mainAxisAlignment: MainAxisAlignment.center, Container(
children: [ width: MediaQuery.of(context).size.width - 76,
if (!isPreviewMode) ...[ height: 52,
Container( decoration: BoxDecoration(
width: MediaQuery.of(context).size.width - 76, color: Colors.white,
height: 52, borderRadius: BorderRadius.circular(20),
decoration: BoxDecoration( boxShadow: [
color: Colors.white, BoxShadow(
borderRadius: BorderRadius.circular(20), color: const Color(0x00000000).withValues(alpha: 0.2),
boxShadow: [ spreadRadius: 0,
BoxShadow( blurRadius: 32,
color: const Color(0x00000000).withValues(alpha: 0.2), offset: const Offset(0, 0),
spreadRadius: 0,
blurRadius: 32,
offset: const Offset(0, 0),
),
],
),
child: Row(
children: [
const SizedBox(width: 8),
_mdTextButton('H', _applyHeading),
const SizedBox(width: 8),
_mdButton(Icons.format_bold, _applyBold),
const SizedBox(width: 8),
_mdButton(Icons.format_italic, _applyItalic),
const SizedBox(width: 8),
_mdButton(
Icons.format_list_numbered,
_applyNumberedList,
),
const SizedBox(width: 8),
_mdButton(
Icons.format_list_bulleted,
_applyBulletedList,
),
const SizedBox(width: 8),
_mdButton(Icons.link, _applyLink),
const SizedBox(width: 8),
_mdButton(Icons.horizontal_rule, _applyHorizontalRule),
const SizedBox(width: 8),
],
),
),
const SizedBox(width: 8),
],
Container(
width: 52,
height: 52,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: const Color(0x00000000).withValues(alpha: 0.2),
spreadRadius: 0,
blurRadius: 32,
offset: const Offset(0, 0),
),
],
),
child: Row(
children: [
_mdButton(
isPreviewMode ? Icons.edit : Icons.visibility,
_togglePreview,
),
],
),
), ),
], ],
) ),
: null, child: Row(
children: [
const SizedBox(width: 8),
_mdTextButton('H', _applyHeading),
const SizedBox(width: 8),
_mdButton(Icons.format_bold, _applyBold),
const SizedBox(width: 8),
_mdButton(Icons.format_italic, _applyItalic),
const SizedBox(width: 8),
_mdButton(Icons.format_list_numbered, _applyNumberedList),
const SizedBox(width: 8),
_mdButton(Icons.format_list_bulleted, _applyBulletedList),
const SizedBox(width: 8),
_mdButton(Icons.link, _applyLink),
const SizedBox(width: 8),
_mdButton(Icons.horizontal_rule, _applyHorizontalRule),
const SizedBox(width: 8),
],
),
),
const SizedBox(width: 8),
],
Container(
width: 52,
height: 52,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: const Color(0x00000000).withValues(alpha: 0.2),
spreadRadius: 0,
blurRadius: 32,
offset: const Offset(0, 0),
),
],
),
child: Row(
children: [
_mdButton(
isPreviewMode ? Icons.edit : Icons.visibility,
_togglePreview,
),
],
),
),
],
);
}
Scaffold _buildBlogPostCreate(BuildContext context) {
final editorToolbarVisible = enableMdPanel || isPreviewMode;
return Scaffold(
resizeToAvoidBottomInset: true,
appBar: AppBar(), appBar: AppBar(),
body: BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>( body: BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
bloc: blogPostCubit, bloc: blogPostCubit,
builder: (context, state) { builder: (context, state) {
return SingleChildScrollView( return Column(
padding: const EdgeInsets.all(16), crossAxisAlignment: CrossAxisAlignment.stretch,
child: Column( children: [
crossAxisAlignment: CrossAxisAlignment.start, Expanded(
children: [ child: SingleChildScrollView(
const SizedBox(height: 16), padding: const EdgeInsets.all(16),
const Text('Заголовок'), child: Column(
const SizedBox(height: 8), crossAxisAlignment: CrossAxisAlignment.start,
CustomTextFormField( children: [
key: AppKeys.blogPostCreateScreenTitleTextField, const Text('Заголовок'),
controller: titleController, const SizedBox(height: 8),
hasError: state.titleError != null, CustomTextFormField(
onChanged: (_) => blogPostCubit.clearTitleError(), key: AppKeys.blogPostCreateScreenTitleTextField,
), controller: titleController,
if (state.titleError != null) ...[ hasError: state.titleError != null,
const SizedBox(height: 4), onChanged: (_) => blogPostCubit.clearTitleError(),
ValidationErrorWidget(error: state.titleError!),
],
const SizedBox(height: 16),
const Text('Описание'),
const SizedBox(height: 8),
isPreviewMode
? _buildMarkdownPreview()
: CustomTextFormField(
key: AppKeys.blogPostCreateScreenContentTextField,
controller: descriptionController,
focusNode: descriptionFocusNode,
maxLines: 6,
minLines: 4,
hasError: state.descriptionError != null,
onChanged: (_) => blogPostCubit.clearDescriptionError(),
), ),
if (state.descriptionError != null) ...[ if (state.titleError != null) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
ValidationErrorWidget(error: state.descriptionError!), ValidationErrorWidget(error: state.titleError!),
], ],
const SizedBox(height: 16), const SizedBox(height: 16),
Text( const Text('Описание'),
'Изображения ($_galleryCount/${BlogPostRepository.maxGalleryImages})', const SizedBox(height: 8),
isPreviewMode
? _buildMarkdownPreview()
: CustomTextFormField(
key: AppKeys.blogPostCreateScreenContentTextField,
controller: descriptionController,
focusNode: descriptionFocusNode,
maxLines: 6,
minLines: 4,
hasError: state.descriptionError != null,
onChanged: (_) =>
blogPostCubit.clearDescriptionError(),
),
if (state.descriptionError != null) ...[
const SizedBox(height: 4),
ValidationErrorWidget(error: state.descriptionError!),
],
const SizedBox(height: 16),
Text(
'Изображения ($_galleryCount/${BlogPostRepository.maxGalleryImages})',
),
const SizedBox(height: 8),
_buildGallery(),
if (state.imagesError != null) ...[
const SizedBox(height: 4),
ValidationErrorWidget(error: state.imagesError!),
],
const SizedBox(height: 16),
const Text('Ссылка на видео'),
const SizedBox(height: 4),
Text(
'Rutube, VK или OK',
style: TextStyle(
fontSize: 13,
color: appTheme.blueGray300,
),
),
const SizedBox(height: 8),
CustomTextFormField(
key: AppKeys.blogPostCreateScreenVideoLinkTextField,
controller: videoUrlController,
hasError: state.videoUrlError != null,
keyboardType: TextInputType.url,
onChanged: (_) => blogPostCubit.clearVideoUrlError(),
),
if (state.videoUrlError != null) ...[
const SizedBox(height: 4),
ValidationErrorWidget(error: state.videoUrlError!),
],
],
),
), ),
),
if (editorToolbarVisible) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
_buildGallery(), _buildEditorToolbar(context),
if (state.imagesError != null) ...[
const SizedBox(height: 4),
ValidationErrorWidget(error: state.imagesError!),
],
const SizedBox(height: 16),
const Text('Ссылка на видео'),
const SizedBox(height: 4),
Text(
'Rutube, VK или OK',
style: TextStyle(fontSize: 13, color: appTheme.blueGray300),
),
const SizedBox(height: 8),
CustomTextFormField(
key: AppKeys.blogPostCreateScreenVideoLinkTextField,
controller: videoUrlController,
hasError: state.videoUrlError != null,
keyboardType: TextInputType.url,
onChanged: (_) => blogPostCubit.clearVideoUrlError(),
),
if (state.videoUrlError != null) ...[
const SizedBox(height: 4),
ValidationErrorWidget(error: state.videoUrlError!),
],
if (state.formError != null) ...[
const SizedBox(height: 16),
ValidationErrorWidget(error: state.formError!),
],
const SizedBox(height: 80),
_createBlogPostButton(isSubmitting: state.isLoading),
const SizedBox(height: 80),
], ],
), SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
if (state.formError != null) ...[
ValidationErrorWidget(error: state.formError!),
const SizedBox(height: 8),
],
_createBlogPostButton(isSubmitting: state.isLoading),
],
),
),
),
],
); );
}, },
), ),
@@ -839,6 +864,11 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
return; return;
} }
if (_undersizedPendingPaths.isNotEmpty) {
blogPostCubit.setImagesError(S.current.blog_post_image_min_size_error);
return;
}
final title = titleController.text.trim(); final title = titleController.text.trim();
final description = descriptionController.text.trim(); final description = descriptionController.text.trim();
final videoUrl = videoUrlController.text.trim(); final videoUrl = videoUrlController.text.trim();
@@ -889,9 +919,8 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
onTap: isSubmitting ? null : _submitBlogPost, onTap: isSubmitting ? null : _submitBlogPost,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
child: isSubmitting child: isSubmitting
? const Center(child: Loader()) ? const SizedBox(height: 52, child: Center(child: Loader()))
: Ink( : Ink(
width: MediaQuery.of(context).size.width - 32,
height: 52, height: 52,
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
@@ -907,6 +936,50 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
); );
} }
Future<void> _addPendingImages(List<XFile> images) async {
if (images.isEmpty) {
return;
}
final undersizedPaths = <String>{};
for (final image in images) {
if (!await _isImageLargeEnough(image)) {
undersizedPaths.add(image.path);
}
}
setState(() {
pendingImages.addAll(images);
_undersizedPendingPaths.addAll(undersizedPaths);
});
_syncImagesSizeError();
}
void _syncImagesSizeError() {
final minSizeError = S.current.blog_post_image_min_size_error;
if (_undersizedPendingPaths.isNotEmpty) {
blogPostCubit.setImagesError(minSizeError);
return;
}
if (blogPostCubit.state.imagesError == minSizeError) {
blogPostCubit.clearImagesError();
}
}
Future<bool> _isImageLargeEnough(XFile image) async {
try {
final bytes = await File(image.path).readAsBytes();
final descriptor = await ui.ImmutableBuffer.fromUint8List(bytes);
final codec = await ui.instantiateImageCodecFromBuffer(descriptor);
final frame = await codec.getNextFrame();
final width = frame.image.width;
final height = frame.image.height;
return width >= _minImageWidth && height >= _minImageHeight;
} catch (_) {
return false;
}
}
Widget _buildGallery() { Widget _buildGallery() {
final itemHeight = shrinkImage ? 72.0 : 112.0; final itemHeight = shrinkImage ? 72.0 : 112.0;
@@ -946,6 +1019,7 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
child: _buildGalleryTile( child: _buildGalleryTile(
index: index, index: index,
height: itemHeight, height: itemHeight,
hasError: _undersizedPendingPaths.contains(pending.path),
imageProvider: FileImage(File(pending.path)), imageProvider: FileImage(File(pending.path)),
onRemove: () => _removePendingImage(pendingIndex), onRemove: () => _removePendingImage(pendingIndex),
), ),
@@ -985,6 +1059,7 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
required double height, required double height,
required ImageProvider imageProvider, required ImageProvider imageProvider,
required VoidCallback onRemove, required VoidCallback onRemove,
bool hasError = false,
}) { }) {
return ReorderableDelayedDragStartListener( return ReorderableDelayedDragStartListener(
index: index, index: index,
@@ -992,12 +1067,16 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
width: height, width: height,
height: height, height: height,
margin: const EdgeInsets.only(right: 8), margin: const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: hasError ? Border.all(color: appTheme.red, width: 2) : null,
),
child: Stack( child: Stack(
clipBehavior: Clip.none, clipBehavior: Clip.none,
children: [ children: [
Positioned.fill( Positioned.fill(
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(14),
child: Image(image: imageProvider, fit: BoxFit.cover), child: Image(image: imageProvider, fit: BoxFit.cover),
), ),
), ),

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

View File

@@ -31,6 +31,11 @@ abstract class ChatMessageModel with _$ChatMessageModel {
@JsonKey(name: 'author_id', fromJson: _parseAuthorId) required int authorId, @JsonKey(name: 'author_id', fromJson: _parseAuthorId) required int authorId,
@JsonKey(name: 'updated_at', fromJson: _parseUpdatedAt) required DateTime updatedAt, @JsonKey(name: 'updated_at', fromJson: _parseUpdatedAt) required DateTime updatedAt,
@JsonKey(name: 'read_by_to_user', defaultValue: false) required bool isRead, @JsonKey(name: 'read_by_to_user', defaultValue: false) required bool isRead,
// @deprecated Backend BC-поле. В broadcast (ChatMessageUpdated) оно
// recipient-relative и для второго устройства отправителя всегда false,
// поэтому решать «моё/не моё» по нему нельзя. Источник истины — authorId:
// сравнивайте его со своим user_id (wsManager.userId). Поле оставлено
// только для обратной совместимости парсинга.
@JsonKey(name: 'is_author_message_auth_user') required bool? isCurrentUserAuthor, @JsonKey(name: 'is_author_message_auth_user') required bool? isCurrentUserAuthor,
@JsonKey(name: 'room_id') required String? roomId, @JsonKey(name: 'room_id') required String? roomId,
@JsonKey(name: 'type', unknownEnumValue: ChatMessageType.unknown) ChatMessageType? type, @JsonKey(name: 'type', unknownEnumValue: ChatMessageType.unknown) ChatMessageType? type,

View File

@@ -31,6 +31,7 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
StreamSubscription<ChannelReadEvent>? typingSubscription; StreamSubscription<ChannelReadEvent>? typingSubscription;
StreamSubscription<ChannelReadEvent>? messagesSubscription; StreamSubscription<ChannelReadEvent>? messagesSubscription;
StreamSubscription<void>? _reconnectSubscription;
int currentPage = 1; int currentPage = 1;
late int lastPage; late int lastPage;
bool isLoading = false; bool isLoading = false;
@@ -39,11 +40,18 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
void Function(bool isOnline)? _presenceOnlineListener; void Function(bool isOnline)? _presenceOnlineListener;
var uuid = const Uuid(); var uuid = const Uuid();
ChatRoomCubit({required this.chatRepository, required this.wsManager, required this.cardsRepository}) : super(const ChatRoomState.loading()); ChatRoomCubit({
required this.chatRepository,
required this.wsManager,
required this.cardsRepository,
}) : super(const ChatRoomState.loading());
void setTimer() { void setTimer() {
_timer?.cancel(); _timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 5), (_) => updateChosenPlanState()); _timer = Timer.periodic(
const Duration(seconds: 5),
(_) => updateChosenPlanState(),
);
} }
void cancelTimer() { void cancelTimer() {
@@ -54,16 +62,22 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
if (currentPage < lastPage && !isLoading) { if (currentPage < lastPage && !isLoading) {
isLoading = true; isLoading = true;
emit((state as ChatRoomStateLoaded).copyWith(isLoading: isLoading)); _emitLoaded((current) => current.copyWith(isLoading: isLoading));
try { try {
final chatMessages = await chatRepository.getChatMessages(uuid, currentPage + 1); final chatMessages = await chatRepository.getChatMessages(
uuid,
currentPage + 1,
);
isLoading = false; isLoading = false;
emit( _emitLoaded(
(state as ChatRoomStateLoaded).copyWith( (current) => current.copyWith(
isLoading: isLoading, isLoading: isLoading,
chatMessages: [...(state as ChatRoomStateLoaded).chatMessages, ...chatMessages.messagesList], chatMessages: [
...current.chatMessages,
...chatMessages.messagesList,
],
), ),
); );
@@ -86,11 +100,8 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
} }
void _setCard({required List<CardModel> cards}) { void _setCard({required List<CardModel> cards}) {
final currentState = state; if (cards.where((card) => card.isDefault).isNotEmpty) {
if (currentState is ChatRoomStateLoaded) { _emitLoaded((current) => current.copyWith(hasCard: true));
if (cards.where((card) => card.isDefault).isNotEmpty) {
emit(currentState.copyWith(hasCard: true));
}
} }
} }
@@ -100,7 +111,11 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
/// `GET /api/consultation/resolve/order_id_to_conversation/{orderId}`. /// `GET /api/consultation/resolve/order_id_to_conversation/{orderId}`.
/// ///
/// Возвращает conversation uuid, которым инициализировали чат (для reload без повторного resolve). /// Возвращает conversation uuid, которым инициализировали чат (для reload без повторного resolve).
Future<String?> initChat(String? uuid, {String? orderId, bool sendInitialMessage = false}) async { Future<String?> initChat(
String? uuid, {
String? orderId,
bool sendInitialMessage = false,
}) async {
try { try {
emit(const ChatRoomState.loading()); emit(const ChatRoomState.loading());
@@ -108,7 +123,9 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
int? resolvedChosenPlanId; int? resolvedChosenPlanId;
if (orderId != null && orderId.isNotEmpty) { if (orderId != null && orderId.isNotEmpty) {
final resolved = await chatRepository.resolveOrderIdToConversation(orderId: orderId); final resolved = await chatRepository.resolveOrderIdToConversation(
orderId: orderId,
);
resolvedUuid = resolved.conversationUuid; resolvedUuid = resolved.conversationUuid;
resolvedChosenPlanId = resolved.chosenPlanId; resolvedChosenPlanId = resolved.chosenPlanId;
} }
@@ -117,15 +134,23 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
throw ArgumentError('initChat requires uuid or orderId'); throw ArgumentError('initChat requires uuid or orderId');
} }
currentPage = 1;
isLoading = false;
_activeChatUuid = resolvedUuid;
final chatRoom = await chatRepository.getChatRoom(resolvedUuid); final chatRoom = await chatRepository.getChatRoom(resolvedUuid);
ChosenPlanModel? chosenPlan; ChosenPlanModel? chosenPlan;
final planId = resolvedChosenPlanId ?? chatRoom.chosenPlanId; final planId = resolvedChosenPlanId ?? chatRoom.chosenPlanId;
if (planId != null) { if (planId != null) {
chosenPlan = await chatRepository.getChosenPlan(id: planId); chosenPlan = await chatRepository.getChosenPlan(id: planId);
} }
final chatMessages = await chatRepository.getChatMessages(resolvedUuid, currentPage); final chatMessages = await chatRepository.getChatMessages(
resolvedUuid,
currentPage,
);
lastPage = chatMessages.meta.lastPage; lastPage = chatMessages.meta.lastPage;
await _subscribeToChatChannel(uuid: resolvedUuid, chatId: chatRoom.id); await _subscribeToChatChannel(uuid: resolvedUuid, chatId: chatRoom.id);
_listenToReconnect();
emit( emit(
ChatRoomState.loaded( ChatRoomState.loaded(
chatRoom: chatRoom.copyWith(chosenPlan: chosenPlan), chatRoom: chatRoom.copyWith(chosenPlan: chosenPlan),
@@ -135,6 +160,7 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
hasCard: false, hasCard: false,
), ),
); );
await _subscribeToPresenceChannel(chatId: chatRoom.id);
if (chosenPlan?.consultantId == chatRoom.users.sender.id) { if (chosenPlan?.consultantId == chatRoom.users.sender.id) {
await checkCards(); await checkCards();
@@ -145,22 +171,106 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
log(e.toString()); log(e.toString());
typingSubscription?.cancel(); typingSubscription?.cancel();
messagesSubscription?.cancel(); messagesSubscription?.cancel();
_reconnectSubscription?.cancel();
_reconnectSubscription = null;
wsManager.unsubscribeDirectChatChannel(); wsManager.unsubscribeDirectChatChannel();
emit(const ChatRoomState.error(errorMessage: 'Ошибка получеия данных')); emit(const ChatRoomState.error(errorMessage: 'Ошибка получеия данных'));
return null; return null;
} }
} }
/// Подписывается на реконнект сокета, чтобы догрузить пропущенное (S5).
void _listenToReconnect() {
_reconnectSubscription?.cancel();
_reconnectSubscription = wsManager.reconnected.listen((_) {
unawaited(refreshCurrentChat());
});
}
/// Догружает первую страницу сообщений после восстановления соединения (S5).
///
/// Мерж по `id` сохраняет уже подгруженные старые страницы: пропущенные
/// сообщения добавляются, существующие не дублируются. Порядок — по
/// `updatedAt` по убыванию, как его ждёт UI и отдаёт backend (S1).
Future<void> refreshCurrentChat() async {
final uuid = _activeChatUuid;
if (uuid == null) return;
try {
final page = await chatRepository.getChatMessages(uuid, 1);
lastPage = page.meta.lastPage;
_emitLoaded((current) {
final byId = {
for (final message in current.chatMessages) message.id: message,
};
for (final message in page.messagesList) {
byId.putIfAbsent(message.id, () => message);
}
final merged = byId.values.toList()
..sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
return current.copyWith(chatMessages: merged);
});
} catch (e, st) {
await reportFrom(e, st, this);
}
}
/// Перезагружает сообщения, комнату и состояние плана без экрана загрузки.
Future<void> refreshChat() async {
final uuid = _activeChatUuid;
if (uuid == null || uuid.isEmpty) {
return;
}
final currentState = state;
if (currentState is! ChatRoomStateLoaded) {
return;
}
try {
currentPage = 1;
isLoading = false;
final chatRoom = await chatRepository.getChatRoom(uuid);
ChosenPlanModel? chosenPlan;
final planId =
chatRoom.chosenPlanId ?? currentState.chatRoom.chosenPlanId;
if (planId != null) {
chosenPlan = await chatRepository.getChosenPlan(id: planId);
}
final chatMessages = await chatRepository.getChatMessages(
uuid,
currentPage,
);
lastPage = chatMessages.meta.lastPage;
_emitLoaded(
(latest) => latest.copyWith(
chatRoom: chatRoom.copyWith(chosenPlan: chosenPlan),
chatMessages: chatMessages.messagesList,
isLoading: false,
),
);
if (chosenPlan?.consultantId == chatRoom.users.sender.id) {
await checkCards();
}
} catch (e, st) {
await reportFrom(e, st, this);
}
}
Future<void> updateChosenPlanState() async { Future<void> updateChosenPlanState() async {
try { try {
final currentState = state; final currentState = state;
if (currentState is ChatRoomStateLoaded) { if (currentState is! ChatRoomStateLoaded) {
final chosenPlanId = currentState.chatRoom.chosenPlanId; return;
if (chosenPlanId != null) {
final chosenPlan = await chatRepository.getChosenPlan(id: chosenPlanId);
emit(currentState.copyWith.chatRoom(chosenPlan: chosenPlan));
}
} }
final chosenPlanId = currentState.chatRoom.chosenPlanId;
if (chosenPlanId == null) {
return;
}
final chosenPlan = await chatRepository.getChosenPlan(id: chosenPlanId);
_emitLoaded((latest) => latest.copyWith.chatRoom(chosenPlan: chosenPlan));
} catch (e, st) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
} }
@@ -172,9 +282,16 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
final planId = (state as ChatRoomStateLoaded).chatRoom.chosenPlanId; final planId = (state as ChatRoomStateLoaded).chatRoom.chosenPlanId;
if (planId == null) return false; if (planId == null) return false;
final stringDate = date.format('yyyy-MM-dd'); final stringDate = date.format('yyyy-MM-dd');
await chatRepository.choseStartPlanDate(id: planId, date: {'start_date': stringDate}); await chatRepository.choseStartPlanDate(
id: planId,
date: {'start_date': stringDate},
);
final chosenPlan = await chatRepository.getChosenPlan(id: planId); final chosenPlan = await chatRepository.getChosenPlan(id: planId);
emit((state as ChatRoomStateLoaded).copyWith.chatRoom(chosenPlan: chosenPlan)); emit(
(state as ChatRoomStateLoaded).copyWith.chatRoom(
chosenPlan: chosenPlan,
),
);
return true; return true;
} catch (e, st) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
@@ -183,7 +300,10 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
} }
} }
Future<void> cancelChosenPlan({required String chatId, required int planId}) async { Future<void> cancelChosenPlan({
required String chatId,
required int planId,
}) async {
try { try {
await chatRepository.cancelChosenPlan(id: planId); await chatRepository.cancelChosenPlan(id: planId);
emit((state as ChatRoomStateLoaded).copyWith.chatRoom(chosenPlan: null)); emit((state as ChatRoomStateLoaded).copyWith.chatRoom(chosenPlan: null));
@@ -193,38 +313,129 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
} }
} }
Future<void> _subscribeToChatChannel({required String uuid, required String chatId}) async { bool _emitLoaded(
ChatRoomStateLoaded Function(ChatRoomStateLoaded current) update,
) {
if (isClosed) {
return false;
}
final currentState = state;
if (currentState is! ChatRoomStateLoaded) {
return false;
}
emit(update(currentState));
return true;
}
Future<void> _subscribeToChatChannel({
required String uuid,
required String chatId,
}) async {
// Активный чат запоминаем до проверки клиента: он нужен и для догрузки
// пропущенного после реконнекта (S5), даже если сокет появится позже.
_activeChatUuid = uuid;
final client = wsManager.client; final client = wsManager.client;
if (client == null) return; if (client == null) return;
_activeChatUuid = uuid; await messagesSubscription?.cancel();
await typingSubscription?.cancel();
messagesSubscription = null;
typingSubscription = null;
await wsManager.initPrivateDirectChatChannel(chatId); await wsManager.initPrivateDirectChatChannel(chatId);
wsManager.subscribeDirectChatChannel(); wsManager.subscribeDirectChatChannel();
await _subscribeToPresenceChannel(chatId: chatId); messagesSubscription = wsManager.directChatStream?.listen(
messagesSubscription = wsManager.directChatStream?.listen((event) { _onDirectChatEvent,
AudioPlayer().play((AssetSource('audio/recive_message.wav'))); onError: (Object e, StackTrace st) {
final message = ChatMessageModel.fromJson(jsonDecode(event.data)); unawaited(reportFrom(e, st, this));
final currentState = state as ChatRoomStateLoaded; },
);
// Сообщения от текущего пользователя не приходят через WebSocket,
// они обрабатываются через ответ API в sendMessage
// Здесь обрабатываем только входящие сообщения от других пользователей
List<ChatMessageModel> messagesList = [message, ...currentState.chatMessages];
chatRepository.emitNewMessageToChats(message);
emit(currentState.copyWith(chatMessages: messagesList, isLoading: isLoading));
EasyDebounce.debounce('searchChatRoom', const Duration(milliseconds: 300), () => sl<ChatsCubit>().getChatRooms());
});
typingSubscription = wsManager.clientTypingStream?.listen((event) { typingSubscription = wsManager.clientTypingStream?.listen((event) {
emit((state as ChatRoomStateLoaded).copyWith(isTyping: true, isLoading: isLoading)); _emitLoaded(
(current) => current.copyWith(isTyping: true, isLoading: isLoading),
);
EasyDebounce.debounce( EasyDebounce.debounce(
'client-typing', 'client-typing',
const Duration(seconds: 3), const Duration(seconds: 3),
() => emit((state as ChatRoomStateLoaded).copyWith(isTyping: false, isLoading: isLoading)), () => _emitLoaded(
(current) => current.copyWith(isTyping: false, isLoading: isLoading),
),
); );
}); });
} }
void _onDirectChatEvent(ChannelReadEvent event) {
try {
if (addIncomingMessage(
ChatMessageModel.fromJson(
jsonDecode(event.data) as Map<String, dynamic>,
),
)) {
unawaited(_playIncomingSound());
}
} catch (e, st) {
unawaited(reportFrom(e, st, this));
}
}
Future<void> _playIncomingSound() async {
try {
await AudioPlayer().play(AssetSource('audio/recive_message.wav'));
} catch (_) {}
}
/// Добавляет входящее сообщение в текущий список, не затирая более новый state.
///
/// Собственное сообщение, пришедшее по сокету, — это эхо с другого устройства
/// автора (`author_id == wsManager.userId`). Такое сообщение тоже добавляется
/// в открытый чат, но не считается входящим: звук не играет, а список чатов
/// обновляет S3 через `new-chat-messages.{senderId}`.
///
/// Возвращает `true`, только если нужно проиграть звук входящего (чужое
/// сообщение, реально добавленное).
bool addIncomingMessage(ChatMessageModel message) {
final currentState = state;
if (currentState is! ChatRoomStateLoaded) {
return false;
}
final currentUserId = wsManager.userId;
final isOwn = currentUserId != null && message.authorId == currentUserId;
// Дедуп: по серверному id и по uuid (если broadcast вернул наш uuid, т.е.
// это эхо уже добавленного оптимистичного сообщения).
final alreadyExists = currentState.chatMessages.any(
(existing) =>
(message.id != 0 && existing.id == message.id) ||
(message.uuid != null && existing.uuid == message.uuid),
);
if (alreadyExists) {
return false;
}
final added = _emitLoaded((current) {
if (current.chatMessages.any(
(existing) =>
(message.id != 0 && existing.id == message.id) ||
(message.uuid != null && existing.uuid == message.uuid),
)) {
return current;
}
return current.copyWith(
chatMessages: [message, ...current.chatMessages],
isLoading: isLoading,
);
});
if (!added) {
return false;
}
// Список чатов за собственные сообщения обновляет S3 (new-chat-messages.{senderId}).
if (!isOwn) {
chatRepository.emitNewMessageToChats(message);
}
return !isOwn;
}
Future<void> _subscribeToPresenceChannel({required String chatId}) async { Future<void> _subscribeToPresenceChannel({required String chatId}) async {
final currentState = state; final currentState = state;
if (currentState is! ChatRoomStateLoaded) return; if (currentState is! ChatRoomStateLoaded) return;
@@ -236,12 +447,15 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
await wsManager.initPresenceDirectChatChannel(chatId); await wsManager.initPresenceDirectChatChannel(chatId);
_presenceOnlineListener = (isOnline) { _presenceOnlineListener = (isOnline) {
unawaited(_handleCounterpartOnlineChanged( unawaited(
counterpartField: counterpart.id == currentState.chatRoom.users.receiver.id _handleCounterpartOnlineChanged(
? _ChatCounterpartField.receiver counterpartField:
: _ChatCounterpartField.sender, counterpart.id == currentState.chatRoom.users.receiver.id
isOnline: isOnline, ? _ChatCounterpartField.receiver
)); : _ChatCounterpartField.sender,
isOnline: isOnline,
),
);
}; };
wsManager.addPresenceDirectChatListener( wsManager.addPresenceDirectChatListener(
counterpartUserId: counterpart.id, counterpartUserId: counterpart.id,
@@ -260,7 +474,8 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
? currentState.chatRoom.users.receiver ? currentState.chatRoom.users.receiver
: currentState.chatRoom.users.sender; : currentState.chatRoom.users.sender;
if (counterpart.isOnline == isOnline && (isOnline || counterpart.lastSeenText.isNotEmpty)) { if (counterpart.isOnline == isOnline &&
(isOnline || counterpart.lastSeenText.isNotEmpty)) {
return; return;
} }
@@ -337,63 +552,55 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
); );
} }
Future<bool> sendMessage({String? data, List<File>? files, required String userSlug}) async { Future<bool> sendMessage({
String? data,
List<File>? files,
required String userSlug,
}) async {
try { try {
final messageUuid = uuid.v4(); final messageUuid = uuid.v4();
final compressedFiles = files != null ? await ImageCompressUtility.compressFilesList(List<File>.from(files)) : null; final compressedFiles = files != null
AudioPlayer().play((AssetSource('audio/send_message.wav'))); ? await ImageCompressUtility.compressFilesList(List<File>.from(files))
: null;
try {
unawaited(AudioPlayer().play(AssetSource('audio/send_message.wav')));
} catch (_) {}
// Создаем временное сообщение с состоянием loading _emitLoaded(
emit( (current) => current.copyWith(
(state as ChatRoomStateLoaded).copyWith(
chatMessages: [ chatMessages: [
ChatMessageModel( ChatMessageModel(
id: 1, id: -messageUuid.hashCode.abs(),
data: data ?? '', data: data ?? '',
mediaData: compressedFiles, mediaData: compressedFiles,
authorId: 1, authorId: wsManager.userId ?? 0,
updatedAt: DateTime.now(), updatedAt: DateTime.now(),
isRead: true, isRead: true,
isCurrentUserAuthor: true, isCurrentUserAuthor: true,
roomId: '', roomId: current.chatRoom.id,
messageState: MessageState.loading, messageState: MessageState.loading,
uuid: messageUuid, uuid: messageUuid,
), ),
...(state as ChatRoomStateLoaded).chatMessages, ...current.chatMessages,
], ],
), ),
); );
try { try {
// Отправляем сообщение и получаем ответ от сервера с правильными данными final serverMessage = await chatRepository.sendMessage(
final serverMessage = await chatRepository.sendMessage(data: data, files: compressedFiles, slug: userSlug); data: data,
files: compressedFiles,
// Заменяем локальное сообщение данными с сервера (с правильными URL файлов) slug: userSlug,
emit( );
(state as ChatRoomStateLoaded).copyWith( _replaceOptimisticMessage(
chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) { messageUuid: messageUuid,
if (elem.uuid == messageUuid) { serverMessage: serverMessage,
// Сохраняем UUID, состояние и обновляем данные из ответа сервера
// Используем значения из локального сообщения для полей, которые могут отсутствовать в ответе
return serverMessage.copyWith(
uuid: messageUuid,
messageState: MessageState.sent,
isCurrentUserAuthor: true,
isRead: elem.isRead,
roomId: elem.roomId ?? serverMessage.roomId,
authorId: serverMessage.authorId,
updatedAt: serverMessage.updatedAt,
);
}
return elem;
}).toList(),
),
); );
} catch (e, st) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
emit( _emitLoaded(
(state as ChatRoomStateLoaded).copyWith( (current) => current.copyWith(
chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) { chatMessages: current.chatMessages.map((elem) {
if (elem.uuid == messageUuid) { if (elem.uuid == messageUuid) {
return elem.copyWith(messageState: MessageState.error); return elem.copyWith(messageState: MessageState.error);
} }
@@ -411,21 +618,25 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
} }
} }
Future<bool> markMessagesAsRead(String chatId, List<({int id, int index})> messagesIds) async { Future<bool> markMessagesAsRead(String chatId, List<int> messageIds) async {
try { try {
if (state is ChatRoomStateLoaded) { if (state is! ChatRoomStateLoaded) {
final ids = messagesIds.map((e) => e.id).toList(); return false;
await chatRepository.markMessagesAsRead(chatId, ids);
final currentMessages = [...(state as ChatRoomStateLoaded).chatMessages];
for (var elem in messagesIds) {
currentMessages[elem.index] = currentMessages[elem.index].copyWith(isRead: true);
}
if (!isClosed) {
emit((state as ChatRoomStateLoaded).copyWith(chatMessages: currentMessages));
}
return true;
} }
return false; await chatRepository.markMessagesAsRead(chatId, messageIds);
final idSet = messageIds.toSet();
_emitLoaded(
(current) => current.copyWith(
chatMessages: current.chatMessages
.map(
(message) => idSet.contains(message.id)
? message.copyWith(isRead: true)
: message,
)
.toList(),
),
);
return true;
} catch (e, st) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
log(e.toString(), stackTrace: st); log(e.toString(), stackTrace: st);
@@ -433,11 +644,52 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
} }
} }
void _replaceOptimisticMessage({
required String messageUuid,
required ChatMessageModel serverMessage,
}) {
_emitLoaded((current) {
final sentMessage = serverMessage.copyWith(
uuid: messageUuid,
messageState: MessageState.sent,
isCurrentUserAuthor: true,
);
final withoutDuplicate = current.chatMessages.where(
(message) =>
message.id != serverMessage.id || message.uuid == messageUuid,
);
final hasOptimistic = withoutDuplicate.any(
(message) => message.uuid == messageUuid,
);
if (!hasOptimistic) {
return current.copyWith(
chatMessages: [sentMessage, ...withoutDuplicate],
);
}
return current.copyWith(
chatMessages: withoutDuplicate.map((elem) {
if (elem.uuid == messageUuid) {
return sentMessage.copyWith(
isRead: elem.isRead,
roomId: elem.roomId?.isNotEmpty == true
? elem.roomId
: serverMessage.roomId,
);
}
return elem;
}).toList(),
);
});
}
Future<String?> getPaymentLink() async { Future<String?> getPaymentLink() async {
try { try {
final planId = (state as ChatRoomStateLoaded).chatRoom.chosenPlan?.id; final planId = (state as ChatRoomStateLoaded).chatRoom.chosenPlan?.id;
if (planId == null) return null; if (planId == null) return null;
final paymentModel = await chatRepository.getPaymentLink(id: planId, paymentProvider: 'alfa-bank'); final paymentModel = await chatRepository.getPaymentLink(
id: planId,
paymentProvider: 'alfa-bank',
);
return paymentModel.paymentUrl; return paymentModel.paymentUrl;
} catch (e, st) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
@@ -477,91 +729,93 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
@override @override
Future<void> close() async { Future<void> close() async {
EasyDebounce.cancel('client-typing');
EasyDebounce.cancel('searchChatRoom');
if (_presenceOnlineListener != null) { if (_presenceOnlineListener != null) {
wsManager.removePresenceDirectChatListener(_presenceOnlineListener!); wsManager.removePresenceDirectChatListener(_presenceOnlineListener!);
_presenceOnlineListener = null; _presenceOnlineListener = null;
} }
wsManager.unsubscribeDirectChatChannel(); wsManager.unsubscribeDirectChatChannel();
_timer?.cancel(); _timer?.cancel();
messagesSubscription?.cancel(); await messagesSubscription?.cancel();
typingSubscription?.cancel(); await typingSubscription?.cancel();
await _reconnectSubscription?.cancel();
super.close(); super.close();
} }
void deleteMessage(String uuid) { void deleteMessage(String uuid) {
final currentState = state; final currentState = state;
if (currentState is ChatRoomStateLoaded) { if (currentState is ChatRoomStateLoaded) {
emit(currentState.copyWith(chatMessages: currentState.chatMessages.where((message) => message.uuid != uuid).toList())); emit(
currentState.copyWith(
chatMessages: currentState.chatMessages
.where((message) => message.uuid != uuid)
.toList(),
),
);
} }
} }
Future<void> resendMessage({required String uuid, required String userSlug}) async { Future<void> resendMessage({
required String uuid,
required String userSlug,
}) async {
final currentState = state; final currentState = state;
if (currentState is ChatRoomStateLoaded) { if (currentState is ChatRoomStateLoaded) {
final message = currentState.chatMessages.firstWhereOrNull((message) => message.uuid == uuid); final message = currentState.chatMessages.firstWhereOrNull(
(message) => message.uuid == uuid,
);
if (message != null) { if (message != null) {
try { try {
AudioPlayer().play((AssetSource('audio/send_message.wav'))); try {
unawaited(
AudioPlayer().play(AssetSource('audio/send_message.wav')),
);
} catch (_) {}
// Определяем, есть ли файлы для повторной отправки
List<File>? filesToResend; List<File>? filesToResend;
if (message.mediaData != null && message.mediaData!.isNotEmpty) { if (message.mediaData != null && message.mediaData!.isNotEmpty) {
// Если mediaData содержит File объекты, используем их
// Если это строки (URL), то файлы уже на сервере и повторная отправка не нужна
final firstItem = message.mediaData!.first; final firstItem = message.mediaData!.first;
if (firstItem is File) { if (firstItem is File) {
filesToResend = message.mediaData!.cast<File>(); filesToResend = message.mediaData!.cast<File>();
} }
} }
emit( _emitLoaded(
(state as ChatRoomStateLoaded).copyWith( (current) => current.copyWith(
chatMessages: [ chatMessages: [
ChatMessageModel( ChatMessageModel(
id: 1, id: -uuid.hashCode.abs(),
data: message.data, data: message.data,
mediaData: message.mediaData, mediaData: message.mediaData,
authorId: 1, authorId: wsManager.userId ?? 0,
updatedAt: DateTime.now(), updatedAt: DateTime.now(),
isRead: true, isRead: true,
isCurrentUserAuthor: true, isCurrentUserAuthor: true,
roomId: '', roomId: current.chatRoom.id,
messageState: MessageState.loading, messageState: MessageState.loading,
uuid: uuid, uuid: uuid,
), ),
...(state as ChatRoomStateLoaded).chatMessages.where((message) => message.uuid != uuid), ...current.chatMessages.where((item) => item.uuid != uuid),
], ],
), ),
); );
try { try {
// Отправляем сообщение и получаем ответ от сервера final serverMessage = await chatRepository.sendMessage(
final serverMessage = await chatRepository.sendMessage(data: message.data, files: filesToResend, slug: userSlug); data: message.data,
files: filesToResend,
// Заменяем локальное сообщение данными с сервера slug: userSlug,
emit( );
(state as ChatRoomStateLoaded).copyWith( _replaceOptimisticMessage(
chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) { messageUuid: uuid,
if (elem.uuid == uuid) { serverMessage: serverMessage,
return serverMessage.copyWith(
uuid: uuid,
messageState: MessageState.sent,
isCurrentUserAuthor: true,
isRead: elem.isRead,
roomId: elem.roomId ?? serverMessage.roomId,
authorId: serverMessage.authorId,
updatedAt: serverMessage.updatedAt,
);
}
return elem;
}).toList(),
),
); );
} catch (e, st) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
emit( _emitLoaded(
(state as ChatRoomStateLoaded).copyWith( (current) => current.copyWith(
chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) { chatMessages: current.chatMessages.map((elem) {
if (elem.uuid == uuid) { if (elem.uuid == uuid) {
return elem.copyWith(messageState: MessageState.error); return elem.copyWith(messageState: MessageState.error);
} }

View File

@@ -2,12 +2,20 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:developer'; import 'dart:developer';
import 'package:eatvio_app/core/device/device_hw_id_provider.dart';
import 'package:eatvio_app/core/enums/base_state.dart'; import 'package:eatvio_app/core/enums/base_state.dart';
import 'package:eatvio_app/core/websockets/ws_manager.dart'; import 'package:eatvio_app/core/websockets/ws_manager.dart';
import 'package:eatvio_app/features/chat/models/chat_message_model.dart'; import 'package:eatvio_app/features/chat/models/chat_message_model.dart';
import 'package:eatvio_app/features/chat/models/chat_room_pagination_model.dart';
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_state.dart'; import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_state.dart';
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart'; import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart'; import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_chat_signal.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_end_reason.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_ended_push_handler.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_socket_signal.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_incoming_push_presenter.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:eatvio_app/core/error_reporting/report_from.dart'; import 'package:eatvio_app/core/error_reporting/report_from.dart';
@@ -17,13 +25,31 @@ class ChatsCubit extends Cubit<ChatsState> {
final WsManager wsManager; final WsManager wsManager;
StreamSubscription? newIncomingMessagesSubscription; StreamSubscription? newIncomingMessagesSubscription;
StreamSubscription? newLastMessageSubscription; StreamSubscription? newLastMessageSubscription;
StreamSubscription<void>? reconnectedSubscription;
StreamSubscription? videoCallDeclinedSubscription;
StreamSubscription? videoCallEndedSubscription;
StreamSubscription? videoCallAcceptedSubscription;
final Set<int> _presentedVideoCallMessageIds = {};
/// Test seam. Production uses CallKit.
Future<void> Function(VideoCallPushPayload payload)? presentIncomingVideoCall;
/// Test seam. Production reads [DeviceHwIdProvider.current].
Future<String?> Function()? currentDeviceHwId;
Future<void> Function(String? conversationUuid)? dismissIncomingVideoCall;
Future<void> Function({String? conversationUuid, String? callId})?
endActiveVideoCall;
ChatsCubit({ ChatsCubit({
required this.chatRepository, required this.chatRepository,
required this.wsManager, required this.wsManager,
required this.profileRepository, required this.profileRepository,
this.presentIncomingVideoCall,
this.dismissIncomingVideoCall,
}) : super(ChatsState()) { }) : super(ChatsState()) {
newLastMessageSubscription = chatRepository.newUserMessagesStream.listen((message) { newLastMessageSubscription = chatRepository.newUserMessagesStream.listen((
message,
) {
_setNewLastMessage(message); _setNewLastMessage(message);
}); });
} }
@@ -34,9 +60,47 @@ class ChatsCubit extends Cubit<ChatsState> {
Future<void> connectToWebSocket() async { Future<void> connectToWebSocket() async {
try { try {
await newIncomingMessagesSubscription?.cancel(); await newIncomingMessagesSubscription?.cancel();
newIncomingMessagesSubscription = wsManager.newMessagesStream?.listen((event) { newIncomingMessagesSubscription = wsManager.newMessagesStream?.listen((
event,
) {
_handleNewMessageViaSocket(event.data); _handleNewMessageViaSocket(event.data);
}); });
await videoCallDeclinedSubscription?.cancel();
videoCallDeclinedSubscription = wsManager.videoCallDeclinedStream?.listen(
(event) {
unawaited(
_closeCallFromSocket(
VideoCallSocketSignal.tryParseDeclined(event.data),
),
);
},
);
await videoCallEndedSubscription?.cancel();
videoCallEndedSubscription = wsManager.videoCallEndedStream?.listen((
event,
) {
unawaited(
_closeCallFromSocket(VideoCallSocketSignal.tryParseEnded(event.data)),
);
});
await videoCallAcceptedSubscription?.cancel();
videoCallAcceptedSubscription = wsManager.videoCallAcceptedStream?.listen(
(event) {
unawaited(
_closeCallFromSocket(
VideoCallSocketSignal.tryParseEnded(event.data),
),
);
},
);
await reconnectedSubscription?.cancel();
reconnectedSubscription = wsManager.reconnected.listen((_) {
unawaited(getChatRooms());
});
} catch (e, st) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
} }
@@ -47,7 +111,9 @@ class ChatsCubit extends Cubit<ChatsState> {
_query = query; _query = query;
emit(state.copyWith(state: BaseState.loading)); emit(state.copyWith(state: BaseState.loading));
try { try {
final chatRoomsPaginationModel = await chatRepository.getChatRooms(query: query); final chatRoomsPaginationModel = await chatRepository.getChatRooms(
query: query,
);
final unreadChats = chatRoomsPaginationModel.unreadChatsCount; final unreadChats = chatRoomsPaginationModel.unreadChatsCount;
lastPage = chatRoomsPaginationModel.meta.lastPage + 1; lastPage = chatRoomsPaginationModel.meta.lastPage + 1;
emit( emit(
@@ -60,9 +126,7 @@ class ChatsCubit extends Cubit<ChatsState> {
page++; page++;
} catch (e, st) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
emit( emit(state.copyWith(errorMessage: e.toString(), state: BaseState.error));
state.copyWith(errorMessage: e.toString(), state: BaseState.error),
);
} }
} }
@@ -70,7 +134,9 @@ class ChatsCubit extends Cubit<ChatsState> {
page = 1; page = 1;
_query = query; _query = query;
try { try {
final chatRoomsPaginationModel = await chatRepository.getChatRooms(query: query); final chatRoomsPaginationModel = await chatRepository.getChatRooms(
query: query,
);
final unreadChats = chatRoomsPaginationModel.unreadChatsCount; final unreadChats = chatRoomsPaginationModel.unreadChatsCount;
lastPage = chatRoomsPaginationModel.meta.lastPage + 1; lastPage = chatRoomsPaginationModel.meta.lastPage + 1;
emit( emit(
@@ -84,9 +150,7 @@ class ChatsCubit extends Cubit<ChatsState> {
} catch (e, st) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
log(e.toString(), stackTrace: st); log(e.toString(), stackTrace: st);
emit( emit(state.copyWith(errorMessage: e.toString(), state: BaseState.error));
state.copyWith(errorMessage: e.toString(), state: BaseState.error),
);
} }
} }
@@ -94,7 +158,10 @@ class ChatsCubit extends Cubit<ChatsState> {
try { try {
if (state.isLoadingMoreChats || lastPage == page) return; if (state.isLoadingMoreChats || lastPage == page) return;
emit(state.copyWith(isLoadingMoreChats: true)); emit(state.copyWith(isLoadingMoreChats: true));
final chatRoomsPaginationModel = await chatRepository.getChatRooms(query: _query, page: page); final chatRoomsPaginationModel = await chatRepository.getChatRooms(
query: _query,
page: page,
);
final unreadChats = chatRoomsPaginationModel.unreadChatsCount; final unreadChats = chatRoomsPaginationModel.unreadChatsCount;
emit( emit(
state.copyWith( state.copyWith(
@@ -134,15 +201,21 @@ class ChatsCubit extends Cubit<ChatsState> {
emit( emit(
state.copyWith( state.copyWith(
chatRooms: updatedChatRooms, chatRooms: updatedChatRooms,
chatsWithUnreadMessagesCount: isChatRead ? state.chatsWithUnreadMessagesCount - 1 : state.chatsWithUnreadMessagesCount, chatsWithUnreadMessagesCount: isChatRead
? state.chatsWithUnreadMessagesCount - 1
: state.chatsWithUnreadMessagesCount,
), ),
); );
} }
Future<void> _handleNewMessageViaSocket(dynamic data) async { Future<void> _handleNewMessageViaSocket(dynamic data) async {
try { try {
final json = jsonDecode(data); final json = jsonDecode(data) as Map<String, dynamic>;
final message = ChatMessageModel.fromJson(json['message']); final message = ChatMessageModel.fromJson(json);
final callId = _stringField(json, const ['call_id', 'callId']);
final currentUserId = wsManager.userId;
final isOwn = currentUserId != null && message.authorId == currentUserId;
final currentChatRooms = [...state.chatRooms]; final currentChatRooms = [...state.chatRooms];
@@ -151,10 +224,21 @@ class ChatsCubit extends Cubit<ChatsState> {
final updatedChatRooms = currentChatRooms.map((chatRoom) { final updatedChatRooms = currentChatRooms.map((chatRoom) {
if (chatRoom.id == message.roomId) { if (chatRoom.id == message.roomId) {
wasChanged = true; wasChanged = true;
if (chatRoom.unreadMessagesCount == 0) { // Идемпотентность: повторная доставка того же message.id (реконнект,
// доставка по нескольким каналам) не начисляет непрочитанное дважды.
final isDuplicate =
message.id != 0 && chatRoom.lastMessage?.id == message.id;
// Собственное сообщение (второе устройство автора) обновляет
// lastMessage, но не создаёт непрочитанное (S3).
if (!isOwn && !isDuplicate && chatRoom.unreadMessagesCount == 0) {
willIncrementUnreadChats = true; willIncrementUnreadChats = true;
} }
return chatRoom.copyWith(lastMessage: message, unreadMessagesCount: chatRoom.unreadMessagesCount + 1); return chatRoom.copyWith(
lastMessage: message,
unreadMessagesCount: isOwn || isDuplicate
? chatRoom.unreadMessagesCount
: chatRoom.unreadMessagesCount + 1,
);
} }
return chatRoom; return chatRoom;
}).toList(); }).toList();
@@ -165,15 +249,154 @@ class ChatsCubit extends Cubit<ChatsState> {
emit( emit(
state.copyWith( state.copyWith(
chatRooms: updatedChatRooms, chatRooms: updatedChatRooms,
chatsWithUnreadMessagesCount: willIncrementUnreadChats ? state.chatsWithUnreadMessagesCount + 1 : state.chatsWithUnreadMessagesCount, chatsWithUnreadMessagesCount: willIncrementUnreadChats
? state.chatsWithUnreadMessagesCount + 1
: state.chatsWithUnreadMessagesCount,
), ),
); );
} }
_presentVideoCallFromMessage(
message,
isOwn: isOwn,
chatRooms: currentChatRooms,
callId: callId,
);
} catch (e, st) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
} }
} }
void _presentVideoCallFromMessage(
ChatMessageModel message, {
required bool isOwn,
required List<ChatRoom> chatRooms,
String? callId,
}) {
final kind = VideoCallChatSignal.kindOf(message);
if (kind == VideoCallChatSignalKind.ended) {
unawaited(_dismissIncomingVideoCallUi(message.roomId));
unawaited(
VideoCallEndedPushHandler.endActiveCallIfMatches(
conversationUuid: message.roomId,
),
);
return;
}
if (isOwn || kind != VideoCallChatSignalKind.started) return;
if (message.id != 0 && !_presentedVideoCallMessageIds.add(message.id)) {
return;
}
final caller = _callerFromRooms(message, chatRooms);
final payload = VideoCallChatSignal.incomingPayload(
message: message,
callerName: caller?.name,
callerAvatarUrl: caller?.profilePhotoUrl,
callId: callId,
);
if (payload == null) return;
unawaited(_showIncomingVideoCall(payload));
}
Future<void> _showIncomingVideoCall(VideoCallPushPayload payload) async {
try {
await (presentIncomingVideoCall ??
VideoCallIncomingPushPresenter.showIncomingCall)(payload);
} catch (e, st) {
log('ChatsCubit: failed to show incoming video call: $e', stackTrace: st);
}
}
Future<void> _closeCallFromSocket(VideoCallSocketSignal? signal) async {
if (signal == null) return;
if (await _acceptedOnThisDevice(signal)) return;
if (signal.endsActiveCall) {
final endActive = endActiveVideoCall;
if (endActive != null) {
await endActive(
conversationUuid: signal.conversationUuid,
callId: signal.callId,
);
} else {
await VideoCallEndedPushHandler.endActiveCallIfMatches(
conversationUuid: signal.conversationUuid,
callId: signal.callId,
);
}
}
final dismiss = dismissIncomingVideoCall;
if (dismiss != null) {
await dismiss(signal.conversationUuid);
return;
}
await VideoCallEndedPushHandler.dismissIncomingCallUi(
conversationUuid: signal.conversationUuid,
callId: signal.callId,
reason: signal.reason,
);
}
Future<void> _dismissIncomingVideoCallUi(String? conversationUuid) async {
try {
final dismiss = dismissIncomingVideoCall;
if (dismiss != null) {
await dismiss(conversationUuid);
return;
}
await VideoCallEndedPushHandler.dismissIncomingCallUi(
conversationUuid: conversationUuid,
);
} catch (e, st) {
log(
'ChatsCubit: failed to dismiss incoming video call: $e',
stackTrace: st,
);
}
}
/// `video-call-accepted` also reaches the device that just answered.
/// Closing CallKit there hangs up the call that is starting.
Future<bool> _acceptedOnThisDevice(VideoCallSocketSignal signal) async {
if (signal.reason != VideoCallEndReason.accepted) return false;
final acceptedId = signal.acceptedDeviceId?.trim();
if (acceptedId == null || acceptedId.isEmpty) return false;
try {
final current =
(await (currentDeviceHwId ?? DeviceHwIdProvider.current)())?.trim();
if (current == null || current.isEmpty) return false;
return current.toLowerCase() == acceptedId.toLowerCase();
} catch (e, st) {
log(
'ChatsCubit: failed to read hw id for accepted call: $e',
stackTrace: st,
);
return false;
}
}
String? _stringField(Map<String, dynamic> json, List<String> keys) {
for (final key in keys) {
final value = json[key];
if (value is String && value.trim().isNotEmpty) return value.trim();
}
return null;
}
ChatUser? _callerFromRooms(
ChatMessageModel message,
List<ChatRoom> chatRooms,
) {
for (final room in chatRooms) {
if (room.id != message.roomId) continue;
final from = room.users.fromChatUser;
final to = room.users.toChatUser;
if (from.id == message.authorId) return from;
if (to.id == message.authorId) return to;
}
return null;
}
void _setNewLastMessage(ChatMessageModel message) { void _setNewLastMessage(ChatMessageModel message) {
final chats = state.chatRooms.map((chat) { final chats = state.chatRooms.map((chat) {
if (chat.id == message.roomId) { if (chat.id == message.roomId) {
@@ -188,6 +411,10 @@ class ChatsCubit extends Cubit<ChatsState> {
@override @override
Future<void> close() { Future<void> close() {
newIncomingMessagesSubscription?.cancel(); newIncomingMessagesSubscription?.cancel();
videoCallDeclinedSubscription?.cancel();
videoCallEndedSubscription?.cancel();
videoCallAcceptedSubscription?.cancel();
reconnectedSubscription?.cancel();
return super.close(); return super.close();
} }
} }

View File

@@ -30,6 +30,7 @@ import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
import 'package:eatvio_app/features/order_info/presentation/info_by_order_screen.dart'; import 'package:eatvio_app/features/order_info/presentation/info_by_order_screen.dart';
import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart'; import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart';
import 'package:eatvio_app/features/user_details/presentation/user_details_screen.dart'; import 'package:eatvio_app/features/user_details/presentation/user_details_screen.dart';
import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart';
import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart'; import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart';
import 'package:eatvio_app/generated/l10n.dart'; import 'package:eatvio_app/generated/l10n.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
@@ -46,7 +47,12 @@ class ChatScreen extends StatefulWidget {
final String? orderId; final String? orderId;
final bool? sendInitialMessage; final bool? sendInitialMessage;
const ChatScreen({this.uuid, this.orderId, super.key, required this.sendInitialMessage}); const ChatScreen({
this.uuid,
this.orderId,
super.key,
required this.sendInitialMessage,
});
@override @override
State<ChatScreen> createState() => _ChatScreenState(); State<ChatScreen> createState() => _ChatScreenState();
@@ -56,9 +62,9 @@ class _ChatScreenState extends State<ChatScreen> {
final scrollController = ScrollController(); final scrollController = ScrollController();
final TextEditingController textEditingController = TextEditingController(); final TextEditingController textEditingController = TextEditingController();
final ChatRoomCubit chatRoomCubit = sl<ChatRoomCubit>(); final ChatRoomCubit chatRoomCubit = sl<ChatRoomCubit>();
final List<({int id, int index})> unreadMessagesIds = []; final List<int> unreadMessageIds = [];
final List<({int id, int index})> unreadMessagesIdsCopy = []; final showableChatBadgeStates =
final showableChatBadgeStates = ChosenPlanStateEnum.getShowableChatBadgeStates(); ChosenPlanStateEnum.getShowableChatBadgeStates();
late AppLifecycleListener _listener; late AppLifecycleListener _listener;
List<File> pickedFiles = []; List<File> pickedFiles = [];
@@ -109,13 +115,15 @@ class _ChatScreenState extends State<ChatScreen> {
void _onScroll() { void _onScroll() {
final uuid = conversationUuid; final uuid = conversationUuid;
if (uuid == null) return; if (uuid == null) return;
if (scrollController.offset >= scrollController.position.maxScrollExtent && !scrollController.position.outOfRange) { if (scrollController.offset >= scrollController.position.maxScrollExtent &&
!scrollController.position.outOfRange) {
chatRoomCubit.loadNextPage(uuid); chatRoomCubit.loadNextPage(uuid);
} }
} }
@override @override
void dispose() { void dispose() {
EasyDebounce.cancel('mark_as_read');
scrollController.dispose(); scrollController.dispose();
_listener.dispose(); _listener.dispose();
chatRoomCubit.close(); chatRoomCubit.close();
@@ -168,9 +176,13 @@ class _ChatScreenState extends State<ChatScreen> {
} }
Scaffold _buildChat(BuildContext context, ChatRoomStateLoaded state) { Scaffold _buildChat(BuildContext context, ChatRoomStateLoaded state) {
final chatCounterpart = resolveChatCounterpart(users: state.chatRoom.users, currentUserId: sl<WsManager>().userId); final chatCounterpart = resolveChatCounterpart(
users: state.chatRoom.users,
currentUserId: sl<WsManager>().userId,
);
final chosenPlan = state.chatRoom.chosenPlan; final chosenPlan = state.chatRoom.chosenPlan;
final amIConsultant = chosenPlan?.consultantId == state.chatRoom.users.sender.id; final amIConsultant =
chosenPlan?.consultantId == state.chatRoom.users.sender.id;
final showUserDetailsButton = [ final showUserDetailsButton = [
ChosenPlanStateEnum.stateStartDateHasBeenSet, ChosenPlanStateEnum.stateStartDateHasBeenSet,
ChosenPlanStateEnum.statePendingPayment, ChosenPlanStateEnum.statePendingPayment,
@@ -186,22 +198,37 @@ class _ChatScreenState extends State<ChatScreen> {
} }
final showAboutOrderButton = final showAboutOrderButton =
state.chatRoom.chosenPlan != null && state.chatRoom.chosenPlan!.chosenPlanState != ChosenPlanStateEnum.stateCanceledByTheInitiator; state.chatRoom.chosenPlan != null &&
state.chatRoom.chosenPlan!.chosenPlanState !=
ChosenPlanStateEnum.stateCanceledByTheInitiator;
BannerType? bannerType; BannerType? bannerType;
if (chosenPlan == null || chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateCanceledByTheInitiator) { if (chosenPlan == null ||
chosenPlan.chosenPlanState ==
ChosenPlanStateEnum.stateCanceledByTheInitiator) {
bannerType = null; bannerType = null;
} else if (amIConsultant && !state.hasCard && chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateInCoordination) { } else if (amIConsultant &&
!state.hasCard &&
chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateInCoordination) {
bannerType = BannerType.card; bannerType = BannerType.card;
} else if (amIConsultant && chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateInCoordination) { } else if (amIConsultant &&
chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateInCoordination) {
bannerType = BannerType.date; bannerType = BannerType.date;
} else if (!amIConsultant && } else if (!amIConsultant &&
(chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateStartDateHasBeenSet || chosenPlan.chosenPlanState == ChosenPlanStateEnum.statePendingPayment)) { (chosenPlan.chosenPlanState ==
ChosenPlanStateEnum.stateStartDateHasBeenSet ||
chosenPlan.chosenPlanState ==
ChosenPlanStateEnum.statePendingPayment)) {
bannerType = BannerType.payment; bannerType = BannerType.payment;
} else if (chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateDone && amIConsultant) { } else if (chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateDone &&
amIConsultant) {
bannerType = BannerType.collectCharge; bannerType = BannerType.collectCharge;
} else if (chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateLastDayFinished && !amIConsultant) { } else if (chosenPlan.chosenPlanState ==
ChosenPlanStateEnum.stateLastDayFinished &&
!amIConsultant) {
bannerType = BannerType.finishPlan; bannerType = BannerType.finishPlan;
} else if (chosenOption?.hasPersonalDiet == true && amIConsultant && chosenPlan.chosenPlanState != ChosenPlanStateEnum.stateDone) { } else if (chosenOption?.hasPersonalDiet == true &&
amIConsultant &&
chosenPlan.chosenPlanState != ChosenPlanStateEnum.stateDone) {
bannerType = BannerType.setPersonalDiet; bannerType = BannerType.setPersonalDiet;
} }
@@ -223,25 +250,40 @@ class _ChatScreenState extends State<ChatScreen> {
title: GestureDetector( title: GestureDetector(
onTap: () { onTap: () {
GoRouter.of(context).pushNamed('profileScreen', extra: state.chatRoom.users.receiver.slug); GoRouter.of(context).pushNamed(
'profileScreen',
extra: state.chatRoom.users.receiver.slug,
);
}, },
child: Row( child: Row(
children: [ children: [
if (state.chatRoom.users.receiver.profilePhotoUrl != null) if (state.chatRoom.users.receiver.profilePhotoUrl != null)
CircleAvatar( CircleAvatar(
radius: 20, radius: 20,
foregroundImage: NetworkImage(state.chatRoom.users.receiver.profilePhotoUrl!), foregroundImage: NetworkImage(
backgroundImage: const AssetImage('assets/images/no_avatar.png'), state.chatRoom.users.receiver.profilePhotoUrl!,
),
backgroundImage: const AssetImage(
'assets/images/no_avatar.png',
),
), ),
if (state.chatRoom.users.receiver.profilePhotoUrl == null) if (state.chatRoom.users.receiver.profilePhotoUrl == null)
Container( Container(
width: 40, width: 40,
height: 40, height: 40,
alignment: Alignment.center, alignment: Alignment.center,
decoration: const BoxDecoration(color: Color(0xFFEBF4FF), shape: BoxShape.circle), decoration: const BoxDecoration(
color: Color(0xFFEBF4FF),
shape: BoxShape.circle,
),
child: Text( child: Text(
state.chatRoom.users.receiver.name.split(' ').first[0] + state.chatRoom.users.receiver.name.split(' ')[1][0], state.chatRoom.users.receiver.name.split(' ').first[0] +
style: const TextStyle(color: Color(0xFF7F9CF5), fontWeight: FontWeight.w400, fontSize: 20), state.chatRoom.users.receiver.name.split(' ')[1][0],
style: const TextStyle(
color: Color(0xFF7F9CF5),
fontWeight: FontWeight.w400,
fontSize: 20,
),
), ),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
@@ -253,10 +295,17 @@ class _ChatScreenState extends State<ChatScreen> {
state.chatRoom.users.receiver.name, state.chatRoom.users.receiver.name,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: CustomTextStyles.titleSmallBold.copyWith(color: PrimaryColors().black), style: CustomTextStyles.titleSmallBold.copyWith(
color: PrimaryColors().black,
),
), ),
AnimatedCrossFade( AnimatedCrossFade(
firstChild: Text('Печатает...', maxLines: 1, overflow: TextOverflow.ellipsis, style: CustomTextStyles.bodySmallMulishPrimary), firstChild: Text(
'Печатает...',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: CustomTextStyles.bodySmallMulishPrimary,
),
firstCurve: Curves.easeIn, firstCurve: Curves.easeIn,
secondCurve: Curves.easeOut, secondCurve: Curves.easeOut,
secondChild: ChatUserOnlineStatusLine( secondChild: ChatUserOnlineStatusLine(
@@ -265,7 +314,9 @@ class _ChatScreenState extends State<ChatScreen> {
style: CustomTextStyles.bodySmallMulishPrimary, style: CustomTextStyles.bodySmallMulishPrimary,
showOnlineDot: false, showOnlineDot: false,
), ),
crossFadeState: state.isTyping ? CrossFadeState.showFirst : CrossFadeState.showSecond, crossFadeState: state.isTyping
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
duration: const Duration(milliseconds: 150), duration: const Duration(milliseconds: 150),
), ),
], ],
@@ -277,7 +328,9 @@ class _ChatScreenState extends State<ChatScreen> {
actions: [ actions: [
Padding( Padding(
padding: const EdgeInsetsDirectional.only(end: 8), padding: const EdgeInsetsDirectional.only(end: 8),
child: _ChatHeaderVideoCallButton(onPressed: () => _openVideoCall(context, state, chatCounterpart)), child: _ChatHeaderVideoCallButton(
onPressed: () => _openVideoCall(context, state, chatCounterpart),
),
), ),
], ],
), ),
@@ -291,44 +344,133 @@ class _ChatScreenState extends State<ChatScreen> {
child: Container( child: Container(
color: appTheme.whiteA700, color: appTheme.whiteA700,
width: double.maxFinite, width: double.maxFinite,
child: SingleChildScrollView( child: RefreshIndicator(
physics: const BouncingScrollPhysics(), onRefresh: chatRoomCubit.refreshChat,
controller: scrollController, child: SingleChildScrollView(
reverse: true, physics: const AlwaysScrollableScrollPhysics(
child: Column( parent: BouncingScrollPhysics(),
children: [ ),
const SizedBox(height: 16), controller: scrollController,
ListView.builder( reverse: true,
physics: const NeverScrollableScrollPhysics(), child: Column(
reverse: true, children: [
shrinkWrap: true, const SizedBox(height: 16),
itemBuilder: (context, index) { ListView.builder(
if (index == state.chatMessages.length) { physics: const NeverScrollableScrollPhysics(),
return const Padding(padding: EdgeInsets.all(8), child: Loader()); reverse: true,
} shrinkWrap: true,
itemBuilder: (context, index) {
if (index == state.chatMessages.length) {
return const Padding(
padding: EdgeInsets.all(8),
child: Loader(),
);
}
if ((index + 1 != state.chatMessages.length && if ((index + 1 != state.chatMessages.length &&
state.chatMessages[index].updatedAt.day != state.chatMessages[index + 1].updatedAt.day) || state
index + 1 == state.chatMessages.length) { .chatMessages[index]
final isCurrentUserAuthor = state.chatRoom.users.receiver.id != state.chatMessages[index].authorId; .updatedAt
.day !=
state
.chatMessages[index + 1]
.updatedAt
.day) ||
index + 1 == state.chatMessages.length) {
final isCurrentUserAuthor =
state.chatRoom.users.receiver.id !=
state.chatMessages[index].authorId;
final message = state.chatMessages[index];
final isPlanOrderMessage =
message.type == ChatMessageType.planOrder;
PacketOptions? messageOption;
if (isPlanOrderMessage &&
chosenPlan != null) {
if (chosenPlan.optionType == 'options1') {
messageOption =
chosenPlan.plan.packet1Options;
} else if (chosenPlan.optionType ==
'options2') {
messageOption =
chosenPlan.plan.packet2Options;
} else if (chosenPlan.optionType ==
'options3') {
messageOption =
chosenPlan.plan.packet3Options;
}
}
return Column(
children: [
_messageDate(message.updatedAt),
if (isPlanOrderMessage &&
chosenPlan != null &&
messageOption != null)
PlanOrderMessageWidget(
planName: chosenPlan.plan.name,
planSlug: chosenPlan.plan.slug,
price: messageOption.price,
sendTime: message.updatedAt,
isCurrentUser: isCurrentUserAuthor,
),
VisibilityDetector(
key: ValueKey(
'msg-${message.uuid ?? message.id}',
),
onVisibilityChanged:
(VisibilityInfo info) {
_queueMarkAsRead(
context: context,
chatId: state.chatRoom.id,
message: message,
isCurrentUserAuthor:
isCurrentUserAuthor,
visibleFraction:
info.visibleFraction,
);
},
child: _buildMessageBubble(
context: context,
state: state,
chatCounterpart: chatCounterpart,
isCurrentUserAuthor:
isCurrentUserAuthor,
message: message,
amIConsultant: amIConsultant,
),
),
],
);
}
final isCurrentUserAuthor =
state.chatRoom.users.receiver.id !=
state.chatMessages[index].authorId;
final message = state.chatMessages[index]; final message = state.chatMessages[index];
final isPlanOrderMessage = message.type == ChatMessageType.planOrder; final isPlanOrderMessage =
message.type == ChatMessageType.planOrder;
PacketOptions? messageOption; PacketOptions? messageOption;
if (isPlanOrderMessage && chosenPlan != null) { if (isPlanOrderMessage && chosenPlan != null) {
if (chosenPlan.optionType == 'options1') { if (chosenPlan.optionType == 'options1') {
messageOption = chosenPlan.plan.packet1Options; messageOption =
} else if (chosenPlan.optionType == 'options2') { chosenPlan.plan.packet1Options;
messageOption = chosenPlan.plan.packet2Options; } else if (chosenPlan.optionType ==
} else if (chosenPlan.optionType == 'options3') { 'options2') {
messageOption = chosenPlan.plan.packet3Options; messageOption =
chosenPlan.plan.packet2Options;
} else if (chosenPlan.optionType ==
'options3') {
messageOption =
chosenPlan.plan.packet3Options;
} }
} }
return Column( return Column(
children: [ children: [
_messageDate(message.updatedAt), if (isPlanOrderMessage &&
if (isPlanOrderMessage && chosenPlan != null && messageOption != null) chosenPlan != null &&
messageOption != null)
PlanOrderMessageWidget( PlanOrderMessageWidget(
planName: chosenPlan.plan.name, planName: chosenPlan.plan.name,
planSlug: chosenPlan.plan.slug, planSlug: chosenPlan.plan.slug,
@@ -337,126 +479,94 @@ class _ChatScreenState extends State<ChatScreen> {
isCurrentUser: isCurrentUserAuthor, isCurrentUser: isCurrentUserAuthor,
), ),
VisibilityDetector( VisibilityDetector(
key: Key(message.id.toString()), key: ValueKey(
onVisibilityChanged: (VisibilityInfo info) { 'msg-${message.uuid ?? message.id}',
if (!message.isRead && !isCurrentUserAuthor && info.visibleFraction > 0) { ),
unreadMessagesIdsCopy.add((id: message.id, index: index)); onVisibilityChanged:
EasyDebounce.debounce('mark_as_read', const Duration(milliseconds: 500), () async { (VisibilityInfo info) {
final result = await context.read<ChatRoomCubit>().markMessagesAsRead(state.chatRoom.id, unreadMessagesIdsCopy); _queueMarkAsRead(
if (!result) return; context: context,
if (!context.mounted) return;
context.read<ChatsCubit>().reduceUnreadMessagesCount(
chatId: state.chatRoom.id, chatId: state.chatRoom.id,
count: unreadMessagesIdsCopy.length, message: message,
isCurrentUserAuthor:
isCurrentUserAuthor,
visibleFraction:
info.visibleFraction,
); );
unreadMessagesIdsCopy.clear(); },
});
}
},
child: _buildMessageBubble( child: _buildMessageBubble(
context: context, context: context,
state: state, state: state,
chatCounterpart: chatCounterpart, chatCounterpart: chatCounterpart,
isCurrentUserAuthor: isCurrentUserAuthor, isCurrentUserAuthor:
isCurrentUserAuthor,
message: message, message: message,
amIConsultant: amIConsultant, amIConsultant: amIConsultant,
), ),
), ),
], ],
); );
} },
final isCurrentUserAuthor = state.chatRoom.users.receiver.id != state.chatMessages[index].authorId; itemCount:
final message = state.chatMessages[index]; state.chatMessages.length +
final isPlanOrderMessage = message.type == ChatMessageType.planOrder; (state.isLoading ? 1 : 0),
),
PacketOptions? messageOption; const SizedBox(height: 16),
if (isPlanOrderMessage && chosenPlan != null) { if (showUserDetailsButton) ...[
if (chosenPlan.optionType == 'options1') { // OrderCard(
messageOption = chosenPlan.plan.packet1Options; // plan: chosenPlan.plan,
} else if (chosenPlan.optionType == 'options2') { // isCustomerView: !amIConsultant,
messageOption = chosenPlan.plan.packet2Options; // optionType: chosenPlan.optionType,
} else if (chosenPlan.optionType == 'options3') { // onCancel: () async {
messageOption = chosenPlan.plan.packet3Options; // await context.read<ChatRoomCubit>().cancelChosenPlan(chatId: state.chatRoom.id, planId: chosenPlan.id);
} // },
} // ),
if (amIConsultant)
return Column( Padding(
children: [ padding: const EdgeInsets.symmetric(
if (isPlanOrderMessage && chosenPlan != null && messageOption != null) vertical: 12,
PlanOrderMessageWidget( horizontal: 16,
planName: chosenPlan.plan.name, ),
planSlug: chosenPlan.plan.slug, child: CustomOutlinedButton(
price: messageOption.price, text: S.current.user_info,
sendTime: message.updatedAt, height: 52,
isCurrentUser: isCurrentUserAuthor, buttonTextStyle: TextStyles.textMSemibold
.copyWith(color: appTheme.whiteA700),
buttonStyle: ButtonStyle(
side: const WidgetStatePropertyAll(
BorderSide.none,
),
backgroundColor: WidgetStatePropertyAll(
appTheme.blueA400,
),
), ),
VisibilityDetector( onPressed: () async {
key: Key(message.id.toString()), GoRouter.of(context).push(
onVisibilityChanged: (VisibilityInfo info) { UserDetailsScreen.path,
if (!message.isRead && !isCurrentUserAuthor && info.visibleFraction > 0) { extra:
unreadMessagesIds.add((id: message.id, index: index)); state.chatRoom.users.receiver.slug,
EasyDebounce.debounce('mark_as_read', const Duration(milliseconds: 500), () async { );
final result = await context.read<ChatRoomCubit>().markMessagesAsRead(state.chatRoom.id, unreadMessagesIds);
if (!result) return;
if (!context.mounted) return;
context.read<ChatsCubit>().reduceUnreadMessagesCount(chatId: state.chatRoom.id, count: unreadMessagesIds.length);
unreadMessagesIds.clear();
});
}
}, },
child: _buildMessageBubble(
context: context,
state: state,
chatCounterpart: chatCounterpart,
isCurrentUserAuthor: isCurrentUserAuthor,
message: message,
amIConsultant: amIConsultant,
),
), ),
], ),
); ],
}, if (bannerType != null)
itemCount: state.chatMessages.length + (state.isLoading ? 1 : 0),
),
const SizedBox(height: 16),
if (showUserDetailsButton) ...[
// OrderCard(
// plan: chosenPlan.plan,
// isCustomerView: !amIConsultant,
// optionType: chosenPlan.optionType,
// onCancel: () async {
// await context.read<ChatRoomCubit>().cancelChosenPlan(chatId: state.chatRoom.id, planId: chosenPlan.id);
// },
// ),
if (amIConsultant)
Padding( Padding(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), padding: const EdgeInsets.symmetric(
child: CustomOutlinedButton( vertical: 12,
text: S.current.user_info, horizontal: 16,
height: 52, ),
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700), child: NotificationBanner(
buttonStyle: ButtonStyle( bannerType: bannerType,
side: const WidgetStatePropertyAll(BorderSide.none), chosenPlanId: chosenPlan?.id,
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400), planName: chosenPlan?.plan.name,
), planPrice: chosenOption?.price.toDouble(),
onPressed: () async { hasPersonalDiet:
GoRouter.of(context).push(UserDetailsScreen.path, extra: state.chatRoom.users.receiver.slug); chosenOption?.hasPersonalDiet ?? false,
},
), ),
), ),
], ],
if (bannerType != null) ),
Padding(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
child: NotificationBanner(
bannerType: bannerType,
chosenPlanId: chosenPlan?.id,
planName: chosenPlan?.plan.name,
planPrice: chosenOption?.price.toDouble(),
hasPersonalDiet: chosenOption?.hasPersonalDiet ?? false,
),
),
],
), ),
), ),
), ),
@@ -481,19 +591,40 @@ class _ChatScreenState extends State<ChatScreen> {
_isImageFile(pickedFiles[i].path) _isImageFile(pickedFiles[i].path)
? GestureDetector( ? GestureDetector(
onTap: () { onTap: () {
final imageFiles = pickedFiles.where((f) => _isImageFile(f.path)).toList(); final imageFiles = pickedFiles
final imageIndex = _imageIndexAt(pickedFiles, i); .where(
(f) => _isImageFile(f.path),
)
.toList();
final imageIndex = _imageIndexAt(
pickedFiles,
i,
);
context.pushTransparentRoute( context.pushTransparentRoute(
PhotoViewScreen(imageUrls: imageFiles.map((e) => e.path).toList(), isLocal: true, index: imageIndex), PhotoViewScreen(
imageUrls: imageFiles
.map((e) => e.path)
.toList(),
isLocal: true,
index: imageIndex,
),
); );
}, },
child: Hero( child: Hero(
tag: pickedFiles[i].path, tag: pickedFiles[i].path,
transitionOnUserGestures: true, transitionOnUserGestures: true,
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(8), borderRadius:
child: SizedBox(height: 104, width: 104, child: Image.file(pickedFiles[i], fit: BoxFit.cover)), BorderRadius.circular(8),
child: SizedBox(
height: 104,
width: 104,
child: Image.file(
pickedFiles[i],
fit: BoxFit.cover,
),
),
), ),
), ),
) )
@@ -510,8 +641,15 @@ class _ChatScreenState extends State<ChatScreen> {
}, },
child: Container( child: Container(
padding: const EdgeInsets.all(4), padding: const EdgeInsets.all(4),
decoration: BoxDecoration(color: appTheme.gray50, shape: BoxShape.circle), decoration: BoxDecoration(
child: const Icon(size: 20, Icons.close, color: Colors.black), color: appTheme.gray50,
shape: BoxShape.circle,
),
child: const Icon(
size: 20,
Icons.close,
color: Colors.black,
),
), ),
), ),
), ),
@@ -528,14 +666,20 @@ class _ChatScreenState extends State<ChatScreen> {
children: [ children: [
const SizedBox(width: 8), const SizedBox(width: 8),
InkWell( InkWell(
onTap: () => showImagePicker(state.chatRoom.users.receiver.slug), onTap: () => showImagePicker(
state.chatRoom.users.receiver.slug,
),
customBorder: const CircleBorder(), customBorder: const CircleBorder(),
child: Ink( child: Ink(
height: 40, height: 40,
width: 40, width: 40,
child: Transform.rotate( child: Transform.rotate(
angle: pi / 4, angle: pi / 4,
child: const Icon(Icons.attach_file_rounded, size: 30, color: Color(0xFF858E99)), child: const Icon(
Icons.attach_file_rounded,
size: 30,
color: Color(0xFF858E99),
),
), ),
), ),
), ),
@@ -547,24 +691,46 @@ class _ChatScreenState extends State<ChatScreen> {
maxLines: 3, maxLines: 3,
minLines: 1, minLines: 1,
keyboardType: TextInputType.multiline, keyboardType: TextInputType.multiline,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, color: Colors.black, height: 1), style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w400,
color: Colors.black,
height: 1,
),
decoration: InputDecoration( decoration: InputDecoration(
isDense: true, isDense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
hintText: 'Сообщение', hintText: 'Сообщение',
hintStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w400, height: 1, color: Color(0xFFAEAEB2)), hintStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400,
height: 1,
color: Color(0xFFAEAEB2),
),
filled: true, filled: true,
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(100), borderRadius: BorderRadius.circular(100),
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1), borderSide: const BorderSide(
color: Color(0xFFD1D1D6),
width: 1,
),
), ),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(100), borderRadius: BorderRadius.circular(100),
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1), borderSide: const BorderSide(
color: Color(0xFFD1D1D6),
width: 1,
),
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(100), borderRadius: BorderRadius.circular(100),
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1), borderSide: const BorderSide(
color: Color(0xFFD1D1D6),
width: 1,
),
), ),
), ),
), ),
@@ -579,12 +745,18 @@ class _ChatScreenState extends State<ChatScreen> {
final blockedState = blockedUsersCubit.state; final blockedState = blockedUsersCubit.state;
if (blockedState is BlockedUsersStateLoaded) { if (blockedState is BlockedUsersStateLoaded) {
final isBlocked = blockedState.profilesList.any((profile) => profile.slug == state.chatRoom.users.receiver.slug); final isBlocked = blockedState.profilesList.any(
(profile) =>
profile.slug ==
state.chatRoom.users.receiver.slug,
);
if (isBlocked) { if (isBlocked) {
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text('Вы не можете отправить сообщение заблокированному пользователю'), content: Text(
'Вы не можете отправить сообщение заблокированному пользователю',
),
backgroundColor: Colors.red, backgroundColor: Colors.red,
), ),
); );
@@ -593,10 +765,15 @@ class _ChatScreenState extends State<ChatScreen> {
} }
} }
if (textEditingController.text.isNotEmpty || pickedFiles.isNotEmpty) { if (textEditingController.text.isNotEmpty ||
pickedFiles.isNotEmpty) {
chatRoomCubit.sendMessage( chatRoomCubit.sendMessage(
data: textEditingController.text.isEmpty ? null : textEditingController.text.trim(), data: textEditingController.text.isEmpty
files: pickedFiles.isEmpty ? null : pickedFiles, ? null
: textEditingController.text.trim(),
files: pickedFiles.isEmpty
? null
: pickedFiles,
userSlug: state.chatRoom.users.receiver.slug, userSlug: state.chatRoom.users.receiver.slug,
); );
setState(() { setState(() {
@@ -606,10 +783,17 @@ class _ChatScreenState extends State<ChatScreen> {
} }
}, },
child: Ink( child: Ink(
decoration: BoxDecoration(color: appTheme.blueA400, shape: BoxShape.circle), decoration: BoxDecoration(
color: appTheme.blueA400,
shape: BoxShape.circle,
),
height: 40, height: 40,
width: 40, width: 40,
child: const Icon(Icons.arrow_upward_rounded, size: 24, color: Colors.white), child: const Icon(
Icons.arrow_upward_rounded,
size: 24,
color: Colors.white,
),
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -628,12 +812,19 @@ class _ChatScreenState extends State<ChatScreen> {
child: AboutOrderButton( child: AboutOrderButton(
onTap: () async { onTap: () async {
if (amIConsultant) { if (amIConsultant) {
GoRouter.of( GoRouter.of(context).push(
context, InfoByOrderScreen.path,
).push(InfoByOrderScreen.path, extra: {'planId': state.chatRoom.chosenPlan?.id, 'customerSlug': state.chatRoom.users.receiver.slug}); extra: {
'planId': state.chatRoom.chosenPlan?.id,
'customerSlug': state.chatRoom.users.receiver.slug,
},
);
return; return;
} }
GoRouter.of(context).push(InfoByOrderScreen.path, extra: {'planId': state.chatRoom.chosenPlan?.id}); GoRouter.of(context).push(
InfoByOrderScreen.path,
extra: {'planId': state.chatRoom.chosenPlan?.id},
);
}, },
), ),
), ),
@@ -658,7 +849,13 @@ class _ChatScreenState extends State<ChatScreen> {
const Icon(Icons.attach_file, size: 32, color: Colors.black54), const Icon(Icons.attach_file, size: 32, color: Colors.black54),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 4), padding: const EdgeInsets.symmetric(horizontal: 4),
child: Text(fileName, textAlign: TextAlign.center, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12)), child: Text(
fileName,
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12),
),
), ),
], ],
), ),
@@ -706,7 +903,13 @@ class _ChatScreenState extends State<ChatScreen> {
// await chatRoomCubit.sendMessage(files: [File(image.path)], userSlug: slug); // await chatRoomCubit.sendMessage(files: [File(image.path)], userSlug: slug);
// } // }
}, },
child: const Row(children: [Icon(Icons.photo_camera), SizedBox(width: 8), Text('Фото')]), child: const Row(
children: [
Icon(Icons.photo_camera),
SizedBox(width: 8),
Text('Фото'),
],
),
), ),
CupertinoActionSheetAction( CupertinoActionSheetAction(
onPressed: () async { onPressed: () async {
@@ -715,13 +918,21 @@ class _ChatScreenState extends State<ChatScreen> {
List<XFile> images = await picker.pickMultiImage(); List<XFile> images = await picker.pickMultiImage();
if (images != []) { if (images != []) {
setState(() { setState(() {
pickedFiles.addAll(images.map((image) => File(image.path)).toList()); pickedFiles.addAll(
images.map((image) => File(image.path)).toList(),
);
}); });
// List<File> files = images.map((image) => File(image.path)).toList(); // List<File> files = images.map((image) => File(image.path)).toList();
// await chatRoomCubit.sendMessage(files: files, userSlug: slug); // await chatRoomCubit.sendMessage(files: files, userSlug: slug);
} }
}, },
child: const Row(children: [Icon(Icons.photo_library_rounded), SizedBox(width: 8), Text('Галерея')]), child: const Row(
children: [
Icon(Icons.photo_library_rounded),
SizedBox(width: 8),
Text('Галерея'),
],
),
), ),
CupertinoActionSheetAction( CupertinoActionSheetAction(
onPressed: () async { onPressed: () async {
@@ -753,15 +964,27 @@ class _ChatScreenState extends State<ChatScreen> {
if (result != null) { if (result != null) {
// List<File> files = result.paths.map((path) => File(path!)).toList(); // List<File> files = result.paths.map((path) => File(path!)).toList();
setState(() { setState(() {
pickedFiles.addAll(result.paths.map((path) => File(path!)).toList()); pickedFiles.addAll(
result.paths.map((path) => File(path!)).toList(),
);
}); });
// await chatRoomCubit.sendMessage(files: files, userSlug: slug); // await chatRoomCubit.sendMessage(files: files, userSlug: slug);
} }
}, },
child: const Row(children: [Icon(Icons.file_present_rounded), SizedBox(width: 8), Text('Файлы')]), child: const Row(
children: [
Icon(Icons.file_present_rounded),
SizedBox(width: 8),
Text('Файлы'),
],
),
), ),
], ],
cancelButton: CupertinoActionSheetAction(onPressed: () => Navigator.of(context).pop(), isDestructiveAction: true, child: const Text('Отмена')), cancelButton: CupertinoActionSheetAction(
onPressed: () => Navigator.of(context).pop(),
isDestructiveAction: true,
child: const Text('Отмена'),
),
), ),
); );
} }
@@ -772,13 +995,22 @@ class _ChatScreenState extends State<ChatScreen> {
padding: const EdgeInsets.only(top: 32), padding: const EdgeInsets.only(top: 32),
child: Text( child: Text(
DateFormat.yMMMMd('RU').format(date), DateFormat.yMMMMd('RU').format(date),
style: const TextStyle(height: 1, color: Color(0xFF8393A1), fontWeight: FontWeight.w500), style: const TextStyle(
height: 1,
color: Color(0xFF8393A1),
fontWeight: FontWeight.w500,
),
), ),
), ),
); );
} }
void _openVideoCall(BuildContext context, ChatRoomStateLoaded state, ChatUserModel chatCounterpart) { void _openVideoCall(
BuildContext context,
ChatRoomStateLoaded state,
ChatUserModel chatCounterpart,
) {
sl<VideoCallManager>().permitParticipantTokenRequest();
GoRouter.of(context).pushNamed( GoRouter.of(context).pushNamed(
VideoCallScreen.name, VideoCallScreen.name,
extra: <String, dynamic>{ extra: <String, dynamic>{
@@ -793,6 +1025,43 @@ class _ChatScreenState extends State<ChatScreen> {
); );
} }
void _queueMarkAsRead({
required BuildContext context,
required String chatId,
required ChatMessageModel message,
required bool isCurrentUserAuthor,
required double visibleFraction,
}) {
if (message.isRead || isCurrentUserAuthor || visibleFraction <= 0) {
return;
}
if (!unreadMessageIds.contains(message.id)) {
unreadMessageIds.add(message.id);
}
EasyDebounce.debounce(
'mark_as_read',
const Duration(milliseconds: 500),
() async {
final ids = List<int>.from(unreadMessageIds);
if (ids.isEmpty) {
return;
}
unreadMessageIds.clear();
final result = await context.read<ChatRoomCubit>().markMessagesAsRead(
chatId,
ids,
);
if (!result || !context.mounted) {
return;
}
context.read<ChatsCubit>().reduceUnreadMessagesCount(
chatId: chatId,
count: ids.length,
);
},
);
}
Widget _buildMessageBubble({ Widget _buildMessageBubble({
required BuildContext context, required BuildContext context,
required ChatRoomStateLoaded state, required ChatRoomStateLoaded state,
@@ -801,11 +1070,13 @@ class _ChatScreenState extends State<ChatScreen> {
required ChatMessageModel message, required ChatMessageModel message,
required bool? amIConsultant, required bool? amIConsultant,
}) { }) {
if (message.type == ChatMessageType.videoCallStarted && !isCurrentUserAuthor) { if (message.type == ChatMessageType.videoCallStarted &&
!isCurrentUserAuthor) {
return VideoCallChatMessageWidget( return VideoCallChatMessageWidget(
text: message.data.trim(), text: message.data.trim(),
sendTime: message.updatedAt, sendTime: message.updatedAt,
onVideoCallPressed: () => _openVideoCall(context, state, chatCounterpart), onVideoCallPressed: () =>
_openVideoCall(context, state, chatCounterpart),
); );
} }
@@ -844,7 +1115,13 @@ class _ChatHeaderVideoCallButton extends StatelessWidget {
child: SizedBox( child: SizedBox(
width: 40, width: 40,
height: 40, height: 40,
child: Center(child: Icon(Icons.videocam_rounded, color: appTheme.blueA400, size: 18)), child: Center(
child: Icon(
Icons.videocam_rounded,
color: appTheme.blueA400,
size: 18,
),
),
), ),
), ),
); );

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

View File

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

View File

@@ -69,6 +69,9 @@ class _PassportDataScreenState extends State<PassportDataScreen> {
filter: {'#': RegExp(r'[0-9]')}, filter: {'#': RegExp(r'[0-9]')},
type: MaskAutoCompletionType.lazy, type: MaskAutoCompletionType.lazy,
); );
final _alfaPersonNameFormatter = FilteringTextInputFormatter.allow(
RegExp(r'[a-zA-Zа-яА-ЯёЁ ,.-]'),
);
@override @override
void initState() { void initState() {
@@ -301,6 +304,8 @@ class _PassportDataScreenState extends State<PassportDataScreen> {
child: CustomTextFormField( child: CustomTextFormField(
hintText: 'Фамилия', hintText: 'Фамилия',
controller: lastNameController, controller: lastNameController,
maxLength: 50,
inputFormatters: [_alfaPersonNameFormatter],
hasError: errors.containsKey('last_name'), hasError: errors.containsKey('last_name'),
onChanged: (_) => _syncToCubit(), onChanged: (_) => _syncToCubit(),
), ),
@@ -311,6 +316,8 @@ class _PassportDataScreenState extends State<PassportDataScreen> {
child: CustomTextFormField( child: CustomTextFormField(
hintText: 'Имя', hintText: 'Имя',
controller: firstNameController, controller: firstNameController,
maxLength: 50,
inputFormatters: [_alfaPersonNameFormatter],
hasError: errors.containsKey('first_name'), hasError: errors.containsKey('first_name'),
onChanged: (_) => _syncToCubit(), onChanged: (_) => _syncToCubit(),
), ),
@@ -321,6 +328,8 @@ class _PassportDataScreenState extends State<PassportDataScreen> {
child: CustomTextFormField( child: CustomTextFormField(
hintText: 'Отчество', hintText: 'Отчество',
controller: middleNameController, controller: middleNameController,
maxLength: 50,
inputFormatters: [_alfaPersonNameFormatter],
hasError: errors.containsKey('middle_name'), hasError: errors.containsKey('middle_name'),
onChanged: (_) => _syncToCubit(), onChanged: (_) => _syncToCubit(),
), ),

View File

@@ -18,7 +18,8 @@ class LaravelValidationErrors {
} }
const _genericValidationSummary = 'Проверьте введённые данные'; const _genericValidationSummary = 'Проверьте введённые данные';
const _genericNetworkSummary = 'Не удалось отправить данные. Попробуйте ещё раз'; const _genericNetworkSummary =
'Не удалось отправить данные. Попробуйте ещё раз';
/// Parses Laravel `errors` map and maps them to user-facing Russian texts. /// Parses Laravel `errors` map and maps them to user-facing Russian texts.
/// Raw backend messages are never shown in the UI. /// Raw backend messages are never shown in the UI.
@@ -85,8 +86,7 @@ String mapConsultantValidationMessage({
raw.contains('required') || raw.contains('required') ||
raw.contains('обязательн') || raw.contains('обязательн') ||
raw.contains('required_if'); raw.contains('required_if');
final isAccepted = final isAccepted = raw.contains('accepted') || field.contains('agreement');
raw.contains('accepted') || field.contains('agreement');
final isUrl = raw.contains('url') || field.contains('link'); final isUrl = raw.contains('url') || field.contains('link');
final isFormat = final isFormat =
raw.contains('format') || raw.contains('format') ||
@@ -154,6 +154,9 @@ String mapConsultantValidationMessage({
'passport_number' => 'Некорректный номер паспорта', 'passport_number' => 'Некорректный номер паспорта',
'passport_issue_date' => 'Некорректная дата выдачи', 'passport_issue_date' => 'Некорректная дата выдачи',
'passport_issuer_code' => 'Некорректный код подразделения', 'passport_issuer_code' => 'Некорректный код подразделения',
'last_name' => 'Некорректная фамилия',
'first_name' => 'Некорректное имя',
'middle_name' => 'Некорректное отчество',
'birth_date' => 'Некорректная дата рождения', 'birth_date' => 'Некорректная дата рождения',
'citizenship' => 'Некорректный код гражданства', 'citizenship' => 'Некорректный код гражданства',
'sbp_phone' => 'Некорректный телефон СБП', 'sbp_phone' => 'Некорректный телефон СБП',

View File

@@ -8,6 +8,9 @@ import 'package:eatvio_app/features/consultant/become_consultant/models/consulta
abstract final class PassportDataValidator { abstract final class PassportDataValidator {
static const futureDateMessage = 'Дата не может быть в будущем'; 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( static Map<String, String> validate(
ConsultantProfileModel consultant, { ConsultantProfileModel consultant, {
DateTime? now, DateTime? now,
@@ -15,17 +18,28 @@ abstract final class PassportDataValidator {
final today = now ?? DateTime.now(); final today = now ?? DateTime.now();
final errors = <String, String>{}; final errors = <String, String>{};
_requireText( _requirePattern(
errors, errors,
key: 'last_name', key: 'last_name',
value: consultant.lastName, value: consultant.lastName,
pattern: alfaPersonNamePattern,
emptyMessage: 'Укажите фамилию', emptyMessage: 'Укажите фамилию',
invalidMessage: 'Некорректная фамилия',
); );
_requireText( _requirePattern(
errors, errors,
key: 'first_name', key: 'first_name',
value: consultant.firstName, value: consultant.firstName,
pattern: alfaPersonNamePattern,
emptyMessage: 'Укажите имя', emptyMessage: 'Укажите имя',
invalidMessage: 'Некорректное имя',
);
_optionalPattern(
errors,
key: 'middle_name',
value: consultant.middleName,
pattern: alfaPersonNamePattern,
invalidMessage: 'Некорректное отчество',
); );
_requirePattern( _requirePattern(
errors, errors,
@@ -148,6 +162,22 @@ abstract final class PassportDataValidator {
} }
} }
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( static void _requireDate(
Map<String, String> errors, { Map<String, String> errors, {
required String key, required String key,

View File

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

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_diet/presentation/widgets/error_widget.dart';
import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_cubit.dart'; import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_cubit.dart';
import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_state.dart'; import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_state.dart';
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
import 'package:eatvio_app/features/create_plan/presentation/widgets/option_widget.dart'; import 'package:eatvio_app/features/create_plan/presentation/widgets/option_widget.dart';
import 'package:eatvio_app/features/create_plan/presentation/widgets/options_count_button.dart'; import 'package:eatvio_app/features/create_plan/presentation/widgets/options_count_button.dart';
import 'package:eatvio_app/features/create_plan/presentation/widgets/plan_duration_drop_down_widget.dart'; import 'package:eatvio_app/features/create_plan/presentation/widgets/plan_duration_drop_down_widget.dart';
@@ -25,11 +26,23 @@ import 'package:go_router/go_router.dart';
class CreatePlanScreen extends StatelessWidget { class CreatePlanScreen extends StatelessWidget {
static const name = 'createPlanScreen'; static const name = 'createPlanScreen';
static const path = '/create-Plan-screen'; static const path = '/create-Plan-screen';
const CreatePlanScreen({super.key}); const CreatePlanScreen({super.key, this.plan});
final PlanModel? plan;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider(create: (context) => sl<CreatePlanCubit>(), child: const CreatePlanScreenContent()); return BlocProvider(
create: (context) {
final cubit = sl<CreatePlanCubit>();
final existingPlan = plan;
if (existingPlan != null) {
cubit.loadExisting(existingPlan);
}
return cubit;
},
child: const CreatePlanScreenContent(),
);
} }
} }
@@ -47,17 +60,19 @@ class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final s = S.of(context); final s = S.of(context);
final isEditing = context.read<CreatePlanCubit>().sourcePlan != null;
return BlocBuilder<CreatePlanCubit, CreatePlanState>( return BlocBuilder<CreatePlanCubit, CreatePlanState>(
buildWhen: (previous, current) => previous.planCreated != current.planCreated || previous.isLoading != current.isLoading, buildWhen: (previous, current) => previous.planCreated != current.planCreated || previous.isLoading != current.isLoading,
builder: (context, state) { builder: (context, state) {
if (state.planCreated) { if (state.planCreated) {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted) return; if (!context.mounted) return;
context.pop(); final isEditing = context.read<CreatePlanCubit>().sourcePlan != null;
context.pop(true);
showBottomSheetWithBlur( showBottomSheetWithBlur(
userRootNavigator: true, userRootNavigator: true,
context: context, context: context,
child: SuccessPublishedDietOrPlanBottomSheet(title: state.planData.name ?? 'План', isPlan: true), child: SuccessPublishedDietOrPlanBottomSheet(title: state.planData.name ?? 'План', isPlan: true, isUpdated: isEditing),
); );
}); });
} }
@@ -68,7 +83,7 @@ class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
appBar: AppBar( appBar: AppBar(
centerTitle: true, centerTitle: true,
title: Text( title: Text(
s.adding_plan, isEditing ? 'Редактирование плана' : s.adding_plan,
textAlign: TextAlign.center, textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
softWrap: true, softWrap: true,
@@ -102,7 +117,9 @@ class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
return Column( return Column(
children: [ children: [
CustomTextFormField( CustomTextFormField(
key: const ValueKey('plan-name'),
hintText: s.plan_label_desc, hintText: s.plan_label_desc,
initialValue: state.planData.name,
hasError: state.validationState.titleHasError, hasError: state.validationState.titleHasError,
onChanged: (value) { onChanged: (value) {
context.read<CreatePlanCubit>().setName(value); context.read<CreatePlanCubit>().setName(value);
@@ -144,6 +161,7 @@ class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
children: [ children: [
MarkdownEditor( MarkdownEditor(
key: _mdKey, key: _mdKey,
initialText: state.planData.details,
hintText: s.plan_desc_example, hintText: s.plan_desc_example,
hasError: state.validationState.descriptionHasError, hasError: state.validationState.descriptionHasError,
minLines: 6, minLines: 6,
@@ -230,7 +248,7 @@ class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
onPressed: () async { onPressed: () async {
await context.read<CreatePlanCubit>().publishPlan(); await context.read<CreatePlanCubit>().publishPlan();
}, },
text: s.publish, text: isEditing ? 'Обновить' : s.publish,
height: 52, height: 52,
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700), buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
buttonStyle: ButtonStyle( buttonStyle: ButtonStyle(

View File

@@ -4,16 +4,57 @@ import 'dart:io';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:eatvio_app/core/const.dart'; import 'package:eatvio_app/core/const.dart';
import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart'; import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart';
import 'package:eatvio_app/core/utils/toasts.dart';
import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_state.dart'; import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_state.dart';
import 'package:eatvio_app/features/create_plan/repositories/create_plan_repository.dart'; import 'package:eatvio_app/features/create_plan/repositories/create_plan_repository.dart';
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:eatvio_app/core/error_reporting/report_from.dart'; import 'package:eatvio_app/core/error_reporting/report_from.dart';
import 'package:flutter_image_compress/flutter_image_compress.dart'; import 'package:flutter_image_compress/flutter_image_compress.dart';
class CreatePlanCubit extends Cubit<CreatePlanState> { class CreatePlanCubit extends Cubit<CreatePlanState> {
final CreatePlanRepository createPlanRepository; final CreatePlanRepository createPlanRepository;
PlanModel? sourcePlan;
CreatePlanCubit({required this.createPlanRepository}) : super(CreatePlanState.initial()); CreatePlanCubit({required this.createPlanRepository}) : super(CreatePlanState.initial());
void loadExisting(PlanModel plan) {
sourcePlan = plan;
final durationIndex = durationValues.indexOf(plan.duration);
emit(
state.copyWith(
durationIndex: durationIndex >= 0 ? durationIndex : null,
selectedOptionIndex: plan.packetsCount == 3 ? 0 : null,
planData: state.planData.copyWith(
name: plan.name,
details: plan.details,
duration: plan.duration,
state: plan.state,
type: [
plan.type.slim ?? false,
plan.type.fit ?? false,
plan.type.wellness ?? false,
plan.type.behavior ?? false,
],
options: [
_packetOrEmpty(plan.packet1Options),
plan.packetsCount == 3 ? _packetOrEmpty(plan.packet2Options) : PacketOptions.empty(),
plan.packetsCount == 3 ? _packetOrEmpty(plan.packet3Options) : PacketOptions.empty(),
],
),
),
);
}
PacketOptions _packetOrEmpty(PacketOptions? option) {
if (option == null) {
return PacketOptions.empty();
}
final price = option.price;
final normalizedPrice = price is num ? price.toDouble() : double.tryParse('$price') ?? 1200;
return option.copyWith(price: normalizedPrice);
}
void setDetails(String text) { void setDetails(String text) {
emit(state.copyWith(planData: state.planData.copyWith(details: text))); emit(state.copyWith(planData: state.planData.copyWith(details: text)));
validateDetails(); validateDetails();
@@ -147,7 +188,7 @@ class CreatePlanCubit extends Cubit<CreatePlanState> {
} }
bool validateImage() { bool validateImage() {
if (state.planData.image == null) { if (state.planData.image == null && sourcePlan == null) {
emit(state.copyWith.validationState(imageHasError: true)); emit(state.copyWith.validationState(imageHasError: true));
return false; return false;
} }
@@ -413,31 +454,34 @@ class CreatePlanCubit extends Cubit<CreatePlanState> {
'type[fit]': state.planData.type[1] ? '1' : '0', 'type[fit]': state.planData.type[1] ? '1' : '0',
'type[wellness]': state.planData.type[2] ? '1' : '0', 'type[wellness]': state.planData.type[2] ? '1' : '0',
'type[behavior]': state.planData.type[3] ? '1' : '0', 'type[behavior]': state.planData.type[3] ? '1' : '0',
'image': state.planData.image != null
? await MultipartFile.fromFile((await ImageCompressUtility.compressFile(File(state.planData.image!.path))).path,
filename: state.planData.image!.name)
: null
}); });
if (state.planData.image != null) {
final image = state.planData.image!;
planMap['image'] = await MultipartFile.fromFile(
(await ImageCompressUtility.compressFile(File(image.path))).path,
filename: image.name,
);
}
if (state.planData.options[1].short.isNotEmpty) { if (state.planData.options[1].short.isNotEmpty) {
planMap.addAll({ planMap.addAll({
'packets_count': 3, 'packets_count': 3,
'packet1_options': { 'packet1_options': {
'short': state.planData.options[0].short, 'short': state.planData.options[0].short,
'price': int.parse((state.planData.options[0].price as double).toStringAsFixed(0)), 'price': _optionPrice(state.planData.options[0].price),
'description_md': state.planData.options[0].description, 'description_md': state.planData.options[0].description,
'items': Map.fromIterable(state.planData.options[0].items), 'items': Map.fromIterable(state.planData.options[0].items),
'has_personal_diet': state.planData.options[0].hasPersonalDiet ? '1' : '0', 'has_personal_diet': state.planData.options[0].hasPersonalDiet ? '1' : '0',
}, },
'packet2_options': { 'packet2_options': {
'short': state.planData.options[1].short, 'short': state.planData.options[1].short,
'price': int.parse((state.planData.options[1].price as double).toStringAsFixed(0)), 'price': _optionPrice(state.planData.options[1].price),
'description_md': state.planData.options[1].description, 'description_md': state.planData.options[1].description,
'items': Map.fromIterable(state.planData.options[1].items), 'items': Map.fromIterable(state.planData.options[1].items),
'has_personal_diet': state.planData.options[1].hasPersonalDiet ? '1' : '0', 'has_personal_diet': state.planData.options[1].hasPersonalDiet ? '1' : '0',
}, },
'packet3_options': { 'packet3_options': {
'short': state.planData.options[2].short, 'short': state.planData.options[2].short,
'price': int.parse((state.planData.options[2].price as double).toStringAsFixed(0)), 'price': _optionPrice(state.planData.options[2].price),
'description_md': state.planData.options[2].description, 'description_md': state.planData.options[2].description,
'items': Map.fromIterable(state.planData.options[2].items), 'items': Map.fromIterable(state.planData.options[2].items),
'has_personal_diet': state.planData.options[2].hasPersonalDiet ? '1' : '0', 'has_personal_diet': state.planData.options[2].hasPersonalDiet ? '1' : '0',
@@ -448,20 +492,34 @@ class CreatePlanCubit extends Cubit<CreatePlanState> {
'packets_count': 1, 'packets_count': 1,
'packet1_options': { 'packet1_options': {
'short': state.planData.options[0].short, 'short': state.planData.options[0].short,
'price': int.parse((state.planData.options[0].price as double).toStringAsFixed(0)), 'price': _optionPrice(state.planData.options[0].price),
'description_md': state.planData.options[0].description, 'description_md': state.planData.options[0].description,
'items': Map.fromIterable(state.planData.options[0].items), 'items': Map.fromIterable(state.planData.options[0].items),
'has_personal_diet': state.planData.options[0].hasPersonalDiet ? '1' : '0', 'has_personal_diet': state.planData.options[0].hasPersonalDiet ? '1' : '0',
}, },
}); });
} }
await createPlanRepository.createPlan(model: planMap); final editingPlan = sourcePlan;
if (editingPlan == null) {
await createPlanRepository.createPlan(model: planMap);
} else {
await createPlanRepository.updatePublishedPlan(slug: editingPlan.slug, model: planMap);
}
emit(state.copyWith(isLoading: false, planCreated: true)); emit(state.copyWith(isLoading: false, planCreated: true));
} }
} catch (e, st) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
log(e.toString()); log(e.toString());
emit(state.copyWith(isLoading: false, planCreated: true)); emit(state.copyWith(isLoading: false, planCreated: false));
final isForbidden = e is DioException && e.response?.statusCode == 403;
showErrorToast(isForbidden ? 'Редактирование недоступно: клиент уже начал проходить план' : 'Не удалось сохранить план');
} }
} }
int _optionPrice(dynamic price) {
if (price is num) {
return price.round();
}
return int.parse(price.toString());
}
} }

View File

@@ -27,6 +27,12 @@ class AddItemWidget extends StatefulWidget {
class _AddItemWidgetState extends State<AddItemWidget> { class _AddItemWidgetState extends State<AddItemWidget> {
final controller = TextEditingController(); final controller = TextEditingController();
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override @override
void didUpdateWidget(oldWidget) { void didUpdateWidget(oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
@@ -38,14 +44,24 @@ class _AddItemWidgetState extends State<AddItemWidget> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final s = S.of(context); final s = S.of(context);
if (widget.items.length > widget.index && controller.text != widget.items[widget.index]) { if (widget.items.length > widget.index &&
controller.text != widget.items[widget.index]) {
controller.text = widget.items[widget.index]; controller.text = widget.items[widget.index];
} }
return BlocBuilder<CreatePlanCubit, CreatePlanState>( return BlocBuilder<CreatePlanCubit, CreatePlanState>(
buildWhen: (previous, current) => previous.validationState != current.validationState, buildWhen: (previous, current) =>
previous.validationState != current.validationState,
builder: (context, state) { builder: (context, state) {
final hasError = state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].isItemsEmpty || final hasError =
(state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].itemsErrors[widget.index] ?? false); state
.validationState
.optionsValidationState[state.selectedOptionIndex ?? 0]
.isItemsEmpty ||
(state
.validationState
.optionsValidationState[state.selectedOptionIndex ?? 0]
.itemsErrors[widget.index] ??
false);
return Column( return Column(
children: [ children: [
Row( Row(
@@ -54,9 +70,14 @@ class _AddItemWidgetState extends State<AddItemWidget> {
child: CustomTextFormField( child: CustomTextFormField(
controller: controller, controller: controller,
hasError: hasError, hasError: hasError,
hintText: widget.enableHint ? s.option_content_hint_text : null, hintText: widget.enableHint
? s.option_content_hint_text
: null,
onChanged: (value) { onChanged: (value) {
context.read<CreatePlanCubit>().setOptionContent(value, widget.index); context.read<CreatePlanCubit>().setOptionContent(
value,
widget.index,
);
}, },
), ),
), ),
@@ -64,7 +85,9 @@ class _AddItemWidgetState extends State<AddItemWidget> {
const SizedBox(width: 8), const SizedBox(width: 8),
GestureDetector( GestureDetector(
onTap: () { onTap: () {
context.read<CreatePlanCubit>().removeOptionContentItem(widget.index); context.read<CreatePlanCubit>().removeOptionContentItem(
widget.index,
);
}, },
child: Container( child: Container(
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(14),
@@ -72,19 +95,30 @@ class _AddItemWidgetState extends State<AddItemWidget> {
color: appTheme.lightGray0, color: appTheme.lightGray0,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
), ),
child: SvgPicture.asset('assets/icons/trash_can.svg', width: 20, height: 24), child: SvgPicture.asset(
'assets/icons/trash_can.svg',
width: 20,
height: 24,
),
), ),
), ),
], ],
], ],
), ),
if (state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].isItemsEmpty) ...[ if (state
.validationState
.optionsValidationState[state.selectedOptionIndex ?? 0]
.isItemsEmpty) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
ValidationErrorWidget(error: s.addAtLeastOneItem) ValidationErrorWidget(error: s.addAtLeastOneItem),
] else if (state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].itemsErrors[widget.index] ?? false) ...[ ] else if (state
.validationState
.optionsValidationState[state.selectedOptionIndex ?? 0]
.itemsErrors[widget.index] ??
false) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
ValidationErrorWidget(error: s.atLeast3Symbols) ValidationErrorWidget(error: s.atLeast3Symbols),
] ],
], ],
); );
}, },

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

View File

@@ -1,7 +1,9 @@
import 'dart:io'; import 'dart:io';
import 'package:dotted_border/dotted_border.dart'; import 'package:dotted_border/dotted_border.dart';
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/permission_handler/permission_handler.dart'; import 'package:eatvio_app/core/permission_handler/permission_handler.dart';
import 'package:eatvio_app/core/theme/custom_text_style.dart'; import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart';
@@ -91,6 +93,18 @@ class _PlanImagePickerWidgetState extends State<PlanImagePickerWidget> {
final s = S.of(context); final s = S.of(context);
return BlocBuilder<CreatePlanCubit, CreatePlanState>( return BlocBuilder<CreatePlanCubit, CreatePlanState>(
builder: (context, state) { builder: (context, state) {
final selectedImage = state.planData.image;
final existingImage = context.read<CreatePlanCubit>().sourcePlan?.image;
final hasCover = selectedImage != null || (existingImage != null && existingImage.isNotEmpty);
final ImageProvider? coverImage;
if (selectedImage != null) {
coverImage = FileImage(File(selectedImage.path));
} else if (existingImage != null && existingImage.isNotEmpty) {
final url = existingImage.startsWith('http') ? existingImage : '${sl<AppConfig>().baseUrl}/storage/$existingImage';
coverImage = NetworkImage(url);
} else {
coverImage = null;
}
return Column( return Column(
children: [ children: [
GestureDetector( GestureDetector(
@@ -101,12 +115,12 @@ class _PlanImagePickerWidgetState extends State<PlanImagePickerWidget> {
height: 240, height: 240,
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
image: state.planData.image != null ? DecorationImage(fit: BoxFit.cover, image: FileImage(File(state.planData.image?.path ?? ''))) : null, image: coverImage != null ? DecorationImage(fit: BoxFit.cover, image: coverImage) : null,
color: appTheme.gray50, color: appTheme.gray50,
), ),
width: MediaQuery.sizeOf(context).width, width: MediaQuery.sizeOf(context).width,
padding: state.planData.image != null ? null : const EdgeInsets.all(8), padding: hasCover ? null : const EdgeInsets.all(8),
child: state.planData.image != null child: selectedImage != null
? Padding( ? Padding(
padding: const EdgeInsets.only(top: 6, right: 6), padding: const EdgeInsets.only(top: 6, right: 6),
child: GestureDetector( child: GestureDetector(
@@ -131,6 +145,8 @@ class _PlanImagePickerWidgetState extends State<PlanImagePickerWidget> {
), ),
), ),
) )
: hasCover
? const SizedBox.shrink()
: Stack( : Stack(
children: [ children: [
DottedBorder( DottedBorder(

View File

@@ -11,7 +11,13 @@ class CreatePlanRepository {
}) async { }) async {
final formData = FormData.fromMap(model); final formData = FormData.fromMap(model);
return await createPlanService.createPlan(formData: formData); return await createPlanService.createPlan(formData: formData);
} }
Future<void> updatePublishedPlan({
required String slug,
required Map<String, dynamic> model,
}) async {
final formData = FormData.fromMap(model);
return await createPlanService.updatePublishedPlan(slug: slug, formData: formData);
}
} }

View File

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

View File

@@ -18,8 +18,13 @@ enum PortionState {
@JsonValue('eaten_edited') @JsonValue('eaten_edited')
eatenEdited, eatenEdited,
commented, commented,
@JsonValue('commented_from_diet')
commentedFromDiet,
@JsonValue('reviewed_by_user') @JsonValue('reviewed_by_user')
reviewedByUser, reviewedByUser,
@JsonValue('reviewed_from_diet')
reviewedFromDiet,
unknown,
} }
@freezed @freezed
@@ -138,7 +143,7 @@ abstract class PortionModel with _$PortionModel {
@JsonKey(name: "quantity") required int quantity, @JsonKey(name: "quantity") required int quantity,
@JsonKey(name: "portion") required String portion, @JsonKey(name: "portion") required String portion,
@JsonKey(name: "from_diet") required bool fromDiet, @JsonKey(name: "from_diet") required bool fromDiet,
@JsonKey(name: "state") required PortionState portionState, @JsonKey(name: "state", unknownEnumValue: PortionState.unknown) required PortionState portionState,
}) = _PortionModel; }) = _PortionModel;
factory PortionModel.fromJson(Map<String, dynamic> json) => _$PortionModelFromJson(json); factory PortionModel.fromJson(Map<String, dynamic> json) => _$PortionModelFromJson(json);

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/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/consultant_review_model.dart';
import 'package:eatvio_app/features/diary/models/nutrition_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'; import 'package:freezed_annotation/freezed_annotation.dart';
part 'diary_state.freezed.dart'; part 'diary_state.freezed.dart';
@@ -12,7 +13,8 @@ abstract class DiaryState with _$DiaryState {
const factory DiaryState.loading() = DiaryStateLoading; 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({ const factory DiaryState.loaded({
required DiaryDayEntity diary, required DiaryDayEntity diary,
@@ -31,6 +33,19 @@ abstract class DiaryState with _$DiaryState {
@Default(false) bool isLoadingAddingProductInMeal, @Default(false) bool isLoadingAddingProductInMeal,
}) = DiaryStateLoaded; }) = DiaryStateLoaded;
const factory DiaryState.loadedTomorrowWithDiet({required DiaryFutureDietDayEntity diary, required DateTime selectedDay, required int selectedMeal}) = const factory DiaryState.loadedTomorrowWithDiet({
DiaryStateLoadedTomorrowWithDiet; 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) { if (state is DiaryStateError) {
return Scaffold( 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(toolbarHeight: 0)
: AppBar( : AppBar(
title: Text( title: Text(
state is DiaryStateLoaded ? DateTime.parse(state.diary.date).toLocal().format('d MMMM') : '', state is DiaryStateLoaded
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black), ? DateTime.parse(
state.diary.date,
).toLocal().format('d MMMM')
: '',
style: TextStyles.headingXLSemibold.copyWith(
color: appTheme.black,
),
), ),
centerTitle: true, centerTitle: true,
), ),
@@ -95,26 +106,39 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
child: Column( child: Column(
children: [ children: [
if (widget.diaryId == null) CalendarWidget(date: widget.date), 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) if (state is DiaryStateLoaded)
WakeupAndRetireWidget( WakeupAndRetireWidget(
isEnabled: state.diary.typeOfDiary.hasChosenPlan || state.isConsultantView || state.isWakeUpAndRetireEnabled, isEnabled:
disable: state.isConsultantView || state.isDiaryFinished || state.isDiaryCommented, state.diary.typeOfDiary.hasChosenPlan ||
state.isConsultantView ||
state.isWakeUpAndRetireEnabled,
disable: !state.canEditDiary,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
if (state is DiaryStateLoaded) if (state is DiaryStateLoaded)
MealWidget( MealWidget(
showMood: state.diary.typeOfDiary.hasChosenPlan || state.isConsultantView || state.isMoodEnabled, showMood:
showAddButton: !state.isConsultantView && !state.isDiaryFinished && !state.isDiaryCommented, 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(), if (state is DiaryStateLoaded) const CaloriesChartWidget(),
const SizedBox(height: 12), const SizedBox(height: 12),
Builder( Builder(
builder: (context) { builder: (context) {
if (state is DiaryStateLoaded && state.diary.typeOfDiary.hasChosenPlan) { if (state is DiaryStateLoaded &&
state.diary.typeOfDiary.hasChosenPlan) {
if (state.diary.comment.isNotEmpty) { if (state.diary.comment.isNotEmpty) {
return CommentForConsultantWidget(comment: state.diary.comment); return CommentForConsultantWidget(
comment: state.diary.comment,
);
} }
if (state.isDiaryCommented || state.isDiaryFinished) { if (state.isDiaryCommented || state.isDiaryFinished) {
return const SizedBox.shrink(); return const SizedBox.shrink();
@@ -124,7 +148,10 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
return const SizedBox.shrink(); 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(
padding: const EdgeInsets.only(top: 12), padding: const EdgeInsets.only(top: 12),
child: _isDiaryFinishing child: _isDiaryFinishing
@@ -135,11 +162,13 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
setState(() { setState(() {
_isDiaryFinishing = true; _isDiaryFinishing = true;
}); });
final isFinished = await context.read<DiaryCubit>().finishDiaryDay( final isFinished = await context
diaryId: state.diary.id, .read<DiaryCubit>()
wakeUp: state.diary.wakeup.toString(), .finishDiaryDay(
retire: state.diary.retire.toString(), diaryId: state.diary.id,
); wakeUp: state.diary.wakeup.toString(),
retire: state.diary.retire.toString(),
);
if (!isFinished && context.mounted) { if (!isFinished && context.mounted) {
showErrorDialog(context); 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 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), const SizedBox(height: 12),
MainBackgroundBouble( MainBackgroundBouble(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
children: [ 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), const SizedBox(height: 12),
CommentContainer(comment: state.consultantReview?.consultantComments.dayComment ?? ''), CommentContainer(
comment:
state
.consultantReview
?.consultantComments
.dayComment ??
'',
),
], ],
), ),
), ),
const SizedBox(height: 28), 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), const SizedBox(height: 16),
CustomOutlinedButton( CustomOutlinedButton(
text: s.send, text: s.send,
height: 52, height: 52,
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700), buttonTextStyle: TextStyles.textMSemibold.copyWith(
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)), color: appTheme.whiteA700,
),
buttonStyle: ButtonStyle(
side: const WidgetStatePropertyAll(BorderSide.none),
backgroundColor: WidgetStatePropertyAll(
appTheme.blueA400,
),
),
onPressed: () async { onPressed: () async {
await context.read<ConsultantCommentsCubit>().submitComments(state.diary.id); await context
.read<ConsultantCommentsCubit>()
.submitComments(state.diary.id);
}, },
), ),
const SizedBox(height: 16), const SizedBox(height: 16),

View File

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

View File

@@ -16,6 +16,9 @@ class CalendarWidget extends StatefulWidget {
} }
class _CalendarWidgetState extends State<CalendarWidget> { class _CalendarWidgetState extends State<CalendarWidget> {
static const _kDaysOfWeekHeight = 16.0;
static const _kRowHeight = 52.0;
CalendarFormat _calendarFormat = CalendarFormat.week; CalendarFormat _calendarFormat = CalendarFormat.week;
DateTime _focusedDay = DateTime.now(); DateTime _focusedDay = DateTime.now();
DateTime _selectedDay = DateTime.now(); DateTime _selectedDay = DateTime.now();
@@ -40,6 +43,8 @@ class _CalendarWidgetState extends State<CalendarWidget> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final s = S.of(context); final s = S.of(context);
final textScaler = MediaQuery.textScalerOf(context);
final daysOfWeekHeight = _scaledDaysOfWeekHeight(context);
return BlocConsumer<DiaryCubit, DiaryState>( return BlocConsumer<DiaryCubit, DiaryState>(
listener: (BuildContext context, DiaryState state) { listener: (BuildContext context, DiaryState state) {
if (state is DiaryStateLoaded) { if (state is DiaryStateLoaded) {
@@ -61,6 +66,8 @@ class _CalendarWidgetState extends State<CalendarWidget> {
locale: 'ru_RU', locale: 'ru_RU',
startingDayOfWeek: StartingDayOfWeek.monday, startingDayOfWeek: StartingDayOfWeek.monday,
calendarFormat: _calendarFormat, calendarFormat: _calendarFormat,
daysOfWeekHeight: daysOfWeekHeight,
rowHeight: textScaler.scale(_kRowHeight),
headerStyle: const HeaderStyle(titleCentered: true, formatButtonVisible: false, leftChevronVisible: false, rightChevronVisible: false), headerStyle: const HeaderStyle(titleCentered: true, formatButtonVisible: false, leftChevronVisible: false, rightChevronVisible: false),
onDaySelected: (selectedDay, focusedDay) async { onDaySelected: (selectedDay, focusedDay) async {
if (!isSameDay(_selectedDay, selectedDay)) { 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() { void _nextCalendarFormat() {
setState(() { setState(() {
_calendarFormat = CalendarFormat.values[(_calendarFormat.index - 1) % CalendarFormat.values.length]; _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; final meals = state.diary.meals;
if (selectedIndex >= 0 && selectedIndex < meals.length) { if (selectedIndex >= 0 && selectedIndex < meals.length) {
if (isFirstFetch[selectedIndex]) { if (isFirstFetch[selectedIndex]) {
currentSelectedRating[selectedIndex] = meals[selectedIndex].mood.toDouble() + 1; currentSelectedRating[selectedIndex] =
meals[selectedIndex].mood.toDouble() + 1;
isFirstFetch[selectedIndex] = false; isFirstFetch[selectedIndex] = false;
} }
} }
@@ -36,21 +37,30 @@ class _MoodRatingWidgetState extends State<MoodRatingWidget> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text(
state.isConsultantView ? 'Настроение пользователя после приема пищи' : 'Ваше настроение после приёма пищи', state.isConsultantView
? 'Настроение пользователя после приема пищи'
: 'Ваше настроение после приёма пищи',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: const TextStyle(fontSize: 16), style: const TextStyle(fontSize: 16),
), ),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ 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( RatingBar.builder(
ignoreGestures: state.isConsultantView || state.isDiaryFinished || state.isDiaryCommented, ignoreGestures: !state.canEditDiary,
initialRating: (selectedIndex >= 0 && selectedIndex < state.diary.meals.length) initialRating:
(selectedIndex >= 0 &&
selectedIndex < state.diary.meals.length)
? state.diary.meals[selectedIndex].mood.toDouble() + 1 ? state.diary.meals[selectedIndex].mood.toDouble() + 1
: 3, : 3,
itemCount: 6, itemCount: 6,
itemSize: MediaQuery.of(context).size.width / 6 - (124 / 6), itemSize:
MediaQuery.of(context).size.width / 6 - (124 / 6),
updateOnDrag: true, updateOnDrag: true,
itemBuilder: (context, index) { itemBuilder: (context, index) {
switch (index) { switch (index) {
@@ -58,82 +68,154 @@ class _MoodRatingWidgetState extends State<MoodRatingWidget> {
return Container( return Container(
width: MediaQuery.of(context).size.width / 6, width: MediaQuery.of(context).size.width / 6,
height: 24, height: 24,
margin: currentSelectedRating[selectedIndex] == 1 ? const EdgeInsets.symmetric(horizontal: 2) : null, margin: currentSelectedRating[selectedIndex] == 1
? const EdgeInsets.symmetric(horizontal: 2)
: null,
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFC82222), color: const Color(0xFFC82222),
border: currentSelectedRating[selectedIndex] == 1 border:
? Border.all(width: 2, color: const Color(0xFFAA3333), strokeAlign: BorderSide.strokeAlignOutside) currentSelectedRating[selectedIndex] == 1
? Border.all(
width: 2,
color: const Color(0xFFAA3333),
strokeAlign:
BorderSide.strokeAlignOutside,
)
: null, : null,
borderRadius: currentSelectedRating[selectedIndex] == 1 borderRadius:
? const BorderRadius.horizontal(left: Radius.circular(8), right: Radius.circular(2)) currentSelectedRating[selectedIndex] == 1
: const BorderRadius.horizontal(left: Radius.circular(8)), ? const BorderRadius.horizontal(
left: Radius.circular(8),
right: Radius.circular(2),
)
: const BorderRadius.horizontal(
left: Radius.circular(8),
),
), ),
); );
case 1: case 1:
return Container( return Container(
width: MediaQuery.of(context).size.width / 6, width: MediaQuery.of(context).size.width / 6,
height: 24, height: 24,
margin: currentSelectedRating[selectedIndex] == 2 ? const EdgeInsets.symmetric(horizontal: 2) : null, margin: currentSelectedRating[selectedIndex] == 2
? const EdgeInsets.symmetric(horizontal: 2)
: null,
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFFF3E3E), color: const Color(0xFFFF3E3E),
border: currentSelectedRating[selectedIndex] == 2 border:
? Border.all(width: 2, color: const Color(0xFF550000), strokeAlign: BorderSide.strokeAlignOutside) currentSelectedRating[selectedIndex] == 2
? Border.all(
width: 2,
color: const Color(0xFF550000),
strokeAlign:
BorderSide.strokeAlignOutside,
)
: null,
borderRadius:
currentSelectedRating[selectedIndex] == 2
? BorderRadius.circular(2)
: null, : null,
borderRadius: currentSelectedRating[selectedIndex] == 2 ? BorderRadius.circular(2) : null,
), ),
); );
case 2: case 2:
return Container( return Container(
width: MediaQuery.of(context).size.width / 6, width: MediaQuery.of(context).size.width / 6,
height: 24, height: 24,
margin: currentSelectedRating[selectedIndex] == 3 ? const EdgeInsets.symmetric(horizontal: 2) : null, margin: currentSelectedRating[selectedIndex] == 3
? const EdgeInsets.symmetric(horizontal: 2)
: null,
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFF8C926), color: const Color(0xFFF8C926),
border: currentSelectedRating[selectedIndex] == 3 border:
? Border.all(width: 2, color: const Color(0xFF8C5D00), strokeAlign: BorderSide.strokeAlignOutside) currentSelectedRating[selectedIndex] == 3
? Border.all(
width: 2,
color: const Color(0xFF8C5D00),
strokeAlign:
BorderSide.strokeAlignOutside,
)
: null,
borderRadius:
currentSelectedRating[selectedIndex] == 3
? BorderRadius.circular(2)
: null, : null,
borderRadius: currentSelectedRating[selectedIndex] == 3 ? BorderRadius.circular(2) : null,
), ),
); );
case 3: case 3:
return Container( return Container(
width: MediaQuery.of(context).size.width / 6, width: MediaQuery.of(context).size.width / 6,
height: 24, height: 24,
margin: currentSelectedRating[selectedIndex] == 4 ? const EdgeInsets.symmetric(horizontal: 2) : null, margin: currentSelectedRating[selectedIndex] == 4
? const EdgeInsets.symmetric(horizontal: 2)
: null,
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFFFF3BF), color: const Color(0xFFFFF3BF),
border: currentSelectedRating[selectedIndex] == 4 border:
? Border.all(width: 2, color: const Color(0xFFB5911B), strokeAlign: BorderSide.strokeAlignOutside) currentSelectedRating[selectedIndex] == 4
? Border.all(
width: 2,
color: const Color(0xFFB5911B),
strokeAlign:
BorderSide.strokeAlignOutside,
)
: null,
borderRadius:
currentSelectedRating[selectedIndex] == 4
? BorderRadius.circular(2)
: null, : null,
borderRadius: currentSelectedRating[selectedIndex] == 4 ? BorderRadius.circular(2) : null,
), ),
); );
case 4: case 4:
return Container( return Container(
width: MediaQuery.of(context).size.width / 6, width: MediaQuery.of(context).size.width / 6,
height: 24, height: 24,
margin: currentSelectedRating[selectedIndex] == 5 ? const EdgeInsets.symmetric(horizontal: 2) : null, margin: currentSelectedRating[selectedIndex] == 5
? const EdgeInsets.symmetric(horizontal: 2)
: null,
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF62E833), color: const Color(0xFF62E833),
border: currentSelectedRating[selectedIndex] == 5 border:
? Border.all(width: 2, color: const Color(0xFF276918), strokeAlign: BorderSide.strokeAlignOutside) currentSelectedRating[selectedIndex] == 5
? Border.all(
width: 2,
color: const Color(0xFF276918),
strokeAlign:
BorderSide.strokeAlignOutside,
)
: null,
borderRadius:
currentSelectedRating[selectedIndex] == 5
? BorderRadius.circular(2)
: null, : null,
borderRadius: currentSelectedRating[selectedIndex] == 5 ? BorderRadius.circular(2) : null,
), ),
); );
default: default:
return Container( return Container(
width: MediaQuery.of(context).size.width / 6, width: MediaQuery.of(context).size.width / 6,
height: 24, height: 24,
margin: currentSelectedRating[selectedIndex] == 6 ? const EdgeInsets.symmetric(horizontal: 2) : null, margin: currentSelectedRating[selectedIndex] == 6
? const EdgeInsets.symmetric(horizontal: 2)
: null,
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF4DCE20), color: const Color(0xFF4DCE20),
border: currentSelectedRating[selectedIndex] == 6 border:
? Border.all(width: 2, color: const Color(0xFF184D12), strokeAlign: BorderSide.strokeAlignOutside) currentSelectedRating[selectedIndex] == 6
? Border.all(
width: 2,
color: const Color(0xFF184D12),
strokeAlign:
BorderSide.strokeAlignOutside,
)
: null, : null,
borderRadius: currentSelectedRating[selectedIndex] == 6 borderRadius:
? const BorderRadius.horizontal(left: Radius.circular(2), right: Radius.circular(8)) currentSelectedRating[selectedIndex] == 6
: const BorderRadius.horizontal(right: Radius.circular(8)), ? 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(() { setState(() {
currentSelectedRating[selectedIndex] = value; currentSelectedRating[selectedIndex] = value;
}); });
EasyDebounce.debounce('change-satiety', const Duration(milliseconds: 250), () { EasyDebounce.debounce(
context.read<DiaryCubit>().fillMood(diaryId: state.diary.id, mood: currentSelectedRating[selectedIndex].toInt() - 1); '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(); AppRouter.navigatorKey.currentContext?.pop();
final picker = ImagePicker(); final picker = ImagePicker();
XFile? image = await picker.pickImage(source: ImageSource.camera); XFile? image = await picker.pickImage(
source: ImageSource.camera,
);
if (image != null) { if (image != null) {
diaryCubit.uploadPhotos( diaryCubit.uploadPhotos(
files: [File(image.path)], files: [File(image.path)],
@@ -116,7 +118,8 @@ class _PhotoGalleryWidgetState extends State<PhotoGalleryWidget> {
builder: (context, state) { builder: (context, state) {
if (state is DiaryStateLoaded) { if (state is DiaryStateLoaded) {
int mealIndex = state.selectedMeal - 1; 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( return GestureDetector(
onTap: () async { onTap: () async {
showImagePicker(state.diary); showImagePicker(state.diary);
@@ -140,10 +143,11 @@ class _PhotoGalleryWidgetState extends State<PhotoGalleryWidget> {
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: CachedNetworkImage( 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(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
child: IconButton.filled( child: IconButton.filled(
@@ -153,12 +157,24 @@ class _PhotoGalleryWidgetState extends State<PhotoGalleryWidget> {
), ),
onPressed: () async { onPressed: () async {
await diaryCubit.deletePhoto( await diaryCubit.deletePhoto(
photo: state.diary.meals[mealIndex].photos[currentSelectedPhotoIndex], photo: state
.diary
.meals[mealIndex]
.photos[currentSelectedPhotoIndex],
diaryId: state.diary.id, diaryId: state.diary.id,
); );
setState(() { setState(() {
if (currentSelectedPhotoIndex == state.diary.meals[mealIndex].photos.length - 1 && currentSelectedPhotoIndex != 0) { if (currentSelectedPhotoIndex ==
currentSelectedPhotoIndex = state.diary.meals[mealIndex].photos.length - 2; 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, mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start,
children: [ children: [
for (int i = 0; i < state.diary.meals[mealIndex].photos.length + 1; i++) ...[ for (
if (i == state.diary.meals[mealIndex].photos.length && !state.isConsultantView && !state.isDiaryFinished && !state.isDiaryCommented) int i = 0;
i < state.diary.meals[mealIndex].photos.length + 1;
i++
) ...[
if (i == state.diary.meals[mealIndex].photos.length &&
state.canEditDiary)
Padding( Padding(
padding: const EdgeInsets.only(left: 8), padding: const EdgeInsets.only(left: 8),
child: InkWell( child: InkWell(
@@ -212,13 +233,15 @@ class _PhotoGalleryWidgetState extends State<PhotoGalleryWidget> {
height: 52, height: 52,
width: 52, width: 52,
child: CachedNetworkImage( 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, 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:collection/collection.dart';
import 'package:dotted_border/dotted_border.dart'; import 'package:dotted_border/dotted_border.dart';
import 'package:eatvio_app/core/theme/theme_helper.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/consultant_review_model.dart';
import 'package:eatvio_app/features/diary/models/diary_day_model.dart'; import 'package:eatvio_app/features/diary/models/diary_day_model.dart';
import 'package:eatvio_app/features/diary/presentation/bloc/diary_cubit.dart'; import 'package:eatvio_app/features/diary/presentation/bloc/diary_cubit.dart';
@@ -32,17 +30,26 @@ class _ProductWidgetState extends State<ProductWidget> {
super.initState(); super.initState();
diaryCubit = context.read<DiaryCubit>(); diaryCubit = context.read<DiaryCubit>();
portionName = widget.portion.portion == 'gr' ? 'гр' : 'шт'; portionName = widget.portion.portion == 'gr' ? 'гр' : 'шт';
isEaten = widget.portion.portionState == PortionState.eatenFromDiet ? true : false; isEaten = widget.portion.portionState == PortionState.eatenFromDiet
? true
: false;
} }
Widget _buildNutritionalInfo() { Widget _buildNutritionalInfo() {
if (widget.portion.productNutritionalValueId == null) { if (widget.portion.productNutritionalValueId == null) {
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), 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( 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; calories = (caloriesInfo.calories * widget.portion.quantity) / 100;
proteins = (caloriesInfo.proteins * widget.portion.quantity) / 100; proteins = (caloriesInfo.proteins * widget.portion.quantity) / 100;
fats = (caloriesInfo.fats * 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 { } else {
calories = caloriesInfo.energyOnPortion; calories = caloriesInfo.energyOnPortion;
proteins = caloriesInfo.proteinsOnPortion; proteins = caloriesInfo.proteinsOnPortion;
@@ -81,10 +89,17 @@ class _ProductWidgetState extends State<ProductWidget> {
Widget _buildNutritionChip(String label, int value) { Widget _buildNutritionChip(String label, int value) {
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), 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( child: Text(
'$label $value', '$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) { void didUpdateWidget(ProductWidget oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
portionName = widget.portion.portion == 'gr' ? 'гр' : 'шт'; 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) { Future updateProductInMeal(DiaryStateLoaded state, PortionModel portion) {
return showModalBottomSheet( return showModalBottomSheet(
useRootNavigator: true, useRootNavigator: true,
context: context, context: context,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(12))), shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
),
isScrollControlled: true, 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) { if (state is DiaryStateLoadedTomorrowWithDiet) {
return ListTile( return ListTile(
horizontalTitleGap: 0, 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), title: Text(widget.portion.productName),
subtitle: Column( subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('${widget.portion.quantity} ${widget.portion.portion == 'gr' ? 'гр' : 'шт'}'), Text(
'${widget.portion.quantity} ${widget.portion.portion == 'gr' ? 'гр' : 'шт'}',
),
const SizedBox(height: 4), const SizedBox(height: 4),
_buildNutritionalInfo(), _buildNutritionalInfo(),
], ],
), ),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
tileColor: const Color(0xFFF6FAFF), tileColor: const Color(0xFFF6FAFF),
); );
} }
if (state is DiaryStateLoaded) { if (state is DiaryStateLoaded) {
final isDiaryCommented = final canEditPortion = state.canEditDiary && !widget.portion.fromDiet;
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 consultantComments = state.consultantReview?.consultantComments; final consultantComments = state.consultantReview?.consultantComments;
final canMarkAsEaten = !state.isConsultantView && !isDiaryCommented && !isDiaryFinished; final canMarkAsEaten = state.canEditDiary;
PortionComment? portionComment; PortionComment? portionComment;
if (consultantComments != null) { 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) { if (widget.portion.portionState == PortionState.eatenDeleted) {
return DottedBorder( return DottedBorder(
@@ -151,27 +177,41 @@ class _ProductWidgetState extends State<ProductWidget> {
color: Color(0xFF6D6F71), color: Color(0xFF6D6F71),
), ),
child: ListTile( child: ListTile(
onTap: () => diaryCubit.restore(diaryId: state.diary.id, mealId: state.selectedMeal, portionId: widget.portion.id), onTap: state.canEditDiary
trailing: SvgPicture.asset('assets/icons/revert_product.svg'), ? () => 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, 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), title: Text(widget.portion.productName),
subtitle: Column( subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('${widget.portion.quantity} ${widget.portion.portion == 'gr' ? 'гр' : 'шт'}'), Text(
'${widget.portion.quantity} ${widget.portion.portion == 'gr' ? 'гр' : 'шт'}',
),
const SizedBox(height: 4), const SizedBox(height: 4),
_buildNutritionalInfo(), _buildNutritionalInfo(),
], ],
), ),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
// ignore: use_full_hex_values_for_flutter_colors // ignore: use_full_hex_values_for_flutter_colors
tileColor: const Color(0xfff6faff03).withValues(alpha: 0.01), tileColor: const Color(0xfff6faff03).withValues(alpha: 0.01),
), ),
); );
} else { } else {
Widget? trailing; Widget? trailing;
if (state.isConsultantView && !isDiaryCommented) { if (state.isConsultantView && !state.isDiaryCommented) {
trailing = GestureDetector( trailing = GestureDetector(
onTap: () { onTap: () {
setState(() { setState(() {
@@ -180,13 +220,15 @@ class _ProductWidgetState extends State<ProductWidget> {
}, },
child: SvgPicture.asset('assets/icons/edit.svg'), child: SvgPicture.asset('assets/icons/edit.svg'),
); );
} else if (!isDiaryCommented && !widget.portion.fromDiet) { } else if (canEditPortion) {
trailing = const Icon(Icons.more_vert_rounded); trailing = const Icon(Icons.more_vert_rounded);
} }
return Column( return Column(
children: [ children: [
ListTile( ListTile(
onTap: canEditPortion ? () => updateProductInMeal(state, widget.portion) : null, onTap: canEditPortion
? () => updateProductInMeal(state, widget.portion)
: null,
trailing: trailing, trailing: trailing,
leading: widget.portion.fromDiet leading: widget.portion.fromDiet
? Transform.scale( ? Transform.scale(
@@ -199,35 +241,55 @@ class _ProductWidgetState extends State<ProductWidget> {
isEaten = value!; isEaten = value!;
}); });
if (isEaten) { 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 { } 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, : null,
activeColor: appTheme.blueA400, activeColor: appTheme.blueA400,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
side: const BorderSide(color: Color(0xFF809CB9)), side: const BorderSide(color: Color(0xFF809CB9)),
), ),
) )
: null, : null,
horizontalTitleGap: 0, 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), title: Text(widget.portion.productName),
subtitle: Column( subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('${widget.portion.quantity} ${widget.portion.portion == 'gr' ? 'гр' : 'шт'}'), Text(
'${widget.portion.quantity} ${widget.portion.portion == 'gr' ? 'гр' : 'шт'}',
),
const SizedBox(height: 4), const SizedBox(height: 4),
_buildNutritionalInfo(), _buildNutritionalInfo(),
], ],
), ),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
tileColor: const Color(0xFFF6FAFF), tileColor: const Color(0xFFF6FAFF),
), ),
Padding( Padding(
padding: const EdgeInsets.only(top: 8.0), 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; final meals = state.diary.meals;
if (selectedIndex >= 0 && selectedIndex < meals.length) { if (selectedIndex >= 0 && selectedIndex < meals.length) {
if (isFirstFetch[selectedIndex]) { if (isFirstFetch[selectedIndex]) {
currentSelectedRating[selectedIndex] = meals[selectedIndex].satiety.toDouble() + 1; currentSelectedRating[selectedIndex] =
meals[selectedIndex].satiety.toDouble() + 1;
isFirstFetch[selectedIndex] = false; isFirstFetch[selectedIndex] = false;
} }
} }
@@ -36,21 +37,32 @@ class _SatietyRatingWidgetState extends State<SatietyRatingWidget> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text(
state.isConsultantView ? 'Сытость пользователя после приема пищи' : 'Ваша сытость после приёма пищи', state.isConsultantView
? 'Сытость пользователя после приема пищи'
: 'Ваша сытость после приёма пищи',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: const TextStyle(fontSize: 16), style: const TextStyle(fontSize: 16),
), ),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ 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( RatingBar.builder(
ignoreGestures: state.isConsultantView || state.isDiaryFinished || state.isDiaryCommented, ignoreGestures: !state.canEditDiary,
initialRating: (selectedIndex >= 0 && selectedIndex < state.diary.meals.length) initialRating:
? state.diary.meals[selectedIndex].satiety.toDouble() + 1 (selectedIndex >= 0 &&
selectedIndex < state.diary.meals.length)
? state.diary.meals[selectedIndex].satiety
.toDouble() +
1
: 3, : 3,
itemCount: 6, itemCount: 6,
itemSize: MediaQuery.of(context).size.width / 6 - (124 / 6), itemSize:
MediaQuery.of(context).size.width / 6 - (124 / 6),
updateOnDrag: true, updateOnDrag: true,
itemBuilder: (context, index) { itemBuilder: (context, index) {
switch (index) { switch (index) {
@@ -58,82 +70,154 @@ class _SatietyRatingWidgetState extends State<SatietyRatingWidget> {
return Container( return Container(
width: MediaQuery.of(context).size.width / 6, width: MediaQuery.of(context).size.width / 6,
height: 24, height: 24,
margin: currentSelectedRating[selectedIndex] == 1 ? const EdgeInsets.symmetric(horizontal: 2) : null, margin: currentSelectedRating[selectedIndex] == 1
? const EdgeInsets.symmetric(horizontal: 2)
: null,
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFC82222), color: const Color(0xFFC82222),
border: currentSelectedRating[selectedIndex] == 1 border:
? Border.all(width: 2, color: const Color(0xFFAA3333), strokeAlign: BorderSide.strokeAlignOutside) currentSelectedRating[selectedIndex] == 1
? Border.all(
width: 2,
color: const Color(0xFFAA3333),
strokeAlign:
BorderSide.strokeAlignOutside,
)
: null, : null,
borderRadius: currentSelectedRating[selectedIndex] == 1 borderRadius:
? const BorderRadius.horizontal(left: Radius.circular(8), right: Radius.circular(2)) currentSelectedRating[selectedIndex] == 1
: const BorderRadius.horizontal(left: Radius.circular(8)), ? const BorderRadius.horizontal(
left: Radius.circular(8),
right: Radius.circular(2),
)
: const BorderRadius.horizontal(
left: Radius.circular(8),
),
), ),
); );
case 1: case 1:
return Container( return Container(
width: MediaQuery.of(context).size.width / 6, width: MediaQuery.of(context).size.width / 6,
height: 24, height: 24,
margin: currentSelectedRating[selectedIndex] == 2 ? const EdgeInsets.symmetric(horizontal: 2) : null, margin: currentSelectedRating[selectedIndex] == 2
? const EdgeInsets.symmetric(horizontal: 2)
: null,
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFFF3E3E), color: const Color(0xFFFF3E3E),
border: currentSelectedRating[selectedIndex] == 2 border:
? Border.all(width: 2, color: const Color(0xFF550000), strokeAlign: BorderSide.strokeAlignOutside) currentSelectedRating[selectedIndex] == 2
? Border.all(
width: 2,
color: const Color(0xFF550000),
strokeAlign:
BorderSide.strokeAlignOutside,
)
: null,
borderRadius:
currentSelectedRating[selectedIndex] == 2
? BorderRadius.circular(2)
: null, : null,
borderRadius: currentSelectedRating[selectedIndex] == 2 ? BorderRadius.circular(2) : null,
), ),
); );
case 2: case 2:
return Container( return Container(
width: MediaQuery.of(context).size.width / 6, width: MediaQuery.of(context).size.width / 6,
height: 24, height: 24,
margin: currentSelectedRating[selectedIndex] == 3 ? const EdgeInsets.symmetric(horizontal: 2) : null, margin: currentSelectedRating[selectedIndex] == 3
? const EdgeInsets.symmetric(horizontal: 2)
: null,
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFF8C926), color: const Color(0xFFF8C926),
border: currentSelectedRating[selectedIndex] == 3 border:
? Border.all(width: 2, color: const Color(0xFF8C5D00), strokeAlign: BorderSide.strokeAlignOutside) currentSelectedRating[selectedIndex] == 3
? Border.all(
width: 2,
color: const Color(0xFF8C5D00),
strokeAlign:
BorderSide.strokeAlignOutside,
)
: null,
borderRadius:
currentSelectedRating[selectedIndex] == 3
? BorderRadius.circular(2)
: null, : null,
borderRadius: currentSelectedRating[selectedIndex] == 3 ? BorderRadius.circular(2) : null,
), ),
); );
case 3: case 3:
return Container( return Container(
width: MediaQuery.of(context).size.width / 6, width: MediaQuery.of(context).size.width / 6,
height: 24, height: 24,
margin: currentSelectedRating[selectedIndex] == 4 ? const EdgeInsets.symmetric(horizontal: 2) : null, margin: currentSelectedRating[selectedIndex] == 4
? const EdgeInsets.symmetric(horizontal: 2)
: null,
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFFFF3BF), color: const Color(0xFFFFF3BF),
border: currentSelectedRating[selectedIndex] == 4 border:
? Border.all(width: 2, color: const Color(0xFFB5911B), strokeAlign: BorderSide.strokeAlignOutside) currentSelectedRating[selectedIndex] == 4
? Border.all(
width: 2,
color: const Color(0xFFB5911B),
strokeAlign:
BorderSide.strokeAlignOutside,
)
: null,
borderRadius:
currentSelectedRating[selectedIndex] == 4
? BorderRadius.circular(2)
: null, : null,
borderRadius: currentSelectedRating[selectedIndex] == 4 ? BorderRadius.circular(2) : null,
), ),
); );
case 4: case 4:
return Container( return Container(
width: MediaQuery.of(context).size.width / 6, width: MediaQuery.of(context).size.width / 6,
height: 24, height: 24,
margin: currentSelectedRating[selectedIndex] == 5 ? const EdgeInsets.symmetric(horizontal: 2) : null, margin: currentSelectedRating[selectedIndex] == 5
? const EdgeInsets.symmetric(horizontal: 2)
: null,
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF62E833), color: const Color(0xFF62E833),
border: currentSelectedRating[selectedIndex] == 5 border:
? Border.all(width: 2, color: const Color(0xFF276918), strokeAlign: BorderSide.strokeAlignOutside) currentSelectedRating[selectedIndex] == 5
? Border.all(
width: 2,
color: const Color(0xFF276918),
strokeAlign:
BorderSide.strokeAlignOutside,
)
: null,
borderRadius:
currentSelectedRating[selectedIndex] == 5
? BorderRadius.circular(2)
: null, : null,
borderRadius: currentSelectedRating[selectedIndex] == 5 ? BorderRadius.circular(2) : null,
), ),
); );
default: default:
return Container( return Container(
width: MediaQuery.of(context).size.width / 6, width: MediaQuery.of(context).size.width / 6,
height: 24, height: 24,
margin: currentSelectedRating[selectedIndex] == 6 ? const EdgeInsets.symmetric(horizontal: 2) : null, margin: currentSelectedRating[selectedIndex] == 6
? const EdgeInsets.symmetric(horizontal: 2)
: null,
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF4DCE20), color: const Color(0xFF4DCE20),
border: currentSelectedRating[selectedIndex] == 6 border:
? Border.all(width: 2, color: const Color(0xFF184D12), strokeAlign: BorderSide.strokeAlignOutside) currentSelectedRating[selectedIndex] == 6
? Border.all(
width: 2,
color: const Color(0xFF184D12),
strokeAlign:
BorderSide.strokeAlignOutside,
)
: null, : null,
borderRadius: currentSelectedRating[selectedIndex] == 6 borderRadius:
? const BorderRadius.horizontal(left: Radius.circular(2), right: Radius.circular(8)) currentSelectedRating[selectedIndex] == 6
: const BorderRadius.horizontal(right: Radius.circular(8)), ? 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(() { setState(() {
currentSelectedRating[selectedIndex] = value; currentSelectedRating[selectedIndex] = value;
}); });
EasyDebounce.debounce('change-satiety', const Duration(milliseconds: 250), () { EasyDebounce.debounce(
context.read<DiaryCubit>().fillSatiety(diaryId: state.diary.id, satiety: currentSelectedRating[selectedIndex].toInt() - 1); '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(); Navigator.of(context).pop();
diaryCubit.fillTime( diaryCubit.fillTime(
diaryId: diaryId, 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), 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( 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), borderRadius: BorderRadius.circular(100),
child: Ink( child: Ink(
height: 32, height: 32,
@@ -110,7 +113,16 @@ class _TimeOfMealPickerWidgetState extends State<TimeOfMealPickerWidget> {
alignment: Alignment.center, alignment: Alignment.center,
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text( 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( style: const TextStyle(
color: Color(0xFF6D6F71), color: Color(0xFF6D6F71),
fontSize: 17, fontSize: 17,

View File

@@ -296,6 +296,7 @@ class _AboutDietScreenState extends State<AboutDietScreen> {
actions: [ actions: [
CustomPopup( CustomPopup(
isAuthor: dietState.diet.author.authorIsMe, isAuthor: dietState.diet.author.authorIsMe,
isAuthorAdmin: dietState.diet.author.isAdmin,
path: 'diet/${dietState.diet.slug}', path: 'diet/${dietState.diet.slug}',
complainedContentType: ComplainedContent.diet, complainedContentType: ComplainedContent.diet,
id: dietState.diet.id, 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/diets/presentation/widgets/info_bouble.dart';
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
import 'package:eatvio_app/core/theme/custom_text_style.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/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/profile/models/profile_model.dart'; import 'package:eatvio_app/features/profile/models/profile_model.dart';
import 'package:eatvio_app/features/user_details/presentation/user_details_screen.dart'; import 'package:eatvio_app/features/user_details/presentation/user_details_screen.dart';
@@ -11,7 +12,14 @@ import 'package:flutter_svg/svg.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
class AuthorCard extends StatelessWidget { 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 Author author;
final String title; final String title;
final Color? backgroundColor; final Color? backgroundColor;
@@ -21,15 +29,28 @@ class AuthorCard extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GestureDetector( return GestureDetector(
onTap: () { 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( child: Container(
width: MediaQuery.sizeOf(context).width, 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( child: Stack(
children: [ children: [
Padding( 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( child: Column(
children: [ children: [
ListTile( ListTile(
@@ -41,28 +62,43 @@ class AuthorCard extends StatelessWidget {
shape: BoxShape.circle, shape: BoxShape.circle,
image: DecorationImage( image: DecorationImage(
image: NetworkImage( image: NetworkImage(
customerProfile != null joinBaseUrl(
? customerProfile!.profilePhotoUrl.contains('https') sl<AppConfig>().baseUrl,
? customerProfile!.profilePhotoUrl customerProfile != null
: sl<AppConfig>().baseUrl + customerProfile!.profilePhotoUrl ? customerProfile!.profilePhotoUrl
: author.authorProfileIcon.contains('https') : author.authorProfileIcon,
? author.authorProfileIcon ),
: sl<AppConfig>().baseUrl + author.authorProfileIcon,
), ),
fit: BoxFit.cover, fit: BoxFit.cover,
), ),
), ),
), ),
subtitle: Text( subtitle: Text(
customerProfile != null ? customerProfile!.name : author.authorName, customerProfile != null
style: TextStyles.textLSemibold.copyWith(color: appTheme.black), ? 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( Row(
children: [ children: [
if (customerProfile == null) ...[ if (customerProfile == null) ...[
Text('Консультант', style: TextStyles.textMMedium.copyWith(color: appTheme.black)), Text(
'Консультант',
style: TextStyles.textMMedium.copyWith(
color: appTheme.black,
),
),
const SizedBox(width: 10), const SizedBox(width: 10),
if (author.authorPlaceInRating != null) if (author.authorPlaceInRating != null)
@@ -70,20 +106,35 @@ class AuthorCard extends StatelessWidget {
radius: 12, radius: 12,
backgroundColor: appTheme.cardBackground, backgroundColor: appTheme.cardBackground,
children: [ children: [
Text('${author.authorPlaceInRating}-й в рейтинге', style: TextStyles.textMMediumAuto.copyWith(color: appTheme.blueA400)), Text(
'${author.authorPlaceInRating}-й в рейтинге',
style: TextStyles.textMMediumAuto.copyWith(
color: appTheme.blueA400,
),
),
], ],
), ),
] else ] else
Expanded( Expanded(
child: GestureDetector( child: GestureDetector(
onTap: () { onTap: () {
GoRouter.of(context).pushNamed(UserDetailsScreen.name, extra: customerProfile!.slug); GoRouter.of(context).pushNamed(
UserDetailsScreen.name,
extra: customerProfile!.slug,
);
}, },
child: InfoBouble( child: InfoBouble(
radius: 12, radius: 12,
backgroundColor: appTheme.cardBackground, backgroundColor: appTheme.cardBackground,
mainAxisAlignment: MainAxisAlignment.center, 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( child: Container(
width: 28, width: 28,
height: 28, height: 28,
decoration: BoxDecoration(color: appTheme.whiteA700, borderRadius: BorderRadius.circular(8)), decoration: BoxDecoration(
child: Center(child: SvgPicture.asset('assets/icons/arrow.svg', width: 12, height: 12, fit: BoxFit.none)), 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'; import 'package:share_plus/share_plus.dart';
class MoreOptions extends StatelessWidget { 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 ComplainedContent? complainedContentType;
final int? id; final int? id;
final String? slug; final String? slug;
final String path; final String path;
final bool? disableActions; final bool? disableActions;
final bool isAuthorAdmin;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -83,40 +92,42 @@ class MoreOptions extends StatelessWidget {
], ],
), ),
), ),
const Padding( if (!isAuthorAdmin) ...[
padding: EdgeInsets.symmetric(vertical: 12.0), const Padding(
child: CustomDivider(), padding: EdgeInsets.symmetric(vertical: 12.0),
), child: CustomDivider(),
GestureDetector( ),
onTap: disableActions == true GestureDetector(
? null onTap: disableActions == true
: () { ? null
if (complainedContentType != null) { : () {
showModalBottomSheet( if (complainedContentType != null) {
context: context, showModalBottomSheet(
isScrollControlled: true, context: context,
builder: (context) => ComplainingBottomSheet( isScrollControlled: true,
complainedContentType: complainedContentType!, builder: (context) => ComplainingBottomSheet(
id: id, complainedContentType: complainedContentType!,
slug: slug, id: id,
), slug: slug,
); ),
} );
}, }
child: Row( },
children: [ child: Row(
Expanded( children: [
child: Text( Expanded(
s.complain, child: Text(
style: TextStyles.textMMedium.copyWith( s.complain,
color: appTheme.black, 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 @freezed
abstract class Author with _$Author { abstract class Author with _$Author {
const Author._();
const factory Author({ const factory Author({
@JsonKey(name: "author_slug") required String authorSlug, @JsonKey(name: "author_slug") required String authorSlug,
@JsonKey(name: "author_name") required String authorName, @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: "author_is_me") required bool authorIsMe,
@JsonKey(name: "is_entity_subscribed_by_me") bool? isEntitySubscribedByMe, @JsonKey(name: "is_entity_subscribed_by_me") bool? isEntitySubscribedByMe,
@JsonKey(name: "author_place_in_rating") int? authorPlaceInRating, @JsonKey(name: "author_place_in_rating") int? authorPlaceInRating,
@JsonKey(name: "author_roles") @Default([]) List<String> authorRoles,
}) = _Author; }) = _Author;
factory Author.fromJson(Map<String, dynamic> json) => _$AuthorFromJson(json); factory Author.fromJson(Map<String, dynamic> json) => _$AuthorFromJson(json);
bool get isAdmin => authorRoles.contains('admin');
} }
@freezed @freezed

View File

@@ -26,16 +26,47 @@ class PostFeedMapper {
totalViews: item.postViewsCount, totalViews: item.postViewsCount,
totalComments: item.postCommentsCount, totalComments: item.postCommentsCount,
wasLikedByMe: item.postIsLikedByUser, wasLikedByMe: item.postIsLikedByUser,
images: item.images, images: item.images.where((image) => hasRenderableImage(image.url)).toList(),
videoEmbedUrl: item.videoEmbedUrl, videoEmbedUrl: item.videoEmbedUrl,
videoUrlThumb: item.videoUrlThumb, videoUrlThumb: item.videoUrlThumb,
); );
} }
static String resolveMediaUrl(String? url) { static String resolveMediaUrl(String? url) {
if (url == null || url.isEmpty) return ''; if (url == null || url.isEmpty || isPlaceholderMediaUrl(url)) {
if (url.startsWith('http://') || url.startsWith('https://')) return url; return '';
if (url.startsWith('/')) return '${sl<AppConfig>().baseUrl}$url'; }
if (url.startsWith('http://') || url.startsWith('https://')) {
return url;
}
if (url.startsWith('/')) {
return '${sl<AppConfig>().baseUrl}$url';
}
return '${sl<AppConfig>().baseUrl}/storage/$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 { }) async {
try { try {
_query = query; _query = query;
currentPage = 1;
final type = _types[state.filterState.typeIndex]; final type = _types[state.filterState.typeIndex];
final duration = _durationValues[state.filterState.durationIndex]; final duration = _durationValues[state.filterState.durationIndex];
final sorting = _sortingValues[state.filterState.sortingIndex]; final sorting = _sortingValues[state.filterState.sortingIndex];

View File

@@ -149,6 +149,7 @@ class PlansCubit extends Cubit<PlansState> {
}) async { }) async {
try { try {
_query = query; _query = query;
currentPage = 1;
final type = _types[state.filterState.typeIndex]; final type = _types[state.filterState.typeIndex];
final duration = _durationValues[state.filterState.durationIndex]; final duration = _durationValues[state.filterState.durationIndex];
final minPrice = state.filterState.priceFilter.$1; final minPrice = state.filterState.priceFilter.$1;

View File

@@ -46,6 +46,7 @@ class RecipesCubit extends Cubit<RecipesState> {
}) async { }) async {
try { try {
_query = query; _query = query;
currentPage = 1;
final recipesList = await feedRepository.getRecipes( final recipesList = await feedRepository.getRecipes(
tags: _selectedTags.toList(), tags: _selectedTags.toList(),
query: query, query: query,

View File

@@ -123,6 +123,19 @@ class _FeedScreenContentState extends State<FeedScreenContent> {
super.dispose(); super.dispose();
} }
Future<void> _openCreateAndReloadFeed({
required Future<Object?> Function() openCreate,
required Future<void> Function() reload,
}) async {
final result = await openCreate();
if (!mounted) {
return;
}
if (shouldReloadFeedAfterCreate(result)) {
await reload();
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
FlutterNativeSplash.remove(); FlutterNativeSplash.remove();
@@ -254,26 +267,43 @@ class _FeedScreenContentState extends State<FeedScreenContent> {
const SizedBox(width: 12), const SizedBox(width: 12),
switch (_selectedChipIndex) { switch (_selectedChipIndex) {
1 when !hidePlans => AddItemButton( 1 when !hidePlans => AddItemButton(
onTap: state.profile?.roles.contains('consultant') ?? false ? () => GoRouter.of(context).pushNamed(CreatePlanScreen.name) : null, onTap: state.profile?.roles.contains('consultant') ?? false
? () => _openCreateAndReloadFeed(
openCreate: () => GoRouter.of(context).pushNamed(CreatePlanScreen.name),
reload: () => context.read<PlansCubit>().loadPlans(),
)
: null,
), ),
2 when hidePlans => AddItemButton( 2 when hidePlans => AddItemButton(
onTap: state.profile?.roles.contains('consultant') ?? false ? () => GoRouter.of(context).pushNamed(RecipeCreateScreen.name) : null, onTap: state.profile?.roles.contains('consultant') ?? false
? () => _openCreateAndReloadFeed(
openCreate: () => GoRouter.of(context).pushNamed(RecipeCreateScreen.name),
reload: () => context.read<RecipesCubit>().loadRecipesList(),
)
: null,
), ),
3 when !hidePlans => AddItemButton( 3 when !hidePlans => AddItemButton(
onTap: state.profile?.roles.contains('consultant') ?? false ? () => GoRouter.of(context).pushNamed(RecipeCreateScreen.name) : null, onTap: state.profile?.roles.contains('consultant') ?? false
? () => _openCreateAndReloadFeed(
openCreate: () => GoRouter.of(context).pushNamed(RecipeCreateScreen.name),
reload: () => context.read<RecipesCubit>().loadRecipesList(),
)
: null,
), ),
3 when hidePlans => AddItemButton( 3 when hidePlans => AddItemButton(
onTap: state.profile?.roles.contains('consultant') ?? false onTap: state.profile?.roles.contains('consultant') ?? false
? () async { ? () => _openCreateAndReloadFeed(
await GoRouter.of(context).pushNamed(CreateDietScreen.name, extra: {'isEditing': false}); openCreate: () => GoRouter.of(context).pushNamed(CreateDietScreen.name, extra: {'isEditing': false}),
} reload: () => context.read<DietsCubit>().loadDiets(),
)
: null, : null,
), ),
4 when !hidePlans => AddItemButton( 4 when !hidePlans => AddItemButton(
onTap: state.profile?.roles.contains('consultant') ?? false onTap: state.profile?.roles.contains('consultant') ?? false
? () async { ? () => _openCreateAndReloadFeed(
await GoRouter.of(context).pushNamed(CreateDietScreen.name); openCreate: () => GoRouter.of(context).pushNamed(CreateDietScreen.name),
} reload: () => context.read<DietsCubit>().loadDiets(),
)
: null, : null,
), ),
_ => const AddItemButton(key: AppKeys.feedScreenBlogPostCreationButton), _ => const AddItemButton(key: AppKeys.feedScreenBlogPostCreationButton),
@@ -301,6 +331,9 @@ class _FeedScreenContentState extends State<FeedScreenContent> {
} }
} }
/// Create screens pop `true` (diet/plan) or `'active'` (recipe) after a successful publish.
bool shouldReloadFeedAfterCreate(Object? result) => result == true || result == 'active';
enum ViewableContentType { enum ViewableContentType {
plans, plans,
posts, posts,

View File

@@ -39,15 +39,17 @@ class BlogPostMediaContent extends StatefulWidget {
class _BlogPostMediaContentState extends State<BlogPostMediaContent> { class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
static const _counterDisplayDuration = Duration(seconds: 1); static const _counterDisplayDuration = Duration(seconds: 1);
/// Landscape / video slide aspect (width / height). /// Fallback while the real photo size is unknown, and for video-only.
static const _horizontalAspectRatio = 16 / 9; static const _horizontalAspectRatio = 16 / 9;
/// Portrait slide aspect (width / height).
static const _verticalAspectRatio = 3 / 4; static const _verticalAspectRatio = 3 / 4;
bool _isCounterVisible = false; bool _isCounterVisible = false;
Timer? _counterHideTimer; Timer? _counterHideTimer;
final Map<String, double> _aspectByUrl = {};
final Map<String, ImageStream> _aspectStreams = {};
final Map<String, ImageStreamListener> _aspectListeners = {};
bool get _hasVideo => bool get _hasVideo =>
widget.videoEmbedUrl != null && widget.videoEmbedUrl!.trim().isNotEmpty; widget.videoEmbedUrl != null && widget.videoEmbedUrl!.trim().isNotEmpty;
@@ -82,7 +84,6 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
bool get _shouldShowCounter => !_isCurrentSlideVideo || _isCounterVisible; bool get _shouldShowCounter => !_isCurrentSlideVideo || _isCounterVisible;
/// Height is fixed for the whole post from the first gallery image (web-like).
String get _postOrientation { String get _postOrientation {
if (_gallery.isNotEmpty) { if (_gallery.isNotEmpty) {
return _gallery.first.orientation; return _gallery.first.orientation;
@@ -90,23 +91,89 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
return 'horizontal'; return 'horizontal';
} }
double _heightForAspect(double aspectRatio, double width) {
if (aspectRatio <= 0) {
return width / _horizontalAspectRatio;
}
return width / aspectRatio;
}
double _heightForOrientation(String orientation, double width) { double _heightForOrientation(String orientation, double width) {
final aspectRatio = orientation == 'vertical' final aspectRatio = orientation == 'vertical'
? _verticalAspectRatio ? _verticalAspectRatio
: _horizontalAspectRatio; : _horizontalAspectRatio;
final maxHeight = MediaQuery.sizeOf(context).height * 0.7; return _heightForAspect(aspectRatio, width);
return (width / aspectRatio).clamp(180.0, maxHeight); }
/// Window height follows the current photo's real size, not a fake 16:9 / 3:4 box.
double _mediaHeight(double width) {
final gallery = _gallery;
if (gallery.isEmpty) {
return _heightForOrientation(_postOrientation, width);
}
final index = widget.currentGalleryIndex;
final image = index < gallery.length ? gallery[index] : gallery.first;
final aspect = _aspectByUrl[image.url];
if (aspect != null && aspect > 0) {
return _heightForAspect(aspect, width);
}
return _heightForOrientation(image.orientation, width);
}
void _syncAspectListeners() {
final urls = _gallery.map((image) => image.url).toSet();
for (final url in _aspectStreams.keys.toList()) {
if (urls.contains(url)) {
continue;
}
final stream = _aspectStreams.remove(url);
final listener = _aspectListeners.remove(url);
if (stream != null && listener != null) {
stream.removeListener(listener);
}
_aspectByUrl.remove(url);
}
for (final url in urls) {
if (_aspectStreams.containsKey(url)) {
continue;
}
final stream = CachedNetworkImageProvider(
url,
).resolve(ImageConfiguration.empty);
final listener = ImageStreamListener((info, _) {
final aspect = info.image.width / info.image.height;
if (!mounted || aspect <= 0 || _aspectByUrl[url] == aspect) {
return;
}
setState(() => _aspectByUrl[url] = aspect);
}, onError: (_, _) {});
stream.addListener(listener);
_aspectStreams[url] = stream;
_aspectListeners[url] = listener;
}
}
@override
void initState() {
super.initState();
_syncAspectListeners();
} }
@override @override
void dispose() { void dispose() {
_counterHideTimer?.cancel(); _counterHideTimer?.cancel();
for (final url in _aspectStreams.keys) {
_aspectStreams[url]!.removeListener(_aspectListeners[url]!);
}
super.dispose(); super.dispose();
} }
@override @override
void didUpdateWidget(covariant BlogPostMediaContent oldWidget) { void didUpdateWidget(covariant BlogPostMediaContent oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
_syncAspectListeners();
if (oldWidget.currentGalleryIndex != widget.currentGalleryIndex) { if (oldWidget.currentGalleryIndex != widget.currentGalleryIndex) {
_scheduleCounterHideOnVideo(); _scheduleCounterHideOnVideo();
} }
@@ -138,16 +205,26 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
return false; return false;
} }
Widget _imageSlide(String itemUrl, int itemIndex, double height) { Widget _photo(String url, {required double width, double? height}) {
return CachedNetworkImage(
fit: BoxFit.fitWidth,
alignment: Alignment.center,
width: width,
height: height,
imageUrl: url,
errorWidget: (context, str, object) => const SizedBox.shrink(),
);
}
Widget _imageSlide(
String itemUrl,
int itemIndex, {
required double width,
required double height,
}) {
return GestureDetector( return GestureDetector(
onTap: () => widget.onOpenGallery(_galleryUrls, itemIndex), onTap: () => widget.onOpenGallery(_galleryUrls, itemIndex),
child: CachedNetworkImage( child: _photo(itemUrl, width: width, height: height),
fit: BoxFit.cover,
width: MediaQuery.sizeOf(context).width,
height: height,
imageUrl: itemUrl,
errorWidget: (context, str, object) => const SizedBox.shrink(),
),
); );
} }
@@ -175,6 +252,8 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
viewportFraction: 1, viewportFraction: 1,
height: height, height: height,
enableInfiniteScroll: false, enableInfiniteScroll: false,
disableCenter: true,
padEnds: false,
onPageChanged: (index, _) => widget.onGalleryChanged(index), onPageChanged: (index, _) => widget.onGalleryChanged(index),
), ),
), ),
@@ -200,42 +279,59 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth.isFinite
? constraints.maxWidth
: MediaQuery.sizeOf(context).width;
return _buildMedia(width);
},
);
}
Widget _buildMedia(double width) {
final gallery = _gallery; final gallery = _gallery;
final width = MediaQuery.sizeOf(context).width; final mediaHeight = _mediaHeight(width);
final mediaHeight = _heightForOrientation(_postOrientation, width);
if (gallery.length > 1 || (gallery.length == 1 && _hasVideo)) { if (gallery.length > 1 || (gallery.length == 1 && _hasVideo)) {
final itemCount = _slideCount; final itemCount = _slideCount;
return _carousel( return AnimatedSize(
itemCount: itemCount, duration: const Duration(milliseconds: 200),
height: mediaHeight, curve: Curves.easeInOut,
itemBuilder: (context, itemIndex) { child: _carousel(
if (itemIndex < gallery.length) { itemCount: itemCount,
return _imageSlide(gallery[itemIndex].url, itemIndex, mediaHeight); height: mediaHeight,
} itemBuilder: (context, itemIndex) {
if (_hasVideo) { if (itemIndex < gallery.length) {
return _videoSlide(); return _imageSlide(
} gallery[itemIndex].url,
return const SizedBox.shrink(); itemIndex,
}, width: width,
height: mediaHeight,
);
}
if (_hasVideo) {
return _videoSlide();
}
return const SizedBox.shrink();
},
),
); );
} }
if (gallery.length == 1) { if (gallery.length == 1) {
return _imageSlide(gallery.first.url, 0, mediaHeight); return GestureDetector(
onTap: () => widget.onOpenGallery(_galleryUrls, 0),
child: _photo(gallery.first.url, width: width),
);
} }
if (_hasVideo) { if (_hasVideo) {
return SizedBox(height: mediaHeight, width: width, child: _videoSlide()); return SizedBox(height: mediaHeight, width: width, child: _videoSlide());
} }
if (widget.imageUrl.isNotEmpty) { if (PostFeedMapper.hasRenderableImage(widget.imageUrl)) {
final image = CachedNetworkImage( final image = _photo(widget.imageUrl, width: width);
fit: BoxFit.fitWidth,
width: width,
imageUrl: widget.imageUrl,
errorWidget: (context, str, object) => const SizedBox.shrink(),
);
return GestureDetector( return GestureDetector(
onTap: () => widget.onOpenGallery([widget.imageUrl], 0), onTap: () => widget.onOpenGallery([widget.imageUrl], 0),
child: widget.heroTag == null child: widget.heroTag == null

View File

@@ -83,6 +83,11 @@ class _BlogPostWidgetState extends State<BlogPostWidget> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final imageUrl = PostFeedMapper.resolveMediaUrl(post.image); final imageUrl = PostFeedMapper.resolveMediaUrl(post.image);
final embedUrl = post.videoEmbedUrl?.trim(); final embedUrl = post.videoEmbedUrl?.trim();
final hasMedia = PostFeedMapper.hasVisibleMedia(
image: imageUrl,
images: post.images,
videoEmbedUrl: embedUrl,
);
return InkWell( return InkWell(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
@@ -97,30 +102,27 @@ class _BlogPostWidgetState extends State<BlogPostWidget> {
children: [ children: [
const SizedBox(height: 12), const SizedBox(height: 12),
_BlogPostHeader(post: post), _BlogPostHeader(post: post),
_BlogPostTextContent(post: post), _BlogPostTextContent(post: post, includeBottomSpacing: hasMedia),
BlogPostMediaContent( if (hasMedia)
galleryImages: post.images, BlogPostMediaContent(
imageUrl: imageUrl, galleryImages: post.images,
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty imageUrl: imageUrl,
? embedUrl videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null,
: null, videoUrlThumb: post.videoUrlThumb,
videoUrlThumb: post.videoUrlThumb, currentGalleryIndex: currentGalleryIndex,
currentGalleryIndex: currentGalleryIndex, heroTag: 'feed-blog-post-${post.id}-cover',
heroTag: 'feed-blog-post-${post.id}-cover', onOpenGallery: (urls, index) => _openGallery(
onOpenGallery: (urls, index) => _openGallery( urls,
urls, index,
index, heroTag: urls.length == 1 ? 'feed-blog-post-${post.id}-cover' : null,
heroTag: urls.length == 1 ),
? 'feed-blog-post-${post.id}-cover' onGalleryChanged: (index) {
: null, setState(() {
currentGalleryIndex = index;
});
},
), ),
onGalleryChanged: (index) { SizedBox(height: hasMedia ? 12 : 8),
setState(() {
currentGalleryIndex = index;
});
},
),
const SizedBox(height: 12),
_BlogPostActionsRow( _BlogPostActionsRow(
post: post, post: post,
isLiked: isLiked, isLiked: isLiked,
@@ -199,6 +201,7 @@ class _BlogPostHeader extends StatelessWidget {
), ),
CustomPopup( CustomPopup(
isAuthor: post.author.authorIsMe, isAuthor: post.author.authorIsMe,
isAuthorAdmin: post.author.isAdmin,
complainedContentType: ComplainedContent.posts, complainedContentType: ComplainedContent.posts,
path: 'blog_post/${post.id}', path: 'blog_post/${post.id}',
id: post.id, id: post.id,
@@ -212,9 +215,10 @@ class _BlogPostHeader extends StatelessWidget {
} }
class _BlogPostTextContent extends StatelessWidget { class _BlogPostTextContent extends StatelessWidget {
const _BlogPostTextContent({required this.post}); const _BlogPostTextContent({required this.post, required this.includeBottomSpacing});
final FeedBlogPostModel post; final FeedBlogPostModel post;
final bool includeBottomSpacing;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -258,7 +262,7 @@ class _BlogPostTextContent extends StatelessWidget {
), ),
), ),
), ),
if (post.title.isNotEmpty || post.description.isNotEmpty) if (includeBottomSpacing && (post.title.isNotEmpty || post.description.isNotEmpty))
const SizedBox(height: 8), const SizedBox(height: 8),
], ],
); );

View File

@@ -3,6 +3,7 @@ import 'package:eatvio_app/core/comments/presentation/comments_actions_bottom_sh
import 'package:eatvio_app/core/enums/complained_content.dart'; import 'package:eatvio_app/core/enums/complained_content.dart';
import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/utils/date_time_utils.dart'; import 'package:eatvio_app/core/utils/date_time_utils.dart';
import 'package:eatvio_app/core/utils/url_utils.dart';
import 'package:eatvio_app/core/utils/toasts.dart'; import 'package:eatvio_app/core/utils/toasts.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart'; import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_state.dart'; import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_state.dart';
@@ -54,13 +55,15 @@ class _CommentsWidgetState extends State<CommentsWidget> {
@override @override
void didUpdateWidget(CommentsWidget oldWidget) { void didUpdateWidget(CommentsWidget oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (widget.highlightedCommentId != null && widget.highlightedCommentId != oldWidget.highlightedCommentId) { if (widget.highlightedCommentId != null &&
widget.highlightedCommentId != oldWidget.highlightedCommentId) {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollToHighlightedComment(); _scrollToHighlightedComment();
}); });
} else if (widget.comment != oldWidget.comment && widget.highlightedCommentId != null) { } else if (widget.comment != oldWidget.comment &&
// Comments might have just loaded widget.highlightedCommentId != null) {
WidgetsBinding.instance.addPostFrameCallback((_) { // Comments might have just loaded
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollToHighlightedComment(); _scrollToHighlightedComment();
}); });
} }
@@ -84,9 +87,22 @@ class _CommentsWidgetState extends State<CommentsWidget> {
children: [ children: [
for (int i = 0; i < widget.comment.data.length; i++) ...[ for (int i = 0; i < widget.comment.data.length; i++) ...[
_comment(s, widget.comment.data[i]), _comment(s, widget.comment.data[i]),
if (widget.comment.data[i].childrenComments.isNotEmpty) const SizedBox(height: 8), if (widget.comment.data[i].childrenComments.isNotEmpty)
for (int j = 0; j < widget.comment.data[i].childrenComments.length; j++) const SizedBox(height: 8),
Padding(padding: const EdgeInsets.only(left: 40), child: _comment(s, widget.comment.data[i].childrenComments[j], 16, true)), for (
int j = 0;
j < widget.comment.data[i].childrenComments.length;
j++
)
Padding(
padding: const EdgeInsets.only(left: 40),
child: _comment(
s,
widget.comment.data[i].childrenComments[j],
16,
true,
),
),
const SizedBox(height: 8), const SizedBox(height: 8),
], ],
const SizedBox(height: 48), const SizedBox(height: 48),
@@ -94,9 +110,18 @@ class _CommentsWidgetState extends State<CommentsWidget> {
); );
} }
Widget _comment(S s, Comment comment, [double radius = 18, bool isChild = false]) { Widget _comment(
S s,
Comment comment, [
double radius = 18,
bool isChild = false,
]) {
final key = commentKeys.putIfAbsent(comment.id, () => GlobalKey()); final key = commentKeys.putIfAbsent(comment.id, () => GlobalKey());
final isMe = (sl<ProfileAvatarCubit>().state as ProfileAvatarStateLoaded).profile.slug == comment.authorSlug; final isMe =
(sl<ProfileAvatarCubit>().state as ProfileAvatarStateLoaded)
.profile
.slug ==
comment.authorSlug;
return GestureDetector( return GestureDetector(
key: key, key: key,
onTap: () { onTap: () {
@@ -113,7 +138,10 @@ class _CommentsWidgetState extends State<CommentsWidget> {
showInfoToast(s.text_was_copied); showInfoToast(s.text_was_copied);
}, },
edit: (String text) { edit: (String text) {
context.read<CommentsCubit>().updateComment(id: comment.id, body: text); context.read<CommentsCubit>().updateComment(
id: comment.id,
body: text,
);
}, },
delete: () { delete: () {
context.read<CommentsCubit>().deleteComment(id: comment.id); context.read<CommentsCubit>().deleteComment(id: comment.id);
@@ -122,7 +150,10 @@ class _CommentsWidgetState extends State<CommentsWidget> {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
builder: (context) => ComplainingBottomSheet(complainedContentType: ComplainedContent.comment, id: comment.id), builder: (context) => ComplainingBottomSheet(
complainedContentType: ComplainedContent.comment,
id: comment.id,
),
); );
}, },
isMyComment: isMe, isMyComment: isMe,
@@ -131,7 +162,11 @@ class _CommentsWidgetState extends State<CommentsWidget> {
child: Container( child: Container(
width: MediaQuery.of(context).size.width, width: MediaQuery.of(context).size.width,
decoration: BoxDecoration( decoration: BoxDecoration(
color: (widget.replyId == comment.id || widget.highlightedCommentId == comment.id) ? appTheme.blueA400.withValues(alpha: 0.1) : Colors.white, color:
(widget.replyId == comment.id ||
widget.highlightedCommentId == comment.id)
? appTheme.blueA400.withValues(alpha: 0.1)
: Colors.white,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
), ),
child: Padding( child: Padding(
@@ -141,14 +176,20 @@ class _CommentsWidgetState extends State<CommentsWidget> {
children: [ children: [
GestureDetector( GestureDetector(
onTap: () { onTap: () {
GoRouter.of(context).pushNamed( GoRouter.of(
'profileScreen', context,
extra: comment.authorSlug, ).pushNamed('profileScreen', extra: comment.authorSlug);
);
}, },
child: CircleAvatar( child: CircleAvatar(
foregroundImage: NetworkImage(comment.authorProfileIcon.contains('https') ? comment.authorProfileIcon : sl<AppConfig>().baseUrl + comment.authorProfileIcon), foregroundImage: NetworkImage(
backgroundImage: const AssetImage('assets/images/no_avatar.png'), joinBaseUrl(
sl<AppConfig>().baseUrl,
comment.authorProfileIcon,
),
),
backgroundImage: const AssetImage(
'assets/images/no_avatar.png',
),
radius: radius, radius: radius,
), ),
), ),
@@ -159,14 +200,26 @@ class _CommentsWidgetState extends State<CommentsWidget> {
children: [ children: [
Text( Text(
comment.authorName, comment.authorName,
style: TextStyle(height: 1, color: appTheme.gray, fontSize: 14, fontWeight: FontWeight.w500), style: TextStyle(
height: 1,
color: appTheme.gray,
fontSize: 14,
fontWeight: FontWeight.w500,
),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
SizedBox( SizedBox(
width: isChild ? MediaQuery.of(context).size.width * 0.72 : MediaQuery.of(context).size.width * 0.8, width: isChild
? MediaQuery.of(context).size.width * 0.72
: MediaQuery.of(context).size.width * 0.8,
child: Text( child: Text(
comment.body, comment.body,
style: TextStyle(height: 1, color: appTheme.black, fontSize: 16, fontWeight: FontWeight.w500), style: TextStyle(
height: 1,
color: appTheme.black,
fontSize: 16,
fontWeight: FontWeight.w500,
),
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
@@ -174,17 +227,29 @@ class _CommentsWidgetState extends State<CommentsWidget> {
children: [ children: [
Text( Text(
comment.createdAt.format('dd MMMM', 'ru'), comment.createdAt.format('dd MMMM', 'ru'),
style: TextStyle(height: 1, color: appTheme.gray, fontSize: 14, fontWeight: FontWeight.w500), style: TextStyle(
height: 1,
color: appTheme.gray,
fontSize: 14,
fontWeight: FontWeight.w500,
),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
if (!isChild) if (!isChild)
GestureDetector( GestureDetector(
onTap: () { onTap: () {
context.read<CommentsCubit>().setReplyId(comment.id); context.read<CommentsCubit>().setReplyId(
comment.id,
);
}, },
child: Text( child: Text(
s.answer, s.answer,
style: TextStyle(height: 1, color: appTheme.gray, fontSize: 14, fontWeight: FontWeight.w600), style: TextStyle(
height: 1,
color: appTheme.gray,
fontSize: 14,
fontWeight: FontWeight.w600,
),
), ),
), ),
], ],

View File

@@ -2,6 +2,8 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/utils/url_utils.dart';
import 'package:eatvio_app/core/widgets/network_svg_picture.dart';
import 'package:eatvio_app/features/feed/models/feed_consultant_model.dart'; import 'package:eatvio_app/features/feed/models/feed_consultant_model.dart';
import 'package:eatvio_app/features/profile/models/badge_model.dart'; import 'package:eatvio_app/features/profile/models/badge_model.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -17,15 +19,15 @@ class ConsultantWidget extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return InkWell( return InkWell(
onTap: () { onTap: () {
GoRouter.of(context).pushNamed( GoRouter.of(context).pushNamed('profileScreen', extra: consultant.slug);
'profileScreen',
extra: consultant.slug,
);
}, },
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
child: Ink( child: Ink(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: const Color(0xFFF6F6F6)), decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: const Color(0xFFF6F6F6),
),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -36,33 +38,54 @@ class ConsultantWidget extends StatelessWidget {
Row( Row(
children: [ children: [
Ink( Ink(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), padding: const EdgeInsets.symmetric(
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(8)), vertical: 4,
horizontal: 8,
),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
),
child: Text( child: Text(
'${consultant.ratingPlace}', '${consultant.ratingPlace}',
style: TextStyle(fontSize: 20, color: appTheme.blueA400, fontWeight: FontWeight.w600), style: TextStyle(
fontSize: 20,
color: appTheme.blueA400,
fontWeight: FontWeight.w600,
),
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
if (consultant.photo.contains('https')) CircleAvatar(
CircleAvatar(foregroundImage: NetworkImage(consultant.photo), backgroundImage: const AssetImage('assets/images/no_avatar.png')) foregroundImage: NetworkImage(
else joinBaseUrl(sl<AppConfig>().baseUrl, consultant.photo),
CircleAvatar(
foregroundImage: NetworkImage(sl<AppConfig>().baseUrl + consultant.photo),
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
), ),
backgroundImage: const AssetImage(
'assets/images/no_avatar.png',
),
),
const SizedBox(width: 8), const SizedBox(width: 8),
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
consultant.name, consultant.name,
style: const TextStyle(height: 1, fontWeight: FontWeight.w500, fontSize: 15, color: Color(0xFF323F4B)), style: const TextStyle(
height: 1,
fontWeight: FontWeight.w500,
fontSize: 15,
color: Color(0xFF323F4B),
),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'Рейтинг: ${consultant.totalScores}', 'Рейтинг: ${consultant.totalScores}',
style: TextStyle(color: appTheme.blueA400, height: 1, fontWeight: FontWeight.w500, fontSize: 12), style: TextStyle(
color: appTheme.blueA400,
height: 1,
fontWeight: FontWeight.w500,
fontSize: 12,
),
), ),
], ],
), ),
@@ -70,7 +93,10 @@ class ConsultantWidget extends StatelessWidget {
), ),
Ink( Ink(
padding: const EdgeInsets.all(6), padding: const EdgeInsets.all(6),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(4), color: Colors.white), decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color: Colors.white,
),
child: SvgPicture.asset('assets/icons/go_to_arrow.svg'), child: SvgPicture.asset('assets/icons/go_to_arrow.svg'),
), ),
], ],
@@ -92,9 +118,21 @@ class ConsultantWidget extends StatelessWidget {
children: [ children: [
for (int i = 0; i < badges.length; i++) ...[ for (int i = 0; i < badges.length; i++) ...[
badges[i].image.contains('svg') badges[i].image.contains('svg')
? SvgPicture.network('${sl<AppConfig>().baseUrl}/storage/${badges[i].image}', width: 28, height: 28) ? NetworkSvgPicture(
: CachedNetworkImage(imageUrl: '${sl<AppConfig>().baseUrl}/storage/${badges[i].image}', width: 28, height: 28), url:
i < badges.length - 1 ? const SizedBox(width: 8) : const SizedBox.shrink(), '${sl<AppConfig>().baseUrl}/storage/${badges[i].image}',
width: 28,
height: 28,
)
: CachedNetworkImage(
imageUrl:
'${sl<AppConfig>().baseUrl}/storage/${badges[i].image}',
width: 28,
height: 28,
),
i < badges.length - 1
? const SizedBox(width: 8)
: const SizedBox.shrink(),
], ],
], ],
), ),

View File

@@ -80,6 +80,7 @@ class _DietsWidgetState extends State<DietsWidget> {
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
child: CustomPopup( child: CustomPopup(
isAuthor: widget.diet.author.authorIsMe, isAuthor: widget.diet.author.authorIsMe,
isAuthorAdmin: widget.diet.author.isAdmin,
path: 'diet/${widget.diet.slug}', path: 'diet/${widget.diet.slug}',
id: widget.diet.id, id: widget.diet.id,
complainedContentType: ComplainedContent.diet, complainedContentType: ComplainedContent.diet,

View File

@@ -11,6 +11,7 @@ import 'package:eatvio_app/core/widgets/markdown_html_widget.dart';
import 'package:eatvio_app/features/diets/presentation/widgets/info_bouble.dart'; import 'package:eatvio_app/features/diets/presentation/widgets/info_bouble.dart';
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart'; import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
import 'package:eatvio_app/core/theme/custom_text_style.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/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/feed/presentation/bloc/plans_cubit/plans_cubit.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/plans_cubit/plans_cubit.dart';
import 'package:eatvio_app/generated/l10n.dart'; import 'package:eatvio_app/generated/l10n.dart';
@@ -37,7 +38,14 @@ class _PlanWidgetState extends State<PlanWidget> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final s = S.of(context); final s = S.of(context);
return InkWell( return InkWell(
onTap: () => GoRouter.of(context).pushNamed('aboutPlanScreen', extra: {'slug': widget.plan.slug, 'index': widget.index, 'navigateToComments': false}), onTap: () => GoRouter.of(context).pushNamed(
'aboutPlanScreen',
extra: {
'slug': widget.plan.slug,
'index': widget.index,
'navigateToComments': false,
},
),
child: Material( child: Material(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
color: Colors.white, color: Colors.white,
@@ -48,9 +56,12 @@ class _PlanWidgetState extends State<PlanWidget> {
Stack( Stack(
children: [ children: [
ClipRRect( ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), borderRadius: const BorderRadius.vertical(
top: Radius.circular(12),
),
child: CachedNetworkImage( child: CachedNetworkImage(
imageUrl: '${sl<AppConfig>().baseUrl}/storage/${widget.plan.image}', imageUrl:
'${sl<AppConfig>().baseUrl}/storage/${widget.plan.image}',
fit: BoxFit.cover, fit: BoxFit.cover,
width: MediaQuery.of(context).size.width, width: MediaQuery.of(context).size.width,
height: 426, height: 426,
@@ -61,14 +72,22 @@ class _PlanWidgetState extends State<PlanWidget> {
child: BookmarkButton( child: BookmarkButton(
onTap: () async { onTap: () async {
if (widget.plan.isFavorite) { if (widget.plan.isFavorite) {
await context.read<PlansCubit>().removePlanFromFavorite(widget.plan.slug, widget.index); await context.read<PlansCubit>().removePlanFromFavorite(
widget.plan.slug,
widget.index,
);
} else { } else {
await context.read<PlansCubit>().addPlanToFavorite(widget.plan.slug, widget.index); await context.read<PlansCubit>().addPlanToFavorite(
widget.plan.slug,
widget.index,
);
} }
}, },
color: appTheme.whiteA700, color: appTheme.whiteA700,
isFavorite: widget.plan.isFavorite, isFavorite: widget.plan.isFavorite,
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(12)), borderRadius: const BorderRadius.vertical(
bottom: Radius.circular(12),
),
width: 40, width: 40,
height: 44, height: 44,
size: 19, size: 19,
@@ -85,9 +104,11 @@ class _PlanWidgetState extends State<PlanWidget> {
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
child: CustomPopup( child: CustomPopup(
isAuthor: widget.plan.author.authorIsMe, isAuthor: widget.plan.author.authorIsMe,
isAuthorAdmin: widget.plan.author.isAdmin,
path: 'plan/${widget.plan.slug}', path: 'plan/${widget.plan.slug}',
id: widget.plan.id, id: widget.plan.id,
slug: widget.plan.slug, slug: widget.plan.slug,
plan: widget.plan,
complainedContentType: ComplainedContent.plan, complainedContentType: ComplainedContent.plan,
userSlug: widget.plan.author.authorSlug, userSlug: widget.plan.author.authorSlug,
), ),
@@ -98,7 +119,12 @@ class _PlanWidgetState extends State<PlanWidget> {
bottom: 16, bottom: 16,
child: SizedBox( child: SizedBox(
width: MediaQuery.sizeOf(context).width, width: MediaQuery.sizeOf(context).width,
child: InfoRow(plan: widget.plan, itemsBackgroundColor: appTheme.whiteA700, disableDescriptionOfTypes: true, type: ProductType.plan), child: InfoRow(
plan: widget.plan,
itemsBackgroundColor: appTheme.whiteA700,
disableDescriptionOfTypes: true,
type: ProductType.plan,
),
), ),
), ),
], ],
@@ -109,14 +135,22 @@ class _PlanWidgetState extends State<PlanWidget> {
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(widget.plan.name, style: TextStyles.textMSemibold.copyWith(color: appTheme.black, fontSize: 18)), child: Text(
widget.plan.name,
style: TextStyles.textMSemibold.copyWith(
color: appTheme.black,
fontSize: 18,
),
),
), ),
if (widget.plan.details.isNotEmpty) ...[ if (widget.plan.details.isNotEmpty) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: MarkdownHtmlWidget( child: MarkdownHtmlWidget(
markdownText: widget.plan.details.length > 200 ? '${widget.plan.details.substring(0, 200)}...' : widget.plan.details, markdownText: widget.plan.details.length > 200
? '${widget.plan.details.substring(0, 200)}...'
: widget.plan.details,
textColor: appTheme.black, textColor: appTheme.black,
), ),
), ),
@@ -125,7 +159,12 @@ class _PlanWidgetState extends State<PlanWidget> {
padding: EdgeInsets.only(left: 16, top: 8), padding: EdgeInsets.only(left: 16, top: 8),
child: Text( child: Text(
'Показать ещё', 'Показать ещё',
style: TextStyle(height: 1, color: Color(0xFF8B8B8B), fontSize: 14, fontWeight: FontWeight.w600), style: TextStyle(
height: 1,
color: Color(0xFF8B8B8B),
fontSize: 14,
fontWeight: FontWeight.w600,
),
), ),
), ),
], ],
@@ -134,7 +173,10 @@ class _PlanWidgetState extends State<PlanWidget> {
const SizedBox(height: 16), const SizedBox(height: 16),
GestureDetector( GestureDetector(
onTap: () { onTap: () {
GoRouter.of(context).pushNamed('profileScreen', extra: widget.plan.author.authorSlug); GoRouter.of(context).pushNamed(
'profileScreen',
extra: widget.plan.author.authorSlug,
);
}, },
child: Padding( child: Padding(
padding: const EdgeInsets.only(left: 16.0), padding: const EdgeInsets.only(left: 16.0),
@@ -145,7 +187,15 @@ class _PlanWidgetState extends State<PlanWidget> {
height: 46, height: 46,
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
image: DecorationImage(image: NetworkImage(sl<AppConfig>().baseUrl + widget.plan.author.authorProfileIcon), fit: BoxFit.cover), image: DecorationImage(
image: NetworkImage(
joinBaseUrl(
sl<AppConfig>().baseUrl,
widget.plan.author.authorProfileIcon,
),
),
fit: BoxFit.cover,
),
), ),
), ),
subtitle: Row( subtitle: Row(
@@ -153,22 +203,37 @@ class _PlanWidgetState extends State<PlanWidget> {
InfoBouble( InfoBouble(
radius: 12, radius: 12,
backgroundColor: appTheme.gray50, backgroundColor: appTheme.gray50,
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 10), padding: const EdgeInsets.symmetric(
vertical: 4,
horizontal: 10,
),
children: [ children: [
Text('${widget.plan.author.authorPlaceInRating}-й в рейтинге', style: TextStyles.textXSSemibold.copyWith(color: appTheme.blueA400)), Text(
'${widget.plan.author.authorPlaceInRating}-й в рейтинге',
style: TextStyles.textXSSemibold.copyWith(
color: appTheme.blueA400,
),
),
], ],
), ),
const Spacer(), const Spacer(),
Text( Text(
'${widget.plan.packetsCount == 1 ? widget.plan.packet1Options?.price : '${s.from} ${widget.plan.minPriceOfOptions.toStringAsFixed(0)}'}₽', '${widget.plan.packetsCount == 1 ? widget.plan.packet1Options?.price : '${s.from} ${widget.plan.minPriceOfOptions.toStringAsFixed(0)}'}₽',
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.greenA700), style: TextStyles.headingXLSemibold.copyWith(
color: appTheme.greenA700,
),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
], ],
), ),
title: Padding( title: Padding(
padding: const EdgeInsets.only(bottom: 4.0), padding: const EdgeInsets.only(bottom: 4.0),
child: Text(widget.plan.author.authorName, style: TextStyles.textSSemibold.copyWith(color: appTheme.black)), child: Text(
widget.plan.author.authorName,
style: TextStyles.textSSemibold.copyWith(
color: appTheme.black,
),
),
), ),
), ),
), ),
@@ -179,12 +244,21 @@ class _PlanWidgetState extends State<PlanWidget> {
child: Row( child: Row(
children: [ children: [
FeedIconButton( FeedIconButton(
icon: widget.plan.wasLikedByMe ? Icons.favorite_rounded : Icons.favorite_border_rounded, icon: widget.plan.wasLikedByMe
? Icons.favorite_rounded
: Icons.favorite_border_rounded,
text: widget.plan.totalLikes.toString(), text: widget.plan.totalLikes.toString(),
iconColor: widget.plan.wasLikedByMe ? appTheme.blueA400 : null, iconColor: widget.plan.wasLikedByMe
backgroundColor: widget.plan.wasLikedByMe ? appTheme.transparentBlue : null, ? appTheme.blueA400
: null,
backgroundColor: widget.plan.wasLikedByMe
? appTheme.transparentBlue
: null,
onTap: () async { onTap: () async {
await context.read<PlansCubit>().likePlan(widget.plan.slug, widget.index); await context.read<PlansCubit>().likePlan(
widget.plan.slug,
widget.index,
);
}, },
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
@@ -192,9 +266,14 @@ class _PlanWidgetState extends State<PlanWidget> {
icon: Icons.chat_bubble_outline, icon: Icons.chat_bubble_outline,
text: widget.plan.totalComments.toString(), text: widget.plan.totalComments.toString(),
onTap: () { onTap: () {
return GoRouter.of( return GoRouter.of(context).pushNamed(
context, 'aboutPlanScreen',
).pushNamed('aboutPlanScreen', extra: {'slug': widget.plan.slug, 'index': widget.index, 'navigateToComments': true}); extra: {
'slug': widget.plan.slug,
'index': widget.index,
'navigateToComments': true,
},
);
}, },
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
@@ -204,8 +283,11 @@ class _PlanWidgetState extends State<PlanWidget> {
final box = context.findRenderObject() as RenderBox?; final box = context.findRenderObject() as RenderBox?;
final result = await SharePlus.instance.share( final result = await SharePlus.instance.share(
ShareParams( ShareParams(
uri: Uri.parse('${sl<AppConfig>().baseUrl}/ru/diet/${widget.plan.id}'), uri: Uri.parse(
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, '${sl<AppConfig>().baseUrl}/ru/diet/${widget.plan.id}',
),
sharePositionOrigin:
box!.localToGlobal(Offset.zero) & box.size,
), ),
); );
switch (result.status) { switch (result.status) {

View File

@@ -115,6 +115,7 @@ class _RecipesWidgetState extends State<RecipesWidget> {
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
child: CustomPopup( child: CustomPopup(
isAuthor: widget.recipe.author.authorIsMe, isAuthor: widget.recipe.author.authorIsMe,
isAuthorAdmin: widget.recipe.author.isAdmin,
path: 'recipe/${widget.recipe.slug}', path: 'recipe/${widget.recipe.slug}',
slug: widget.recipe.slug, slug: widget.recipe.slug,
complainedContentType: ComplainedContent.recipe, complainedContentType: ComplainedContent.recipe,

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