forked from eatvio/eatvio_app
Compare commits
101 Commits
pasport_da
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 67e6526552 | |||
|
|
1c8709fc50 | ||
| 6b4c88068e | |||
|
|
d30c63be85 | ||
|
|
0ddd4c6b14 | ||
| c45612decb | |||
|
|
2779f79464 | ||
| 05eaa3caa9 | |||
|
|
d8d7a211bd | ||
| e6601e2fe6 | |||
|
|
992573aead | ||
| d2fc087699 | |||
|
|
3122394037 | ||
| 18925fd95e | |||
|
|
9916828956 | ||
| fb68f02ca4 | |||
|
|
8cb3a184b0 | ||
| 4b72258b1f | |||
|
|
592f387221 | ||
|
|
dcf695777a | ||
|
|
141cf5543d | ||
| 40ba5f304e | |||
|
|
996cf07fb8 | ||
|
|
1766314bbe | ||
| d1575d7ac4 | |||
| 94bca6a793 | |||
|
|
a6b94f835c | ||
|
|
4c38026748 | ||
|
|
355ec2df48 | ||
| 04195dd95b | |||
|
|
d3336b1263 | ||
| 82f5c9a7fe | |||
|
|
39a512193e | ||
| 4dc5839317 | |||
|
|
da6158625f | ||
|
|
5a8f8aee3e | ||
|
|
86e9d0cc31 | ||
| 60080c7407 | |||
|
|
6d2935400a | ||
| 293601f1e1 | |||
|
|
ff17b813ac | ||
| 6a259e5452 | |||
|
|
9288a5c849 | ||
|
|
4ef174ebde | ||
|
|
1901248539 | ||
| b2e700a4eb | |||
|
|
1bbb08f2a2 | ||
| 61f1b76f99 | |||
|
|
ef4df92f58 | ||
|
|
dcd343e765 | ||
|
|
98c175df44 | ||
|
|
cb312037b2 | ||
| 572a6f1943 | |||
|
|
a6ae9abb57 | ||
| 431354170e | |||
|
|
c38e82f558 | ||
| afd6f9a93a | |||
|
|
641eb4d2e2 | ||
| 74c66c9600 | |||
|
|
73e89ff7a9 | ||
|
|
f8e06b6d7a | ||
|
|
866ea2fe3b | ||
| d73f68e24b | |||
|
|
61cded0081 | ||
|
|
df634fedd8 | ||
|
|
603a158cf2 | ||
| a1bae68307 | |||
|
|
2dce28b196 | ||
| ae4f9d2e9b | |||
|
|
a00aae36cf | ||
|
|
27e98bdb44 | ||
|
|
8ec6d5b799 | ||
|
|
077cfb3a67 | ||
| c4f5cae36d | |||
|
|
b040c6e020 | ||
|
|
745e77f948 | ||
|
|
c6ea1d414d | ||
|
|
10af279d87 | ||
| d0879e556f | |||
|
|
8f933118a3 | ||
| 7dc7eab774 | |||
|
|
16a13488d1 | ||
|
|
de7bf8dc19 | ||
| eeb261df0a | |||
|
|
1b03a0d5e1 | ||
|
|
0208ed753a | ||
| 184f0417a7 | |||
|
|
6265e865e9 | ||
| 1550b7ea0b | |||
|
|
db10895ebb | ||
|
|
a7361d3ab1 | ||
| 1c54063a35 | |||
| efae7fc958 | |||
|
|
90ac4e750d | ||
|
|
6d7419c283 | ||
| caf45a9050 | |||
|
|
0a5e7d95c4 | ||
|
|
85482173ad | ||
| 65f7992d40 | |||
|
|
52a01bee12 | ||
| 179fc0ad99 |
62
.gitea/ISSUE_TEMPLATE/bug_report.yml
Normal file
62
.gitea/ISSUE_TEMPLATE/bug_report.yml
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
name: "🐞 Bug Report"
|
||||||
|
description: "Report a bug to help us improve"
|
||||||
|
title: "[Bug]: "
|
||||||
|
labels: ["bug"]
|
||||||
|
body:
|
||||||
|
- type: dropdown
|
||||||
|
id: stand
|
||||||
|
attributes:
|
||||||
|
label: "Stand"
|
||||||
|
description: "Select the environment where the bug occurs"
|
||||||
|
options:
|
||||||
|
- "prod"
|
||||||
|
- "stage-dev2"
|
||||||
|
- "local"
|
||||||
|
- "all"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: platform
|
||||||
|
attributes:
|
||||||
|
label: "Platform"
|
||||||
|
description: "Select the platform where the bug occurs"
|
||||||
|
options:
|
||||||
|
- "Android"
|
||||||
|
- "iPhone"
|
||||||
|
- "Huawei"
|
||||||
|
- "All"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: device_model
|
||||||
|
attributes:
|
||||||
|
label: "Device Model"
|
||||||
|
description: "Optional. Specify the device where the bug occurs"
|
||||||
|
placeholder: "e.g., Samsung Galaxy S23, iPhone 15 Pro, Huawei P60"
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: reproduction_steps
|
||||||
|
attributes:
|
||||||
|
label: "Steps to Reproduce"
|
||||||
|
description: "Clear numbered steps"
|
||||||
|
placeholder: "1. Click X\n2. Navigate to Y..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: expected_actual
|
||||||
|
attributes:
|
||||||
|
label: "Expected vs Actual Behavior"
|
||||||
|
description: "What should happen vs what happens"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: "Description"
|
||||||
|
description: "Дополнительная информация описывающая проблему"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
5
.gitea/ISSUE_TEMPLATE/config.yml
Normal file
5
.gitea/ISSUE_TEMPLATE/config.yml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: "💬 Community Support"
|
||||||
|
url: "https://pepe.eatv.io/channel/general"
|
||||||
|
about: "Ask questions here"
|
||||||
26
.gitea/ISSUE_TEMPLATE/feature_request.yml
Normal file
26
.gitea/ISSUE_TEMPLATE/feature_request.yml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
name: "✨ Feature Request"
|
||||||
|
description: "Suggest an idea for this project"
|
||||||
|
title: "[Feature]: "
|
||||||
|
labels: ["enhancement"]
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
id: use_case
|
||||||
|
attributes:
|
||||||
|
label: "Use Case"
|
||||||
|
description: "Problem solved or value added"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: "Description"
|
||||||
|
description: "Дополнительная информация описывающая функциональность"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: priority
|
||||||
|
attributes:
|
||||||
|
label: "Priority Level"
|
||||||
|
options: ["Low", "Medium", "High"]
|
||||||
@@ -8,8 +8,16 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
runs-on: [macos-arm64]
|
runs-on: [macos-arm64]
|
||||||
steps:
|
steps:
|
||||||
|
# launchd does not load the login-shell PATH, so Homebrew git is invisible to act.
|
||||||
|
- name: Add Homebrew to PATH
|
||||||
|
run: |
|
||||||
|
echo "/opt/homebrew/bin" >> "$GITHUB_PATH"
|
||||||
|
echo "/opt/homebrew/sbin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
- name: Fastlane version
|
- name: Fastlane version
|
||||||
run: fastlane --version
|
run: |
|
||||||
|
git --version
|
||||||
|
fastlane --version
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: https://github.com/actions/checkout@v2
|
uses: https://github.com/actions/checkout@v2
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ android {
|
|||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
manifestPlaceholders = [
|
manifestPlaceholders = [
|
||||||
YANDEX_CLIENT_ID: yandexClientId,
|
YANDEX_CLIENT_ID: yandexClientId,
|
||||||
applicationName: "io.flutter.app.FlutterApplication"
|
applicationName: "com.eatvioapp.app.EatvioApplication"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +69,7 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
testImplementation 'junit:junit:4.13.2'
|
||||||
implementation platform('com.google.firebase:firebase-bom:34.4.0')
|
implementation platform('com.google.firebase:firebase-bom:34.4.0')
|
||||||
implementation 'com.google.firebase:firebase-messaging'
|
implementation 'com.google.firebase:firebase-messaging'
|
||||||
implementation 'com.huawei.agconnect:agconnect-core:1.9.5.300'
|
implementation 'com.huawei.agconnect:agconnect-core:1.9.5.300'
|
||||||
|
|||||||
@@ -78,12 +78,7 @@
|
|||||||
<data android:host="eatvio.com" />
|
<data android:host="eatvio.com" />
|
||||||
<data android:host="www.eatvio.com" />
|
<data android:host="www.eatvio.com" />
|
||||||
|
|
||||||
<!-- Development/Subdomains -->
|
|
||||||
<data android:host="*.eatvio.ru" />
|
|
||||||
<data android:host="*.eatvio.com" />
|
|
||||||
|
|
||||||
<!-- Simplify patterns to allow navigation within the app for all matching hosts -->
|
|
||||||
<data android:pathPattern="/.*" />
|
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
@@ -92,8 +87,11 @@
|
|||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
|
|
||||||
<!-- Tap on notification body opens CallkitIncomingActivity via PendingIntent.
|
<!-- The incoming notification's content intent and full-screen intent both open
|
||||||
Replace it with a bridge that routes to the same accept flow as Answer. -->
|
CallkitIncomingActivity. Replace it with a bridge: a tap on an unlocked phone
|
||||||
|
routes to the same accept flow as Answer; on a locked/dark screen it shows a
|
||||||
|
ringing screen that wakes the device. Own task affinity keeps the plugin's
|
||||||
|
CLEAR_TASK flag from killing MainActivity. -->
|
||||||
<activity
|
<activity
|
||||||
android:name="com.hiennv.flutter_callkit_incoming.CallkitIncomingActivity"
|
android:name="com.hiennv.flutter_callkit_incoming.CallkitIncomingActivity"
|
||||||
tools:node="remove" />
|
tools:node="remove" />
|
||||||
@@ -102,20 +100,26 @@
|
|||||||
android:name=".EatvioCallkitIncomingBridgeActivity"
|
android:name=".EatvioCallkitIncomingBridgeActivity"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:theme="@android:style/Theme.Translucent.NoTitleBar"
|
android:theme="@android:style/Theme.Translucent.NoTitleBar"
|
||||||
|
android:taskAffinity=""
|
||||||
|
android:allowTaskReparenting="false"
|
||||||
android:excludeFromRecents="true"
|
android:excludeFromRecents="true"
|
||||||
android:noHistory="true"
|
android:noHistory="true"
|
||||||
|
android:showWhenLocked="true"
|
||||||
android:turnScreenOn="true"
|
android:turnScreenOn="true"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleInstance"
|
||||||
android:configChanges="orientation" />
|
android:configChanges="orientation" />
|
||||||
|
|
||||||
<activity-alias
|
<activity-alias
|
||||||
android:name="com.hiennv.flutter_callkit_incoming.CallkitIncomingActivity"
|
android:name="com.hiennv.flutter_callkit_incoming.CallkitIncomingActivity"
|
||||||
android:targetActivity=".EatvioCallkitIncomingBridgeActivity"
|
android:targetActivity=".EatvioCallkitIncomingBridgeActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
android:taskAffinity=""
|
||||||
|
android:allowTaskReparenting="false"
|
||||||
android:excludeFromRecents="true"
|
android:excludeFromRecents="true"
|
||||||
android:noHistory="true"
|
android:noHistory="true"
|
||||||
|
android:showWhenLocked="true"
|
||||||
android:turnScreenOn="true"
|
android:turnScreenOn="true"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleInstance"
|
||||||
android:configChanges="orientation"
|
android:configChanges="orientation"
|
||||||
android:permission="${applicationId}.PERMISSION_CALL">
|
android:permission="${applicationId}.PERMISSION_CALL">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
|
|||||||
@@ -13,14 +13,12 @@ object CallKitAcceptIntentMapper {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return hashMapOf(
|
return payloadMap(data, action)
|
||||||
"action" to action,
|
}
|
||||||
"id" to data.getString(CallkitConstants.EXTRA_CALLKIT_ID, ""),
|
|
||||||
"handle" to data.getString(CallkitConstants.EXTRA_CALLKIT_HANDLE, ""),
|
fun declinePayloadFromBundle(data: Bundle, action: String): Map<String, Any?>? {
|
||||||
"nameCaller" to data.getString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, ""),
|
if (!action.contains(CallkitConstants.ACTION_CALL_DECLINE)) return null
|
||||||
"avatar" to data.getString(CallkitConstants.EXTRA_CALLKIT_AVATAR, ""),
|
return payloadMap(data, action)
|
||||||
"extra" to data.getSerializable(CallkitConstants.EXTRA_CALLKIT_EXTRA),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun payloadFromIntent(intent: Intent?): Map<String, Any?>? {
|
fun payloadFromIntent(intent: Intent?): Map<String, Any?>? {
|
||||||
@@ -35,6 +33,16 @@ object CallKitAcceptIntentMapper {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return payloadMap(data, action)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearCallKitLaunchIntent(intent: Intent?) {
|
||||||
|
if (intent == null) return
|
||||||
|
intent.removeExtra(FlutterCallkitIncomingPlugin.EXTRA_CALLKIT_CALL_DATA)
|
||||||
|
intent.action = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun payloadMap(data: Bundle, action: String): Map<String, Any?> {
|
||||||
return hashMapOf(
|
return hashMapOf(
|
||||||
"action" to action,
|
"action" to action,
|
||||||
"id" to data.getString(CallkitConstants.EXTRA_CALLKIT_ID, ""),
|
"id" to data.getString(CallkitConstants.EXTRA_CALLKIT_ID, ""),
|
||||||
@@ -44,10 +52,4 @@ object CallKitAcceptIntentMapper {
|
|||||||
"extra" to data.getSerializable(CallkitConstants.EXTRA_CALLKIT_EXTRA),
|
"extra" to data.getSerializable(CallkitConstants.EXTRA_CALLKIT_EXTRA),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearCallKitLaunchIntent(intent: Intent?) {
|
|
||||||
if (intent == null) return
|
|
||||||
intent.removeExtra(FlutterCallkitIncomingPlugin.EXTRA_CALLKIT_CALL_DATA)
|
|
||||||
intent.action = null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,20 @@ import com.hiennv.flutter_callkit_incoming.CallkitIncomingBroadcastReceiver
|
|||||||
|
|
||||||
object CallKitIntentBridge {
|
object CallKitIntentBridge {
|
||||||
fun dispatchCallKitAction(context: Context, action: String, data: Bundle?) {
|
fun dispatchCallKitAction(context: Context, action: String, data: Bundle?) {
|
||||||
|
if (IncomingCallRingtonePolicy.actionStopsRingtone(action)) {
|
||||||
|
VideoCallPushBridge.silenceIncomingCall(context)
|
||||||
|
}
|
||||||
|
|
||||||
if (data != null) {
|
if (data != null) {
|
||||||
CallKitAcceptIntentMapper.payloadFromBundle(data, action)?.let { payload ->
|
CallKitAcceptIntentMapper.payloadFromBundle(data, action)?.let { payload ->
|
||||||
PendingCallKitAcceptBridge.store(payload)
|
PendingCallKitAcceptBridge.store(payload)
|
||||||
}
|
}
|
||||||
|
CallKitAcceptIntentMapper.declinePayloadFromBundle(data, action)?.let { payload ->
|
||||||
|
PendingCallKitDeclineBridge.store(payload)
|
||||||
|
if (MainActivity.isInForeground) {
|
||||||
|
MainActivity.onPendingDecline?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val broadcastIntent = CallkitIncomingBroadcastReceiver.getIntent(context, action, data)
|
val broadcastIntent = CallkitIncomingBroadcastReceiver.getIntent(context, action, data)
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.eatvioapp.app
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
|
||||||
|
class EatvioApplication : Application() {
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
VideoCallDeclineReporter.install(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,71 @@
|
|||||||
package com.eatvioapp.app
|
package com.eatvioapp.app
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
|
import android.app.KeyguardManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.ActivityInfo
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.os.PowerManager
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.View
|
||||||
|
import android.view.WindowManager
|
||||||
|
import android.view.ViewGroup.MarginLayoutParams
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.TextView
|
||||||
|
import com.hiennv.flutter_callkit_incoming.AppUtils
|
||||||
import com.hiennv.flutter_callkit_incoming.CallkitConstants
|
import com.hiennv.flutter_callkit_incoming.CallkitConstants
|
||||||
|
import com.hiennv.flutter_callkit_incoming.CallkitNotificationManager
|
||||||
|
import com.hiennv.flutter_callkit_incoming.ImageLoaderProvider
|
||||||
|
import com.hiennv.flutter_callkit_incoming.Utils
|
||||||
|
import com.hiennv.flutter_callkit_incoming.widgets.RippleRelativeLayout
|
||||||
|
import java.lang.ref.WeakReference
|
||||||
|
import kotlin.math.abs
|
||||||
|
import com.hiennv.flutter_callkit_incoming.R as CallkitR
|
||||||
|
|
||||||
/// Redirects notification body / full-screen intent taps to the same accept path as
|
/// Target of the incoming notification's content intent and full-screen intent.
|
||||||
/// the Answer button, instead of showing the native Callkit incoming UI.
|
///
|
||||||
|
/// Unlocked, lit screen: the user tapped the notification body, route it to the same
|
||||||
|
/// accept flow as the Answer button.
|
||||||
|
///
|
||||||
|
/// Locked or dark screen: Android fired the full-screen intent on its own, so show a
|
||||||
|
/// ringing screen that wakes the device and lets the user answer or decline.
|
||||||
class EatvioCallkitIncomingBridgeActivity : Activity() {
|
class EatvioCallkitIncomingBridgeActivity : Activity() {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "EatvioIncomingBridge"
|
||||||
|
private const val WAKE_LOCK_TAG = "eatvio:incoming_call"
|
||||||
|
private const val DEFAULT_RING_DURATION_MS = 45_000L
|
||||||
|
private const val MIN_RING_SCREEN_MS = 1_000L
|
||||||
|
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
private var ringingScreen: WeakReference<EatvioCallkitIncomingBridgeActivity>? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes the ringing screen once the call stopped ringing: answered or declined
|
||||||
|
* from the notification, timed out, or ended by the caller.
|
||||||
|
*/
|
||||||
|
fun dismissRingingScreen() {
|
||||||
|
mainHandler.post {
|
||||||
|
ringingScreen?.get()?.closeRingingScreen()
|
||||||
|
ringingScreen = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shouldShowRingingScreen(keyguardLocked: Boolean, screenInteractive: Boolean): Boolean {
|
||||||
|
return keyguardLocked || !screenInteractive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var callData: Bundle? = null
|
||||||
|
private var wakeLock: PowerManager.WakeLock? = null
|
||||||
|
private var ringTimeout: Runnable? = null
|
||||||
|
private var actionTaken = false
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
@@ -15,15 +74,256 @@ class EatvioCallkitIncomingBridgeActivity : Activity() {
|
|||||||
finish()
|
finish()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
callData = data
|
||||||
|
|
||||||
CallKitIntentBridge.dispatchCallKitAction(
|
if (!launchedOnLockedOrDarkScreen()) {
|
||||||
this,
|
Log.i(TAG, "tap on notification body: accept")
|
||||||
CallkitConstants.ACTION_CALL_ACCEPT,
|
CallKitIntentBridge.dispatchCallKitAction(this, CallkitConstants.ACTION_CALL_ACCEPT, data)
|
||||||
data,
|
finish()
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
overridePendingTransition(0, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.i(TAG, "full-screen intent on locked/dark screen: show ringing screen")
|
||||||
|
showRingingScreen(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
setIntent(intent)
|
||||||
|
val data = intent.extras?.getBundle(CallkitConstants.EXTRA_CALLKIT_INCOMING_DATA) ?: return
|
||||||
|
if (ringingScreen?.get() !== this) return
|
||||||
|
callData = data
|
||||||
|
bindCallData(data)
|
||||||
|
scheduleRingTimeout(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
cancelRingTimeout()
|
||||||
|
releaseWakeLock()
|
||||||
|
if (ringingScreen?.get() === this) {
|
||||||
|
ringingScreen = null
|
||||||
|
}
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Deprecated("Deprecated in Java")
|
||||||
|
override fun onBackPressed() {
|
||||||
|
// The ringing screen is dismissed by Answer / Decline / timeout only.
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun launchedOnLockedOrDarkScreen(): Boolean {
|
||||||
|
val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
|
||||||
|
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
|
return shouldShowRingingScreen(
|
||||||
|
keyguardLocked = keyguardManager.isKeyguardLocked,
|
||||||
|
screenInteractive = powerManager.isInteractive,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showRingingScreen(data: Bundle) {
|
||||||
|
ringingScreen = WeakReference(this)
|
||||||
|
|
||||||
|
setTheme(CallkitR.style.CallkitIncomingTheme)
|
||||||
|
requestedOrientation = if (Utils.isTablet(this)) {
|
||||||
|
ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||||
|
} else {
|
||||||
|
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||||
|
}
|
||||||
|
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
||||||
|
setTurnScreenOn(true)
|
||||||
|
setShowWhenLocked(true)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
window.addFlags(
|
||||||
|
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON or
|
||||||
|
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||||
|
window.statusBarColor = Color.TRANSPARENT
|
||||||
|
window.navigationBarColor = Color.TRANSPARENT
|
||||||
|
}
|
||||||
|
|
||||||
|
acquireWakeLock(ringDuration(data))
|
||||||
|
VideoCallPushBridge.keepRingingOnFullScreen()
|
||||||
|
|
||||||
|
setContentView(CallkitR.layout.activity_callkit_incoming)
|
||||||
|
bindCallData(data)
|
||||||
|
scheduleRingTimeout(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindCallData(data: Bundle) {
|
||||||
|
val background: ImageView = findViewById(CallkitR.id.ivBackground)
|
||||||
|
val ripple: RippleRelativeLayout = findViewById(CallkitR.id.llBackgroundAnimation)
|
||||||
|
val callerName: TextView = findViewById(CallkitR.id.tvNameCaller)
|
||||||
|
val callerNumber: TextView = findViewById(CallkitR.id.tvNumber)
|
||||||
|
val logo: ImageView = findViewById(CallkitR.id.ivLogo)
|
||||||
|
val avatar: ImageView = findViewById(CallkitR.id.ivAvatar)
|
||||||
|
val actions: LinearLayout = findViewById(CallkitR.id.llAction)
|
||||||
|
val acceptButton: ImageView = findViewById(CallkitR.id.ivAcceptCall)
|
||||||
|
val acceptLabel: TextView = findViewById(CallkitR.id.tvAccept)
|
||||||
|
val declineButton: ImageView = findViewById(CallkitR.id.ivDeclineCall)
|
||||||
|
val declineLabel: TextView = findViewById(CallkitR.id.tvDecline)
|
||||||
|
|
||||||
|
ripple.layoutParams.height = Utils.getScreenWidth() + Utils.getStatusBarHeight(this)
|
||||||
|
ripple.startRippleAnimation()
|
||||||
|
(actions.layoutParams as? MarginLayoutParams)?.let { params ->
|
||||||
|
params.setMargins(0, 0, 0, Utils.getNavigationBarHeight(this))
|
||||||
|
actions.layoutParams = params
|
||||||
|
}
|
||||||
|
|
||||||
|
callerName.text = data.getString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, "")
|
||||||
|
callerNumber.visibility = View.INVISIBLE
|
||||||
|
logo.visibility = View.INVISIBLE
|
||||||
|
|
||||||
|
val textColor = parseColor(data.getString(CallkitConstants.EXTRA_CALLKIT_TEXT_COLOR), Color.WHITE)
|
||||||
|
callerName.setTextColor(textColor)
|
||||||
|
acceptLabel.setTextColor(textColor)
|
||||||
|
declineLabel.setTextColor(textColor)
|
||||||
|
background.setBackgroundColor(
|
||||||
|
parseColor(data.getString(CallkitConstants.EXTRA_CALLKIT_BACKGROUND_COLOR), Color.parseColor("#0955fa")),
|
||||||
)
|
)
|
||||||
|
|
||||||
finish()
|
val avatarUrl = data.getString(CallkitConstants.EXTRA_CALLKIT_AVATAR, "")
|
||||||
@Suppress("DEPRECATION")
|
if (!avatarUrl.isNullOrEmpty()) {
|
||||||
overridePendingTransition(0, 0)
|
avatar.visibility = View.VISIBLE
|
||||||
|
@Suppress("DEPRECATION", "UNCHECKED_CAST")
|
||||||
|
val headers = data.getSerializable(CallkitConstants.EXTRA_CALLKIT_HEADERS) as? HashMap<String, Any?>
|
||||||
|
try {
|
||||||
|
ImageLoaderProvider.loadImage(this, avatarUrl, headers, CallkitR.drawable.ic_default_avatar, avatar)
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(TAG, "failed to load caller avatar", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.getInt(CallkitConstants.EXTRA_CALLKIT_TYPE, 0) > 0) {
|
||||||
|
acceptButton.setImageResource(CallkitR.drawable.ic_video)
|
||||||
|
}
|
||||||
|
acceptButton.background = AppUtils.createCircleDrawable(
|
||||||
|
parseColor(data.getString(CallkitConstants.EXTRA_CALLKIT_ACCEPT_COLOR), Color.parseColor("#4CAF50")),
|
||||||
|
)
|
||||||
|
declineButton.background = AppUtils.createCircleDrawable(
|
||||||
|
parseColor(data.getString(CallkitConstants.EXTRA_CALLKIT_DECLINE_COLOR), Color.parseColor("#F44336")),
|
||||||
|
)
|
||||||
|
|
||||||
|
val acceptText = data.getString(CallkitConstants.EXTRA_CALLKIT_TEXT_ACCEPT, "")
|
||||||
|
acceptLabel.text = if (acceptText.isNullOrEmpty()) getString(CallkitR.string.text_accept) else acceptText
|
||||||
|
val declineText = data.getString(CallkitConstants.EXTRA_CALLKIT_TEXT_DECLINE, "")
|
||||||
|
declineLabel.text = if (declineText.isNullOrEmpty()) getString(CallkitR.string.text_decline) else declineText
|
||||||
|
|
||||||
|
acceptButton.setOnClickListener { onAcceptClick() }
|
||||||
|
declineButton.setOnClickListener { onDeclineClick() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onAcceptClick() {
|
||||||
|
val data = callData ?: return
|
||||||
|
if (actionTaken) return
|
||||||
|
actionTaken = true
|
||||||
|
cancelRingTimeout()
|
||||||
|
Log.i(TAG, "ringing screen: accept")
|
||||||
|
|
||||||
|
// Same order as the plugin's own incoming activity: dispatch the accept, then ask
|
||||||
|
// the keyguard to unlock so MainActivity (which cannot show over the keyguard)
|
||||||
|
// becomes visible as soon as the user unlocks.
|
||||||
|
CallKitIntentBridge.dispatchCallKitAction(this, CallkitConstants.ACTION_CALL_ACCEPT, data)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
|
||||||
|
if (keyguardManager.isKeyguardLocked) {
|
||||||
|
keyguardManager.requestDismissKeyguard(this, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finishRingingTask()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onDeclineClick() {
|
||||||
|
val data = callData ?: return
|
||||||
|
if (actionTaken) return
|
||||||
|
actionTaken = true
|
||||||
|
cancelRingTimeout()
|
||||||
|
Log.i(TAG, "ringing screen: decline")
|
||||||
|
|
||||||
|
CallKitIntentBridge.dispatchCallKitAction(this, CallkitConstants.ACTION_CALL_DECLINE, data)
|
||||||
|
finishRingingTask()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun closeRingingScreen() {
|
||||||
|
if (isFinishing) return
|
||||||
|
cancelRingTimeout()
|
||||||
|
finishRingingTask()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun finishRingingTask() {
|
||||||
|
if (isFinishing) return
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||||
|
finishAndRemoveTask()
|
||||||
|
} else {
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ringDuration(data: Bundle): Long {
|
||||||
|
val duration = data.getLong(CallkitConstants.EXTRA_CALLKIT_DURATION, DEFAULT_RING_DURATION_MS)
|
||||||
|
return if (duration > 0L) duration else DEFAULT_RING_DURATION_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The notification was posted before this screen appeared, so ring only for the
|
||||||
|
* remaining part of the call duration.
|
||||||
|
*/
|
||||||
|
private fun scheduleRingTimeout(data: Bundle) {
|
||||||
|
cancelRingTimeout()
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val startedAt = data.getLong(CallkitNotificationManager.EXTRA_TIME_START_CALL, now)
|
||||||
|
val remaining = (ringDuration(data) - abs(now - startedAt)).coerceAtLeast(MIN_RING_SCREEN_MS)
|
||||||
|
val timeout = Runnable {
|
||||||
|
Log.i(TAG, "ringing screen: timed out")
|
||||||
|
closeRingingScreen()
|
||||||
|
}
|
||||||
|
ringTimeout = timeout
|
||||||
|
mainHandler.postDelayed(timeout, remaining)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelRingTimeout() {
|
||||||
|
ringTimeout?.let { mainHandler.removeCallbacks(it) }
|
||||||
|
ringTimeout = null
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
private fun acquireWakeLock(durationMs: Long) {
|
||||||
|
try {
|
||||||
|
val powerManager = applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
|
val lock = powerManager.newWakeLock(
|
||||||
|
PowerManager.SCREEN_BRIGHT_WAKE_LOCK or
|
||||||
|
PowerManager.ACQUIRE_CAUSES_WAKEUP or
|
||||||
|
PowerManager.ON_AFTER_RELEASE,
|
||||||
|
WAKE_LOCK_TAG,
|
||||||
|
)
|
||||||
|
lock.acquire(durationMs)
|
||||||
|
wakeLock = lock
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(TAG, "failed to acquire wake lock", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun releaseWakeLock() {
|
||||||
|
val lock = wakeLock ?: return
|
||||||
|
wakeLock = null
|
||||||
|
try {
|
||||||
|
if (lock.isHeld) lock.release()
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(TAG, "failed to release wake lock", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseColor(value: String?, fallback: Int): Int {
|
||||||
|
if (value.isNullOrBlank()) return fallback
|
||||||
|
return try {
|
||||||
|
Color.parseColor(value)
|
||||||
|
} catch (error: IllegalArgumentException) {
|
||||||
|
fallback
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class EatvioFcmMessagingService : FirebaseMessagingService() {
|
|||||||
Log.i(TAG, "onMessageReceived: ${message.data}")
|
Log.i(TAG, "onMessageReceived: ${message.data}")
|
||||||
|
|
||||||
if (VideoCallPushBridge.isEndedVideoCall(message.data)) {
|
if (VideoCallPushBridge.isEndedVideoCall(message.data)) {
|
||||||
VideoCallPushBridge.dismissAllIncomingCalls(this)
|
VideoCallPushBridge.dismissEndedVideoCall(this, message.data)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,11 +24,9 @@ class EatvioFcmMessagingService : FirebaseMessagingService() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (MainActivity.isInForeground) {
|
// Data-only call pushes are delivered only to this service.
|
||||||
Log.i(TAG, "App in foreground, deferring incoming call to Flutter handler")
|
// FlutterFirebaseMessagingService is removed, so the Dart listener does
|
||||||
return
|
// not see them. Show the incoming UI here for both foreground and background.
|
||||||
}
|
|
||||||
|
|
||||||
VideoCallPushBridge.showIncomingCall(this, message.data)
|
VideoCallPushBridge.showIncomingCall(this, message.data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class HmsPushService : HmsMessageService() {
|
|||||||
if (message == null) return
|
if (message == null) return
|
||||||
|
|
||||||
if (VideoCallPushBridge.isEndedVideoCall(message.dataOfMap)) {
|
if (VideoCallPushBridge.isEndedVideoCall(message.dataOfMap)) {
|
||||||
VideoCallPushBridge.dismissAllIncomingCalls(this)
|
VideoCallPushBridge.dismissEndedVideoCall(this, message.dataOfMap)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.eatvioapp.app
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call actions that must stop the incoming ringtone.
|
||||||
|
* Accept is included: answering the call is not the same as ending it,
|
||||||
|
* but the ringing player is a separate object from the in-call audio.
|
||||||
|
*/
|
||||||
|
internal object IncomingCallRingtonePolicy {
|
||||||
|
fun actionStopsRingtone(action: String): Boolean {
|
||||||
|
return action.contains("ACTION_CALL_ACCEPT") ||
|
||||||
|
action.contains("ACTION_CALL_DECLINE") ||
|
||||||
|
action.contains("ACTION_CALL_ENDED") ||
|
||||||
|
action.contains("ACTION_CALL_TIMEOUT")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,9 @@ import io.flutter.plugin.common.MethodChannel
|
|||||||
import com.hiennv.flutter_callkit_incoming.FlutterCallkitIncomingPlugin
|
import com.hiennv.flutter_callkit_incoming.FlutterCallkitIncomingPlugin
|
||||||
import com.huawei.agconnect.config.AGConnectServicesConfig
|
import com.huawei.agconnect.config.AGConnectServicesConfig
|
||||||
import com.huawei.hms.aaid.HmsInstanceId
|
import com.huawei.hms.aaid.HmsInstanceId
|
||||||
|
import com.huawei.hms.api.ConnectionResult
|
||||||
|
import com.huawei.hms.api.HuaweiApiAvailability
|
||||||
|
import com.huawei.hms.common.ApiException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -22,6 +25,9 @@ class MainActivity: FlutterActivity() {
|
|||||||
@JvmStatic
|
@JvmStatic
|
||||||
var isInForeground: Boolean = false
|
var isInForeground: Boolean = false
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
var onPendingDecline: (() -> Unit)? = null
|
||||||
}
|
}
|
||||||
|
|
||||||
private val channelName = "com.eatvioapp/hms_push"
|
private val channelName = "com.eatvioapp/hms_push"
|
||||||
@@ -35,13 +41,18 @@ class MainActivity: FlutterActivity() {
|
|||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
isInForeground = true
|
isInForeground = true
|
||||||
|
onPendingDecline = { scheduleDeliverPendingCallKitDecline() }
|
||||||
captureCallKitLaunchIntent(intent)
|
captureCallKitLaunchIntent(intent)
|
||||||
if (PendingCallKitAcceptBridge.peek() != null) {
|
if (PendingCallKitAcceptBridge.peek() != null) {
|
||||||
scheduleDeliverPendingCallKitAccept()
|
scheduleDeliverPendingCallKitAccept()
|
||||||
}
|
}
|
||||||
|
if (PendingCallKitDeclineBridge.peek() != null) {
|
||||||
|
scheduleDeliverPendingCallKitDecline()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
|
onPendingDecline = null
|
||||||
isInForeground = false
|
isInForeground = false
|
||||||
super.onPause()
|
super.onPause()
|
||||||
}
|
}
|
||||||
@@ -52,22 +63,50 @@ class MainActivity: FlutterActivity() {
|
|||||||
methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName)
|
methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName)
|
||||||
videoCallPipChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, videoCallPipChannelName)
|
videoCallPipChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, videoCallPipChannelName)
|
||||||
videoCallPushChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, videoCallPushChannelName)
|
videoCallPushChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, videoCallPushChannelName)
|
||||||
|
onPendingDecline = { scheduleDeliverPendingCallKitDecline() }
|
||||||
videoCallPushChannel?.setMethodCallHandler { call, result ->
|
videoCallPushChannel?.setMethodCallHandler { call, result ->
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
"dismissIncomingCalls" -> {
|
"dismissIncomingCalls" -> {
|
||||||
try {
|
try {
|
||||||
VideoCallPushBridge.dismissAllIncomingCalls(applicationContext)
|
val args = call.arguments as? Map<*, *>
|
||||||
|
val callId = args?.get("call_id")?.toString()
|
||||||
|
?: args?.get("callId")?.toString()
|
||||||
|
val conversationUuid = args?.get("conversation_uuid")?.toString()
|
||||||
|
?: args?.get("conversationUuid")?.toString()
|
||||||
|
VideoCallPushBridge.dismissIncomingCalls(
|
||||||
|
applicationContext,
|
||||||
|
callId = callId,
|
||||||
|
conversationUuid = conversationUuid,
|
||||||
|
)
|
||||||
result.success(null)
|
result.success(null)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
result.error("DISMISS_INCOMING_CALLS_FAILED", e.message, null)
|
result.error("DISMISS_INCOMING_CALLS_FAILED", e.message, null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"showIncomingCall" -> {
|
||||||
|
try {
|
||||||
|
val args = call.arguments as? Map<*, *> ?: emptyMap<Any, Any>()
|
||||||
|
val data = HashMap<String, String>()
|
||||||
|
for ((key, value) in args) {
|
||||||
|
val name = key as? String ?: continue
|
||||||
|
if (value == null) continue
|
||||||
|
data[name] = value.toString()
|
||||||
|
}
|
||||||
|
VideoCallPushBridge.showIncomingCall(applicationContext, data)
|
||||||
|
result.success(null)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
result.error("SHOW_INCOMING_CALL_FAILED", e.message, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
"drainPendingCallKitAccept" -> {
|
"drainPendingCallKitAccept" -> {
|
||||||
result.success(PendingCallKitAcceptBridge.drain())
|
result.success(PendingCallKitAcceptBridge.drain())
|
||||||
}
|
}
|
||||||
"peekPendingCallKitAccept" -> {
|
"peekPendingCallKitAccept" -> {
|
||||||
result.success(PendingCallKitAcceptBridge.peekPending())
|
result.success(PendingCallKitAcceptBridge.peekPending())
|
||||||
}
|
}
|
||||||
|
"drainPendingCallKitDecline" -> {
|
||||||
|
result.success(PendingCallKitDeclineBridge.drain())
|
||||||
|
}
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,6 +121,20 @@ class MainActivity: FlutterActivity() {
|
|||||||
}
|
}
|
||||||
methodChannel?.setMethodCallHandler { call, result ->
|
methodChannel?.setMethodCallHandler { call, result ->
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
|
"isHmsAvailable" -> {
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
val available = try {
|
||||||
|
HuaweiApiAvailability.getInstance()
|
||||||
|
.isHuaweiMobileServicesAvailable(applicationContext) == ConnectionResult.SUCCESS
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.w("HMS", "HMS availability check failed: ${e.message}")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
runOnUiThread {
|
||||||
|
result.success(available)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
"getHuaweiToken" -> {
|
"getHuaweiToken" -> {
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
try {
|
try {
|
||||||
@@ -121,10 +174,30 @@ class MainActivity: FlutterActivity() {
|
|||||||
runOnUiThread {
|
runOnUiThread {
|
||||||
result.success(token)
|
result.success(token)
|
||||||
}
|
}
|
||||||
|
} catch (e: ApiException) {
|
||||||
|
if (e.statusCode == 907135003) {
|
||||||
|
android.util.Log.w("HMS", "HMS client API invalid (${e.statusCode}), falling back to FCM")
|
||||||
|
runOnUiThread {
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
android.util.Log.e("HMS", "Error getting HMS token: ${e.statusCode}: ${e.message}", e)
|
||||||
|
runOnUiThread {
|
||||||
|
result.error("HMS_TOKEN_ERROR", "${e.statusCode}: ${e.message}", null)
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
android.util.Log.e("HMS", "Error getting HMS token: ${e.message}", e)
|
val message = e.message.orEmpty()
|
||||||
runOnUiThread {
|
if (message.contains("907135003") || message.contains("client api invalid", ignoreCase = true)) {
|
||||||
result.error("HMS_TOKEN_ERROR", e.message, null)
|
android.util.Log.w("HMS", "HMS client API invalid, falling back to FCM")
|
||||||
|
runOnUiThread {
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
android.util.Log.e("HMS", "Error getting HMS token: $message", e)
|
||||||
|
runOnUiThread {
|
||||||
|
result.error("HMS_TOKEN_ERROR", e.message, null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -189,6 +262,30 @@ class MainActivity: FlutterActivity() {
|
|||||||
channel.invokeMethod("onCallKitLaunchIntent", payload)
|
channel.invokeMethod("onCallKitLaunchIntent", payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun scheduleDeliverPendingCallKitDecline() {
|
||||||
|
PendingCallKitDeclineBridge.scheduleDeliverToFlutter(
|
||||||
|
deliver = { deliverPendingCallKitDeclineToFlutter() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deliverPendingCallKitDeclineToFlutter() {
|
||||||
|
val payload = PendingCallKitDeclineBridge.peek() ?: return
|
||||||
|
val channel = videoCallPushChannel
|
||||||
|
if (channel == null) {
|
||||||
|
android.util.Log.w(
|
||||||
|
"VideoCallPush",
|
||||||
|
"deliverPendingCallKitDeclineToFlutter: channel not ready",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
android.util.Log.i(
|
||||||
|
"VideoCallPush",
|
||||||
|
"deliverPendingCallKitDeclineToFlutter: id=${payload["id"]}",
|
||||||
|
)
|
||||||
|
channel.invokeMethod("onCallKitDecline", payload)
|
||||||
|
}
|
||||||
|
|
||||||
private fun handleIntentForActionUrl(intent: Intent?) {
|
private fun handleIntentForActionUrl(intent: Intent?) {
|
||||||
if (intent == null) return
|
if (intent == null) return
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.eatvioapp.app
|
||||||
|
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
/// Stores a decline until Flutter can POST /video-call/decline.
|
||||||
|
///
|
||||||
|
/// The plugin broadcasts the decline. If the Flutter engine is not attached,
|
||||||
|
/// that broadcast never reaches the backend and the call keeps ringing on the
|
||||||
|
/// user's other devices.
|
||||||
|
object PendingCallKitDeclineBridge {
|
||||||
|
private const val TAG = "PendingCallKitDecline"
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var pendingPayload: Map<String, Any?>? = null
|
||||||
|
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
|
fun store(payload: Map<String, Any?>) {
|
||||||
|
pendingPayload = payload
|
||||||
|
Log.i(TAG, "stored pending decline id=${payload["id"]}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun peek(): Map<String, Any?>? = pendingPayload
|
||||||
|
|
||||||
|
fun drain(): Map<String, Any?>? {
|
||||||
|
val payload = pendingPayload
|
||||||
|
pendingPayload = null
|
||||||
|
if (payload != null) {
|
||||||
|
Log.i(TAG, "drained pending decline id=${payload["id"]}")
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
fun scheduleDeliverToFlutter(
|
||||||
|
deliver: () -> Unit,
|
||||||
|
delaysMs: LongArray = longArrayOf(0L, 250L, 700L, 1500L, 2800L, 4200L, 6000L),
|
||||||
|
) {
|
||||||
|
for (delayMs in delaysMs) {
|
||||||
|
mainHandler.postDelayed({ deliver() }, delayMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package com.eatvioapp.app
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
|
internal object FlutterSharedPrefs {
|
||||||
|
const val NAME = "FlutterSharedPreferences"
|
||||||
|
private const val PREFIX = "flutter."
|
||||||
|
|
||||||
|
fun string(context: Context, key: String): String? {
|
||||||
|
return context
|
||||||
|
.getSharedPreferences(NAME, Context.MODE_PRIVATE)
|
||||||
|
.getString(PREFIX + key, null)
|
||||||
|
?.trim()
|
||||||
|
?.takeIf { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal object VideoCallDeclineClient {
|
||||||
|
private const val TAG = "VideoCallDecline"
|
||||||
|
private const val TIMEOUT_MS = 4_000
|
||||||
|
|
||||||
|
fun send(context: Context, handle: String?, extra: Map<*, *>?) {
|
||||||
|
val fields = VideoCallDeclineRequestFactory.fields(handle, extra)
|
||||||
|
val baseUrl = FlutterSharedPrefs.string(context, "apiBaseUrl")
|
||||||
|
?: FlutterSharedPrefs.string(context, "customBaseUrl")
|
||||||
|
val request = VideoCallDeclineRequestFactory.build(
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
token = FlutterSharedPrefs.string(context, "token"),
|
||||||
|
hwId = FlutterSharedPrefs.string(context, "eatvio.android_install_hw_id"),
|
||||||
|
fields = fields,
|
||||||
|
)
|
||||||
|
if (request == null) {
|
||||||
|
Log.w(
|
||||||
|
TAG,
|
||||||
|
"skip decline POST conversation=${fields.conversationUuid} ended=${fields.endedPush}",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val code = post(request)
|
||||||
|
Log.i(TAG, "decline POST status=$code conversation=${fields.conversationUuid}")
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(TAG, "decline POST failed conversation=${fields.conversationUuid}", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun post(request: VideoCallDeclineRequest): Int {
|
||||||
|
val connection = (URL(request.url).openConnection() as HttpURLConnection).apply {
|
||||||
|
requestMethod = "POST"
|
||||||
|
connectTimeout = TIMEOUT_MS
|
||||||
|
readTimeout = TIMEOUT_MS
|
||||||
|
doOutput = true
|
||||||
|
setRequestProperty("Authorization", "Bearer ${request.token}")
|
||||||
|
setRequestProperty("Accept", "application/json")
|
||||||
|
setRequestProperty("Content-Type", "application/json; charset=utf-8")
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
connection.outputStream.use { stream ->
|
||||||
|
stream.write(request.body.toByteArray(Charsets.UTF_8))
|
||||||
|
}
|
||||||
|
val code = connection.responseCode
|
||||||
|
val response = if (code in 200..299) connection.inputStream else connection.errorStream
|
||||||
|
response?.use { stream -> stream.readBytes() }
|
||||||
|
return code
|
||||||
|
} finally {
|
||||||
|
connection.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package com.eatvioapp.app
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.os.BundleCompat
|
||||||
|
import com.hiennv.flutter_callkit_incoming.CallkitConstants
|
||||||
|
import com.hiennv.flutter_callkit_incoming.CallkitEventCallback
|
||||||
|
import com.hiennv.flutter_callkit_incoming.FlutterCallkitIncomingPlugin
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends `POST /video-call/decline` from the process that handled the tap.
|
||||||
|
*
|
||||||
|
* The notification Decline button is an explicit broadcast into the CallKit
|
||||||
|
* plugin. That broadcast starts the process when the app was killed and does
|
||||||
|
* not start Flutter. The plugin notifies this callback before it returns, so
|
||||||
|
* the request can finish while the process is still alive.
|
||||||
|
*/
|
||||||
|
object VideoCallDeclineReporter : CallkitEventCallback {
|
||||||
|
private const val TAG = "VideoCallDecline"
|
||||||
|
private const val WAIT_FOR_HTTP_MS = 9_000L
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var appContext: Context? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var installed = false
|
||||||
|
|
||||||
|
fun install(context: Context) {
|
||||||
|
if (installed) return
|
||||||
|
installed = true
|
||||||
|
appContext = context.applicationContext
|
||||||
|
FlutterCallkitIncomingPlugin.registerEventCallback(this)
|
||||||
|
Log.i(TAG, "installed")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCallEvent(event: CallkitEventCallback.CallEvent, callData: Bundle) {
|
||||||
|
if (event != CallkitEventCallback.CallEvent.DECLINE) return
|
||||||
|
val context = appContext ?: return
|
||||||
|
val handle = callData.getString(CallkitConstants.EXTRA_CALLKIT_HANDLE)
|
||||||
|
val extra = copyExtra(callData)
|
||||||
|
if (MainActivity.isInForeground) {
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
VideoCallDeclineClient.send(context, handle, extra)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The Decline button starts this process and then the broadcast ends.
|
||||||
|
// Wait here so Android does not kill the process before the POST returns.
|
||||||
|
runBlocking(Dispatchers.IO) {
|
||||||
|
withTimeoutOrNull(WAIT_FOR_HTTP_MS) {
|
||||||
|
VideoCallDeclineClient.send(context, handle, extra)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun copyExtra(callData: Bundle): Map<String, Any?> {
|
||||||
|
val raw = BundleCompat.getSerializable(
|
||||||
|
callData,
|
||||||
|
CallkitConstants.EXTRA_CALLKIT_EXTRA,
|
||||||
|
HashMap::class.java,
|
||||||
|
) ?: return emptyMap()
|
||||||
|
return buildMap {
|
||||||
|
for ((key, value) in raw) {
|
||||||
|
if (key is String) put(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package com.eatvioapp.app
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body of `POST /api/v2/chat/{conversation}/video-call/decline`.
|
||||||
|
*
|
||||||
|
* [callId] is omitted when it is missing, not a uuid, or equal to the
|
||||||
|
* conversation uuid: the server treats `call_id` as the session uuid.
|
||||||
|
*/
|
||||||
|
internal data class VideoCallDeclineRequest(
|
||||||
|
val url: String,
|
||||||
|
val token: String,
|
||||||
|
val body: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal data class VideoCallDeclineFields(
|
||||||
|
val conversationUuid: String,
|
||||||
|
val callId: String?,
|
||||||
|
val endedPush: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal object VideoCallDeclineRequestFactory {
|
||||||
|
private val HEX = "0123456789ABCDEF".toCharArray()
|
||||||
|
private val uuidPattern =
|
||||||
|
Regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
|
||||||
|
|
||||||
|
fun fields(handle: String?, extra: Map<*, *>?): VideoCallDeclineFields {
|
||||||
|
val conversation = firstString(
|
||||||
|
extra,
|
||||||
|
listOf("conversationUuid", "conversation_uuid"),
|
||||||
|
) ?: handle?.trim().orEmpty()
|
||||||
|
val rawCallId = firstString(extra, listOf("call_id", "callId"))
|
||||||
|
return VideoCallDeclineFields(
|
||||||
|
conversationUuid = conversation,
|
||||||
|
callId = backendCallId(rawCallId, conversation),
|
||||||
|
endedPush = marksEndedPush(extra),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun build(
|
||||||
|
baseUrl: String?,
|
||||||
|
token: String?,
|
||||||
|
hwId: String?,
|
||||||
|
fields: VideoCallDeclineFields,
|
||||||
|
): VideoCallDeclineRequest? {
|
||||||
|
if (fields.endedPush) return null
|
||||||
|
val conversation = fields.conversationUuid.trim()
|
||||||
|
val authToken = token?.trim().orEmpty()
|
||||||
|
val deviceId = hwId?.trim().orEmpty()
|
||||||
|
val root = baseUrl?.trim()?.trimEnd('/').orEmpty()
|
||||||
|
if (conversation.isEmpty() || authToken.isEmpty()) return null
|
||||||
|
if (deviceId.length < 8 || deviceId.length > 64) return null
|
||||||
|
if (!root.startsWith("http://") && !root.startsWith("https://")) return null
|
||||||
|
|
||||||
|
val encoded = encodePathSegment(conversation)
|
||||||
|
return VideoCallDeclineRequest(
|
||||||
|
url = "$root/api/v2/chat/$encoded/video-call/decline",
|
||||||
|
token = authToken,
|
||||||
|
body = jsonBody(deviceId, fields.callId),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun backendCallId(callId: String?, conversationUuid: String): String? {
|
||||||
|
val id = callId?.trim().orEmpty()
|
||||||
|
if (!uuidPattern.matches(id)) return null
|
||||||
|
if (id.equals(conversationUuid.trim(), ignoreCase = true)) return null
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun firstString(extra: Map<*, *>?, keys: List<String>): String? {
|
||||||
|
if (extra == null) return null
|
||||||
|
for (key in keys) {
|
||||||
|
val value = extra[key] as? String ?: continue
|
||||||
|
val trimmed = value.trim()
|
||||||
|
if (trimmed.isNotEmpty()) return trimmed
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun marksEndedPush(extra: Map<*, *>?): Boolean {
|
||||||
|
val value = extra?.get("endedPush") ?: return false
|
||||||
|
return when (value) {
|
||||||
|
is Boolean -> value
|
||||||
|
is Number -> value.toInt() != 0
|
||||||
|
is String -> {
|
||||||
|
val normalized = value.trim().lowercase()
|
||||||
|
normalized == "true" || normalized == "1"
|
||||||
|
}
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun jsonBody(hwId: String, callId: String?): String {
|
||||||
|
val hw = jsonString(hwId)
|
||||||
|
if (callId == null) return """{"hw_id":$hw}"""
|
||||||
|
return """{"hw_id":$hw,"call_id":${jsonString(callId)}}"""
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun encodePathSegment(value: String): String {
|
||||||
|
val bytes = value.encodeToByteArray()
|
||||||
|
return buildString(bytes.size) {
|
||||||
|
for (byte in bytes) {
|
||||||
|
val code = byte.toInt() and 0xFF
|
||||||
|
val allowed = code in 'A'.code..'Z'.code ||
|
||||||
|
code in 'a'.code..'z'.code ||
|
||||||
|
code in '0'.code..'9'.code ||
|
||||||
|
code == '-'.code ||
|
||||||
|
code == '_'.code ||
|
||||||
|
code == '.'.code ||
|
||||||
|
code == '~'.code
|
||||||
|
if (allowed) {
|
||||||
|
append(code.toChar())
|
||||||
|
} else {
|
||||||
|
append('%')
|
||||||
|
append(HEX[code ushr 4])
|
||||||
|
append(HEX[code and 0x0F])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun jsonString(value: String): String {
|
||||||
|
val escaped = buildString {
|
||||||
|
for (char in value) {
|
||||||
|
when (char) {
|
||||||
|
'\\' -> append("\\\\")
|
||||||
|
'"' -> append("\\\"")
|
||||||
|
'\n' -> append("\\n")
|
||||||
|
'\r' -> append("\\r")
|
||||||
|
'\t' -> append("\\t")
|
||||||
|
else -> append(char)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "\"$escaped\""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,10 +4,12 @@ import android.content.Context
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
|
import android.os.PowerManager
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import com.hiennv.flutter_callkit_incoming.CallkitConstants
|
import com.hiennv.flutter_callkit_incoming.CallkitConstants
|
||||||
|
import com.hiennv.flutter_callkit_incoming.CallkitEventCallback
|
||||||
import com.hiennv.flutter_callkit_incoming.CallkitNotificationManager
|
import com.hiennv.flutter_callkit_incoming.CallkitNotificationManager
|
||||||
import com.hiennv.flutter_callkit_incoming.CallkitNotificationService
|
import com.hiennv.flutter_callkit_incoming.CallkitNotificationService
|
||||||
import com.hiennv.flutter_callkit_incoming.CallkitSoundPlayerManager
|
import com.hiennv.flutter_callkit_incoming.CallkitSoundPlayerManager
|
||||||
@@ -16,15 +18,42 @@ import com.hiennv.flutter_callkit_incoming.FlutterCallkitIncomingPlugin
|
|||||||
import com.hiennv.flutter_callkit_incoming.addCall
|
import com.hiennv.flutter_callkit_incoming.addCall
|
||||||
import com.hiennv.flutter_callkit_incoming.getDataActiveCalls
|
import com.hiennv.flutter_callkit_incoming.getDataActiveCalls
|
||||||
import com.hiennv.flutter_callkit_incoming.removeAllCalls
|
import com.hiennv.flutter_callkit_incoming.removeAllCalls
|
||||||
|
import com.hiennv.flutter_callkit_incoming.removeCall
|
||||||
import java.util.HashMap
|
import java.util.HashMap
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
object VideoCallPushBridge {
|
object VideoCallPushBridge {
|
||||||
private const val TAG = "VideoCallPushBridge"
|
private const val TAG = "VideoCallPushBridge"
|
||||||
private const val ANDROID_ACCENT_COLOR = "#2787F5"
|
private const val ANDROID_ACCENT_COLOR = "#2787F5"
|
||||||
private const val MISSED_CALL_CLEANUP_DELAY_MS = 250L
|
private const val MISSED_CALL_CLEANUP_DELAY_MS = 250L
|
||||||
|
private const val TOMBSTONE_TTL_MS = 2 * 60 * 1000L
|
||||||
|
private const val WAKE_LOCK_TAG = "eatvio:incoming_call_ring"
|
||||||
|
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
private val playbackLock = Any()
|
||||||
|
private val playbackGeneration = AtomicInteger(0)
|
||||||
|
private val tombstonedCallIds = HashMap<String, Long>()
|
||||||
|
|
||||||
private var activeSoundManager: CallkitSoundPlayerManager? = null
|
private var activeSoundManager: CallkitSoundPlayerManager? = null
|
||||||
private var activeNotificationManager: CallkitNotificationManager? = null
|
private var activeNotificationManager: CallkitNotificationManager? = null
|
||||||
|
private var activeCallBundle: Bundle? = null
|
||||||
|
private var storedAppContext: Context? = null
|
||||||
|
private var ringtoneTimeout: Runnable? = null
|
||||||
|
private var ringWakeLock: PowerManager.WakeLock? = null
|
||||||
|
private var callbackRegistered = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The plugin stops only the sound player owned by [FlutterCallkitIncomingPlugin].
|
||||||
|
* Incoming pushes start a different player, so accept/decline never reached it
|
||||||
|
* and the ringtone kept looping until the process died.
|
||||||
|
*/
|
||||||
|
private val incomingCallEventCallback = object : CallkitEventCallback {
|
||||||
|
override fun onCallEvent(event: CallkitEventCallback.CallEvent, callData: Bundle) {
|
||||||
|
val context = storedAppContext ?: return
|
||||||
|
Log.i(TAG, "incoming call event=$event, stopping ringtone")
|
||||||
|
silenceIncomingCall(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun isIncomingVideoCall(data: Map<String, String>): Boolean {
|
fun isIncomingVideoCall(data: Map<String, String>): Boolean {
|
||||||
val rawType = data["type"]
|
val rawType = data["type"]
|
||||||
@@ -89,18 +118,191 @@ object VideoCallPushBridge {
|
|||||||
|
|
||||||
fun showIncomingCall(context: Context, data: Map<String, String>) {
|
fun showIncomingCall(context: Context, data: Map<String, String>) {
|
||||||
val appContext = context.applicationContext
|
val appContext = context.applicationContext
|
||||||
|
storedAppContext = appContext
|
||||||
|
ensureIncomingCallCallbackRegistered()
|
||||||
|
|
||||||
|
val serverCallId = callId(data)
|
||||||
|
if (isTombstoned(serverCallId)) {
|
||||||
|
Log.i(TAG, "showIncomingCall: skip tombstoned call_id=$serverCallId")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val callData = buildCallDataBundle(data)
|
val callData = buildCallDataBundle(data)
|
||||||
if (callData == null) {
|
if (callData == null) {
|
||||||
Log.w(TAG, "showIncomingCall: missing conversation uuid, data=$data")
|
Log.w(TAG, "showIncomingCall: missing conversation uuid, data=$data")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.i(TAG, "showIncomingCall: conversation=${callData.getString(CallkitConstants.EXTRA_CALLKIT_ID)}")
|
val callId = callData.getString(CallkitConstants.EXTRA_CALLKIT_ID)
|
||||||
|
val seenGeneration = playbackGeneration.get()
|
||||||
|
Log.i(TAG, "showIncomingCall: id=$callId conversation=${conversationUuid(data)}")
|
||||||
|
|
||||||
|
// Ringtone.play() and stop() must run on the same thread. FCM delivers
|
||||||
|
// pushes on a background thread, while Answer runs on the main thread.
|
||||||
|
// A second show for the same conversation must not bump the generation:
|
||||||
|
// that would cancel the timeout of the call that is already ringing.
|
||||||
|
mainHandler.post {
|
||||||
|
synchronized(playbackLock) {
|
||||||
|
if (seenGeneration != playbackGeneration.get()) {
|
||||||
|
Log.i(TAG, "showIncomingCall: skipped after silence")
|
||||||
|
return@post
|
||||||
|
}
|
||||||
|
val alreadyRinging = activeCallBundle?.getString(CallkitConstants.EXTRA_CALLKIT_ID)
|
||||||
|
if (!callId.isNullOrBlank() && callId == alreadyRinging) {
|
||||||
|
Log.i(TAG, "showIncomingCall: already ringing $callId")
|
||||||
|
return@post
|
||||||
|
}
|
||||||
|
val generation = playbackGeneration.incrementAndGet()
|
||||||
|
startIncomingCallLocked(appContext, callData, generation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops the ringtone that [showIncomingCall] started and removes the incoming
|
||||||
|
* notification. Safe to call more than once.
|
||||||
|
*/
|
||||||
|
fun silenceIncomingCall(context: Context) {
|
||||||
|
val appContext = context.applicationContext
|
||||||
|
storedAppContext = appContext
|
||||||
|
Log.i(TAG, "silenceIncomingCall")
|
||||||
|
playbackGeneration.incrementAndGet()
|
||||||
|
val stop = Runnable {
|
||||||
|
synchronized(playbackLock) {
|
||||||
|
stopPlaybackLocked(appContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||||
|
stop.run()
|
||||||
|
} else {
|
||||||
|
mainHandler.post(stop)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The plugin's sound player stops on ACTION_SCREEN_OFF. The ringing screen turns
|
||||||
|
* the display on itself, so keep ringing through that transition.
|
||||||
|
*/
|
||||||
|
fun keepRingingOnFullScreen() {
|
||||||
|
synchronized(playbackLock) {
|
||||||
|
activeSoundManager?.keepRingingOnFullScreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissIncomingCalls(
|
||||||
|
context: Context,
|
||||||
|
callId: String? = null,
|
||||||
|
conversationUuid: String? = null,
|
||||||
|
) {
|
||||||
|
val normalizedCallId = callId?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
val normalizedConversation = conversationUuid?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
if (normalizedCallId != null) {
|
||||||
|
markTombstone(normalizedCallId)
|
||||||
|
}
|
||||||
|
|
||||||
|
val appContext = context.applicationContext
|
||||||
|
val calls = getDataActiveCalls(appContext)
|
||||||
|
Log.i(
|
||||||
|
TAG,
|
||||||
|
"dismissIncomingCalls: active=${calls.size} call_id=${normalizedCallId ?: "-"} " +
|
||||||
|
"conversation=${normalizedConversation ?: "-"}",
|
||||||
|
)
|
||||||
|
playbackGeneration.incrementAndGet()
|
||||||
|
|
||||||
|
val dismiss = Runnable {
|
||||||
|
synchronized(playbackLock) {
|
||||||
|
val notificationManager = resolveNotificationManager(appContext)
|
||||||
|
val allBundles = calls.map { it.toBundle() }
|
||||||
|
val targeted = hasDismissTarget(normalizedCallId, normalizedConversation)
|
||||||
|
val bundles = if (targeted) {
|
||||||
|
allBundles.filter { bundleMatches(it, normalizedCallId, normalizedConversation) }
|
||||||
|
} else {
|
||||||
|
allBundles
|
||||||
|
}
|
||||||
|
|
||||||
|
val activeMatches = activeCallBundle?.let { bundle ->
|
||||||
|
!targeted || bundleMatches(bundle, normalizedCallId, normalizedConversation)
|
||||||
|
} == true
|
||||||
|
if (activeMatches || !targeted) {
|
||||||
|
stopPlaybackLocked(appContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (bundle in bundles) {
|
||||||
|
try {
|
||||||
|
dismissIncomingCallNotification(appContext, notificationManager, bundle)
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(
|
||||||
|
TAG,
|
||||||
|
"dismissIncomingCalls: failed for call ${bundle.getString(CallkitConstants.EXTRA_CALLKIT_ID)}",
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targeted || bundles.isNotEmpty()) {
|
||||||
|
try {
|
||||||
|
CallkitNotificationService.stopService(appContext)
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(TAG, "dismissIncomingCalls: failed to stop callkit service", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targeted) {
|
||||||
|
try {
|
||||||
|
removeAllCalls(appContext)
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(TAG, "dismissIncomingCalls: failed to remove persisted calls", error)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (bundle in bundles) {
|
||||||
|
try {
|
||||||
|
removeCall(appContext, Data.fromBundle(bundle))
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(TAG, "dismissIncomingCalls: failed to remove targeted call", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeMatches || !targeted) {
|
||||||
|
stopIncomingRingtone()
|
||||||
|
}
|
||||||
|
scheduleMissedCallCleanup(notificationManager, bundles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||||
|
dismiss.run()
|
||||||
|
} else {
|
||||||
|
mainHandler.post(dismiss)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissAllIncomingCalls(context: Context) {
|
||||||
|
dismissIncomingCalls(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissEndedVideoCall(context: Context, data: Map<String, String>) {
|
||||||
|
dismissIncomingCalls(
|
||||||
|
context = context,
|
||||||
|
callId = callId(data),
|
||||||
|
conversationUuid = conversationUuid(data),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureIncomingCallCallbackRegistered() {
|
||||||
|
if (callbackRegistered) return
|
||||||
|
FlutterCallkitIncomingPlugin.registerEventCallback(incomingCallEventCallback)
|
||||||
|
callbackRegistered = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startIncomingCallLocked(appContext: Context, callData: Bundle, generation: Int) {
|
||||||
|
stopPlaybackLocked(appContext)
|
||||||
|
|
||||||
val soundManager = CallkitSoundPlayerManager(appContext)
|
val soundManager = CallkitSoundPlayerManager(appContext)
|
||||||
val notificationManager = CallkitNotificationManager(appContext, soundManager)
|
val notificationManager = CallkitNotificationManager(appContext, soundManager)
|
||||||
activeSoundManager = soundManager
|
activeSoundManager = soundManager
|
||||||
activeNotificationManager = notificationManager
|
activeNotificationManager = notificationManager
|
||||||
|
activeCallBundle = callData
|
||||||
notificationManager.createNotificationChanel(callData)
|
notificationManager.createNotificationChanel(callData)
|
||||||
notificationManager.showIncomingNotification(callData)
|
notificationManager.showIncomingNotification(callData)
|
||||||
|
|
||||||
@@ -109,46 +311,78 @@ object VideoCallPushBridge {
|
|||||||
} catch (error: Exception) {
|
} catch (error: Exception) {
|
||||||
Log.w(TAG, "showIncomingCall: failed to persist active call", error)
|
Log.w(TAG, "showIncomingCall: failed to persist active call", error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val durationMs = callData.getLong(CallkitConstants.EXTRA_CALLKIT_DURATION, 45_000L)
|
||||||
|
wakeScreenLocked(appContext, if (durationMs > 0L) durationMs else 45_000L)
|
||||||
|
if (durationMs > 0L) {
|
||||||
|
val timeout = Runnable {
|
||||||
|
synchronized(playbackLock) {
|
||||||
|
if (generation != playbackGeneration.get()) return@Runnable
|
||||||
|
Log.i(TAG, "incoming ringtone timed out")
|
||||||
|
stopPlaybackLocked(appContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ringtoneTimeout = timeout
|
||||||
|
mainHandler.postDelayed(timeout, durationMs)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun dismissAllIncomingCalls(context: Context) {
|
/**
|
||||||
val appContext = context.applicationContext
|
* Turns the screen on while the call rings. The full-screen intent of the
|
||||||
val calls = getDataActiveCalls(appContext)
|
* notification does the same only when the system grants it; the wake lock
|
||||||
Log.i(TAG, "dismissAllIncomingCalls: active calls=${calls.size}")
|
* works without any grant, so the lock screen with the call card lights up.
|
||||||
|
*/
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
private fun wakeScreenLocked(appContext: Context, durationMs: Long) {
|
||||||
|
releaseScreenWakeLockLocked()
|
||||||
|
try {
|
||||||
|
val powerManager = appContext.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
|
val lock = powerManager.newWakeLock(
|
||||||
|
PowerManager.SCREEN_BRIGHT_WAKE_LOCK or
|
||||||
|
PowerManager.ACQUIRE_CAUSES_WAKEUP or
|
||||||
|
PowerManager.ON_AFTER_RELEASE,
|
||||||
|
WAKE_LOCK_TAG,
|
||||||
|
)
|
||||||
|
lock.acquire(durationMs)
|
||||||
|
ringWakeLock = lock
|
||||||
|
Log.i(TAG, "wakeScreen: screen on for ${durationMs}ms, interactive=${powerManager.isInteractive}")
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(TAG, "wakeScreen: failed to acquire wake lock", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
stopIncomingRingtone(appContext)
|
private fun releaseScreenWakeLockLocked() {
|
||||||
|
val lock = ringWakeLock ?: return
|
||||||
|
ringWakeLock = null
|
||||||
|
try {
|
||||||
|
if (lock.isHeld) lock.release()
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(TAG, "wakeScreen: failed to release wake lock", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val notificationManager = resolveNotificationManager(appContext)
|
private fun stopPlaybackLocked(appContext: Context) {
|
||||||
val bundles = calls.map { it.toBundle() }
|
cancelRingtoneTimeoutLocked()
|
||||||
|
releaseScreenWakeLockLocked()
|
||||||
for (bundle in bundles) {
|
val notificationManager = activeNotificationManager
|
||||||
|
val bundle = activeCallBundle
|
||||||
|
stopIncomingRingtone()
|
||||||
|
if (notificationManager != null && bundle != null) {
|
||||||
try {
|
try {
|
||||||
dismissIncomingCallNotification(appContext, notificationManager, bundle)
|
dismissIncomingCallNotification(appContext, notificationManager, bundle)
|
||||||
} catch (error: Exception) {
|
} catch (error: Exception) {
|
||||||
Log.w(
|
Log.w(TAG, "stopPlayback: failed to dismiss incoming notification", error)
|
||||||
TAG,
|
|
||||||
"dismissAllIncomingCalls: failed for call ${bundle.getString(CallkitConstants.EXTRA_CALLKIT_ID)}",
|
|
||||||
error,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
CallkitNotificationService.stopService(appContext)
|
|
||||||
} catch (error: Exception) {
|
|
||||||
Log.w(TAG, "dismissAllIncomingCalls: failed to stop callkit service", error)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
removeAllCalls(appContext)
|
|
||||||
} catch (error: Exception) {
|
|
||||||
Log.w(TAG, "dismissAllIncomingCalls: failed to remove persisted calls", error)
|
|
||||||
}
|
|
||||||
|
|
||||||
stopIncomingRingtone(appContext)
|
|
||||||
scheduleMissedCallCleanup(notificationManager, bundles)
|
|
||||||
activeSoundManager = null
|
activeSoundManager = null
|
||||||
activeNotificationManager = null
|
activeNotificationManager = null
|
||||||
|
activeCallBundle = null
|
||||||
|
EatvioCallkitIncomingBridgeActivity.dismissRingingScreen()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelRingtoneTimeoutLocked() {
|
||||||
|
ringtoneTimeout?.let { mainHandler.removeCallbacks(it) }
|
||||||
|
ringtoneTimeout = null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun resolveNotificationManager(context: Context): CallkitNotificationManager {
|
private fun resolveNotificationManager(context: Context): CallkitNotificationManager {
|
||||||
@@ -161,11 +395,19 @@ object VideoCallPushBridge {
|
|||||||
return CallkitNotificationManager(context, soundManager)
|
return CallkitNotificationManager(context, soundManager)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun stopIncomingRingtone(context: Context) {
|
private fun stopIncomingRingtone() {
|
||||||
activeSoundManager?.stop()
|
try {
|
||||||
FlutterCallkitIncomingPlugin.getInstance()
|
activeSoundManager?.stop()
|
||||||
?.getCallkitSoundPlayerManager()
|
} catch (error: Exception) {
|
||||||
?.stop()
|
Log.w(TAG, "stopIncomingRingtone: active player", error)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
FlutterCallkitIncomingPlugin.getInstance()
|
||||||
|
?.getCallkitSoundPlayerManager()
|
||||||
|
?.stop()
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(TAG, "stopIncomingRingtone: plugin player", error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -222,6 +464,8 @@ object VideoCallPushBridge {
|
|||||||
|
|
||||||
private fun buildCallDataBundle(data: Map<String, String>): Bundle? {
|
private fun buildCallDataBundle(data: Map<String, String>): Bundle? {
|
||||||
val conversationUuid = conversationUuid(data) ?: return null
|
val conversationUuid = conversationUuid(data) ?: return null
|
||||||
|
val serverCallId = callId(data)
|
||||||
|
val callKitId = serverCallId ?: conversationUuid
|
||||||
val callerName = data["counterpartName"]
|
val callerName = data["counterpartName"]
|
||||||
?: data["counterpart_name"]
|
?: data["counterpart_name"]
|
||||||
?: data["caller_name"]
|
?: data["caller_name"]
|
||||||
@@ -247,6 +491,9 @@ object VideoCallPushBridge {
|
|||||||
|
|
||||||
val extra = HashMap<String, Any?>()
|
val extra = HashMap<String, Any?>()
|
||||||
extra["conversationUuid"] = conversationUuid
|
extra["conversationUuid"] = conversationUuid
|
||||||
|
if (!serverCallId.isNullOrBlank()) {
|
||||||
|
extra["call_id"] = serverCallId
|
||||||
|
}
|
||||||
extra["counterpartName"] = callerName
|
extra["counterpartName"] = callerName
|
||||||
if (avatarUrl.isNotBlank()) {
|
if (avatarUrl.isNotBlank()) {
|
||||||
extra["counterpartAvatarUrl"] = avatarUrl
|
extra["counterpartAvatarUrl"] = avatarUrl
|
||||||
@@ -257,14 +504,18 @@ object VideoCallPushBridge {
|
|||||||
if (counterpartIsConsultant) {
|
if (counterpartIsConsultant) {
|
||||||
extra["counterpartIsConsultant"] = true
|
extra["counterpartIsConsultant"] = true
|
||||||
}
|
}
|
||||||
|
val messageId = data["message_id"] ?: data["messageId"]
|
||||||
|
if (!messageId.isNullOrBlank()) {
|
||||||
|
extra["message_id"] = messageId
|
||||||
|
}
|
||||||
|
|
||||||
return Bundle().apply {
|
return Bundle().apply {
|
||||||
putString(CallkitConstants.EXTRA_CALLKIT_ID, conversationUuid)
|
putString(CallkitConstants.EXTRA_CALLKIT_ID, callKitId)
|
||||||
putString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, callerName)
|
putString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, callerName)
|
||||||
putString(CallkitConstants.EXTRA_CALLKIT_APP_NAME, "Итвио")
|
putString(CallkitConstants.EXTRA_CALLKIT_APP_NAME, "Итвио")
|
||||||
putString(CallkitConstants.EXTRA_CALLKIT_HANDLE, conversationUuid)
|
putString(CallkitConstants.EXTRA_CALLKIT_HANDLE, conversationUuid)
|
||||||
putString(CallkitConstants.EXTRA_CALLKIT_AVATAR, avatarUrl)
|
putString(CallkitConstants.EXTRA_CALLKIT_AVATAR, avatarUrl)
|
||||||
putInt(CallkitConstants.EXTRA_CALLKIT_TYPE, 0)
|
putInt(CallkitConstants.EXTRA_CALLKIT_TYPE, 1)
|
||||||
putLong(CallkitConstants.EXTRA_CALLKIT_DURATION, 45000L)
|
putLong(CallkitConstants.EXTRA_CALLKIT_DURATION, 45000L)
|
||||||
putString(CallkitConstants.EXTRA_CALLKIT_TEXT_ACCEPT, "Answer")
|
putString(CallkitConstants.EXTRA_CALLKIT_TEXT_ACCEPT, "Answer")
|
||||||
putString(CallkitConstants.EXTRA_CALLKIT_TEXT_DECLINE, "Decline")
|
putString(CallkitConstants.EXTRA_CALLKIT_TEXT_DECLINE, "Decline")
|
||||||
@@ -284,6 +535,77 @@ object VideoCallPushBridge {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun callId(data: Map<String, String>): String? {
|
||||||
|
return data["call_id"]?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
?: data["callId"]?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasDismissTarget(callId: String?, conversationUuid: String?): Boolean {
|
||||||
|
return !callId.isNullOrBlank() || !conversationUuid.isNullOrBlank()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bundleMatches(
|
||||||
|
bundle: Bundle,
|
||||||
|
callId: String?,
|
||||||
|
conversationUuid: String?,
|
||||||
|
): Boolean {
|
||||||
|
val normalizedCallId = callId?.trim()?.lowercase()
|
||||||
|
if (!normalizedCallId.isNullOrEmpty()) {
|
||||||
|
val id = bundle.getString(CallkitConstants.EXTRA_CALLKIT_ID)?.trim()?.lowercase()
|
||||||
|
if (id != null && id == normalizedCallId) return true
|
||||||
|
val extraCallId = extraString(bundle, "call_id")
|
||||||
|
?: extraString(bundle, "callId")
|
||||||
|
if (extraCallId != null && extraCallId.lowercase() == normalizedCallId) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// A ring that already stores its own call id must not be closed by
|
||||||
|
// another call in the same chat. Legacy rings have no call id and
|
||||||
|
// still match the conversation.
|
||||||
|
if (!extraCallId.isNullOrBlank()) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val normalizedConversation = conversationUuid?.trim()?.lowercase() ?: return false
|
||||||
|
val handle = bundle.getString(CallkitConstants.EXTRA_CALLKIT_HANDLE)?.trim()?.lowercase()
|
||||||
|
if (handle != null && handle == normalizedConversation) return true
|
||||||
|
val extraConversation = extraString(bundle, "conversationUuid")
|
||||||
|
?: extraString(bundle, "conversation_uuid")
|
||||||
|
return extraConversation?.lowercase() == normalizedConversation
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
private fun extraString(bundle: Bundle, key: String): String? {
|
||||||
|
val extra = bundle.getSerializable(CallkitConstants.EXTRA_CALLKIT_EXTRA) as? HashMap<String, Any?>
|
||||||
|
?: return null
|
||||||
|
val value = extra[key] ?: return null
|
||||||
|
val text = value.toString().trim()
|
||||||
|
return text.takeIf { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun markTombstone(callId: String) {
|
||||||
|
val normalized = callId.trim().lowercase()
|
||||||
|
if (normalized.isEmpty()) return
|
||||||
|
pruneTombstones()
|
||||||
|
tombstonedCallIds[normalized] = System.currentTimeMillis() + TOMBSTONE_TTL_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isTombstoned(callId: String?): Boolean {
|
||||||
|
val normalized = callId?.trim()?.lowercase() ?: return false
|
||||||
|
if (normalized.isEmpty()) return false
|
||||||
|
pruneTombstones()
|
||||||
|
return tombstonedCallIds.containsKey(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pruneTombstones() {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val iterator = tombstonedCallIds.entries.iterator()
|
||||||
|
while (iterator.hasNext()) {
|
||||||
|
val entry = iterator.next()
|
||||||
|
if (entry.value <= now) {
|
||||||
|
iterator.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun conversationUuid(data: Map<String, String>): String? {
|
private fun conversationUuid(data: Map<String, String>): String? {
|
||||||
val fromData = data["conversationUuid"]
|
val fromData = data["conversationUuid"]
|
||||||
?: data["conversation_uuid"]
|
?: data["conversation_uuid"]
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.eatvioapp.app
|
||||||
|
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class IncomingCallRingtonePolicyTest {
|
||||||
|
@Test
|
||||||
|
fun answerDeclineEndAndTimeoutStopTheRingtone() {
|
||||||
|
assertTrue(
|
||||||
|
IncomingCallRingtonePolicy.actionStopsRingtone(
|
||||||
|
"com.hiennv.flutter_callkit_incoming.ACTION_CALL_ACCEPT",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
IncomingCallRingtonePolicy.actionStopsRingtone(
|
||||||
|
"com.hiennv.flutter_callkit_incoming.ACTION_CALL_DECLINE",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
IncomingCallRingtonePolicy.actionStopsRingtone(
|
||||||
|
"com.hiennv.flutter_callkit_incoming.ACTION_CALL_ENDED",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
IncomingCallRingtonePolicy.actionStopsRingtone(
|
||||||
|
"com.hiennv.flutter_callkit_incoming.ACTION_CALL_TIMEOUT",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun incomingAndStartActionsKeepTheRingtonePlaying() {
|
||||||
|
assertFalse(
|
||||||
|
IncomingCallRingtonePolicy.actionStopsRingtone(
|
||||||
|
"com.hiennv.flutter_callkit_incoming.ACTION_CALL_INCOMING",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertFalse(
|
||||||
|
IncomingCallRingtonePolicy.actionStopsRingtone(
|
||||||
|
"com.hiennv.flutter_callkit_incoming.ACTION_CALL_START",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package com.eatvioapp.app
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class VideoCallDeclineRequestTest {
|
||||||
|
private val baseUrl = "https://eatvio.example"
|
||||||
|
private val token = "sanctum-token"
|
||||||
|
private val hwId = "install-hw-id-01"
|
||||||
|
private val conversation = "11111111-1111-1111-1111-111111111111"
|
||||||
|
private val callId = "22222222-2222-2222-2222-222222222222"
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildsDeclinePostWithCallId() {
|
||||||
|
val request = request(callId = callId)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"https://eatvio.example/api/v2/chat/$conversation/video-call/decline",
|
||||||
|
request?.url,
|
||||||
|
)
|
||||||
|
assertEquals(token, request?.token)
|
||||||
|
assertEquals(
|
||||||
|
"""{"hw_id":"$hwId","call_id":"$callId"}""",
|
||||||
|
request?.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun omitsCallIdWhenItIsTheConversationUuid() {
|
||||||
|
val request = request(callId = conversation.uppercase())
|
||||||
|
|
||||||
|
assertEquals("""{"hw_id":"$hwId"}""", request?.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun omitsCallIdThatIsNotAUuid() {
|
||||||
|
val request = request(callId = "message-15")
|
||||||
|
|
||||||
|
assertEquals("""{"hw_id":"$hwId"}""", request?.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun readsConversationFromHandleWhenExtraHasNone() {
|
||||||
|
val fields = VideoCallDeclineRequestFactory.fields(conversation, emptyMap<String, Any?>())
|
||||||
|
val request = VideoCallDeclineRequestFactory.build(baseUrl, token, hwId, fields)
|
||||||
|
|
||||||
|
assertTrue(request?.url?.contains(conversation) == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun skipsEndedPush() {
|
||||||
|
val fields = VideoCallDeclineRequestFactory.fields(
|
||||||
|
conversation,
|
||||||
|
mapOf("conversationUuid" to conversation, "endedPush" to true),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNull(VideoCallDeclineRequestFactory.build(baseUrl, token, hwId, fields))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun skipsMissingTokenBaseUrlOrShortHwId() {
|
||||||
|
val fields = VideoCallDeclineRequestFactory.fields(
|
||||||
|
null,
|
||||||
|
mapOf("conversationUuid" to conversation, "call_id" to callId),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNull(VideoCallDeclineRequestFactory.build(null, token, hwId, fields))
|
||||||
|
assertNull(VideoCallDeclineRequestFactory.build(baseUrl, " ", hwId, fields))
|
||||||
|
assertNull(VideoCallDeclineRequestFactory.build(baseUrl, token, "short", fields))
|
||||||
|
assertNull(VideoCallDeclineRequestFactory.build("eatvio.example", token, hwId, fields))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun trimsATrailingSlashOnTheBaseUrl() {
|
||||||
|
val request = VideoCallDeclineRequestFactory.build(
|
||||||
|
"$baseUrl/",
|
||||||
|
token,
|
||||||
|
hwId,
|
||||||
|
VideoCallDeclineRequestFactory.fields(
|
||||||
|
null,
|
||||||
|
mapOf("conversationUuid" to conversation),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"https://eatvio.example/api/v2/chat/$conversation/video-call/decline",
|
||||||
|
request?.url,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun request(callId: String?): VideoCallDeclineRequest? {
|
||||||
|
val fields = VideoCallDeclineRequestFactory.fields(
|
||||||
|
null,
|
||||||
|
mapOf(
|
||||||
|
"conversationUuid" to conversation,
|
||||||
|
"call_id" to callId,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return VideoCallDeclineRequestFactory.build(baseUrl, token, hwId, fields)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -650,7 +650,7 @@ code + .copy-button {
|
|||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
function configurationCacheProblems() { return (
|
function configurationCacheProblems() { return (
|
||||||
// begin-report-data
|
// begin-report-data
|
||||||
{"diagnostics":[{"problem":[{"text":"Properties should be assigned using the 'propName = value' syntax. Setting a property via the Gradle-generated 'propName value' or 'propName(value)' syntax in Groovy DSL has been deprecated."}],"severity":"WARNING","problemDetails":[{"text":"This is scheduled to be removed in Gradle 10."}],"contextualLabel":"Properties should be assigned using the 'propName = value' syntax. Setting a property via the Gradle-generated 'propName value' or 'propName(value)' syntax in Groovy DSL has been deprecated.","documentationLink":"https://docs.gradle.org/9.0.0/userguide/upgrading_version_8.html#groovy_space_assignment_syntax","problemId":[{"name":"deprecation","displayName":"Deprecation"},{"name":"properties-should-be-assigned-using-the-propname-value-syntax-setting-a-property-via-the-gradle-generated-propname-value-or-propname-value-syntax-in-groovy-dsl","displayName":"Properties should be assigned using the 'propName = value' syntax. Setting a property via the Gradle-generated 'propName value' or 'propName(value)' syntax in Groovy DSL has been deprecated."}],"solutions":[[{"text":"Use assignment ('url = <value>') instead."}]]}],"problemsReport":{"totalProblemCount":1,"requestedTasks":":app:compileDebugKotlin","documentationLink":"https://docs.gradle.org/9.0.0/userguide/reporting_problems.html","documentationLinkCaption":"Problem report","summaries":[]}}
|
{"diagnostics":[{"problem":[{"text":"Properties should be assigned using the 'propName = value' syntax. Setting a property via the Gradle-generated 'propName value' or 'propName(value)' syntax in Groovy DSL has been deprecated."}],"severity":"WARNING","problemDetails":[{"text":"This is scheduled to be removed in Gradle 10."}],"contextualLabel":"Properties should be assigned using the 'propName = value' syntax. Setting a property via the Gradle-generated 'propName value' or 'propName(value)' syntax in Groovy DSL has been deprecated.","documentationLink":"https://docs.gradle.org/9.0.0/userguide/upgrading_version_8.html#groovy_space_assignment_syntax","problemId":[{"name":"deprecation","displayName":"Deprecation"},{"name":"properties-should-be-assigned-using-the-propname-value-syntax-setting-a-property-via-the-gradle-generated-propname-value-or-propname-value-syntax-in-groovy-dsl","displayName":"Properties should be assigned using the 'propName = value' syntax. Setting a property via the Gradle-generated 'propName value' or 'propName(value)' syntax in Groovy DSL has been deprecated."}],"solutions":[[{"text":"Use assignment ('url = <value>') instead."}]]}],"problemsReport":{"totalProblemCount":1,"requestedTasks":":app:testDebugUnitTest --tests com.eatvioapp.app.VideoCallDeclineRequestTest","documentationLink":"https://docs.gradle.org/9.0.0/userguide/reporting_problems.html","documentationLinkCaption":"Problem report","summaries":[]}}
|
||||||
// end-report-data
|
// end-report-data
|
||||||
);}
|
);}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -44,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',
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
<array>
|
<array>
|
||||||
<string>applinks:eatvio.ru</string>
|
<string>applinks:eatvio.ru</string>
|
||||||
<string>applinks:www.eatvio.ru</string>
|
<string>applinks:www.eatvio.ru</string>
|
||||||
|
<string>applinks:eatvio.com</string>
|
||||||
|
<string>applinks:www.eatvio.com</string>
|
||||||
<string>applinks:5399b03636104c6d920b87c1ad329254.oauth.yandex.ru</string>
|
<string>applinks:5399b03636104c6d920b87c1ad329254.oauth.yandex.ru</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
86
ios/Runner/SceneDelegate.swift
Normal file
86
ios/Runner/SceneDelegate.swift
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import Flutter
|
||||||
|
import UIKit
|
||||||
|
import YandexLoginSDK
|
||||||
|
|
||||||
|
/// iOS 27 requires a UIScene lifecycle. Flutter 3.38 does not yet have
|
||||||
|
/// `FlutterImplicitEngineDelegate`, so channels and URL handling are wired
|
||||||
|
/// here after the scene creates the Flutter view.
|
||||||
|
class SceneDelegate: FlutterSceneDelegate {
|
||||||
|
override func scene(
|
||||||
|
_ scene: UIScene,
|
||||||
|
willConnectTo session: UISceneSession,
|
||||||
|
options connectionOptions: UIScene.ConnectionOptions
|
||||||
|
) {
|
||||||
|
super.scene(scene, willConnectTo: session, options: connectionOptions)
|
||||||
|
registerCallChannels()
|
||||||
|
handleConnectionOptions(connectionOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func sceneDidBecomeActive(_ scene: UIScene) {
|
||||||
|
super.sceneDidBecomeActive(scene)
|
||||||
|
registerCallChannels()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
|
||||||
|
var handledCustomURL = false
|
||||||
|
if let appDelegate = UIApplication.shared.delegate as? AppDelegate {
|
||||||
|
for context in URLContexts {
|
||||||
|
if appDelegate.handleOpenURL(
|
||||||
|
context.url,
|
||||||
|
sourceApplication: context.options.sourceApplication
|
||||||
|
) {
|
||||||
|
handledCustomURL = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !handledCustomURL {
|
||||||
|
super.scene(scene, openURLContexts: URLContexts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
|
||||||
|
if YXLSdk.shared.processUserActivity(userActivity) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
super.scene(scene, continue: userActivity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func registerCallChannels() {
|
||||||
|
guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
appDelegate.registerCallChannels(from: flutterViewController)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var flutterViewController: FlutterViewController? {
|
||||||
|
if let controller = window?.rootViewController as? FlutterViewController {
|
||||||
|
return controller
|
||||||
|
}
|
||||||
|
for scene in UIApplication.shared.connectedScenes {
|
||||||
|
guard let windowScene = scene as? UIWindowScene else { continue }
|
||||||
|
for sceneWindow in windowScene.windows {
|
||||||
|
if let controller = sceneWindow.rootViewController as? FlutterViewController {
|
||||||
|
return controller
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleConnectionOptions(_ options: UIScene.ConnectionOptions) {
|
||||||
|
guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for context in options.urlContexts {
|
||||||
|
_ = appDelegate.handleOpenURL(
|
||||||
|
context.url,
|
||||||
|
sourceApplication: context.options.sourceApplication
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for activity in options.userActivities {
|
||||||
|
if YXLSdk.shared.processUserActivity(activity) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
149
ios/Runner/VideoCallDeclineClient.swift
Normal file
149
ios/Runner/VideoCallDeclineClient.swift
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
enum VideoCallDeclineClient {
|
||||||
|
private static let uuidPattern =
|
||||||
|
#"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"#
|
||||||
|
|
||||||
|
/// `POST /video-call/decline` from the CallKit callback. The process is
|
||||||
|
/// awake here even when the user never opens the app afterwards.
|
||||||
|
static func send(extra: [String: Any], handle: String) {
|
||||||
|
let fields = declineFields(extra: extra, handle: handle)
|
||||||
|
guard let request = makeRequest(
|
||||||
|
baseUrl: prefsString("apiBaseUrl") ?? prefsString("customBaseUrl"),
|
||||||
|
token: prefsString("token"),
|
||||||
|
hwId: UIDevice.current.identifierForVendor?.uuidString,
|
||||||
|
fields: fields
|
||||||
|
) else {
|
||||||
|
NSLog(
|
||||||
|
"[EatvioVoIP] decline POST skipped conversation=\(fields.conversationUuid) ended=\(fields.endedPush)"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let task = EatvioBackgroundTask()
|
||||||
|
task.begin(name: "video-call-decline")
|
||||||
|
let urlTask = URLSession.shared.dataTask(with: request) { _, response, error in
|
||||||
|
let code = (response as? HTTPURLResponse)?.statusCode ?? -1
|
||||||
|
NSLog(
|
||||||
|
"[EatvioVoIP] decline POST status=\(code) conversation=\(fields.conversationUuid) error=\(error?.localizedDescription ?? "-")"
|
||||||
|
)
|
||||||
|
task.end()
|
||||||
|
}
|
||||||
|
urlTask.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func makeRequest(
|
||||||
|
baseUrl: String?,
|
||||||
|
token: String?,
|
||||||
|
hwId: String?,
|
||||||
|
fields: VideoCallDeclineFields
|
||||||
|
) -> URLRequest? {
|
||||||
|
if fields.endedPush { return nil }
|
||||||
|
let conversation = fields.conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let authToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
let deviceId = hwId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
var root = baseUrl?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
while root.hasSuffix("/") {
|
||||||
|
root.removeLast()
|
||||||
|
}
|
||||||
|
guard !conversation.isEmpty, !authToken.isEmpty else { return nil }
|
||||||
|
guard deviceId.count >= 8, deviceId.count <= 64 else { return nil }
|
||||||
|
guard root.hasPrefix("http://") || root.hasPrefix("https://") else { return nil }
|
||||||
|
guard let encoded = conversation.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed),
|
||||||
|
let url = URL(string: "\(root)/api/v2/chat/\(encoded)/video-call/decline")
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
var body: [String: String] = ["hw_id": deviceId]
|
||||||
|
if let callId = fields.callId {
|
||||||
|
body["call_id"] = callId
|
||||||
|
}
|
||||||
|
guard let data = try? JSONSerialization.data(withJSONObject: body) else { return nil }
|
||||||
|
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.timeoutInterval = 10
|
||||||
|
request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.httpBody = data
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func declineFields(extra: [String: Any], handle: String) -> VideoCallDeclineFields {
|
||||||
|
let conversation = firstString(extra, keys: ["conversationUuid", "conversation_uuid"])
|
||||||
|
?? handle.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let rawCallId = firstString(extra, keys: ["call_id", "callId"])
|
||||||
|
return VideoCallDeclineFields(
|
||||||
|
conversationUuid: conversation,
|
||||||
|
callId: backendCallId(rawCallId, conversation: conversation),
|
||||||
|
endedPush: marksEndedPush(extra["endedPush"])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func backendCallId(_ callId: String?, conversation: String) -> String? {
|
||||||
|
let id = callId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
guard let regex = try? NSRegularExpression(pattern: uuidPattern),
|
||||||
|
regex.firstMatch(in: id, range: NSRange(id.startIndex..., in: id)) != nil
|
||||||
|
else { return nil }
|
||||||
|
if id.compare(conversation.trimmingCharacters(in: .whitespacesAndNewlines), options: .caseInsensitive) == .orderedSame {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func firstString(_ extra: [String: Any], keys: [String]) -> String? {
|
||||||
|
for key in keys {
|
||||||
|
guard let value = extra[key] as? String else { continue }
|
||||||
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !trimmed.isEmpty { return trimmed }
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func marksEndedPush(_ value: Any?) -> Bool {
|
||||||
|
if let bool = value as? Bool { return bool }
|
||||||
|
if let number = value as? NSNumber { return number.intValue != 0 }
|
||||||
|
if let text = value as? String {
|
||||||
|
let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
return normalized == "true" || normalized == "1"
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func prefsString(_ key: String) -> String? {
|
||||||
|
let value = UserDefaults.standard.string(forKey: "flutter.\(key)")?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if let value, !value.isEmpty { return value }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct VideoCallDeclineFields {
|
||||||
|
var conversationUuid: String
|
||||||
|
var callId: String?
|
||||||
|
var endedPush: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class EatvioBackgroundTask {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var id: UIBackgroundTaskIdentifier = .invalid
|
||||||
|
|
||||||
|
func begin(name: String) {
|
||||||
|
let taskId = UIApplication.shared.beginBackgroundTask(withName: name) { [weak self] in
|
||||||
|
self?.end()
|
||||||
|
}
|
||||||
|
lock.lock()
|
||||||
|
id = taskId
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func end() {
|
||||||
|
lock.lock()
|
||||||
|
let current = id
|
||||||
|
id = .invalid
|
||||||
|
lock.unlock()
|
||||||
|
guard current != .invalid else { return }
|
||||||
|
UIApplication.shared.endBackgroundTask(current)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:eatvio_app/core/app_initializer.dart';
|
import 'package:eatvio_app/core/app_initializer.dart';
|
||||||
import 'package:eatvio_app/core/cubit/marketing/marketing_cubit.dart';
|
import 'package:eatvio_app/core/cubit/marketing/marketing_cubit.dart';
|
||||||
import 'package:eatvio_app/core/env_config.dart';
|
import 'package:eatvio_app/core/env_config.dart';
|
||||||
import 'package:eatvio_app/core/internet_connection_cubit/internet_connection_cubit.dart';
|
import 'package:eatvio_app/core/internet_connection_cubit/internet_connection_cubit.dart';
|
||||||
import 'package:eatvio_app/core/locator_service.dart';
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
|
import 'package:eatvio_app/core/prefs.dart';
|
||||||
|
import 'package:eatvio_app/core/prefs_keys.dart';
|
||||||
import 'package:eatvio_app/core/views/cubit/views_cubit.dart';
|
import 'package:eatvio_app/core/views/cubit/views_cubit.dart';
|
||||||
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/new_messages_cubit/new_messages_cubit.dart';
|
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/new_messages_cubit/new_messages_cubit.dart';
|
||||||
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
|
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
|
||||||
@@ -31,20 +35,51 @@ class AppConfig {
|
|||||||
|
|
||||||
void setBaseUrl(String baseUrls) {
|
void setBaseUrl(String baseUrls) {
|
||||||
_baseUrl = baseUrls;
|
_baseUrl = baseUrls;
|
||||||
|
_persistBaseUrl(baseUrls);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _persistBaseUrl(String baseUrl) {
|
||||||
|
if (!sl.isRegistered<Prefs>()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final normalized = baseUrl.trim();
|
||||||
|
if (normalized.isEmpty) {
|
||||||
|
unawaited(sl<Prefs>().remove(PrefsKeys.customBaseUrl));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unawaited(sl<Prefs>().setString(PrefsKeys.customBaseUrl, normalized));
|
||||||
|
unawaited(sl<Prefs>().setString(PrefsKeys.apiBaseUrl, normalized));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Список глобальных BlocProvider'ов
|
/// Список глобальных BlocProvider'ов
|
||||||
/// Используются lazy: true для отложенной инициализации, чтобы не блокировать старт приложения
|
/// Используются lazy: true для отложенной инициализации, чтобы не блокировать старт приложения
|
||||||
List<BlocProvider> get globalBlocProviders => [
|
List<BlocProvider> get globalBlocProviders => [
|
||||||
BlocProvider<VideoCallSessionCubit>.value(value: sl<VideoCallSessionCubit>()),
|
BlocProvider<VideoCallSessionCubit>.value(
|
||||||
BlocProvider<NewMessagesCubit>(lazy: true, create: (context) => sl<NewMessagesCubit>()),
|
value: sl<VideoCallSessionCubit>(),
|
||||||
BlocProvider<NotificationsCubit>(lazy: true, create: (context) => sl<NotificationsCubit>()),
|
),
|
||||||
|
BlocProvider<NewMessagesCubit>(
|
||||||
|
lazy: true,
|
||||||
|
create: (context) => sl<NewMessagesCubit>(),
|
||||||
|
),
|
||||||
|
BlocProvider<NotificationsCubit>(
|
||||||
|
lazy: true,
|
||||||
|
create: (context) => sl<NotificationsCubit>(),
|
||||||
|
),
|
||||||
BlocProvider<FeedCubit>(lazy: true, create: (context) => sl<FeedCubit>()),
|
BlocProvider<FeedCubit>(lazy: true, create: (context) => sl<FeedCubit>()),
|
||||||
BlocProvider<ProfileAvatarCubit>(lazy: true, create: (context) => sl<ProfileAvatarCubit>()),
|
BlocProvider<ProfileAvatarCubit>(
|
||||||
|
lazy: true,
|
||||||
|
create: (context) => sl<ProfileAvatarCubit>(),
|
||||||
|
),
|
||||||
BlocProvider<ChatsCubit>(lazy: true, create: (context) => sl<ChatsCubit>()),
|
BlocProvider<ChatsCubit>(lazy: true, create: (context) => sl<ChatsCubit>()),
|
||||||
BlocProvider<ViewsCubit>(lazy: true, create: (context) => sl<ViewsCubit>()),
|
BlocProvider<ViewsCubit>(lazy: true, create: (context) => sl<ViewsCubit>()),
|
||||||
BlocProvider<InternetConnectionCubit>(lazy: false, create: (context) => sl<InternetConnectionCubit>()),
|
BlocProvider<InternetConnectionCubit>(
|
||||||
BlocProvider<MarketingCubit>(lazy: true, create: (context) => sl<MarketingCubit>()),
|
lazy: false,
|
||||||
|
create: (context) => sl<InternetConnectionCubit>(),
|
||||||
|
),
|
||||||
|
BlocProvider<MarketingCubit>(
|
||||||
|
lazy: true,
|
||||||
|
create: (context) => sl<MarketingCubit>(),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Делегаты локализации
|
/// Делегаты локализации
|
||||||
@@ -56,5 +91,7 @@ class AppConfig {
|
|||||||
];
|
];
|
||||||
|
|
||||||
/// Поддерживаемые локали
|
/// Поддерживаемые локали
|
||||||
List<Locale> get supportedLocales => [const Locale.fromSubtags(languageCode: 'ru')];
|
List<Locale> get supportedLocales => [
|
||||||
|
const Locale.fromSubtags(languageCode: 'ru'),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:developer';
|
|
||||||
import 'dart:io' as io;
|
import 'dart:io' as io;
|
||||||
import 'package:eatvio_app/core/app_config.dart';
|
|
||||||
import 'package:eatvio_app/core/env_config.dart';
|
import 'package:eatvio_app/core/env_config.dart';
|
||||||
import 'package:eatvio_app/core/error_reporting/error_report_policy.dart';
|
import 'package:eatvio_app/core/error_reporting/error_report_policy.dart';
|
||||||
import 'package:eatvio_app/core/locator_service.dart' as di;
|
import 'package:eatvio_app/core/locator_service.dart' as di;
|
||||||
import 'package:eatvio_app/core/locator_service.dart';
|
|
||||||
import 'package:eatvio_app/features/video_calls/services/video_call_fcm_handler.dart';
|
import 'package:eatvio_app/features/video_calls/services/video_call_fcm_handler.dart';
|
||||||
import 'package:eatvio_app/firebase_options.dart';
|
import 'package:eatvio_app/firebase_options.dart';
|
||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_native_splash/flutter_native_splash.dart';
|
import 'package:flutter_native_splash/flutter_native_splash.dart';
|
||||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
@@ -18,7 +14,6 @@ import 'package:sentry_flutter/sentry_flutter.dart';
|
|||||||
|
|
||||||
/// Класс для инициализации приложения
|
/// Класс для инициализации приложения
|
||||||
class AppInitializer {
|
class AppInitializer {
|
||||||
|
|
||||||
/// Инициализация всех сервисов приложения
|
/// Инициализация всех сервисов приложения
|
||||||
static Future<void> initialize({required AppBuildType buildType}) async {
|
static Future<void> initialize({required AppBuildType buildType}) async {
|
||||||
await _initializeEnvironment();
|
await _initializeEnvironment();
|
||||||
@@ -42,25 +37,21 @@ class AppInitializer {
|
|||||||
|
|
||||||
/// Инициализация Firebase
|
/// Инициализация Firebase
|
||||||
static Future<void> _initializeFirebase() async {
|
static Future<void> _initializeFirebase() async {
|
||||||
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform).timeout(
|
await Firebase.initializeApp(
|
||||||
|
options: DefaultFirebaseOptions.currentPlatform,
|
||||||
|
).timeout(
|
||||||
const Duration(seconds: 10),
|
const Duration(seconds: 10),
|
||||||
onTimeout: () {
|
onTimeout: () {
|
||||||
throw TimeoutException('Firebase initialization timeout');
|
throw TimeoutException('Firebase initialization timeout');
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
VideoCallFcmHandler.registerBackgroundHandler();
|
VideoCallFcmHandler.registerBackgroundHandler();
|
||||||
if (sl<AppConfig>().buildType == AppBuildType.prod) {
|
|
||||||
try {
|
|
||||||
await FirebaseMessaging.instance.requestPermission().timeout(const Duration(seconds: 5));
|
|
||||||
} catch (e) {
|
|
||||||
// Разрешение не критично, продолжаем работу
|
|
||||||
log('Failed to request Firebase messaging permission: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Инициализация системы внедрения зависимостей
|
/// Инициализация системы внедрения зависимостей
|
||||||
static Future<void> _initializeDependencyInjection({required AppBuildType buildType}) async {
|
static Future<void> _initializeDependencyInjection({
|
||||||
|
required AppBuildType buildType,
|
||||||
|
}) async {
|
||||||
await di.init(buildType: buildType);
|
await di.init(buildType: buildType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
30
lib/core/cached_base_url.dart
Normal file
30
lib/core/cached_base_url.dart
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:eatvio_app/core/app_config.dart';
|
||||||
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
|
import 'package:eatvio_app/core/prefs.dart';
|
||||||
|
import 'package:eatvio_app/core/prefs_keys.dart';
|
||||||
|
|
||||||
|
/// Подставляет сохранённый base URL до создания HTTP-клиента.
|
||||||
|
///
|
||||||
|
/// Действующий адрес также пишется в [PrefsKeys.apiBaseUrl], чтобы нативный
|
||||||
|
/// decline мог собрать `POST /video-call/decline`, когда Flutter не запущен.
|
||||||
|
void applyCachedBaseUrl() {
|
||||||
|
if (!sl.isRegistered<Prefs>() || !sl.isRegistered<AppConfig>()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final cached = sl<Prefs>().getString(PrefsKeys.customBaseUrl)?.trim();
|
||||||
|
if (cached != null && cached.isNotEmpty) {
|
||||||
|
sl<AppConfig>().setBaseUrl(cached);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_persistApiBaseUrl(sl<AppConfig>().baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _persistApiBaseUrl(String baseUrl) {
|
||||||
|
final normalized = baseUrl.trim();
|
||||||
|
if (normalized.isEmpty || !sl.isRegistered<Prefs>()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unawaited(sl<Prefs>().setString(PrefsKeys.apiBaseUrl, normalized));
|
||||||
|
}
|
||||||
@@ -1,16 +1,70 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:device_info_plus/device_info_plus.dart';
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
/// Stable id of this install, sent as `hw_id` on push registration and on
|
||||||
|
/// video-call accept/decline.
|
||||||
|
///
|
||||||
|
/// Android `Build.ID` is the firmware id. Two phones on the same build share
|
||||||
|
/// it, so the backend treats them as one device and a decline on one does not
|
||||||
|
/// reach the other. A per-install id stays unique.
|
||||||
abstract final class DeviceHwIdProvider {
|
abstract final class DeviceHwIdProvider {
|
||||||
static Future<String?> current() async {
|
static const androidInstallIdKey = 'eatvio.android_install_hw_id';
|
||||||
final deviceInfo = DeviceInfoPlugin();
|
static const androidTokenMigratedKey = 'eatvio.android_hw_id_token_migrated';
|
||||||
|
|
||||||
|
static Future<String?> current({SharedPreferences? preferences}) async {
|
||||||
if (Platform.isIOS) {
|
if (Platform.isIOS) {
|
||||||
return (await deviceInfo.iosInfo).identifierForVendor;
|
return (await DeviceInfoPlugin().iosInfo).identifierForVendor;
|
||||||
}
|
}
|
||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
return (await deviceInfo.androidInfo).id;
|
return androidInstallId(preferences: preferences);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<String> androidInstallId({
|
||||||
|
SharedPreferences? preferences,
|
||||||
|
}) async {
|
||||||
|
final prefs = preferences ?? await SharedPreferences.getInstance();
|
||||||
|
final existing = prefs.getString(androidInstallIdKey)?.trim();
|
||||||
|
if (existing != null && existing.length >= 8) return existing;
|
||||||
|
|
||||||
|
final created = _newInstallId();
|
||||||
|
await prefs.setString(androidInstallIdKey, created);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The previous Android registration used `Build.ID`. Drop that token once
|
||||||
|
/// and register again with the install id.
|
||||||
|
static Future<bool> androidTokenNeedsReregister({
|
||||||
|
SharedPreferences? preferences,
|
||||||
|
}) async {
|
||||||
|
if (!Platform.isAndroid) return false;
|
||||||
|
final prefs = preferences ?? await SharedPreferences.getInstance();
|
||||||
|
return prefs.getBool(androidTokenMigratedKey) != true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> markAndroidTokenReregistered({
|
||||||
|
SharedPreferences? preferences,
|
||||||
|
}) async {
|
||||||
|
final prefs = preferences ?? await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(androidTokenMigratedKey, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _newInstallId() {
|
||||||
|
final random = Random.secure();
|
||||||
|
final bytes = List<int>.generate(16, (_) => random.nextInt(256));
|
||||||
|
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||||
|
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||||
|
final hex = bytes
|
||||||
|
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join();
|
||||||
|
return '${hex.substring(0, 8)}-'
|
||||||
|
'${hex.substring(8, 12)}-'
|
||||||
|
'${hex.substring(12, 16)}-'
|
||||||
|
'${hex.substring(16, 20)}-'
|
||||||
|
'${hex.substring(20)}';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ class CubitRegistrations {
|
|||||||
|
|
||||||
// Content viewing cubits
|
// Content viewing cubits
|
||||||
sl.registerFactory<AboutDietCubit>(() => AboutDietCubit(dietsRepository: sl(), profileRepository: sl()));
|
sl.registerFactory<AboutDietCubit>(() => AboutDietCubit(dietsRepository: sl(), profileRepository: sl()));
|
||||||
sl.registerFactory<AboutPlanCubit>(() => AboutPlanCubit(planRepository: sl()));
|
sl.registerFactory<AboutPlanCubit>(() => AboutPlanCubit(planRepository: sl(), profileRepository: sl(), dietRepository: sl()));
|
||||||
sl.registerFactory<AboutRecipeCubit>(() => AboutRecipeCubit(recipeRepository: sl(), profileRepository: sl()));
|
sl.registerFactory<AboutRecipeCubit>(() => AboutRecipeCubit(recipeRepository: sl(), profileRepository: sl()));
|
||||||
sl.registerFactory<BlogPostCubit>(() => BlogPostCubit(sl()));
|
sl.registerFactory<BlogPostCubit>(() => BlogPostCubit(sl()));
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:eatvio_app/core/push/hms_client_error.dart';
|
||||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||||
|
|
||||||
/// Rules for which errors are sent to crash reporting.
|
/// Rules for which errors are sent to crash reporting.
|
||||||
@@ -15,6 +16,7 @@ class ErrorReportPolicy {
|
|||||||
if (error == null) return false;
|
if (error == null) return false;
|
||||||
if (error is String) return false;
|
if (error is String) return false;
|
||||||
if (error is SocketException) return false;
|
if (error is SocketException) return false;
|
||||||
|
if (isHmsClientUnavailable(error)) return false;
|
||||||
|
|
||||||
if (error is DioException) {
|
if (error is DioException) {
|
||||||
return _shouldReportDio(error);
|
return _shouldReportDio(error);
|
||||||
|
|||||||
@@ -3,59 +3,59 @@ import 'dart:developer';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:eatvio_app/core/app_config.dart';
|
import 'package:eatvio_app/core/app_config.dart';
|
||||||
import 'package:eatvio_app/core/app_initializer.dart';
|
import 'package:eatvio_app/core/app_initializer.dart';
|
||||||
import 'package:eatvio_app/core/utils/toasts.dart';
|
import 'package:eatvio_app/core/device/device_hw_id_provider.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:eatvio_app/core/notifications/notification_permission_requester.dart';
|
||||||
|
import 'package:eatvio_app/core/push/hms_client_error.dart';
|
||||||
import 'package:device_info_plus/device_info_plus.dart';
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:eatvio_app/core/locator_service.dart';
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
import 'package:eatvio_app/core/token_storage.dart';
|
import 'package:eatvio_app/core/token_storage.dart';
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
import 'package:flutter/services.dart' show MethodChannel, PlatformException;
|
import 'package:flutter/services.dart';
|
||||||
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
|
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
|
||||||
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
|
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
|
||||||
|
|
||||||
class FirebaseApi {
|
class FirebaseApi {
|
||||||
final ErrorReporter _errorReporter;
|
final ErrorReporter _errorReporter;
|
||||||
|
final NotificationPermissionRequester _notificationPermissionRequester;
|
||||||
final _firebaseMessaging = FirebaseMessaging.instance;
|
final _firebaseMessaging = FirebaseMessaging.instance;
|
||||||
|
|
||||||
FirebaseApi({required ErrorReporter errorReporter}) : _errorReporter = errorReporter;
|
FirebaseApi({
|
||||||
|
required ErrorReporter errorReporter,
|
||||||
|
NotificationPermissionRequester? notificationPermissionRequester,
|
||||||
|
}) : _errorReporter = errorReporter,
|
||||||
|
_notificationPermissionRequester = notificationPermissionRequester ?? NotificationPermissionRequester();
|
||||||
|
|
||||||
Future<String?> getToken() async {
|
Future<String?> getToken() async {
|
||||||
String? token;
|
final credentials = await _resolvePushCredentials();
|
||||||
|
return credentials.token;
|
||||||
|
}
|
||||||
|
|
||||||
if (await _isHuaweiDevice()) {
|
Future<void> _requestNotificationPermission() async {
|
||||||
token = await _getHuaweiPushTokenNative().timeout(
|
if (sl<AppConfig>().buildType != AppBuildType.prod) return;
|
||||||
const Duration(seconds: 5),
|
try {
|
||||||
onTimeout: () => null,
|
await _notificationPermissionRequester.requestOnce(
|
||||||
);
|
() => _firebaseMessaging.requestPermission(),
|
||||||
} else {
|
|
||||||
if ((sl<AppConfig>().buildType == AppBuildType.prod)) {
|
|
||||||
try {
|
|
||||||
await _firebaseMessaging.requestPermission().timeout(
|
|
||||||
const Duration(seconds: 5),
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
// Разрешение не критично, продолжаем
|
|
||||||
log('Failed to request Firebase messaging permission: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
token = await _firebaseMessaging.getToken().timeout(
|
|
||||||
const Duration(seconds: 5),
|
|
||||||
onTimeout: () => null,
|
|
||||||
);
|
);
|
||||||
|
} catch (e) {
|
||||||
|
log('Failed to request Firebase messaging permission: $e');
|
||||||
}
|
}
|
||||||
return token;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> registerAnonymousPushToken() async {
|
Future<void> registerAnonymousPushToken() async {
|
||||||
try {
|
try {
|
||||||
final deviceInfo = DeviceInfoPlugin();
|
await _requestNotificationPermission();
|
||||||
|
final credentials = await _resolvePushCredentials();
|
||||||
|
final token = credentials.token;
|
||||||
|
if (token == null || token.isEmpty) return;
|
||||||
|
final hwId = await DeviceHwIdProvider.current();
|
||||||
|
if (hwId == null || hwId.length < 8) return;
|
||||||
await sl<Dio>().post(
|
await sl<Dio>().post(
|
||||||
'/api/anonymous_token',
|
'/api/anonymous_token',
|
||||||
data: {
|
data: {
|
||||||
'platform': Platform.isIOS ? 'ios' : (await _isHuaweiDevice()) ? 'huawei' : 'android',
|
'platform': credentials.platform,
|
||||||
'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id,
|
'hw_id': hwId,
|
||||||
'token': await getToken(),
|
'token': token,
|
||||||
},
|
},
|
||||||
).timeout(
|
).timeout(
|
||||||
const Duration(seconds: 10),
|
const Duration(seconds: 10),
|
||||||
@@ -71,31 +71,52 @@ class FirebaseApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> registerPushToken() async {
|
Future<void> registerPushToken() async {
|
||||||
if (await TokenStorage.haveTokenInStorage()) {
|
if (!await TokenStorage.haveTokenInStorage()) return;
|
||||||
try {
|
try {
|
||||||
final deviceInfo = DeviceInfoPlugin();
|
await _requestNotificationPermission();
|
||||||
final isHuawei = Platform.isAndroid ? await _isHuaweiDevice() : false;
|
final credentials = await _resolvePushCredentials();
|
||||||
await sl<Dio>().post(
|
final token = credentials.token;
|
||||||
'/api/profile/notification/token/register',
|
final hwId = await DeviceHwIdProvider.current();
|
||||||
data: {
|
final reregisterAndroid =
|
||||||
'platform': Platform.isIOS ? 'ios' : (isHuawei ? 'huawei' : 'android'),
|
await DeviceHwIdProvider.androidTokenNeedsReregister();
|
||||||
'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id,
|
if (token != null &&
|
||||||
'token': await getToken(),
|
token.isNotEmpty &&
|
||||||
},
|
hwId != null &&
|
||||||
).timeout(
|
hwId.length >= 8) {
|
||||||
const Duration(seconds: 10),
|
if (reregisterAndroid) {
|
||||||
onTimeout: () {
|
try {
|
||||||
throw TimeoutException('registerPushToken timeout');
|
await unregisterPushToken();
|
||||||
},
|
} catch (_) {}
|
||||||
);
|
}
|
||||||
if (Platform.isIOS) {
|
await sl<Dio>()
|
||||||
await _registerIosVoipPushToken();
|
.post(
|
||||||
|
'/api/profile/notification/token/register',
|
||||||
|
data: {
|
||||||
|
'platform': credentials.platform,
|
||||||
|
'hw_id': hwId,
|
||||||
|
'token': token,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.timeout(
|
||||||
|
const Duration(seconds: 10),
|
||||||
|
onTimeout: () {
|
||||||
|
throw TimeoutException('registerPushToken timeout');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (reregisterAndroid) {
|
||||||
|
await DeviceHwIdProvider.markAndroidTokenReregistered();
|
||||||
}
|
}
|
||||||
} catch (e, st) {
|
|
||||||
log(e.toString(), stackTrace: st);
|
|
||||||
await _errorReporter.report(e, st, origin: 'FirebaseApi');
|
|
||||||
// Не пробрасываем ошибку дальше, так как это не критичная операция
|
|
||||||
}
|
}
|
||||||
|
} catch (e, st) {
|
||||||
|
log(e.toString(), stackTrace: st);
|
||||||
|
await _errorReporter.report(e, st, origin: 'FirebaseApi');
|
||||||
|
// Не пробрасываем ошибку дальше, так как это не критичная операция
|
||||||
|
}
|
||||||
|
// Incoming calls on iOS come only through the VoIP token. It does not
|
||||||
|
// depend on the FCM token, so a missing or failed FCM registration must
|
||||||
|
// not leave the device without calls.
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
await _registerIosVoipPushToken();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,13 +136,13 @@ class FirebaseApi {
|
|||||||
Future<void> unregisterPushToken() async {
|
Future<void> unregisterPushToken() async {
|
||||||
if (!await TokenStorage.haveTokenInStorage()) return;
|
if (!await TokenStorage.haveTokenInStorage()) return;
|
||||||
try {
|
try {
|
||||||
final isHuawei = Platform.isAndroid ? await _isHuaweiDevice() : false;
|
final credentials = await _resolvePushCredentials();
|
||||||
final platform = Platform.isIOS ? 'ios' : (isHuawei ? 'huawei' : 'android');
|
final token = credentials.token ?? '';
|
||||||
final token = (await getToken()) ?? '';
|
if (token.isEmpty) return;
|
||||||
await sl<Dio>().post(
|
await sl<Dio>().post(
|
||||||
'/api/profile/notification/token/unregister',
|
'/api/profile/notification/token/unregister',
|
||||||
data: {
|
data: {
|
||||||
'platform': platform,
|
'platform': credentials.platform,
|
||||||
'token': token,
|
'token': token,
|
||||||
},
|
},
|
||||||
).timeout(
|
).timeout(
|
||||||
@@ -141,12 +162,13 @@ class FirebaseApi {
|
|||||||
final token = (await FlutterCallkitIncoming.getDevicePushTokenVoIP())?.trim();
|
final token = (await FlutterCallkitIncoming.getDevicePushTokenVoIP())?.trim();
|
||||||
if (token == null || token.isEmpty) return;
|
if (token == null || token.isEmpty) return;
|
||||||
|
|
||||||
final deviceInfo = DeviceInfoPlugin();
|
final hwId = await DeviceHwIdProvider.current();
|
||||||
|
if (hwId == null || hwId.length < 8) return;
|
||||||
await sl<Dio>().post(
|
await sl<Dio>().post(
|
||||||
'/api/profile/notification/token/voip/register',
|
'/api/profile/notification/token/voip/register',
|
||||||
data: {
|
data: {
|
||||||
'platform': 'ios',
|
'platform': 'ios',
|
||||||
'hw_id': (await deviceInfo.iosInfo).identifierForVendor,
|
'hw_id': hwId,
|
||||||
'token': token,
|
'token': token,
|
||||||
},
|
},
|
||||||
).timeout(
|
).timeout(
|
||||||
@@ -181,30 +203,74 @@ class FirebaseApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<({String? token, String platform})> _resolvePushCredentials() async {
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
return (
|
||||||
|
token: await _getFcmToken(),
|
||||||
|
platform: pushPlatform(isIos: true, huaweiFamily: false, hmsAvailable: false, hasHmsToken: false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final huaweiFamily = await _isHuaweiDevice();
|
||||||
|
if (huaweiFamily && await _isHmsAvailable()) {
|
||||||
|
final token = await _getHuaweiPushTokenNative().timeout(
|
||||||
|
const Duration(seconds: 5),
|
||||||
|
onTimeout: () => null,
|
||||||
|
);
|
||||||
|
if (token != null && token.isNotEmpty) {
|
||||||
|
return (
|
||||||
|
token: token,
|
||||||
|
platform: pushPlatform(isIos: false, huaweiFamily: true, hmsAvailable: true, hasHmsToken: true),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
token: await _getFcmToken(),
|
||||||
|
platform: pushPlatform(isIos: false, huaweiFamily: huaweiFamily, hmsAvailable: false, hasHmsToken: false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> _getFcmToken() {
|
||||||
|
return _firebaseMessaging.getToken().timeout(
|
||||||
|
const Duration(seconds: 5),
|
||||||
|
onTimeout: () => null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<bool> _isHuaweiDevice() async {
|
Future<bool> _isHuaweiDevice() async {
|
||||||
if (!Platform.isAndroid) return false;
|
if (!Platform.isAndroid) return false;
|
||||||
try {
|
try {
|
||||||
final info = await DeviceInfoPlugin().androidInfo;
|
final info = await DeviceInfoPlugin().androidInfo;
|
||||||
final brand = info.brand.toLowerCase();
|
return isHuaweiFamilyBrand(brand: info.brand, manufacturer: info.manufacturer);
|
||||||
final manufacturer = info.manufacturer.toLowerCase();
|
|
||||||
return brand.contains('huawei') || brand.contains('honor') || manufacturer.contains('huawei') || manufacturer.contains('honor');
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<bool> _isHmsAvailable() async {
|
||||||
|
try {
|
||||||
|
const channel = MethodChannel('com.eatvioapp/hms_push');
|
||||||
|
final available = await channel.invokeMethod<bool>('isHmsAvailable').timeout(const Duration(seconds: 5));
|
||||||
|
return available ?? false;
|
||||||
|
} catch (e, st) {
|
||||||
|
log('HMS availability check failed: $e', stackTrace: st);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<String?> _getHuaweiPushTokenNative() async {
|
Future<String?> _getHuaweiPushTokenNative() async {
|
||||||
try {
|
try {
|
||||||
const channel = MethodChannel('com.eatvioapp/hms_push');
|
const channel = MethodChannel('com.eatvioapp/hms_push');
|
||||||
final String? token = await channel.invokeMethod<String>('getHuaweiToken');
|
final String? token = await channel.invokeMethod<String>('getHuaweiToken');
|
||||||
return token;
|
return token;
|
||||||
} on PlatformException catch (e, st) {
|
} on PlatformException catch (e, st) {
|
||||||
showErrorToast('$e $st');
|
|
||||||
log(e.toString(), stackTrace: st);
|
log(e.toString(), stackTrace: st);
|
||||||
await _errorReporter.report(e, st, origin: 'FirebaseApi');
|
if (!isHmsClientUnavailable(e)) {
|
||||||
|
await _errorReporter.report(e, st, origin: 'FirebaseApi');
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
showErrorToast('$e $st');
|
|
||||||
log(e.toString(), stackTrace: st);
|
log(e.toString(), stackTrace: st);
|
||||||
await _errorReporter.report(e, st, origin: 'FirebaseApi');
|
await _errorReporter.report(e, st, origin: 'FirebaseApi');
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:eatvio_app/core/app_router.dart';
|
import 'package:eatvio_app/core/app_router.dart';
|
||||||
|
import 'package:eatvio_app/core/interceptors/verification_requirement.dart';
|
||||||
import 'package:eatvio_app/core/locator_service.dart';
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
import 'package:eatvio_app/core/routing/route_constants.dart';
|
import 'package:eatvio_app/core/routing/route_constants.dart';
|
||||||
import 'package:eatvio_app/core/widgets/show_error_dialog.dart';
|
import 'package:eatvio_app/core/widgets/show_error_dialog.dart';
|
||||||
@@ -9,28 +10,77 @@ import 'package:go_router/go_router.dart';
|
|||||||
class PhoneVerifyErrorInterceptor extends Interceptor {
|
class PhoneVerifyErrorInterceptor extends Interceptor {
|
||||||
@override
|
@override
|
||||||
void onError(DioException err, ErrorInterceptorHandler handler) async {
|
void onError(DioException err, ErrorInterceptorHandler handler) async {
|
||||||
if (err.response?.statusCode == 405) {
|
if (err.response?.statusCode != 405) {
|
||||||
final data = err.response?.data;
|
return handler.next(err);
|
||||||
|
}
|
||||||
if (data is Map<String, dynamic> && data['type'] == 'unverificated_phone') {
|
|
||||||
final String phone = await sl<ProfileRepository>().getMyPhone();
|
final requirement = VerificationRequirement.tryParse(err.response?.data);
|
||||||
final String passKey = await sl<ProfileRepository>().getPassKey(routeName: 'user_detailz.change-phone-verify', routeParams: {"phone": phone});
|
if (requirement == null) {
|
||||||
await showErrorDialog(
|
return handler.next(err);
|
||||||
AppRouter.navigatorKey.currentContext!,
|
}
|
||||||
title: 'Привяжите номер телефона',
|
|
||||||
description: 'Для того, что бы выполнить это действие необходимо привязать номер телефона',
|
// Бэкенд может требовать подтверждения почты, телефона или обоих каналов сразу.
|
||||||
);
|
if (requirement.needsEmail) {
|
||||||
final currentContext = AppRouter.navigatorKey.currentContext;
|
await _handleEmailVerificationRequired();
|
||||||
|
} else {
|
||||||
if (currentContext != null && currentContext.mounted && GoRouter.of(currentContext).state.path == RouteConstants.stepperBecomeConsultantScreen) {
|
await _handlePhoneVerificationRequired();
|
||||||
GoRouter.of(currentContext).pop();
|
}
|
||||||
}
|
|
||||||
AppRouter.navigatorKey.currentContext?.pushNamed('verifyScreen', extra: passKey);
|
return handler.reject(
|
||||||
return handler.reject(
|
DioException(
|
||||||
DioException(requestOptions: err.requestOptions, response: err.response, error: 'Phone number not verified', type: DioExceptionType.badResponse),
|
requestOptions: err.requestOptions,
|
||||||
);
|
response: err.response,
|
||||||
}
|
error: 'Phone number not verified',
|
||||||
|
type: DioExceptionType.badResponse,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Старый сценарий: показывает привязку телефона и открывает passkey-экран.
|
||||||
|
Future<void> _handlePhoneVerificationRequired() async {
|
||||||
|
final String phone = await sl<ProfileRepository>().getMyPhone();
|
||||||
|
final String passKey = await sl<ProfileRepository>().getPassKey(
|
||||||
|
routeName: 'user_detailz.change-phone-verify',
|
||||||
|
routeParams: {"phone": phone},
|
||||||
|
);
|
||||||
|
await showErrorDialog(
|
||||||
|
AppRouter.navigatorKey.currentContext!,
|
||||||
|
title: 'Привяжите номер телефона',
|
||||||
|
description:
|
||||||
|
'Для того, что бы выполнить это действие необходимо привязать номер телефона',
|
||||||
|
);
|
||||||
|
final currentContext = AppRouter.navigatorKey.currentContext;
|
||||||
|
|
||||||
|
if (currentContext != null &&
|
||||||
|
currentContext.mounted &&
|
||||||
|
GoRouter.of(currentContext).state.path ==
|
||||||
|
RouteConstants.stepperBecomeConsultantScreen) {
|
||||||
|
GoRouter.of(currentContext).pop();
|
||||||
|
}
|
||||||
|
AppRouter.navigatorKey.currentContext?.pushNamed(
|
||||||
|
'verifyScreen',
|
||||||
|
extra: passKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Новый сценарий: предлагает подтвердить e-mail и открывает экран ввода кода.
|
||||||
|
Future<void> _handleEmailVerificationRequired() async {
|
||||||
|
final context = AppRouter.navigatorKey.currentContext;
|
||||||
|
|
||||||
|
if (context == null || !context.mounted) {
|
||||||
|
// UI недоступен (например, в тестах) — просто отклоняем запрос, без падения.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await showErrorDialog(
|
||||||
|
context,
|
||||||
|
title: 'Подтвердите e-mail',
|
||||||
|
description:
|
||||||
|
'Чтобы публиковать материалы, подтвердите адрес электронной почты. Мы отправим код на вашу почту.',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (context.mounted) {
|
||||||
|
context.pushNamed('verifyEmailScreen', extra: true);
|
||||||
}
|
}
|
||||||
return handler.next(err);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
61
lib/core/interceptors/verification_requirement.dart
Normal file
61
lib/core/interceptors/verification_requirement.dart
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/// Канал верификации, которого не хватает для публичного действия.
|
||||||
|
enum VerificationChannel { phone, email }
|
||||||
|
|
||||||
|
/// Разбор 405-контракта верификации (type/reason/missing) в список каналов.
|
||||||
|
///
|
||||||
|
/// Бэкенд отвечает на публичное действие без подтверждения так:
|
||||||
|
/// `{"type": "unverificated_phone", "reason": "verification_required", "missing": ["email"]}`.
|
||||||
|
/// Старые ответы не содержали поля `missing` — тогда не хватало только телефона.
|
||||||
|
class VerificationRequirement {
|
||||||
|
const VerificationRequirement({required this.missing});
|
||||||
|
|
||||||
|
/// Каналы, подтверждение которых требует бэкенд.
|
||||||
|
final List<VerificationChannel> missing;
|
||||||
|
|
||||||
|
bool get needsEmail => missing.contains(VerificationChannel.email);
|
||||||
|
|
||||||
|
bool get needsPhone => missing.contains(VerificationChannel.phone);
|
||||||
|
|
||||||
|
/// Возвращает null, если ответ не является отказом верификации.
|
||||||
|
static VerificationRequirement? tryParse(Object? data) {
|
||||||
|
if (data is! Map) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (data['type'] != 'unverificated_phone') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final Object? rawMissing = data['missing'];
|
||||||
|
if (rawMissing is! List) {
|
||||||
|
// Legacy-контракт: поля missing не было, значит не подтверждён телефон.
|
||||||
|
return const VerificationRequirement(
|
||||||
|
missing: [VerificationChannel.phone],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasPhone = false;
|
||||||
|
var hasEmail = false;
|
||||||
|
for (final Object? value in rawMissing) {
|
||||||
|
// Неизвестные значения игнорируем, чтобы новые каналы бэкенда не ломали разбор.
|
||||||
|
if (value == 'phone') {
|
||||||
|
hasPhone = true;
|
||||||
|
} else if (value == 'email') {
|
||||||
|
hasEmail = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пустой или нераспознанный missing трактуем как legacy-контракт «телефон».
|
||||||
|
if (!hasPhone && !hasEmail) {
|
||||||
|
return const VerificationRequirement(
|
||||||
|
missing: [VerificationChannel.phone],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return VerificationRequirement(
|
||||||
|
missing: [
|
||||||
|
if (hasPhone) VerificationChannel.phone,
|
||||||
|
if (hasEmail) VerificationChannel.email,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:eatvio_app/core/app_initializer.dart';
|
import 'package:eatvio_app/core/app_initializer.dart';
|
||||||
|
import 'package:eatvio_app/core/cached_base_url.dart';
|
||||||
import 'package:eatvio_app/core/di/app_config_registration.dart';
|
import 'package:eatvio_app/core/di/app_config_registration.dart';
|
||||||
import 'package:eatvio_app/core/di/cubit_registrations.dart';
|
import 'package:eatvio_app/core/di/cubit_registrations.dart';
|
||||||
import 'package:eatvio_app/core/di/external_registrations.dart';
|
import 'package:eatvio_app/core/di/external_registrations.dart';
|
||||||
@@ -21,6 +22,8 @@ Future<void> init({required AppBuildType buildType}) async {
|
|||||||
|
|
||||||
// Ожидание инициализации асинхронных зависимостей
|
// Ожидание инициализации асинхронных зависимостей
|
||||||
await sl.getAsync<Prefs>();
|
await sl.getAsync<Prefs>();
|
||||||
|
// До создания Dio и любых запросов к бэкенду.
|
||||||
|
applyCachedBaseUrl();
|
||||||
|
|
||||||
// Регистрация сервисов
|
// Регистрация сервисов
|
||||||
ServiceRegistrations.registerAll();
|
ServiceRegistrations.registerAll();
|
||||||
@@ -32,5 +35,7 @@ Future<void> init({required AppBuildType buildType}) async {
|
|||||||
CubitRegistrations.registerAll();
|
CubitRegistrations.registerAll();
|
||||||
|
|
||||||
// Регистрация WebSocket менеджера
|
// Регистрация WebSocket менеджера
|
||||||
sl.registerLazySingleton<WsManager>(() => WsManager(wsService: sl(), errorReporter: sl()));
|
sl.registerLazySingleton<WsManager>(
|
||||||
|
() => WsManager(wsService: sl(), errorReporter: sl()),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
class NotificationPermissionRequester {
|
||||||
|
Future<void>? _inFlight;
|
||||||
|
|
||||||
|
/// Coalesces overlapping calls so the OS prompt is shown at most once at a time.
|
||||||
|
Future<void> requestOnce(Future<void> Function() requestPermission) async {
|
||||||
|
final inFlight = _inFlight;
|
||||||
|
if (inFlight != null) {
|
||||||
|
await inFlight;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final future = requestPermission();
|
||||||
|
_inFlight = future;
|
||||||
|
try {
|
||||||
|
await future;
|
||||||
|
} finally {
|
||||||
|
if (identical(_inFlight, future)) {
|
||||||
|
_inFlight = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,10 +62,18 @@ class PushNavigationHandler {
|
|||||||
final endedPayload = VideoCallEndedPushPayload.tryParse(data);
|
final endedPayload = VideoCallEndedPushPayload.tryParse(data);
|
||||||
if (endedPayload != null) {
|
if (endedPayload != null) {
|
||||||
developer.log(
|
developer.log(
|
||||||
'PushNavigationHandler: dismiss incoming call UI, source=$source',
|
'PushNavigationHandler: dismiss incoming call UI, source=$source '
|
||||||
|
'call_id=${endedPayload.callId ?? '-'} '
|
||||||
|
'reason=${endedPayload.reason?.name ?? '-'}',
|
||||||
name: 'PushNavigationHandler',
|
name: 'PushNavigationHandler',
|
||||||
);
|
);
|
||||||
unawaited(VideoCallEndedPushHandler.dismissIncomingCallUi());
|
unawaited(
|
||||||
|
VideoCallEndedPushHandler.dismissIncomingCallUi(
|
||||||
|
conversationUuid: endedPayload.conversationUuid,
|
||||||
|
callId: endedPayload.callId,
|
||||||
|
reason: endedPayload.reason,
|
||||||
|
),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,9 @@
|
|||||||
enum PrefsKeys { hasAnonymousTokenBeenSent, welcomeScreenWasShowed, registrationDate, marketingBottomSheetHasBeenShown, isEmailVerificationSkipped }
|
enum PrefsKeys {
|
||||||
|
hasAnonymousTokenBeenSent,
|
||||||
|
welcomeScreenWasShowed,
|
||||||
|
registrationDate,
|
||||||
|
marketingBottomSheetHasBeenShown,
|
||||||
|
isEmailVerificationSkipped,
|
||||||
|
customBaseUrl,
|
||||||
|
apiBaseUrl,
|
||||||
|
}
|
||||||
|
|||||||
36
lib/core/push/hms_client_error.dart
Normal file
36
lib/core/push/hms_client_error.dart
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
bool isHuaweiFamilyBrand({
|
||||||
|
required String brand,
|
||||||
|
required String manufacturer,
|
||||||
|
}) {
|
||||||
|
final normalizedBrand = brand.toLowerCase();
|
||||||
|
final normalizedManufacturer = manufacturer.toLowerCase();
|
||||||
|
return normalizedBrand.contains('huawei') ||
|
||||||
|
normalizedBrand.contains('honor') ||
|
||||||
|
normalizedManufacturer.contains('huawei') ||
|
||||||
|
normalizedManufacturer.contains('honor');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HMS Core is missing or rejects this app.
|
||||||
|
///
|
||||||
|
/// Honor devices with Google services return `907135003: client api invalid`.
|
||||||
|
/// That is a device capability, not an app crash: the caller should use FCM.
|
||||||
|
bool isHmsClientUnavailable(Object error) {
|
||||||
|
if (error is! PlatformException) return false;
|
||||||
|
final message = '${error.message ?? ''} ${error.details ?? ''}'.toLowerCase();
|
||||||
|
return message.contains('907135003') ||
|
||||||
|
message.contains('client api invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `huawei` only when an HMS token was actually issued. Otherwise Android uses FCM.
|
||||||
|
String pushPlatform({
|
||||||
|
required bool isIos,
|
||||||
|
required bool huaweiFamily,
|
||||||
|
required bool hmsAvailable,
|
||||||
|
required bool hasHmsToken,
|
||||||
|
}) {
|
||||||
|
if (isIos) return 'ios';
|
||||||
|
if (huaweiFamily && hmsAvailable && hasHmsToken) return 'huawei';
|
||||||
|
return 'android';
|
||||||
|
}
|
||||||
@@ -34,7 +34,7 @@ class AuthRoutes {
|
|||||||
name: 'verifyScreen',
|
name: 'verifyScreen',
|
||||||
builder: (context, state) => VerifyScreen(passKey: state.extra as String),
|
builder: (context, state) => VerifyScreen(passKey: state.extra as String),
|
||||||
),
|
),
|
||||||
GoRoute(path: RouteConstants.verifyEmailScreen, name: 'verifyEmailScreen', builder: (context, state) => const VerifyEmailScreen()),
|
GoRoute(path: RouteConstants.verifyEmailScreen, name: 'verifyEmailScreen', builder: (context, state) => VerifyEmailScreen(returnOnSuccess: state.extra == true)),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: RouteConstants.registerViaSocialScreen,
|
path: RouteConstants.registerViaSocialScreen,
|
||||||
name: 'registerViaSocialScreen',
|
name: 'registerViaSocialScreen',
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import 'package:eatvio_app/features/create_diet/presentation/diet_preview_screen
|
|||||||
import 'package:eatvio_app/features/create_plan/presentation/create_plan_screen.dart';
|
import 'package:eatvio_app/features/create_plan/presentation/create_plan_screen.dart';
|
||||||
import 'package:eatvio_app/features/create_recipe/presentation/pages/recipe_create_screen.dart';
|
import 'package:eatvio_app/features/create_recipe/presentation/pages/recipe_create_screen.dart';
|
||||||
import 'package:eatvio_app/features/dev_tools/presentation/dev_tools_screen.dart';
|
import 'package:eatvio_app/features/dev_tools/presentation/dev_tools_screen.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||||
import 'package:eatvio_app/features/feed/models/recipes_model.dart';
|
import 'package:eatvio_app/features/feed/models/recipes_model.dart';
|
||||||
import 'package:eatvio_app/features/glossary/presentation/pages/glossary_screen.dart';
|
import 'package:eatvio_app/features/glossary/presentation/pages/glossary_screen.dart';
|
||||||
import 'package:eatvio_app/features/menu/presentation/pages/blocked_users_screen.dart';
|
import 'package:eatvio_app/features/menu/presentation/pages/blocked_users_screen.dart';
|
||||||
@@ -183,7 +184,14 @@ class OtherRoutes {
|
|||||||
return DietPreviewScreen(diet: diet, isImageInBase64: isImageInBase64);
|
return DietPreviewScreen(diet: diet, isImageInBase64: isImageInBase64);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
GoRoute(path: RouteConstants.createPlanScreen, name: 'createPlanScreen', builder: (context, state) => const CreatePlanScreen()),
|
GoRoute(
|
||||||
|
path: RouteConstants.createPlanScreen,
|
||||||
|
name: 'createPlanScreen',
|
||||||
|
builder: (context, state) {
|
||||||
|
final plan = state.extra is PlanModel ? state.extra as PlanModel : null;
|
||||||
|
return CreatePlanScreen(plan: plan);
|
||||||
|
},
|
||||||
|
),
|
||||||
GoRoute(path: MyDietsScreen.path, name: MyDietsScreen.name, builder: (context, state) => const MyDietsScreen()),
|
GoRoute(path: MyDietsScreen.path, name: MyDietsScreen.name, builder: (context, state) => const MyDietsScreen()),
|
||||||
GoRoute(path: MyPlansScreen.path, name: MyPlansScreen.name, builder: (context, state) => const MyPlansScreen()),
|
GoRoute(path: MyPlansScreen.path, name: MyPlansScreen.name, builder: (context, state) => const MyPlansScreen()),
|
||||||
GoRoute(path: MyRecipesScreen.path, name: MyRecipesScreen.name, builder: (context, state) => const MyRecipesScreen()),
|
GoRoute(path: MyRecipesScreen.path, name: MyRecipesScreen.name, builder: (context, state) => const MyRecipesScreen()),
|
||||||
|
|||||||
61
lib/core/utils/inline_svg_css.dart
Normal file
61
lib/core/utils/inline_svg_css.dart
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/// flutter_svg / vector_graphics ignore `<style>` blocks.
|
||||||
|
/// Paths then get the SVG default fill (black), so badges look like a black blob.
|
||||||
|
/// This inlines class rules from `<style>` as attributes before parsing.
|
||||||
|
String inlineSvgCss(String svg) {
|
||||||
|
final styleBlockPattern = RegExp(
|
||||||
|
r'<style[^>]*>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?</style>',
|
||||||
|
caseSensitive: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
final classProps = <String, Map<String, String>>{};
|
||||||
|
for (final match in styleBlockPattern.allMatches(svg)) {
|
||||||
|
final css = match.group(1) ?? '';
|
||||||
|
final rulePattern = RegExp(r'\.([A-Za-z0-9_-]+)\s*\{([^}]*)\}');
|
||||||
|
for (final rule in rulePattern.allMatches(css)) {
|
||||||
|
final className = rule.group(1)!;
|
||||||
|
final body = rule.group(2)!;
|
||||||
|
final props = classProps.putIfAbsent(className, () => <String, String>{});
|
||||||
|
for (final declaration in body.split(';')) {
|
||||||
|
final colon = declaration.indexOf(':');
|
||||||
|
if (colon <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final key = declaration.substring(0, colon).trim();
|
||||||
|
final value = declaration.substring(colon + 1).trim();
|
||||||
|
if (key.isNotEmpty && value.isNotEmpty) {
|
||||||
|
props[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = svg.replaceAll(styleBlockPattern, '');
|
||||||
|
if (classProps.isEmpty) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
result = result.replaceAllMapped(RegExp(r'''\sclass=(["'])([^"']+)\1'''), (
|
||||||
|
Match match,
|
||||||
|
) {
|
||||||
|
final classes = match
|
||||||
|
.group(2)!
|
||||||
|
.split(RegExp(r'\s+'))
|
||||||
|
.where((name) => name.isNotEmpty);
|
||||||
|
final attrs = <String, String>{};
|
||||||
|
for (final className in classes) {
|
||||||
|
final props = classProps[className];
|
||||||
|
if (props != null) {
|
||||||
|
attrs.addAll(props);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (attrs.isEmpty) {
|
||||||
|
return match.group(0)!;
|
||||||
|
}
|
||||||
|
final attrString = attrs.entries
|
||||||
|
.map((entry) => '${entry.key}="${entry.value}"')
|
||||||
|
.join(' ');
|
||||||
|
return ' $attrString';
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
11
lib/core/utils/url_utils.dart
Normal file
11
lib/core/utils/url_utils.dart
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/// Joins an API media path with [baseUrl].
|
||||||
|
///
|
||||||
|
/// Uploaded photos come back as relative paths (`/storage/...`).
|
||||||
|
/// Default avatars are already absolute (`https://ui-avatars...`) and must stay as-is.
|
||||||
|
String joinBaseUrl(String baseUrl, String path) {
|
||||||
|
final normalized = path.toLowerCase();
|
||||||
|
if (normalized.startsWith('https://') || normalized.startsWith('http://')) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
return '$baseUrl$path';
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ class WsManager {
|
|||||||
final ErrorReporter _errorReporter;
|
final ErrorReporter _errorReporter;
|
||||||
|
|
||||||
WsManager({required this.wsService, required ErrorReporter errorReporter})
|
WsManager({required this.wsService, required ErrorReporter errorReporter})
|
||||||
: _errorReporter = errorReporter;
|
: _errorReporter = errorReporter;
|
||||||
|
|
||||||
PusherChannelsClient? client;
|
PusherChannelsClient? client;
|
||||||
int? userId;
|
int? userId;
|
||||||
@@ -38,15 +38,53 @@ class WsManager {
|
|||||||
StreamSubscription? _eventSubscription;
|
StreamSubscription? _eventSubscription;
|
||||||
StreamSubscription? _connectionEstablishedSubscription;
|
StreamSubscription? _connectionEstablishedSubscription;
|
||||||
|
|
||||||
Stream<ChannelReadEvent>? get newMessagesStream => _privateMessagesChannel?.bind('App\\Events\\ChatMessageUpdated');
|
/// Широковещательный сигнал о восстановлении соединения после обрыва (S5).
|
||||||
|
///
|
||||||
|
/// Контроллер создаётся один раз и не закрывается в [clear]: подписчики
|
||||||
|
/// (чаты, окно чата) переживают логаут/повторный вход и продолжают получать
|
||||||
|
/// сигналы. Если контроллер всё же закрыт, он пересоздаётся в [initialize].
|
||||||
|
StreamController<void> _reconnectController =
|
||||||
|
StreamController<void>.broadcast();
|
||||||
|
|
||||||
Stream<ChannelReadEvent>? get newNotificationsStream => _privateNotificationChannel?.bind('Illuminate\\Notifications\\Events\\BroadcastNotificationCreated');
|
Stream<ChannelReadEvent>? _directChatStream;
|
||||||
|
Stream<ChannelReadEvent>? _clientTypingStream;
|
||||||
|
|
||||||
Stream<ChannelReadEvent>? get directChatStream => _privateDirectChatChannel?.bind('App\\Events\\ChatMessageUpdated');
|
Stream<ChannelReadEvent>? get newMessagesStream =>
|
||||||
|
_privateMessagesChannel?.bind('App\\Events\\ChatMessageUpdated');
|
||||||
|
|
||||||
Stream<ChannelReadEvent>? get clientTypingStream => _privateDirectChatChannel?.bind('client-typing');
|
/// The callee rejected the ring. Broadcast to the caller and the callee.
|
||||||
|
Stream<ChannelReadEvent>? get videoCallDeclinedStream =>
|
||||||
|
_privateMessagesChannel?.bind('video-call-declined');
|
||||||
|
|
||||||
|
/// The call was cancelled or hung up. Same channel the web client uses.
|
||||||
|
Stream<ChannelReadEvent>? get videoCallEndedStream =>
|
||||||
|
_privateMessagesChannel?.bind('video-call-ended');
|
||||||
|
|
||||||
|
/// Another device accepted the ring. Ringing on this device must stop,
|
||||||
|
/// except on the device that accepted.
|
||||||
|
Stream<ChannelReadEvent>? get videoCallAcceptedStream =>
|
||||||
|
_privateMessagesChannel?.bind('video-call-accepted');
|
||||||
|
|
||||||
|
Stream<ChannelReadEvent>? get newNotificationsStream =>
|
||||||
|
_privateNotificationChannel?.bind(
|
||||||
|
'Illuminate\\Notifications\\Events\\BroadcastNotificationCreated',
|
||||||
|
);
|
||||||
|
|
||||||
|
Stream<ChannelReadEvent>? get directChatStream => _directChatStream;
|
||||||
|
|
||||||
|
Stream<ChannelReadEvent>? get clientTypingStream => _clientTypingStream;
|
||||||
|
|
||||||
|
/// Сигнал о реконнекте сокета: эмитится после повторной подписки на каналы.
|
||||||
|
///
|
||||||
|
/// Подписчики используют его, чтобы догрузить пропущенные за время обрыва
|
||||||
|
/// сообщения (S5).
|
||||||
|
Stream<void> get reconnected => _reconnectController.stream;
|
||||||
|
|
||||||
Future<void> initialize() async {
|
Future<void> initialize() async {
|
||||||
|
if (_reconnectController.isClosed) {
|
||||||
|
_reconnectController = StreamController<void>.broadcast();
|
||||||
|
}
|
||||||
|
|
||||||
if (client == null) {
|
if (client == null) {
|
||||||
try {
|
try {
|
||||||
if (!await TokenStorage.haveTokenInStorage()) return;
|
if (!await TokenStorage.haveTokenInStorage()) return;
|
||||||
@@ -66,15 +104,9 @@ class WsManager {
|
|||||||
connectionErrorHandler: (exception, trace, refresh) {
|
connectionErrorHandler: (exception, trace, refresh) {
|
||||||
refresh();
|
refresh();
|
||||||
},
|
},
|
||||||
minimumReconnectDelayDuration: const Duration(
|
minimumReconnectDelayDuration: const Duration(seconds: 1),
|
||||||
seconds: 1,
|
defaultActivityDuration: const Duration(seconds: 30),
|
||||||
),
|
waitForPongDuration: const Duration(seconds: 30),
|
||||||
defaultActivityDuration: const Duration(
|
|
||||||
seconds: 30,
|
|
||||||
),
|
|
||||||
waitForPongDuration: const Duration(
|
|
||||||
seconds: 30,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
String? extractSocketId(String? data) {
|
String? extractSocketId(String? data) {
|
||||||
@@ -87,7 +119,9 @@ class WsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_connectionEstablishedSubscription = client?.eventStream.listen((event) async {
|
_connectionEstablishedSubscription = client?.eventStream.listen((
|
||||||
|
event,
|
||||||
|
) async {
|
||||||
if (event.name == 'pusher:connection_established') {
|
if (event.name == 'pusher:connection_established') {
|
||||||
final data = event.data;
|
final data = event.data;
|
||||||
final socketId = extractSocketId(data);
|
final socketId = extractSocketId(data);
|
||||||
@@ -104,9 +138,9 @@ class WsManager {
|
|||||||
await _initPrivateMessagesChannel();
|
await _initPrivateMessagesChannel();
|
||||||
|
|
||||||
_lifecycleSubscription = client?.lifecycleStream.listen((data) {
|
_lifecycleSubscription = client?.lifecycleStream.listen((data) {
|
||||||
if (data == PusherChannelsClientLifeCycleState.establishedConnection) {
|
if (data ==
|
||||||
_unsubscribe();
|
PusherChannelsClientLifeCycleState.establishedConnection) {
|
||||||
_subscribe();
|
handleConnectionEstablished();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
_eventSubscription = client?.eventStream.listen((event) {
|
_eventSubscription = client?.eventStream.listen((event) {
|
||||||
@@ -147,12 +181,13 @@ class WsManager {
|
|||||||
final token = await TokenStorage.getTokenFromStorage();
|
final token = await TokenStorage.getTokenFromStorage();
|
||||||
_privateNotificationChannel = client?.privateChannel(
|
_privateNotificationChannel = client?.privateChannel(
|
||||||
'$newNotificationsChannelName.$userId',
|
'$newNotificationsChannelName.$userId',
|
||||||
authorizationDelegate: EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel(
|
authorizationDelegate:
|
||||||
authorizationEndpoint: Uri.parse('${sl<AppConfig>().baseUrl}/broadcasting/auth'),
|
EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel(
|
||||||
headers: {
|
authorizationEndpoint: Uri.parse(
|
||||||
'Authorization': 'Bearer $token',
|
'${sl<AppConfig>().baseUrl}/broadcasting/auth',
|
||||||
},
|
),
|
||||||
),
|
headers: {'Authorization': 'Bearer $token'},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,38 +195,46 @@ class WsManager {
|
|||||||
final token = await TokenStorage.getTokenFromStorage();
|
final token = await TokenStorage.getTokenFromStorage();
|
||||||
_privateMessagesChannel = client?.privateChannel(
|
_privateMessagesChannel = client?.privateChannel(
|
||||||
'$newMessagesChannelName.$userId',
|
'$newMessagesChannelName.$userId',
|
||||||
authorizationDelegate: EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel(
|
authorizationDelegate:
|
||||||
authorizationEndpoint: Uri.parse('${sl<AppConfig>().baseUrl}/broadcasting/auth'),
|
EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel(
|
||||||
headers: {
|
authorizationEndpoint: Uri.parse(
|
||||||
'Authorization': 'Bearer $token',
|
'${sl<AppConfig>().baseUrl}/broadcasting/auth',
|
||||||
},
|
),
|
||||||
),
|
headers: {'Authorization': 'Bearer $token'},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> initPrivateDirectChatChannel(String chatId) async {
|
Future<void> initPrivateDirectChatChannel(String chatId) async {
|
||||||
|
_privateDirectChatChannel?.unsubscribe();
|
||||||
final token = await TokenStorage.getTokenFromStorage();
|
final token = await TokenStorage.getTokenFromStorage();
|
||||||
_privateDirectChatChannel = client?.privateChannel(
|
_privateDirectChatChannel = client?.privateChannel(
|
||||||
'$directChatChannelName.$chatId',
|
'$directChatChannelName.$chatId',
|
||||||
authorizationDelegate: EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel(
|
authorizationDelegate:
|
||||||
authorizationEndpoint: Uri.parse('${sl<AppConfig>().baseUrl}/broadcasting/auth'),
|
EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel(
|
||||||
headers: {
|
authorizationEndpoint: Uri.parse(
|
||||||
'Authorization': 'Bearer $token',
|
'${sl<AppConfig>().baseUrl}/broadcasting/auth',
|
||||||
},
|
),
|
||||||
),
|
headers: {'Authorization': 'Bearer $token'},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
_directChatStream = _privateDirectChatChannel?.bind(
|
||||||
|
'App\\Events\\ChatMessageUpdated',
|
||||||
|
);
|
||||||
|
_clientTypingStream = _privateDirectChatChannel?.bind('client-typing');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> initPresenceDirectChatChannel(String chatId) async {
|
Future<void> initPresenceDirectChatChannel(String chatId) async {
|
||||||
final token = await TokenStorage.getTokenFromStorage();
|
final token = await TokenStorage.getTokenFromStorage();
|
||||||
_presenceDirectChatChannel = client?.presenceChannel(
|
_presenceDirectChatChannel = client?.presenceChannel(
|
||||||
'$presenceDirectChatChannelName.$chatId',
|
'$presenceDirectChatChannelName.$chatId',
|
||||||
authorizationDelegate: EndpointAuthorizableChannelTokenAuthorizationDelegate.forPresenceChannel(
|
authorizationDelegate:
|
||||||
authorizationEndpoint: Uri.parse('${sl<AppConfig>().baseUrl}/broadcasting/auth'),
|
EndpointAuthorizableChannelTokenAuthorizationDelegate.forPresenceChannel(
|
||||||
headers: {
|
authorizationEndpoint: Uri.parse(
|
||||||
'Authorization': 'Bearer $token',
|
'${sl<AppConfig>().baseUrl}/broadcasting/auth',
|
||||||
},
|
),
|
||||||
),
|
headers: {'Authorization': 'Bearer $token'},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,7 +261,8 @@ class WsManager {
|
|||||||
void Function(bool isOnline) onCounterpartOnlineChanged,
|
void Function(bool isOnline) onCounterpartOnlineChanged,
|
||||||
) {
|
) {
|
||||||
_presenceDirectChatListeners.removeWhere(
|
_presenceDirectChatListeners.removeWhere(
|
||||||
(listener) => listener.onCounterpartOnlineChanged == onCounterpartOnlineChanged,
|
(listener) =>
|
||||||
|
listener.onCounterpartOnlineChanged == onCounterpartOnlineChanged,
|
||||||
);
|
);
|
||||||
if (_presenceDirectChatListeners.isEmpty) {
|
if (_presenceDirectChatListeners.isEmpty) {
|
||||||
unsubscribePresenceDirectChatChannel();
|
unsubscribePresenceDirectChatChannel();
|
||||||
@@ -232,21 +276,26 @@ class WsManager {
|
|||||||
|
|
||||||
_presenceChannelEventsBound = true;
|
_presenceChannelEventsBound = true;
|
||||||
|
|
||||||
_presenceSubscriptionSucceededSubscription =
|
_presenceSubscriptionSucceededSubscription = channel
|
||||||
channel.whenSubscriptionSucceeded().listen((_) {
|
.whenSubscriptionSucceeded()
|
||||||
final counterpartIds = _presenceDirectChatListeners
|
.listen((_) {
|
||||||
.map((listener) => listener.counterpartUserId)
|
final counterpartIds = _presenceDirectChatListeners
|
||||||
.toSet();
|
.map((listener) => listener.counterpartUserId)
|
||||||
for (final counterpartUserId in counterpartIds) {
|
.toSet();
|
||||||
_emitCounterpartPresence(counterpartUserId);
|
for (final counterpartUserId in counterpartIds) {
|
||||||
}
|
_emitCounterpartPresence(counterpartUserId);
|
||||||
});
|
}
|
||||||
_presenceMemberAddedSubscription = channel.whenMemberAdded().listen((event) {
|
});
|
||||||
|
_presenceMemberAddedSubscription = channel.whenMemberAdded().listen((
|
||||||
|
event,
|
||||||
|
) {
|
||||||
final memberUserId = _parsePresenceMemberUserId(event.data);
|
final memberUserId = _parsePresenceMemberUserId(event.data);
|
||||||
if (memberUserId == null) return;
|
if (memberUserId == null) return;
|
||||||
_notifyPresenceListeners(memberUserId, isOnline: true);
|
_notifyPresenceListeners(memberUserId, isOnline: true);
|
||||||
});
|
});
|
||||||
_presenceMemberRemovedSubscription = channel.whenMemberRemoved().listen((event) {
|
_presenceMemberRemovedSubscription = channel.whenMemberRemoved().listen((
|
||||||
|
event,
|
||||||
|
) {
|
||||||
final memberUserId = _parsePresenceMemberUserId(event.data);
|
final memberUserId = _parsePresenceMemberUserId(event.data);
|
||||||
if (memberUserId == null) return;
|
if (memberUserId == null) return;
|
||||||
_notifyPresenceListeners(memberUserId, isOnline: false);
|
_notifyPresenceListeners(memberUserId, isOnline: false);
|
||||||
@@ -258,11 +307,15 @@ class WsManager {
|
|||||||
if (channel == null) return;
|
if (channel == null) return;
|
||||||
final members = channel.state?.members;
|
final members = channel.state?.members;
|
||||||
if (members == null) return;
|
if (members == null) return;
|
||||||
final isOnline = members.getMemberInfo(counterpartUserId.toString()) != null;
|
final isOnline =
|
||||||
|
members.getMemberInfo(counterpartUserId.toString()) != null;
|
||||||
_notifyPresenceListeners(counterpartUserId, isOnline: isOnline);
|
_notifyPresenceListeners(counterpartUserId, isOnline: isOnline);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _notifyPresenceListeners(int counterpartUserId, {required bool isOnline}) {
|
void _notifyPresenceListeners(
|
||||||
|
int counterpartUserId, {
|
||||||
|
required bool isOnline,
|
||||||
|
}) {
|
||||||
for (final listener in _presenceDirectChatListeners) {
|
for (final listener in _presenceDirectChatListeners) {
|
||||||
if (listener.counterpartUserId == counterpartUserId) {
|
if (listener.counterpartUserId == counterpartUserId) {
|
||||||
listener.onCounterpartOnlineChanged(isOnline);
|
listener.onCounterpartOnlineChanged(isOnline);
|
||||||
@@ -293,12 +346,27 @@ class WsManager {
|
|||||||
_privateNotificationChannel?.unsubscribe();
|
_privateNotificationChannel?.unsubscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Повторно подписывается на каналы и сигналит подписчикам о реконнекте.
|
||||||
|
///
|
||||||
|
/// Вызывается при `establishedConnection` — в том числе после обрыва сети.
|
||||||
|
/// Публичный, чтобы сигнал [reconnected] покрывался тестами без реального
|
||||||
|
/// Pusher-клиента.
|
||||||
|
void handleConnectionEstablished() {
|
||||||
|
_unsubscribe();
|
||||||
|
_subscribe();
|
||||||
|
_privateDirectChatChannel?.subscribe();
|
||||||
|
_presenceDirectChatChannel?.subscribe();
|
||||||
|
_reconnectController.add(null);
|
||||||
|
}
|
||||||
|
|
||||||
void subscribeDirectChatChannel() {
|
void subscribeDirectChatChannel() {
|
||||||
_privateDirectChatChannel?.subscribe();
|
_privateDirectChatChannel?.subscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
void unsubscribeDirectChatChannel() {
|
void unsubscribeDirectChatChannel() {
|
||||||
_privateDirectChatChannel?.unsubscribe();
|
_privateDirectChatChannel?.unsubscribe();
|
||||||
|
_directChatStream = null;
|
||||||
|
_clientTypingStream = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
void unsubscribePresenceDirectChatChannel() {
|
void unsubscribePresenceDirectChatChannel() {
|
||||||
@@ -331,6 +399,8 @@ class WsManager {
|
|||||||
_privateDirectChatChannel = null;
|
_privateDirectChatChannel = null;
|
||||||
_privateMessagesChannel = null;
|
_privateMessagesChannel = null;
|
||||||
_privateNotificationChannel = null;
|
_privateNotificationChannel = null;
|
||||||
|
_directChatStream = null;
|
||||||
|
_clientTypingStream = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,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',
|
||||||
'Заблокировать автора',
|
'Заблокировать автора',
|
||||||
|
|||||||
57
lib/core/widgets/network_svg_picture.dart
Normal file
57
lib/core/widgets/network_svg_picture.dart
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import 'package:eatvio_app/core/utils/inline_svg_css.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
final Map<String, String> _inlinedSvgCache = <String, String>{};
|
||||||
|
|
||||||
|
class NetworkSvgPicture extends StatefulWidget {
|
||||||
|
const NetworkSvgPicture({
|
||||||
|
super.key,
|
||||||
|
required this.url,
|
||||||
|
this.width,
|
||||||
|
this.height,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String url;
|
||||||
|
final double? width;
|
||||||
|
final double? height;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<NetworkSvgPicture> createState() => _NetworkSvgPictureState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NetworkSvgPictureState extends State<NetworkSvgPicture> {
|
||||||
|
late final Future<String> _svgFuture = _load(widget.url);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return FutureBuilder<String>(
|
||||||
|
future: _svgFuture,
|
||||||
|
builder: (BuildContext context, AsyncSnapshot<String> snapshot) {
|
||||||
|
if (!snapshot.hasData) {
|
||||||
|
return SizedBox(width: widget.width, height: widget.height);
|
||||||
|
}
|
||||||
|
return SvgPicture.string(
|
||||||
|
snapshot.data!,
|
||||||
|
width: widget.width,
|
||||||
|
height: widget.height,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<String> _load(String url) async {
|
||||||
|
final cached = _inlinedSvgCache[url];
|
||||||
|
if (cached != null) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
final response = await http.get(Uri.parse(url));
|
||||||
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
|
throw Exception('Failed to load SVG from $url');
|
||||||
|
}
|
||||||
|
final processed = inlineSvgCss(response.body);
|
||||||
|
_inlinedSvgCache[url] = processed;
|
||||||
|
return processed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ class SuccessPublishedDietOrPlanBottomSheet extends StatelessWidget {
|
|||||||
if (isDrafted == true) {
|
if (isDrafted == true) {
|
||||||
text = 'cохранена в черновиках';
|
text = 'cохранена в черновиках';
|
||||||
} else if (isUpdated == true) {
|
} else if (isUpdated == true) {
|
||||||
text = 'успешно обновлена';
|
text = isPlan ? 'успешно обновлён' : 'успешно обновлена';
|
||||||
} else if (isPlan == true) {
|
} else if (isPlan == true) {
|
||||||
text = 'опубликован';
|
text = 'опубликован';
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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 &&
|
||||||
|
|||||||
@@ -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),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:eatvio_app/core/app_config.dart';
|
|||||||
import 'package:eatvio_app/core/env_config.dart';
|
import 'package:eatvio_app/core/env_config.dart';
|
||||||
import 'package:eatvio_app/core/locator_service.dart';
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
import 'package:eatvio_app/core/utils/image_constant.dart';
|
import 'package:eatvio_app/core/utils/image_constant.dart';
|
||||||
|
import 'package:eatvio_app/core/utils/url_utils.dart';
|
||||||
import 'package:eatvio_app/features/dev_tools/presentation/dev_tools_screen.dart';
|
import 'package:eatvio_app/features/dev_tools/presentation/dev_tools_screen.dart';
|
||||||
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
|
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
|
||||||
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_state.dart';
|
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_state.dart';
|
||||||
@@ -26,11 +27,13 @@ class BottomNavigationBarWidget extends StatefulWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
final Widget child;
|
final Widget child;
|
||||||
|
|
||||||
/// Текущий путь из GoRouter (matchedLocation), чтобы подсветка таба совпадала с экраном при навигации через go() (диплинки, пуши).
|
/// Текущий путь из GoRouter (matchedLocation), чтобы подсветка таба совпадала с экраном при навигации через go() (диплинки, пуши).
|
||||||
final String currentLocation;
|
final String currentLocation;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<BottomNavigationBarWidget> createState() => _BottomNavigationBarWidgetState();
|
State<BottomNavigationBarWidget> createState() =>
|
||||||
|
_BottomNavigationBarWidgetState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||||
@@ -40,9 +43,24 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
|||||||
|
|
||||||
List<String> _getPages(bool isConsultant) {
|
List<String> _getPages(bool isConsultant) {
|
||||||
if (isConsultant) {
|
if (isConsultant) {
|
||||||
return ['/my-profile-screen', '/feed-screen', '/diary-screen', '/consultations-screen', '/chat-select-screen', '/notifications-screen', '/menu-screen'];
|
return [
|
||||||
|
'/my-profile-screen',
|
||||||
|
'/feed-screen',
|
||||||
|
'/diary-screen',
|
||||||
|
'/consultations-screen',
|
||||||
|
'/chat-select-screen',
|
||||||
|
'/notifications-screen',
|
||||||
|
'/menu-screen',
|
||||||
|
];
|
||||||
} else {
|
} else {
|
||||||
return ['/my-profile-screen', '/feed-screen', '/diary-screen', '/chat-select-screen', '/notifications-screen', '/menu-screen'];
|
return [
|
||||||
|
'/my-profile-screen',
|
||||||
|
'/feed-screen',
|
||||||
|
'/diary-screen',
|
||||||
|
'/chat-select-screen',
|
||||||
|
'/notifications-screen',
|
||||||
|
'/menu-screen',
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +116,9 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
|||||||
final logo = SvgPicture.asset(
|
final logo = SvgPicture.asset(
|
||||||
ImageConstant.imgLogo,
|
ImageConstant.imgLogo,
|
||||||
height: 32,
|
height: 32,
|
||||||
colorFilter: isProduction ? null : const ColorFilter.mode(_devAccentColor, BlendMode.srcIn),
|
colorFilter: isProduction
|
||||||
|
? null
|
||||||
|
: const ColorFilter.mode(_devAccentColor, BlendMode.srcIn),
|
||||||
);
|
);
|
||||||
if (isProduction) return logo;
|
if (isProduction) return logo;
|
||||||
return Stack(
|
return Stack(
|
||||||
@@ -109,7 +129,10 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
border: Border.all(color: _devAccentColor.withValues(alpha: 0.6), width: 1.5),
|
border: Border.all(
|
||||||
|
color: _devAccentColor.withValues(alpha: 0.6),
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
color: _devAccentColor.withValues(alpha: 0.08),
|
color: _devAccentColor.withValues(alpha: 0.08),
|
||||||
),
|
),
|
||||||
child: logo,
|
child: logo,
|
||||||
@@ -122,11 +145,23 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _devAccentColor,
|
color: _devAccentColor,
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
boxShadow: [BoxShadow(color: _devAccentColor.withValues(alpha: 0.4), blurRadius: 4, offset: const Offset(0, 1))],
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: _devAccentColor.withValues(alpha: 0.4),
|
||||||
|
blurRadius: 4,
|
||||||
|
offset: const Offset(0, 1),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
child: const Text(
|
child: const Text(
|
||||||
'DEV',
|
'DEV',
|
||||||
style: TextStyle(fontSize: 8, fontWeight: FontWeight.w700, color: Colors.white, letterSpacing: 0.3, height: 1.0),
|
style: TextStyle(
|
||||||
|
fontSize: 8,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Colors.white,
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
height: 1.0,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -155,10 +190,15 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
|||||||
// Подсветка таба по текущему маршруту (совпадает при навигации через go(), в т.ч. диплинки/пуши).
|
// Подсветка таба по текущему маршруту (совпадает при навигации через go(), в т.ч. диплинки/пуши).
|
||||||
final pathWithoutQuery = widget.currentLocation.split('?').first;
|
final pathWithoutQuery = widget.currentLocation.split('?').first;
|
||||||
final indexFromRoute = pages!.indexOf(pathWithoutQuery);
|
final indexFromRoute = pages!.indexOf(pathWithoutQuery);
|
||||||
final effectiveIndex = indexFromRoute >= 0 ? indexFromRoute : currentBottomNavBarIndex;
|
final effectiveIndex = indexFromRoute >= 0
|
||||||
if (indexFromRoute >= 0 && indexFromRoute != currentBottomNavBarIndex) {
|
? indexFromRoute
|
||||||
|
: currentBottomNavBarIndex;
|
||||||
|
if (indexFromRoute >= 0 &&
|
||||||
|
indexFromRoute != currentBottomNavBarIndex) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (mounted) setState(() => currentBottomNavBarIndex = indexFromRoute);
|
if (mounted) {
|
||||||
|
setState(() => currentBottomNavBarIndex = indexFromRoute);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,9 +216,14 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
|||||||
label: '',
|
label: '',
|
||||||
icon: CircleAvatar(
|
icon: CircleAvatar(
|
||||||
foregroundImage: NetworkImage(
|
foregroundImage: NetworkImage(
|
||||||
profileModel.profilePhotoUrl.contains('https') ? profileModel.profilePhotoUrl : sl<AppConfig>().baseUrl + profileModel.profilePhotoUrl,
|
joinBaseUrl(
|
||||||
|
sl<AppConfig>().baseUrl,
|
||||||
|
profileModel.profilePhotoUrl,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
backgroundImage: const AssetImage(
|
||||||
|
'assets/images/no_avatar.png',
|
||||||
),
|
),
|
||||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
|
||||||
radius: 16,
|
radius: 16,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -186,22 +231,54 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
|||||||
label: 'Лента',
|
label: 'Лента',
|
||||||
icon: Padding(
|
icon: Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 2),
|
padding: const EdgeInsets.only(bottom: 2),
|
||||||
child: SvgPicture.asset(colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn), width: 20, height: 20, ImageConstant.icnFeed),
|
child: SvgPicture.asset(
|
||||||
|
colorFilter: const ColorFilter.mode(
|
||||||
|
Colors.grey,
|
||||||
|
BlendMode.srcIn,
|
||||||
|
),
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
ImageConstant.icnFeed,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
activeIcon: Padding(
|
activeIcon: Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 2),
|
padding: const EdgeInsets.only(bottom: 2),
|
||||||
child: SvgPicture.asset(ImageConstant.icnFeed, width: 20, height: 20, colorFilter: ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn)),
|
child: SvgPicture.asset(
|
||||||
|
ImageConstant.icnFeed,
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
colorFilter: ColorFilter.mode(
|
||||||
|
appTheme.blueA400,
|
||||||
|
BlendMode.srcIn,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
BottomNavigationBarItem(
|
BottomNavigationBarItem(
|
||||||
label: s.diary,
|
label: s.diary,
|
||||||
icon: Padding(
|
icon: Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 2),
|
padding: const EdgeInsets.only(bottom: 2),
|
||||||
child: SvgPicture.asset(colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn), ImageConstant.icnDiary, width: 20, height: 20),
|
child: SvgPicture.asset(
|
||||||
|
colorFilter: const ColorFilter.mode(
|
||||||
|
Colors.grey,
|
||||||
|
BlendMode.srcIn,
|
||||||
|
),
|
||||||
|
ImageConstant.icnDiary,
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
activeIcon: Padding(
|
activeIcon: Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 2),
|
padding: const EdgeInsets.only(bottom: 2),
|
||||||
child: SvgPicture.asset(ImageConstant.icnDiary, width: 20, height: 20, colorFilter: ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn)),
|
child: SvgPicture.asset(
|
||||||
|
ImageConstant.icnDiary,
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
colorFilter: ColorFilter.mode(
|
||||||
|
appTheme.blueA400,
|
||||||
|
BlendMode.srcIn,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (profileModel.roles.contains('consultant'))
|
if (profileModel.roles.contains('consultant'))
|
||||||
@@ -210,7 +287,10 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
|||||||
icon: Padding(
|
icon: Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 2),
|
padding: const EdgeInsets.only(bottom: 2),
|
||||||
child: SvgPicture.asset(
|
child: SvgPicture.asset(
|
||||||
colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn),
|
colorFilter: const ColorFilter.mode(
|
||||||
|
Colors.grey,
|
||||||
|
BlendMode.srcIn,
|
||||||
|
),
|
||||||
'assets/icons/consultations.svg',
|
'assets/icons/consultations.svg',
|
||||||
width: 20,
|
width: 20,
|
||||||
height: 20,
|
height: 20,
|
||||||
@@ -222,7 +302,10 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
|||||||
'assets/icons/consultations.svg',
|
'assets/icons/consultations.svg',
|
||||||
width: 20,
|
width: 20,
|
||||||
height: 20,
|
height: 20,
|
||||||
colorFilter: ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn),
|
colorFilter: ColorFilter.mode(
|
||||||
|
appTheme.blueA400,
|
||||||
|
BlendMode.srcIn,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -230,14 +313,26 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
|||||||
label: s.chat,
|
label: s.chat,
|
||||||
icon: BlocBuilder<ChatsCubit, ChatsState>(
|
icon: BlocBuilder<ChatsCubit, ChatsState>(
|
||||||
builder: (BuildContext context, ChatsState state) {
|
builder: (BuildContext context, ChatsState state) {
|
||||||
final hasUnreadMessages = state.chatsWithUnreadMessagesCount > 0;
|
final hasUnreadMessages =
|
||||||
|
state.chatsWithUnreadMessagesCount > 0;
|
||||||
return badges.Badge(
|
return badges.Badge(
|
||||||
position: badges.BadgePosition.topEnd(),
|
position: badges.BadgePosition.topEnd(),
|
||||||
badgeAnimation: const badges.BadgeAnimation.fade(),
|
badgeAnimation: const badges.BadgeAnimation.fade(),
|
||||||
badgeStyle: badges.BadgeStyle(badgeColor: hasUnreadMessages ? Colors.red : Colors.transparent),
|
badgeStyle: badges.BadgeStyle(
|
||||||
|
badgeColor: hasUnreadMessages
|
||||||
|
? Colors.red
|
||||||
|
: Colors.transparent,
|
||||||
|
),
|
||||||
badgeContent: Text(
|
badgeContent: Text(
|
||||||
hasUnreadMessages ? state.chatsWithUnreadMessagesCount.toString() : '',
|
hasUnreadMessages
|
||||||
style: TextStyle(color: hasUnreadMessages ? Colors.white : Colors.transparent, fontSize: 8),
|
? state.chatsWithUnreadMessagesCount.toString()
|
||||||
|
: '',
|
||||||
|
style: TextStyle(
|
||||||
|
color: hasUnreadMessages
|
||||||
|
? Colors.white
|
||||||
|
: Colors.transparent,
|
||||||
|
fontSize: 8,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 2),
|
padding: const EdgeInsets.only(bottom: 2),
|
||||||
@@ -245,9 +340,16 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
|||||||
ImageConstant.icnChat,
|
ImageConstant.icnChat,
|
||||||
width: 20,
|
width: 20,
|
||||||
height: 20,
|
height: 20,
|
||||||
colorFilter: pages![effectiveIndex] == '/chat-select-screen'
|
colorFilter:
|
||||||
? ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn)
|
pages![effectiveIndex] == '/chat-select-screen'
|
||||||
: const ColorFilter.mode(Colors.grey, BlendMode.srcIn),
|
? ColorFilter.mode(
|
||||||
|
appTheme.blueA400,
|
||||||
|
BlendMode.srcIn,
|
||||||
|
)
|
||||||
|
: const ColorFilter.mode(
|
||||||
|
Colors.grey,
|
||||||
|
BlendMode.srcIn,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -261,15 +363,28 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
|||||||
return badges.Badge(
|
return badges.Badge(
|
||||||
position: badges.BadgePosition.topEnd(),
|
position: badges.BadgePosition.topEnd(),
|
||||||
badgeAnimation: const badges.BadgeAnimation.fade(),
|
badgeAnimation: const badges.BadgeAnimation.fade(),
|
||||||
showBadge: state is NotificationsStateLoaded && state.hasUnreadNotifications,
|
showBadge:
|
||||||
badgeContent: const Text('', style: TextStyle(color: Colors.white, fontSize: 8)),
|
state is NotificationsStateLoaded &&
|
||||||
|
state.hasUnreadNotifications,
|
||||||
|
badgeContent: const Text(
|
||||||
|
'',
|
||||||
|
style: TextStyle(color: Colors.white, fontSize: 8),
|
||||||
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 2),
|
padding: const EdgeInsets.only(bottom: 2),
|
||||||
child: SvgPicture.asset(
|
child: SvgPicture.asset(
|
||||||
ImageConstant.icnBell,
|
ImageConstant.icnBell,
|
||||||
colorFilter: pages![effectiveIndex] == '/notifications-screen'
|
colorFilter:
|
||||||
? ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn)
|
pages![effectiveIndex] ==
|
||||||
: const ColorFilter.mode(Colors.grey, BlendMode.srcIn),
|
'/notifications-screen'
|
||||||
|
? ColorFilter.mode(
|
||||||
|
appTheme.blueA400,
|
||||||
|
BlendMode.srcIn,
|
||||||
|
)
|
||||||
|
: const ColorFilter.mode(
|
||||||
|
Colors.grey,
|
||||||
|
BlendMode.srcIn,
|
||||||
|
),
|
||||||
width: 20,
|
width: 20,
|
||||||
height: 20,
|
height: 20,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ abstract class ChatMessageModel with _$ChatMessageModel {
|
|||||||
@JsonKey(name: 'author_id', fromJson: _parseAuthorId) required int authorId,
|
@JsonKey(name: 'author_id', fromJson: _parseAuthorId) required int authorId,
|
||||||
@JsonKey(name: 'updated_at', fromJson: _parseUpdatedAt) required DateTime updatedAt,
|
@JsonKey(name: 'updated_at', fromJson: _parseUpdatedAt) required DateTime updatedAt,
|
||||||
@JsonKey(name: 'read_by_to_user', defaultValue: false) required bool isRead,
|
@JsonKey(name: 'read_by_to_user', defaultValue: false) required bool isRead,
|
||||||
|
// @deprecated Backend BC-поле. В broadcast (ChatMessageUpdated) оно
|
||||||
|
// recipient-relative и для второго устройства отправителя всегда false,
|
||||||
|
// поэтому решать «моё/не моё» по нему нельзя. Источник истины — authorId:
|
||||||
|
// сравнивайте его со своим user_id (wsManager.userId). Поле оставлено
|
||||||
|
// только для обратной совместимости парсинга.
|
||||||
@JsonKey(name: 'is_author_message_auth_user') required bool? isCurrentUserAuthor,
|
@JsonKey(name: 'is_author_message_auth_user') required bool? isCurrentUserAuthor,
|
||||||
@JsonKey(name: 'room_id') required String? roomId,
|
@JsonKey(name: 'room_id') required String? roomId,
|
||||||
@JsonKey(name: 'type', unknownEnumValue: ChatMessageType.unknown) ChatMessageType? type,
|
@JsonKey(name: 'type', unknownEnumValue: ChatMessageType.unknown) ChatMessageType? type,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
|
|
||||||
StreamSubscription<ChannelReadEvent>? typingSubscription;
|
StreamSubscription<ChannelReadEvent>? typingSubscription;
|
||||||
StreamSubscription<ChannelReadEvent>? messagesSubscription;
|
StreamSubscription<ChannelReadEvent>? messagesSubscription;
|
||||||
|
StreamSubscription<void>? _reconnectSubscription;
|
||||||
int currentPage = 1;
|
int currentPage = 1;
|
||||||
late int lastPage;
|
late int lastPage;
|
||||||
bool isLoading = false;
|
bool isLoading = false;
|
||||||
@@ -39,11 +40,18 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
void Function(bool isOnline)? _presenceOnlineListener;
|
void Function(bool isOnline)? _presenceOnlineListener;
|
||||||
var uuid = const Uuid();
|
var uuid = const Uuid();
|
||||||
|
|
||||||
ChatRoomCubit({required this.chatRepository, required this.wsManager, required this.cardsRepository}) : super(const ChatRoomState.loading());
|
ChatRoomCubit({
|
||||||
|
required this.chatRepository,
|
||||||
|
required this.wsManager,
|
||||||
|
required this.cardsRepository,
|
||||||
|
}) : super(const ChatRoomState.loading());
|
||||||
|
|
||||||
void setTimer() {
|
void setTimer() {
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
_timer = Timer.periodic(const Duration(seconds: 5), (_) => updateChosenPlanState());
|
_timer = Timer.periodic(
|
||||||
|
const Duration(seconds: 5),
|
||||||
|
(_) => updateChosenPlanState(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void cancelTimer() {
|
void cancelTimer() {
|
||||||
@@ -54,16 +62,22 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
if (currentPage < lastPage && !isLoading) {
|
if (currentPage < lastPage && !isLoading) {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
|
|
||||||
emit((state as ChatRoomStateLoaded).copyWith(isLoading: isLoading));
|
_emitLoaded((current) => current.copyWith(isLoading: isLoading));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final chatMessages = await chatRepository.getChatMessages(uuid, currentPage + 1);
|
final chatMessages = await chatRepository.getChatMessages(
|
||||||
|
uuid,
|
||||||
|
currentPage + 1,
|
||||||
|
);
|
||||||
|
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
emit(
|
_emitLoaded(
|
||||||
(state as ChatRoomStateLoaded).copyWith(
|
(current) => current.copyWith(
|
||||||
isLoading: isLoading,
|
isLoading: isLoading,
|
||||||
chatMessages: [...(state as ChatRoomStateLoaded).chatMessages, ...chatMessages.messagesList],
|
chatMessages: [
|
||||||
|
...current.chatMessages,
|
||||||
|
...chatMessages.messagesList,
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -86,11 +100,8 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _setCard({required List<CardModel> cards}) {
|
void _setCard({required List<CardModel> cards}) {
|
||||||
final currentState = state;
|
if (cards.where((card) => card.isDefault).isNotEmpty) {
|
||||||
if (currentState is ChatRoomStateLoaded) {
|
_emitLoaded((current) => current.copyWith(hasCard: true));
|
||||||
if (cards.where((card) => card.isDefault).isNotEmpty) {
|
|
||||||
emit(currentState.copyWith(hasCard: true));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +111,11 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
/// `GET /api/consultation/resolve/order_id_to_conversation/{orderId}`.
|
/// `GET /api/consultation/resolve/order_id_to_conversation/{orderId}`.
|
||||||
///
|
///
|
||||||
/// Возвращает conversation uuid, которым инициализировали чат (для reload без повторного resolve).
|
/// Возвращает conversation uuid, которым инициализировали чат (для reload без повторного resolve).
|
||||||
Future<String?> initChat(String? uuid, {String? orderId, bool sendInitialMessage = false}) async {
|
Future<String?> initChat(
|
||||||
|
String? uuid, {
|
||||||
|
String? orderId,
|
||||||
|
bool sendInitialMessage = false,
|
||||||
|
}) async {
|
||||||
try {
|
try {
|
||||||
emit(const ChatRoomState.loading());
|
emit(const ChatRoomState.loading());
|
||||||
|
|
||||||
@@ -108,7 +123,9 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
int? resolvedChosenPlanId;
|
int? resolvedChosenPlanId;
|
||||||
|
|
||||||
if (orderId != null && orderId.isNotEmpty) {
|
if (orderId != null && orderId.isNotEmpty) {
|
||||||
final resolved = await chatRepository.resolveOrderIdToConversation(orderId: orderId);
|
final resolved = await chatRepository.resolveOrderIdToConversation(
|
||||||
|
orderId: orderId,
|
||||||
|
);
|
||||||
resolvedUuid = resolved.conversationUuid;
|
resolvedUuid = resolved.conversationUuid;
|
||||||
resolvedChosenPlanId = resolved.chosenPlanId;
|
resolvedChosenPlanId = resolved.chosenPlanId;
|
||||||
}
|
}
|
||||||
@@ -117,15 +134,23 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
throw ArgumentError('initChat requires uuid or orderId');
|
throw ArgumentError('initChat requires uuid or orderId');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
currentPage = 1;
|
||||||
|
isLoading = false;
|
||||||
|
_activeChatUuid = resolvedUuid;
|
||||||
|
|
||||||
final chatRoom = await chatRepository.getChatRoom(resolvedUuid);
|
final chatRoom = await chatRepository.getChatRoom(resolvedUuid);
|
||||||
ChosenPlanModel? chosenPlan;
|
ChosenPlanModel? chosenPlan;
|
||||||
final planId = resolvedChosenPlanId ?? chatRoom.chosenPlanId;
|
final planId = resolvedChosenPlanId ?? chatRoom.chosenPlanId;
|
||||||
if (planId != null) {
|
if (planId != null) {
|
||||||
chosenPlan = await chatRepository.getChosenPlan(id: planId);
|
chosenPlan = await chatRepository.getChosenPlan(id: planId);
|
||||||
}
|
}
|
||||||
final chatMessages = await chatRepository.getChatMessages(resolvedUuid, currentPage);
|
final chatMessages = await chatRepository.getChatMessages(
|
||||||
|
resolvedUuid,
|
||||||
|
currentPage,
|
||||||
|
);
|
||||||
lastPage = chatMessages.meta.lastPage;
|
lastPage = chatMessages.meta.lastPage;
|
||||||
await _subscribeToChatChannel(uuid: resolvedUuid, chatId: chatRoom.id);
|
await _subscribeToChatChannel(uuid: resolvedUuid, chatId: chatRoom.id);
|
||||||
|
_listenToReconnect();
|
||||||
emit(
|
emit(
|
||||||
ChatRoomState.loaded(
|
ChatRoomState.loaded(
|
||||||
chatRoom: chatRoom.copyWith(chosenPlan: chosenPlan),
|
chatRoom: chatRoom.copyWith(chosenPlan: chosenPlan),
|
||||||
@@ -135,6 +160,7 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
hasCard: false,
|
hasCard: false,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
await _subscribeToPresenceChannel(chatId: chatRoom.id);
|
||||||
|
|
||||||
if (chosenPlan?.consultantId == chatRoom.users.sender.id) {
|
if (chosenPlan?.consultantId == chatRoom.users.sender.id) {
|
||||||
await checkCards();
|
await checkCards();
|
||||||
@@ -145,22 +171,106 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
log(e.toString());
|
log(e.toString());
|
||||||
typingSubscription?.cancel();
|
typingSubscription?.cancel();
|
||||||
messagesSubscription?.cancel();
|
messagesSubscription?.cancel();
|
||||||
|
_reconnectSubscription?.cancel();
|
||||||
|
_reconnectSubscription = null;
|
||||||
wsManager.unsubscribeDirectChatChannel();
|
wsManager.unsubscribeDirectChatChannel();
|
||||||
emit(const ChatRoomState.error(errorMessage: 'Ошибка получеия данных'));
|
emit(const ChatRoomState.error(errorMessage: 'Ошибка получеия данных'));
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Подписывается на реконнект сокета, чтобы догрузить пропущенное (S5).
|
||||||
|
void _listenToReconnect() {
|
||||||
|
_reconnectSubscription?.cancel();
|
||||||
|
_reconnectSubscription = wsManager.reconnected.listen((_) {
|
||||||
|
unawaited(refreshCurrentChat());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Догружает первую страницу сообщений после восстановления соединения (S5).
|
||||||
|
///
|
||||||
|
/// Мерж по `id` сохраняет уже подгруженные старые страницы: пропущенные
|
||||||
|
/// сообщения добавляются, существующие не дублируются. Порядок — по
|
||||||
|
/// `updatedAt` по убыванию, как его ждёт UI и отдаёт backend (S1).
|
||||||
|
Future<void> refreshCurrentChat() async {
|
||||||
|
final uuid = _activeChatUuid;
|
||||||
|
if (uuid == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final page = await chatRepository.getChatMessages(uuid, 1);
|
||||||
|
lastPage = page.meta.lastPage;
|
||||||
|
_emitLoaded((current) {
|
||||||
|
final byId = {
|
||||||
|
for (final message in current.chatMessages) message.id: message,
|
||||||
|
};
|
||||||
|
for (final message in page.messagesList) {
|
||||||
|
byId.putIfAbsent(message.id, () => message);
|
||||||
|
}
|
||||||
|
final merged = byId.values.toList()
|
||||||
|
..sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
|
||||||
|
return current.copyWith(chatMessages: merged);
|
||||||
|
});
|
||||||
|
} catch (e, st) {
|
||||||
|
await reportFrom(e, st, this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Перезагружает сообщения, комнату и состояние плана без экрана загрузки.
|
||||||
|
Future<void> refreshChat() async {
|
||||||
|
final uuid = _activeChatUuid;
|
||||||
|
if (uuid == null || uuid.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final currentState = state;
|
||||||
|
if (currentState is! ChatRoomStateLoaded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
currentPage = 1;
|
||||||
|
isLoading = false;
|
||||||
|
|
||||||
|
final chatRoom = await chatRepository.getChatRoom(uuid);
|
||||||
|
ChosenPlanModel? chosenPlan;
|
||||||
|
final planId =
|
||||||
|
chatRoom.chosenPlanId ?? currentState.chatRoom.chosenPlanId;
|
||||||
|
if (planId != null) {
|
||||||
|
chosenPlan = await chatRepository.getChosenPlan(id: planId);
|
||||||
|
}
|
||||||
|
final chatMessages = await chatRepository.getChatMessages(
|
||||||
|
uuid,
|
||||||
|
currentPage,
|
||||||
|
);
|
||||||
|
lastPage = chatMessages.meta.lastPage;
|
||||||
|
|
||||||
|
_emitLoaded(
|
||||||
|
(latest) => latest.copyWith(
|
||||||
|
chatRoom: chatRoom.copyWith(chosenPlan: chosenPlan),
|
||||||
|
chatMessages: chatMessages.messagesList,
|
||||||
|
isLoading: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (chosenPlan?.consultantId == chatRoom.users.sender.id) {
|
||||||
|
await checkCards();
|
||||||
|
}
|
||||||
|
} catch (e, st) {
|
||||||
|
await reportFrom(e, st, this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> updateChosenPlanState() async {
|
Future<void> updateChosenPlanState() async {
|
||||||
try {
|
try {
|
||||||
final currentState = state;
|
final currentState = state;
|
||||||
if (currentState is ChatRoomStateLoaded) {
|
if (currentState is! ChatRoomStateLoaded) {
|
||||||
final chosenPlanId = currentState.chatRoom.chosenPlanId;
|
return;
|
||||||
if (chosenPlanId != null) {
|
|
||||||
final chosenPlan = await chatRepository.getChosenPlan(id: chosenPlanId);
|
|
||||||
emit(currentState.copyWith.chatRoom(chosenPlan: chosenPlan));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
final chosenPlanId = currentState.chatRoom.chosenPlanId;
|
||||||
|
if (chosenPlanId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final chosenPlan = await chatRepository.getChosenPlan(id: chosenPlanId);
|
||||||
|
_emitLoaded((latest) => latest.copyWith.chatRoom(chosenPlan: chosenPlan));
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
}
|
}
|
||||||
@@ -172,9 +282,16 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
final planId = (state as ChatRoomStateLoaded).chatRoom.chosenPlanId;
|
final planId = (state as ChatRoomStateLoaded).chatRoom.chosenPlanId;
|
||||||
if (planId == null) return false;
|
if (planId == null) return false;
|
||||||
final stringDate = date.format('yyyy-MM-dd');
|
final stringDate = date.format('yyyy-MM-dd');
|
||||||
await chatRepository.choseStartPlanDate(id: planId, date: {'start_date': stringDate});
|
await chatRepository.choseStartPlanDate(
|
||||||
|
id: planId,
|
||||||
|
date: {'start_date': stringDate},
|
||||||
|
);
|
||||||
final chosenPlan = await chatRepository.getChosenPlan(id: planId);
|
final chosenPlan = await chatRepository.getChosenPlan(id: planId);
|
||||||
emit((state as ChatRoomStateLoaded).copyWith.chatRoom(chosenPlan: chosenPlan));
|
emit(
|
||||||
|
(state as ChatRoomStateLoaded).copyWith.chatRoom(
|
||||||
|
chosenPlan: chosenPlan,
|
||||||
|
),
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
@@ -183,7 +300,10 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> cancelChosenPlan({required String chatId, required int planId}) async {
|
Future<void> cancelChosenPlan({
|
||||||
|
required String chatId,
|
||||||
|
required int planId,
|
||||||
|
}) async {
|
||||||
try {
|
try {
|
||||||
await chatRepository.cancelChosenPlan(id: planId);
|
await chatRepository.cancelChosenPlan(id: planId);
|
||||||
emit((state as ChatRoomStateLoaded).copyWith.chatRoom(chosenPlan: null));
|
emit((state as ChatRoomStateLoaded).copyWith.chatRoom(chosenPlan: null));
|
||||||
@@ -193,38 +313,129 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _subscribeToChatChannel({required String uuid, required String chatId}) async {
|
bool _emitLoaded(
|
||||||
|
ChatRoomStateLoaded Function(ChatRoomStateLoaded current) update,
|
||||||
|
) {
|
||||||
|
if (isClosed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final currentState = state;
|
||||||
|
if (currentState is! ChatRoomStateLoaded) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
emit(update(currentState));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _subscribeToChatChannel({
|
||||||
|
required String uuid,
|
||||||
|
required String chatId,
|
||||||
|
}) async {
|
||||||
|
// Активный чат запоминаем до проверки клиента: он нужен и для догрузки
|
||||||
|
// пропущенного после реконнекта (S5), даже если сокет появится позже.
|
||||||
|
_activeChatUuid = uuid;
|
||||||
final client = wsManager.client;
|
final client = wsManager.client;
|
||||||
if (client == null) return;
|
if (client == null) return;
|
||||||
_activeChatUuid = uuid;
|
await messagesSubscription?.cancel();
|
||||||
|
await typingSubscription?.cancel();
|
||||||
|
messagesSubscription = null;
|
||||||
|
typingSubscription = null;
|
||||||
await wsManager.initPrivateDirectChatChannel(chatId);
|
await wsManager.initPrivateDirectChatChannel(chatId);
|
||||||
wsManager.subscribeDirectChatChannel();
|
wsManager.subscribeDirectChatChannel();
|
||||||
await _subscribeToPresenceChannel(chatId: chatId);
|
messagesSubscription = wsManager.directChatStream?.listen(
|
||||||
messagesSubscription = wsManager.directChatStream?.listen((event) {
|
_onDirectChatEvent,
|
||||||
AudioPlayer().play((AssetSource('audio/recive_message.wav')));
|
onError: (Object e, StackTrace st) {
|
||||||
final message = ChatMessageModel.fromJson(jsonDecode(event.data));
|
unawaited(reportFrom(e, st, this));
|
||||||
final currentState = state as ChatRoomStateLoaded;
|
},
|
||||||
|
);
|
||||||
// Сообщения от текущего пользователя не приходят через WebSocket,
|
|
||||||
// они обрабатываются через ответ API в sendMessage
|
|
||||||
// Здесь обрабатываем только входящие сообщения от других пользователей
|
|
||||||
List<ChatMessageModel> messagesList = [message, ...currentState.chatMessages];
|
|
||||||
|
|
||||||
chatRepository.emitNewMessageToChats(message);
|
|
||||||
emit(currentState.copyWith(chatMessages: messagesList, isLoading: isLoading));
|
|
||||||
EasyDebounce.debounce('searchChatRoom', const Duration(milliseconds: 300), () => sl<ChatsCubit>().getChatRooms());
|
|
||||||
});
|
|
||||||
|
|
||||||
typingSubscription = wsManager.clientTypingStream?.listen((event) {
|
typingSubscription = wsManager.clientTypingStream?.listen((event) {
|
||||||
emit((state as ChatRoomStateLoaded).copyWith(isTyping: true, isLoading: isLoading));
|
_emitLoaded(
|
||||||
|
(current) => current.copyWith(isTyping: true, isLoading: isLoading),
|
||||||
|
);
|
||||||
EasyDebounce.debounce(
|
EasyDebounce.debounce(
|
||||||
'client-typing',
|
'client-typing',
|
||||||
const Duration(seconds: 3),
|
const Duration(seconds: 3),
|
||||||
() => emit((state as ChatRoomStateLoaded).copyWith(isTyping: false, isLoading: isLoading)),
|
() => _emitLoaded(
|
||||||
|
(current) => current.copyWith(isTyping: false, isLoading: isLoading),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onDirectChatEvent(ChannelReadEvent event) {
|
||||||
|
try {
|
||||||
|
if (addIncomingMessage(
|
||||||
|
ChatMessageModel.fromJson(
|
||||||
|
jsonDecode(event.data) as Map<String, dynamic>,
|
||||||
|
),
|
||||||
|
)) {
|
||||||
|
unawaited(_playIncomingSound());
|
||||||
|
}
|
||||||
|
} catch (e, st) {
|
||||||
|
unawaited(reportFrom(e, st, this));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _playIncomingSound() async {
|
||||||
|
try {
|
||||||
|
await AudioPlayer().play(AssetSource('audio/recive_message.wav'));
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Добавляет входящее сообщение в текущий список, не затирая более новый state.
|
||||||
|
///
|
||||||
|
/// Собственное сообщение, пришедшее по сокету, — это эхо с другого устройства
|
||||||
|
/// автора (`author_id == wsManager.userId`). Такое сообщение тоже добавляется
|
||||||
|
/// в открытый чат, но не считается входящим: звук не играет, а список чатов
|
||||||
|
/// обновляет S3 через `new-chat-messages.{senderId}`.
|
||||||
|
///
|
||||||
|
/// Возвращает `true`, только если нужно проиграть звук входящего (чужое
|
||||||
|
/// сообщение, реально добавленное).
|
||||||
|
bool addIncomingMessage(ChatMessageModel message) {
|
||||||
|
final currentState = state;
|
||||||
|
if (currentState is! ChatRoomStateLoaded) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final currentUserId = wsManager.userId;
|
||||||
|
final isOwn = currentUserId != null && message.authorId == currentUserId;
|
||||||
|
|
||||||
|
// Дедуп: по серверному id и по uuid (если broadcast вернул наш uuid, т.е.
|
||||||
|
// это эхо уже добавленного оптимистичного сообщения).
|
||||||
|
final alreadyExists = currentState.chatMessages.any(
|
||||||
|
(existing) =>
|
||||||
|
(message.id != 0 && existing.id == message.id) ||
|
||||||
|
(message.uuid != null && existing.uuid == message.uuid),
|
||||||
|
);
|
||||||
|
if (alreadyExists) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final added = _emitLoaded((current) {
|
||||||
|
if (current.chatMessages.any(
|
||||||
|
(existing) =>
|
||||||
|
(message.id != 0 && existing.id == message.id) ||
|
||||||
|
(message.uuid != null && existing.uuid == message.uuid),
|
||||||
|
)) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
return current.copyWith(
|
||||||
|
chatMessages: [message, ...current.chatMessages],
|
||||||
|
isLoading: isLoading,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (!added) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Список чатов за собственные сообщения обновляет S3 (new-chat-messages.{senderId}).
|
||||||
|
if (!isOwn) {
|
||||||
|
chatRepository.emitNewMessageToChats(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return !isOwn;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _subscribeToPresenceChannel({required String chatId}) async {
|
Future<void> _subscribeToPresenceChannel({required String chatId}) async {
|
||||||
final currentState = state;
|
final currentState = state;
|
||||||
if (currentState is! ChatRoomStateLoaded) return;
|
if (currentState is! ChatRoomStateLoaded) return;
|
||||||
@@ -236,12 +447,15 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
|
|
||||||
await wsManager.initPresenceDirectChatChannel(chatId);
|
await wsManager.initPresenceDirectChatChannel(chatId);
|
||||||
_presenceOnlineListener = (isOnline) {
|
_presenceOnlineListener = (isOnline) {
|
||||||
unawaited(_handleCounterpartOnlineChanged(
|
unawaited(
|
||||||
counterpartField: counterpart.id == currentState.chatRoom.users.receiver.id
|
_handleCounterpartOnlineChanged(
|
||||||
? _ChatCounterpartField.receiver
|
counterpartField:
|
||||||
: _ChatCounterpartField.sender,
|
counterpart.id == currentState.chatRoom.users.receiver.id
|
||||||
isOnline: isOnline,
|
? _ChatCounterpartField.receiver
|
||||||
));
|
: _ChatCounterpartField.sender,
|
||||||
|
isOnline: isOnline,
|
||||||
|
),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
wsManager.addPresenceDirectChatListener(
|
wsManager.addPresenceDirectChatListener(
|
||||||
counterpartUserId: counterpart.id,
|
counterpartUserId: counterpart.id,
|
||||||
@@ -260,7 +474,8 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
? currentState.chatRoom.users.receiver
|
? currentState.chatRoom.users.receiver
|
||||||
: currentState.chatRoom.users.sender;
|
: currentState.chatRoom.users.sender;
|
||||||
|
|
||||||
if (counterpart.isOnline == isOnline && (isOnline || counterpart.lastSeenText.isNotEmpty)) {
|
if (counterpart.isOnline == isOnline &&
|
||||||
|
(isOnline || counterpart.lastSeenText.isNotEmpty)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,63 +552,55 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> sendMessage({String? data, List<File>? files, required String userSlug}) async {
|
Future<bool> sendMessage({
|
||||||
|
String? data,
|
||||||
|
List<File>? files,
|
||||||
|
required String userSlug,
|
||||||
|
}) async {
|
||||||
try {
|
try {
|
||||||
final messageUuid = uuid.v4();
|
final messageUuid = uuid.v4();
|
||||||
final compressedFiles = files != null ? await ImageCompressUtility.compressFilesList(List<File>.from(files)) : null;
|
final compressedFiles = files != null
|
||||||
AudioPlayer().play((AssetSource('audio/send_message.wav')));
|
? await ImageCompressUtility.compressFilesList(List<File>.from(files))
|
||||||
|
: null;
|
||||||
|
try {
|
||||||
|
unawaited(AudioPlayer().play(AssetSource('audio/send_message.wav')));
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
// Создаем временное сообщение с состоянием loading
|
_emitLoaded(
|
||||||
emit(
|
(current) => current.copyWith(
|
||||||
(state as ChatRoomStateLoaded).copyWith(
|
|
||||||
chatMessages: [
|
chatMessages: [
|
||||||
ChatMessageModel(
|
ChatMessageModel(
|
||||||
id: 1,
|
id: -messageUuid.hashCode.abs(),
|
||||||
data: data ?? '',
|
data: data ?? '',
|
||||||
mediaData: compressedFiles,
|
mediaData: compressedFiles,
|
||||||
authorId: 1,
|
authorId: wsManager.userId ?? 0,
|
||||||
updatedAt: DateTime.now(),
|
updatedAt: DateTime.now(),
|
||||||
isRead: true,
|
isRead: true,
|
||||||
isCurrentUserAuthor: true,
|
isCurrentUserAuthor: true,
|
||||||
roomId: '',
|
roomId: current.chatRoom.id,
|
||||||
messageState: MessageState.loading,
|
messageState: MessageState.loading,
|
||||||
uuid: messageUuid,
|
uuid: messageUuid,
|
||||||
),
|
),
|
||||||
...(state as ChatRoomStateLoaded).chatMessages,
|
...current.chatMessages,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Отправляем сообщение и получаем ответ от сервера с правильными данными
|
final serverMessage = await chatRepository.sendMessage(
|
||||||
final serverMessage = await chatRepository.sendMessage(data: data, files: compressedFiles, slug: userSlug);
|
data: data,
|
||||||
|
files: compressedFiles,
|
||||||
// Заменяем локальное сообщение данными с сервера (с правильными URL файлов)
|
slug: userSlug,
|
||||||
emit(
|
);
|
||||||
(state as ChatRoomStateLoaded).copyWith(
|
_replaceOptimisticMessage(
|
||||||
chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) {
|
messageUuid: messageUuid,
|
||||||
if (elem.uuid == messageUuid) {
|
serverMessage: serverMessage,
|
||||||
// Сохраняем UUID, состояние и обновляем данные из ответа сервера
|
|
||||||
// Используем значения из локального сообщения для полей, которые могут отсутствовать в ответе
|
|
||||||
return serverMessage.copyWith(
|
|
||||||
uuid: messageUuid,
|
|
||||||
messageState: MessageState.sent,
|
|
||||||
isCurrentUserAuthor: true,
|
|
||||||
isRead: elem.isRead,
|
|
||||||
roomId: elem.roomId ?? serverMessage.roomId,
|
|
||||||
authorId: serverMessage.authorId,
|
|
||||||
updatedAt: serverMessage.updatedAt,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return elem;
|
|
||||||
}).toList(),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
emit(
|
_emitLoaded(
|
||||||
(state as ChatRoomStateLoaded).copyWith(
|
(current) => current.copyWith(
|
||||||
chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) {
|
chatMessages: current.chatMessages.map((elem) {
|
||||||
if (elem.uuid == messageUuid) {
|
if (elem.uuid == messageUuid) {
|
||||||
return elem.copyWith(messageState: MessageState.error);
|
return elem.copyWith(messageState: MessageState.error);
|
||||||
}
|
}
|
||||||
@@ -411,21 +618,25 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> markMessagesAsRead(String chatId, List<({int id, int index})> messagesIds) async {
|
Future<bool> markMessagesAsRead(String chatId, List<int> messageIds) async {
|
||||||
try {
|
try {
|
||||||
if (state is ChatRoomStateLoaded) {
|
if (state is! ChatRoomStateLoaded) {
|
||||||
final ids = messagesIds.map((e) => e.id).toList();
|
return false;
|
||||||
await chatRepository.markMessagesAsRead(chatId, ids);
|
|
||||||
final currentMessages = [...(state as ChatRoomStateLoaded).chatMessages];
|
|
||||||
for (var elem in messagesIds) {
|
|
||||||
currentMessages[elem.index] = currentMessages[elem.index].copyWith(isRead: true);
|
|
||||||
}
|
|
||||||
if (!isClosed) {
|
|
||||||
emit((state as ChatRoomStateLoaded).copyWith(chatMessages: currentMessages));
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
return false;
|
await chatRepository.markMessagesAsRead(chatId, messageIds);
|
||||||
|
final idSet = messageIds.toSet();
|
||||||
|
_emitLoaded(
|
||||||
|
(current) => current.copyWith(
|
||||||
|
chatMessages: current.chatMessages
|
||||||
|
.map(
|
||||||
|
(message) => idSet.contains(message.id)
|
||||||
|
? message.copyWith(isRead: true)
|
||||||
|
: message,
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
log(e.toString(), stackTrace: st);
|
log(e.toString(), stackTrace: st);
|
||||||
@@ -433,11 +644,52 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _replaceOptimisticMessage({
|
||||||
|
required String messageUuid,
|
||||||
|
required ChatMessageModel serverMessage,
|
||||||
|
}) {
|
||||||
|
_emitLoaded((current) {
|
||||||
|
final sentMessage = serverMessage.copyWith(
|
||||||
|
uuid: messageUuid,
|
||||||
|
messageState: MessageState.sent,
|
||||||
|
isCurrentUserAuthor: true,
|
||||||
|
);
|
||||||
|
final withoutDuplicate = current.chatMessages.where(
|
||||||
|
(message) =>
|
||||||
|
message.id != serverMessage.id || message.uuid == messageUuid,
|
||||||
|
);
|
||||||
|
final hasOptimistic = withoutDuplicate.any(
|
||||||
|
(message) => message.uuid == messageUuid,
|
||||||
|
);
|
||||||
|
if (!hasOptimistic) {
|
||||||
|
return current.copyWith(
|
||||||
|
chatMessages: [sentMessage, ...withoutDuplicate],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return current.copyWith(
|
||||||
|
chatMessages: withoutDuplicate.map((elem) {
|
||||||
|
if (elem.uuid == messageUuid) {
|
||||||
|
return sentMessage.copyWith(
|
||||||
|
isRead: elem.isRead,
|
||||||
|
roomId: elem.roomId?.isNotEmpty == true
|
||||||
|
? elem.roomId
|
||||||
|
: serverMessage.roomId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return elem;
|
||||||
|
}).toList(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<String?> getPaymentLink() async {
|
Future<String?> getPaymentLink() async {
|
||||||
try {
|
try {
|
||||||
final planId = (state as ChatRoomStateLoaded).chatRoom.chosenPlan?.id;
|
final planId = (state as ChatRoomStateLoaded).chatRoom.chosenPlan?.id;
|
||||||
if (planId == null) return null;
|
if (planId == null) return null;
|
||||||
final paymentModel = await chatRepository.getPaymentLink(id: planId, paymentProvider: 'alfa-bank');
|
final paymentModel = await chatRepository.getPaymentLink(
|
||||||
|
id: planId,
|
||||||
|
paymentProvider: 'alfa-bank',
|
||||||
|
);
|
||||||
return paymentModel.paymentUrl;
|
return paymentModel.paymentUrl;
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
@@ -477,91 +729,93 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> close() async {
|
Future<void> close() async {
|
||||||
|
EasyDebounce.cancel('client-typing');
|
||||||
|
EasyDebounce.cancel('searchChatRoom');
|
||||||
if (_presenceOnlineListener != null) {
|
if (_presenceOnlineListener != null) {
|
||||||
wsManager.removePresenceDirectChatListener(_presenceOnlineListener!);
|
wsManager.removePresenceDirectChatListener(_presenceOnlineListener!);
|
||||||
_presenceOnlineListener = null;
|
_presenceOnlineListener = null;
|
||||||
}
|
}
|
||||||
wsManager.unsubscribeDirectChatChannel();
|
wsManager.unsubscribeDirectChatChannel();
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
messagesSubscription?.cancel();
|
await messagesSubscription?.cancel();
|
||||||
typingSubscription?.cancel();
|
await typingSubscription?.cancel();
|
||||||
|
await _reconnectSubscription?.cancel();
|
||||||
super.close();
|
super.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
void deleteMessage(String uuid) {
|
void deleteMessage(String uuid) {
|
||||||
final currentState = state;
|
final currentState = state;
|
||||||
if (currentState is ChatRoomStateLoaded) {
|
if (currentState is ChatRoomStateLoaded) {
|
||||||
emit(currentState.copyWith(chatMessages: currentState.chatMessages.where((message) => message.uuid != uuid).toList()));
|
emit(
|
||||||
|
currentState.copyWith(
|
||||||
|
chatMessages: currentState.chatMessages
|
||||||
|
.where((message) => message.uuid != uuid)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> resendMessage({required String uuid, required String userSlug}) async {
|
Future<void> resendMessage({
|
||||||
|
required String uuid,
|
||||||
|
required String userSlug,
|
||||||
|
}) async {
|
||||||
final currentState = state;
|
final currentState = state;
|
||||||
if (currentState is ChatRoomStateLoaded) {
|
if (currentState is ChatRoomStateLoaded) {
|
||||||
final message = currentState.chatMessages.firstWhereOrNull((message) => message.uuid == uuid);
|
final message = currentState.chatMessages.firstWhereOrNull(
|
||||||
|
(message) => message.uuid == uuid,
|
||||||
|
);
|
||||||
if (message != null) {
|
if (message != null) {
|
||||||
try {
|
try {
|
||||||
AudioPlayer().play((AssetSource('audio/send_message.wav')));
|
try {
|
||||||
|
unawaited(
|
||||||
|
AudioPlayer().play(AssetSource('audio/send_message.wav')),
|
||||||
|
);
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
// Определяем, есть ли файлы для повторной отправки
|
|
||||||
List<File>? filesToResend;
|
List<File>? filesToResend;
|
||||||
if (message.mediaData != null && message.mediaData!.isNotEmpty) {
|
if (message.mediaData != null && message.mediaData!.isNotEmpty) {
|
||||||
// Если mediaData содержит File объекты, используем их
|
|
||||||
// Если это строки (URL), то файлы уже на сервере и повторная отправка не нужна
|
|
||||||
final firstItem = message.mediaData!.first;
|
final firstItem = message.mediaData!.first;
|
||||||
if (firstItem is File) {
|
if (firstItem is File) {
|
||||||
filesToResend = message.mediaData!.cast<File>();
|
filesToResend = message.mediaData!.cast<File>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
emit(
|
_emitLoaded(
|
||||||
(state as ChatRoomStateLoaded).copyWith(
|
(current) => current.copyWith(
|
||||||
chatMessages: [
|
chatMessages: [
|
||||||
ChatMessageModel(
|
ChatMessageModel(
|
||||||
id: 1,
|
id: -uuid.hashCode.abs(),
|
||||||
data: message.data,
|
data: message.data,
|
||||||
mediaData: message.mediaData,
|
mediaData: message.mediaData,
|
||||||
authorId: 1,
|
authorId: wsManager.userId ?? 0,
|
||||||
updatedAt: DateTime.now(),
|
updatedAt: DateTime.now(),
|
||||||
isRead: true,
|
isRead: true,
|
||||||
isCurrentUserAuthor: true,
|
isCurrentUserAuthor: true,
|
||||||
roomId: '',
|
roomId: current.chatRoom.id,
|
||||||
messageState: MessageState.loading,
|
messageState: MessageState.loading,
|
||||||
uuid: uuid,
|
uuid: uuid,
|
||||||
),
|
),
|
||||||
...(state as ChatRoomStateLoaded).chatMessages.where((message) => message.uuid != uuid),
|
...current.chatMessages.where((item) => item.uuid != uuid),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Отправляем сообщение и получаем ответ от сервера
|
final serverMessage = await chatRepository.sendMessage(
|
||||||
final serverMessage = await chatRepository.sendMessage(data: message.data, files: filesToResend, slug: userSlug);
|
data: message.data,
|
||||||
|
files: filesToResend,
|
||||||
// Заменяем локальное сообщение данными с сервера
|
slug: userSlug,
|
||||||
emit(
|
);
|
||||||
(state as ChatRoomStateLoaded).copyWith(
|
_replaceOptimisticMessage(
|
||||||
chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) {
|
messageUuid: uuid,
|
||||||
if (elem.uuid == uuid) {
|
serverMessage: serverMessage,
|
||||||
return serverMessage.copyWith(
|
|
||||||
uuid: uuid,
|
|
||||||
messageState: MessageState.sent,
|
|
||||||
isCurrentUserAuthor: true,
|
|
||||||
isRead: elem.isRead,
|
|
||||||
roomId: elem.roomId ?? serverMessage.roomId,
|
|
||||||
authorId: serverMessage.authorId,
|
|
||||||
updatedAt: serverMessage.updatedAt,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return elem;
|
|
||||||
}).toList(),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
emit(
|
_emitLoaded(
|
||||||
(state as ChatRoomStateLoaded).copyWith(
|
(current) => current.copyWith(
|
||||||
chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) {
|
chatMessages: current.chatMessages.map((elem) {
|
||||||
if (elem.uuid == uuid) {
|
if (elem.uuid == uuid) {
|
||||||
return elem.copyWith(messageState: MessageState.error);
|
return elem.copyWith(messageState: MessageState.error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,20 @@ import 'dart:async';
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
|
|
||||||
|
import 'package:eatvio_app/core/device/device_hw_id_provider.dart';
|
||||||
import 'package:eatvio_app/core/enums/base_state.dart';
|
import 'package:eatvio_app/core/enums/base_state.dart';
|
||||||
import 'package:eatvio_app/core/websockets/ws_manager.dart';
|
import 'package:eatvio_app/core/websockets/ws_manager.dart';
|
||||||
import 'package:eatvio_app/features/chat/models/chat_message_model.dart';
|
import 'package:eatvio_app/features/chat/models/chat_message_model.dart';
|
||||||
|
import 'package:eatvio_app/features/chat/models/chat_room_pagination_model.dart';
|
||||||
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_state.dart';
|
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_state.dart';
|
||||||
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
|
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
|
||||||
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
|
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/services/video_call_chat_signal.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/services/video_call_end_reason.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/services/video_call_ended_push_handler.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/services/video_call_socket_signal.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/services/video_call_incoming_push_presenter.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||||
|
|
||||||
@@ -17,13 +25,31 @@ class ChatsCubit extends Cubit<ChatsState> {
|
|||||||
final WsManager wsManager;
|
final WsManager wsManager;
|
||||||
StreamSubscription? newIncomingMessagesSubscription;
|
StreamSubscription? newIncomingMessagesSubscription;
|
||||||
StreamSubscription? newLastMessageSubscription;
|
StreamSubscription? newLastMessageSubscription;
|
||||||
|
StreamSubscription<void>? reconnectedSubscription;
|
||||||
|
StreamSubscription? videoCallDeclinedSubscription;
|
||||||
|
StreamSubscription? videoCallEndedSubscription;
|
||||||
|
StreamSubscription? videoCallAcceptedSubscription;
|
||||||
|
final Set<int> _presentedVideoCallMessageIds = {};
|
||||||
|
|
||||||
|
/// Test seam. Production uses CallKit.
|
||||||
|
Future<void> Function(VideoCallPushPayload payload)? presentIncomingVideoCall;
|
||||||
|
|
||||||
|
/// Test seam. Production reads [DeviceHwIdProvider.current].
|
||||||
|
Future<String?> Function()? currentDeviceHwId;
|
||||||
|
Future<void> Function(String? conversationUuid)? dismissIncomingVideoCall;
|
||||||
|
Future<void> Function({String? conversationUuid, String? callId})?
|
||||||
|
endActiveVideoCall;
|
||||||
|
|
||||||
ChatsCubit({
|
ChatsCubit({
|
||||||
required this.chatRepository,
|
required this.chatRepository,
|
||||||
required this.wsManager,
|
required this.wsManager,
|
||||||
required this.profileRepository,
|
required this.profileRepository,
|
||||||
|
this.presentIncomingVideoCall,
|
||||||
|
this.dismissIncomingVideoCall,
|
||||||
}) : super(ChatsState()) {
|
}) : super(ChatsState()) {
|
||||||
newLastMessageSubscription = chatRepository.newUserMessagesStream.listen((message) {
|
newLastMessageSubscription = chatRepository.newUserMessagesStream.listen((
|
||||||
|
message,
|
||||||
|
) {
|
||||||
_setNewLastMessage(message);
|
_setNewLastMessage(message);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -34,9 +60,47 @@ class ChatsCubit extends Cubit<ChatsState> {
|
|||||||
Future<void> connectToWebSocket() async {
|
Future<void> connectToWebSocket() async {
|
||||||
try {
|
try {
|
||||||
await newIncomingMessagesSubscription?.cancel();
|
await newIncomingMessagesSubscription?.cancel();
|
||||||
newIncomingMessagesSubscription = wsManager.newMessagesStream?.listen((event) {
|
newIncomingMessagesSubscription = wsManager.newMessagesStream?.listen((
|
||||||
|
event,
|
||||||
|
) {
|
||||||
_handleNewMessageViaSocket(event.data);
|
_handleNewMessageViaSocket(event.data);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await videoCallDeclinedSubscription?.cancel();
|
||||||
|
videoCallDeclinedSubscription = wsManager.videoCallDeclinedStream?.listen(
|
||||||
|
(event) {
|
||||||
|
unawaited(
|
||||||
|
_closeCallFromSocket(
|
||||||
|
VideoCallSocketSignal.tryParseDeclined(event.data),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await videoCallEndedSubscription?.cancel();
|
||||||
|
videoCallEndedSubscription = wsManager.videoCallEndedStream?.listen((
|
||||||
|
event,
|
||||||
|
) {
|
||||||
|
unawaited(
|
||||||
|
_closeCallFromSocket(VideoCallSocketSignal.tryParseEnded(event.data)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await videoCallAcceptedSubscription?.cancel();
|
||||||
|
videoCallAcceptedSubscription = wsManager.videoCallAcceptedStream?.listen(
|
||||||
|
(event) {
|
||||||
|
unawaited(
|
||||||
|
_closeCallFromSocket(
|
||||||
|
VideoCallSocketSignal.tryParseEnded(event.data),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await reconnectedSubscription?.cancel();
|
||||||
|
reconnectedSubscription = wsManager.reconnected.listen((_) {
|
||||||
|
unawaited(getChatRooms());
|
||||||
|
});
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
}
|
}
|
||||||
@@ -47,7 +111,9 @@ class ChatsCubit extends Cubit<ChatsState> {
|
|||||||
_query = query;
|
_query = query;
|
||||||
emit(state.copyWith(state: BaseState.loading));
|
emit(state.copyWith(state: BaseState.loading));
|
||||||
try {
|
try {
|
||||||
final chatRoomsPaginationModel = await chatRepository.getChatRooms(query: query);
|
final chatRoomsPaginationModel = await chatRepository.getChatRooms(
|
||||||
|
query: query,
|
||||||
|
);
|
||||||
final unreadChats = chatRoomsPaginationModel.unreadChatsCount;
|
final unreadChats = chatRoomsPaginationModel.unreadChatsCount;
|
||||||
lastPage = chatRoomsPaginationModel.meta.lastPage + 1;
|
lastPage = chatRoomsPaginationModel.meta.lastPage + 1;
|
||||||
emit(
|
emit(
|
||||||
@@ -60,9 +126,7 @@ class ChatsCubit extends Cubit<ChatsState> {
|
|||||||
page++;
|
page++;
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
emit(
|
emit(state.copyWith(errorMessage: e.toString(), state: BaseState.error));
|
||||||
state.copyWith(errorMessage: e.toString(), state: BaseState.error),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +134,9 @@ class ChatsCubit extends Cubit<ChatsState> {
|
|||||||
page = 1;
|
page = 1;
|
||||||
_query = query;
|
_query = query;
|
||||||
try {
|
try {
|
||||||
final chatRoomsPaginationModel = await chatRepository.getChatRooms(query: query);
|
final chatRoomsPaginationModel = await chatRepository.getChatRooms(
|
||||||
|
query: query,
|
||||||
|
);
|
||||||
final unreadChats = chatRoomsPaginationModel.unreadChatsCount;
|
final unreadChats = chatRoomsPaginationModel.unreadChatsCount;
|
||||||
lastPage = chatRoomsPaginationModel.meta.lastPage + 1;
|
lastPage = chatRoomsPaginationModel.meta.lastPage + 1;
|
||||||
emit(
|
emit(
|
||||||
@@ -84,9 +150,7 @@ class ChatsCubit extends Cubit<ChatsState> {
|
|||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
log(e.toString(), stackTrace: st);
|
log(e.toString(), stackTrace: st);
|
||||||
emit(
|
emit(state.copyWith(errorMessage: e.toString(), state: BaseState.error));
|
||||||
state.copyWith(errorMessage: e.toString(), state: BaseState.error),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +158,10 @@ class ChatsCubit extends Cubit<ChatsState> {
|
|||||||
try {
|
try {
|
||||||
if (state.isLoadingMoreChats || lastPage == page) return;
|
if (state.isLoadingMoreChats || lastPage == page) return;
|
||||||
emit(state.copyWith(isLoadingMoreChats: true));
|
emit(state.copyWith(isLoadingMoreChats: true));
|
||||||
final chatRoomsPaginationModel = await chatRepository.getChatRooms(query: _query, page: page);
|
final chatRoomsPaginationModel = await chatRepository.getChatRooms(
|
||||||
|
query: _query,
|
||||||
|
page: page,
|
||||||
|
);
|
||||||
final unreadChats = chatRoomsPaginationModel.unreadChatsCount;
|
final unreadChats = chatRoomsPaginationModel.unreadChatsCount;
|
||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
@@ -134,15 +201,21 @@ class ChatsCubit extends Cubit<ChatsState> {
|
|||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
chatRooms: updatedChatRooms,
|
chatRooms: updatedChatRooms,
|
||||||
chatsWithUnreadMessagesCount: isChatRead ? state.chatsWithUnreadMessagesCount - 1 : state.chatsWithUnreadMessagesCount,
|
chatsWithUnreadMessagesCount: isChatRead
|
||||||
|
? state.chatsWithUnreadMessagesCount - 1
|
||||||
|
: state.chatsWithUnreadMessagesCount,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleNewMessageViaSocket(dynamic data) async {
|
Future<void> _handleNewMessageViaSocket(dynamic data) async {
|
||||||
try {
|
try {
|
||||||
final json = jsonDecode(data);
|
final json = jsonDecode(data) as Map<String, dynamic>;
|
||||||
final message = ChatMessageModel.fromJson(json['message']);
|
final message = ChatMessageModel.fromJson(json);
|
||||||
|
final callId = _stringField(json, const ['call_id', 'callId']);
|
||||||
|
|
||||||
|
final currentUserId = wsManager.userId;
|
||||||
|
final isOwn = currentUserId != null && message.authorId == currentUserId;
|
||||||
|
|
||||||
final currentChatRooms = [...state.chatRooms];
|
final currentChatRooms = [...state.chatRooms];
|
||||||
|
|
||||||
@@ -151,10 +224,21 @@ class ChatsCubit extends Cubit<ChatsState> {
|
|||||||
final updatedChatRooms = currentChatRooms.map((chatRoom) {
|
final updatedChatRooms = currentChatRooms.map((chatRoom) {
|
||||||
if (chatRoom.id == message.roomId) {
|
if (chatRoom.id == message.roomId) {
|
||||||
wasChanged = true;
|
wasChanged = true;
|
||||||
if (chatRoom.unreadMessagesCount == 0) {
|
// Идемпотентность: повторная доставка того же message.id (реконнект,
|
||||||
|
// доставка по нескольким каналам) не начисляет непрочитанное дважды.
|
||||||
|
final isDuplicate =
|
||||||
|
message.id != 0 && chatRoom.lastMessage?.id == message.id;
|
||||||
|
// Собственное сообщение (второе устройство автора) обновляет
|
||||||
|
// lastMessage, но не создаёт непрочитанное (S3).
|
||||||
|
if (!isOwn && !isDuplicate && chatRoom.unreadMessagesCount == 0) {
|
||||||
willIncrementUnreadChats = true;
|
willIncrementUnreadChats = true;
|
||||||
}
|
}
|
||||||
return chatRoom.copyWith(lastMessage: message, unreadMessagesCount: chatRoom.unreadMessagesCount + 1);
|
return chatRoom.copyWith(
|
||||||
|
lastMessage: message,
|
||||||
|
unreadMessagesCount: isOwn || isDuplicate
|
||||||
|
? chatRoom.unreadMessagesCount
|
||||||
|
: chatRoom.unreadMessagesCount + 1,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return chatRoom;
|
return chatRoom;
|
||||||
}).toList();
|
}).toList();
|
||||||
@@ -165,15 +249,154 @@ class ChatsCubit extends Cubit<ChatsState> {
|
|||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
chatRooms: updatedChatRooms,
|
chatRooms: updatedChatRooms,
|
||||||
chatsWithUnreadMessagesCount: willIncrementUnreadChats ? state.chatsWithUnreadMessagesCount + 1 : state.chatsWithUnreadMessagesCount,
|
chatsWithUnreadMessagesCount: willIncrementUnreadChats
|
||||||
|
? state.chatsWithUnreadMessagesCount + 1
|
||||||
|
: state.chatsWithUnreadMessagesCount,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_presentVideoCallFromMessage(
|
||||||
|
message,
|
||||||
|
isOwn: isOwn,
|
||||||
|
chatRooms: currentChatRooms,
|
||||||
|
callId: callId,
|
||||||
|
);
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _presentVideoCallFromMessage(
|
||||||
|
ChatMessageModel message, {
|
||||||
|
required bool isOwn,
|
||||||
|
required List<ChatRoom> chatRooms,
|
||||||
|
String? callId,
|
||||||
|
}) {
|
||||||
|
final kind = VideoCallChatSignal.kindOf(message);
|
||||||
|
if (kind == VideoCallChatSignalKind.ended) {
|
||||||
|
unawaited(_dismissIncomingVideoCallUi(message.roomId));
|
||||||
|
unawaited(
|
||||||
|
VideoCallEndedPushHandler.endActiveCallIfMatches(
|
||||||
|
conversationUuid: message.roomId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isOwn || kind != VideoCallChatSignalKind.started) return;
|
||||||
|
if (message.id != 0 && !_presentedVideoCallMessageIds.add(message.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final caller = _callerFromRooms(message, chatRooms);
|
||||||
|
final payload = VideoCallChatSignal.incomingPayload(
|
||||||
|
message: message,
|
||||||
|
callerName: caller?.name,
|
||||||
|
callerAvatarUrl: caller?.profilePhotoUrl,
|
||||||
|
callId: callId,
|
||||||
|
);
|
||||||
|
if (payload == null) return;
|
||||||
|
unawaited(_showIncomingVideoCall(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showIncomingVideoCall(VideoCallPushPayload payload) async {
|
||||||
|
try {
|
||||||
|
await (presentIncomingVideoCall ??
|
||||||
|
VideoCallIncomingPushPresenter.showIncomingCall)(payload);
|
||||||
|
} catch (e, st) {
|
||||||
|
log('ChatsCubit: failed to show incoming video call: $e', stackTrace: st);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _closeCallFromSocket(VideoCallSocketSignal? signal) async {
|
||||||
|
if (signal == null) return;
|
||||||
|
if (await _acceptedOnThisDevice(signal)) return;
|
||||||
|
if (signal.endsActiveCall) {
|
||||||
|
final endActive = endActiveVideoCall;
|
||||||
|
if (endActive != null) {
|
||||||
|
await endActive(
|
||||||
|
conversationUuid: signal.conversationUuid,
|
||||||
|
callId: signal.callId,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await VideoCallEndedPushHandler.endActiveCallIfMatches(
|
||||||
|
conversationUuid: signal.conversationUuid,
|
||||||
|
callId: signal.callId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final dismiss = dismissIncomingVideoCall;
|
||||||
|
if (dismiss != null) {
|
||||||
|
await dismiss(signal.conversationUuid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await VideoCallEndedPushHandler.dismissIncomingCallUi(
|
||||||
|
conversationUuid: signal.conversationUuid,
|
||||||
|
callId: signal.callId,
|
||||||
|
reason: signal.reason,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _dismissIncomingVideoCallUi(String? conversationUuid) async {
|
||||||
|
try {
|
||||||
|
final dismiss = dismissIncomingVideoCall;
|
||||||
|
if (dismiss != null) {
|
||||||
|
await dismiss(conversationUuid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await VideoCallEndedPushHandler.dismissIncomingCallUi(
|
||||||
|
conversationUuid: conversationUuid,
|
||||||
|
);
|
||||||
|
} catch (e, st) {
|
||||||
|
log(
|
||||||
|
'ChatsCubit: failed to dismiss incoming video call: $e',
|
||||||
|
stackTrace: st,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `video-call-accepted` also reaches the device that just answered.
|
||||||
|
/// Closing CallKit there hangs up the call that is starting.
|
||||||
|
Future<bool> _acceptedOnThisDevice(VideoCallSocketSignal signal) async {
|
||||||
|
if (signal.reason != VideoCallEndReason.accepted) return false;
|
||||||
|
final acceptedId = signal.acceptedDeviceId?.trim();
|
||||||
|
if (acceptedId == null || acceptedId.isEmpty) return false;
|
||||||
|
try {
|
||||||
|
final current =
|
||||||
|
(await (currentDeviceHwId ?? DeviceHwIdProvider.current)())?.trim();
|
||||||
|
if (current == null || current.isEmpty) return false;
|
||||||
|
return current.toLowerCase() == acceptedId.toLowerCase();
|
||||||
|
} catch (e, st) {
|
||||||
|
log(
|
||||||
|
'ChatsCubit: failed to read hw id for accepted call: $e',
|
||||||
|
stackTrace: st,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _stringField(Map<String, dynamic> json, List<String> keys) {
|
||||||
|
for (final key in keys) {
|
||||||
|
final value = json[key];
|
||||||
|
if (value is String && value.trim().isNotEmpty) return value.trim();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
ChatUser? _callerFromRooms(
|
||||||
|
ChatMessageModel message,
|
||||||
|
List<ChatRoom> chatRooms,
|
||||||
|
) {
|
||||||
|
for (final room in chatRooms) {
|
||||||
|
if (room.id != message.roomId) continue;
|
||||||
|
final from = room.users.fromChatUser;
|
||||||
|
final to = room.users.toChatUser;
|
||||||
|
if (from.id == message.authorId) return from;
|
||||||
|
if (to.id == message.authorId) return to;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
void _setNewLastMessage(ChatMessageModel message) {
|
void _setNewLastMessage(ChatMessageModel message) {
|
||||||
final chats = state.chatRooms.map((chat) {
|
final chats = state.chatRooms.map((chat) {
|
||||||
if (chat.id == message.roomId) {
|
if (chat.id == message.roomId) {
|
||||||
@@ -188,6 +411,10 @@ class ChatsCubit extends Cubit<ChatsState> {
|
|||||||
@override
|
@override
|
||||||
Future<void> close() {
|
Future<void> close() {
|
||||||
newIncomingMessagesSubscription?.cancel();
|
newIncomingMessagesSubscription?.cancel();
|
||||||
|
videoCallDeclinedSubscription?.cancel();
|
||||||
|
videoCallEndedSubscription?.cancel();
|
||||||
|
videoCallAcceptedSubscription?.cancel();
|
||||||
|
reconnectedSubscription?.cancel();
|
||||||
return super.close();
|
return super.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
|||||||
import 'package:eatvio_app/features/order_info/presentation/info_by_order_screen.dart';
|
import 'package:eatvio_app/features/order_info/presentation/info_by_order_screen.dart';
|
||||||
import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart';
|
import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart';
|
||||||
import 'package:eatvio_app/features/user_details/presentation/user_details_screen.dart';
|
import 'package:eatvio_app/features/user_details/presentation/user_details_screen.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart';
|
||||||
import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart';
|
import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart';
|
||||||
import 'package:eatvio_app/generated/l10n.dart';
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
@@ -46,7 +47,12 @@ class ChatScreen extends StatefulWidget {
|
|||||||
final String? orderId;
|
final String? orderId;
|
||||||
final bool? sendInitialMessage;
|
final bool? sendInitialMessage;
|
||||||
|
|
||||||
const ChatScreen({this.uuid, this.orderId, super.key, required this.sendInitialMessage});
|
const ChatScreen({
|
||||||
|
this.uuid,
|
||||||
|
this.orderId,
|
||||||
|
super.key,
|
||||||
|
required this.sendInitialMessage,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ChatScreen> createState() => _ChatScreenState();
|
State<ChatScreen> createState() => _ChatScreenState();
|
||||||
@@ -56,9 +62,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
final scrollController = ScrollController();
|
final scrollController = ScrollController();
|
||||||
final TextEditingController textEditingController = TextEditingController();
|
final TextEditingController textEditingController = TextEditingController();
|
||||||
final ChatRoomCubit chatRoomCubit = sl<ChatRoomCubit>();
|
final ChatRoomCubit chatRoomCubit = sl<ChatRoomCubit>();
|
||||||
final List<({int id, int index})> unreadMessagesIds = [];
|
final List<int> unreadMessageIds = [];
|
||||||
final List<({int id, int index})> unreadMessagesIdsCopy = [];
|
final showableChatBadgeStates =
|
||||||
final showableChatBadgeStates = ChosenPlanStateEnum.getShowableChatBadgeStates();
|
ChosenPlanStateEnum.getShowableChatBadgeStates();
|
||||||
late AppLifecycleListener _listener;
|
late AppLifecycleListener _listener;
|
||||||
List<File> pickedFiles = [];
|
List<File> pickedFiles = [];
|
||||||
|
|
||||||
@@ -109,13 +115,15 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
void _onScroll() {
|
void _onScroll() {
|
||||||
final uuid = conversationUuid;
|
final uuid = conversationUuid;
|
||||||
if (uuid == null) return;
|
if (uuid == null) return;
|
||||||
if (scrollController.offset >= scrollController.position.maxScrollExtent && !scrollController.position.outOfRange) {
|
if (scrollController.offset >= scrollController.position.maxScrollExtent &&
|
||||||
|
!scrollController.position.outOfRange) {
|
||||||
chatRoomCubit.loadNextPage(uuid);
|
chatRoomCubit.loadNextPage(uuid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
EasyDebounce.cancel('mark_as_read');
|
||||||
scrollController.dispose();
|
scrollController.dispose();
|
||||||
_listener.dispose();
|
_listener.dispose();
|
||||||
chatRoomCubit.close();
|
chatRoomCubit.close();
|
||||||
@@ -168,9 +176,13 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Scaffold _buildChat(BuildContext context, ChatRoomStateLoaded state) {
|
Scaffold _buildChat(BuildContext context, ChatRoomStateLoaded state) {
|
||||||
final chatCounterpart = resolveChatCounterpart(users: state.chatRoom.users, currentUserId: sl<WsManager>().userId);
|
final chatCounterpart = resolveChatCounterpart(
|
||||||
|
users: state.chatRoom.users,
|
||||||
|
currentUserId: sl<WsManager>().userId,
|
||||||
|
);
|
||||||
final chosenPlan = state.chatRoom.chosenPlan;
|
final chosenPlan = state.chatRoom.chosenPlan;
|
||||||
final amIConsultant = chosenPlan?.consultantId == state.chatRoom.users.sender.id;
|
final amIConsultant =
|
||||||
|
chosenPlan?.consultantId == state.chatRoom.users.sender.id;
|
||||||
final showUserDetailsButton = [
|
final showUserDetailsButton = [
|
||||||
ChosenPlanStateEnum.stateStartDateHasBeenSet,
|
ChosenPlanStateEnum.stateStartDateHasBeenSet,
|
||||||
ChosenPlanStateEnum.statePendingPayment,
|
ChosenPlanStateEnum.statePendingPayment,
|
||||||
@@ -186,22 +198,37 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final showAboutOrderButton =
|
final showAboutOrderButton =
|
||||||
state.chatRoom.chosenPlan != null && state.chatRoom.chosenPlan!.chosenPlanState != ChosenPlanStateEnum.stateCanceledByTheInitiator;
|
state.chatRoom.chosenPlan != null &&
|
||||||
|
state.chatRoom.chosenPlan!.chosenPlanState !=
|
||||||
|
ChosenPlanStateEnum.stateCanceledByTheInitiator;
|
||||||
BannerType? bannerType;
|
BannerType? bannerType;
|
||||||
if (chosenPlan == null || chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateCanceledByTheInitiator) {
|
if (chosenPlan == null ||
|
||||||
|
chosenPlan.chosenPlanState ==
|
||||||
|
ChosenPlanStateEnum.stateCanceledByTheInitiator) {
|
||||||
bannerType = null;
|
bannerType = null;
|
||||||
} else if (amIConsultant && !state.hasCard && chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateInCoordination) {
|
} else if (amIConsultant &&
|
||||||
|
!state.hasCard &&
|
||||||
|
chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateInCoordination) {
|
||||||
bannerType = BannerType.card;
|
bannerType = BannerType.card;
|
||||||
} else if (amIConsultant && chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateInCoordination) {
|
} else if (amIConsultant &&
|
||||||
|
chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateInCoordination) {
|
||||||
bannerType = BannerType.date;
|
bannerType = BannerType.date;
|
||||||
} else if (!amIConsultant &&
|
} else if (!amIConsultant &&
|
||||||
(chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateStartDateHasBeenSet || chosenPlan.chosenPlanState == ChosenPlanStateEnum.statePendingPayment)) {
|
(chosenPlan.chosenPlanState ==
|
||||||
|
ChosenPlanStateEnum.stateStartDateHasBeenSet ||
|
||||||
|
chosenPlan.chosenPlanState ==
|
||||||
|
ChosenPlanStateEnum.statePendingPayment)) {
|
||||||
bannerType = BannerType.payment;
|
bannerType = BannerType.payment;
|
||||||
} else if (chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateDone && amIConsultant) {
|
} else if (chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateDone &&
|
||||||
|
amIConsultant) {
|
||||||
bannerType = BannerType.collectCharge;
|
bannerType = BannerType.collectCharge;
|
||||||
} else if (chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateLastDayFinished && !amIConsultant) {
|
} else if (chosenPlan.chosenPlanState ==
|
||||||
|
ChosenPlanStateEnum.stateLastDayFinished &&
|
||||||
|
!amIConsultant) {
|
||||||
bannerType = BannerType.finishPlan;
|
bannerType = BannerType.finishPlan;
|
||||||
} else if (chosenOption?.hasPersonalDiet == true && amIConsultant && chosenPlan.chosenPlanState != ChosenPlanStateEnum.stateDone) {
|
} else if (chosenOption?.hasPersonalDiet == true &&
|
||||||
|
amIConsultant &&
|
||||||
|
chosenPlan.chosenPlanState != ChosenPlanStateEnum.stateDone) {
|
||||||
bannerType = BannerType.setPersonalDiet;
|
bannerType = BannerType.setPersonalDiet;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,25 +250,40 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
|
|
||||||
title: GestureDetector(
|
title: GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
GoRouter.of(context).pushNamed('profileScreen', extra: state.chatRoom.users.receiver.slug);
|
GoRouter.of(context).pushNamed(
|
||||||
|
'profileScreen',
|
||||||
|
extra: state.chatRoom.users.receiver.slug,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
if (state.chatRoom.users.receiver.profilePhotoUrl != null)
|
if (state.chatRoom.users.receiver.profilePhotoUrl != null)
|
||||||
CircleAvatar(
|
CircleAvatar(
|
||||||
radius: 20,
|
radius: 20,
|
||||||
foregroundImage: NetworkImage(state.chatRoom.users.receiver.profilePhotoUrl!),
|
foregroundImage: NetworkImage(
|
||||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
state.chatRoom.users.receiver.profilePhotoUrl!,
|
||||||
|
),
|
||||||
|
backgroundImage: const AssetImage(
|
||||||
|
'assets/images/no_avatar.png',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (state.chatRoom.users.receiver.profilePhotoUrl == null)
|
if (state.chatRoom.users.receiver.profilePhotoUrl == null)
|
||||||
Container(
|
Container(
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
decoration: const BoxDecoration(color: Color(0xFFEBF4FF), shape: BoxShape.circle),
|
decoration: const BoxDecoration(
|
||||||
|
color: Color(0xFFEBF4FF),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
state.chatRoom.users.receiver.name.split(' ').first[0] + state.chatRoom.users.receiver.name.split(' ')[1][0],
|
state.chatRoom.users.receiver.name.split(' ').first[0] +
|
||||||
style: const TextStyle(color: Color(0xFF7F9CF5), fontWeight: FontWeight.w400, fontSize: 20),
|
state.chatRoom.users.receiver.name.split(' ')[1][0],
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Color(0xFF7F9CF5),
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
fontSize: 20,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
@@ -253,10 +295,17 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
state.chatRoom.users.receiver.name,
|
state.chatRoom.users.receiver.name,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: CustomTextStyles.titleSmallBold.copyWith(color: PrimaryColors().black),
|
style: CustomTextStyles.titleSmallBold.copyWith(
|
||||||
|
color: PrimaryColors().black,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
AnimatedCrossFade(
|
AnimatedCrossFade(
|
||||||
firstChild: Text('Печатает...', maxLines: 1, overflow: TextOverflow.ellipsis, style: CustomTextStyles.bodySmallMulishPrimary),
|
firstChild: Text(
|
||||||
|
'Печатает...',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: CustomTextStyles.bodySmallMulishPrimary,
|
||||||
|
),
|
||||||
firstCurve: Curves.easeIn,
|
firstCurve: Curves.easeIn,
|
||||||
secondCurve: Curves.easeOut,
|
secondCurve: Curves.easeOut,
|
||||||
secondChild: ChatUserOnlineStatusLine(
|
secondChild: ChatUserOnlineStatusLine(
|
||||||
@@ -265,7 +314,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
style: CustomTextStyles.bodySmallMulishPrimary,
|
style: CustomTextStyles.bodySmallMulishPrimary,
|
||||||
showOnlineDot: false,
|
showOnlineDot: false,
|
||||||
),
|
),
|
||||||
crossFadeState: state.isTyping ? CrossFadeState.showFirst : CrossFadeState.showSecond,
|
crossFadeState: state.isTyping
|
||||||
|
? CrossFadeState.showFirst
|
||||||
|
: CrossFadeState.showSecond,
|
||||||
duration: const Duration(milliseconds: 150),
|
duration: const Duration(milliseconds: 150),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -277,7 +328,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
actions: [
|
actions: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsetsDirectional.only(end: 8),
|
padding: const EdgeInsetsDirectional.only(end: 8),
|
||||||
child: _ChatHeaderVideoCallButton(onPressed: () => _openVideoCall(context, state, chatCounterpart)),
|
child: _ChatHeaderVideoCallButton(
|
||||||
|
onPressed: () => _openVideoCall(context, state, chatCounterpart),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -291,44 +344,133 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
child: Container(
|
child: Container(
|
||||||
color: appTheme.whiteA700,
|
color: appTheme.whiteA700,
|
||||||
width: double.maxFinite,
|
width: double.maxFinite,
|
||||||
child: SingleChildScrollView(
|
child: RefreshIndicator(
|
||||||
physics: const BouncingScrollPhysics(),
|
onRefresh: chatRoomCubit.refreshChat,
|
||||||
controller: scrollController,
|
child: SingleChildScrollView(
|
||||||
reverse: true,
|
physics: const AlwaysScrollableScrollPhysics(
|
||||||
child: Column(
|
parent: BouncingScrollPhysics(),
|
||||||
children: [
|
),
|
||||||
const SizedBox(height: 16),
|
controller: scrollController,
|
||||||
ListView.builder(
|
reverse: true,
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
child: Column(
|
||||||
reverse: true,
|
children: [
|
||||||
shrinkWrap: true,
|
const SizedBox(height: 16),
|
||||||
itemBuilder: (context, index) {
|
ListView.builder(
|
||||||
if (index == state.chatMessages.length) {
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
return const Padding(padding: EdgeInsets.all(8), child: Loader());
|
reverse: true,
|
||||||
}
|
shrinkWrap: true,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
if (index == state.chatMessages.length) {
|
||||||
|
return const Padding(
|
||||||
|
padding: EdgeInsets.all(8),
|
||||||
|
child: Loader(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if ((index + 1 != state.chatMessages.length &&
|
if ((index + 1 != state.chatMessages.length &&
|
||||||
state.chatMessages[index].updatedAt.day != state.chatMessages[index + 1].updatedAt.day) ||
|
state
|
||||||
index + 1 == state.chatMessages.length) {
|
.chatMessages[index]
|
||||||
final isCurrentUserAuthor = state.chatRoom.users.receiver.id != state.chatMessages[index].authorId;
|
.updatedAt
|
||||||
|
.day !=
|
||||||
|
state
|
||||||
|
.chatMessages[index + 1]
|
||||||
|
.updatedAt
|
||||||
|
.day) ||
|
||||||
|
index + 1 == state.chatMessages.length) {
|
||||||
|
final isCurrentUserAuthor =
|
||||||
|
state.chatRoom.users.receiver.id !=
|
||||||
|
state.chatMessages[index].authorId;
|
||||||
|
final message = state.chatMessages[index];
|
||||||
|
final isPlanOrderMessage =
|
||||||
|
message.type == ChatMessageType.planOrder;
|
||||||
|
|
||||||
|
PacketOptions? messageOption;
|
||||||
|
if (isPlanOrderMessage &&
|
||||||
|
chosenPlan != null) {
|
||||||
|
if (chosenPlan.optionType == 'options1') {
|
||||||
|
messageOption =
|
||||||
|
chosenPlan.plan.packet1Options;
|
||||||
|
} else if (chosenPlan.optionType ==
|
||||||
|
'options2') {
|
||||||
|
messageOption =
|
||||||
|
chosenPlan.plan.packet2Options;
|
||||||
|
} else if (chosenPlan.optionType ==
|
||||||
|
'options3') {
|
||||||
|
messageOption =
|
||||||
|
chosenPlan.plan.packet3Options;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
_messageDate(message.updatedAt),
|
||||||
|
if (isPlanOrderMessage &&
|
||||||
|
chosenPlan != null &&
|
||||||
|
messageOption != null)
|
||||||
|
PlanOrderMessageWidget(
|
||||||
|
planName: chosenPlan.plan.name,
|
||||||
|
planSlug: chosenPlan.plan.slug,
|
||||||
|
price: messageOption.price,
|
||||||
|
sendTime: message.updatedAt,
|
||||||
|
isCurrentUser: isCurrentUserAuthor,
|
||||||
|
),
|
||||||
|
VisibilityDetector(
|
||||||
|
key: ValueKey(
|
||||||
|
'msg-${message.uuid ?? message.id}',
|
||||||
|
),
|
||||||
|
onVisibilityChanged:
|
||||||
|
(VisibilityInfo info) {
|
||||||
|
_queueMarkAsRead(
|
||||||
|
context: context,
|
||||||
|
chatId: state.chatRoom.id,
|
||||||
|
message: message,
|
||||||
|
isCurrentUserAuthor:
|
||||||
|
isCurrentUserAuthor,
|
||||||
|
visibleFraction:
|
||||||
|
info.visibleFraction,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: _buildMessageBubble(
|
||||||
|
context: context,
|
||||||
|
state: state,
|
||||||
|
chatCounterpart: chatCounterpart,
|
||||||
|
isCurrentUserAuthor:
|
||||||
|
isCurrentUserAuthor,
|
||||||
|
message: message,
|
||||||
|
amIConsultant: amIConsultant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final isCurrentUserAuthor =
|
||||||
|
state.chatRoom.users.receiver.id !=
|
||||||
|
state.chatMessages[index].authorId;
|
||||||
final message = state.chatMessages[index];
|
final message = state.chatMessages[index];
|
||||||
final isPlanOrderMessage = message.type == ChatMessageType.planOrder;
|
final isPlanOrderMessage =
|
||||||
|
message.type == ChatMessageType.planOrder;
|
||||||
|
|
||||||
PacketOptions? messageOption;
|
PacketOptions? messageOption;
|
||||||
if (isPlanOrderMessage && chosenPlan != null) {
|
if (isPlanOrderMessage && chosenPlan != null) {
|
||||||
if (chosenPlan.optionType == 'options1') {
|
if (chosenPlan.optionType == 'options1') {
|
||||||
messageOption = chosenPlan.plan.packet1Options;
|
messageOption =
|
||||||
} else if (chosenPlan.optionType == 'options2') {
|
chosenPlan.plan.packet1Options;
|
||||||
messageOption = chosenPlan.plan.packet2Options;
|
} else if (chosenPlan.optionType ==
|
||||||
} else if (chosenPlan.optionType == 'options3') {
|
'options2') {
|
||||||
messageOption = chosenPlan.plan.packet3Options;
|
messageOption =
|
||||||
|
chosenPlan.plan.packet2Options;
|
||||||
|
} else if (chosenPlan.optionType ==
|
||||||
|
'options3') {
|
||||||
|
messageOption =
|
||||||
|
chosenPlan.plan.packet3Options;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
_messageDate(message.updatedAt),
|
if (isPlanOrderMessage &&
|
||||||
if (isPlanOrderMessage && chosenPlan != null && messageOption != null)
|
chosenPlan != null &&
|
||||||
|
messageOption != null)
|
||||||
PlanOrderMessageWidget(
|
PlanOrderMessageWidget(
|
||||||
planName: chosenPlan.plan.name,
|
planName: chosenPlan.plan.name,
|
||||||
planSlug: chosenPlan.plan.slug,
|
planSlug: chosenPlan.plan.slug,
|
||||||
@@ -337,126 +479,94 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
isCurrentUser: isCurrentUserAuthor,
|
isCurrentUser: isCurrentUserAuthor,
|
||||||
),
|
),
|
||||||
VisibilityDetector(
|
VisibilityDetector(
|
||||||
key: Key(message.id.toString()),
|
key: ValueKey(
|
||||||
onVisibilityChanged: (VisibilityInfo info) {
|
'msg-${message.uuid ?? message.id}',
|
||||||
if (!message.isRead && !isCurrentUserAuthor && info.visibleFraction > 0) {
|
),
|
||||||
unreadMessagesIdsCopy.add((id: message.id, index: index));
|
onVisibilityChanged:
|
||||||
EasyDebounce.debounce('mark_as_read', const Duration(milliseconds: 500), () async {
|
(VisibilityInfo info) {
|
||||||
final result = await context.read<ChatRoomCubit>().markMessagesAsRead(state.chatRoom.id, unreadMessagesIdsCopy);
|
_queueMarkAsRead(
|
||||||
if (!result) return;
|
context: context,
|
||||||
if (!context.mounted) return;
|
|
||||||
context.read<ChatsCubit>().reduceUnreadMessagesCount(
|
|
||||||
chatId: state.chatRoom.id,
|
chatId: state.chatRoom.id,
|
||||||
count: unreadMessagesIdsCopy.length,
|
message: message,
|
||||||
|
isCurrentUserAuthor:
|
||||||
|
isCurrentUserAuthor,
|
||||||
|
visibleFraction:
|
||||||
|
info.visibleFraction,
|
||||||
);
|
);
|
||||||
unreadMessagesIdsCopy.clear();
|
},
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: _buildMessageBubble(
|
child: _buildMessageBubble(
|
||||||
context: context,
|
context: context,
|
||||||
state: state,
|
state: state,
|
||||||
chatCounterpart: chatCounterpart,
|
chatCounterpart: chatCounterpart,
|
||||||
isCurrentUserAuthor: isCurrentUserAuthor,
|
isCurrentUserAuthor:
|
||||||
|
isCurrentUserAuthor,
|
||||||
message: message,
|
message: message,
|
||||||
amIConsultant: amIConsultant,
|
amIConsultant: amIConsultant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
final isCurrentUserAuthor = state.chatRoom.users.receiver.id != state.chatMessages[index].authorId;
|
itemCount:
|
||||||
final message = state.chatMessages[index];
|
state.chatMessages.length +
|
||||||
final isPlanOrderMessage = message.type == ChatMessageType.planOrder;
|
(state.isLoading ? 1 : 0),
|
||||||
|
),
|
||||||
PacketOptions? messageOption;
|
const SizedBox(height: 16),
|
||||||
if (isPlanOrderMessage && chosenPlan != null) {
|
if (showUserDetailsButton) ...[
|
||||||
if (chosenPlan.optionType == 'options1') {
|
// OrderCard(
|
||||||
messageOption = chosenPlan.plan.packet1Options;
|
// plan: chosenPlan.plan,
|
||||||
} else if (chosenPlan.optionType == 'options2') {
|
// isCustomerView: !amIConsultant,
|
||||||
messageOption = chosenPlan.plan.packet2Options;
|
// optionType: chosenPlan.optionType,
|
||||||
} else if (chosenPlan.optionType == 'options3') {
|
// onCancel: () async {
|
||||||
messageOption = chosenPlan.plan.packet3Options;
|
// await context.read<ChatRoomCubit>().cancelChosenPlan(chatId: state.chatRoom.id, planId: chosenPlan.id);
|
||||||
}
|
// },
|
||||||
}
|
// ),
|
||||||
|
if (amIConsultant)
|
||||||
return Column(
|
Padding(
|
||||||
children: [
|
padding: const EdgeInsets.symmetric(
|
||||||
if (isPlanOrderMessage && chosenPlan != null && messageOption != null)
|
vertical: 12,
|
||||||
PlanOrderMessageWidget(
|
horizontal: 16,
|
||||||
planName: chosenPlan.plan.name,
|
),
|
||||||
planSlug: chosenPlan.plan.slug,
|
child: CustomOutlinedButton(
|
||||||
price: messageOption.price,
|
text: S.current.user_info,
|
||||||
sendTime: message.updatedAt,
|
height: 52,
|
||||||
isCurrentUser: isCurrentUserAuthor,
|
buttonTextStyle: TextStyles.textMSemibold
|
||||||
|
.copyWith(color: appTheme.whiteA700),
|
||||||
|
buttonStyle: ButtonStyle(
|
||||||
|
side: const WidgetStatePropertyAll(
|
||||||
|
BorderSide.none,
|
||||||
|
),
|
||||||
|
backgroundColor: WidgetStatePropertyAll(
|
||||||
|
appTheme.blueA400,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
VisibilityDetector(
|
onPressed: () async {
|
||||||
key: Key(message.id.toString()),
|
GoRouter.of(context).push(
|
||||||
onVisibilityChanged: (VisibilityInfo info) {
|
UserDetailsScreen.path,
|
||||||
if (!message.isRead && !isCurrentUserAuthor && info.visibleFraction > 0) {
|
extra:
|
||||||
unreadMessagesIds.add((id: message.id, index: index));
|
state.chatRoom.users.receiver.slug,
|
||||||
EasyDebounce.debounce('mark_as_read', const Duration(milliseconds: 500), () async {
|
);
|
||||||
final result = await context.read<ChatRoomCubit>().markMessagesAsRead(state.chatRoom.id, unreadMessagesIds);
|
|
||||||
if (!result) return;
|
|
||||||
if (!context.mounted) return;
|
|
||||||
context.read<ChatsCubit>().reduceUnreadMessagesCount(chatId: state.chatRoom.id, count: unreadMessagesIds.length);
|
|
||||||
unreadMessagesIds.clear();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
child: _buildMessageBubble(
|
|
||||||
context: context,
|
|
||||||
state: state,
|
|
||||||
chatCounterpart: chatCounterpart,
|
|
||||||
isCurrentUserAuthor: isCurrentUserAuthor,
|
|
||||||
message: message,
|
|
||||||
amIConsultant: amIConsultant,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
);
|
],
|
||||||
},
|
if (bannerType != null)
|
||||||
itemCount: state.chatMessages.length + (state.isLoading ? 1 : 0),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
if (showUserDetailsButton) ...[
|
|
||||||
// OrderCard(
|
|
||||||
// plan: chosenPlan.plan,
|
|
||||||
// isCustomerView: !amIConsultant,
|
|
||||||
// optionType: chosenPlan.optionType,
|
|
||||||
// onCancel: () async {
|
|
||||||
// await context.read<ChatRoomCubit>().cancelChosenPlan(chatId: state.chatRoom.id, planId: chosenPlan.id);
|
|
||||||
// },
|
|
||||||
// ),
|
|
||||||
if (amIConsultant)
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
padding: const EdgeInsets.symmetric(
|
||||||
child: CustomOutlinedButton(
|
vertical: 12,
|
||||||
text: S.current.user_info,
|
horizontal: 16,
|
||||||
height: 52,
|
),
|
||||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
child: NotificationBanner(
|
||||||
buttonStyle: ButtonStyle(
|
bannerType: bannerType,
|
||||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
chosenPlanId: chosenPlan?.id,
|
||||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
planName: chosenPlan?.plan.name,
|
||||||
),
|
planPrice: chosenOption?.price.toDouble(),
|
||||||
onPressed: () async {
|
hasPersonalDiet:
|
||||||
GoRouter.of(context).push(UserDetailsScreen.path, extra: state.chatRoom.users.receiver.slug);
|
chosenOption?.hasPersonalDiet ?? false,
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (bannerType != null)
|
),
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
|
||||||
child: NotificationBanner(
|
|
||||||
bannerType: bannerType,
|
|
||||||
chosenPlanId: chosenPlan?.id,
|
|
||||||
planName: chosenPlan?.plan.name,
|
|
||||||
planPrice: chosenOption?.price.toDouble(),
|
|
||||||
hasPersonalDiet: chosenOption?.hasPersonalDiet ?? false,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -481,19 +591,40 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
_isImageFile(pickedFiles[i].path)
|
_isImageFile(pickedFiles[i].path)
|
||||||
? GestureDetector(
|
? GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
final imageFiles = pickedFiles.where((f) => _isImageFile(f.path)).toList();
|
final imageFiles = pickedFiles
|
||||||
final imageIndex = _imageIndexAt(pickedFiles, i);
|
.where(
|
||||||
|
(f) => _isImageFile(f.path),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
final imageIndex = _imageIndexAt(
|
||||||
|
pickedFiles,
|
||||||
|
i,
|
||||||
|
);
|
||||||
|
|
||||||
context.pushTransparentRoute(
|
context.pushTransparentRoute(
|
||||||
PhotoViewScreen(imageUrls: imageFiles.map((e) => e.path).toList(), isLocal: true, index: imageIndex),
|
PhotoViewScreen(
|
||||||
|
imageUrls: imageFiles
|
||||||
|
.map((e) => e.path)
|
||||||
|
.toList(),
|
||||||
|
isLocal: true,
|
||||||
|
index: imageIndex,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: Hero(
|
child: Hero(
|
||||||
tag: pickedFiles[i].path,
|
tag: pickedFiles[i].path,
|
||||||
transitionOnUserGestures: true,
|
transitionOnUserGestures: true,
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius:
|
||||||
child: SizedBox(height: 104, width: 104, child: Image.file(pickedFiles[i], fit: BoxFit.cover)),
|
BorderRadius.circular(8),
|
||||||
|
child: SizedBox(
|
||||||
|
height: 104,
|
||||||
|
width: 104,
|
||||||
|
child: Image.file(
|
||||||
|
pickedFiles[i],
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -510,8 +641,15 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(4),
|
padding: const EdgeInsets.all(4),
|
||||||
decoration: BoxDecoration(color: appTheme.gray50, shape: BoxShape.circle),
|
decoration: BoxDecoration(
|
||||||
child: const Icon(size: 20, Icons.close, color: Colors.black),
|
color: appTheme.gray50,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
size: 20,
|
||||||
|
Icons.close,
|
||||||
|
color: Colors.black,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -528,14 +666,20 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
children: [
|
children: [
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
InkWell(
|
InkWell(
|
||||||
onTap: () => showImagePicker(state.chatRoom.users.receiver.slug),
|
onTap: () => showImagePicker(
|
||||||
|
state.chatRoom.users.receiver.slug,
|
||||||
|
),
|
||||||
customBorder: const CircleBorder(),
|
customBorder: const CircleBorder(),
|
||||||
child: Ink(
|
child: Ink(
|
||||||
height: 40,
|
height: 40,
|
||||||
width: 40,
|
width: 40,
|
||||||
child: Transform.rotate(
|
child: Transform.rotate(
|
||||||
angle: pi / 4,
|
angle: pi / 4,
|
||||||
child: const Icon(Icons.attach_file_rounded, size: 30, color: Color(0xFF858E99)),
|
child: const Icon(
|
||||||
|
Icons.attach_file_rounded,
|
||||||
|
size: 30,
|
||||||
|
color: Color(0xFF858E99),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -547,24 +691,46 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
maxLines: 3,
|
maxLines: 3,
|
||||||
minLines: 1,
|
minLines: 1,
|
||||||
keyboardType: TextInputType.multiline,
|
keyboardType: TextInputType.multiline,
|
||||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, color: Colors.black, height: 1),
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
color: Colors.black,
|
||||||
|
height: 1,
|
||||||
|
),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
isDense: true,
|
isDense: true,
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 10,
|
||||||
|
),
|
||||||
hintText: 'Сообщение',
|
hintText: 'Сообщение',
|
||||||
hintStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w400, height: 1, color: Color(0xFFAEAEB2)),
|
hintStyle: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
height: 1,
|
||||||
|
color: Color(0xFFAEAEB2),
|
||||||
|
),
|
||||||
filled: true,
|
filled: true,
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(100),
|
borderRadius: BorderRadius.circular(100),
|
||||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
borderSide: const BorderSide(
|
||||||
|
color: Color(0xFFD1D1D6),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(100),
|
borderRadius: BorderRadius.circular(100),
|
||||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
borderSide: const BorderSide(
|
||||||
|
color: Color(0xFFD1D1D6),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(100),
|
borderRadius: BorderRadius.circular(100),
|
||||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
borderSide: const BorderSide(
|
||||||
|
color: Color(0xFFD1D1D6),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -579,12 +745,18 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
final blockedState = blockedUsersCubit.state;
|
final blockedState = blockedUsersCubit.state;
|
||||||
|
|
||||||
if (blockedState is BlockedUsersStateLoaded) {
|
if (blockedState is BlockedUsersStateLoaded) {
|
||||||
final isBlocked = blockedState.profilesList.any((profile) => profile.slug == state.chatRoom.users.receiver.slug);
|
final isBlocked = blockedState.profilesList.any(
|
||||||
|
(profile) =>
|
||||||
|
profile.slug ==
|
||||||
|
state.chatRoom.users.receiver.slug,
|
||||||
|
);
|
||||||
if (isBlocked) {
|
if (isBlocked) {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
content: Text('Вы не можете отправить сообщение заблокированному пользователю'),
|
content: Text(
|
||||||
|
'Вы не можете отправить сообщение заблокированному пользователю',
|
||||||
|
),
|
||||||
backgroundColor: Colors.red,
|
backgroundColor: Colors.red,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -593,10 +765,15 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (textEditingController.text.isNotEmpty || pickedFiles.isNotEmpty) {
|
if (textEditingController.text.isNotEmpty ||
|
||||||
|
pickedFiles.isNotEmpty) {
|
||||||
chatRoomCubit.sendMessage(
|
chatRoomCubit.sendMessage(
|
||||||
data: textEditingController.text.isEmpty ? null : textEditingController.text.trim(),
|
data: textEditingController.text.isEmpty
|
||||||
files: pickedFiles.isEmpty ? null : pickedFiles,
|
? null
|
||||||
|
: textEditingController.text.trim(),
|
||||||
|
files: pickedFiles.isEmpty
|
||||||
|
? null
|
||||||
|
: pickedFiles,
|
||||||
userSlug: state.chatRoom.users.receiver.slug,
|
userSlug: state.chatRoom.users.receiver.slug,
|
||||||
);
|
);
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -606,10 +783,17 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Ink(
|
child: Ink(
|
||||||
decoration: BoxDecoration(color: appTheme.blueA400, shape: BoxShape.circle),
|
decoration: BoxDecoration(
|
||||||
|
color: appTheme.blueA400,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
height: 40,
|
height: 40,
|
||||||
width: 40,
|
width: 40,
|
||||||
child: const Icon(Icons.arrow_upward_rounded, size: 24, color: Colors.white),
|
child: const Icon(
|
||||||
|
Icons.arrow_upward_rounded,
|
||||||
|
size: 24,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
@@ -628,12 +812,19 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
child: AboutOrderButton(
|
child: AboutOrderButton(
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
if (amIConsultant) {
|
if (amIConsultant) {
|
||||||
GoRouter.of(
|
GoRouter.of(context).push(
|
||||||
context,
|
InfoByOrderScreen.path,
|
||||||
).push(InfoByOrderScreen.path, extra: {'planId': state.chatRoom.chosenPlan?.id, 'customerSlug': state.chatRoom.users.receiver.slug});
|
extra: {
|
||||||
|
'planId': state.chatRoom.chosenPlan?.id,
|
||||||
|
'customerSlug': state.chatRoom.users.receiver.slug,
|
||||||
|
},
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
GoRouter.of(context).push(InfoByOrderScreen.path, extra: {'planId': state.chatRoom.chosenPlan?.id});
|
GoRouter.of(context).push(
|
||||||
|
InfoByOrderScreen.path,
|
||||||
|
extra: {'planId': state.chatRoom.chosenPlan?.id},
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -658,7 +849,13 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
const Icon(Icons.attach_file, size: 32, color: Colors.black54),
|
const Icon(Icons.attach_file, size: 32, color: Colors.black54),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
child: Text(fileName, textAlign: TextAlign.center, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12)),
|
child: Text(
|
||||||
|
fileName,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -706,7 +903,13 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
// await chatRoomCubit.sendMessage(files: [File(image.path)], userSlug: slug);
|
// await chatRoomCubit.sendMessage(files: [File(image.path)], userSlug: slug);
|
||||||
// }
|
// }
|
||||||
},
|
},
|
||||||
child: const Row(children: [Icon(Icons.photo_camera), SizedBox(width: 8), Text('Фото')]),
|
child: const Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.photo_camera),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Фото'),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
CupertinoActionSheetAction(
|
CupertinoActionSheetAction(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
@@ -715,13 +918,21 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
List<XFile> images = await picker.pickMultiImage();
|
List<XFile> images = await picker.pickMultiImage();
|
||||||
if (images != []) {
|
if (images != []) {
|
||||||
setState(() {
|
setState(() {
|
||||||
pickedFiles.addAll(images.map((image) => File(image.path)).toList());
|
pickedFiles.addAll(
|
||||||
|
images.map((image) => File(image.path)).toList(),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
// List<File> files = images.map((image) => File(image.path)).toList();
|
// List<File> files = images.map((image) => File(image.path)).toList();
|
||||||
// await chatRoomCubit.sendMessage(files: files, userSlug: slug);
|
// await chatRoomCubit.sendMessage(files: files, userSlug: slug);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: const Row(children: [Icon(Icons.photo_library_rounded), SizedBox(width: 8), Text('Галерея')]),
|
child: const Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.photo_library_rounded),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Галерея'),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
CupertinoActionSheetAction(
|
CupertinoActionSheetAction(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
@@ -753,15 +964,27 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
if (result != null) {
|
if (result != null) {
|
||||||
// List<File> files = result.paths.map((path) => File(path!)).toList();
|
// List<File> files = result.paths.map((path) => File(path!)).toList();
|
||||||
setState(() {
|
setState(() {
|
||||||
pickedFiles.addAll(result.paths.map((path) => File(path!)).toList());
|
pickedFiles.addAll(
|
||||||
|
result.paths.map((path) => File(path!)).toList(),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
// await chatRoomCubit.sendMessage(files: files, userSlug: slug);
|
// await chatRoomCubit.sendMessage(files: files, userSlug: slug);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: const Row(children: [Icon(Icons.file_present_rounded), SizedBox(width: 8), Text('Файлы')]),
|
child: const Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.file_present_rounded),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Файлы'),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
cancelButton: CupertinoActionSheetAction(onPressed: () => Navigator.of(context).pop(), isDestructiveAction: true, child: const Text('Отмена')),
|
cancelButton: CupertinoActionSheetAction(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
isDestructiveAction: true,
|
||||||
|
child: const Text('Отмена'),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -772,13 +995,22 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
padding: const EdgeInsets.only(top: 32),
|
padding: const EdgeInsets.only(top: 32),
|
||||||
child: Text(
|
child: Text(
|
||||||
DateFormat.yMMMMd('RU').format(date),
|
DateFormat.yMMMMd('RU').format(date),
|
||||||
style: const TextStyle(height: 1, color: Color(0xFF8393A1), fontWeight: FontWeight.w500),
|
style: const TextStyle(
|
||||||
|
height: 1,
|
||||||
|
color: Color(0xFF8393A1),
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _openVideoCall(BuildContext context, ChatRoomStateLoaded state, ChatUserModel chatCounterpart) {
|
void _openVideoCall(
|
||||||
|
BuildContext context,
|
||||||
|
ChatRoomStateLoaded state,
|
||||||
|
ChatUserModel chatCounterpart,
|
||||||
|
) {
|
||||||
|
sl<VideoCallManager>().permitParticipantTokenRequest();
|
||||||
GoRouter.of(context).pushNamed(
|
GoRouter.of(context).pushNamed(
|
||||||
VideoCallScreen.name,
|
VideoCallScreen.name,
|
||||||
extra: <String, dynamic>{
|
extra: <String, dynamic>{
|
||||||
@@ -793,6 +1025,43 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _queueMarkAsRead({
|
||||||
|
required BuildContext context,
|
||||||
|
required String chatId,
|
||||||
|
required ChatMessageModel message,
|
||||||
|
required bool isCurrentUserAuthor,
|
||||||
|
required double visibleFraction,
|
||||||
|
}) {
|
||||||
|
if (message.isRead || isCurrentUserAuthor || visibleFraction <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!unreadMessageIds.contains(message.id)) {
|
||||||
|
unreadMessageIds.add(message.id);
|
||||||
|
}
|
||||||
|
EasyDebounce.debounce(
|
||||||
|
'mark_as_read',
|
||||||
|
const Duration(milliseconds: 500),
|
||||||
|
() async {
|
||||||
|
final ids = List<int>.from(unreadMessageIds);
|
||||||
|
if (ids.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unreadMessageIds.clear();
|
||||||
|
final result = await context.read<ChatRoomCubit>().markMessagesAsRead(
|
||||||
|
chatId,
|
||||||
|
ids,
|
||||||
|
);
|
||||||
|
if (!result || !context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.read<ChatsCubit>().reduceUnreadMessagesCount(
|
||||||
|
chatId: chatId,
|
||||||
|
count: ids.length,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildMessageBubble({
|
Widget _buildMessageBubble({
|
||||||
required BuildContext context,
|
required BuildContext context,
|
||||||
required ChatRoomStateLoaded state,
|
required ChatRoomStateLoaded state,
|
||||||
@@ -801,11 +1070,13 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
required ChatMessageModel message,
|
required ChatMessageModel message,
|
||||||
required bool? amIConsultant,
|
required bool? amIConsultant,
|
||||||
}) {
|
}) {
|
||||||
if (message.type == ChatMessageType.videoCallStarted && !isCurrentUserAuthor) {
|
if (message.type == ChatMessageType.videoCallStarted &&
|
||||||
|
!isCurrentUserAuthor) {
|
||||||
return VideoCallChatMessageWidget(
|
return VideoCallChatMessageWidget(
|
||||||
text: message.data.trim(),
|
text: message.data.trim(),
|
||||||
sendTime: message.updatedAt,
|
sendTime: message.updatedAt,
|
||||||
onVideoCallPressed: () => _openVideoCall(context, state, chatCounterpart),
|
onVideoCallPressed: () =>
|
||||||
|
_openVideoCall(context, state, chatCounterpart),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -844,7 +1115,13 @@ class _ChatHeaderVideoCallButton extends StatelessWidget {
|
|||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
child: Center(child: Icon(Icons.videocam_rounded, color: appTheme.blueA400, size: 18)),
|
child: Center(
|
||||||
|
child: Icon(
|
||||||
|
Icons.videocam_rounded,
|
||||||
|
color: appTheme.blueA400,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -33,9 +33,7 @@ class _ComplainingBottomSheetState extends State<ComplainingBottomSheet> {
|
|||||||
create: (context) => sl<ComplainingCubit>()..setContentTypeAndIdentifier(contentType: widget.complainedContentType, slug: widget.slug, id: widget.id),
|
create: (context) => sl<ComplainingCubit>()..setContentTypeAndIdentifier(contentType: widget.complainedContentType, slug: widget.slug, id: widget.id),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.only(top: 8, bottom: 24, left: 16, right: 16),
|
padding: const EdgeInsets.only(top: 8, bottom: 24, left: 16, right: 16),
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(borderRadius: BorderRadius.vertical(top: Radius.circular(12))),
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
|
|
||||||
),
|
|
||||||
child: BlocBuilder<ComplainingCubit, ComplainingState>(
|
child: BlocBuilder<ComplainingCubit, ComplainingState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final category = state.category;
|
final category = state.category;
|
||||||
@@ -72,10 +70,7 @@ class _CategoryWidget extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
const BottomSheetHandlerBar(),
|
const BottomSheetHandlerBar(),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Text(
|
Text(s.complaint, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||||
s.complaint,
|
|
||||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
s.complaint_question,
|
s.complaint_question,
|
||||||
@@ -114,10 +109,7 @@ class _SpamWidget extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
const BottomSheetHandlerBar(),
|
const BottomSheetHandlerBar(),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Text(
|
Text(s.spam, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||||
s.spam,
|
|
||||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||||
@@ -145,10 +137,7 @@ class _ExplicitImageWidget extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
const BottomSheetHandlerBar(),
|
const BottomSheetHandlerBar(),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Text(
|
Text(s.explicitImages, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||||
s.explicitImages,
|
|
||||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||||
@@ -202,10 +191,7 @@ class _LawViolationWidgetState extends State<_LawViolationWidget> {
|
|||||||
children: [
|
children: [
|
||||||
const BottomSheetHandlerBar(),
|
const BottomSheetHandlerBar(),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Text(
|
Text(s.lawViolation, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||||
s.lawViolation,
|
|
||||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||||
@@ -235,10 +221,7 @@ class _LawViolationWidgetState extends State<_LawViolationWidget> {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
Text(
|
Text(element.$2, style: TextStyles.textLSemibold.copyWith(color: appTheme.black)),
|
||||||
element.$2,
|
|
||||||
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
@@ -254,12 +237,10 @@ class _LawViolationWidgetState extends State<_LawViolationWidget> {
|
|||||||
if ((state.complainInfo.description?.length ?? 10) < 10) ...[
|
if ((state.complainInfo.description?.length ?? 10) < 10) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
const ValidationErrorWidget(error: 'Минимум 10 символов'),
|
const ValidationErrorWidget(error: 'Минимум 10 символов'),
|
||||||
]
|
],
|
||||||
],
|
],
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
_ComplainButton(
|
_ComplainButton(isDisabled: tags.firstWhere((tag) => tag.$2 == s.other).$1 && (state.complainInfo.description?.length ?? 0) < 10),
|
||||||
isDisabled: (state.complainInfo.description?.length ?? 0) < 10,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -301,10 +282,7 @@ class _ViolenceAndHostilityState extends State<_ViolenceAndHostility> {
|
|||||||
children: [
|
children: [
|
||||||
const BottomSheetHandlerBar(),
|
const BottomSheetHandlerBar(),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Text(
|
Text(s.lawViolation, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||||
s.lawViolation,
|
|
||||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||||
@@ -332,10 +310,7 @@ class _ViolenceAndHostilityState extends State<_ViolenceAndHostility> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(element.$2, style: TextStyles.textLSemibold.copyWith(color: appTheme.black)),
|
||||||
element.$2,
|
|
||||||
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -363,15 +338,9 @@ class _DeceptionWidgetState extends State<_DeceptionWidget> {
|
|||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
final s = S.of(context);
|
final s = S.of(context);
|
||||||
if (tags.isEmpty && descriptions.isEmpty) {
|
if (tags.isEmpty && descriptions.isEmpty) {
|
||||||
tags.addAll([
|
tags.addAll([(false, s.misrepresentation), (false, s.fraud)]);
|
||||||
(false, s.misrepresentation),
|
|
||||||
(false, s.fraud),
|
|
||||||
]);
|
|
||||||
|
|
||||||
descriptions.addAll([
|
descriptions.addAll([s.misrepresentation_description, s.fraud_description]);
|
||||||
s.misrepresentation_description,
|
|
||||||
s.fraud_description,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,10 +352,7 @@ class _DeceptionWidgetState extends State<_DeceptionWidget> {
|
|||||||
children: [
|
children: [
|
||||||
const BottomSheetHandlerBar(),
|
const BottomSheetHandlerBar(),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Text(
|
Text(s.lawViolation, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||||
s.lawViolation,
|
|
||||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||||
@@ -419,16 +385,10 @@ class _DeceptionWidgetState extends State<_DeceptionWidget> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(element.$2, style: TextStyles.textLSemibold.copyWith(color: appTheme.black)),
|
||||||
element.$2,
|
|
||||||
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
|
|
||||||
),
|
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||||
child: Text(
|
child: Text(descriptions[index], style: TextStyles.textSSemibold.copyWith(color: appTheme.lightGray600)),
|
||||||
descriptions[index],
|
|
||||||
style: TextStyles.textSSemibold.copyWith(color: appTheme.lightGray600),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -453,10 +413,7 @@ class _ComplaintSentWidget extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
const BottomSheetHandlerBar(),
|
const BottomSheetHandlerBar(),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Text(
|
Text(s.complaint_sent, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||||
s.complaint_sent,
|
|
||||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||||
@@ -471,10 +428,7 @@ class _ComplaintSentWidget extends StatelessWidget {
|
|||||||
text: s.ok,
|
text: s.ok,
|
||||||
height: 52,
|
height: 52,
|
||||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||||
buttonStyle: ButtonStyle(
|
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
|
||||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
|
||||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
|
||||||
),
|
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
AppRouter.navigatorKey.currentContext?.pop();
|
AppRouter.navigatorKey.currentContext?.pop();
|
||||||
},
|
},
|
||||||
@@ -495,10 +449,7 @@ class _ComplaintFailedWidget extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
const BottomSheetHandlerBar(),
|
const BottomSheetHandlerBar(),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Text(
|
Text(s.could_not_send_complain, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||||
s.could_not_send_complain,
|
|
||||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||||
@@ -513,10 +464,7 @@ class _ComplaintFailedWidget extends StatelessWidget {
|
|||||||
text: s.ok,
|
text: s.ok,
|
||||||
height: 52,
|
height: 52,
|
||||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||||
buttonStyle: ButtonStyle(
|
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
|
||||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
|
||||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
|
||||||
),
|
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
AppRouter.navigatorKey.currentContext?.pop();
|
AppRouter.navigatorKey.currentContext?.pop();
|
||||||
},
|
},
|
||||||
@@ -535,16 +483,13 @@ class _ComplainButton extends StatelessWidget {
|
|||||||
return BlocBuilder<ComplainingCubit, ComplainingState>(
|
return BlocBuilder<ComplainingCubit, ComplainingState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
if (state.isLoading) {
|
if (state.isLoading) {
|
||||||
return const Loader();
|
return const SizedBox(height: 52, child: Loader());
|
||||||
}
|
}
|
||||||
return CustomOutlinedButton(
|
return CustomOutlinedButton(
|
||||||
text: s.send_complain,
|
text: s.send_complain,
|
||||||
height: 52,
|
height: 52,
|
||||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||||
buttonStyle: ButtonStyle(
|
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
|
||||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
|
||||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
|
||||||
),
|
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
if (isDisabled) {
|
if (isDisabled) {
|
||||||
context.read<ComplainingCubit>().setDescription('');
|
context.read<ComplainingCubit>().setDescription('');
|
||||||
@@ -570,10 +515,7 @@ class _CategoryButton extends StatelessWidget {
|
|||||||
child: Ink(
|
child: Ink(
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(category.getTranslatedText(), style: TextStyles.textLSemibold.copyWith(color: appTheme.black)),
|
||||||
category.getTranslatedText(),
|
|
||||||
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
SvgPicture.asset('assets/icons/right_arrow.svg'),
|
SvgPicture.asset('assets/icons/right_arrow.svg'),
|
||||||
],
|
],
|
||||||
@@ -591,18 +533,18 @@ enum ComplainType {
|
|||||||
explicitImage;
|
explicitImage;
|
||||||
|
|
||||||
String getTranslatedText() => switch (this) {
|
String getTranslatedText() => switch (this) {
|
||||||
ComplainType.spam => S.current.spam,
|
ComplainType.spam => S.current.spam,
|
||||||
ComplainType.lawViolation => S.current.lawViolation,
|
ComplainType.lawViolation => S.current.lawViolation,
|
||||||
ComplainType.deception => S.current.deception,
|
ComplainType.deception => S.current.deception,
|
||||||
ComplainType.violenceDndHostility => S.current.violenceAndHostility,
|
ComplainType.violenceDndHostility => S.current.violenceAndHostility,
|
||||||
ComplainType.explicitImage => S.current.explicitImage,
|
ComplainType.explicitImage => S.current.explicitImage,
|
||||||
};
|
};
|
||||||
|
|
||||||
String getValue() => switch (this) {
|
String getValue() => switch (this) {
|
||||||
ComplainType.spam => 'spam',
|
ComplainType.spam => 'spam',
|
||||||
ComplainType.lawViolation => 'legalViolation',
|
ComplainType.lawViolation => 'legalViolation',
|
||||||
ComplainType.deception => 'fraud',
|
ComplainType.deception => 'fraud',
|
||||||
ComplainType.violenceDndHostility => 'violenceAndHatred',
|
ComplainType.violenceDndHostility => 'violenceAndHatred',
|
||||||
ComplainType.explicitImage => 'explicitImage',
|
ComplainType.explicitImage => 'explicitImage',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ class ComplainingCubit extends Cubit<ComplainingState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> complain() async {
|
Future<void> complain() async {
|
||||||
|
if (state.isLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit(state.copyWith(isLoading: true));
|
||||||
try {
|
try {
|
||||||
final category = state.category;
|
final category = state.category;
|
||||||
final id = state.contentId;
|
final id = state.contentId;
|
||||||
@@ -46,37 +50,37 @@ class ComplainingCubit extends Cubit<ComplainingState> {
|
|||||||
case ComplainedContent.diet:
|
case ComplainedContent.diet:
|
||||||
if (id != null) {
|
if (id != null) {
|
||||||
await repository.sendDietComplain(id: id, category: category.getValue(), body: state.complainInfo);
|
await repository.sendDietComplain(id: id, category: category.getValue(), body: state.complainInfo);
|
||||||
emit(state.copyWith(wasSentSuccessfully: true));
|
emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case ComplainedContent.plan:
|
case ComplainedContent.plan:
|
||||||
if (slug != null) {
|
if (slug != null) {
|
||||||
await repository.sendPlanComplain(slug: slug, category: category.getValue(), body: state.complainInfo);
|
await repository.sendPlanComplain(slug: slug, category: category.getValue(), body: state.complainInfo);
|
||||||
emit(state.copyWith(wasSentSuccessfully: true));
|
emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case ComplainedContent.recipe:
|
case ComplainedContent.recipe:
|
||||||
if (slug != null) {
|
if (slug != null) {
|
||||||
await repository.sendRecipeComplain(slug: slug, category: category.getValue(), body: state.complainInfo);
|
await repository.sendRecipeComplain(slug: slug, category: category.getValue(), body: state.complainInfo);
|
||||||
emit(state.copyWith(wasSentSuccessfully: true));
|
emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case ComplainedContent.profile:
|
case ComplainedContent.profile:
|
||||||
if (slug != null) {
|
if (slug != null) {
|
||||||
await repository.sendProfileComplain(slug: slug, category: category.getValue(), body: state.complainInfo);
|
await repository.sendProfileComplain(slug: slug, category: category.getValue(), body: state.complainInfo);
|
||||||
emit(state.copyWith(wasSentSuccessfully: true));
|
emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case ComplainedContent.posts:
|
case ComplainedContent.posts:
|
||||||
if (id != null) {
|
if (id != null) {
|
||||||
await repository.sendBlogPostsComplain(id: id, category: category.getValue(), body: state.complainInfo);
|
await repository.sendBlogPostsComplain(id: id, category: category.getValue(), body: state.complainInfo);
|
||||||
emit(state.copyWith(wasSentSuccessfully: true));
|
emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case ComplainedContent.comment:
|
case ComplainedContent.comment:
|
||||||
if (id != null) {
|
if (id != null) {
|
||||||
await repository.sendCommentComplain(id: id, category: category.getValue(), body: state.complainInfo);
|
await repository.sendCommentComplain(id: id, category: category.getValue(), body: state.complainInfo);
|
||||||
emit(state.copyWith(wasSentSuccessfully: true));
|
emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,6 +89,6 @@ class ComplainingCubit extends Cubit<ComplainingState> {
|
|||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
log(e.toString(), stackTrace: st);
|
log(e.toString(), stackTrace: st);
|
||||||
}
|
}
|
||||||
emit(state.copyWith(wasSentSuccessfully: false));
|
emit(state.copyWith(wasSentSuccessfully: false, isLoading: false));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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' => 'Некорректный телефон СБП',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -59,43 +59,47 @@ class _AddMealWidgetState extends State<AddMealWidget> {
|
|||||||
Row(
|
Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
Expanded(
|
||||||
width: MediaQuery.of(context).size.width * 0.8 - 32,
|
flex: 2,
|
||||||
child: CustomTextFormField(
|
child: SizedBox(
|
||||||
controller: productNameController,
|
child: CustomTextFormField(
|
||||||
autofocus: true,
|
controller: productNameController,
|
||||||
hintText: 'Введите название продукта/блюда',
|
autofocus: true,
|
||||||
onChanged: (query) {
|
hintText: 'Введите название продукта/блюда',
|
||||||
EasyDebounce.debounce('glossary-search', const Duration(milliseconds: 250), () async {
|
onChanged: (query) {
|
||||||
selectedGlossary = null;
|
EasyDebounce.debounce('glossary-search', const Duration(milliseconds: 250), () async {
|
||||||
if (query.isEmpty) {
|
|
||||||
glossariesList = [];
|
|
||||||
selectedGlossary = null;
|
selectedGlossary = null;
|
||||||
|
if (query.isEmpty) {
|
||||||
|
glossariesList = [];
|
||||||
|
selectedGlossary = null;
|
||||||
|
setState(() {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
glossariesList = await widget.createDietCubit.getGlossaryListByQuery(query);
|
||||||
setState(() {});
|
setState(() {});
|
||||||
return;
|
});
|
||||||
}
|
},
|
||||||
glossariesList = await widget.createDietCubit.getGlossaryListByQuery(query);
|
),
|
||||||
setState(() {});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
SizedBox(
|
Expanded(
|
||||||
width: MediaQuery.of(context).size.width * 0.2 - 4,
|
flex: 1,
|
||||||
child: CustomTextFormField(
|
child: SizedBox(
|
||||||
controller: quantityController,
|
child: CustomTextFormField(
|
||||||
keyboardType: TextInputType.number,
|
controller: quantityController,
|
||||||
hintText: selectedGlossary == null
|
keyboardType: TextInputType.number,
|
||||||
? '100'
|
hintText: selectedGlossary == null
|
||||||
: selectedGlossary!.grInPortion == 0
|
? '100'
|
||||||
? '100'
|
: selectedGlossary!.grInPortion == 0
|
||||||
: '1',
|
? '100'
|
||||||
suffixText: selectedGlossary == null
|
: '1',
|
||||||
? 'гр'
|
suffixText: selectedGlossary == null
|
||||||
: selectedGlossary?.grInPortion == 0
|
? 'гр'
|
||||||
? 'гр'
|
: selectedGlossary?.grInPortion == 0
|
||||||
: 'шт',
|
? 'гр'
|
||||||
|
: 'шт',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -149,17 +153,19 @@ class _AddMealWidgetState extends State<AddMealWidget> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (widget.portionIndex != null) const SizedBox(width: 8),
|
if (widget.portionIndex != null) const SizedBox(width: 8),
|
||||||
InkWell(
|
Expanded(
|
||||||
onTap: addPortion,
|
child: InkWell(
|
||||||
borderRadius: BorderRadius.circular(20),
|
onTap: addPortion,
|
||||||
child: Ink(
|
borderRadius: BorderRadius.circular(20),
|
||||||
width: MediaQuery.of(context).size.width - (widget.portionIndex == null ? 32 : 92),
|
child: Ink(
|
||||||
height: 52,
|
width: MediaQuery.of(context).size.width - (widget.portionIndex == null ? 32 : 92),
|
||||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
|
height: 52,
|
||||||
child: Center(
|
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
|
||||||
child: Text(
|
child: Center(
|
||||||
widget.portionIndex == null ? 'Добавить' : 'Сохранить',
|
child: Text(
|
||||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white),
|
widget.portionIndex == null ? 'Добавить' : 'Сохранить',
|
||||||
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import 'package:eatvio_app/core/widgets/success_published_diet_bottom_sheet.dart
|
|||||||
import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart';
|
import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart';
|
||||||
import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_cubit.dart';
|
import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_cubit.dart';
|
||||||
import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_state.dart';
|
import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_state.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||||
import 'package:eatvio_app/features/create_plan/presentation/widgets/option_widget.dart';
|
import 'package:eatvio_app/features/create_plan/presentation/widgets/option_widget.dart';
|
||||||
import 'package:eatvio_app/features/create_plan/presentation/widgets/options_count_button.dart';
|
import 'package:eatvio_app/features/create_plan/presentation/widgets/options_count_button.dart';
|
||||||
import 'package:eatvio_app/features/create_plan/presentation/widgets/plan_duration_drop_down_widget.dart';
|
import 'package:eatvio_app/features/create_plan/presentation/widgets/plan_duration_drop_down_widget.dart';
|
||||||
@@ -25,11 +26,23 @@ import 'package:go_router/go_router.dart';
|
|||||||
class CreatePlanScreen extends StatelessWidget {
|
class CreatePlanScreen extends StatelessWidget {
|
||||||
static const name = 'createPlanScreen';
|
static const name = 'createPlanScreen';
|
||||||
static const path = '/create-Plan-screen';
|
static const path = '/create-Plan-screen';
|
||||||
const CreatePlanScreen({super.key});
|
const CreatePlanScreen({super.key, this.plan});
|
||||||
|
|
||||||
|
final PlanModel? plan;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocProvider(create: (context) => sl<CreatePlanCubit>(), child: const CreatePlanScreenContent());
|
return BlocProvider(
|
||||||
|
create: (context) {
|
||||||
|
final cubit = sl<CreatePlanCubit>();
|
||||||
|
final existingPlan = plan;
|
||||||
|
if (existingPlan != null) {
|
||||||
|
cubit.loadExisting(existingPlan);
|
||||||
|
}
|
||||||
|
return cubit;
|
||||||
|
},
|
||||||
|
child: const CreatePlanScreenContent(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,17 +60,19 @@ class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final s = S.of(context);
|
final s = S.of(context);
|
||||||
|
final isEditing = context.read<CreatePlanCubit>().sourcePlan != null;
|
||||||
return BlocBuilder<CreatePlanCubit, CreatePlanState>(
|
return BlocBuilder<CreatePlanCubit, CreatePlanState>(
|
||||||
buildWhen: (previous, current) => previous.planCreated != current.planCreated || previous.isLoading != current.isLoading,
|
buildWhen: (previous, current) => previous.planCreated != current.planCreated || previous.isLoading != current.isLoading,
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
if (state.planCreated) {
|
if (state.planCreated) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
context.pop();
|
final isEditing = context.read<CreatePlanCubit>().sourcePlan != null;
|
||||||
|
context.pop(true);
|
||||||
showBottomSheetWithBlur(
|
showBottomSheetWithBlur(
|
||||||
userRootNavigator: true,
|
userRootNavigator: true,
|
||||||
context: context,
|
context: context,
|
||||||
child: SuccessPublishedDietOrPlanBottomSheet(title: state.planData.name ?? 'План', isPlan: true),
|
child: SuccessPublishedDietOrPlanBottomSheet(title: state.planData.name ?? 'План', isPlan: true, isUpdated: isEditing),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -68,7 +83,7 @@ class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
|
|||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
title: Text(
|
title: Text(
|
||||||
s.adding_plan,
|
isEditing ? 'Редактирование плана' : s.adding_plan,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
softWrap: true,
|
softWrap: true,
|
||||||
@@ -102,7 +117,9 @@ class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
|
|||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
CustomTextFormField(
|
CustomTextFormField(
|
||||||
|
key: const ValueKey('plan-name'),
|
||||||
hintText: s.plan_label_desc,
|
hintText: s.plan_label_desc,
|
||||||
|
initialValue: state.planData.name,
|
||||||
hasError: state.validationState.titleHasError,
|
hasError: state.validationState.titleHasError,
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
context.read<CreatePlanCubit>().setName(value);
|
context.read<CreatePlanCubit>().setName(value);
|
||||||
@@ -144,6 +161,7 @@ class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
|
|||||||
children: [
|
children: [
|
||||||
MarkdownEditor(
|
MarkdownEditor(
|
||||||
key: _mdKey,
|
key: _mdKey,
|
||||||
|
initialText: state.planData.details,
|
||||||
hintText: s.plan_desc_example,
|
hintText: s.plan_desc_example,
|
||||||
hasError: state.validationState.descriptionHasError,
|
hasError: state.validationState.descriptionHasError,
|
||||||
minLines: 6,
|
minLines: 6,
|
||||||
@@ -230,7 +248,7 @@ class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
|
|||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
await context.read<CreatePlanCubit>().publishPlan();
|
await context.read<CreatePlanCubit>().publishPlan();
|
||||||
},
|
},
|
||||||
text: s.publish,
|
text: isEditing ? 'Обновить' : s.publish,
|
||||||
height: 52,
|
height: 52,
|
||||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||||
buttonStyle: ButtonStyle(
|
buttonStyle: ButtonStyle(
|
||||||
|
|||||||
@@ -4,16 +4,57 @@ import 'dart:io';
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:eatvio_app/core/const.dart';
|
import 'package:eatvio_app/core/const.dart';
|
||||||
import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart';
|
import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart';
|
||||||
|
import 'package:eatvio_app/core/utils/toasts.dart';
|
||||||
import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_state.dart';
|
import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_state.dart';
|
||||||
import 'package:eatvio_app/features/create_plan/repositories/create_plan_repository.dart';
|
import 'package:eatvio_app/features/create_plan/repositories/create_plan_repository.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||||
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||||
|
|
||||||
class CreatePlanCubit extends Cubit<CreatePlanState> {
|
class CreatePlanCubit extends Cubit<CreatePlanState> {
|
||||||
final CreatePlanRepository createPlanRepository;
|
final CreatePlanRepository createPlanRepository;
|
||||||
|
PlanModel? sourcePlan;
|
||||||
|
|
||||||
CreatePlanCubit({required this.createPlanRepository}) : super(CreatePlanState.initial());
|
CreatePlanCubit({required this.createPlanRepository}) : super(CreatePlanState.initial());
|
||||||
|
|
||||||
|
void loadExisting(PlanModel plan) {
|
||||||
|
sourcePlan = plan;
|
||||||
|
final durationIndex = durationValues.indexOf(plan.duration);
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
durationIndex: durationIndex >= 0 ? durationIndex : null,
|
||||||
|
selectedOptionIndex: plan.packetsCount == 3 ? 0 : null,
|
||||||
|
planData: state.planData.copyWith(
|
||||||
|
name: plan.name,
|
||||||
|
details: plan.details,
|
||||||
|
duration: plan.duration,
|
||||||
|
state: plan.state,
|
||||||
|
type: [
|
||||||
|
plan.type.slim ?? false,
|
||||||
|
plan.type.fit ?? false,
|
||||||
|
plan.type.wellness ?? false,
|
||||||
|
plan.type.behavior ?? false,
|
||||||
|
],
|
||||||
|
options: [
|
||||||
|
_packetOrEmpty(plan.packet1Options),
|
||||||
|
plan.packetsCount == 3 ? _packetOrEmpty(plan.packet2Options) : PacketOptions.empty(),
|
||||||
|
plan.packetsCount == 3 ? _packetOrEmpty(plan.packet3Options) : PacketOptions.empty(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
PacketOptions _packetOrEmpty(PacketOptions? option) {
|
||||||
|
if (option == null) {
|
||||||
|
return PacketOptions.empty();
|
||||||
|
}
|
||||||
|
final price = option.price;
|
||||||
|
final normalizedPrice = price is num ? price.toDouble() : double.tryParse('$price') ?? 1200;
|
||||||
|
return option.copyWith(price: normalizedPrice);
|
||||||
|
}
|
||||||
|
|
||||||
void setDetails(String text) {
|
void setDetails(String text) {
|
||||||
emit(state.copyWith(planData: state.planData.copyWith(details: text)));
|
emit(state.copyWith(planData: state.planData.copyWith(details: text)));
|
||||||
validateDetails();
|
validateDetails();
|
||||||
@@ -147,7 +188,7 @@ class CreatePlanCubit extends Cubit<CreatePlanState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool validateImage() {
|
bool validateImage() {
|
||||||
if (state.planData.image == null) {
|
if (state.planData.image == null && sourcePlan == null) {
|
||||||
emit(state.copyWith.validationState(imageHasError: true));
|
emit(state.copyWith.validationState(imageHasError: true));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -413,31 +454,34 @@ class CreatePlanCubit extends Cubit<CreatePlanState> {
|
|||||||
'type[fit]': state.planData.type[1] ? '1' : '0',
|
'type[fit]': state.planData.type[1] ? '1' : '0',
|
||||||
'type[wellness]': state.planData.type[2] ? '1' : '0',
|
'type[wellness]': state.planData.type[2] ? '1' : '0',
|
||||||
'type[behavior]': state.planData.type[3] ? '1' : '0',
|
'type[behavior]': state.planData.type[3] ? '1' : '0',
|
||||||
'image': state.planData.image != null
|
|
||||||
? await MultipartFile.fromFile((await ImageCompressUtility.compressFile(File(state.planData.image!.path))).path,
|
|
||||||
filename: state.planData.image!.name)
|
|
||||||
: null
|
|
||||||
});
|
});
|
||||||
|
if (state.planData.image != null) {
|
||||||
|
final image = state.planData.image!;
|
||||||
|
planMap['image'] = await MultipartFile.fromFile(
|
||||||
|
(await ImageCompressUtility.compressFile(File(image.path))).path,
|
||||||
|
filename: image.name,
|
||||||
|
);
|
||||||
|
}
|
||||||
if (state.planData.options[1].short.isNotEmpty) {
|
if (state.planData.options[1].short.isNotEmpty) {
|
||||||
planMap.addAll({
|
planMap.addAll({
|
||||||
'packets_count': 3,
|
'packets_count': 3,
|
||||||
'packet1_options': {
|
'packet1_options': {
|
||||||
'short': state.planData.options[0].short,
|
'short': state.planData.options[0].short,
|
||||||
'price': int.parse((state.planData.options[0].price as double).toStringAsFixed(0)),
|
'price': _optionPrice(state.planData.options[0].price),
|
||||||
'description_md': state.planData.options[0].description,
|
'description_md': state.planData.options[0].description,
|
||||||
'items': Map.fromIterable(state.planData.options[0].items),
|
'items': Map.fromIterable(state.planData.options[0].items),
|
||||||
'has_personal_diet': state.planData.options[0].hasPersonalDiet ? '1' : '0',
|
'has_personal_diet': state.planData.options[0].hasPersonalDiet ? '1' : '0',
|
||||||
},
|
},
|
||||||
'packet2_options': {
|
'packet2_options': {
|
||||||
'short': state.planData.options[1].short,
|
'short': state.planData.options[1].short,
|
||||||
'price': int.parse((state.planData.options[1].price as double).toStringAsFixed(0)),
|
'price': _optionPrice(state.planData.options[1].price),
|
||||||
'description_md': state.planData.options[1].description,
|
'description_md': state.planData.options[1].description,
|
||||||
'items': Map.fromIterable(state.planData.options[1].items),
|
'items': Map.fromIterable(state.planData.options[1].items),
|
||||||
'has_personal_diet': state.planData.options[1].hasPersonalDiet ? '1' : '0',
|
'has_personal_diet': state.planData.options[1].hasPersonalDiet ? '1' : '0',
|
||||||
},
|
},
|
||||||
'packet3_options': {
|
'packet3_options': {
|
||||||
'short': state.planData.options[2].short,
|
'short': state.planData.options[2].short,
|
||||||
'price': int.parse((state.planData.options[2].price as double).toStringAsFixed(0)),
|
'price': _optionPrice(state.planData.options[2].price),
|
||||||
'description_md': state.planData.options[2].description,
|
'description_md': state.planData.options[2].description,
|
||||||
'items': Map.fromIterable(state.planData.options[2].items),
|
'items': Map.fromIterable(state.planData.options[2].items),
|
||||||
'has_personal_diet': state.planData.options[2].hasPersonalDiet ? '1' : '0',
|
'has_personal_diet': state.planData.options[2].hasPersonalDiet ? '1' : '0',
|
||||||
@@ -448,20 +492,34 @@ class CreatePlanCubit extends Cubit<CreatePlanState> {
|
|||||||
'packets_count': 1,
|
'packets_count': 1,
|
||||||
'packet1_options': {
|
'packet1_options': {
|
||||||
'short': state.planData.options[0].short,
|
'short': state.planData.options[0].short,
|
||||||
'price': int.parse((state.planData.options[0].price as double).toStringAsFixed(0)),
|
'price': _optionPrice(state.planData.options[0].price),
|
||||||
'description_md': state.planData.options[0].description,
|
'description_md': state.planData.options[0].description,
|
||||||
'items': Map.fromIterable(state.planData.options[0].items),
|
'items': Map.fromIterable(state.planData.options[0].items),
|
||||||
'has_personal_diet': state.planData.options[0].hasPersonalDiet ? '1' : '0',
|
'has_personal_diet': state.planData.options[0].hasPersonalDiet ? '1' : '0',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await createPlanRepository.createPlan(model: planMap);
|
final editingPlan = sourcePlan;
|
||||||
|
if (editingPlan == null) {
|
||||||
|
await createPlanRepository.createPlan(model: planMap);
|
||||||
|
} else {
|
||||||
|
await createPlanRepository.updatePublishedPlan(slug: editingPlan.slug, model: planMap);
|
||||||
|
}
|
||||||
emit(state.copyWith(isLoading: false, planCreated: true));
|
emit(state.copyWith(isLoading: false, planCreated: true));
|
||||||
}
|
}
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
log(e.toString());
|
log(e.toString());
|
||||||
emit(state.copyWith(isLoading: false, planCreated: true));
|
emit(state.copyWith(isLoading: false, planCreated: false));
|
||||||
|
final isForbidden = e is DioException && e.response?.statusCode == 403;
|
||||||
|
showErrorToast(isForbidden ? 'Редактирование недоступно: клиент уже начал проходить план' : 'Не удалось сохранить план');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int _optionPrice(dynamic price) {
|
||||||
|
if (price is num) {
|
||||||
|
return price.round();
|
||||||
|
}
|
||||||
|
return int.parse(price.toString());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ class AddItemWidget extends StatefulWidget {
|
|||||||
class _AddItemWidgetState extends State<AddItemWidget> {
|
class _AddItemWidgetState extends State<AddItemWidget> {
|
||||||
final controller = TextEditingController();
|
final controller = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(oldWidget) {
|
void didUpdateWidget(oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
@@ -38,14 +44,24 @@ class _AddItemWidgetState extends State<AddItemWidget> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final s = S.of(context);
|
final s = S.of(context);
|
||||||
if (widget.items.length > widget.index && controller.text != widget.items[widget.index]) {
|
if (widget.items.length > widget.index &&
|
||||||
|
controller.text != widget.items[widget.index]) {
|
||||||
controller.text = widget.items[widget.index];
|
controller.text = widget.items[widget.index];
|
||||||
}
|
}
|
||||||
return BlocBuilder<CreatePlanCubit, CreatePlanState>(
|
return BlocBuilder<CreatePlanCubit, CreatePlanState>(
|
||||||
buildWhen: (previous, current) => previous.validationState != current.validationState,
|
buildWhen: (previous, current) =>
|
||||||
|
previous.validationState != current.validationState,
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final hasError = state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].isItemsEmpty ||
|
final hasError =
|
||||||
(state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].itemsErrors[widget.index] ?? false);
|
state
|
||||||
|
.validationState
|
||||||
|
.optionsValidationState[state.selectedOptionIndex ?? 0]
|
||||||
|
.isItemsEmpty ||
|
||||||
|
(state
|
||||||
|
.validationState
|
||||||
|
.optionsValidationState[state.selectedOptionIndex ?? 0]
|
||||||
|
.itemsErrors[widget.index] ??
|
||||||
|
false);
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
@@ -54,9 +70,14 @@ class _AddItemWidgetState extends State<AddItemWidget> {
|
|||||||
child: CustomTextFormField(
|
child: CustomTextFormField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
hasError: hasError,
|
hasError: hasError,
|
||||||
hintText: widget.enableHint ? s.option_content_hint_text : null,
|
hintText: widget.enableHint
|
||||||
|
? s.option_content_hint_text
|
||||||
|
: null,
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
context.read<CreatePlanCubit>().setOptionContent(value, widget.index);
|
context.read<CreatePlanCubit>().setOptionContent(
|
||||||
|
value,
|
||||||
|
widget.index,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -64,7 +85,9 @@ class _AddItemWidgetState extends State<AddItemWidget> {
|
|||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
context.read<CreatePlanCubit>().removeOptionContentItem(widget.index);
|
context.read<CreatePlanCubit>().removeOptionContentItem(
|
||||||
|
widget.index,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(14),
|
padding: const EdgeInsets.all(14),
|
||||||
@@ -72,19 +95,30 @@ class _AddItemWidgetState extends State<AddItemWidget> {
|
|||||||
color: appTheme.lightGray0,
|
color: appTheme.lightGray0,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
child: SvgPicture.asset('assets/icons/trash_can.svg', width: 20, height: 24),
|
child: SvgPicture.asset(
|
||||||
|
'assets/icons/trash_can.svg',
|
||||||
|
width: 20,
|
||||||
|
height: 24,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].isItemsEmpty) ...[
|
if (state
|
||||||
|
.validationState
|
||||||
|
.optionsValidationState[state.selectedOptionIndex ?? 0]
|
||||||
|
.isItemsEmpty) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
ValidationErrorWidget(error: s.addAtLeastOneItem)
|
ValidationErrorWidget(error: s.addAtLeastOneItem),
|
||||||
] else if (state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].itemsErrors[widget.index] ?? false) ...[
|
] else if (state
|
||||||
|
.validationState
|
||||||
|
.optionsValidationState[state.selectedOptionIndex ?? 0]
|
||||||
|
.itemsErrors[widget.index] ??
|
||||||
|
false) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
ValidationErrorWidget(error: s.atLeast3Symbols)
|
ValidationErrorWidget(error: s.atLeast3Symbols),
|
||||||
]
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widge
|
|||||||
import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_cubit.dart';
|
import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_cubit.dart';
|
||||||
import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_state.dart';
|
import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_state.dart';
|
||||||
import 'package:eatvio_app/features/create_plan/presentation/widgets/add_item_widget.dart';
|
import 'package:eatvio_app/features/create_plan/presentation/widgets/add_item_widget.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||||
import 'package:eatvio_app/generated/l10n.dart';
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
@@ -21,47 +22,107 @@ class OptionWidget extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _OptionWidgetState extends State<OptionWidget> {
|
class _OptionWidgetState extends State<OptionWidget> {
|
||||||
final firstOptionCustomerPriceController = TextEditingController()..text = '1200₽';
|
static const _optionsCount = 3;
|
||||||
final secondOptionCustomerPriceController = TextEditingController()..text = '1200₽';
|
|
||||||
final thirdOptionCustomerPriceController = TextEditingController()..text = '1200₽';
|
|
||||||
|
|
||||||
final creatorPriceController = TextEditingController()..text = '1000₽';
|
final List<TextEditingController> _customerPriceControllers = List.generate(
|
||||||
|
_optionsCount,
|
||||||
final firstOptionTitleController = TextEditingController();
|
(_) => TextEditingController(text: '1200₽'),
|
||||||
final secondOptionTitleController = TextEditingController();
|
);
|
||||||
final thirdOptionTitleController = TextEditingController();
|
final List<TextEditingController> _titleControllers = List.generate(
|
||||||
|
_optionsCount,
|
||||||
final firstOptionDescriptionController = TextEditingController();
|
(_) => TextEditingController(),
|
||||||
final secondOptionDescriptionController = TextEditingController();
|
);
|
||||||
final thirdOptionDescriptionController = TextEditingController();
|
final List<TextEditingController> _descriptionControllers = List.generate(
|
||||||
|
_optionsCount,
|
||||||
|
(_) => TextEditingController(),
|
||||||
|
);
|
||||||
|
final creatorPriceController = TextEditingController(text: '1000₽');
|
||||||
|
|
||||||
|
int? _syncedOptionIndex;
|
||||||
bool firstExplanationVisible = false;
|
bool firstExplanationVisible = false;
|
||||||
bool isChecked = false;
|
bool isChecked = false;
|
||||||
bool secondExplanationVisible = false;
|
bool secondExplanationVisible = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
firstOptionCustomerPriceController.addListener(() {
|
for (final controller in _customerPriceControllers) {
|
||||||
if (!firstOptionCustomerPriceController.text.endsWith('₽')) {
|
controller.addListener(() => _ensureRubleSuffix(controller));
|
||||||
final text = firstOptionCustomerPriceController.text.replaceAll('₽', '');
|
}
|
||||||
firstOptionCustomerPriceController.text = '$text₽';
|
}
|
||||||
firstOptionCustomerPriceController.selection = TextSelection.collapsed(offset: text.length);
|
|
||||||
}
|
@override
|
||||||
});
|
void dispose() {
|
||||||
secondOptionCustomerPriceController.addListener(() {
|
for (final controller in _customerPriceControllers) {
|
||||||
if (!secondOptionCustomerPriceController.text.endsWith('₽')) {
|
controller.dispose();
|
||||||
final text = secondOptionCustomerPriceController.text.replaceAll('₽', '');
|
}
|
||||||
secondOptionCustomerPriceController.text = '$text₽';
|
for (final controller in _titleControllers) {
|
||||||
secondOptionCustomerPriceController.selection = TextSelection.collapsed(offset: text.length);
|
controller.dispose();
|
||||||
}
|
}
|
||||||
});
|
for (final controller in _descriptionControllers) {
|
||||||
thirdOptionCustomerPriceController.addListener(() {
|
controller.dispose();
|
||||||
if (!thirdOptionCustomerPriceController.text.endsWith('₽')) {
|
}
|
||||||
final text = thirdOptionCustomerPriceController.text.replaceAll('₽', '');
|
creatorPriceController.dispose();
|
||||||
thirdOptionCustomerPriceController.text = '$text₽';
|
super.dispose();
|
||||||
thirdOptionCustomerPriceController.selection = TextSelection.collapsed(offset: text.length);
|
}
|
||||||
}
|
|
||||||
});
|
void _ensureRubleSuffix(TextEditingController controller) {
|
||||||
|
if (!controller.text.endsWith('₽')) {
|
||||||
|
final text = controller.text.replaceAll('₽', '');
|
||||||
|
controller.text = '$text₽';
|
||||||
|
controller.selection = TextSelection.collapsed(offset: text.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _syncController(TextEditingController controller, String value) {
|
||||||
|
if (controller.text == value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
controller.value = TextEditingValue(
|
||||||
|
text: value,
|
||||||
|
selection: TextSelection.collapsed(offset: value.length),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatCustomerPrice(dynamic price) {
|
||||||
|
if (price is num) {
|
||||||
|
return '${price.toStringAsFixed(0)}₽';
|
||||||
|
}
|
||||||
|
final parsed = double.tryParse(price.toString().replaceAll('₽', ''));
|
||||||
|
return '${(parsed ?? 0).toStringAsFixed(0)}₽';
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applyOptionToControllers(int index, PacketOptions option) {
|
||||||
|
_syncController(_titleControllers[index], option.short);
|
||||||
|
_syncController(_descriptionControllers[index], option.description ?? '');
|
||||||
|
_syncController(
|
||||||
|
_customerPriceControllers[index],
|
||||||
|
_formatCustomerPrice(option.price),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _syncSelectedOption(CreatePlanState state) {
|
||||||
|
final selectedOptionIndex = state.selectedOptionIndex ?? 0;
|
||||||
|
final option = state.planData.options[selectedOptionIndex];
|
||||||
|
final price = double.tryParse(
|
||||||
|
_customerPriceControllers[selectedOptionIndex].text.replaceAll('₽', ''),
|
||||||
|
);
|
||||||
|
final creatorPrice = '${((price ?? 0) * 0.8).toStringAsFixed(2)}₽';
|
||||||
|
|
||||||
|
if (_syncedOptionIndex != selectedOptionIndex) {
|
||||||
|
_syncedOptionIndex = selectedOptionIndex;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_applyOptionToControllers(selectedOptionIndex, option);
|
||||||
|
creatorPriceController.text = creatorPrice;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (creatorPriceController.text != creatorPrice) {
|
||||||
|
creatorPriceController.text = creatorPrice;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -70,31 +131,21 @@ class _OptionWidgetState extends State<OptionWidget> {
|
|||||||
return BlocBuilder<CreatePlanCubit, CreatePlanState>(
|
return BlocBuilder<CreatePlanCubit, CreatePlanState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final selectedOptionIndex = state.selectedOptionIndex ?? 0;
|
final selectedOptionIndex = state.selectedOptionIndex ?? 0;
|
||||||
|
_syncSelectedOption(state);
|
||||||
final optionNameHintText = switch (selectedOptionIndex) {
|
final optionNameHintText = switch (selectedOptionIndex) {
|
||||||
0 => s.economy,
|
0 => s.economy,
|
||||||
2 => s.comfort,
|
2 => s.comfort,
|
||||||
_ => s.standard,
|
_ => s.standard,
|
||||||
};
|
};
|
||||||
final optionTitleController = switch (selectedOptionIndex) {
|
final optionTitleController = _titleControllers[selectedOptionIndex];
|
||||||
0 => firstOptionTitleController,
|
final optionDescriptionController =
|
||||||
2 => thirdOptionTitleController,
|
_descriptionControllers[selectedOptionIndex];
|
||||||
_ => secondOptionTitleController,
|
final customerPriceController =
|
||||||
};
|
_customerPriceControllers[selectedOptionIndex];
|
||||||
final optionDescriptionController = switch (selectedOptionIndex) {
|
final optionItems = state.planData.options[selectedOptionIndex].items;
|
||||||
0 => firstOptionDescriptionController,
|
final optionValidation =
|
||||||
2 => thirdOptionDescriptionController,
|
state.validationState.optionsValidationState[selectedOptionIndex];
|
||||||
_ => secondOptionDescriptionController,
|
|
||||||
};
|
|
||||||
final customerPriceController = switch (selectedOptionIndex) {
|
|
||||||
0 => firstOptionCustomerPriceController,
|
|
||||||
2 => thirdOptionCustomerPriceController,
|
|
||||||
_ => secondOptionCustomerPriceController,
|
|
||||||
};
|
|
||||||
|
|
||||||
final price = double.tryParse(customerPriceController.text.replaceAll('₽', ''));
|
|
||||||
creatorPriceController.text = '${((price ?? 0) * 0.8).toStringAsFixed(2)}₽';
|
|
||||||
firstOptionTitleController.text = state.planData.options[selectedOptionIndex].short;
|
|
||||||
firstOptionDescriptionController.text = state.planData.options[selectedOptionIndex].description ?? '';
|
|
||||||
return MainBackgroundBouble(
|
return MainBackgroundBouble(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -110,14 +161,14 @@ class _OptionWidgetState extends State<OptionWidget> {
|
|||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
CustomTextFormField(
|
CustomTextFormField(
|
||||||
hintText: optionNameHintText,
|
hintText: optionNameHintText,
|
||||||
hasError: state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].titleHasError,
|
hasError: optionValidation.titleHasError,
|
||||||
controller: optionTitleController,
|
controller: optionTitleController,
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
context.read<CreatePlanCubit>().setOptionTitle(value);
|
context.read<CreatePlanCubit>().setOptionTitle(value);
|
||||||
},
|
},
|
||||||
maxLength: 150,
|
maxLength: 150,
|
||||||
),
|
),
|
||||||
if (state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].titleHasError) ...[
|
if (optionValidation.titleHasError) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
ValidationErrorWidget(error: s.atLeast3Symbols),
|
ValidationErrorWidget(error: s.atLeast3Symbols),
|
||||||
],
|
],
|
||||||
@@ -135,7 +186,7 @@ class _OptionWidgetState extends State<OptionWidget> {
|
|||||||
child: CustomTextFormField(
|
child: CustomTextFormField(
|
||||||
controller: optionDescriptionController,
|
controller: optionDescriptionController,
|
||||||
textAlignVertical: TextAlignVertical.top,
|
textAlignVertical: TextAlignVertical.top,
|
||||||
hasError: state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].descriptionHasError,
|
hasError: optionValidation.descriptionHasError,
|
||||||
maxLength: 1000,
|
maxLength: 1000,
|
||||||
minLines: null,
|
minLines: null,
|
||||||
maxLines: null,
|
maxLines: null,
|
||||||
@@ -146,7 +197,7 @@ class _OptionWidgetState extends State<OptionWidget> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].descriptionHasError) ...[
|
if (optionValidation.descriptionHasError) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
ValidationErrorWidget(error: s.atLeast3Symbols),
|
ValidationErrorWidget(error: s.atLeast3Symbols),
|
||||||
],
|
],
|
||||||
@@ -158,7 +209,9 @@ class _OptionWidgetState extends State<OptionWidget> {
|
|||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
softWrap: true,
|
softWrap: true,
|
||||||
style: TextStyles.textSMedium.copyWith(color: appTheme.black),
|
style: TextStyles.textSMedium.copyWith(
|
||||||
|
color: appTheme.black,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
const _PriceExplanationWidget(),
|
const _PriceExplanationWidget(),
|
||||||
@@ -179,7 +232,9 @@ class _OptionWidgetState extends State<OptionWidget> {
|
|||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
softWrap: true,
|
softWrap: true,
|
||||||
style: TextStyles.textSMedium.copyWith(color: appTheme.black),
|
style: TextStyles.textSMedium.copyWith(
|
||||||
|
color: appTheme.black,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
const _PriceExplanationWidget(),
|
const _PriceExplanationWidget(),
|
||||||
@@ -188,8 +243,10 @@ class _OptionWidgetState extends State<OptionWidget> {
|
|||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
CustomTextFormField(
|
CustomTextFormField(
|
||||||
controller: customerPriceController,
|
controller: customerPriceController,
|
||||||
textInputType: const TextInputType.numberWithOptions(decimal: true),
|
textInputType: const TextInputType.numberWithOptions(
|
||||||
hasError: state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].priceHasError,
|
decimal: true,
|
||||||
|
),
|
||||||
|
hasError: optionValidation.priceHasError,
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
final price = double.tryParse(value.replaceAll('₽', ''));
|
final price = double.tryParse(value.replaceAll('₽', ''));
|
||||||
if (price != null) {
|
if (price != null) {
|
||||||
@@ -222,34 +279,41 @@ class _OptionWidgetState extends State<OptionWidget> {
|
|||||||
separatorBuilder: (context, index) => const SizedBox(height: 8),
|
separatorBuilder: (context, index) => const SizedBox(height: 8),
|
||||||
reverse: false,
|
reverse: false,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final items = state.planData.options[selectedOptionIndex].items;
|
if (optionItems.isEmpty) {
|
||||||
|
|
||||||
if (items.isEmpty) {
|
|
||||||
return AddItemWidget(
|
return AddItemWidget(
|
||||||
|
key: ValueKey(
|
||||||
|
'plan-option-$selectedOptionIndex-item-$index',
|
||||||
|
),
|
||||||
index: index,
|
index: index,
|
||||||
canBeDeleted: false,
|
canBeDeleted: false,
|
||||||
enableHint: true,
|
enableHint: true,
|
||||||
items: items,
|
items: optionItems,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
if (index < items.length) {
|
if (index < optionItems.length) {
|
||||||
return AddItemWidget(
|
return AddItemWidget(
|
||||||
|
key: ValueKey(
|
||||||
|
'plan-option-$selectedOptionIndex-item-$index',
|
||||||
|
),
|
||||||
canBeDeleted: true,
|
canBeDeleted: true,
|
||||||
enableHint: false,
|
enableHint: false,
|
||||||
index: index,
|
index: index,
|
||||||
items: items,
|
items: optionItems,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return AddItemWidget(
|
return AddItemWidget(
|
||||||
|
key: ValueKey(
|
||||||
|
'plan-option-$selectedOptionIndex-item-$index',
|
||||||
|
),
|
||||||
canBeDeleted: false,
|
canBeDeleted: false,
|
||||||
enableHint: false,
|
enableHint: false,
|
||||||
index: index,
|
index: index,
|
||||||
items: items,
|
items: optionItems,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
itemCount: state.planData.options[selectedOptionIndex].items.length + 1,
|
itemCount: optionItems.length + 1,
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
),
|
),
|
||||||
@@ -259,23 +323,30 @@ class _OptionWidgetState extends State<OptionWidget> {
|
|||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Checkbox(
|
Checkbox(
|
||||||
value: state.planData.options[selectedOptionIndex].hasPersonalDiet,
|
value: state
|
||||||
|
.planData
|
||||||
|
.options[selectedOptionIndex]
|
||||||
|
.hasPersonalDiet,
|
||||||
activeColor: appTheme.gray,
|
activeColor: appTheme.gray,
|
||||||
checkColor: appTheme.gray,
|
checkColor: appTheme.gray,
|
||||||
fillColor: const WidgetStatePropertyAll(Colors.transparent),
|
fillColor: const WidgetStatePropertyAll(Colors.transparent),
|
||||||
side: WidgetStateBorderSide.resolveWith(
|
side: WidgetStateBorderSide.resolveWith((
|
||||||
(Set<WidgetState> states) {
|
Set<WidgetState> states,
|
||||||
return BorderSide(width: 1, color: appTheme.gray);
|
) {
|
||||||
},
|
return BorderSide(width: 1, color: appTheme.gray);
|
||||||
),
|
}),
|
||||||
onChanged: (bool? value) {
|
onChanged: (bool? value) {
|
||||||
context.read<CreatePlanCubit>().togglePersonalizedDietOptionState();
|
context
|
||||||
|
.read<CreatePlanCubit>()
|
||||||
|
.togglePersonalizedDietOptionState();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
s.option_personal_diet_desc,
|
s.option_personal_diet_desc,
|
||||||
style: TextStyles.textMMedium.copyWith(color: appTheme.black),
|
style: TextStyles.textMMedium.copyWith(
|
||||||
|
color: appTheme.black,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -292,7 +363,8 @@ class _PriceExplanationWidget extends StatefulWidget {
|
|||||||
const _PriceExplanationWidget();
|
const _PriceExplanationWidget();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_PriceExplanationWidget> createState() => _PriceExplanationWidgetState();
|
State<_PriceExplanationWidget> createState() =>
|
||||||
|
_PriceExplanationWidgetState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PriceExplanationWidgetState extends State<_PriceExplanationWidget> {
|
class _PriceExplanationWidgetState extends State<_PriceExplanationWidget> {
|
||||||
@@ -314,9 +386,7 @@ class _PriceExplanationWidgetState extends State<_PriceExplanationWidget> {
|
|||||||
color: visible ? appTheme.whiteA700 : Colors.transparent,
|
color: visible ? appTheme.whiteA700 : Colors.transparent,
|
||||||
),
|
),
|
||||||
duration: const Duration(milliseconds: 300),
|
duration: const Duration(milliseconds: 300),
|
||||||
child: Text(
|
child: Text(s.price_explanation),
|
||||||
s.price_explanation,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
visible: visible,
|
visible: visible,
|
||||||
@@ -331,7 +401,11 @@ class _PriceExplanationWidgetState extends State<_PriceExplanationWidget> {
|
|||||||
visible = !visible;
|
visible = !visible;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
child: SvgPicture.asset('assets/icons/question_mark.svg', height: 12, width: 12),
|
child: SvgPicture.asset(
|
||||||
|
'assets/icons/question_mark.svg',
|
||||||
|
height: 12,
|
||||||
|
width: 12,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:dotted_border/dotted_border.dart';
|
import 'package:dotted_border/dotted_border.dart';
|
||||||
|
import 'package:eatvio_app/core/app_config.dart';
|
||||||
import 'package:eatvio_app/core/app_router.dart';
|
import 'package:eatvio_app/core/app_router.dart';
|
||||||
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
import 'package:eatvio_app/core/permission_handler/permission_handler.dart';
|
import 'package:eatvio_app/core/permission_handler/permission_handler.dart';
|
||||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||||
@@ -91,6 +93,18 @@ class _PlanImagePickerWidgetState extends State<PlanImagePickerWidget> {
|
|||||||
final s = S.of(context);
|
final s = S.of(context);
|
||||||
return BlocBuilder<CreatePlanCubit, CreatePlanState>(
|
return BlocBuilder<CreatePlanCubit, CreatePlanState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
|
final selectedImage = state.planData.image;
|
||||||
|
final existingImage = context.read<CreatePlanCubit>().sourcePlan?.image;
|
||||||
|
final hasCover = selectedImage != null || (existingImage != null && existingImage.isNotEmpty);
|
||||||
|
final ImageProvider? coverImage;
|
||||||
|
if (selectedImage != null) {
|
||||||
|
coverImage = FileImage(File(selectedImage.path));
|
||||||
|
} else if (existingImage != null && existingImage.isNotEmpty) {
|
||||||
|
final url = existingImage.startsWith('http') ? existingImage : '${sl<AppConfig>().baseUrl}/storage/$existingImage';
|
||||||
|
coverImage = NetworkImage(url);
|
||||||
|
} else {
|
||||||
|
coverImage = null;
|
||||||
|
}
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
@@ -101,12 +115,12 @@ class _PlanImagePickerWidgetState extends State<PlanImagePickerWidget> {
|
|||||||
height: 240,
|
height: 240,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
image: state.planData.image != null ? DecorationImage(fit: BoxFit.cover, image: FileImage(File(state.planData.image?.path ?? ''))) : null,
|
image: coverImage != null ? DecorationImage(fit: BoxFit.cover, image: coverImage) : null,
|
||||||
color: appTheme.gray50,
|
color: appTheme.gray50,
|
||||||
),
|
),
|
||||||
width: MediaQuery.sizeOf(context).width,
|
width: MediaQuery.sizeOf(context).width,
|
||||||
padding: state.planData.image != null ? null : const EdgeInsets.all(8),
|
padding: hasCover ? null : const EdgeInsets.all(8),
|
||||||
child: state.planData.image != null
|
child: selectedImage != null
|
||||||
? Padding(
|
? Padding(
|
||||||
padding: const EdgeInsets.only(top: 6, right: 6),
|
padding: const EdgeInsets.only(top: 6, right: 6),
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
@@ -131,6 +145,8 @@ class _PlanImagePickerWidgetState extends State<PlanImagePickerWidget> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
: hasCover
|
||||||
|
? const SizedBox.shrink()
|
||||||
: Stack(
|
: Stack(
|
||||||
children: [
|
children: [
|
||||||
DottedBorder(
|
DottedBorder(
|
||||||
|
|||||||
@@ -11,7 +11,13 @@ class CreatePlanRepository {
|
|||||||
}) async {
|
}) async {
|
||||||
final formData = FormData.fromMap(model);
|
final formData = FormData.fromMap(model);
|
||||||
return await createPlanService.createPlan(formData: formData);
|
return await createPlanService.createPlan(formData: formData);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> updatePublishedPlan({
|
||||||
|
required String slug,
|
||||||
|
required Map<String, dynamic> model,
|
||||||
|
}) async {
|
||||||
|
final formData = FormData.fromMap(model);
|
||||||
|
return await createPlanService.updatePublishedPlan(slug: slug, formData: formData);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,5 +12,11 @@ abstract class CreatePlanService {
|
|||||||
Future<void> createPlan({
|
Future<void> createPlan({
|
||||||
@Body() required FormData formData,
|
@Body() required FormData formData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@POST('/api/plans/update/published/{slug}')
|
||||||
|
@MultiPart()
|
||||||
|
Future<void> updatePublishedPlan({
|
||||||
|
@Path() required String slug,
|
||||||
|
@Body() required FormData formData,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
38
lib/features/diary/presentation/diary_edit_access.dart
Normal file
38
lib/features/diary/presentation/diary_edit_access.dart
Normal 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);
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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(),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,8 +1,20 @@
|
|||||||
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/widgets/loader.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:webview_flutter/webview_flutter.dart';
|
import 'package:webview_flutter/webview_flutter.dart';
|
||||||
|
|
||||||
|
const _passKeyLoginPath = 'login_with_pass_key';
|
||||||
|
const _contentReadyProgress = 40;
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
bool shouldHideGlossaryLoader({required String url, required int progress}) {
|
||||||
|
if (url.isEmpty || url.contains(_passKeyLoginPath)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return progress >= _contentReadyProgress;
|
||||||
|
}
|
||||||
|
|
||||||
class GlossaryScreen extends StatefulWidget {
|
class GlossaryScreen extends StatefulWidget {
|
||||||
const GlossaryScreen({super.key, required this.passKey});
|
const GlossaryScreen({super.key, required this.passKey});
|
||||||
final String passKey;
|
final String passKey;
|
||||||
@@ -12,6 +24,8 @@ class GlossaryScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _GlossaryScreenState extends State<GlossaryScreen> {
|
class _GlossaryScreenState extends State<GlossaryScreen> {
|
||||||
late final WebViewController _controller;
|
late final WebViewController _controller;
|
||||||
|
bool _isLoading = true;
|
||||||
|
String _currentUrl = '';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -19,14 +33,47 @@ class _GlossaryScreenState extends State<GlossaryScreen> {
|
|||||||
|
|
||||||
_controller = WebViewController()
|
_controller = WebViewController()
|
||||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||||
|
..setNavigationDelegate(
|
||||||
|
NavigationDelegate(
|
||||||
|
onPageStarted: (url) {
|
||||||
|
_currentUrl = url;
|
||||||
|
},
|
||||||
|
onProgress: (progress) {
|
||||||
|
if (shouldHideGlossaryLoader(url: _currentUrl, progress: progress)) {
|
||||||
|
_hideLoader();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onPageFinished: (url) {
|
||||||
|
if (shouldHideGlossaryLoader(url: url, progress: 100)) {
|
||||||
|
_hideLoader();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
..loadRequest(Uri.parse('${sl<AppConfig>().baseUrl}/login_with_pass_key?id=${widget.passKey}'));
|
..loadRequest(Uri.parse('${sl<AppConfig>().baseUrl}/login_with_pass_key?id=${widget.passKey}'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _hideLoader() {
|
||||||
|
if (!_isLoading || !mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Глоссарий')),
|
appBar: AppBar(title: const Text('Глоссарий')),
|
||||||
body: WebViewWidget(controller: _controller),
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
WebViewWidget(controller: _controller),
|
||||||
|
if (_isLoading)
|
||||||
|
ColoredBox(
|
||||||
|
color: Theme.of(context).scaffoldBackgroundColor,
|
||||||
|
child: const SizedBox.expand(child: Loader()),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user