forked from eatvio/eatvio_app
Compare commits
39 Commits
video_call
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | |||
|
|
603a158cf2 | ||
| a1bae68307 |
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:
|
||||
runs-on: [macos-arm64]
|
||||
steps:
|
||||
# launchd does not load the login-shell PATH, so Homebrew git is invisible to act.
|
||||
- name: Add Homebrew to PATH
|
||||
run: |
|
||||
echo "/opt/homebrew/bin" >> "$GITHUB_PATH"
|
||||
echo "/opt/homebrew/sbin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Fastlane version
|
||||
run: fastlane --version
|
||||
run: |
|
||||
git --version
|
||||
fastlane --version
|
||||
|
||||
- name: Checkout
|
||||
uses: https://github.com/actions/checkout@v2
|
||||
|
||||
@@ -69,6 +69,7 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
implementation platform('com.google.firebase:firebase-bom:34.4.0')
|
||||
implementation 'com.google.firebase:firebase-messaging'
|
||||
implementation 'com.huawei.agconnect:agconnect-core:1.9.5.300'
|
||||
|
||||
@@ -87,8 +87,11 @@
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
|
||||
<!-- Tap on notification body opens CallkitIncomingActivity via PendingIntent.
|
||||
Replace it with a bridge that routes to the same accept flow as Answer. -->
|
||||
<!-- The incoming notification's content intent and full-screen intent both open
|
||||
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
|
||||
android:name="com.hiennv.flutter_callkit_incoming.CallkitIncomingActivity"
|
||||
tools:node="remove" />
|
||||
@@ -97,20 +100,26 @@
|
||||
android:name=".EatvioCallkitIncomingBridgeActivity"
|
||||
android:exported="false"
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar"
|
||||
android:taskAffinity=""
|
||||
android:allowTaskReparenting="false"
|
||||
android:excludeFromRecents="true"
|
||||
android:noHistory="true"
|
||||
android:showWhenLocked="true"
|
||||
android:turnScreenOn="true"
|
||||
android:launchMode="singleTop"
|
||||
android:launchMode="singleInstance"
|
||||
android:configChanges="orientation" />
|
||||
|
||||
<activity-alias
|
||||
android:name="com.hiennv.flutter_callkit_incoming.CallkitIncomingActivity"
|
||||
android:targetActivity=".EatvioCallkitIncomingBridgeActivity"
|
||||
android:exported="true"
|
||||
android:taskAffinity=""
|
||||
android:allowTaskReparenting="false"
|
||||
android:excludeFromRecents="true"
|
||||
android:noHistory="true"
|
||||
android:showWhenLocked="true"
|
||||
android:turnScreenOn="true"
|
||||
android:launchMode="singleTop"
|
||||
android:launchMode="singleInstance"
|
||||
android:configChanges="orientation"
|
||||
android:permission="${applicationId}.PERMISSION_CALL">
|
||||
<intent-filter>
|
||||
|
||||
@@ -8,6 +8,10 @@ import com.hiennv.flutter_callkit_incoming.CallkitIncomingBroadcastReceiver
|
||||
|
||||
object CallKitIntentBridge {
|
||||
fun dispatchCallKitAction(context: Context, action: String, data: Bundle?) {
|
||||
if (IncomingCallRingtonePolicy.actionStopsRingtone(action)) {
|
||||
VideoCallPushBridge.silenceIncomingCall(context)
|
||||
}
|
||||
|
||||
if (data != null) {
|
||||
CallKitAcceptIntentMapper.payloadFromBundle(data, action)?.let { payload ->
|
||||
PendingCallKitAcceptBridge.store(payload)
|
||||
|
||||
@@ -1,12 +1,71 @@
|
||||
package com.eatvioapp.app
|
||||
|
||||
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.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.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
|
||||
/// the Answer button, instead of showing the native Callkit incoming UI.
|
||||
/// Target of the incoming notification's content intent and full-screen intent.
|
||||
///
|
||||
/// 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() {
|
||||
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?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
@@ -15,15 +74,256 @@ class EatvioCallkitIncomingBridgeActivity : Activity() {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
callData = data
|
||||
|
||||
CallKitIntentBridge.dispatchCallKitAction(
|
||||
this,
|
||||
CallkitConstants.ACTION_CALL_ACCEPT,
|
||||
data,
|
||||
if (!launchedOnLockedOrDarkScreen()) {
|
||||
Log.i(TAG, "tap on notification body: accept")
|
||||
CallKitIntentBridge.dispatchCallKitAction(this, CallkitConstants.ACTION_CALL_ACCEPT, 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()
|
||||
@Suppress("DEPRECATION")
|
||||
overridePendingTransition(0, 0)
|
||||
val avatarUrl = data.getString(CallkitConstants.EXTRA_CALLKIT_AVATAR, "")
|
||||
if (!avatarUrl.isNullOrEmpty()) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.huawei.agconnect.config.AGConnectServicesConfig
|
||||
import com.huawei.hms.aaid.HmsInstanceId
|
||||
import com.huawei.hms.api.ConnectionResult
|
||||
import com.huawei.hms.api.HuaweiApiAvailability
|
||||
import com.huawei.hms.common.ApiException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -62,6 +65,21 @@ class MainActivity: FlutterActivity() {
|
||||
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" -> {
|
||||
result.success(PendingCallKitAcceptBridge.drain())
|
||||
}
|
||||
@@ -82,6 +100,20 @@ class MainActivity: FlutterActivity() {
|
||||
}
|
||||
methodChannel?.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"isHmsAvailable" -> {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val available = try {
|
||||
HuaweiApiAvailability.getInstance()
|
||||
.isHuaweiMobileServicesAvailable(applicationContext) == ConnectionResult.SUCCESS
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("HMS", "HMS availability check failed: ${e.message}")
|
||||
false
|
||||
}
|
||||
runOnUiThread {
|
||||
result.success(available)
|
||||
}
|
||||
}
|
||||
}
|
||||
"getHuaweiToken" -> {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
@@ -121,10 +153,30 @@ class MainActivity: FlutterActivity() {
|
||||
runOnUiThread {
|
||||
result.success(token)
|
||||
}
|
||||
} catch (e: ApiException) {
|
||||
if (e.statusCode == 907135003) {
|
||||
android.util.Log.w("HMS", "HMS client API invalid (${e.statusCode}), falling back to FCM")
|
||||
runOnUiThread {
|
||||
result.success(null)
|
||||
}
|
||||
} else {
|
||||
android.util.Log.e("HMS", "Error getting HMS token: ${e.statusCode}: ${e.message}", e)
|
||||
runOnUiThread {
|
||||
result.error("HMS_TOKEN_ERROR", "${e.statusCode}: ${e.message}", null)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("HMS", "Error getting HMS token: ${e.message}", e)
|
||||
runOnUiThread {
|
||||
result.error("HMS_TOKEN_ERROR", e.message, null)
|
||||
val message = e.message.orEmpty()
|
||||
if (message.contains("907135003") || message.contains("client api invalid", ignoreCase = true)) {
|
||||
android.util.Log.w("HMS", "HMS client API invalid, falling back to FCM")
|
||||
runOnUiThread {
|
||||
result.success(null)
|
||||
}
|
||||
} else {
|
||||
android.util.Log.e("HMS", "Error getting HMS token: $message", e)
|
||||
runOnUiThread {
|
||||
result.error("HMS_TOKEN_ERROR", e.message, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,12 @@ import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.PowerManager
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.hiennv.flutter_callkit_incoming.CallkitConstants
|
||||
import com.hiennv.flutter_callkit_incoming.CallkitEventCallback
|
||||
import com.hiennv.flutter_callkit_incoming.CallkitNotificationManager
|
||||
import com.hiennv.flutter_callkit_incoming.CallkitNotificationService
|
||||
import com.hiennv.flutter_callkit_incoming.CallkitSoundPlayerManager
|
||||
@@ -17,14 +19,38 @@ import com.hiennv.flutter_callkit_incoming.addCall
|
||||
import com.hiennv.flutter_callkit_incoming.getDataActiveCalls
|
||||
import com.hiennv.flutter_callkit_incoming.removeAllCalls
|
||||
import java.util.HashMap
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
object VideoCallPushBridge {
|
||||
private const val TAG = "VideoCallPushBridge"
|
||||
private const val ANDROID_ACCENT_COLOR = "#2787F5"
|
||||
private const val MISSED_CALL_CLEANUP_DELAY_MS = 250L
|
||||
private 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 var activeSoundManager: CallkitSoundPlayerManager? = null
|
||||
private var activeNotificationManager: CallkitNotificationManager? = null
|
||||
private var activeCallBundle: Bundle? = null
|
||||
private var storedAppContext: Context? = null
|
||||
private var ringtoneTimeout: Runnable? = null
|
||||
private var 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 {
|
||||
val rawType = data["type"]
|
||||
@@ -89,18 +115,133 @@ object VideoCallPushBridge {
|
||||
|
||||
fun showIncomingCall(context: Context, data: Map<String, String>) {
|
||||
val appContext = context.applicationContext
|
||||
storedAppContext = appContext
|
||||
ensureIncomingCallCallbackRegistered()
|
||||
|
||||
val callData = buildCallDataBundle(data)
|
||||
if (callData == null) {
|
||||
Log.w(TAG, "showIncomingCall: missing conversation uuid, data=$data")
|
||||
return
|
||||
}
|
||||
|
||||
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: conversation=$callId")
|
||||
|
||||
// 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 dismissAllIncomingCalls(context: Context) {
|
||||
val appContext = context.applicationContext
|
||||
val calls = getDataActiveCalls(appContext)
|
||||
Log.i(TAG, "dismissAllIncomingCalls: active calls=${calls.size}")
|
||||
playbackGeneration.incrementAndGet()
|
||||
|
||||
val dismiss = Runnable {
|
||||
synchronized(playbackLock) {
|
||||
val notificationManager = resolveNotificationManager(appContext)
|
||||
val bundles = calls.map { it.toBundle() }
|
||||
stopPlaybackLocked(appContext)
|
||||
|
||||
for (bundle in bundles) {
|
||||
try {
|
||||
dismissIncomingCallNotification(appContext, notificationManager, bundle)
|
||||
} catch (error: Exception) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"dismissAllIncomingCalls: failed for call ${bundle.getString(CallkitConstants.EXTRA_CALLKIT_ID)}",
|
||||
error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
CallkitNotificationService.stopService(appContext)
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "dismissAllIncomingCalls: failed to stop callkit service", error)
|
||||
}
|
||||
|
||||
try {
|
||||
removeAllCalls(appContext)
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "dismissAllIncomingCalls: failed to remove persisted calls", error)
|
||||
}
|
||||
|
||||
stopIncomingRingtone()
|
||||
scheduleMissedCallCleanup(notificationManager, bundles)
|
||||
}
|
||||
}
|
||||
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
dismiss.run()
|
||||
} else {
|
||||
mainHandler.post(dismiss)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureIncomingCallCallbackRegistered() {
|
||||
if (callbackRegistered) return
|
||||
FlutterCallkitIncomingPlugin.registerEventCallback(incomingCallEventCallback)
|
||||
callbackRegistered = true
|
||||
}
|
||||
|
||||
private fun startIncomingCallLocked(appContext: Context, callData: Bundle, generation: Int) {
|
||||
stopPlaybackLocked(appContext)
|
||||
|
||||
val soundManager = CallkitSoundPlayerManager(appContext)
|
||||
val notificationManager = CallkitNotificationManager(appContext, soundManager)
|
||||
activeSoundManager = soundManager
|
||||
activeNotificationManager = notificationManager
|
||||
activeCallBundle = callData
|
||||
notificationManager.createNotificationChanel(callData)
|
||||
notificationManager.showIncomingNotification(callData)
|
||||
|
||||
@@ -109,46 +250,78 @@ object VideoCallPushBridge {
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "showIncomingCall: failed to persist active call", error)
|
||||
}
|
||||
|
||||
val durationMs = callData.getLong(CallkitConstants.EXTRA_CALLKIT_DURATION, 45_000L)
|
||||
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
|
||||
val calls = getDataActiveCalls(appContext)
|
||||
Log.i(TAG, "dismissAllIncomingCalls: active calls=${calls.size}")
|
||||
/**
|
||||
* Turns the screen on while the call rings. The full-screen intent of the
|
||||
* notification does the same only when the system grants it; the wake lock
|
||||
* 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)
|
||||
val bundles = calls.map { it.toBundle() }
|
||||
|
||||
for (bundle in bundles) {
|
||||
private fun stopPlaybackLocked(appContext: Context) {
|
||||
cancelRingtoneTimeoutLocked()
|
||||
releaseScreenWakeLockLocked()
|
||||
val notificationManager = activeNotificationManager
|
||||
val bundle = activeCallBundle
|
||||
stopIncomingRingtone()
|
||||
if (notificationManager != null && bundle != null) {
|
||||
try {
|
||||
dismissIncomingCallNotification(appContext, notificationManager, bundle)
|
||||
} catch (error: Exception) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"dismissAllIncomingCalls: failed for call ${bundle.getString(CallkitConstants.EXTRA_CALLKIT_ID)}",
|
||||
error,
|
||||
)
|
||||
Log.w(TAG, "stopPlayback: failed to dismiss incoming notification", error)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
CallkitNotificationService.stopService(appContext)
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "dismissAllIncomingCalls: failed to stop callkit service", error)
|
||||
}
|
||||
|
||||
try {
|
||||
removeAllCalls(appContext)
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "dismissAllIncomingCalls: failed to remove persisted calls", error)
|
||||
}
|
||||
|
||||
stopIncomingRingtone(appContext)
|
||||
scheduleMissedCallCleanup(notificationManager, bundles)
|
||||
activeSoundManager = null
|
||||
activeNotificationManager = null
|
||||
activeCallBundle = null
|
||||
EatvioCallkitIncomingBridgeActivity.dismissRingingScreen()
|
||||
}
|
||||
|
||||
private fun cancelRingtoneTimeoutLocked() {
|
||||
ringtoneTimeout?.let { mainHandler.removeCallbacks(it) }
|
||||
ringtoneTimeout = null
|
||||
}
|
||||
|
||||
private fun resolveNotificationManager(context: Context): CallkitNotificationManager {
|
||||
@@ -161,11 +334,19 @@ object VideoCallPushBridge {
|
||||
return CallkitNotificationManager(context, soundManager)
|
||||
}
|
||||
|
||||
private fun stopIncomingRingtone(context: Context) {
|
||||
activeSoundManager?.stop()
|
||||
FlutterCallkitIncomingPlugin.getInstance()
|
||||
?.getCallkitSoundPlayerManager()
|
||||
?.stop()
|
||||
private fun stopIncomingRingtone() {
|
||||
try {
|
||||
activeSoundManager?.stop()
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "stopIncomingRingtone: active player", error)
|
||||
}
|
||||
try {
|
||||
FlutterCallkitIncomingPlugin.getInstance()
|
||||
?.getCallkitSoundPlayerManager()
|
||||
?.stop()
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "stopIncomingRingtone: plugin player", error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||
46B43B96F809CEC6058EFDD3 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 15ADDB2A35F334AF0AE50011 /* Pods_Runner.framework */; };
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||
C4A8E2022E9A4B1000123456 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A8E2012E9A4B1000123456 /* SceneDelegate.swift */; };
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||
@@ -37,6 +38,7 @@
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
C4A8E2012E9A4B1000123456 /* SceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||
938EE8FB5BAA359AAEDEFFF8 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||
@@ -115,6 +117,7 @@
|
||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||
C4A8E2012E9A4B1000123456 /* SceneDelegate.swift */,
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||
FEE92A0E2D772EA300E6E989 /* Runner.entitlements */,
|
||||
);
|
||||
@@ -300,6 +303,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||
C4A8E2022E9A4B1000123456 /* SceneDelegate.swift in Sources */,
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
||||
@@ -15,12 +15,32 @@ import flutter_foreground_task
|
||||
private var voipDiagnosticsChannel: FlutterMethodChannel?
|
||||
private let voipDiagnosticsStorageKey = "EatvioVoipDiagnostics"
|
||||
private let pendingAcceptedVoipCallKey = "EatvioPendingAcceptedVoipCall"
|
||||
private let pendingAcceptedAtKey = "eatvioAcceptedAt"
|
||||
private let pendingAcceptedCallTtl: TimeInterval = 120
|
||||
private let voipDiagnosticsChannelName = "com.eatvioapp/voip_diagnostics"
|
||||
private let callAudioSessionChannelName = "com.eatvioapp/call_audio_session"
|
||||
private let appUrlScheme = "eatvio"
|
||||
/// CallKit is only the ringing UI. After accept the in-app call continues,
|
||||
/// so the audio session must be handed back to WebRTC when CallKit deactivates.
|
||||
private var resumeAudioAfterCallKitEnds = false
|
||||
/// The push handler and the Flutter channel touch this map on different
|
||||
/// queues. The push writes the id before CallKit has the call, so the
|
||||
/// in-app path can see it and not report the same call again.
|
||||
private var reservedIncomingCallIds: [String: Date] = [:]
|
||||
private let incomingCallClaimLock = NSLock()
|
||||
private let incomingCallClaimTtl: TimeInterval = 90
|
||||
private let voipPushQueue = DispatchQueue(label: "com.eatvioapp.voip")
|
||||
private var voipCallReporterStorage: VoipCallReporter?
|
||||
private var audioRestartGeneration = 0
|
||||
|
||||
private var voipCallReporter: VoipCallReporter {
|
||||
if let voipCallReporterStorage {
|
||||
return voipCallReporterStorage
|
||||
}
|
||||
let reporter = VoipCallReporter()
|
||||
voipCallReporterStorage = reporter
|
||||
return reporter
|
||||
}
|
||||
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
@@ -31,10 +51,12 @@ import flutter_foreground_task
|
||||
GeneratedPluginRegistrant.register(with: registry)
|
||||
}
|
||||
|
||||
let mainQueue = DispatchQueue.main
|
||||
voipRegistry = PKPushRegistry(queue: mainQueue)
|
||||
// Not the main queue. Flutter startup blocks main, and PushKit aborts the
|
||||
// process if this push is not reported before that returns.
|
||||
voipRegistry = PKPushRegistry(queue: voipPushQueue)
|
||||
voipRegistry?.delegate = self
|
||||
voipRegistry?.desiredPushTypes = [PKPushType.voIP]
|
||||
configureWebRTCAudioSession()
|
||||
|
||||
if #available(iOS 10.0, *) {
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
@@ -42,50 +64,81 @@ import flutter_foreground_task
|
||||
|
||||
let launched = super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
|
||||
if let controller = window?.rootViewController as? FlutterViewController {
|
||||
let channel = FlutterMethodChannel(
|
||||
name: voipDiagnosticsChannelName,
|
||||
binaryMessenger: controller.binaryMessenger
|
||||
)
|
||||
voipDiagnosticsChannel = channel
|
||||
channel.setMethodCallHandler { [weak self] call, result in
|
||||
guard let self else {
|
||||
result(nil)
|
||||
return
|
||||
}
|
||||
switch call.method {
|
||||
case "drainVoipDiagnostics":
|
||||
result(self.drainVoipDiagnostics())
|
||||
case "drainPendingAcceptedVoipCall":
|
||||
result(self.drainPendingAcceptedVoipCall())
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
// WebRTC must not activate the session itself. CallKit and the in-app
|
||||
// call both own AVAudioSession, and a second activation drops the mic.
|
||||
let rtcSession = RTCAudioSession.sharedInstance()
|
||||
rtcSession.useManualAudio = true
|
||||
rtcSession.isAudioEnabled = false
|
||||
|
||||
let callAudioChannel = FlutterMethodChannel(
|
||||
name: callAudioSessionChannelName,
|
||||
binaryMessenger: controller.binaryMessenger
|
||||
)
|
||||
callAudioChannel.setMethodCallHandler { [weak self] call, result in
|
||||
guard let self else {
|
||||
result(nil)
|
||||
return
|
||||
}
|
||||
switch call.method {
|
||||
case "activate":
|
||||
self.activateInAppCallAudioSession()
|
||||
result(nil)
|
||||
case "deactivate":
|
||||
self.deactivateInAppCallAudioSession()
|
||||
result(nil)
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
registerCallChannels(from: window?.rootViewController as? FlutterViewController)
|
||||
|
||||
return launched
|
||||
}
|
||||
|
||||
func registerCallChannels(from controller: FlutterViewController?) {
|
||||
guard voipDiagnosticsChannel == nil else { return }
|
||||
let flutterController = controller ?? flutterViewControllerFromScenes()
|
||||
guard let flutterController else { return }
|
||||
|
||||
let channel = FlutterMethodChannel(
|
||||
name: voipDiagnosticsChannelName,
|
||||
binaryMessenger: flutterController.binaryMessenger
|
||||
)
|
||||
voipDiagnosticsChannel = channel
|
||||
channel.setMethodCallHandler { [weak self] call, result in
|
||||
guard let self else {
|
||||
result(nil)
|
||||
return
|
||||
}
|
||||
switch call.method {
|
||||
case "drainVoipDiagnostics":
|
||||
result(self.drainVoipDiagnostics())
|
||||
case "drainPendingAcceptedVoipCall":
|
||||
result(self.drainPendingAcceptedVoipCall())
|
||||
case "isIncomingCallClaimed":
|
||||
result(self.isIncomingCallClaimed(self.incomingCallId(from: call.arguments)))
|
||||
case "reserveIncomingCall":
|
||||
result(self.reserveIncomingCall(self.incomingCallId(from: call.arguments)))
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
|
||||
return launched
|
||||
let callAudioChannel = FlutterMethodChannel(
|
||||
name: callAudioSessionChannelName,
|
||||
binaryMessenger: flutterController.binaryMessenger
|
||||
)
|
||||
callAudioChannel.setMethodCallHandler { [weak self] call, result in
|
||||
guard let self else {
|
||||
result(nil)
|
||||
return
|
||||
}
|
||||
switch call.method {
|
||||
case "activate":
|
||||
self.activateInAppCallAudioSession()
|
||||
result(nil)
|
||||
case "restart":
|
||||
self.restartInAppCallAudioUnit()
|
||||
result(nil)
|
||||
case "deactivate":
|
||||
self.deactivateInAppCallAudioSession()
|
||||
result(nil)
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func flutterViewControllerFromScenes() -> FlutterViewController? {
|
||||
for scene in UIApplication.shared.connectedScenes {
|
||||
guard let windowScene = scene as? UIWindowScene else { continue }
|
||||
for sceneWindow in windowScene.windows {
|
||||
if let controller = sceneWindow.rootViewController as? FlutterViewController {
|
||||
return controller
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@available(iOS 8.0, *)
|
||||
@@ -109,6 +162,14 @@ import flutter_foreground_task
|
||||
open url: URL,
|
||||
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
|
||||
) -> Bool {
|
||||
handleOpenURL(
|
||||
url,
|
||||
sourceApplication: options[UIApplication.OpenURLOptionsKey.sourceApplication] as? String
|
||||
)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func handleOpenURL(_ url: URL, sourceApplication: String?) -> Bool {
|
||||
if url.scheme == appUrlScheme {
|
||||
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||
let queryItems = components?.queryItems ?? []
|
||||
@@ -142,7 +203,7 @@ import flutter_foreground_task
|
||||
|
||||
return YXLSdk.shared.handleOpen(
|
||||
url,
|
||||
sourceApplication: options[UIApplication.OpenURLOptionsKey.sourceApplication] as? String
|
||||
sourceApplication: sourceApplication
|
||||
)
|
||||
}
|
||||
|
||||
@@ -175,6 +236,26 @@ import flutter_foreground_task
|
||||
return nil
|
||||
}
|
||||
|
||||
private func voipBool(_ 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 {
|
||||
switch text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
|
||||
case "1", "true", "yes":
|
||||
return true
|
||||
case "0", "false", "no":
|
||||
return false
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func conversationUuid(fromActionUrl actionUrl: String?) -> String? {
|
||||
guard
|
||||
let actionUrl = actionUrl,
|
||||
@@ -223,16 +304,31 @@ import flutter_foreground_task
|
||||
}
|
||||
|
||||
private func storePendingAcceptedCall(_ call: Call) {
|
||||
let payload = sanitizedDiagnosticValue(call.data.toJSON()) as? [String: Any] ?? [:]
|
||||
var payload = sanitizedDiagnosticValue(call.data.toJSON()) as? [String: Any] ?? [:]
|
||||
payload[pendingAcceptedAtKey] = Date().timeIntervalSince1970
|
||||
UserDefaults.standard.set(payload, forKey: pendingAcceptedVoipCallKey)
|
||||
}
|
||||
|
||||
/// An accept that Flutter never consumed (the process died before the
|
||||
/// scene opened) must not open the call screen on a later launch: that
|
||||
/// screen requests a token, and the token request rings the other person.
|
||||
private func drainPendingAcceptedVoipCall() -> [String: Any]? {
|
||||
let defaults = UserDefaults.standard
|
||||
guard let payload = defaults.dictionary(forKey: pendingAcceptedVoipCallKey) else {
|
||||
guard var payload = defaults.dictionary(forKey: pendingAcceptedVoipCallKey) else {
|
||||
return nil
|
||||
}
|
||||
defaults.removeObject(forKey: pendingAcceptedVoipCallKey)
|
||||
let acceptedAt = (payload.removeValue(forKey: pendingAcceptedAtKey) as? NSNumber)?.doubleValue
|
||||
if let acceptedAt, Date().timeIntervalSince1970 - acceptedAt > pendingAcceptedCallTtl {
|
||||
recordVoipDiagnostic(
|
||||
"pending_accepted_call_expired",
|
||||
data: [
|
||||
"callKitId": payload["id"] as? String ?? "",
|
||||
"ageSeconds": Date().timeIntervalSince1970 - acceptedAt,
|
||||
]
|
||||
)
|
||||
return nil
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
@@ -338,7 +434,9 @@ import flutter_foreground_task
|
||||
events = Array(events.suffix(20))
|
||||
}
|
||||
defaults.set(events, forKey: voipDiagnosticsStorageKey)
|
||||
voipDiagnosticsChannel?.invokeMethod("voipDiagnosticsUpdated", arguments: nil)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.voipDiagnosticsChannel?.invokeMethod("voipDiagnosticsUpdated", arguments: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func drainVoipDiagnostics() -> [[String: Any]] {
|
||||
@@ -348,6 +446,167 @@ import flutter_foreground_task
|
||||
return events
|
||||
}
|
||||
|
||||
private func isEndedVideoCallMarker(_ marker: String) -> Bool {
|
||||
if marker == "video_call_ended" || marker == "video_call_end" {
|
||||
return true
|
||||
}
|
||||
return marker.contains("video_call")
|
||||
&& (marker.contains("ended") || marker.hasSuffix("_end") || marker.hasSuffix("_cancel"))
|
||||
}
|
||||
|
||||
private func incomingCallId(from arguments: Any?) -> String {
|
||||
let raw: String?
|
||||
if let id = arguments as? String {
|
||||
raw = id
|
||||
} else if let map = arguments as? [String: Any] {
|
||||
raw = map["id"] as? String
|
||||
} else {
|
||||
raw = nil
|
||||
}
|
||||
return raw?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? ""
|
||||
}
|
||||
|
||||
/// Caller must hold `incomingCallClaimLock`.
|
||||
private func pruneIncomingCallClaims(now: Date = Date()) {
|
||||
reservedIncomingCallIds = reservedIncomingCallIds.filter {
|
||||
now.timeIntervalSince($0.value) < incomingCallClaimTtl
|
||||
}
|
||||
}
|
||||
|
||||
private func activeIncomingCallIds() -> Set<String> {
|
||||
let calls = SwiftFlutterCallkitIncomingPlugin.sharedInstance?.activeCalls() ?? []
|
||||
return Set(calls.compactMap { ($0["id"] as? String)?.lowercased() })
|
||||
}
|
||||
|
||||
private func isIncomingCallClaimed(_ id: String) -> Bool {
|
||||
let normalized = id.lowercased()
|
||||
guard !normalized.isEmpty else { return false }
|
||||
let reserved = incomingCallClaimLock.withLock { () -> Bool in
|
||||
pruneIncomingCallClaims()
|
||||
return reservedIncomingCallIds[normalized] != nil
|
||||
}
|
||||
if reserved {
|
||||
return true
|
||||
}
|
||||
return activeIncomingCallIds().contains(normalized)
|
||||
}
|
||||
|
||||
/// Returns false when this id is already reserved. The VoIP push still
|
||||
/// reports the call; this only stops the in-app path from reporting it too.
|
||||
private func reserveIncomingCall(_ id: String) -> Bool {
|
||||
let normalized = id.lowercased()
|
||||
guard !normalized.isEmpty else { return false }
|
||||
return incomingCallClaimLock.withLock {
|
||||
pruneIncomingCallClaims()
|
||||
if reservedIncomingCallIds[normalized] != nil {
|
||||
return false
|
||||
}
|
||||
reservedIncomingCallIds[normalized] = Date()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private func releaseIncomingCallClaim(_ id: String) {
|
||||
incomingCallClaimLock.withLock {
|
||||
_ = reservedIncomingCallIds.removeValue(forKey: id.lowercased())
|
||||
}
|
||||
}
|
||||
|
||||
/// An ended VoIP push has no message id, so its CallKit id is the
|
||||
/// conversation uuid, not the ringing call. End that conversation only.
|
||||
/// The first endCall while the push flag is set closes the call this push
|
||||
/// just reported; the next ones close the call that is actually ringing.
|
||||
private func endCallsForEndedPush(reportedId: String, conversationUuid: String) {
|
||||
guard let plugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance else {
|
||||
releaseIncomingCallClaim(reportedId)
|
||||
return
|
||||
}
|
||||
let conversation = conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
var ids: [String] = []
|
||||
func append(_ id: String) {
|
||||
let normalized = id.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalized.isEmpty else { return }
|
||||
if ids.contains(where: { $0.caseInsensitiveCompare(normalized) == .orderedSame }) {
|
||||
return
|
||||
}
|
||||
ids.append(normalized)
|
||||
}
|
||||
append(reportedId)
|
||||
for call in plugin.activeCalls() {
|
||||
let callId = (call["id"] as? String) ?? ""
|
||||
let handle = (call["handle"] as? String) ?? ""
|
||||
let extraUuid = extraConversationUuid(call["extra"])
|
||||
let sameId = callId.caseInsensitiveCompare(reportedId) == .orderedSame
|
||||
let sameConversation = !conversation.isEmpty && (
|
||||
handle.caseInsensitiveCompare(conversation) == .orderedSame
|
||||
|| extraUuid.caseInsensitiveCompare(conversation) == .orderedSame
|
||||
)
|
||||
if sameId || sameConversation {
|
||||
append(callId)
|
||||
}
|
||||
}
|
||||
for id in ids {
|
||||
let endData = flutter_callkit_incoming.Data(
|
||||
id: id,
|
||||
nameCaller: "Итвио",
|
||||
handle: conversation,
|
||||
type: 1
|
||||
)
|
||||
// The plugin sends this payload with its "ended" event. Dart uses the
|
||||
// marker to tell a remote end from a decline by the user.
|
||||
var endExtra: [String: Any] = ["endedPush": true]
|
||||
if !conversation.isEmpty {
|
||||
endExtra["conversationUuid"] = conversation
|
||||
}
|
||||
endData.extra = endExtra as NSDictionary
|
||||
plugin.endCall(endData)
|
||||
releaseIncomingCallClaim(id)
|
||||
}
|
||||
}
|
||||
|
||||
private func activeCallId(forConversation conversationUuid: String) -> String? {
|
||||
let conversation = conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !conversation.isEmpty else { return nil }
|
||||
let calls = SwiftFlutterCallkitIncomingPlugin.sharedInstance?.activeCalls() ?? []
|
||||
for call in calls {
|
||||
let callId = (call["id"] as? String) ?? ""
|
||||
guard !callId.isEmpty else { continue }
|
||||
let handle = (call["handle"] as? String) ?? ""
|
||||
let extraUuid = extraConversationUuid(call["extra"])
|
||||
if handle.caseInsensitiveCompare(conversation) == .orderedSame
|
||||
|| extraUuid.caseInsensitiveCompare(conversation) == .orderedSame
|
||||
{
|
||||
return callId
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func extraConversationUuid(_ extra: Any?) -> String {
|
||||
let map: [String: Any]
|
||||
if let typed = extra as? [String: Any] {
|
||||
map = typed
|
||||
} else if let object = extra as? NSDictionary {
|
||||
var converted: [String: Any] = [:]
|
||||
for (key, value) in object {
|
||||
if let key = key as? String {
|
||||
converted[key] = value
|
||||
}
|
||||
}
|
||||
map = converted
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
for key in ["conversationUuid", "conversation_uuid"] {
|
||||
if let value = map[key] as? String {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func pushRegistry(
|
||||
_ registry: PKPushRegistry,
|
||||
@@ -355,11 +614,15 @@ import flutter_foreground_task
|
||||
for type: PKPushType
|
||||
) {
|
||||
let deviceToken = credentials.token.map { String(format: "%02x", $0) }.joined()
|
||||
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP(deviceToken)
|
||||
DispatchQueue.main.async {
|
||||
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP(deviceToken)
|
||||
}
|
||||
}
|
||||
|
||||
func pushRegistry(_ registry: PKPushRegistry, didInvalidatePushTokenFor type: PKPushType) {
|
||||
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP("")
|
||||
DispatchQueue.main.async {
|
||||
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP("")
|
||||
}
|
||||
}
|
||||
|
||||
func pushRegistry(
|
||||
@@ -384,8 +647,8 @@ import flutter_foreground_task
|
||||
]
|
||||
.compactMap { $0?.lowercased().replacingOccurrences(of: "-", with: "_") }
|
||||
|
||||
if pushMarkers.contains(where: { $0.contains("video_call") })
|
||||
&& pushMarkers.contains(where: { $0.contains("end") || $0.contains("cancel") }) {
|
||||
let isEndedPush = pushMarkers.contains(where: { self.isEndedVideoCallMarker($0) })
|
||||
if isEndedPush {
|
||||
recordVoipDiagnostic(
|
||||
"voip_end_or_cancel_received",
|
||||
data: [
|
||||
@@ -393,9 +656,6 @@ import flutter_foreground_task
|
||||
"payload": dict,
|
||||
]
|
||||
)
|
||||
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.endAllCalls()
|
||||
completion()
|
||||
return
|
||||
}
|
||||
|
||||
let rawNameCaller = (dict["nameCaller"] as? String)
|
||||
@@ -420,14 +680,20 @@ import flutter_foreground_task
|
||||
let parsedMessageId = voipMessageId(dict["message_id"])
|
||||
let id: String
|
||||
let callKitIdSourceName: String
|
||||
if let derived = derivedCallKitId(
|
||||
if isEndedPush, let existing = activeCallId(forConversation: trimmedConversationUuid) {
|
||||
id = existing
|
||||
callKitIdSourceName = "active_conversation"
|
||||
} else if let derived = derivedCallKitId(
|
||||
conversationUuid: trimmedConversationUuid,
|
||||
messageId: parsedMessageId
|
||||
) {
|
||||
id = derived
|
||||
callKitIdSourceName = "conversation_and_message"
|
||||
} else if UUID(uuidString: trimmedConversationUuid) != nil {
|
||||
id = trimmedConversationUuid.lowercased()
|
||||
callKitIdSourceName = "conversation"
|
||||
} else {
|
||||
id = UUID().uuidString
|
||||
id = UUID().uuidString.lowercased()
|
||||
callKitIdSourceName = "generated"
|
||||
}
|
||||
let isVideo = (dict["isVideo"] as? Bool)
|
||||
@@ -464,6 +730,18 @@ import flutter_foreground_task
|
||||
} else if !nameCaller.isEmpty {
|
||||
extra["counterpartName"] = nameCaller
|
||||
}
|
||||
if let isConsultant = voipBool(dict["is_consultant"] ?? dict["isConsultant"]) {
|
||||
extra["counterpartIsConsultant"] = isConsultant
|
||||
}
|
||||
// Closing this report looks like a user decline. Dart must not tell the
|
||||
// backend, or the server sends another ended VoIP and the other phone
|
||||
// shows it as a new incoming call.
|
||||
if isEndedPush {
|
||||
extra["endedPush"] = true
|
||||
}
|
||||
// An ended push with no ringing call still has to be reported. That
|
||||
// report is closed at once, so it must not leave a trace anywhere.
|
||||
let isPhantomEndedReport = isEndedPush && callKitIdSourceName != "active_conversation"
|
||||
|
||||
let data = flutter_callkit_incoming.Data(
|
||||
id: id,
|
||||
@@ -471,8 +749,27 @@ import flutter_foreground_task
|
||||
handle: trimmedConversationUuid.isEmpty ? nameCaller : trimmedConversationUuid,
|
||||
type: isVideo ? 1 : 0
|
||||
)
|
||||
data.appName = "Итвио"
|
||||
data.handleType = "generic"
|
||||
data.supportsVideo = true
|
||||
data.duration = 45000
|
||||
data.extra = extra as NSDictionary
|
||||
data.missedNotificationSubtitle = "Пропущенный видеозвонок"
|
||||
data.missedNotificationCallbackText = "Перезвонить"
|
||||
data.isShowMissedCallNotification = !isEndedPush
|
||||
data.includesCallsInRecents = !isPhantomEndedReport
|
||||
// The plugin activates AVAudioSession itself on answer and again 1.2s
|
||||
// later. That drops the ringing UI and leaves one side silent. CallKit
|
||||
// and WebRTC own the session instead.
|
||||
data.configureAudioSession = false
|
||||
data.audioSessionActive = false
|
||||
data.audioSessionMode = "videoChat"
|
||||
// Do not set ringtonePath. The plugin stores that string as the ringtone
|
||||
// file name. "system_ringtone_default" is not in the app, so CallKit ends
|
||||
// the call at once and PushKit delivers the same push again.
|
||||
// Remember the id so the in-app socket path does not report it again.
|
||||
// The push itself must still be reported, or iOS kills the process.
|
||||
_ = reserveIncomingCall(id)
|
||||
|
||||
NSLog(
|
||||
"[EatvioVoIP] report incoming call id=\(id) conversation=\(conversationUuid) state=\(UIApplication.shared.applicationState.rawValue) markers=\(pushMarkers.joined(separator: ",")) payload=\(dict)"
|
||||
@@ -490,53 +787,102 @@ import flutter_foreground_task
|
||||
]
|
||||
)
|
||||
|
||||
guard let callkitPlugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance else {
|
||||
NSLog("[EatvioVoIP] CallKit plugin missing id=\(id)")
|
||||
recordVoipDiagnostic(
|
||||
"callkit_plugin_missing",
|
||||
data: [
|
||||
"callKitId": id,
|
||||
"conversationUuid": conversationUuid,
|
||||
]
|
||||
)
|
||||
completion()
|
||||
return
|
||||
reportVoipPushToCallKit(
|
||||
data,
|
||||
callKitId: id,
|
||||
callKitIdSourceName: callKitIdSourceName,
|
||||
conversationUuid: conversationUuid,
|
||||
endAfterReport: isEndedPush,
|
||||
endImmediately: isPhantomEndedReport,
|
||||
completion: completion
|
||||
)
|
||||
}
|
||||
|
||||
/// PushKit kills the app unless every VoIP push is reported to CallKit and
|
||||
/// its completion handler runs. Both happen here, including an "ended" push.
|
||||
///
|
||||
/// `endImmediately` is for an ended push that matched no ringing call. The
|
||||
/// report is closed inside its own completion, before the incoming UI has a
|
||||
/// chance to show. An end transaction would take one more round trip and
|
||||
/// would also reach `onDecline`, which resets the in-app audio restart.
|
||||
private func reportVoipPushToCallKit(
|
||||
_ data: flutter_callkit_incoming.Data,
|
||||
callKitId: String,
|
||||
callKitIdSourceName: String,
|
||||
conversationUuid: String,
|
||||
endAfterReport: Bool,
|
||||
endImmediately: Bool,
|
||||
completion: @escaping () -> Void
|
||||
) {
|
||||
let gate = VoipPushCompletion(completion)
|
||||
let finish = { self.voipPushQueue.async { gate.call() } }
|
||||
voipPushQueue.asyncAfter(deadline: .now() + 1.5) {
|
||||
finish()
|
||||
}
|
||||
|
||||
callkitPlugin.showCallkitIncoming(
|
||||
data,
|
||||
fromPushKit: true
|
||||
) {
|
||||
let activeCalls = callkitPlugin.activeCalls()
|
||||
let activeIds = activeCalls.compactMap { $0["id"] as? String }.joined(separator: ",")
|
||||
NSLog(
|
||||
"[EatvioVoIP] report incoming completed id=\(id) activeCallIds=\(activeIds)"
|
||||
)
|
||||
self.recordVoipDiagnostic(
|
||||
"callkit_report_completed",
|
||||
data: [
|
||||
"callKitId": id,
|
||||
"callKitIdSource": callKitIdSourceName,
|
||||
"conversationUuid": conversationUuid,
|
||||
"activeCallIds": activeIds,
|
||||
"activeCallsCount": activeCalls.count,
|
||||
]
|
||||
)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||
let delayedActiveCalls = callkitPlugin.activeCalls()
|
||||
let delayedActiveIds = delayedActiveCalls.compactMap { $0["id"] as? String }.joined(separator: ",")
|
||||
let afterReport = {
|
||||
if endAfterReport, endImmediately,
|
||||
let plugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance
|
||||
{
|
||||
// Reason 6 is CXCallEndedReason.remoteEnded in the plugin.
|
||||
plugin.saveEndCall(callKitId, 6)
|
||||
}
|
||||
if endAfterReport {
|
||||
DispatchQueue.main.async {
|
||||
self.endCallsForEndedPush(
|
||||
reportedId: callKitId,
|
||||
conversationUuid: conversationUuid
|
||||
)
|
||||
}
|
||||
}
|
||||
if let plugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance {
|
||||
let activeCalls = plugin.activeCalls()
|
||||
let activeIds = activeCalls.compactMap { $0["id"] as? String }.joined(separator: ",")
|
||||
self.recordVoipDiagnostic(
|
||||
"callkit_active_calls_delayed",
|
||||
"callkit_report_completed",
|
||||
data: [
|
||||
"callKitId": id,
|
||||
"callKitId": callKitId,
|
||||
"callKitIdSource": callKitIdSourceName,
|
||||
"conversationUuid": conversationUuid,
|
||||
"activeCallIds": delayedActiveIds,
|
||||
"activeCallsCount": delayedActiveCalls.count,
|
||||
"activeCallIds": activeIds,
|
||||
"activeCallsCount": activeCalls.count,
|
||||
"endAfterReport": endAfterReport,
|
||||
]
|
||||
)
|
||||
}
|
||||
completion()
|
||||
finish()
|
||||
}
|
||||
|
||||
if let plugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance {
|
||||
plugin.showCallkitIncoming(data, fromPushKit: true, completion: afterReport)
|
||||
return
|
||||
}
|
||||
|
||||
NSLog("[EatvioVoIP] CallKit plugin missing id=\(callKitId)")
|
||||
recordVoipDiagnostic(
|
||||
"callkit_plugin_missing",
|
||||
data: [
|
||||
"callKitId": callKitId,
|
||||
"conversationUuid": conversationUuid,
|
||||
]
|
||||
)
|
||||
guard let uuid = UUID(uuidString: data.uuid) else {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
voipCallReporter.report(
|
||||
uuid: uuid,
|
||||
callerName: data.nameCaller,
|
||||
hasVideo: data.type > 0
|
||||
) { _ in
|
||||
if endAfterReport {
|
||||
self.voipCallReporter.provider.reportCall(
|
||||
with: uuid,
|
||||
endedAt: Date(),
|
||||
reason: .remoteEnded
|
||||
)
|
||||
}
|
||||
afterReport()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -563,6 +909,7 @@ import flutter_foreground_task
|
||||
|
||||
func onDecline(_ call: Call, _ action: CXEndCallAction) {
|
||||
resumeAudioAfterCallKitEnds = false
|
||||
audioRestartGeneration += 1
|
||||
action.fulfill()
|
||||
}
|
||||
|
||||
@@ -574,50 +921,76 @@ import flutter_foreground_task
|
||||
|
||||
func didActivateAudioSession(_ audioSession: AVAudioSession) {
|
||||
let rtcSession = RTCAudioSession.sharedInstance()
|
||||
rtcSession.useManualAudio = true
|
||||
rtcSession.audioSessionDidActivate(audioSession)
|
||||
applyCallAudioConfiguration(active: false)
|
||||
rtcSession.isAudioEnabled = true
|
||||
}
|
||||
|
||||
func didDeactivateAudioSession(_ audioSession: AVAudioSession) {
|
||||
let rtcSession = RTCAudioSession.sharedInstance()
|
||||
rtcSession.audioSessionDidDeactivate(audioSession)
|
||||
// The audio unit is stopped, but isAudioEnabled stays true. A later
|
||||
// assignment of true is then a no-op and the microphone never restarts.
|
||||
rtcSession.isAudioEnabled = false
|
||||
guard resumeAudioAfterCallKitEnds else { return }
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, self.resumeAudioAfterCallKitEnds else { return }
|
||||
self.activateInAppCallAudioSession()
|
||||
self.restartInAppCallAudioUnit()
|
||||
}
|
||||
}
|
||||
|
||||
func providerDidReset() {
|
||||
resumeAudioAfterCallKitEnds = false
|
||||
audioRestartGeneration += 1
|
||||
}
|
||||
|
||||
private func configureWebRTCAudioSession() {
|
||||
let configuration = callAudioConfiguration()
|
||||
RTCAudioSessionConfiguration.setWebRTC(configuration)
|
||||
}
|
||||
|
||||
private func prepareCallAudioCategory() {
|
||||
let rtcSession = RTCAudioSession.sharedInstance()
|
||||
rtcSession.lockForConfiguration()
|
||||
defer { rtcSession.unlockForConfiguration() }
|
||||
let configuration = callAudioConfiguration()
|
||||
do {
|
||||
try rtcSession.setConfiguration(configuration)
|
||||
} catch {
|
||||
NSLog("[EatvioVoIP] prepare call audio category failed: \(error)")
|
||||
}
|
||||
applyCallAudioConfiguration(active: false)
|
||||
}
|
||||
|
||||
private func activateInAppCallAudioSession() {
|
||||
let rtcSession = RTCAudioSession.sharedInstance()
|
||||
rtcSession.useManualAudio = true
|
||||
applyCallAudioConfiguration(active: true)
|
||||
// Starting the audio unit while the configuration lock is held deadlocks
|
||||
// the capture thread, so the mic stays silent.
|
||||
rtcSession.isAudioEnabled = true
|
||||
}
|
||||
|
||||
/// Restarts capture after the previous audio graph has disconnected.
|
||||
/// Toggling the unit in the same turn makes voice processing throw.
|
||||
private func restartInAppCallAudioUnit() {
|
||||
audioRestartGeneration += 1
|
||||
let generation = audioRestartGeneration
|
||||
let rtcSession = RTCAudioSession.sharedInstance()
|
||||
rtcSession.useManualAudio = true
|
||||
rtcSession.isAudioEnabled = false
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
|
||||
guard let self, generation == self.audioRestartGeneration else { return }
|
||||
self.activateInAppCallAudioSession()
|
||||
}
|
||||
}
|
||||
|
||||
private func applyCallAudioConfiguration(active: Bool) {
|
||||
let rtcSession = RTCAudioSession.sharedInstance()
|
||||
rtcSession.lockForConfiguration()
|
||||
defer { rtcSession.unlockForConfiguration() }
|
||||
do {
|
||||
try rtcSession.setConfiguration(callAudioConfiguration(), active: true)
|
||||
rtcSession.isAudioEnabled = true
|
||||
try rtcSession.setConfiguration(callAudioConfiguration(), active: active)
|
||||
} catch {
|
||||
NSLog("[EatvioVoIP] activate call audio session failed: \(error)")
|
||||
NSLog("[EatvioVoIP] apply call audio configuration failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func deactivateInAppCallAudioSession() {
|
||||
resumeAudioAfterCallKitEnds = false
|
||||
audioRestartGeneration += 1
|
||||
let rtcSession = RTCAudioSession.sharedInstance()
|
||||
rtcSession.isAudioEnabled = false
|
||||
rtcSession.lockForConfiguration()
|
||||
@@ -636,7 +1009,61 @@ import flutter_foreground_task
|
||||
.allowBluetooth,
|
||||
.defaultToSpeaker,
|
||||
]
|
||||
configuration.mode = AVAudioSession.Mode.voiceChat.rawValue
|
||||
// videoChat is the speakerphone mode for a video call. voiceChat keeps
|
||||
// the input on the receiver, so the mic is silent once audio goes to
|
||||
// the loudspeaker.
|
||||
configuration.mode = AVAudioSession.Mode.videoChat.rawValue
|
||||
// Voice processing rejects 44.1 kHz and throws from AVAudioEngine.
|
||||
configuration.sampleRate = 48000
|
||||
configuration.ioBufferDuration = 0.02
|
||||
configuration.inputNumberOfChannels = 1
|
||||
configuration.outputNumberOfChannels = 1
|
||||
return configuration
|
||||
}
|
||||
}
|
||||
|
||||
/// Reports a VoIP push when the Flutter CallKit plugin is not registered yet.
|
||||
private final class VoipPushCompletion {
|
||||
private var finished = false
|
||||
private let completion: () -> Void
|
||||
|
||||
init(_ completion: @escaping () -> Void) {
|
||||
self.completion = completion
|
||||
}
|
||||
|
||||
func call() {
|
||||
if finished { return }
|
||||
finished = true
|
||||
completion()
|
||||
}
|
||||
}
|
||||
|
||||
private final class VoipCallReporter: NSObject, CXProviderDelegate {
|
||||
let provider: CXProvider
|
||||
|
||||
override init() {
|
||||
let configuration = CXProviderConfiguration(localizedName: "Итвио")
|
||||
configuration.supportsVideo = true
|
||||
configuration.maximumCallGroups = 1
|
||||
configuration.maximumCallsPerCallGroup = 1
|
||||
configuration.supportedHandleTypes = [.generic]
|
||||
provider = CXProvider(configuration: configuration)
|
||||
super.init()
|
||||
provider.setDelegate(self, queue: nil)
|
||||
}
|
||||
|
||||
func report(
|
||||
uuid: UUID,
|
||||
callerName: String,
|
||||
hasVideo: Bool,
|
||||
completion: @escaping (Error?) -> Void
|
||||
) {
|
||||
let update = CXCallUpdate()
|
||||
update.remoteHandle = CXHandle(type: .generic, value: callerName)
|
||||
update.localizedCallerName = callerName
|
||||
update.hasVideo = hasVideo
|
||||
provider.reportNewIncomingCall(with: uuid, update: update, completion: completion)
|
||||
}
|
||||
|
||||
func providerDidReset(_ provider: CXProvider) {}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,27 @@
|
||||
<string>Может использоваться для стабильного поддержания связи во время звонка.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Предоставление доступа позволит Вам добавлять фото к записям</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
<key>UISceneConfigurations</key>
|
||||
<dict>
|
||||
<key>UIWindowSceneSessionRoleApplication</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UISceneClassName</key>
|
||||
<string>UIWindowScene</string>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>flutter</string>
|
||||
<key>UISceneDelegateClassName</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
|
||||
<key>UISceneStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UIBackgroundModes</key>
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:eatvio_app/core/app_initializer.dart';
|
||||
import 'package:eatvio_app/core/cubit/marketing/marketing_cubit.dart';
|
||||
import 'package:eatvio_app/core/env_config.dart';
|
||||
import 'package:eatvio_app/core/internet_connection_cubit/internet_connection_cubit.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/prefs.dart';
|
||||
import 'package:eatvio_app/core/prefs_keys.dart';
|
||||
import 'package:eatvio_app/core/views/cubit/views_cubit.dart';
|
||||
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/new_messages_cubit/new_messages_cubit.dart';
|
||||
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
|
||||
@@ -31,20 +35,50 @@ class AppConfig {
|
||||
|
||||
void setBaseUrl(String baseUrls) {
|
||||
_baseUrl = baseUrls;
|
||||
_persistBaseUrl(baseUrls);
|
||||
}
|
||||
|
||||
void _persistBaseUrl(String baseUrl) {
|
||||
if (!sl.isRegistered<Prefs>()) {
|
||||
return;
|
||||
}
|
||||
final normalized = baseUrl.trim();
|
||||
if (normalized.isEmpty) {
|
||||
unawaited(sl<Prefs>().remove(PrefsKeys.customBaseUrl));
|
||||
return;
|
||||
}
|
||||
unawaited(sl<Prefs>().setString(PrefsKeys.customBaseUrl, normalized));
|
||||
}
|
||||
|
||||
/// Список глобальных BlocProvider'ов
|
||||
/// Используются lazy: true для отложенной инициализации, чтобы не блокировать старт приложения
|
||||
List<BlocProvider> get globalBlocProviders => [
|
||||
BlocProvider<VideoCallSessionCubit>.value(value: sl<VideoCallSessionCubit>()),
|
||||
BlocProvider<NewMessagesCubit>(lazy: true, create: (context) => sl<NewMessagesCubit>()),
|
||||
BlocProvider<NotificationsCubit>(lazy: true, create: (context) => sl<NotificationsCubit>()),
|
||||
BlocProvider<VideoCallSessionCubit>.value(
|
||||
value: sl<VideoCallSessionCubit>(),
|
||||
),
|
||||
BlocProvider<NewMessagesCubit>(
|
||||
lazy: true,
|
||||
create: (context) => sl<NewMessagesCubit>(),
|
||||
),
|
||||
BlocProvider<NotificationsCubit>(
|
||||
lazy: true,
|
||||
create: (context) => sl<NotificationsCubit>(),
|
||||
),
|
||||
BlocProvider<FeedCubit>(lazy: true, create: (context) => sl<FeedCubit>()),
|
||||
BlocProvider<ProfileAvatarCubit>(lazy: true, create: (context) => sl<ProfileAvatarCubit>()),
|
||||
BlocProvider<ProfileAvatarCubit>(
|
||||
lazy: true,
|
||||
create: (context) => sl<ProfileAvatarCubit>(),
|
||||
),
|
||||
BlocProvider<ChatsCubit>(lazy: true, create: (context) => sl<ChatsCubit>()),
|
||||
BlocProvider<ViewsCubit>(lazy: true, create: (context) => sl<ViewsCubit>()),
|
||||
BlocProvider<InternetConnectionCubit>(lazy: false, create: (context) => sl<InternetConnectionCubit>()),
|
||||
BlocProvider<MarketingCubit>(lazy: true, create: (context) => sl<MarketingCubit>()),
|
||||
BlocProvider<InternetConnectionCubit>(
|
||||
lazy: false,
|
||||
create: (context) => sl<InternetConnectionCubit>(),
|
||||
),
|
||||
BlocProvider<MarketingCubit>(
|
||||
lazy: true,
|
||||
create: (context) => sl<MarketingCubit>(),
|
||||
),
|
||||
];
|
||||
|
||||
/// Делегаты локализации
|
||||
@@ -56,5 +90,7 @@ class AppConfig {
|
||||
];
|
||||
|
||||
/// Поддерживаемые локали
|
||||
List<Locale> get supportedLocales => [const Locale.fromSubtags(languageCode: 'ru')];
|
||||
List<Locale> get supportedLocales => [
|
||||
const Locale.fromSubtags(languageCode: 'ru'),
|
||||
];
|
||||
}
|
||||
|
||||
16
lib/core/cached_base_url.dart
Normal file
16
lib/core/cached_base_url.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/prefs.dart';
|
||||
import 'package:eatvio_app/core/prefs_keys.dart';
|
||||
|
||||
/// Подставляет сохранённый base URL до создания HTTP-клиента.
|
||||
void applyCachedBaseUrl() {
|
||||
if (!sl.isRegistered<Prefs>() || !sl.isRegistered<AppConfig>()) {
|
||||
return;
|
||||
}
|
||||
final cached = sl<Prefs>().getString(PrefsKeys.customBaseUrl)?.trim();
|
||||
if (cached == null || cached.isEmpty) {
|
||||
return;
|
||||
}
|
||||
sl<AppConfig>().setBaseUrl(cached);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/push/hms_client_error.dart';
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
||||
/// Rules for which errors are sent to crash reporting.
|
||||
@@ -15,6 +16,7 @@ class ErrorReportPolicy {
|
||||
if (error == null) return false;
|
||||
if (error is String) return false;
|
||||
if (error is SocketException) return false;
|
||||
if (isHmsClientUnavailable(error)) return false;
|
||||
|
||||
if (error is DioException) {
|
||||
return _shouldReportDio(error);
|
||||
|
||||
@@ -4,14 +4,13 @@ import 'dart:io';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/app_initializer.dart';
|
||||
import 'package:eatvio_app/core/notifications/notification_permission_requester.dart';
|
||||
import 'package:eatvio_app/core/utils/toasts.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:eatvio_app/core/push/hms_client_error.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/token_storage.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/services.dart' show MethodChannel, PlatformException;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
|
||||
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
|
||||
|
||||
@@ -27,20 +26,8 @@ class FirebaseApi {
|
||||
_notificationPermissionRequester = notificationPermissionRequester ?? NotificationPermissionRequester();
|
||||
|
||||
Future<String?> getToken() async {
|
||||
String? token;
|
||||
|
||||
if (await _isHuaweiDevice()) {
|
||||
token = await _getHuaweiPushTokenNative().timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () => null,
|
||||
);
|
||||
} else {
|
||||
token = await _firebaseMessaging.getToken().timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () => null,
|
||||
);
|
||||
}
|
||||
return token;
|
||||
final credentials = await _resolvePushCredentials();
|
||||
return credentials.token;
|
||||
}
|
||||
|
||||
Future<void> _requestNotificationPermission() async {
|
||||
@@ -58,12 +45,15 @@ class FirebaseApi {
|
||||
try {
|
||||
await _requestNotificationPermission();
|
||||
final deviceInfo = DeviceInfoPlugin();
|
||||
final credentials = await _resolvePushCredentials();
|
||||
final token = credentials.token;
|
||||
if (token == null || token.isEmpty) return;
|
||||
await sl<Dio>().post(
|
||||
'/api/anonymous_token',
|
||||
data: {
|
||||
'platform': Platform.isIOS ? 'ios' : (await _isHuaweiDevice()) ? 'huawei' : 'android',
|
||||
'platform': credentials.platform,
|
||||
'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id,
|
||||
'token': await getToken(),
|
||||
'token': token,
|
||||
},
|
||||
).timeout(
|
||||
const Duration(seconds: 10),
|
||||
@@ -79,17 +69,19 @@ class FirebaseApi {
|
||||
}
|
||||
|
||||
Future<void> registerPushToken() async {
|
||||
if (await TokenStorage.haveTokenInStorage()) {
|
||||
try {
|
||||
await _requestNotificationPermission();
|
||||
final deviceInfo = DeviceInfoPlugin();
|
||||
final isHuawei = Platform.isAndroid ? await _isHuaweiDevice() : false;
|
||||
if (!await TokenStorage.haveTokenInStorage()) return;
|
||||
try {
|
||||
await _requestNotificationPermission();
|
||||
final deviceInfo = DeviceInfoPlugin();
|
||||
final credentials = await _resolvePushCredentials();
|
||||
final token = credentials.token;
|
||||
if (token != null && token.isNotEmpty) {
|
||||
await sl<Dio>().post(
|
||||
'/api/profile/notification/token/register',
|
||||
data: {
|
||||
'platform': Platform.isIOS ? 'ios' : (isHuawei ? 'huawei' : 'android'),
|
||||
'platform': credentials.platform,
|
||||
'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id,
|
||||
'token': await getToken(),
|
||||
'token': token,
|
||||
},
|
||||
).timeout(
|
||||
const Duration(seconds: 10),
|
||||
@@ -97,14 +89,17 @@ class FirebaseApi {
|
||||
throw TimeoutException('registerPushToken timeout');
|
||||
},
|
||||
);
|
||||
if (Platform.isIOS) {
|
||||
await _registerIosVoipPushToken();
|
||||
}
|
||||
} catch (e, st) {
|
||||
log(e.toString(), stackTrace: st);
|
||||
await _errorReporter.report(e, st, origin: 'FirebaseApi');
|
||||
// Не пробрасываем ошибку дальше, так как это не критичная операция
|
||||
}
|
||||
} 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,13 +119,13 @@ class FirebaseApi {
|
||||
Future<void> unregisterPushToken() async {
|
||||
if (!await TokenStorage.haveTokenInStorage()) return;
|
||||
try {
|
||||
final isHuawei = Platform.isAndroid ? await _isHuaweiDevice() : false;
|
||||
final platform = Platform.isIOS ? 'ios' : (isHuawei ? 'huawei' : 'android');
|
||||
final token = (await getToken()) ?? '';
|
||||
final credentials = await _resolvePushCredentials();
|
||||
final token = credentials.token ?? '';
|
||||
if (token.isEmpty) return;
|
||||
await sl<Dio>().post(
|
||||
'/api/profile/notification/token/unregister',
|
||||
data: {
|
||||
'platform': platform,
|
||||
'platform': credentials.platform,
|
||||
'token': token,
|
||||
},
|
||||
).timeout(
|
||||
@@ -190,30 +185,74 @@ class FirebaseApi {
|
||||
}
|
||||
}
|
||||
|
||||
Future<({String? token, String platform})> _resolvePushCredentials() async {
|
||||
if (Platform.isIOS) {
|
||||
return (
|
||||
token: await _getFcmToken(),
|
||||
platform: pushPlatform(isIos: true, huaweiFamily: false, hmsAvailable: false, hasHmsToken: false),
|
||||
);
|
||||
}
|
||||
|
||||
final huaweiFamily = await _isHuaweiDevice();
|
||||
if (huaweiFamily && await _isHmsAvailable()) {
|
||||
final token = await _getHuaweiPushTokenNative().timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () => null,
|
||||
);
|
||||
if (token != null && token.isNotEmpty) {
|
||||
return (
|
||||
token: token,
|
||||
platform: pushPlatform(isIos: false, huaweiFamily: true, hmsAvailable: true, hasHmsToken: true),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
token: await _getFcmToken(),
|
||||
platform: pushPlatform(isIos: false, huaweiFamily: huaweiFamily, hmsAvailable: false, hasHmsToken: false),
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> _getFcmToken() {
|
||||
return _firebaseMessaging.getToken().timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () => null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _isHuaweiDevice() async {
|
||||
if (!Platform.isAndroid) return false;
|
||||
try {
|
||||
final info = await DeviceInfoPlugin().androidInfo;
|
||||
final brand = info.brand.toLowerCase();
|
||||
final manufacturer = info.manufacturer.toLowerCase();
|
||||
return brand.contains('huawei') || brand.contains('honor') || manufacturer.contains('huawei') || manufacturer.contains('honor');
|
||||
return isHuaweiFamilyBrand(brand: info.brand, manufacturer: info.manufacturer);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _isHmsAvailable() async {
|
||||
try {
|
||||
const channel = MethodChannel('com.eatvioapp/hms_push');
|
||||
final available = await channel.invokeMethod<bool>('isHmsAvailable').timeout(const Duration(seconds: 5));
|
||||
return available ?? false;
|
||||
} catch (e, st) {
|
||||
log('HMS availability check failed: $e', stackTrace: st);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> _getHuaweiPushTokenNative() async {
|
||||
try {
|
||||
const channel = MethodChannel('com.eatvioapp/hms_push');
|
||||
final String? token = await channel.invokeMethod<String>('getHuaweiToken');
|
||||
return token;
|
||||
} on PlatformException catch (e, st) {
|
||||
showErrorToast('$e $st');
|
||||
log(e.toString(), stackTrace: st);
|
||||
await _errorReporter.report(e, st, origin: 'FirebaseApi');
|
||||
if (!isHmsClientUnavailable(e)) {
|
||||
await _errorReporter.report(e, st, origin: 'FirebaseApi');
|
||||
}
|
||||
return null;
|
||||
} catch (e, st) {
|
||||
showErrorToast('$e $st');
|
||||
log(e.toString(), stackTrace: st);
|
||||
await _errorReporter.report(e, st, origin: 'FirebaseApi');
|
||||
return null;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:eatvio_app/core/app_initializer.dart';
|
||||
import 'package:eatvio_app/core/cached_base_url.dart';
|
||||
import 'package:eatvio_app/core/di/app_config_registration.dart';
|
||||
import 'package:eatvio_app/core/di/cubit_registrations.dart';
|
||||
import 'package:eatvio_app/core/di/external_registrations.dart';
|
||||
@@ -21,6 +22,8 @@ Future<void> init({required AppBuildType buildType}) async {
|
||||
|
||||
// Ожидание инициализации асинхронных зависимостей
|
||||
await sl.getAsync<Prefs>();
|
||||
// До создания Dio и любых запросов к бэкенду.
|
||||
applyCachedBaseUrl();
|
||||
|
||||
// Регистрация сервисов
|
||||
ServiceRegistrations.registerAll();
|
||||
@@ -32,5 +35,7 @@ Future<void> init({required AppBuildType buildType}) async {
|
||||
CubitRegistrations.registerAll();
|
||||
|
||||
// Регистрация WebSocket менеджера
|
||||
sl.registerLazySingleton<WsManager>(() => WsManager(wsService: sl(), errorReporter: sl()));
|
||||
sl.registerLazySingleton<WsManager>(
|
||||
() => WsManager(wsService: sl(), errorReporter: sl()),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,7 +65,11 @@ class PushNavigationHandler {
|
||||
'PushNavigationHandler: dismiss incoming call UI, source=$source',
|
||||
name: 'PushNavigationHandler',
|
||||
);
|
||||
unawaited(VideoCallEndedPushHandler.dismissIncomingCallUi());
|
||||
unawaited(
|
||||
VideoCallEndedPushHandler.dismissIncomingCallUi(
|
||||
conversationUuid: endedPayload.conversationUuid,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1,8 @@
|
||||
enum PrefsKeys { hasAnonymousTokenBeenSent, welcomeScreenWasShowed, registrationDate, marketingBottomSheetHasBeenShown, isEmailVerificationSkipped }
|
||||
enum PrefsKeys {
|
||||
hasAnonymousTokenBeenSent,
|
||||
welcomeScreenWasShowed,
|
||||
registrationDate,
|
||||
marketingBottomSheetHasBeenShown,
|
||||
isEmailVerificationSkipped,
|
||||
customBaseUrl,
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import 'package:eatvio_app/features/create_diet/presentation/diet_preview_screen
|
||||
import 'package:eatvio_app/features/create_plan/presentation/create_plan_screen.dart';
|
||||
import 'package:eatvio_app/features/create_recipe/presentation/pages/recipe_create_screen.dart';
|
||||
import 'package:eatvio_app/features/dev_tools/presentation/dev_tools_screen.dart';
|
||||
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/recipes_model.dart';
|
||||
import 'package:eatvio_app/features/glossary/presentation/pages/glossary_screen.dart';
|
||||
import 'package:eatvio_app/features/menu/presentation/pages/blocked_users_screen.dart';
|
||||
@@ -183,7 +184,14 @@ class OtherRoutes {
|
||||
return DietPreviewScreen(diet: diet, isImageInBase64: isImageInBase64);
|
||||
},
|
||||
),
|
||||
GoRoute(path: RouteConstants.createPlanScreen, name: 'createPlanScreen', builder: (context, state) => const CreatePlanScreen()),
|
||||
GoRoute(
|
||||
path: RouteConstants.createPlanScreen,
|
||||
name: 'createPlanScreen',
|
||||
builder: (context, state) {
|
||||
final plan = state.extra is PlanModel ? state.extra as PlanModel : null;
|
||||
return CreatePlanScreen(plan: plan);
|
||||
},
|
||||
),
|
||||
GoRoute(path: MyDietsScreen.path, name: MyDietsScreen.name, builder: (context, state) => const MyDietsScreen()),
|
||||
GoRoute(path: MyPlansScreen.path, name: MyPlansScreen.name, builder: (context, state) => const MyPlansScreen()),
|
||||
GoRoute(path: MyRecipesScreen.path, name: MyRecipesScreen.name, builder: (context, state) => const MyRecipesScreen()),
|
||||
|
||||
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';
|
||||
}
|
||||
@@ -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/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_cubit.dart';
|
||||
import 'package:eatvio_app/features/complaining/presentation/complaining_bottom_sheet.dart';
|
||||
import 'package:eatvio_app/features/create_diet/presentation/create_diet_screen.dart';
|
||||
import 'package:eatvio_app/features/create_plan/presentation/create_plan_screen.dart';
|
||||
import 'package:eatvio_app/features/create_recipe/presentation/pages/recipe_create_screen.dart';
|
||||
import 'package:eatvio_app/features/diets/presentation/cubit/about_diet_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/diets_cubit/diets_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/plans_cubit/plans_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/recipes_cubit/recipes_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/recipes_model.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/diets_cubit/diets_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/recipes_cubit/recipes_cubit.dart';
|
||||
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_cubit.dart';
|
||||
import 'package:eatvio_app/features/recipe/presentation/cubit/about_recipe_cubit.dart';
|
||||
import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_cubit.dart';
|
||||
import 'package:eatvio_app/features/my_plans/presentation/cubit/my_plans_cubit.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
|
||||
@@ -33,6 +41,7 @@ class CustomPopup extends StatelessWidget {
|
||||
final FeedBlogPostModel? feedBlogPostModel;
|
||||
final Recipe? recipe;
|
||||
final Diet? diet;
|
||||
final PlanModel? plan;
|
||||
final String userSlug;
|
||||
final VoidCallback? onPostsChanged;
|
||||
|
||||
@@ -47,6 +56,7 @@ class CustomPopup extends StatelessWidget {
|
||||
this.feedBlogPostModel,
|
||||
this.recipe,
|
||||
this.diet,
|
||||
this.plan,
|
||||
required this.userSlug,
|
||||
this.onPostsChanged,
|
||||
});
|
||||
@@ -86,7 +96,103 @@ class CustomPopup extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
List<PopupMenuEntry> _buildMenuItems(S s,BuildContext context) {
|
||||
Future<void> _openEditor(BuildContext context) async {
|
||||
final router = GoRouter.of(context);
|
||||
switch (complainedContentType) {
|
||||
case ComplainedContent.posts:
|
||||
await router.pushNamed('blogPostCreateScreen', extra: feedBlogPostModel);
|
||||
onPostsChanged?.call();
|
||||
case ComplainedContent.recipe:
|
||||
final currentRecipe = recipe;
|
||||
if (currentRecipe == null) {
|
||||
return;
|
||||
}
|
||||
final result = await router.pushNamed(RecipeCreateScreen.name, extra: currentRecipe);
|
||||
if (result != null && context.mounted) {
|
||||
_reloadEditedContent(context);
|
||||
}
|
||||
case ComplainedContent.diet:
|
||||
final currentDiet = diet;
|
||||
if (currentDiet == null) {
|
||||
return;
|
||||
}
|
||||
final result = await router.pushNamed(CreateDietScreen.name, extra: {'diet': currentDiet, 'isEditing': true});
|
||||
if (result == true && context.mounted) {
|
||||
_reloadEditedContent(context);
|
||||
}
|
||||
case ComplainedContent.plan:
|
||||
final currentPlan = plan;
|
||||
if (currentPlan == null) {
|
||||
return;
|
||||
}
|
||||
final result = await router.pushNamed(CreatePlanScreen.name, extra: currentPlan);
|
||||
if (result == true && context.mounted) {
|
||||
_reloadEditedContent(context);
|
||||
}
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void _reloadEditedContent(BuildContext context) {
|
||||
switch (complainedContentType) {
|
||||
case ComplainedContent.recipe:
|
||||
final currentRecipe = recipe;
|
||||
if (currentRecipe == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
context.read<AboutRecipeCubit>().getRecipe(currentRecipe.slug);
|
||||
} catch (_) {}
|
||||
try {
|
||||
context.read<RecipesCubit>().loadRecipesList();
|
||||
} catch (_) {}
|
||||
case ComplainedContent.diet:
|
||||
final currentDiet = diet;
|
||||
if (currentDiet == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
context.read<AboutDietCubit>().getDiet(id: currentDiet.id, slug: currentDiet.slug);
|
||||
} catch (_) {}
|
||||
try {
|
||||
context.read<DietsCubit>().loadDiets();
|
||||
} catch (_) {}
|
||||
case ComplainedContent.plan:
|
||||
final currentPlan = plan;
|
||||
if (currentPlan == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
context.read<AboutPlanCubit>().getPlan(slug: currentPlan.slug);
|
||||
} catch (_) {}
|
||||
try {
|
||||
context.read<PlansCubit>().loadPlans();
|
||||
} catch (_) {}
|
||||
try {
|
||||
context.read<MyPlansCubit>().loadPlans();
|
||||
} catch (_) {}
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
bool _showsEditAction() {
|
||||
switch (complainedContentType) {
|
||||
case ComplainedContent.posts:
|
||||
return feedBlogPostModel != null || id != null;
|
||||
case ComplainedContent.recipe:
|
||||
return recipe != null;
|
||||
case ComplainedContent.diet:
|
||||
return diet != null;
|
||||
case ComplainedContent.plan:
|
||||
return plan != null;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
List<PopupMenuEntry> _buildMenuItems(S s, BuildContext context) {
|
||||
final entries = <PopupMenuEntry>[];
|
||||
|
||||
void addItem(String icon, String text, Function() onTap) {
|
||||
@@ -110,12 +216,9 @@ class CustomPopup extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
});
|
||||
if (complainedContentType == ComplainedContent.posts) {
|
||||
addItem('assets/icons/edit_paint.svg', 'Редактировать', () async {
|
||||
if (complainedContentType == ComplainedContent.posts) {
|
||||
await GoRouter.of(context).pushNamed('blogPostCreateScreen', extra: feedBlogPostModel);
|
||||
onPostsChanged?.call();
|
||||
}
|
||||
if (_showsEditAction()) {
|
||||
addItem('assets/icons/edit_paint.svg', s.edit, () {
|
||||
_openEditor(context);
|
||||
});
|
||||
}
|
||||
addItem('assets/icons/red_trash.svg', 'Удалить', () async {
|
||||
|
||||
@@ -20,7 +20,7 @@ class SuccessPublishedDietOrPlanBottomSheet extends StatelessWidget {
|
||||
if (isDrafted == true) {
|
||||
text = 'cохранена в черновиках';
|
||||
} else if (isUpdated == true) {
|
||||
text = 'успешно обновлена';
|
||||
text = isPlan ? 'успешно обновлён' : 'успешно обновлена';
|
||||
} else if (isPlan == true) {
|
||||
text = 'опубликован';
|
||||
} else {
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:dismissible_page/dismissible_page.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/enums/complained_content.dart';
|
||||
import 'package:eatvio_app/core/utils/date_time_utils.dart';
|
||||
import 'package:eatvio_app/core/utils/url_utils.dart';
|
||||
import 'package:eatvio_app/core/widgets/custom_popup.dart';
|
||||
import 'package:eatvio_app/core/widgets/loader.dart';
|
||||
import 'package:eatvio_app/core/widgets/markdown_html_widget.dart';
|
||||
@@ -26,7 +27,11 @@ class BlogPostScreen extends StatefulWidget {
|
||||
final int id;
|
||||
final int? highlightedCommentId;
|
||||
|
||||
const BlogPostScreen({super.key, required this.id, this.highlightedCommentId});
|
||||
const BlogPostScreen({
|
||||
super.key,
|
||||
required this.id,
|
||||
this.highlightedCommentId,
|
||||
});
|
||||
|
||||
@override
|
||||
State<BlogPostScreen> createState() => _BlogPostScreenState();
|
||||
@@ -45,7 +50,10 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -53,7 +61,8 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
return BlocProvider(
|
||||
create: (context) => sl<BlogPostCubit>()..getBlogPost(widget.id),
|
||||
child: BlocProvider<CommentsCubit>(
|
||||
create: (BuildContext context) => commentsCubit..getComments(widget.id.toString(), CommentSourceType.blogPost, true),
|
||||
create: (BuildContext context) => commentsCubit
|
||||
..getComments(widget.id.toString(), CommentSourceType.blogPost, true),
|
||||
child: BlocBuilder<BlogPostCubit, BlogPostState>(
|
||||
builder: (context, blogPostState) {
|
||||
return BlocConsumer<CommentsCubit, CommentsState>(
|
||||
@@ -81,15 +90,23 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
body: const Loader(),
|
||||
);
|
||||
}
|
||||
final bool isLiked = blogPostState.blogPostModel?.wasLikedByMe ?? false;
|
||||
final int totalLikes = blogPostState.blogPostModel?.totalLikes ?? 0;
|
||||
final bool isLiked =
|
||||
blogPostState.blogPostModel?.wasLikedByMe ?? false;
|
||||
final int totalLikes =
|
||||
blogPostState.blogPostModel?.totalLikes ?? 0;
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: true,
|
||||
bottomSheet: Container(
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom + 8),
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).padding.bottom + 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(top: BorderSide(color: Colors.black.withValues(alpha: 0.1))),
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
@@ -107,10 +124,17 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(color: appTheme.blueA400, shape: BoxShape.circle),
|
||||
decoration: BoxDecoration(
|
||||
color: appTheme.blueA400,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
height: 32,
|
||||
width: 32,
|
||||
child: const Icon(Icons.close_rounded, size: 18, color: Colors.white),
|
||||
child: const Icon(
|
||||
Icons.close_rounded,
|
||||
size: 18,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (replyId != null) const SizedBox(width: 8),
|
||||
@@ -118,7 +142,8 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: 52),
|
||||
child: TextFormField(
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
textCapitalization:
|
||||
TextCapitalization.sentences,
|
||||
onChanged: (_) {
|
||||
setState(() {});
|
||||
},
|
||||
@@ -126,26 +151,48 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
maxLines: 5,
|
||||
minLines: 1,
|
||||
keyboardType: TextInputType.multiline,
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, color: Colors.black, height: 1.2),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: Colors.black,
|
||||
height: 1.2,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
isDense: false,
|
||||
// убираем true, чтобы padding учитывался корректно
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 16,
|
||||
),
|
||||
// вертикальный паддинг увеличен
|
||||
hintText: S.of(context).comment_hint,
|
||||
hintStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, height: 1.2, color: Color(0xFFAEAEB2)),
|
||||
hintStyle: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.2,
|
||||
color: Color(0xFFAEAEB2),
|
||||
),
|
||||
filled: true,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
||||
borderSide: const BorderSide(
|
||||
color: Color(0xFFD1D1D6),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
||||
borderSide: const BorderSide(
|
||||
color: Color(0xFFD1D1D6),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
||||
borderSide: const BorderSide(
|
||||
color: Color(0xFFD1D1D6),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -163,7 +210,11 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
|
||||
if (isSent) context.read<FeedCubit>().incrementCommentsState(widget.id);
|
||||
if (isSent) {
|
||||
context
|
||||
.read<FeedCubit>()
|
||||
.incrementCommentsState(widget.id);
|
||||
}
|
||||
|
||||
context.read<CommentsCubit>().setReplyId(null);
|
||||
setState(() {
|
||||
@@ -174,10 +225,19 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(color: textEditingController.text.length > 2 ? appTheme.blueA400 : Colors.grey, shape: BoxShape.circle),
|
||||
decoration: BoxDecoration(
|
||||
color: textEditingController.text.length > 2
|
||||
? appTheme.blueA400
|
||||
: Colors.grey,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
height: 32,
|
||||
width: 32,
|
||||
child: const Icon(Icons.send_rounded, size: 18, color: Colors.white),
|
||||
child: const Icon(
|
||||
Icons.send_rounded,
|
||||
size: 18,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -197,24 +257,36 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
),
|
||||
actions: [
|
||||
CustomPopup(
|
||||
isAuthor: blogPostState.blogPostModel?.author.authorIsMe ?? false,
|
||||
isAuthorAdmin: blogPostState.blogPostModel?.author.isAdmin ?? false,
|
||||
isAuthor:
|
||||
blogPostState.blogPostModel?.author.authorIsMe ??
|
||||
false,
|
||||
isAuthorAdmin:
|
||||
blogPostState.blogPostModel?.author.isAdmin ??
|
||||
false,
|
||||
complainedContentType: ComplainedContent.posts,
|
||||
path: 'blog_post/${(widget.id)}',
|
||||
id: widget.id,
|
||||
feedBlogPostModel: blogPostState.blogPostModel,
|
||||
userSlug: blogPostState.blogPostModel?.author.authorSlug ?? '',
|
||||
userSlug:
|
||||
blogPostState.blogPostModel?.author.authorSlug ??
|
||||
'',
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
],
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: EdgeInsets.only(bottom: 160 + MediaQuery.of(context).padding.bottom), // поле комментария (52-200) + padding (12+12+56) + safe area
|
||||
padding: EdgeInsets.only(
|
||||
bottom: 160 + MediaQuery.of(context).padding.bottom,
|
||||
), // поле комментария (52-200) + padding (12+12+56) + safe area
|
||||
physics: const BouncingScrollPhysics(),
|
||||
child: Column(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => GoRouter.of(context).pushNamed('profileScreen', extra: blogPostState.blogPostModel?.author.authorSlug),
|
||||
onTap: () => GoRouter.of(context).pushNamed(
|
||||
'profileScreen',
|
||||
extra:
|
||||
blogPostState.blogPostModel?.author.authorSlug,
|
||||
),
|
||||
child: Ink(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
@@ -223,21 +295,48 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 16,
|
||||
foregroundImage: NetworkImage(sl<AppConfig>().baseUrl + (blogPostState.blogPostModel?.author.authorProfileIcon ?? '')),
|
||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
||||
foregroundImage: NetworkImage(
|
||||
joinBaseUrl(
|
||||
sl<AppConfig>().baseUrl,
|
||||
blogPostState
|
||||
.blogPostModel
|
||||
?.author
|
||||
.authorProfileIcon ??
|
||||
'',
|
||||
),
|
||||
),
|
||||
backgroundImage: const AssetImage(
|
||||
'assets/images/no_avatar.png',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
blogPostState.blogPostModel?.author.authorName ?? '',
|
||||
style: const TextStyle(color: Colors.black, fontSize: 14, fontWeight: FontWeight.w400, height: 1),
|
||||
blogPostState
|
||||
.blogPostModel
|
||||
?.author
|
||||
.authorName ??
|
||||
'',
|
||||
style: const TextStyle(
|
||||
color: Colors.black,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
(blogPostState.blogPostModel?.createdAt ?? DateTime.now()).format('dd MMMM', 'ru'),
|
||||
style: TextStyle(color: Colors.grey.shade600, fontSize: 12, fontWeight: FontWeight.w400, height: 1),
|
||||
(blogPostState.blogPostModel?.createdAt ??
|
||||
DateTime.now())
|
||||
.format('dd MMMM', 'ru'),
|
||||
style: TextStyle(
|
||||
color: Colors.grey.shade600,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -245,10 +344,20 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (blogPostState.blogPostModel?.description.isNotEmpty ?? false)
|
||||
if (blogPostState
|
||||
.blogPostModel
|
||||
?.description
|
||||
.isNotEmpty ??
|
||||
false)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: MarkdownHtmlWidget(markdownText: blogPostState.blogPostModel!.description),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
child: MarkdownHtmlWidget(
|
||||
markdownText:
|
||||
blogPostState.blogPostModel!.description,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Builder(
|
||||
@@ -257,16 +366,31 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
if (post == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final imageUrl = PostFeedMapper.resolveMediaUrl(post.image);
|
||||
final imageUrl = PostFeedMapper.resolveMediaUrl(
|
||||
post.image,
|
||||
);
|
||||
final embedUrl = post.videoEmbedUrl?.trim();
|
||||
return BlogPostMediaContent(
|
||||
galleryImages: post.images,
|
||||
imageUrl: imageUrl,
|
||||
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null,
|
||||
videoUrlThumb: post.videoUrlThumb != null ? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb) : null,
|
||||
videoEmbedUrl:
|
||||
embedUrl != null && embedUrl.isNotEmpty
|
||||
? embedUrl
|
||||
: null,
|
||||
videoUrlThumb: post.videoUrlThumb != null
|
||||
? PostFeedMapper.resolveMediaUrl(
|
||||
post.videoUrlThumb,
|
||||
)
|
||||
: null,
|
||||
currentGalleryIndex: currentGalleryIndex,
|
||||
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) {
|
||||
setState(() {
|
||||
currentGalleryIndex = index;
|
||||
@@ -276,22 +400,43 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
},
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_iconButton(
|
||||
icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
||||
icon: isLiked
|
||||
? Icons.favorite_rounded
|
||||
: Icons.favorite_border_rounded,
|
||||
text: totalLikes.toString(),
|
||||
iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
|
||||
backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null,
|
||||
textColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
|
||||
iconColor: isLiked
|
||||
? Colors.red.withValues(alpha: 0.8)
|
||||
: null,
|
||||
backgroundColor: isLiked
|
||||
? Colors.red.withValues(alpha: 0.1)
|
||||
: null,
|
||||
textColor: isLiked
|
||||
? Colors.red.withValues(alpha: 0.8)
|
||||
: null,
|
||||
onTap: () async {
|
||||
await context.read<BlogPostCubit>().toggleLike();
|
||||
await context
|
||||
.read<BlogPostCubit>()
|
||||
.toggleLike();
|
||||
// ignore: use_build_context_synchronously
|
||||
final updated = context.read<BlogPostCubit>().state.blogPostModel;
|
||||
final updated = context
|
||||
.read<BlogPostCubit>()
|
||||
.state
|
||||
.blogPostModel;
|
||||
if (updated != null) {
|
||||
// ignore: use_build_context_synchronously
|
||||
context.read<FeedCubit>().updateFeedBlogPostList(updated, updated.wasLikedByMe);
|
||||
context
|
||||
.read<FeedCubit>()
|
||||
.updateFeedBlogPostList(
|
||||
updated,
|
||||
updated.wasLikedByMe,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
@@ -299,21 +444,36 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
_iconButton(
|
||||
icon: Icons.reply_rounded,
|
||||
onTap: () {
|
||||
final box = context.findRenderObject() as RenderBox?;
|
||||
final box =
|
||||
context.findRenderObject() as RenderBox?;
|
||||
SharePlus.instance.share(
|
||||
ShareParams(
|
||||
uri: Uri.parse('${sl<AppConfig>().baseUrl}/ru/blog_post/${widget.id}'),
|
||||
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
|
||||
uri: Uri.parse(
|
||||
'${sl<AppConfig>().baseUrl}/ru/blog_post/${widget.id}',
|
||||
),
|
||||
sharePositionOrigin:
|
||||
box!.localToGlobal(Offset.zero) &
|
||||
box.size,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const Spacer(),
|
||||
Icon(Icons.visibility_rounded, size: 16, color: appTheme.gray500),
|
||||
Icon(
|
||||
Icons.visibility_rounded,
|
||||
size: 16,
|
||||
color: appTheme.gray500,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
blogPostState.blogPostModel?.totalViews.toString() ?? '',
|
||||
style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400),
|
||||
blogPostState.blogPostModel?.totalViews
|
||||
.toString() ??
|
||||
'',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: appTheme.gray500,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -321,7 +481,10 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
Container(
|
||||
width: MediaQuery.of(context).size.width - 32,
|
||||
height: 0.4,
|
||||
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(16)),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
@@ -329,7 +492,11 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
'${blogPostState.blogPostModel?.totalComments} КОММЕНТАРИЕВ',
|
||||
style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: appTheme.gray500,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
@@ -354,13 +521,23 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
InkWell _iconButton({required IconData icon, String? text, Function()? onTap, Color? iconColor, Color? backgroundColor, Color? textColor}) {
|
||||
InkWell _iconButton({
|
||||
required IconData icon,
|
||||
String? text,
|
||||
Function()? onTap,
|
||||
Color? iconColor,
|
||||
Color? backgroundColor,
|
||||
Color? textColor,
|
||||
}) {
|
||||
return InkWell(
|
||||
onTap: onTap ?? () {},
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Ink(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(color: backgroundColor ?? appTheme.gray100, borderRadius: BorderRadius.circular(16)),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor ?? appTheme.gray100,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
height: 32,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -370,7 +547,10 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
if (text != null)
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(color: textColor ?? appTheme.gray600, fontWeight: FontWeight.w500),
|
||||
style: TextStyle(
|
||||
color: textColor ?? appTheme.gray600,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -674,163 +674,185 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Scaffold _buildBlogPostCreate(BuildContext context) {
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: true,
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
|
||||
floatingActionButton: enableMdPanel || isPreviewMode
|
||||
? Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (!isPreviewMode) ...[
|
||||
Container(
|
||||
width: MediaQuery.of(context).size.width - 76,
|
||||
height: 52,
|
||||
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: [
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
Widget _buildEditorToolbar(BuildContext context) {
|
||||
return Row(
|
||||
key: const ValueKey('blogPostCreateEditorToolbar'),
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (!isPreviewMode) ...[
|
||||
Container(
|
||||
width: MediaQuery.of(context).size.width - 76,
|
||||
height: 52,
|
||||
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),
|
||||
),
|
||||
],
|
||||
)
|
||||
: 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(),
|
||||
body: BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
|
||||
bloc: blogPostCubit,
|
||||
builder: (context, state) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Заголовок'),
|
||||
const SizedBox(height: 8),
|
||||
CustomTextFormField(
|
||||
key: AppKeys.blogPostCreateScreenTitleTextField,
|
||||
controller: titleController,
|
||||
hasError: state.titleError != null,
|
||||
onChanged: (_) => blogPostCubit.clearTitleError(),
|
||||
),
|
||||
if (state.titleError != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
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(),
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Заголовок'),
|
||||
const SizedBox(height: 8),
|
||||
CustomTextFormField(
|
||||
key: AppKeys.blogPostCreateScreenTitleTextField,
|
||||
controller: titleController,
|
||||
hasError: state.titleError != null,
|
||||
onChanged: (_) => blogPostCubit.clearTitleError(),
|
||||
),
|
||||
if (state.descriptionError != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
ValidationErrorWidget(error: state.descriptionError!),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Изображения ($_galleryCount/${BlogPostRepository.maxGalleryImages})',
|
||||
if (state.titleError != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
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) ...[
|
||||
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),
|
||||
_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 (state.formError != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
ValidationErrorWidget(error: state.formError!),
|
||||
],
|
||||
const SizedBox(height: 80),
|
||||
_createBlogPostButton(isSubmitting: state.isLoading),
|
||||
const SizedBox(height: 80),
|
||||
_buildEditorToolbar(context),
|
||||
],
|
||||
),
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -897,9 +919,8 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
onTap: isSubmitting ? null : _submitBlogPost,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: isSubmitting
|
||||
? const Center(child: Loader())
|
||||
? const SizedBox(height: 52, child: Center(child: Loader()))
|
||||
: Ink(
|
||||
width: MediaQuery.of(context).size.width - 32,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/env_config.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/utils/image_constant.dart';
|
||||
import 'package:eatvio_app/core/utils/url_utils.dart';
|
||||
import 'package:eatvio_app/features/dev_tools/presentation/dev_tools_screen.dart';
|
||||
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
|
||||
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_state.dart';
|
||||
@@ -26,11 +27,13 @@ class BottomNavigationBarWidget extends StatefulWidget {
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
|
||||
/// Текущий путь из GoRouter (matchedLocation), чтобы подсветка таба совпадала с экраном при навигации через go() (диплинки, пуши).
|
||||
final String currentLocation;
|
||||
|
||||
@override
|
||||
State<BottomNavigationBarWidget> createState() => _BottomNavigationBarWidgetState();
|
||||
State<BottomNavigationBarWidget> createState() =>
|
||||
_BottomNavigationBarWidgetState();
|
||||
}
|
||||
|
||||
class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
@@ -40,9 +43,24 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
|
||||
List<String> _getPages(bool isConsultant) {
|
||||
if (isConsultant) {
|
||||
return ['/my-profile-screen', '/feed-screen', '/diary-screen', '/consultations-screen', '/chat-select-screen', '/notifications-screen', '/menu-screen'];
|
||||
return [
|
||||
'/my-profile-screen',
|
||||
'/feed-screen',
|
||||
'/diary-screen',
|
||||
'/consultations-screen',
|
||||
'/chat-select-screen',
|
||||
'/notifications-screen',
|
||||
'/menu-screen',
|
||||
];
|
||||
} else {
|
||||
return ['/my-profile-screen', '/feed-screen', '/diary-screen', '/chat-select-screen', '/notifications-screen', '/menu-screen'];
|
||||
return [
|
||||
'/my-profile-screen',
|
||||
'/feed-screen',
|
||||
'/diary-screen',
|
||||
'/chat-select-screen',
|
||||
'/notifications-screen',
|
||||
'/menu-screen',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +116,9 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
final logo = SvgPicture.asset(
|
||||
ImageConstant.imgLogo,
|
||||
height: 32,
|
||||
colorFilter: isProduction ? null : const ColorFilter.mode(_devAccentColor, BlendMode.srcIn),
|
||||
colorFilter: isProduction
|
||||
? null
|
||||
: const ColorFilter.mode(_devAccentColor, BlendMode.srcIn),
|
||||
);
|
||||
if (isProduction) return logo;
|
||||
return Stack(
|
||||
@@ -109,7 +129,10 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: _devAccentColor.withValues(alpha: 0.6), width: 1.5),
|
||||
border: Border.all(
|
||||
color: _devAccentColor.withValues(alpha: 0.6),
|
||||
width: 1.5,
|
||||
),
|
||||
color: _devAccentColor.withValues(alpha: 0.08),
|
||||
),
|
||||
child: logo,
|
||||
@@ -122,11 +145,23 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
decoration: BoxDecoration(
|
||||
color: _devAccentColor,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
boxShadow: [BoxShadow(color: _devAccentColor.withValues(alpha: 0.4), blurRadius: 4, offset: const Offset(0, 1))],
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: _devAccentColor.withValues(alpha: 0.4),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Text(
|
||||
'DEV',
|
||||
style: TextStyle(fontSize: 8, fontWeight: FontWeight.w700, color: Colors.white, letterSpacing: 0.3, height: 1.0),
|
||||
style: TextStyle(
|
||||
fontSize: 8,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
letterSpacing: 0.3,
|
||||
height: 1.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -155,10 +190,15 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
// Подсветка таба по текущему маршруту (совпадает при навигации через go(), в т.ч. диплинки/пуши).
|
||||
final pathWithoutQuery = widget.currentLocation.split('?').first;
|
||||
final indexFromRoute = pages!.indexOf(pathWithoutQuery);
|
||||
final effectiveIndex = indexFromRoute >= 0 ? indexFromRoute : currentBottomNavBarIndex;
|
||||
if (indexFromRoute >= 0 && indexFromRoute != currentBottomNavBarIndex) {
|
||||
final effectiveIndex = indexFromRoute >= 0
|
||||
? indexFromRoute
|
||||
: currentBottomNavBarIndex;
|
||||
if (indexFromRoute >= 0 &&
|
||||
indexFromRoute != currentBottomNavBarIndex) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) setState(() => currentBottomNavBarIndex = indexFromRoute);
|
||||
if (mounted) {
|
||||
setState(() => currentBottomNavBarIndex = indexFromRoute);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -176,9 +216,14 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
label: '',
|
||||
icon: CircleAvatar(
|
||||
foregroundImage: NetworkImage(
|
||||
profileModel.profilePhotoUrl.contains('https') ? profileModel.profilePhotoUrl : sl<AppConfig>().baseUrl + profileModel.profilePhotoUrl,
|
||||
joinBaseUrl(
|
||||
sl<AppConfig>().baseUrl,
|
||||
profileModel.profilePhotoUrl,
|
||||
),
|
||||
),
|
||||
backgroundImage: const AssetImage(
|
||||
'assets/images/no_avatar.png',
|
||||
),
|
||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
||||
radius: 16,
|
||||
),
|
||||
),
|
||||
@@ -186,22 +231,54 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
label: 'Лента',
|
||||
icon: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn), width: 20, height: 20, ImageConstant.icnFeed),
|
||||
child: SvgPicture.asset(
|
||||
colorFilter: const ColorFilter.mode(
|
||||
Colors.grey,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
width: 20,
|
||||
height: 20,
|
||||
ImageConstant.icnFeed,
|
||||
),
|
||||
),
|
||||
activeIcon: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(ImageConstant.icnFeed, width: 20, height: 20, colorFilter: ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn)),
|
||||
child: SvgPicture.asset(
|
||||
ImageConstant.icnFeed,
|
||||
width: 20,
|
||||
height: 20,
|
||||
colorFilter: ColorFilter.mode(
|
||||
appTheme.blueA400,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
label: s.diary,
|
||||
icon: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn), ImageConstant.icnDiary, width: 20, height: 20),
|
||||
child: SvgPicture.asset(
|
||||
colorFilter: const ColorFilter.mode(
|
||||
Colors.grey,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
ImageConstant.icnDiary,
|
||||
width: 20,
|
||||
height: 20,
|
||||
),
|
||||
),
|
||||
activeIcon: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(ImageConstant.icnDiary, width: 20, height: 20, colorFilter: ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn)),
|
||||
child: SvgPicture.asset(
|
||||
ImageConstant.icnDiary,
|
||||
width: 20,
|
||||
height: 20,
|
||||
colorFilter: ColorFilter.mode(
|
||||
appTheme.blueA400,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (profileModel.roles.contains('consultant'))
|
||||
@@ -210,7 +287,10 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
icon: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(
|
||||
colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn),
|
||||
colorFilter: const ColorFilter.mode(
|
||||
Colors.grey,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
'assets/icons/consultations.svg',
|
||||
width: 20,
|
||||
height: 20,
|
||||
@@ -222,7 +302,10 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
'assets/icons/consultations.svg',
|
||||
width: 20,
|
||||
height: 20,
|
||||
colorFilter: ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn),
|
||||
colorFilter: ColorFilter.mode(
|
||||
appTheme.blueA400,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -230,14 +313,26 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
label: s.chat,
|
||||
icon: BlocBuilder<ChatsCubit, ChatsState>(
|
||||
builder: (BuildContext context, ChatsState state) {
|
||||
final hasUnreadMessages = state.chatsWithUnreadMessagesCount > 0;
|
||||
final hasUnreadMessages =
|
||||
state.chatsWithUnreadMessagesCount > 0;
|
||||
return badges.Badge(
|
||||
position: badges.BadgePosition.topEnd(),
|
||||
badgeAnimation: const badges.BadgeAnimation.fade(),
|
||||
badgeStyle: badges.BadgeStyle(badgeColor: hasUnreadMessages ? Colors.red : Colors.transparent),
|
||||
badgeStyle: badges.BadgeStyle(
|
||||
badgeColor: hasUnreadMessages
|
||||
? Colors.red
|
||||
: Colors.transparent,
|
||||
),
|
||||
badgeContent: Text(
|
||||
hasUnreadMessages ? state.chatsWithUnreadMessagesCount.toString() : '',
|
||||
style: TextStyle(color: hasUnreadMessages ? Colors.white : Colors.transparent, fontSize: 8),
|
||||
hasUnreadMessages
|
||||
? state.chatsWithUnreadMessagesCount.toString()
|
||||
: '',
|
||||
style: TextStyle(
|
||||
color: hasUnreadMessages
|
||||
? Colors.white
|
||||
: Colors.transparent,
|
||||
fontSize: 8,
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
@@ -245,9 +340,16 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
ImageConstant.icnChat,
|
||||
width: 20,
|
||||
height: 20,
|
||||
colorFilter: pages![effectiveIndex] == '/chat-select-screen'
|
||||
? ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn)
|
||||
: const ColorFilter.mode(Colors.grey, BlendMode.srcIn),
|
||||
colorFilter:
|
||||
pages![effectiveIndex] == '/chat-select-screen'
|
||||
? ColorFilter.mode(
|
||||
appTheme.blueA400,
|
||||
BlendMode.srcIn,
|
||||
)
|
||||
: const ColorFilter.mode(
|
||||
Colors.grey,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -261,15 +363,28 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
return badges.Badge(
|
||||
position: badges.BadgePosition.topEnd(),
|
||||
badgeAnimation: const badges.BadgeAnimation.fade(),
|
||||
showBadge: state is NotificationsStateLoaded && state.hasUnreadNotifications,
|
||||
badgeContent: const Text('', style: TextStyle(color: Colors.white, fontSize: 8)),
|
||||
showBadge:
|
||||
state is NotificationsStateLoaded &&
|
||||
state.hasUnreadNotifications,
|
||||
badgeContent: const Text(
|
||||
'',
|
||||
style: TextStyle(color: Colors.white, fontSize: 8),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(
|
||||
ImageConstant.icnBell,
|
||||
colorFilter: pages![effectiveIndex] == '/notifications-screen'
|
||||
? ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn)
|
||||
: const ColorFilter.mode(Colors.grey, BlendMode.srcIn),
|
||||
colorFilter:
|
||||
pages![effectiveIndex] ==
|
||||
'/notifications-screen'
|
||||
? ColorFilter.mode(
|
||||
appTheme.blueA400,
|
||||
BlendMode.srcIn,
|
||||
)
|
||||
: const ColorFilter.mode(
|
||||
Colors.grey,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
width: 20,
|
||||
height: 20,
|
||||
),
|
||||
|
||||
@@ -27,7 +27,7 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
|
||||
/// Test seam. Production uses CallKit.
|
||||
Future<void> Function(VideoCallPushPayload payload)? presentIncomingVideoCall;
|
||||
Future<void> Function()? dismissIncomingVideoCall;
|
||||
Future<void> Function(String? conversationUuid)? dismissIncomingVideoCall;
|
||||
|
||||
ChatsCubit({
|
||||
required this.chatRepository,
|
||||
@@ -230,7 +230,10 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
}) {
|
||||
final kind = VideoCallChatSignal.kindOf(message);
|
||||
if (kind == VideoCallChatSignalKind.ended) {
|
||||
unawaited(_dismissIncomingVideoCallUi());
|
||||
unawaited(_dismissIncomingVideoCallUi(message.roomId));
|
||||
unawaited(
|
||||
VideoCallEndedPushHandler.endActiveCallIfMatches(message.roomId),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (isOwn || kind != VideoCallChatSignalKind.started) return;
|
||||
@@ -257,10 +260,16 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _dismissIncomingVideoCallUi() async {
|
||||
Future<void> _dismissIncomingVideoCallUi(String? conversationUuid) async {
|
||||
try {
|
||||
await (dismissIncomingVideoCall ??
|
||||
VideoCallEndedPushHandler.dismissIncomingCallUi)();
|
||||
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',
|
||||
|
||||
@@ -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/plans/enums/chosen_plan_state_enum.dart';
|
||||
import 'package:eatvio_app/features/user_details/presentation/user_details_screen.dart';
|
||||
import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
@@ -1009,6 +1010,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
ChatRoomStateLoaded state,
|
||||
ChatUserModel chatCounterpart,
|
||||
) {
|
||||
sl<VideoCallManager>().permitParticipantTokenRequest();
|
||||
GoRouter.of(context).pushNamed(
|
||||
VideoCallScreen.name,
|
||||
extra: <String, dynamic>{
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:eatvio_app/core/widgets/success_published_diet_bottom_sheet.dart
|
||||
import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart';
|
||||
import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_cubit.dart';
|
||||
import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_state.dart';
|
||||
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/create_plan/presentation/widgets/option_widget.dart';
|
||||
import 'package:eatvio_app/features/create_plan/presentation/widgets/options_count_button.dart';
|
||||
import 'package:eatvio_app/features/create_plan/presentation/widgets/plan_duration_drop_down_widget.dart';
|
||||
@@ -25,11 +26,23 @@ import 'package:go_router/go_router.dart';
|
||||
class CreatePlanScreen extends StatelessWidget {
|
||||
static const name = 'createPlanScreen';
|
||||
static const path = '/create-Plan-screen';
|
||||
const CreatePlanScreen({super.key});
|
||||
const CreatePlanScreen({super.key, this.plan});
|
||||
|
||||
final PlanModel? plan;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(create: (context) => sl<CreatePlanCubit>(), child: const CreatePlanScreenContent());
|
||||
return BlocProvider(
|
||||
create: (context) {
|
||||
final cubit = sl<CreatePlanCubit>();
|
||||
final existingPlan = plan;
|
||||
if (existingPlan != null) {
|
||||
cubit.loadExisting(existingPlan);
|
||||
}
|
||||
return cubit;
|
||||
},
|
||||
child: const CreatePlanScreenContent(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,17 +60,19 @@ class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
final isEditing = context.read<CreatePlanCubit>().sourcePlan != null;
|
||||
return BlocBuilder<CreatePlanCubit, CreatePlanState>(
|
||||
buildWhen: (previous, current) => previous.planCreated != current.planCreated || previous.isLoading != current.isLoading,
|
||||
builder: (context, state) {
|
||||
if (state.planCreated) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!context.mounted) return;
|
||||
final isEditing = context.read<CreatePlanCubit>().sourcePlan != null;
|
||||
context.pop(true);
|
||||
showBottomSheetWithBlur(
|
||||
userRootNavigator: true,
|
||||
context: context,
|
||||
child: SuccessPublishedDietOrPlanBottomSheet(title: state.planData.name ?? 'План', isPlan: true),
|
||||
child: SuccessPublishedDietOrPlanBottomSheet(title: state.planData.name ?? 'План', isPlan: true, isUpdated: isEditing),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -68,7 +83,7 @@ class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
|
||||
appBar: AppBar(
|
||||
centerTitle: true,
|
||||
title: Text(
|
||||
s.adding_plan,
|
||||
isEditing ? 'Редактирование плана' : s.adding_plan,
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
softWrap: true,
|
||||
@@ -102,7 +117,9 @@ class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
|
||||
return Column(
|
||||
children: [
|
||||
CustomTextFormField(
|
||||
key: const ValueKey('plan-name'),
|
||||
hintText: s.plan_label_desc,
|
||||
initialValue: state.planData.name,
|
||||
hasError: state.validationState.titleHasError,
|
||||
onChanged: (value) {
|
||||
context.read<CreatePlanCubit>().setName(value);
|
||||
@@ -144,6 +161,7 @@ class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
|
||||
children: [
|
||||
MarkdownEditor(
|
||||
key: _mdKey,
|
||||
initialText: state.planData.details,
|
||||
hintText: s.plan_desc_example,
|
||||
hasError: state.validationState.descriptionHasError,
|
||||
minLines: 6,
|
||||
@@ -230,7 +248,7 @@ class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
|
||||
onPressed: () async {
|
||||
await context.read<CreatePlanCubit>().publishPlan();
|
||||
},
|
||||
text: s.publish,
|
||||
text: isEditing ? 'Обновить' : s.publish,
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(
|
||||
|
||||
@@ -4,16 +4,57 @@ import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/const.dart';
|
||||
import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart';
|
||||
import 'package:eatvio_app/core/utils/toasts.dart';
|
||||
import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_state.dart';
|
||||
import 'package:eatvio_app/features/create_plan/repositories/create_plan_repository.dart';
|
||||
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||
|
||||
class CreatePlanCubit extends Cubit<CreatePlanState> {
|
||||
final CreatePlanRepository createPlanRepository;
|
||||
PlanModel? sourcePlan;
|
||||
|
||||
CreatePlanCubit({required this.createPlanRepository}) : super(CreatePlanState.initial());
|
||||
|
||||
void loadExisting(PlanModel plan) {
|
||||
sourcePlan = plan;
|
||||
final durationIndex = durationValues.indexOf(plan.duration);
|
||||
emit(
|
||||
state.copyWith(
|
||||
durationIndex: durationIndex >= 0 ? durationIndex : null,
|
||||
selectedOptionIndex: plan.packetsCount == 3 ? 0 : null,
|
||||
planData: state.planData.copyWith(
|
||||
name: plan.name,
|
||||
details: plan.details,
|
||||
duration: plan.duration,
|
||||
state: plan.state,
|
||||
type: [
|
||||
plan.type.slim ?? false,
|
||||
plan.type.fit ?? false,
|
||||
plan.type.wellness ?? false,
|
||||
plan.type.behavior ?? false,
|
||||
],
|
||||
options: [
|
||||
_packetOrEmpty(plan.packet1Options),
|
||||
plan.packetsCount == 3 ? _packetOrEmpty(plan.packet2Options) : PacketOptions.empty(),
|
||||
plan.packetsCount == 3 ? _packetOrEmpty(plan.packet3Options) : PacketOptions.empty(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
PacketOptions _packetOrEmpty(PacketOptions? option) {
|
||||
if (option == null) {
|
||||
return PacketOptions.empty();
|
||||
}
|
||||
final price = option.price;
|
||||
final normalizedPrice = price is num ? price.toDouble() : double.tryParse('$price') ?? 1200;
|
||||
return option.copyWith(price: normalizedPrice);
|
||||
}
|
||||
|
||||
void setDetails(String text) {
|
||||
emit(state.copyWith(planData: state.planData.copyWith(details: text)));
|
||||
validateDetails();
|
||||
@@ -147,7 +188,7 @@ class CreatePlanCubit extends Cubit<CreatePlanState> {
|
||||
}
|
||||
|
||||
bool validateImage() {
|
||||
if (state.planData.image == null) {
|
||||
if (state.planData.image == null && sourcePlan == null) {
|
||||
emit(state.copyWith.validationState(imageHasError: true));
|
||||
return false;
|
||||
}
|
||||
@@ -413,31 +454,34 @@ class CreatePlanCubit extends Cubit<CreatePlanState> {
|
||||
'type[fit]': state.planData.type[1] ? '1' : '0',
|
||||
'type[wellness]': state.planData.type[2] ? '1' : '0',
|
||||
'type[behavior]': state.planData.type[3] ? '1' : '0',
|
||||
'image': state.planData.image != null
|
||||
? await MultipartFile.fromFile((await ImageCompressUtility.compressFile(File(state.planData.image!.path))).path,
|
||||
filename: state.planData.image!.name)
|
||||
: null
|
||||
});
|
||||
if (state.planData.image != null) {
|
||||
final image = state.planData.image!;
|
||||
planMap['image'] = await MultipartFile.fromFile(
|
||||
(await ImageCompressUtility.compressFile(File(image.path))).path,
|
||||
filename: image.name,
|
||||
);
|
||||
}
|
||||
if (state.planData.options[1].short.isNotEmpty) {
|
||||
planMap.addAll({
|
||||
'packets_count': 3,
|
||||
'packet1_options': {
|
||||
'short': state.planData.options[0].short,
|
||||
'price': int.parse((state.planData.options[0].price as double).toStringAsFixed(0)),
|
||||
'price': _optionPrice(state.planData.options[0].price),
|
||||
'description_md': state.planData.options[0].description,
|
||||
'items': Map.fromIterable(state.planData.options[0].items),
|
||||
'has_personal_diet': state.planData.options[0].hasPersonalDiet ? '1' : '0',
|
||||
},
|
||||
'packet2_options': {
|
||||
'short': state.planData.options[1].short,
|
||||
'price': int.parse((state.planData.options[1].price as double).toStringAsFixed(0)),
|
||||
'price': _optionPrice(state.planData.options[1].price),
|
||||
'description_md': state.planData.options[1].description,
|
||||
'items': Map.fromIterable(state.planData.options[1].items),
|
||||
'has_personal_diet': state.planData.options[1].hasPersonalDiet ? '1' : '0',
|
||||
},
|
||||
'packet3_options': {
|
||||
'short': state.planData.options[2].short,
|
||||
'price': int.parse((state.planData.options[2].price as double).toStringAsFixed(0)),
|
||||
'price': _optionPrice(state.planData.options[2].price),
|
||||
'description_md': state.planData.options[2].description,
|
||||
'items': Map.fromIterable(state.planData.options[2].items),
|
||||
'has_personal_diet': state.planData.options[2].hasPersonalDiet ? '1' : '0',
|
||||
@@ -448,20 +492,34 @@ class CreatePlanCubit extends Cubit<CreatePlanState> {
|
||||
'packets_count': 1,
|
||||
'packet1_options': {
|
||||
'short': state.planData.options[0].short,
|
||||
'price': int.parse((state.planData.options[0].price as double).toStringAsFixed(0)),
|
||||
'price': _optionPrice(state.planData.options[0].price),
|
||||
'description_md': state.planData.options[0].description,
|
||||
'items': Map.fromIterable(state.planData.options[0].items),
|
||||
'has_personal_diet': state.planData.options[0].hasPersonalDiet ? '1' : '0',
|
||||
},
|
||||
});
|
||||
}
|
||||
await createPlanRepository.createPlan(model: planMap);
|
||||
final editingPlan = sourcePlan;
|
||||
if (editingPlan == null) {
|
||||
await createPlanRepository.createPlan(model: planMap);
|
||||
} else {
|
||||
await createPlanRepository.updatePublishedPlan(slug: editingPlan.slug, model: planMap);
|
||||
}
|
||||
emit(state.copyWith(isLoading: false, planCreated: true));
|
||||
}
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString());
|
||||
emit(state.copyWith(isLoading: false, planCreated: true));
|
||||
emit(state.copyWith(isLoading: false, planCreated: false));
|
||||
final isForbidden = e is DioException && e.response?.statusCode == 403;
|
||||
showErrorToast(isForbidden ? 'Редактирование недоступно: клиент уже начал проходить план' : 'Не удалось сохранить план');
|
||||
}
|
||||
}
|
||||
|
||||
int _optionPrice(dynamic price) {
|
||||
if (price is num) {
|
||||
return price.round();
|
||||
}
|
||||
return int.parse(price.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dotted_border/dotted_border.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/app_router.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/permission_handler/permission_handler.dart';
|
||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
@@ -91,6 +93,18 @@ class _PlanImagePickerWidgetState extends State<PlanImagePickerWidget> {
|
||||
final s = S.of(context);
|
||||
return BlocBuilder<CreatePlanCubit, CreatePlanState>(
|
||||
builder: (context, state) {
|
||||
final selectedImage = state.planData.image;
|
||||
final existingImage = context.read<CreatePlanCubit>().sourcePlan?.image;
|
||||
final hasCover = selectedImage != null || (existingImage != null && existingImage.isNotEmpty);
|
||||
final ImageProvider? coverImage;
|
||||
if (selectedImage != null) {
|
||||
coverImage = FileImage(File(selectedImage.path));
|
||||
} else if (existingImage != null && existingImage.isNotEmpty) {
|
||||
final url = existingImage.startsWith('http') ? existingImage : '${sl<AppConfig>().baseUrl}/storage/$existingImage';
|
||||
coverImage = NetworkImage(url);
|
||||
} else {
|
||||
coverImage = null;
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
GestureDetector(
|
||||
@@ -101,12 +115,12 @@ class _PlanImagePickerWidgetState extends State<PlanImagePickerWidget> {
|
||||
height: 240,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
image: state.planData.image != null ? DecorationImage(fit: BoxFit.cover, image: FileImage(File(state.planData.image?.path ?? ''))) : null,
|
||||
image: coverImage != null ? DecorationImage(fit: BoxFit.cover, image: coverImage) : null,
|
||||
color: appTheme.gray50,
|
||||
),
|
||||
width: MediaQuery.sizeOf(context).width,
|
||||
padding: state.planData.image != null ? null : const EdgeInsets.all(8),
|
||||
child: state.planData.image != null
|
||||
padding: hasCover ? null : const EdgeInsets.all(8),
|
||||
child: selectedImage != null
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(top: 6, right: 6),
|
||||
child: GestureDetector(
|
||||
@@ -131,6 +145,8 @@ class _PlanImagePickerWidgetState extends State<PlanImagePickerWidget> {
|
||||
),
|
||||
),
|
||||
)
|
||||
: hasCover
|
||||
? const SizedBox.shrink()
|
||||
: Stack(
|
||||
children: [
|
||||
DottedBorder(
|
||||
|
||||
@@ -11,7 +11,13 @@ class CreatePlanRepository {
|
||||
}) async {
|
||||
final formData = FormData.fromMap(model);
|
||||
return await createPlanService.createPlan(formData: formData);
|
||||
|
||||
}
|
||||
|
||||
|
||||
Future<void> updatePublishedPlan({
|
||||
required String slug,
|
||||
required Map<String, dynamic> model,
|
||||
}) async {
|
||||
final formData = FormData.fromMap(model);
|
||||
return await createPlanService.updatePublishedPlan(slug: slug, formData: formData);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,5 +12,11 @@ abstract class CreatePlanService {
|
||||
Future<void> createPlan({
|
||||
@Body() required FormData formData,
|
||||
});
|
||||
|
||||
|
||||
@POST('/api/plans/update/published/{slug}')
|
||||
@MultiPart()
|
||||
Future<void> updatePublishedPlan({
|
||||
@Path() required String slug,
|
||||
@Body() required FormData formData,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,8 +18,13 @@ enum PortionState {
|
||||
@JsonValue('eaten_edited')
|
||||
eatenEdited,
|
||||
commented,
|
||||
@JsonValue('commented_from_diet')
|
||||
commentedFromDiet,
|
||||
@JsonValue('reviewed_by_user')
|
||||
reviewedByUser,
|
||||
@JsonValue('reviewed_from_diet')
|
||||
reviewedFromDiet,
|
||||
unknown,
|
||||
}
|
||||
|
||||
@freezed
|
||||
@@ -138,7 +143,7 @@ abstract class PortionModel with _$PortionModel {
|
||||
@JsonKey(name: "quantity") required int quantity,
|
||||
@JsonKey(name: "portion") required String portion,
|
||||
@JsonKey(name: "from_diet") required bool fromDiet,
|
||||
@JsonKey(name: "state") required PortionState portionState,
|
||||
@JsonKey(name: "state", unknownEnumValue: PortionState.unknown) required PortionState portionState,
|
||||
}) = _PortionModel;
|
||||
|
||||
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/models/consultant_review_model.dart';
|
||||
import 'package:eatvio_app/features/diary/models/nutrition_model.dart';
|
||||
import 'package:eatvio_app/features/diary/presentation/diary_edit_access.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'diary_state.freezed.dart';
|
||||
@@ -12,7 +13,8 @@ abstract class DiaryState with _$DiaryState {
|
||||
|
||||
const factory DiaryState.loading() = DiaryStateLoading;
|
||||
|
||||
const factory DiaryState.error({required String errorMessage}) = DiaryStateError;
|
||||
const factory DiaryState.error({required String errorMessage}) =
|
||||
DiaryStateError;
|
||||
|
||||
const factory DiaryState.loaded({
|
||||
required DiaryDayEntity diary,
|
||||
@@ -31,6 +33,19 @@ abstract class DiaryState with _$DiaryState {
|
||||
@Default(false) bool isLoadingAddingProductInMeal,
|
||||
}) = DiaryStateLoaded;
|
||||
|
||||
const factory DiaryState.loadedTomorrowWithDiet({required DiaryFutureDietDayEntity diary, required DateTime selectedDay, required int selectedMeal}) =
|
||||
DiaryStateLoadedTomorrowWithDiet;
|
||||
const factory DiaryState.loadedTomorrowWithDiet({
|
||||
required DiaryFutureDietDayEntity diary,
|
||||
required DateTime selectedDay,
|
||||
required int selectedMeal,
|
||||
}) = DiaryStateLoadedTomorrowWithDiet;
|
||||
}
|
||||
|
||||
extension DiaryStateLoadedEditAccess on DiaryStateLoaded {
|
||||
bool get canEditDiary => canEditDiaryDay(
|
||||
isConsultantView: isConsultantView,
|
||||
isDiaryCommented: isDiaryCommented,
|
||||
isDiaryFinished: isDiaryFinished,
|
||||
closed: diary.closed,
|
||||
diaryDate: diary.date,
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
return Scaffold(
|
||||
body: DataErrorWidget(onReload: () => context.read<DiaryCubit>().fetchDiaryV2(needLoading: false, diaryId: widget.diaryId)),
|
||||
body: DataErrorWidget(
|
||||
onReload: () => context.read<DiaryCubit>().fetchDiaryV2(
|
||||
needLoading: false,
|
||||
diaryId: widget.diaryId,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -84,8 +89,14 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
|
||||
? AppBar(toolbarHeight: 0)
|
||||
: AppBar(
|
||||
title: Text(
|
||||
state is DiaryStateLoaded ? DateTime.parse(state.diary.date).toLocal().format('d MMMM') : '',
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
state is DiaryStateLoaded
|
||||
? DateTime.parse(
|
||||
state.diary.date,
|
||||
).toLocal().format('d MMMM')
|
||||
: '',
|
||||
style: TextStyles.headingXLSemibold.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
),
|
||||
@@ -95,26 +106,39 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
|
||||
child: Column(
|
||||
children: [
|
||||
if (widget.diaryId == null) CalendarWidget(date: widget.date),
|
||||
if (state is DiaryStateLoading) ...[const SizedBox(height: 60), const Loader()],
|
||||
if (state is DiaryStateLoading) ...[
|
||||
const SizedBox(height: 60),
|
||||
const Loader(),
|
||||
],
|
||||
if (state is DiaryStateLoaded)
|
||||
WakeupAndRetireWidget(
|
||||
isEnabled: state.diary.typeOfDiary.hasChosenPlan || state.isConsultantView || state.isWakeUpAndRetireEnabled,
|
||||
disable: state.isConsultantView || state.isDiaryFinished || state.isDiaryCommented,
|
||||
isEnabled:
|
||||
state.diary.typeOfDiary.hasChosenPlan ||
|
||||
state.isConsultantView ||
|
||||
state.isWakeUpAndRetireEnabled,
|
||||
disable: !state.canEditDiary,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (state is DiaryStateLoaded)
|
||||
MealWidget(
|
||||
showMood: state.diary.typeOfDiary.hasChosenPlan || state.isConsultantView || state.isMoodEnabled,
|
||||
showAddButton: !state.isConsultantView && !state.isDiaryFinished && !state.isDiaryCommented,
|
||||
showMood:
|
||||
state.diary.typeOfDiary.hasChosenPlan ||
|
||||
state.isConsultantView ||
|
||||
state.isMoodEnabled,
|
||||
showAddButton: state.canEditDiary,
|
||||
),
|
||||
if (state is DiaryStateLoadedTomorrowWithDiet) const MealWidget(showMood: false, showAddButton: false),
|
||||
if (state is DiaryStateLoadedTomorrowWithDiet)
|
||||
const MealWidget(showMood: false, showAddButton: false),
|
||||
if (state is DiaryStateLoaded) const CaloriesChartWidget(),
|
||||
const SizedBox(height: 12),
|
||||
Builder(
|
||||
builder: (context) {
|
||||
if (state is DiaryStateLoaded && state.diary.typeOfDiary.hasChosenPlan) {
|
||||
if (state is DiaryStateLoaded &&
|
||||
state.diary.typeOfDiary.hasChosenPlan) {
|
||||
if (state.diary.comment.isNotEmpty) {
|
||||
return CommentForConsultantWidget(comment: state.diary.comment);
|
||||
return CommentForConsultantWidget(
|
||||
comment: state.diary.comment,
|
||||
);
|
||||
}
|
||||
if (state.isDiaryCommented || state.isDiaryFinished) {
|
||||
return const SizedBox.shrink();
|
||||
@@ -124,7 +148,10 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
if (state is DiaryStateLoaded && !state.isConsultantView && !state.isDiaryFinished && !state.isDiaryCommented)
|
||||
if (state is DiaryStateLoaded &&
|
||||
!state.isConsultantView &&
|
||||
!state.isDiaryFinished &&
|
||||
!state.isDiaryCommented)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: _isDiaryFinishing
|
||||
@@ -135,11 +162,13 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
|
||||
setState(() {
|
||||
_isDiaryFinishing = true;
|
||||
});
|
||||
final isFinished = await context.read<DiaryCubit>().finishDiaryDay(
|
||||
diaryId: state.diary.id,
|
||||
wakeUp: state.diary.wakeup.toString(),
|
||||
retire: state.diary.retire.toString(),
|
||||
);
|
||||
final isFinished = await context
|
||||
.read<DiaryCubit>()
|
||||
.finishDiaryDay(
|
||||
diaryId: state.diary.id,
|
||||
wakeUp: state.diary.wakeup.toString(),
|
||||
retire: state.diary.retire.toString(),
|
||||
);
|
||||
if (!isFinished && context.mounted) {
|
||||
showErrorDialog(context);
|
||||
}
|
||||
@@ -149,33 +178,64 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
|
||||
},
|
||||
),
|
||||
),
|
||||
if (state is DiaryStateLoaded && state.isConsultantView && !state.isDiaryCommented) ...[
|
||||
if (state is DiaryStateLoaded &&
|
||||
state.isConsultantView &&
|
||||
!state.isDiaryCommented) ...[
|
||||
const SizedBox(height: 16),
|
||||
const MainBackgroundBouble(padding: EdgeInsets.all(16), child: DayCommentWidget()),
|
||||
const MainBackgroundBouble(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: DayCommentWidget(),
|
||||
),
|
||||
],
|
||||
if (state is DiaryStateLoaded && state.consultantReview?.consultantComments.dayComment != null) ...[
|
||||
if (state is DiaryStateLoaded &&
|
||||
state.consultantReview?.consultantComments.dayComment !=
|
||||
null) ...[
|
||||
const SizedBox(height: 12),
|
||||
MainBackgroundBouble(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(s.dayComment, style: TextStyles.textLSemibold.copyWith(color: appTheme.black)),
|
||||
Text(
|
||||
s.dayComment,
|
||||
style: TextStyles.textLSemibold.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
CommentContainer(comment: state.consultantReview?.consultantComments.dayComment ?? ''),
|
||||
CommentContainer(
|
||||
comment:
|
||||
state
|
||||
.consultantReview
|
||||
?.consultantComments
|
||||
.dayComment ??
|
||||
'',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
],
|
||||
if (state is DiaryStateLoaded && state.isConsultantView && state.isDiaryFinished && !state.isDiaryCommented) ...[
|
||||
if (state is DiaryStateLoaded &&
|
||||
state.isConsultantView &&
|
||||
state.isDiaryFinished &&
|
||||
!state.isDiaryCommented) ...[
|
||||
const SizedBox(height: 16),
|
||||
CustomOutlinedButton(
|
||||
text: s.send,
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(
|
||||
color: appTheme.whiteA700,
|
||||
),
|
||||
buttonStyle: ButtonStyle(
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
backgroundColor: WidgetStatePropertyAll(
|
||||
appTheme.blueA400,
|
||||
),
|
||||
),
|
||||
onPressed: () async {
|
||||
await context.read<ConsultantCommentsCubit>().submitComments(state.diary.id);
|
||||
await context
|
||||
.read<ConsultantCommentsCubit>()
|
||||
.submitComments(state.diary.id);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
@@ -26,7 +26,8 @@ class _MoodRatingWidgetState extends State<MoodRatingWidget> {
|
||||
final meals = state.diary.meals;
|
||||
if (selectedIndex >= 0 && selectedIndex < meals.length) {
|
||||
if (isFirstFetch[selectedIndex]) {
|
||||
currentSelectedRating[selectedIndex] = meals[selectedIndex].mood.toDouble() + 1;
|
||||
currentSelectedRating[selectedIndex] =
|
||||
meals[selectedIndex].mood.toDouble() + 1;
|
||||
isFirstFetch[selectedIndex] = false;
|
||||
}
|
||||
}
|
||||
@@ -36,21 +37,30 @@ class _MoodRatingWidgetState extends State<MoodRatingWidget> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
state.isConsultantView ? 'Настроение пользователя после приема пищи' : 'Ваше настроение после приёма пищи',
|
||||
state.isConsultantView
|
||||
? 'Настроение пользователя после приема пищи'
|
||||
: 'Ваше настроение после приёма пищи',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
SvgPicture.asset('assets/icons/mood_1.svg', height: 32, width: 32),
|
||||
SvgPicture.asset(
|
||||
'assets/icons/mood_1.svg',
|
||||
height: 32,
|
||||
width: 32,
|
||||
),
|
||||
RatingBar.builder(
|
||||
ignoreGestures: state.isConsultantView || state.isDiaryFinished || state.isDiaryCommented,
|
||||
initialRating: (selectedIndex >= 0 && selectedIndex < state.diary.meals.length)
|
||||
ignoreGestures: !state.canEditDiary,
|
||||
initialRating:
|
||||
(selectedIndex >= 0 &&
|
||||
selectedIndex < state.diary.meals.length)
|
||||
? state.diary.meals[selectedIndex].mood.toDouble() + 1
|
||||
: 3,
|
||||
itemCount: 6,
|
||||
itemSize: MediaQuery.of(context).size.width / 6 - (124 / 6),
|
||||
itemSize:
|
||||
MediaQuery.of(context).size.width / 6 - (124 / 6),
|
||||
updateOnDrag: true,
|
||||
itemBuilder: (context, index) {
|
||||
switch (index) {
|
||||
@@ -58,82 +68,154 @@ class _MoodRatingWidgetState extends State<MoodRatingWidget> {
|
||||
return Container(
|
||||
width: MediaQuery.of(context).size.width / 6,
|
||||
height: 24,
|
||||
margin: currentSelectedRating[selectedIndex] == 1 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
||||
margin: currentSelectedRating[selectedIndex] == 1
|
||||
? const EdgeInsets.symmetric(horizontal: 2)
|
||||
: null,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFC82222),
|
||||
border: currentSelectedRating[selectedIndex] == 1
|
||||
? Border.all(width: 2, color: const Color(0xFFAA3333), strokeAlign: BorderSide.strokeAlignOutside)
|
||||
border:
|
||||
currentSelectedRating[selectedIndex] == 1
|
||||
? Border.all(
|
||||
width: 2,
|
||||
color: const Color(0xFFAA3333),
|
||||
strokeAlign:
|
||||
BorderSide.strokeAlignOutside,
|
||||
)
|
||||
: null,
|
||||
borderRadius: currentSelectedRating[selectedIndex] == 1
|
||||
? const BorderRadius.horizontal(left: Radius.circular(8), right: Radius.circular(2))
|
||||
: const BorderRadius.horizontal(left: Radius.circular(8)),
|
||||
borderRadius:
|
||||
currentSelectedRating[selectedIndex] == 1
|
||||
? const BorderRadius.horizontal(
|
||||
left: Radius.circular(8),
|
||||
right: Radius.circular(2),
|
||||
)
|
||||
: const BorderRadius.horizontal(
|
||||
left: Radius.circular(8),
|
||||
),
|
||||
),
|
||||
);
|
||||
case 1:
|
||||
return Container(
|
||||
width: MediaQuery.of(context).size.width / 6,
|
||||
height: 24,
|
||||
margin: currentSelectedRating[selectedIndex] == 2 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
||||
margin: currentSelectedRating[selectedIndex] == 2
|
||||
? const EdgeInsets.symmetric(horizontal: 2)
|
||||
: null,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFF3E3E),
|
||||
border: currentSelectedRating[selectedIndex] == 2
|
||||
? Border.all(width: 2, color: const Color(0xFF550000), strokeAlign: BorderSide.strokeAlignOutside)
|
||||
border:
|
||||
currentSelectedRating[selectedIndex] == 2
|
||||
? Border.all(
|
||||
width: 2,
|
||||
color: const Color(0xFF550000),
|
||||
strokeAlign:
|
||||
BorderSide.strokeAlignOutside,
|
||||
)
|
||||
: null,
|
||||
borderRadius:
|
||||
currentSelectedRating[selectedIndex] == 2
|
||||
? BorderRadius.circular(2)
|
||||
: null,
|
||||
borderRadius: currentSelectedRating[selectedIndex] == 2 ? BorderRadius.circular(2) : null,
|
||||
),
|
||||
);
|
||||
case 2:
|
||||
return Container(
|
||||
width: MediaQuery.of(context).size.width / 6,
|
||||
height: 24,
|
||||
margin: currentSelectedRating[selectedIndex] == 3 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
||||
margin: currentSelectedRating[selectedIndex] == 3
|
||||
? const EdgeInsets.symmetric(horizontal: 2)
|
||||
: null,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8C926),
|
||||
border: currentSelectedRating[selectedIndex] == 3
|
||||
? Border.all(width: 2, color: const Color(0xFF8C5D00), strokeAlign: BorderSide.strokeAlignOutside)
|
||||
border:
|
||||
currentSelectedRating[selectedIndex] == 3
|
||||
? Border.all(
|
||||
width: 2,
|
||||
color: const Color(0xFF8C5D00),
|
||||
strokeAlign:
|
||||
BorderSide.strokeAlignOutside,
|
||||
)
|
||||
: null,
|
||||
borderRadius:
|
||||
currentSelectedRating[selectedIndex] == 3
|
||||
? BorderRadius.circular(2)
|
||||
: null,
|
||||
borderRadius: currentSelectedRating[selectedIndex] == 3 ? BorderRadius.circular(2) : null,
|
||||
),
|
||||
);
|
||||
case 3:
|
||||
return Container(
|
||||
width: MediaQuery.of(context).size.width / 6,
|
||||
height: 24,
|
||||
margin: currentSelectedRating[selectedIndex] == 4 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
||||
margin: currentSelectedRating[selectedIndex] == 4
|
||||
? const EdgeInsets.symmetric(horizontal: 2)
|
||||
: null,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF3BF),
|
||||
border: currentSelectedRating[selectedIndex] == 4
|
||||
? Border.all(width: 2, color: const Color(0xFFB5911B), strokeAlign: BorderSide.strokeAlignOutside)
|
||||
border:
|
||||
currentSelectedRating[selectedIndex] == 4
|
||||
? Border.all(
|
||||
width: 2,
|
||||
color: const Color(0xFFB5911B),
|
||||
strokeAlign:
|
||||
BorderSide.strokeAlignOutside,
|
||||
)
|
||||
: null,
|
||||
borderRadius:
|
||||
currentSelectedRating[selectedIndex] == 4
|
||||
? BorderRadius.circular(2)
|
||||
: null,
|
||||
borderRadius: currentSelectedRating[selectedIndex] == 4 ? BorderRadius.circular(2) : null,
|
||||
),
|
||||
);
|
||||
case 4:
|
||||
return Container(
|
||||
width: MediaQuery.of(context).size.width / 6,
|
||||
height: 24,
|
||||
margin: currentSelectedRating[selectedIndex] == 5 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
||||
margin: currentSelectedRating[selectedIndex] == 5
|
||||
? const EdgeInsets.symmetric(horizontal: 2)
|
||||
: null,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF62E833),
|
||||
border: currentSelectedRating[selectedIndex] == 5
|
||||
? Border.all(width: 2, color: const Color(0xFF276918), strokeAlign: BorderSide.strokeAlignOutside)
|
||||
border:
|
||||
currentSelectedRating[selectedIndex] == 5
|
||||
? Border.all(
|
||||
width: 2,
|
||||
color: const Color(0xFF276918),
|
||||
strokeAlign:
|
||||
BorderSide.strokeAlignOutside,
|
||||
)
|
||||
: null,
|
||||
borderRadius:
|
||||
currentSelectedRating[selectedIndex] == 5
|
||||
? BorderRadius.circular(2)
|
||||
: null,
|
||||
borderRadius: currentSelectedRating[selectedIndex] == 5 ? BorderRadius.circular(2) : null,
|
||||
),
|
||||
);
|
||||
default:
|
||||
return Container(
|
||||
width: MediaQuery.of(context).size.width / 6,
|
||||
height: 24,
|
||||
margin: currentSelectedRating[selectedIndex] == 6 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
||||
margin: currentSelectedRating[selectedIndex] == 6
|
||||
? const EdgeInsets.symmetric(horizontal: 2)
|
||||
: null,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF4DCE20),
|
||||
border: currentSelectedRating[selectedIndex] == 6
|
||||
? Border.all(width: 2, color: const Color(0xFF184D12), strokeAlign: BorderSide.strokeAlignOutside)
|
||||
border:
|
||||
currentSelectedRating[selectedIndex] == 6
|
||||
? Border.all(
|
||||
width: 2,
|
||||
color: const Color(0xFF184D12),
|
||||
strokeAlign:
|
||||
BorderSide.strokeAlignOutside,
|
||||
)
|
||||
: null,
|
||||
borderRadius: currentSelectedRating[selectedIndex] == 6
|
||||
? const BorderRadius.horizontal(left: Radius.circular(2), right: Radius.circular(8))
|
||||
: const BorderRadius.horizontal(right: Radius.circular(8)),
|
||||
borderRadius:
|
||||
currentSelectedRating[selectedIndex] == 6
|
||||
? const BorderRadius.horizontal(
|
||||
left: Radius.circular(2),
|
||||
right: Radius.circular(8),
|
||||
)
|
||||
: const BorderRadius.horizontal(
|
||||
right: Radius.circular(8),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -142,12 +224,25 @@ class _MoodRatingWidgetState extends State<MoodRatingWidget> {
|
||||
setState(() {
|
||||
currentSelectedRating[selectedIndex] = value;
|
||||
});
|
||||
EasyDebounce.debounce('change-satiety', const Duration(milliseconds: 250), () {
|
||||
context.read<DiaryCubit>().fillMood(diaryId: state.diary.id, mood: currentSelectedRating[selectedIndex].toInt() - 1);
|
||||
});
|
||||
EasyDebounce.debounce(
|
||||
'change-satiety',
|
||||
const Duration(milliseconds: 250),
|
||||
() {
|
||||
context.read<DiaryCubit>().fillMood(
|
||||
diaryId: state.diary.id,
|
||||
mood:
|
||||
currentSelectedRating[selectedIndex].toInt() -
|
||||
1,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
SvgPicture.asset('assets/icons/mood_2.svg', height: 32, width: 32),
|
||||
SvgPicture.asset(
|
||||
'assets/icons/mood_2.svg',
|
||||
height: 32,
|
||||
width: 32,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -52,7 +52,9 @@ class _PhotoGalleryWidgetState extends State<PhotoGalleryWidget> {
|
||||
AppRouter.navigatorKey.currentContext?.pop();
|
||||
|
||||
final picker = ImagePicker();
|
||||
XFile? image = await picker.pickImage(source: ImageSource.camera);
|
||||
XFile? image = await picker.pickImage(
|
||||
source: ImageSource.camera,
|
||||
);
|
||||
if (image != null) {
|
||||
diaryCubit.uploadPhotos(
|
||||
files: [File(image.path)],
|
||||
@@ -116,7 +118,8 @@ class _PhotoGalleryWidgetState extends State<PhotoGalleryWidget> {
|
||||
builder: (context, state) {
|
||||
if (state is DiaryStateLoaded) {
|
||||
int mealIndex = state.selectedMeal - 1;
|
||||
if (state.diary.meals[mealIndex].photos.isEmpty && !state.isConsultantView && !state.isDiaryFinished && !state.isDiaryCommented) {
|
||||
if (state.diary.meals[mealIndex].photos.isEmpty &&
|
||||
state.canEditDiary) {
|
||||
return GestureDetector(
|
||||
onTap: () async {
|
||||
showImagePicker(state.diary);
|
||||
@@ -140,10 +143,11 @@ class _PhotoGalleryWidgetState extends State<PhotoGalleryWidget> {
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: '${sl<AppConfig>().baseUrl}/storage/${state.diary.meals[mealIndex].photos[currentSelectedPhotoIndex].path}',
|
||||
imageUrl:
|
||||
'${sl<AppConfig>().baseUrl}/storage/${state.diary.meals[mealIndex].photos[currentSelectedPhotoIndex].path}',
|
||||
),
|
||||
),
|
||||
if (!state.isConsultantView && !state.isDiaryFinished && !state.isDiaryCommented)
|
||||
if (state.canEditDiary)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: IconButton.filled(
|
||||
@@ -153,12 +157,24 @@ class _PhotoGalleryWidgetState extends State<PhotoGalleryWidget> {
|
||||
),
|
||||
onPressed: () async {
|
||||
await diaryCubit.deletePhoto(
|
||||
photo: state.diary.meals[mealIndex].photos[currentSelectedPhotoIndex],
|
||||
photo: state
|
||||
.diary
|
||||
.meals[mealIndex]
|
||||
.photos[currentSelectedPhotoIndex],
|
||||
diaryId: state.diary.id,
|
||||
);
|
||||
setState(() {
|
||||
if (currentSelectedPhotoIndex == state.diary.meals[mealIndex].photos.length - 1 && currentSelectedPhotoIndex != 0) {
|
||||
currentSelectedPhotoIndex = state.diary.meals[mealIndex].photos.length - 2;
|
||||
if (currentSelectedPhotoIndex ==
|
||||
state
|
||||
.diary
|
||||
.meals[mealIndex]
|
||||
.photos
|
||||
.length -
|
||||
1 &&
|
||||
currentSelectedPhotoIndex != 0) {
|
||||
currentSelectedPhotoIndex =
|
||||
state.diary.meals[mealIndex].photos.length -
|
||||
2;
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -178,8 +194,13 @@ class _PhotoGalleryWidgetState extends State<PhotoGalleryWidget> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
for (int i = 0; i < state.diary.meals[mealIndex].photos.length + 1; i++) ...[
|
||||
if (i == state.diary.meals[mealIndex].photos.length && !state.isConsultantView && !state.isDiaryFinished && !state.isDiaryCommented)
|
||||
for (
|
||||
int i = 0;
|
||||
i < state.diary.meals[mealIndex].photos.length + 1;
|
||||
i++
|
||||
) ...[
|
||||
if (i == state.diary.meals[mealIndex].photos.length &&
|
||||
state.canEditDiary)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: InkWell(
|
||||
@@ -212,13 +233,15 @@ class _PhotoGalleryWidgetState extends State<PhotoGalleryWidget> {
|
||||
height: 52,
|
||||
width: 52,
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: '${sl<AppConfig>().baseUrl}/storage/${state.diary.meals[mealIndex].photos[i].path}',
|
||||
imageUrl:
|
||||
'${sl<AppConfig>().baseUrl}/storage/${state.diary.meals[mealIndex].photos[i].path}',
|
||||
fit: BoxFit.fitWidth,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (i < state.diary.meals[mealIndex].photos.length - 1) const SizedBox(width: 8),
|
||||
if (i < state.diary.meals[mealIndex].photos.length - 1)
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:dotted_border/dotted_border.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/features/consultations/models/consultation_model.dart';
|
||||
import 'package:eatvio_app/features/diary/models/consultant_review_model.dart';
|
||||
import 'package:eatvio_app/features/diary/models/diary_day_model.dart';
|
||||
import 'package:eatvio_app/features/diary/presentation/bloc/diary_cubit.dart';
|
||||
@@ -32,17 +30,26 @@ class _ProductWidgetState extends State<ProductWidget> {
|
||||
super.initState();
|
||||
diaryCubit = context.read<DiaryCubit>();
|
||||
portionName = widget.portion.portion == 'gr' ? 'гр' : 'шт';
|
||||
isEaten = widget.portion.portionState == PortionState.eatenFromDiet ? true : false;
|
||||
isEaten = widget.portion.portionState == PortionState.eatenFromDiet
|
||||
? true
|
||||
: false;
|
||||
}
|
||||
|
||||
Widget _buildNutritionalInfo() {
|
||||
if (widget.portion.productNutritionalValueId == null) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Text(
|
||||
'Продукт отправлен на модерацию',
|
||||
style: TextStyle(color: Color(0xFF2F80ED), fontSize: 12, fontWeight: FontWeight.w500),
|
||||
style: TextStyle(
|
||||
color: Color(0xFF2F80ED),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -57,7 +64,8 @@ class _ProductWidgetState extends State<ProductWidget> {
|
||||
calories = (caloriesInfo.calories * widget.portion.quantity) / 100;
|
||||
proteins = (caloriesInfo.proteins * widget.portion.quantity) / 100;
|
||||
fats = (caloriesInfo.fats * widget.portion.quantity) / 100;
|
||||
carbohydrates = (caloriesInfo.carbohydrates * widget.portion.quantity) / 100;
|
||||
carbohydrates =
|
||||
(caloriesInfo.carbohydrates * widget.portion.quantity) / 100;
|
||||
} else {
|
||||
calories = caloriesInfo.energyOnPortion;
|
||||
proteins = caloriesInfo.proteinsOnPortion;
|
||||
@@ -81,10 +89,17 @@ class _ProductWidgetState extends State<ProductWidget> {
|
||||
Widget _buildNutritionChip(String label, int value) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(8)),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'$label $value',
|
||||
style: const TextStyle(color: Color(0xFF6D6F71), fontSize: 12, fontWeight: FontWeight.w500),
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF6D6F71),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -93,16 +108,24 @@ class _ProductWidgetState extends State<ProductWidget> {
|
||||
void didUpdateWidget(ProductWidget oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
portionName = widget.portion.portion == 'gr' ? 'гр' : 'шт';
|
||||
isEaten = widget.portion.portionState == PortionState.eatenFromDiet ? true : false;
|
||||
isEaten = widget.portion.portionState == PortionState.eatenFromDiet
|
||||
? true
|
||||
: false;
|
||||
}
|
||||
|
||||
Future updateProductInMeal(DiaryStateLoaded state, PortionModel portion) {
|
||||
return showModalBottomSheet(
|
||||
useRootNavigator: true,
|
||||
context: context,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(12))),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
|
||||
),
|
||||
isScrollControlled: true,
|
||||
builder: (context) => AddProductModal(state: state, portion: portion, diaryCubit: diaryCubit),
|
||||
builder: (context) => AddProductModal(
|
||||
state: state,
|
||||
portion: portion,
|
||||
diaryCubit: diaryCubit,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -113,33 +136,36 @@ class _ProductWidgetState extends State<ProductWidget> {
|
||||
if (state is DiaryStateLoadedTomorrowWithDiet) {
|
||||
return ListTile(
|
||||
horizontalTitleGap: 0,
|
||||
contentPadding: widget.portion.fromDiet ? const EdgeInsets.only(right: 24, left: 12) : null,
|
||||
contentPadding: widget.portion.fromDiet
|
||||
? const EdgeInsets.only(right: 24, left: 12)
|
||||
: null,
|
||||
title: Text(widget.portion.productName),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('${widget.portion.quantity} ${widget.portion.portion == 'gr' ? 'гр' : 'шт'}'),
|
||||
Text(
|
||||
'${widget.portion.quantity} ${widget.portion.portion == 'gr' ? 'гр' : 'шт'}',
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
_buildNutritionalInfo(),
|
||||
],
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
tileColor: const Color(0xFFF6FAFF),
|
||||
);
|
||||
}
|
||||
if (state is DiaryStateLoaded) {
|
||||
final isDiaryCommented =
|
||||
state.diary.typeOfDiary.diaryState == DiaryStateEnum.commented ||
|
||||
state.diary.typeOfDiary.diaryState == DiaryStateEnum.lastDayCommented ||
|
||||
state.diary.typeOfDiary.diaryState == DiaryStateEnum.reviewedByUser;
|
||||
final isDiaryFinished =
|
||||
state.diary.typeOfDiary.diaryState == DiaryStateEnum.finished || state.diary.typeOfDiary.diaryState == DiaryStateEnum.lastDayFinished;
|
||||
final canEditPortion = !state.isConsultantView && !isDiaryCommented && !isDiaryFinished && !widget.portion.fromDiet;
|
||||
final canEditPortion = state.canEditDiary && !widget.portion.fromDiet;
|
||||
final consultantComments = state.consultantReview?.consultantComments;
|
||||
final canMarkAsEaten = !state.isConsultantView && !isDiaryCommented && !isDiaryFinished;
|
||||
final canMarkAsEaten = state.canEditDiary;
|
||||
PortionComment? portionComment;
|
||||
if (consultantComments != null) {
|
||||
portionComment = consultantComments.portionsComments.firstWhereOrNull((elem) => elem.portionId == widget.portion.id);
|
||||
portionComment = consultantComments.portionsComments
|
||||
.firstWhereOrNull(
|
||||
(elem) => elem.portionId == widget.portion.id,
|
||||
);
|
||||
}
|
||||
if (widget.portion.portionState == PortionState.eatenDeleted) {
|
||||
return DottedBorder(
|
||||
@@ -151,27 +177,41 @@ class _ProductWidgetState extends State<ProductWidget> {
|
||||
color: Color(0xFF6D6F71),
|
||||
),
|
||||
child: ListTile(
|
||||
onTap: () => diaryCubit.restore(diaryId: state.diary.id, mealId: state.selectedMeal, portionId: widget.portion.id),
|
||||
trailing: SvgPicture.asset('assets/icons/revert_product.svg'),
|
||||
onTap: state.canEditDiary
|
||||
? () => diaryCubit.restore(
|
||||
diaryId: state.diary.id,
|
||||
mealId: state.selectedMeal,
|
||||
portionId: widget.portion.id,
|
||||
)
|
||||
: null,
|
||||
trailing: state.canEditDiary
|
||||
? SvgPicture.asset('assets/icons/revert_product.svg')
|
||||
: null,
|
||||
horizontalTitleGap: 0,
|
||||
contentPadding: widget.portion.fromDiet ? const EdgeInsets.only(right: 24, left: 4) : null,
|
||||
contentPadding: widget.portion.fromDiet
|
||||
? const EdgeInsets.only(right: 24, left: 4)
|
||||
: null,
|
||||
title: Text(widget.portion.productName),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('${widget.portion.quantity} ${widget.portion.portion == 'gr' ? 'гр' : 'шт'}'),
|
||||
Text(
|
||||
'${widget.portion.quantity} ${widget.portion.portion == 'gr' ? 'гр' : 'шт'}',
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
_buildNutritionalInfo(),
|
||||
],
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
// ignore: use_full_hex_values_for_flutter_colors
|
||||
tileColor: const Color(0xfff6faff03).withValues(alpha: 0.01),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
Widget? trailing;
|
||||
if (state.isConsultantView && !isDiaryCommented) {
|
||||
if (state.isConsultantView && !state.isDiaryCommented) {
|
||||
trailing = GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
@@ -180,13 +220,15 @@ class _ProductWidgetState extends State<ProductWidget> {
|
||||
},
|
||||
child: SvgPicture.asset('assets/icons/edit.svg'),
|
||||
);
|
||||
} else if (!isDiaryCommented && !widget.portion.fromDiet) {
|
||||
} else if (canEditPortion) {
|
||||
trailing = const Icon(Icons.more_vert_rounded);
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
ListTile(
|
||||
onTap: canEditPortion ? () => updateProductInMeal(state, widget.portion) : null,
|
||||
onTap: canEditPortion
|
||||
? () => updateProductInMeal(state, widget.portion)
|
||||
: null,
|
||||
trailing: trailing,
|
||||
leading: widget.portion.fromDiet
|
||||
? Transform.scale(
|
||||
@@ -199,35 +241,55 @@ class _ProductWidgetState extends State<ProductWidget> {
|
||||
isEaten = value!;
|
||||
});
|
||||
if (isEaten) {
|
||||
diaryCubit.markEaten(diaryId: state.diary.id, mealId: state.selectedMeal, portionId: widget.portion.id);
|
||||
diaryCubit.markEaten(
|
||||
diaryId: state.diary.id,
|
||||
mealId: state.selectedMeal,
|
||||
portionId: widget.portion.id,
|
||||
);
|
||||
} else {
|
||||
diaryCubit.unmarkEaten(diaryId: state.diary.id, mealId: state.selectedMeal, portionId: widget.portion.id);
|
||||
diaryCubit.unmarkEaten(
|
||||
diaryId: state.diary.id,
|
||||
mealId: state.selectedMeal,
|
||||
portionId: widget.portion.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
: null,
|
||||
activeColor: appTheme.blueA400,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
side: const BorderSide(color: Color(0xFF809CB9)),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
horizontalTitleGap: 0,
|
||||
contentPadding: widget.portion.fromDiet ? const EdgeInsets.only(right: 24, left: 4) : null,
|
||||
contentPadding: widget.portion.fromDiet
|
||||
? const EdgeInsets.only(right: 24, left: 4)
|
||||
: null,
|
||||
title: Text(widget.portion.productName),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('${widget.portion.quantity} ${widget.portion.portion == 'gr' ? 'гр' : 'шт'}'),
|
||||
Text(
|
||||
'${widget.portion.quantity} ${widget.portion.portion == 'gr' ? 'гр' : 'шт'}',
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
_buildNutritionalInfo(),
|
||||
],
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
tileColor: const Color(0xFFF6FAFF),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: PortionCommentWidget(enabled: showCommentWidget, portionId: widget.portion.id, comment: portionComment?.comment),
|
||||
child: PortionCommentWidget(
|
||||
enabled: showCommentWidget,
|
||||
portionId: widget.portion.id,
|
||||
comment: portionComment?.comment,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -26,7 +26,8 @@ class _SatietyRatingWidgetState extends State<SatietyRatingWidget> {
|
||||
final meals = state.diary.meals;
|
||||
if (selectedIndex >= 0 && selectedIndex < meals.length) {
|
||||
if (isFirstFetch[selectedIndex]) {
|
||||
currentSelectedRating[selectedIndex] = meals[selectedIndex].satiety.toDouble() + 1;
|
||||
currentSelectedRating[selectedIndex] =
|
||||
meals[selectedIndex].satiety.toDouble() + 1;
|
||||
isFirstFetch[selectedIndex] = false;
|
||||
}
|
||||
}
|
||||
@@ -36,21 +37,32 @@ class _SatietyRatingWidgetState extends State<SatietyRatingWidget> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
state.isConsultantView ? 'Сытость пользователя после приема пищи' : 'Ваша сытость после приёма пищи',
|
||||
state.isConsultantView
|
||||
? 'Сытость пользователя после приема пищи'
|
||||
: 'Ваша сытость после приёма пищи',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
SvgPicture.asset('assets/icons/satiety_emoji_left.svg', height: 32, width: 32),
|
||||
SvgPicture.asset(
|
||||
'assets/icons/satiety_emoji_left.svg',
|
||||
height: 32,
|
||||
width: 32,
|
||||
),
|
||||
RatingBar.builder(
|
||||
ignoreGestures: state.isConsultantView || state.isDiaryFinished || state.isDiaryCommented,
|
||||
initialRating: (selectedIndex >= 0 && selectedIndex < state.diary.meals.length)
|
||||
? state.diary.meals[selectedIndex].satiety.toDouble() + 1
|
||||
ignoreGestures: !state.canEditDiary,
|
||||
initialRating:
|
||||
(selectedIndex >= 0 &&
|
||||
selectedIndex < state.diary.meals.length)
|
||||
? state.diary.meals[selectedIndex].satiety
|
||||
.toDouble() +
|
||||
1
|
||||
: 3,
|
||||
itemCount: 6,
|
||||
itemSize: MediaQuery.of(context).size.width / 6 - (124 / 6),
|
||||
itemSize:
|
||||
MediaQuery.of(context).size.width / 6 - (124 / 6),
|
||||
updateOnDrag: true,
|
||||
itemBuilder: (context, index) {
|
||||
switch (index) {
|
||||
@@ -58,82 +70,154 @@ class _SatietyRatingWidgetState extends State<SatietyRatingWidget> {
|
||||
return Container(
|
||||
width: MediaQuery.of(context).size.width / 6,
|
||||
height: 24,
|
||||
margin: currentSelectedRating[selectedIndex] == 1 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
||||
margin: currentSelectedRating[selectedIndex] == 1
|
||||
? const EdgeInsets.symmetric(horizontal: 2)
|
||||
: null,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFC82222),
|
||||
border: currentSelectedRating[selectedIndex] == 1
|
||||
? Border.all(width: 2, color: const Color(0xFFAA3333), strokeAlign: BorderSide.strokeAlignOutside)
|
||||
border:
|
||||
currentSelectedRating[selectedIndex] == 1
|
||||
? Border.all(
|
||||
width: 2,
|
||||
color: const Color(0xFFAA3333),
|
||||
strokeAlign:
|
||||
BorderSide.strokeAlignOutside,
|
||||
)
|
||||
: null,
|
||||
borderRadius: currentSelectedRating[selectedIndex] == 1
|
||||
? const BorderRadius.horizontal(left: Radius.circular(8), right: Radius.circular(2))
|
||||
: const BorderRadius.horizontal(left: Radius.circular(8)),
|
||||
borderRadius:
|
||||
currentSelectedRating[selectedIndex] == 1
|
||||
? const BorderRadius.horizontal(
|
||||
left: Radius.circular(8),
|
||||
right: Radius.circular(2),
|
||||
)
|
||||
: const BorderRadius.horizontal(
|
||||
left: Radius.circular(8),
|
||||
),
|
||||
),
|
||||
);
|
||||
case 1:
|
||||
return Container(
|
||||
width: MediaQuery.of(context).size.width / 6,
|
||||
height: 24,
|
||||
margin: currentSelectedRating[selectedIndex] == 2 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
||||
margin: currentSelectedRating[selectedIndex] == 2
|
||||
? const EdgeInsets.symmetric(horizontal: 2)
|
||||
: null,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFF3E3E),
|
||||
border: currentSelectedRating[selectedIndex] == 2
|
||||
? Border.all(width: 2, color: const Color(0xFF550000), strokeAlign: BorderSide.strokeAlignOutside)
|
||||
border:
|
||||
currentSelectedRating[selectedIndex] == 2
|
||||
? Border.all(
|
||||
width: 2,
|
||||
color: const Color(0xFF550000),
|
||||
strokeAlign:
|
||||
BorderSide.strokeAlignOutside,
|
||||
)
|
||||
: null,
|
||||
borderRadius:
|
||||
currentSelectedRating[selectedIndex] == 2
|
||||
? BorderRadius.circular(2)
|
||||
: null,
|
||||
borderRadius: currentSelectedRating[selectedIndex] == 2 ? BorderRadius.circular(2) : null,
|
||||
),
|
||||
);
|
||||
case 2:
|
||||
return Container(
|
||||
width: MediaQuery.of(context).size.width / 6,
|
||||
height: 24,
|
||||
margin: currentSelectedRating[selectedIndex] == 3 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
||||
margin: currentSelectedRating[selectedIndex] == 3
|
||||
? const EdgeInsets.symmetric(horizontal: 2)
|
||||
: null,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8C926),
|
||||
border: currentSelectedRating[selectedIndex] == 3
|
||||
? Border.all(width: 2, color: const Color(0xFF8C5D00), strokeAlign: BorderSide.strokeAlignOutside)
|
||||
border:
|
||||
currentSelectedRating[selectedIndex] == 3
|
||||
? Border.all(
|
||||
width: 2,
|
||||
color: const Color(0xFF8C5D00),
|
||||
strokeAlign:
|
||||
BorderSide.strokeAlignOutside,
|
||||
)
|
||||
: null,
|
||||
borderRadius:
|
||||
currentSelectedRating[selectedIndex] == 3
|
||||
? BorderRadius.circular(2)
|
||||
: null,
|
||||
borderRadius: currentSelectedRating[selectedIndex] == 3 ? BorderRadius.circular(2) : null,
|
||||
),
|
||||
);
|
||||
case 3:
|
||||
return Container(
|
||||
width: MediaQuery.of(context).size.width / 6,
|
||||
height: 24,
|
||||
margin: currentSelectedRating[selectedIndex] == 4 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
||||
margin: currentSelectedRating[selectedIndex] == 4
|
||||
? const EdgeInsets.symmetric(horizontal: 2)
|
||||
: null,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF3BF),
|
||||
border: currentSelectedRating[selectedIndex] == 4
|
||||
? Border.all(width: 2, color: const Color(0xFFB5911B), strokeAlign: BorderSide.strokeAlignOutside)
|
||||
border:
|
||||
currentSelectedRating[selectedIndex] == 4
|
||||
? Border.all(
|
||||
width: 2,
|
||||
color: const Color(0xFFB5911B),
|
||||
strokeAlign:
|
||||
BorderSide.strokeAlignOutside,
|
||||
)
|
||||
: null,
|
||||
borderRadius:
|
||||
currentSelectedRating[selectedIndex] == 4
|
||||
? BorderRadius.circular(2)
|
||||
: null,
|
||||
borderRadius: currentSelectedRating[selectedIndex] == 4 ? BorderRadius.circular(2) : null,
|
||||
),
|
||||
);
|
||||
case 4:
|
||||
return Container(
|
||||
width: MediaQuery.of(context).size.width / 6,
|
||||
height: 24,
|
||||
margin: currentSelectedRating[selectedIndex] == 5 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
||||
margin: currentSelectedRating[selectedIndex] == 5
|
||||
? const EdgeInsets.symmetric(horizontal: 2)
|
||||
: null,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF62E833),
|
||||
border: currentSelectedRating[selectedIndex] == 5
|
||||
? Border.all(width: 2, color: const Color(0xFF276918), strokeAlign: BorderSide.strokeAlignOutside)
|
||||
border:
|
||||
currentSelectedRating[selectedIndex] == 5
|
||||
? Border.all(
|
||||
width: 2,
|
||||
color: const Color(0xFF276918),
|
||||
strokeAlign:
|
||||
BorderSide.strokeAlignOutside,
|
||||
)
|
||||
: null,
|
||||
borderRadius:
|
||||
currentSelectedRating[selectedIndex] == 5
|
||||
? BorderRadius.circular(2)
|
||||
: null,
|
||||
borderRadius: currentSelectedRating[selectedIndex] == 5 ? BorderRadius.circular(2) : null,
|
||||
),
|
||||
);
|
||||
default:
|
||||
return Container(
|
||||
width: MediaQuery.of(context).size.width / 6,
|
||||
height: 24,
|
||||
margin: currentSelectedRating[selectedIndex] == 6 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
||||
margin: currentSelectedRating[selectedIndex] == 6
|
||||
? const EdgeInsets.symmetric(horizontal: 2)
|
||||
: null,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF4DCE20),
|
||||
border: currentSelectedRating[selectedIndex] == 6
|
||||
? Border.all(width: 2, color: const Color(0xFF184D12), strokeAlign: BorderSide.strokeAlignOutside)
|
||||
border:
|
||||
currentSelectedRating[selectedIndex] == 6
|
||||
? Border.all(
|
||||
width: 2,
|
||||
color: const Color(0xFF184D12),
|
||||
strokeAlign:
|
||||
BorderSide.strokeAlignOutside,
|
||||
)
|
||||
: null,
|
||||
borderRadius: currentSelectedRating[selectedIndex] == 6
|
||||
? const BorderRadius.horizontal(left: Radius.circular(2), right: Radius.circular(8))
|
||||
: const BorderRadius.horizontal(right: Radius.circular(8)),
|
||||
borderRadius:
|
||||
currentSelectedRating[selectedIndex] == 6
|
||||
? const BorderRadius.horizontal(
|
||||
left: Radius.circular(2),
|
||||
right: Radius.circular(8),
|
||||
)
|
||||
: const BorderRadius.horizontal(
|
||||
right: Radius.circular(8),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -143,12 +227,25 @@ class _SatietyRatingWidgetState extends State<SatietyRatingWidget> {
|
||||
setState(() {
|
||||
currentSelectedRating[selectedIndex] = value;
|
||||
});
|
||||
EasyDebounce.debounce('change-satiety', const Duration(milliseconds: 250), () {
|
||||
context.read<DiaryCubit>().fillSatiety(diaryId: state.diary.id, satiety: currentSelectedRating[selectedIndex].toInt() - 1);
|
||||
});
|
||||
EasyDebounce.debounce(
|
||||
'change-satiety',
|
||||
const Duration(milliseconds: 250),
|
||||
() {
|
||||
context.read<DiaryCubit>().fillSatiety(
|
||||
diaryId: state.diary.id,
|
||||
satiety:
|
||||
currentSelectedRating[selectedIndex].toInt() -
|
||||
1,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
SvgPicture.asset('assets/icons/satiety_emoji_right.svg', height: 32, width: 32),
|
||||
SvgPicture.asset(
|
||||
'assets/icons/satiety_emoji_right.svg',
|
||||
height: 32,
|
||||
width: 32,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -46,7 +46,8 @@ class _TimeOfMealPickerWidgetState extends State<TimeOfMealPickerWidget> {
|
||||
Navigator.of(context).pop();
|
||||
diaryCubit.fillTime(
|
||||
diaryId: diaryId,
|
||||
time: '${value.hour.toString().padLeft(2, '0')}:${value.minute.toString().padLeft(2, '0')}:00',
|
||||
time:
|
||||
'${value.hour.toString().padLeft(2, '0')}:${value.minute.toString().padLeft(2, '0')}:00',
|
||||
);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
@@ -69,7 +70,7 @@ class _TimeOfMealPickerWidgetState extends State<TimeOfMealPickerWidget> {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 30)
|
||||
const SizedBox(height: 30),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -98,7 +99,9 @@ class _TimeOfMealPickerWidgetState extends State<TimeOfMealPickerWidget> {
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
onTap: state.isConsultantView || state.isDiaryFinished || state.isDiaryCommented ? null : () => showTimePicker(state.diary.id),
|
||||
onTap: state.canEditDiary
|
||||
? () => showTimePicker(state.diary.id)
|
||||
: null,
|
||||
borderRadius: BorderRadius.circular(100),
|
||||
child: Ink(
|
||||
height: 32,
|
||||
@@ -110,7 +113,16 @@ class _TimeOfMealPickerWidgetState extends State<TimeOfMealPickerWidget> {
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
state.diary.meals[state.selectedMeal - 1].time.substring(0, state.diary.meals[state.selectedMeal - 1].time.length - 3),
|
||||
state.diary.meals[state.selectedMeal - 1].time
|
||||
.substring(
|
||||
0,
|
||||
state
|
||||
.diary
|
||||
.meals[state.selectedMeal - 1]
|
||||
.time
|
||||
.length -
|
||||
3,
|
||||
),
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF6D6F71),
|
||||
fontSize: 17,
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/features/diets/presentation/widgets/info_bouble.dart';
|
||||
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||
import 'package:eatvio_app/core/utils/url_utils.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/features/profile/models/profile_model.dart';
|
||||
import 'package:eatvio_app/features/user_details/presentation/user_details_screen.dart';
|
||||
@@ -11,7 +12,14 @@ import 'package:flutter_svg/svg.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class AuthorCard extends StatelessWidget {
|
||||
const AuthorCard({super.key, required this.author, required this.title, this.backgroundColor, this.titleStyle, this.customerProfile});
|
||||
const AuthorCard({
|
||||
super.key,
|
||||
required this.author,
|
||||
required this.title,
|
||||
this.backgroundColor,
|
||||
this.titleStyle,
|
||||
this.customerProfile,
|
||||
});
|
||||
final Author author;
|
||||
final String title;
|
||||
final Color? backgroundColor;
|
||||
@@ -21,15 +29,28 @@ class AuthorCard extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed('profileScreen', extra: customerProfile != null ? customerProfile!.slug : author.authorSlug);
|
||||
GoRouter.of(context).pushNamed(
|
||||
'profileScreen',
|
||||
extra: customerProfile != null
|
||||
? customerProfile!.slug
|
||||
: author.authorSlug,
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
width: MediaQuery.sizeOf(context).width,
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(12), color: backgroundColor ?? appTheme.cardBackground),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: backgroundColor ?? appTheme.cardBackground,
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16, top: 16, bottom: 24, right: 16),
|
||||
padding: const EdgeInsets.only(
|
||||
left: 16,
|
||||
top: 16,
|
||||
bottom: 24,
|
||||
right: 16,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
ListTile(
|
||||
@@ -41,28 +62,43 @@ class AuthorCard extends StatelessWidget {
|
||||
shape: BoxShape.circle,
|
||||
image: DecorationImage(
|
||||
image: NetworkImage(
|
||||
customerProfile != null
|
||||
? customerProfile!.profilePhotoUrl.contains('https')
|
||||
? customerProfile!.profilePhotoUrl
|
||||
: sl<AppConfig>().baseUrl + customerProfile!.profilePhotoUrl
|
||||
: author.authorProfileIcon.contains('https')
|
||||
? author.authorProfileIcon
|
||||
: sl<AppConfig>().baseUrl + author.authorProfileIcon,
|
||||
joinBaseUrl(
|
||||
sl<AppConfig>().baseUrl,
|
||||
customerProfile != null
|
||||
? customerProfile!.profilePhotoUrl
|
||||
: author.authorProfileIcon,
|
||||
),
|
||||
),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
customerProfile != null ? customerProfile!.name : author.authorName,
|
||||
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
|
||||
customerProfile != null
|
||||
? customerProfile!.name
|
||||
: author.authorName,
|
||||
style: TextStyles.textLSemibold.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
title,
|
||||
style:
|
||||
titleStyle ??
|
||||
TextStyles.textSSemibold.copyWith(
|
||||
color: appTheme.gray,
|
||||
),
|
||||
),
|
||||
title: Text(title, style: titleStyle ?? TextStyles.textSSemibold.copyWith(color: appTheme.gray)),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
if (customerProfile == null) ...[
|
||||
Text('Консультант', style: TextStyles.textMMedium.copyWith(color: appTheme.black)),
|
||||
Text(
|
||||
'Консультант',
|
||||
style: TextStyles.textMMedium.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
|
||||
if (author.authorPlaceInRating != null)
|
||||
@@ -70,20 +106,35 @@ class AuthorCard extends StatelessWidget {
|
||||
radius: 12,
|
||||
backgroundColor: appTheme.cardBackground,
|
||||
children: [
|
||||
Text('${author.authorPlaceInRating}-й в рейтинге', style: TextStyles.textMMediumAuto.copyWith(color: appTheme.blueA400)),
|
||||
Text(
|
||||
'${author.authorPlaceInRating}-й в рейтинге',
|
||||
style: TextStyles.textMMediumAuto.copyWith(
|
||||
color: appTheme.blueA400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
] else
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed(UserDetailsScreen.name, extra: customerProfile!.slug);
|
||||
GoRouter.of(context).pushNamed(
|
||||
UserDetailsScreen.name,
|
||||
extra: customerProfile!.slug,
|
||||
);
|
||||
},
|
||||
child: InfoBouble(
|
||||
radius: 12,
|
||||
backgroundColor: appTheme.cardBackground,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [Text('Анкета пользователя', style: TextStyles.textMMediumAuto.copyWith(color: appTheme.blueA400))],
|
||||
children: [
|
||||
Text(
|
||||
'Анкета пользователя',
|
||||
style: TextStyles.textMMediumAuto.copyWith(
|
||||
color: appTheme.blueA400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -98,8 +149,18 @@ class AuthorCard extends StatelessWidget {
|
||||
child: Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(color: appTheme.whiteA700, borderRadius: BorderRadius.circular(8)),
|
||||
child: Center(child: SvgPicture.asset('assets/icons/arrow.svg', width: 12, height: 12, fit: BoxFit.none)),
|
||||
decoration: BoxDecoration(
|
||||
color: appTheme.whiteA700,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Center(
|
||||
child: SvgPicture.asset(
|
||||
'assets/icons/arrow.svg',
|
||||
width: 12,
|
||||
height: 12,
|
||||
fit: BoxFit.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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/locator_service.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/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';
|
||||
@@ -54,13 +55,15 @@ class _CommentsWidgetState extends State<CommentsWidget> {
|
||||
@override
|
||||
void didUpdateWidget(CommentsWidget oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.highlightedCommentId != null && widget.highlightedCommentId != oldWidget.highlightedCommentId) {
|
||||
if (widget.highlightedCommentId != null &&
|
||||
widget.highlightedCommentId != oldWidget.highlightedCommentId) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_scrollToHighlightedComment();
|
||||
});
|
||||
} else if (widget.comment != oldWidget.comment && widget.highlightedCommentId != null) {
|
||||
// Comments might have just loaded
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
} else if (widget.comment != oldWidget.comment &&
|
||||
widget.highlightedCommentId != null) {
|
||||
// Comments might have just loaded
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_scrollToHighlightedComment();
|
||||
});
|
||||
}
|
||||
@@ -84,9 +87,22 @@ class _CommentsWidgetState extends State<CommentsWidget> {
|
||||
children: [
|
||||
for (int i = 0; i < widget.comment.data.length; i++) ...[
|
||||
_comment(s, widget.comment.data[i]),
|
||||
if (widget.comment.data[i].childrenComments.isNotEmpty) const SizedBox(height: 8),
|
||||
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)),
|
||||
if (widget.comment.data[i].childrenComments.isNotEmpty)
|
||||
const SizedBox(height: 8),
|
||||
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: 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 isMe = (sl<ProfileAvatarCubit>().state as ProfileAvatarStateLoaded).profile.slug == comment.authorSlug;
|
||||
final isMe =
|
||||
(sl<ProfileAvatarCubit>().state as ProfileAvatarStateLoaded)
|
||||
.profile
|
||||
.slug ==
|
||||
comment.authorSlug;
|
||||
return GestureDetector(
|
||||
key: key,
|
||||
onTap: () {
|
||||
@@ -113,7 +138,10 @@ class _CommentsWidgetState extends State<CommentsWidget> {
|
||||
showInfoToast(s.text_was_copied);
|
||||
},
|
||||
edit: (String text) {
|
||||
context.read<CommentsCubit>().updateComment(id: comment.id, body: text);
|
||||
context.read<CommentsCubit>().updateComment(
|
||||
id: comment.id,
|
||||
body: text,
|
||||
);
|
||||
},
|
||||
delete: () {
|
||||
context.read<CommentsCubit>().deleteComment(id: comment.id);
|
||||
@@ -122,7 +150,10 @@ class _CommentsWidgetState extends State<CommentsWidget> {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => ComplainingBottomSheet(complainedContentType: ComplainedContent.comment, id: comment.id),
|
||||
builder: (context) => ComplainingBottomSheet(
|
||||
complainedContentType: ComplainedContent.comment,
|
||||
id: comment.id,
|
||||
),
|
||||
);
|
||||
},
|
||||
isMyComment: isMe,
|
||||
@@ -131,7 +162,11 @@ class _CommentsWidgetState extends State<CommentsWidget> {
|
||||
child: Container(
|
||||
width: MediaQuery.of(context).size.width,
|
||||
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),
|
||||
),
|
||||
child: Padding(
|
||||
@@ -141,14 +176,20 @@ class _CommentsWidgetState extends State<CommentsWidget> {
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed(
|
||||
'profileScreen',
|
||||
extra: comment.authorSlug,
|
||||
);
|
||||
GoRouter.of(
|
||||
context,
|
||||
).pushNamed('profileScreen', extra: comment.authorSlug);
|
||||
},
|
||||
child: CircleAvatar(
|
||||
foregroundImage: NetworkImage(comment.authorProfileIcon.contains('https') ? comment.authorProfileIcon : sl<AppConfig>().baseUrl + comment.authorProfileIcon),
|
||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
||||
foregroundImage: NetworkImage(
|
||||
joinBaseUrl(
|
||||
sl<AppConfig>().baseUrl,
|
||||
comment.authorProfileIcon,
|
||||
),
|
||||
),
|
||||
backgroundImage: const AssetImage(
|
||||
'assets/images/no_avatar.png',
|
||||
),
|
||||
radius: radius,
|
||||
),
|
||||
),
|
||||
@@ -159,14 +200,26 @@ class _CommentsWidgetState extends State<CommentsWidget> {
|
||||
children: [
|
||||
Text(
|
||||
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),
|
||||
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(
|
||||
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),
|
||||
@@ -174,17 +227,29 @@ class _CommentsWidgetState extends State<CommentsWidget> {
|
||||
children: [
|
||||
Text(
|
||||
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),
|
||||
if (!isChild)
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
context.read<CommentsCubit>().setReplyId(comment.id);
|
||||
context.read<CommentsCubit>().setReplyId(
|
||||
comment.id,
|
||||
);
|
||||
},
|
||||
child: Text(
|
||||
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,7 @@ import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/locator_service.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/profile/models/badge_model.dart';
|
||||
@@ -18,15 +19,15 @@ class ConsultantWidget extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed(
|
||||
'profileScreen',
|
||||
extra: consultant.slug,
|
||||
);
|
||||
GoRouter.of(context).pushNamed('profileScreen', extra: consultant.slug);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Ink(
|
||||
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(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -37,33 +38,54 @@ class ConsultantWidget extends StatelessWidget {
|
||||
Row(
|
||||
children: [
|
||||
Ink(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(8)),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 4,
|
||||
horizontal: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'${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),
|
||||
if (consultant.photo.contains('https'))
|
||||
CircleAvatar(foregroundImage: NetworkImage(consultant.photo), backgroundImage: const AssetImage('assets/images/no_avatar.png'))
|
||||
else
|
||||
CircleAvatar(
|
||||
foregroundImage: NetworkImage(sl<AppConfig>().baseUrl + consultant.photo),
|
||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
||||
CircleAvatar(
|
||||
foregroundImage: NetworkImage(
|
||||
joinBaseUrl(sl<AppConfig>().baseUrl, consultant.photo),
|
||||
),
|
||||
backgroundImage: const AssetImage(
|
||||
'assets/images/no_avatar.png',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
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),
|
||||
Text(
|
||||
'Рейтинг: ${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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -71,7 +93,10 @@ class ConsultantWidget extends StatelessWidget {
|
||||
),
|
||||
Ink(
|
||||
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'),
|
||||
),
|
||||
],
|
||||
@@ -93,9 +118,21 @@ class ConsultantWidget extends StatelessWidget {
|
||||
children: [
|
||||
for (int i = 0; i < badges.length; i++) ...[
|
||||
badges[i].image.contains('svg')
|
||||
? NetworkSvgPicture(url: '${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(),
|
||||
? NetworkSvgPicture(
|
||||
url:
|
||||
'${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(),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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/feed/models/plan_pagination_model.dart';
|
||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||
import 'package:eatvio_app/core/utils/url_utils.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/plans_cubit/plans_cubit.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
@@ -37,7 +38,14 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
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(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: Colors.white,
|
||||
@@ -48,9 +56,12 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(12),
|
||||
),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: '${sl<AppConfig>().baseUrl}/storage/${widget.plan.image}',
|
||||
imageUrl:
|
||||
'${sl<AppConfig>().baseUrl}/storage/${widget.plan.image}',
|
||||
fit: BoxFit.cover,
|
||||
width: MediaQuery.of(context).size.width,
|
||||
height: 426,
|
||||
@@ -61,14 +72,22 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
child: BookmarkButton(
|
||||
onTap: () async {
|
||||
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 {
|
||||
await context.read<PlansCubit>().addPlanToFavorite(widget.plan.slug, widget.index);
|
||||
await context.read<PlansCubit>().addPlanToFavorite(
|
||||
widget.plan.slug,
|
||||
widget.index,
|
||||
);
|
||||
}
|
||||
},
|
||||
color: appTheme.whiteA700,
|
||||
isFavorite: widget.plan.isFavorite,
|
||||
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(12)),
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
bottom: Radius.circular(12),
|
||||
),
|
||||
width: 40,
|
||||
height: 44,
|
||||
size: 19,
|
||||
@@ -89,6 +108,7 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
path: 'plan/${widget.plan.slug}',
|
||||
id: widget.plan.id,
|
||||
slug: widget.plan.slug,
|
||||
plan: widget.plan,
|
||||
complainedContentType: ComplainedContent.plan,
|
||||
userSlug: widget.plan.author.authorSlug,
|
||||
),
|
||||
@@ -99,7 +119,12 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
bottom: 16,
|
||||
child: SizedBox(
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -110,14 +135,22 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
children: [
|
||||
Padding(
|
||||
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) ...[
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
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,
|
||||
),
|
||||
),
|
||||
@@ -126,7 +159,12 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
padding: EdgeInsets.only(left: 16, top: 8),
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -135,7 +173,10 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
const SizedBox(height: 16),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed('profileScreen', extra: widget.plan.author.authorSlug);
|
||||
GoRouter.of(context).pushNamed(
|
||||
'profileScreen',
|
||||
extra: widget.plan.author.authorSlug,
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0),
|
||||
@@ -146,7 +187,15 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
height: 46,
|
||||
decoration: BoxDecoration(
|
||||
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(
|
||||
@@ -154,22 +203,37 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
InfoBouble(
|
||||
radius: 12,
|
||||
backgroundColor: appTheme.gray50,
|
||||
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 10),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 4,
|
||||
horizontal: 10,
|
||||
),
|
||||
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(),
|
||||
Text(
|
||||
'${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),
|
||||
],
|
||||
),
|
||||
title: Padding(
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -180,12 +244,21 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
child: Row(
|
||||
children: [
|
||||
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(),
|
||||
iconColor: widget.plan.wasLikedByMe ? appTheme.blueA400 : null,
|
||||
backgroundColor: widget.plan.wasLikedByMe ? appTheme.transparentBlue : null,
|
||||
iconColor: widget.plan.wasLikedByMe
|
||||
? appTheme.blueA400
|
||||
: null,
|
||||
backgroundColor: widget.plan.wasLikedByMe
|
||||
? appTheme.transparentBlue
|
||||
: null,
|
||||
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),
|
||||
@@ -193,9 +266,14 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
icon: Icons.chat_bubble_outline,
|
||||
text: widget.plan.totalComments.toString(),
|
||||
onTap: () {
|
||||
return GoRouter.of(
|
||||
context,
|
||||
).pushNamed('aboutPlanScreen', extra: {'slug': widget.plan.slug, 'index': widget.index, 'navigateToComments': true});
|
||||
return GoRouter.of(context).pushNamed(
|
||||
'aboutPlanScreen',
|
||||
extra: {
|
||||
'slug': widget.plan.slug,
|
||||
'index': widget.index,
|
||||
'navigateToComments': true,
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
@@ -205,8 +283,11 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
final box = context.findRenderObject() as RenderBox?;
|
||||
final result = await SharePlus.instance.share(
|
||||
ShareParams(
|
||||
uri: Uri.parse('${sl<AppConfig>().baseUrl}/ru/diet/${widget.plan.id}'),
|
||||
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
|
||||
uri: Uri.parse(
|
||||
'${sl<AppConfig>().baseUrl}/ru/diet/${widget.plan.id}',
|
||||
),
|
||||
sharePositionOrigin:
|
||||
box!.localToGlobal(Offset.zero) & box.size,
|
||||
),
|
||||
);
|
||||
switch (result.status) {
|
||||
|
||||
@@ -2,10 +2,12 @@ import 'dart:developer';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||
import 'package:eatvio_app/features/menu/presentation/bloc/notification_settings_cubit/notification_settings_state.dart';
|
||||
import 'package:eatvio_app/features/menu/presentation/notification_settings_user_message.dart';
|
||||
import 'package:eatvio_app/features/menu/repositories/notification_settings_repository.dart';
|
||||
|
||||
class NotificationSettingsCubit extends Cubit<NotificationSettingsState> {
|
||||
final NotificationSettingsRepository _notificationSettingsRepository;
|
||||
int _alertSeq = 0;
|
||||
|
||||
NotificationSettingsCubit({required NotificationSettingsRepository notificationSettingsRepository})
|
||||
: _notificationSettingsRepository = notificationSettingsRepository,
|
||||
@@ -30,6 +32,17 @@ class NotificationSettingsCubit extends Cubit<NotificationSettingsState> {
|
||||
} catch (e, stackTrace) {
|
||||
await reportFrom(e, stackTrace, this);
|
||||
log('Error updating notification settings: $e', stackTrace: stackTrace);
|
||||
final current = state;
|
||||
if (current is NotificationSettingsStateLoaded) {
|
||||
emit(
|
||||
NotificationSettingsStateLoaded(
|
||||
notificationSettings: current.notificationSettings,
|
||||
alertMessage: notificationSettingsUpdateAlertMessage(e),
|
||||
alertId: ++_alertSeq,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
emit(NotificationSettingsStateError(errorMessage: e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,11 +14,17 @@ class NotificationSettingsStateLoading extends NotificationSettingsState {
|
||||
|
||||
class NotificationSettingsStateLoaded extends NotificationSettingsState {
|
||||
final NotificationSettingsModel notificationSettings;
|
||||
final String? alertMessage;
|
||||
final int? alertId;
|
||||
|
||||
const NotificationSettingsStateLoaded({required this.notificationSettings});
|
||||
const NotificationSettingsStateLoaded({
|
||||
required this.notificationSettings,
|
||||
this.alertMessage,
|
||||
this.alertId,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [notificationSettings];
|
||||
List<Object?> get props => [notificationSettings, alertMessage, alertId];
|
||||
}
|
||||
|
||||
class NotificationSettingsStateError extends NotificationSettingsState {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
const notificationSettingsSaveFailedMessage =
|
||||
'Не удалось сохранить настройки уведомлений';
|
||||
|
||||
|
||||
String notificationSettingsUpdateAlertMessage(Object error) {
|
||||
final validationMessage = _validationMessage(error);
|
||||
if (validationMessage != null) return validationMessage;
|
||||
return notificationSettingsSaveFailedMessage;
|
||||
}
|
||||
|
||||
String? _validationMessage(Object error) {
|
||||
if (error is! DioException || error.response?.statusCode != 422) return null;
|
||||
|
||||
final data = error.response?.data;
|
||||
if (data is! Map) return null;
|
||||
|
||||
final errors = data['errors'];
|
||||
if (errors is Map) {
|
||||
for (final value in errors.values) {
|
||||
final text = _firstErrorText(value);
|
||||
if (text != null) return text;
|
||||
}
|
||||
}
|
||||
|
||||
final message = data['message'];
|
||||
if (message is String && message.trim().isNotEmpty) return message.trim();
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _firstErrorText(Object? value) {
|
||||
if (value is List) {
|
||||
for (final item in value) {
|
||||
final text = item.toString().trim();
|
||||
if (text.isNotEmpty) return text;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (value is String && value.trim().isNotEmpty) return value.trim();
|
||||
return null;
|
||||
}
|
||||
@@ -27,7 +27,37 @@ class NotificationsSettingsContent extends StatelessWidget {
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(s.notifications)),
|
||||
body: BlocBuilder<NotificationSettingsCubit, NotificationSettingsState>(
|
||||
body: BlocConsumer<NotificationSettingsCubit, NotificationSettingsState>(
|
||||
listenWhen: (previous, current) {
|
||||
if (current is! NotificationSettingsStateLoaded || current.alertId == null) {
|
||||
return false;
|
||||
}
|
||||
final previousId = previous is NotificationSettingsStateLoaded ? previous.alertId : null;
|
||||
return current.alertId != previousId;
|
||||
},
|
||||
listener: (context, state) {
|
||||
if (state is! NotificationSettingsStateLoaded) {
|
||||
return;
|
||||
}
|
||||
final message = state.alertMessage;
|
||||
if (message == null || message.isEmpty) {
|
||||
return;
|
||||
}
|
||||
showCupertinoDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return CupertinoAlertDialog(
|
||||
content: Text(message),
|
||||
actions: [
|
||||
CupertinoDialogAction(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
builder: (context, state) {
|
||||
if (state is NotificationSettingsStateLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/utils/url_utils.dart';
|
||||
import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_cubit.dart';
|
||||
import 'package:eatvio_app/features/profile/models/profile_model.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -18,16 +19,20 @@ class _BlockedUserWidgetState extends State<BlockedUserWidget> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: () => GoRouter.of(context).pushNamed(
|
||||
'profileScreen',
|
||||
extra: widget.profile.slug,
|
||||
),
|
||||
onTap: () => GoRouter.of(
|
||||
context,
|
||||
).pushNamed('profileScreen', extra: widget.profile.slug),
|
||||
child: Ink(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
foregroundImage: NetworkImage(sl<AppConfig>().baseUrl + widget.profile.profilePhotoUrl),
|
||||
foregroundImage: NetworkImage(
|
||||
joinBaseUrl(
|
||||
sl<AppConfig>().baseUrl,
|
||||
widget.profile.profilePhotoUrl,
|
||||
),
|
||||
),
|
||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
||||
radius: 28,
|
||||
),
|
||||
@@ -50,11 +55,16 @@ class _BlockedUserWidgetState extends State<BlockedUserWidget> {
|
||||
const Spacer(),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
context.read<BlockedUsersCubit>().unblockUser(widget.profile.slug);
|
||||
context.read<BlockedUsersCubit>().unblockUser(
|
||||
widget.profile.slug,
|
||||
);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Ink(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF1F2F6),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/utils/url_utils.dart';
|
||||
import 'package:eatvio_app/core/app_router.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||
@@ -14,7 +15,11 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class ConsultationWidget extends StatelessWidget {
|
||||
const ConsultationWidget({super.key, required this.consultationModel, required this.dietName});
|
||||
const ConsultationWidget({
|
||||
super.key,
|
||||
required this.consultationModel,
|
||||
required this.dietName,
|
||||
});
|
||||
final ConsultationModel consultationModel;
|
||||
final String dietName;
|
||||
@override
|
||||
@@ -33,7 +38,12 @@ class ConsultationWidget extends StatelessWidget {
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 30,
|
||||
backgroundImage: NetworkImage(sl<AppConfig>().baseUrl + consultationModel.userProfilePhotoUrl),
|
||||
backgroundImage: NetworkImage(
|
||||
joinBaseUrl(
|
||||
sl<AppConfig>().baseUrl,
|
||||
consultationModel.userProfilePhotoUrl,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
@@ -43,18 +53,24 @@ class ConsultationWidget extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
consultationModel.userName,
|
||||
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
|
||||
style: TextStyles.textLSemibold.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
consultationModel.planTitle,
|
||||
style: TextStyles.textMMedium.copyWith(color: const Color(0xFF88949F)),
|
||||
style: TextStyles.textMMedium.copyWith(
|
||||
color: const Color(0xFF88949F),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Старт: ${consultationModel.userName}',
|
||||
style: TextStyles.textMMedium.copyWith(color: const Color(0xFF88949F)),
|
||||
style: TextStyles.textMMedium.copyWith(
|
||||
color: const Color(0xFF88949F),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
@@ -62,7 +78,12 @@ class ConsultationWidget extends StatelessWidget {
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 30,
|
||||
backgroundImage: NetworkImage(sl<AppConfig>().baseUrl + consultationModel.userProfilePhotoUrl),
|
||||
backgroundImage: NetworkImage(
|
||||
joinBaseUrl(
|
||||
sl<AppConfig>().baseUrl,
|
||||
consultationModel.userProfilePhotoUrl,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
@@ -72,25 +93,32 @@ class ConsultationWidget extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
consultationModel.userName,
|
||||
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
|
||||
style: TextStyles.textLSemibold.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
consultationModel.planTitle,
|
||||
style: TextStyles.textMMedium.copyWith(color: const Color(0xFF88949F)),
|
||||
style: TextStyles.textMMedium.copyWith(
|
||||
color: const Color(0xFF88949F),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(
|
||||
height: 12,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (consultationModel.personalDietId != null)
|
||||
CustomOutlinedButton(
|
||||
text: s.changeDiet,
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.gray),
|
||||
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.dividerColor)),
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(
|
||||
color: appTheme.gray,
|
||||
),
|
||||
buttonStyle: ButtonStyle(
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
backgroundColor: WidgetStatePropertyAll(appTheme.dividerColor),
|
||||
),
|
||||
onPressed: () async {
|
||||
showBottomSheetWithBlur(
|
||||
userRootNavigator: true,
|
||||
@@ -100,7 +128,11 @@ class ConsultationWidget extends StatelessWidget {
|
||||
userName: consultationModel.userName,
|
||||
dietName: dietName,
|
||||
onTap: () async {
|
||||
final result = await context.read<PersonalDietSuggestionCubit>().attachDietToConsultation(consultationId: consultationModel.id);
|
||||
final result = await context
|
||||
.read<PersonalDietSuggestionCubit>()
|
||||
.attachDietToConsultation(
|
||||
consultationId: consultationModel.id,
|
||||
);
|
||||
AppRouter.navigatorKey.currentContext?.pop();
|
||||
if (result) {
|
||||
if (!context.mounted) return;
|
||||
@@ -134,8 +166,13 @@ class ConsultationWidget extends StatelessWidget {
|
||||
CustomOutlinedButton(
|
||||
text: s.suggestDiet,
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(
|
||||
color: appTheme.whiteA700,
|
||||
),
|
||||
buttonStyle: ButtonStyle(
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
||||
),
|
||||
onPressed: () async {
|
||||
showBottomSheetWithBlur(
|
||||
userRootNavigator: true,
|
||||
@@ -146,7 +183,11 @@ class ConsultationWidget extends StatelessWidget {
|
||||
dietName: dietName,
|
||||
onTap: () async {
|
||||
AppRouter.navigatorKey.currentContext?.pop();
|
||||
final result = await context.read<PersonalDietSuggestionCubit>().attachDietToConsultation(consultationId: consultationModel.id);
|
||||
final result = await context
|
||||
.read<PersonalDietSuggestionCubit>()
|
||||
.attachDietToConsultation(
|
||||
consultationId: consultationModel.id,
|
||||
);
|
||||
if (result) {
|
||||
if (!context.mounted) return;
|
||||
showBottomSheetWithBlur(
|
||||
@@ -174,7 +215,7 @@ class ConsultationWidget extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -204,35 +245,33 @@ class _SuggestDietBottomSheet extends State<SuggestDietBottomSheet> {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const BottomSheetHandlerBar(),
|
||||
const SizedBox(
|
||||
height: 20,
|
||||
),
|
||||
Text('Предложение диеты', style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'Предложение диеты',
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Предложить пользователю ${widget.userName} «${widget.dietName}»',
|
||||
style: TextStyles.textSMedium.copyWith(color: appTheme.black),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
CustomOutlinedButton(
|
||||
text: s.toOffer,
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(
|
||||
color: appTheme.whiteA700,
|
||||
),
|
||||
buttonStyle: ButtonStyle(
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
||||
),
|
||||
onPressed: widget.onTap,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 40,
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -249,10 +288,12 @@ class SuggestionSuccessBottomSheet extends StatefulWidget {
|
||||
final String dietName;
|
||||
|
||||
@override
|
||||
State<SuggestionSuccessBottomSheet> createState() => _SuggestionSuccessBottomSheet();
|
||||
State<SuggestionSuccessBottomSheet> createState() =>
|
||||
_SuggestionSuccessBottomSheet();
|
||||
}
|
||||
|
||||
class _SuggestionSuccessBottomSheet extends State<SuggestionSuccessBottomSheet> {
|
||||
class _SuggestionSuccessBottomSheet
|
||||
extends State<SuggestionSuccessBottomSheet> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
@@ -261,37 +302,35 @@ class _SuggestionSuccessBottomSheet extends State<SuggestionSuccessBottomSheet>
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const BottomSheetHandlerBar(),
|
||||
const SizedBox(
|
||||
height: 20,
|
||||
),
|
||||
Text('Предложение диеты', style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'Предложение диеты',
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'«${widget.dietName}» предложена пользователю ${widget.userName}',
|
||||
style: TextStyles.textSMedium.copyWith(color: appTheme.black),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
CustomOutlinedButton(
|
||||
text: s.ok,
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(
|
||||
color: appTheme.whiteA700,
|
||||
),
|
||||
buttonStyle: ButtonStyle(
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
||||
),
|
||||
onPressed: () async {
|
||||
AppRouter.navigatorKey.currentContext?.pop();
|
||||
},
|
||||
),
|
||||
const SizedBox(
|
||||
height: 40,
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -308,10 +347,12 @@ class SuggestionFailureBottomSheet extends StatefulWidget {
|
||||
final String dietName;
|
||||
|
||||
@override
|
||||
State<SuggestionFailureBottomSheet> createState() => _SuggestionFailureBottomSheet();
|
||||
State<SuggestionFailureBottomSheet> createState() =>
|
||||
_SuggestionFailureBottomSheet();
|
||||
}
|
||||
|
||||
class _SuggestionFailureBottomSheet extends State<SuggestionFailureBottomSheet> {
|
||||
class _SuggestionFailureBottomSheet
|
||||
extends State<SuggestionFailureBottomSheet> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
@@ -320,37 +361,35 @@ class _SuggestionFailureBottomSheet extends State<SuggestionFailureBottomSheet>
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const BottomSheetHandlerBar(),
|
||||
const SizedBox(
|
||||
height: 20,
|
||||
),
|
||||
Text('Предложение диеты', style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'Предложение диеты',
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Не удалось предложить диету ${widget.dietName} пользователю ${widget.userName}',
|
||||
style: TextStyles.textSMedium.copyWith(color: appTheme.black),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
CustomOutlinedButton(
|
||||
text: s.ok,
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(
|
||||
color: appTheme.whiteA700,
|
||||
),
|
||||
buttonStyle: ButtonStyle(
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
||||
),
|
||||
onPressed: () async {
|
||||
AppRouter.navigatorKey.currentContext?.pop();
|
||||
},
|
||||
),
|
||||
const SizedBox(
|
||||
height: 40,
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -196,6 +196,9 @@ class _AboutPlanScreenState extends State<AboutPlanScreen> {
|
||||
isAuthor: state.planModel?.author.authorIsMe ?? false,
|
||||
isAuthorAdmin: state.planModel?.author.isAdmin ?? false,
|
||||
path: 'plan/${widget.slug}',
|
||||
slug: widget.slug,
|
||||
id: state.planModel?.id,
|
||||
plan: state.planModel,
|
||||
complainedContentType: ComplainedContent.plan,
|
||||
userSlug: state.planModel?.author.authorSlug ?? '',
|
||||
),
|
||||
@@ -376,6 +379,9 @@ class _AboutPlanScreenState extends State<AboutPlanScreen> {
|
||||
isAuthor: state.planModel?.author.authorIsMe ?? false,
|
||||
isAuthorAdmin: state.planModel?.author.isAdmin ?? false,
|
||||
path: 'plan/${widget.slug}',
|
||||
slug: widget.slug,
|
||||
id: state.planModel?.id,
|
||||
plan: state.planModel,
|
||||
complainedContentType: ComplainedContent.plan,
|
||||
userSlug: state.planModel?.author.authorSlug ?? '',
|
||||
),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/utils/url_utils.dart';
|
||||
import 'package:eatvio_app/features/profile/models/sub_model.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/widgets/sub_action_button_widget.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -8,10 +9,7 @@ import 'package:go_router/go_router.dart';
|
||||
class SubWidget extends StatefulWidget {
|
||||
final SubModel subModel;
|
||||
|
||||
const SubWidget({
|
||||
super.key,
|
||||
required this.subModel,
|
||||
});
|
||||
const SubWidget({super.key, required this.subModel});
|
||||
|
||||
@override
|
||||
State<SubWidget> createState() => _SubWidgetState();
|
||||
@@ -21,16 +19,20 @@ class _SubWidgetState extends State<SubWidget> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: () => GoRouter.of(context).pushNamed(
|
||||
'profileScreen',
|
||||
extra: widget.subModel.slug,
|
||||
),
|
||||
onTap: () => GoRouter.of(
|
||||
context,
|
||||
).pushNamed('profileScreen', extra: widget.subModel.slug),
|
||||
child: Ink(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
foregroundImage: NetworkImage(sl<AppConfig>().baseUrl + widget.subModel.profilePhotoUrl),
|
||||
foregroundImage: NetworkImage(
|
||||
joinBaseUrl(
|
||||
sl<AppConfig>().baseUrl,
|
||||
widget.subModel.profilePhotoUrl,
|
||||
),
|
||||
),
|
||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
||||
radius: 28,
|
||||
),
|
||||
@@ -62,7 +64,9 @@ class _SubWidgetState extends State<SubWidget> {
|
||||
),
|
||||
const Spacer(),
|
||||
SubActionButtonWidget(
|
||||
subActionType: widget.subModel.isEntitySubscribedByMe ? SubActionType.sendMessage : SubActionType.subscribe,
|
||||
subActionType: widget.subModel.isEntitySubscribedByMe
|
||||
? SubActionType.sendMessage
|
||||
: SubActionType.subscribe,
|
||||
slug: widget.subModel.slug,
|
||||
name: widget.subModel.name,
|
||||
porfileIcon: widget.subModel.profilePhotoUrl,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:eatvio_app/core/app_router.dart';
|
||||
import 'package:eatvio_app/core/device/device_hw_id_provider.dart';
|
||||
@@ -8,6 +9,7 @@ import 'package:eatvio_app/features/video_calls/managers/video_call_manager_snap
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_audio_session_coordinator.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_foreground_coordinator.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_lifecycle_binding.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_peer_departure.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_notification_navigator.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_task_handler.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
|
||||
@@ -37,6 +39,12 @@ class VideoCallManager {
|
||||
_audioSession =
|
||||
audioSessionCoordinator ?? VideoCallAudioSessionCoordinator() {
|
||||
FlutterForegroundTask.addTaskDataCallback(_onForegroundTaskData);
|
||||
if (Platform.isIOS) {
|
||||
// LiveKit rewrites the audio session whenever a remote audio track
|
||||
// appears. That second configuration stops the microphone and does
|
||||
// not start it again.
|
||||
lk.Hardware.instance.setAutomaticConfigurationEnabled(enable: false);
|
||||
}
|
||||
}
|
||||
|
||||
final VideoCallRepository _videoCallRepository;
|
||||
@@ -46,7 +54,10 @@ class VideoCallManager {
|
||||
final VideoCallAudioSessionCoordinator _audioSession;
|
||||
|
||||
static const lk.AudioCaptureOptions _micCaptureOptions =
|
||||
lk.AudioCaptureOptions(stopAudioCaptureOnMute: false);
|
||||
lk.AudioCaptureOptions(
|
||||
stopAudioCaptureOnMute: false,
|
||||
voiceIsolation: false,
|
||||
);
|
||||
static const Duration _mutedMicPollInterval = Duration(milliseconds: 320);
|
||||
static const Duration _mutedMicOverlayMinInterval = Duration(
|
||||
milliseconds: 2600,
|
||||
@@ -65,6 +76,9 @@ class VideoCallManager {
|
||||
num? _lastMutedMicTotalEnergy;
|
||||
bool _liveKitDisposed = true;
|
||||
bool _callEndedNotified = true;
|
||||
bool _forceEndRequested = false;
|
||||
bool _sessionMayRequestToken = false;
|
||||
final Map<String, DateTime> _leftConversationAt = {};
|
||||
final Set<String> _callAcceptNotifiedConversationUuids = {};
|
||||
bool _callStageEntered = false;
|
||||
bool _joinInProgress = false;
|
||||
@@ -86,6 +100,8 @@ class VideoCallManager {
|
||||
String? get counterpartName => _snapshot.counterpartName;
|
||||
String? get localParticipantName => _snapshot.localParticipantName;
|
||||
|
||||
bool get callEndedNotified => _callEndedNotified;
|
||||
|
||||
bool get isActiveCall =>
|
||||
_snapshot.connectPhase == VideoCallConnectPhase.connected &&
|
||||
_callStageEntered &&
|
||||
@@ -164,13 +180,29 @@ class VideoCallManager {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> startCall({
|
||||
/// Lets the next [startCall] ask the server for a token.
|
||||
///
|
||||
/// Only the call button and an accepted incoming call set this. A screen
|
||||
/// that reappears after hang-up must not request a token: that request is
|
||||
/// what rings the other person again.
|
||||
void permitParticipantTokenRequest() {
|
||||
_sessionMayRequestToken = true;
|
||||
}
|
||||
|
||||
Future<bool> startCall({
|
||||
required String conversationUuid,
|
||||
String? counterpartName,
|
||||
String? localParticipantName,
|
||||
bool micEnabled = false,
|
||||
bool videoEnabled = false,
|
||||
}) async {
|
||||
final allowed = mayRequestParticipantToken(
|
||||
sessionPermitted: _sessionMayRequestToken,
|
||||
leftAt: _leftConversationAt[conversationUuid],
|
||||
now: DateTime.now(),
|
||||
);
|
||||
if (!allowed) return false;
|
||||
|
||||
if (_room != null &&
|
||||
!_liveKitDisposed &&
|
||||
_snapshot.conversationUuid == conversationUuid) {
|
||||
@@ -185,7 +217,7 @@ class VideoCallManager {
|
||||
);
|
||||
_ensureLifecycleAttached();
|
||||
unawaited(_startCallAudioSession());
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (!_callStageEntered &&
|
||||
_snapshot.connectPhase == VideoCallConnectPhase.connected &&
|
||||
@@ -199,14 +231,16 @@ class VideoCallManager {
|
||||
),
|
||||
);
|
||||
_ensureLifecycleAttached();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
await endCall();
|
||||
_sessionMayRequestToken = true;
|
||||
|
||||
_resetSessionFlagsForNewRoom();
|
||||
_callEndedNotified = false;
|
||||
_forceEndRequested = false;
|
||||
_liveKitDisposed = false;
|
||||
_callStageEntered = false;
|
||||
_room = lk.Room(
|
||||
@@ -240,9 +274,10 @@ class VideoCallManager {
|
||||
);
|
||||
|
||||
await _prepareLiveKitConnection();
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> onCallScreenVisible({
|
||||
Future<bool> onCallScreenVisible({
|
||||
required String conversationUuid,
|
||||
String? counterpartName,
|
||||
String? localParticipantName,
|
||||
@@ -252,7 +287,7 @@ class VideoCallManager {
|
||||
if (_snapshot.conversationUuid != conversationUuid ||
|
||||
_room == null ||
|
||||
_liveKitDisposed) {
|
||||
await startCall(
|
||||
return startCall(
|
||||
conversationUuid: conversationUuid,
|
||||
counterpartName: counterpartName,
|
||||
localParticipantName: localParticipantName,
|
||||
@@ -271,6 +306,7 @@ class VideoCallManager {
|
||||
unawaited(_ensureLocalParticipantNamePublished());
|
||||
_syncRemoteParticipantNameFromRoom();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,10 +427,19 @@ class VideoCallManager {
|
||||
|
||||
Future<void> endCall() async {
|
||||
final uuid = _snapshot.conversationUuid;
|
||||
final shouldNotifyBackend = shouldNotifyCallEnded(
|
||||
alreadyNotified: _callEndedNotified,
|
||||
hasConversation: uuid.isNotEmpty,
|
||||
peerStillInRoom: _roomHasRemoteUser(),
|
||||
);
|
||||
if (uuid.isNotEmpty) {
|
||||
_callEndedNotified = true;
|
||||
_leftConversationAt[uuid] = DateTime.now();
|
||||
}
|
||||
_sessionMayRequestToken = false;
|
||||
_disposeRoomEphemerals();
|
||||
await _disconnectLiveKitOnly();
|
||||
if (!_callEndedNotified && uuid.isNotEmpty) {
|
||||
_callEndedNotified = true;
|
||||
if (shouldNotifyBackend) {
|
||||
try {
|
||||
await _videoCallRepository.notifyCallEnded(uuid);
|
||||
} catch (_) {}
|
||||
@@ -444,6 +489,12 @@ class VideoCallManager {
|
||||
}
|
||||
|
||||
await endCall();
|
||||
if (!incomingDeclineShouldNotifyBackend(
|
||||
leftAt: _leftConversationAt[uuid],
|
||||
now: DateTime.now(),
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await _videoCallRepository.notifyCallEnded(uuid);
|
||||
} catch (_) {}
|
||||
@@ -477,6 +528,9 @@ class VideoCallManager {
|
||||
next,
|
||||
audioCaptureOptions: _micCaptureOptions,
|
||||
);
|
||||
if (next) {
|
||||
await _audioSession.restartCapture();
|
||||
}
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
@@ -674,6 +728,7 @@ class VideoCallManager {
|
||||
true,
|
||||
audioCaptureOptions: _micCaptureOptions,
|
||||
);
|
||||
await _audioSession.restartCapture();
|
||||
} catch (_) {}
|
||||
}
|
||||
await _resumeVideoAfterForeground();
|
||||
@@ -814,6 +869,12 @@ class VideoCallManager {
|
||||
}
|
||||
}
|
||||
|
||||
void _requestForceEnd() {
|
||||
if (_forceEndRequested || _liveKitDisposed) return;
|
||||
_forceEndRequested = true;
|
||||
_emitSnapshot(_snapshot.copyWith(forceEndCall: true));
|
||||
}
|
||||
|
||||
void _attachRoomEventListeners() {
|
||||
final room = _room;
|
||||
if (room == null) return;
|
||||
@@ -825,11 +886,34 @@ class VideoCallManager {
|
||||
if (_snapshot.linkIssue.needsManualReconnect) return;
|
||||
_emitSnapshot(_snapshot.copyWith(linkIssue: VideoCallLinkIssue.none));
|
||||
})
|
||||
..on<lk.RoomDisconnectedEvent>((_) {
|
||||
..on<lk.RoomDisconnectedEvent>((event) {
|
||||
if (_liveKitDisposed) return;
|
||||
if (_snapshot.connectPhase != VideoCallConnectPhase.connected) return;
|
||||
if (shouldForceEndCallAfterRoomDisconnect(
|
||||
callStageEntered: _callStageEntered,
|
||||
sessionConnected: true,
|
||||
reason: event.reason,
|
||||
)) {
|
||||
_requestForceEnd();
|
||||
return;
|
||||
}
|
||||
_emitSnapshot(_snapshot.copyWith(linkIssue: VideoCallLinkIssue.lost));
|
||||
})
|
||||
..on<lk.ParticipantDisconnectedEvent>((_) {
|
||||
final room = _room;
|
||||
if (room == null) return;
|
||||
if (!shouldForceEndCallAfterPeerLeft(
|
||||
callStageEntered: _callStageEntered,
|
||||
sessionConnected:
|
||||
_snapshot.connectPhase == VideoCallConnectPhase.connected,
|
||||
remoteIdentities: room.remoteParticipants.values.map(
|
||||
(participant) => participant.identity,
|
||||
),
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
_requestForceEnd();
|
||||
})
|
||||
..on<lk.ParticipantConnectionQualityUpdatedEvent>((e) {
|
||||
if (e.participant != room.localParticipant) return;
|
||||
_syncPoorConnectionOverlay(e.connectionQuality);
|
||||
@@ -922,6 +1006,7 @@ class VideoCallManager {
|
||||
Future<void> _prepareLiveKitConnection() async {
|
||||
final room = _room;
|
||||
if (room == null) return;
|
||||
if (!_tokenRequestAllowed(_snapshot.conversationUuid)) return;
|
||||
_emitSnapshot(_snapshot.copyWith(connectError: null));
|
||||
try {
|
||||
final tokenResponse = await _videoCallRepository.getParticipantToken(
|
||||
@@ -970,6 +1055,7 @@ class VideoCallManager {
|
||||
var wsUrl = _preparedWsUrl;
|
||||
var token = _preparedToken;
|
||||
if (wsUrl == null || token == null) {
|
||||
if (!_tokenRequestAllowed(_snapshot.conversationUuid)) return;
|
||||
final tokenResponse = await _videoCallRepository.getParticipantToken(
|
||||
_snapshot.conversationUuid,
|
||||
);
|
||||
@@ -1048,6 +1134,22 @@ class VideoCallManager {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _tokenRequestAllowed(String conversationUuid) {
|
||||
return mayRequestParticipantToken(
|
||||
sessionPermitted: _sessionMayRequestToken,
|
||||
leftAt: _leftConversationAt[conversationUuid],
|
||||
now: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
bool _roomHasRemoteUser() {
|
||||
final room = _room;
|
||||
if (room == null) return false;
|
||||
return videoCallRoomStillHasRemoteUser(
|
||||
room.remoteParticipants.values.map((participant) => participant.identity),
|
||||
);
|
||||
}
|
||||
|
||||
bool get _canHandleCallAudio =>
|
||||
_callStageEntered &&
|
||||
!_liveKitDisposed &&
|
||||
@@ -1089,6 +1191,7 @@ class VideoCallManager {
|
||||
true,
|
||||
audioCaptureOptions: _micCaptureOptions,
|
||||
);
|
||||
await _audioSession.restartCapture();
|
||||
} catch (_) {
|
||||
effectiveMic = false;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ class VideoCallManagerSnapshot {
|
||||
required this.connectPhase,
|
||||
required this.linkIssue,
|
||||
this.counterpartPresentInLobby = false,
|
||||
this.forceEndCall = false,
|
||||
});
|
||||
|
||||
final String conversationUuid;
|
||||
@@ -22,6 +23,7 @@ class VideoCallManagerSnapshot {
|
||||
final VideoCallConnectPhase connectPhase;
|
||||
final VideoCallLinkIssue linkIssue;
|
||||
final bool counterpartPresentInLobby;
|
||||
final bool forceEndCall;
|
||||
|
||||
static const initial = VideoCallManagerSnapshot(
|
||||
conversationUuid: '',
|
||||
@@ -41,6 +43,7 @@ class VideoCallManagerSnapshot {
|
||||
VideoCallConnectPhase? connectPhase,
|
||||
VideoCallLinkIssue? linkIssue,
|
||||
bool? counterpartPresentInLobby,
|
||||
bool? forceEndCall,
|
||||
bool clearConnectError = false,
|
||||
}) {
|
||||
return VideoCallManagerSnapshot(
|
||||
@@ -54,6 +57,7 @@ class VideoCallManagerSnapshot {
|
||||
linkIssue: linkIssue ?? this.linkIssue,
|
||||
counterpartPresentInLobby:
|
||||
counterpartPresentInLobby ?? this.counterpartPresentInLobby,
|
||||
forceEndCall: forceEndCall ?? this.forceEndCall,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:eatvio_app/core/cubit/base_cubit.dart';
|
||||
import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart';
|
||||
import 'package:eatvio_app/features/video_calls/managers/video_call_manager_snapshot.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_notification_navigator.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_screen_wake_lock_coordinator.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_system_pip_coordinator.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -30,6 +31,8 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
||||
final VideoCallSystemPipCoordinator _systemPipCoordinator;
|
||||
final VideoCallScreenWakeLockCoordinator _screenWakeLock;
|
||||
bool _callScreenRouteVisible = false;
|
||||
bool _handlingRemoteCallEnd = false;
|
||||
bool _suppressDisposeSideEffects = false;
|
||||
bool _autoEnterInFlight = false;
|
||||
bool _counterpartPresentWhenLobbyReady = false;
|
||||
bool _sawEmptyLobbyWhenConnected = false;
|
||||
@@ -74,6 +77,10 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
||||
|
||||
void _onManagerSnapshot(VideoCallManagerSnapshot snapshot) {
|
||||
if (isClosed) return;
|
||||
if (snapshot.forceEndCall) {
|
||||
unawaited(handleRemoteCallEnded());
|
||||
return;
|
||||
}
|
||||
if (_isCallSessionInactive(snapshot)) {
|
||||
unawaited(_systemPipCoordinator.disarm());
|
||||
if (_callScreenRouteVisible) {
|
||||
@@ -164,7 +171,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> onCallScreenVisible({
|
||||
Future<bool> onCallScreenVisible({
|
||||
required String conversationUuid,
|
||||
String? counterpartName,
|
||||
String? localParticipantName,
|
||||
@@ -218,7 +225,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
unawaited(_applyVideoCallSystemUi());
|
||||
});
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isResumingActiveCall) {
|
||||
@@ -248,13 +255,17 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
||||
entryMode: effectiveEntryMode,
|
||||
),
|
||||
);
|
||||
await _videoCallManager.startCall(
|
||||
final started = await _videoCallManager.startCall(
|
||||
conversationUuid: conversationUuid,
|
||||
counterpartName: counterpartName,
|
||||
localParticipantName: localParticipantName,
|
||||
micEnabled: false,
|
||||
videoEnabled: false,
|
||||
);
|
||||
if (!started) {
|
||||
if (!isClosed) emit(const VideoCallSessionState());
|
||||
return false;
|
||||
}
|
||||
emit(state.copyWith(entryMode: effectiveEntryMode));
|
||||
if (isIncomingAccepted) {
|
||||
await _tryAutoEnterCallStage();
|
||||
@@ -266,6 +277,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
unawaited(_applyVideoCallSystemUi());
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _ensureSystemPipArmedIfInCall() async {
|
||||
@@ -377,8 +389,32 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The other participant hung up or was removed from the room.
|
||||
Future<void> handleRemoteCallEnded() async {
|
||||
if (_handlingRemoteCallEnd || isClosed) return;
|
||||
if (_videoCallManager.callEndedNotified) return;
|
||||
final inCall =
|
||||
state.callStageEntered ||
|
||||
state.connectPhase == VideoCallConnectPhase.connected;
|
||||
if (!inCall) return;
|
||||
|
||||
_handlingRemoteCallEnd = true;
|
||||
_suppressDisposeSideEffects = true;
|
||||
try {
|
||||
await endCall();
|
||||
if (isClosed) return;
|
||||
emit(const VideoCallSessionState());
|
||||
await _restoreSystemUiAfterCall();
|
||||
VideoCallNotificationNavigator.closeCallRouteIfOpen();
|
||||
} finally {
|
||||
_suppressDisposeSideEffects = false;
|
||||
_handlingRemoteCallEnd = false;
|
||||
}
|
||||
}
|
||||
|
||||
void onCallScreenDisposed() {
|
||||
_callScreenRouteVisible = false;
|
||||
if (_suppressDisposeSideEffects) return;
|
||||
if (state.uiMode != VideoCallUiMode.fullscreen) {
|
||||
if (state.uiMode == VideoCallUiMode.pip && state.callStageEntered) {
|
||||
// [isCallScreenRouteVisible] is not in bloc state; refresh the global
|
||||
@@ -510,9 +546,20 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
||||
}
|
||||
|
||||
Future<void> endCall() async {
|
||||
await _screenWakeLock.release();
|
||||
await _systemPipCoordinator.disarm();
|
||||
await _videoCallManager.endCall();
|
||||
_suppressDisposeSideEffects = true;
|
||||
try {
|
||||
await _screenWakeLock.release();
|
||||
await _systemPipCoordinator.disarm();
|
||||
await _videoCallManager.endCall();
|
||||
if (isClosed) return;
|
||||
// The idle snapshot is ignored while the call screen is still visible,
|
||||
// so hang up has to clear the session before the route pops. Otherwise
|
||||
// dispose keeps the call and the next token request rings both users.
|
||||
emit(const VideoCallSessionState());
|
||||
await _restoreSystemUiAfterCall();
|
||||
} finally {
|
||||
_suppressDisposeSideEffects = false;
|
||||
}
|
||||
}
|
||||
|
||||
void expandLocal() {
|
||||
|
||||
@@ -80,18 +80,22 @@ class _VideoCallScreenState extends State<VideoCallScreen> {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
unawaited(_initCounterpartMetadata());
|
||||
unawaited(
|
||||
_cubit.onCallScreenVisible(
|
||||
conversationUuid: widget.conversationUuid,
|
||||
counterpartName: widget.counterpartName,
|
||||
localParticipantName: widget.localParticipantName,
|
||||
counterpartIsConsultant: widget.counterpartIsConsultant,
|
||||
entryMode: widget.entryMode,
|
||||
),
|
||||
);
|
||||
unawaited(_openCallSession());
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _openCallSession() async {
|
||||
final started = await _cubit.onCallScreenVisible(
|
||||
conversationUuid: widget.conversationUuid,
|
||||
counterpartName: widget.counterpartName,
|
||||
localParticipantName: widget.localParticipantName,
|
||||
counterpartIsConsultant: widget.counterpartIsConsultant,
|
||||
entryMode: widget.entryMode,
|
||||
);
|
||||
if (!mounted || started) return;
|
||||
if (context.canPop()) context.pop();
|
||||
}
|
||||
|
||||
Future<void> _initCounterpartMetadata() async {
|
||||
var isOnline = widget.counterpartIsOnline ?? false;
|
||||
var lastSeenText = widget.counterpartLastSeenText ?? '';
|
||||
|
||||
@@ -31,7 +31,7 @@ class VideoCallAudioSessionCoordinator {
|
||||
avAudioSessionCategoryOptions:
|
||||
AVAudioSessionCategoryOptions.allowBluetooth |
|
||||
AVAudioSessionCategoryOptions.defaultToSpeaker,
|
||||
avAudioSessionMode: AVAudioSessionMode.voiceChat,
|
||||
avAudioSessionMode: AVAudioSessionMode.videoChat,
|
||||
avAudioSessionRouteSharingPolicy:
|
||||
AVAudioSessionRouteSharingPolicy.defaultPolicy,
|
||||
avAudioSessionSetActiveOptions: AVAudioSessionSetActiveOptions.none,
|
||||
@@ -88,6 +88,15 @@ class VideoCallAudioSessionCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
/// Restarts capture after the microphone track has been created.
|
||||
Future<void> restartCapture() async {
|
||||
if (!_isIos) return;
|
||||
try {
|
||||
await _iosCallAudioSession('restart');
|
||||
_iosActive = true;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
if (_isIos) {
|
||||
_iosActive = false;
|
||||
|
||||
@@ -8,7 +8,9 @@ import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart
|
||||
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_fcm_handler.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_peer_departure.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_notification_navigator.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_remote_end_registry.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_callkit_incoming/entities/call_event.dart';
|
||||
@@ -362,7 +364,7 @@ class VideoCallCallKitCoordinator {
|
||||
);
|
||||
return;
|
||||
}
|
||||
await _handleDecline(id);
|
||||
await _handleDecline(id, eventExtra: _callKitEventParams(event)?.extra);
|
||||
}
|
||||
|
||||
Future<void> _handleEndedEvent(CallEvent event) async {
|
||||
@@ -374,7 +376,7 @@ class VideoCallCallKitCoordinator {
|
||||
);
|
||||
return;
|
||||
}
|
||||
await _handleEnded(id);
|
||||
await _handleEnded(id, eventExtra: _callKitEventParams(event)?.extra);
|
||||
}
|
||||
|
||||
Future<void> _captureCallKitEventToSentry(CallEvent event) async {
|
||||
@@ -842,6 +844,7 @@ class VideoCallCallKitCoordinator {
|
||||
bool? counterpartIsConsultant,
|
||||
}) async {
|
||||
FlutterNativeSplash.remove();
|
||||
_manager.permitParticipantTokenRequest();
|
||||
|
||||
return VideoCallNotificationNavigator.openIncomingCall(
|
||||
conversationUuid: conversationUuid,
|
||||
@@ -853,7 +856,10 @@ class VideoCallCallKitCoordinator {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleDecline(String callKitId) async {
|
||||
Future<void> _handleDecline(
|
||||
String callKitId, {
|
||||
Map<String, dynamic>? eventExtra,
|
||||
}) async {
|
||||
if (_suppressedEndCallKitIds.contains(callKitId)) {
|
||||
log(
|
||||
'VideoCallCallKitCoordinator: ignore spurious end/decline during accept, callKitId=$callKitId',
|
||||
@@ -863,11 +869,29 @@ class VideoCallCallKitCoordinator {
|
||||
}
|
||||
|
||||
final params = _paramsForCallId(callKitId);
|
||||
final extra = params?.extra ?? <String, dynamic>{};
|
||||
final extra = params?.extra ?? eventExtra ?? <String, dynamic>{};
|
||||
final conversationUuid = _parseConversationUuid(
|
||||
extra,
|
||||
handle: params?.handle ?? callKitId,
|
||||
);
|
||||
final remoteEndDismissal =
|
||||
callKitDeclineIsEndedPush(
|
||||
cachedExtra: params?.extra,
|
||||
eventExtra: eventExtra,
|
||||
) ||
|
||||
VideoCallRemoteEndRegistry.contains(callKitId);
|
||||
|
||||
if (remoteEndDismissal) {
|
||||
// The call is already closed on the native side. Another endCall is
|
||||
// not needed: while the plugin's push flag is set it would end the
|
||||
// last reported call instead of this id, which may be a newer ring.
|
||||
log(
|
||||
'VideoCallCallKitCoordinator: remote end dismissal, callKitId=$callKitId',
|
||||
name: 'VideoCallCallKit',
|
||||
);
|
||||
_forgetCallKitId(callKitId);
|
||||
return;
|
||||
}
|
||||
|
||||
_suppressedEndCallKitIds.add(callKitId);
|
||||
try {
|
||||
@@ -879,10 +903,7 @@ class VideoCallCallKitCoordinator {
|
||||
}),
|
||||
);
|
||||
}
|
||||
_callKitParamsById.remove(callKitId);
|
||||
if (_activeCallKitId == callKitId) {
|
||||
_activeCallKitId = null;
|
||||
}
|
||||
_forgetCallKitId(callKitId);
|
||||
|
||||
if (_manager.isActiveCall ||
|
||||
_cubit.state.connectPhase != VideoCallConnectPhase.idle) {
|
||||
@@ -896,7 +917,10 @@ class VideoCallCallKitCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleEnded(String callKitId) async {
|
||||
Future<void> _handleEnded(
|
||||
String callKitId, {
|
||||
Map<String, dynamic>? eventExtra,
|
||||
}) async {
|
||||
if (_suppressedEndCallKitIds.contains(callKitId)) {
|
||||
log(
|
||||
'VideoCallCallKitCoordinator: ignore spurious ended during accept, callKitId=$callKitId',
|
||||
@@ -905,16 +929,36 @@ class VideoCallCallKitCoordinator {
|
||||
return;
|
||||
}
|
||||
|
||||
_callKitParamsById.remove(callKitId);
|
||||
if (_activeCallKitId == callKitId) {
|
||||
_activeCallKitId = null;
|
||||
// iOS reports an ended VoIP push to CallKit and closes it right away.
|
||||
// That close is marked and must be remembered before the decline (or the
|
||||
// later timeout) event for the same id arrives with no marker.
|
||||
final remoteEnd = callKitDeclineIsEndedPush(
|
||||
cachedExtra: _paramsForCallId(callKitId)?.extra,
|
||||
eventExtra: eventExtra,
|
||||
);
|
||||
if (remoteEnd) {
|
||||
VideoCallRemoteEndRegistry.mark(callKitId);
|
||||
}
|
||||
|
||||
_forgetCallKitId(callKitId);
|
||||
|
||||
// Native already ends a remote-ended call. Calling endCall again here
|
||||
// overwrites the plugin's stored payload, so the decline that follows
|
||||
// would lose the marker.
|
||||
if (remoteEnd) return;
|
||||
|
||||
try {
|
||||
await FlutterCallkitIncoming.endCall(callKitId);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _forgetCallKitId(String callKitId) {
|
||||
_callKitParamsById.remove(callKitId);
|
||||
if (_activeCallKitId == callKitId) {
|
||||
_activeCallKitId = null;
|
||||
}
|
||||
}
|
||||
|
||||
static String _parseConversationUuid(
|
||||
Map<String, Object?> extra, {
|
||||
String? handle,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_remote_end_registry.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_callkit_incoming/entities/call_kit_params.dart';
|
||||
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
|
||||
|
||||
/// Dismisses incoming call UI after a remote `video_call_ended` push.
|
||||
@@ -9,7 +13,8 @@ abstract final class VideoCallEndedPushHandler {
|
||||
'com.eatvioapp/video_call_push',
|
||||
);
|
||||
|
||||
static Future<void> dismissIncomingCallUi() async {
|
||||
static Future<void> dismissIncomingCallUi({String? conversationUuid}) async {
|
||||
await endActiveCallIfMatches(conversationUuid);
|
||||
if (Platform.isAndroid) {
|
||||
try {
|
||||
await _androidPushChannel.invokeMethod<void>('dismissIncomingCalls');
|
||||
@@ -17,21 +22,59 @@ abstract final class VideoCallEndedPushHandler {
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
await _dismissViaCallKit();
|
||||
await _dismissViaCallKit(conversationUuid);
|
||||
}
|
||||
|
||||
static Future<void> _dismissViaCallKit() async {
|
||||
static Future<void> endActiveCallIfMatches(String? conversationUuid) async {
|
||||
final uuid = conversationUuid?.trim();
|
||||
if (uuid == null || uuid.isEmpty) return;
|
||||
if (!sl.isRegistered<VideoCallSessionCubit>()) return;
|
||||
|
||||
final cubit = sl<VideoCallSessionCubit>();
|
||||
if (cubit.state.conversationUuid != uuid) return;
|
||||
try {
|
||||
await cubit.handleRemoteCallEnded();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
static bool callMatchesConversation(
|
||||
CallKitParams call,
|
||||
String conversationUuid,
|
||||
) {
|
||||
final uuid = conversationUuid.trim().toLowerCase();
|
||||
if (uuid.isEmpty) return false;
|
||||
final handle = call.handle?.trim().toLowerCase();
|
||||
if (handle != null && handle == uuid) return true;
|
||||
final extra = call.extra;
|
||||
if (extra == null) return false;
|
||||
for (final key in const ['conversationUuid', 'conversation_uuid']) {
|
||||
final value = extra[key];
|
||||
if (value is String && value.trim().toLowerCase() == uuid) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// The close of a ringing call comes back from the plugin as a decline.
|
||||
/// Each id is marked first, so the coordinator does not report that
|
||||
/// decline to the backend as if the user had rejected the call.
|
||||
static Future<void> _dismissViaCallKit(String? conversationUuid) async {
|
||||
final uuid = conversationUuid?.trim();
|
||||
try {
|
||||
final activeCalls = await FlutterCallkitIncoming.activeCalls();
|
||||
if (uuid == null || uuid.isEmpty) {
|
||||
for (final call in activeCalls) {
|
||||
VideoCallRemoteEndRegistry.mark(call.id);
|
||||
}
|
||||
await FlutterCallkitIncoming.endAllCalls();
|
||||
return;
|
||||
}
|
||||
for (final call in activeCalls) {
|
||||
if (!callMatchesConversation(call, uuid)) continue;
|
||||
VideoCallRemoteEndRegistry.mark(call.id);
|
||||
try {
|
||||
await FlutterCallkitIncoming.endCall(call.id);
|
||||
} catch (_) {}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
await FlutterCallkitIncoming.endAllCalls();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart';
|
||||
|
||||
/// Marker for a data-only `video_call_ended` push (no conversation uuid required).
|
||||
/// Marker for a data-only `video_call_ended` push.
|
||||
class VideoCallEndedPushPayload {
|
||||
const VideoCallEndedPushPayload._();
|
||||
const VideoCallEndedPushPayload({this.conversationUuid});
|
||||
|
||||
final String? conversationUuid;
|
||||
|
||||
static VideoCallEndedPushPayload? tryParse(Map<String, dynamic> data) {
|
||||
if (!VideoCallPushPayload.isEndedVideoCall(data)) return null;
|
||||
return const VideoCallEndedPushPayload._();
|
||||
final rawUuid =
|
||||
data['conversationUuid'] ??
|
||||
data['conversation_uuid'] ??
|
||||
data['uuid'];
|
||||
final conversationUuid = rawUuid?.toString().trim();
|
||||
return VideoCallEndedPushPayload(
|
||||
conversationUuid:
|
||||
conversationUuid == null || conversationUuid.isEmpty
|
||||
? null
|
||||
: conversationUuid,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,9 @@ abstract final class VideoCallFcmHandler {
|
||||
name: 'VideoCallFcm',
|
||||
);
|
||||
try {
|
||||
await VideoCallEndedPushHandler.dismissIncomingCallUi();
|
||||
await VideoCallEndedPushHandler.dismissIncomingCallUi(
|
||||
conversationUuid: endedPayload.conversationUuid,
|
||||
);
|
||||
} catch (e, st) {
|
||||
log(
|
||||
'VideoCallFcmHandler: failed to dismiss incoming call UI: $e',
|
||||
|
||||
@@ -2,18 +2,62 @@ import 'dart:io';
|
||||
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_kit_id.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_callkit_incoming/entities/entities.dart';
|
||||
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
|
||||
|
||||
typedef VideoCallIncomingClaim = Future<bool> Function(String callId);
|
||||
typedef VideoCallIncomingShow = Future<void> Function(CallKitParams params);
|
||||
typedef VideoCallIncomingDelay = Future<void> Function(Duration delay);
|
||||
typedef VideoCallAndroidIncomingShow =
|
||||
Future<void> Function(VideoCallPushPayload payload);
|
||||
|
||||
/// Shows the system incoming-call UI.
|
||||
///
|
||||
/// On iOS the VoIP push reports the call even when the app is in the
|
||||
/// foreground. A second report for the same id dismisses that UI, so the
|
||||
/// socket path waits for the push and presents CallKit only if the push
|
||||
/// never arrived.
|
||||
abstract final class VideoCallIncomingPushPresenter {
|
||||
static const String _androidAccentColor = '#2787F5';
|
||||
|
||||
static Future<void> showIncomingCall(VideoCallPushPayload payload) async {
|
||||
/// Same texts as the native VoIP path in `AppDelegate.swift`.
|
||||
static const String iosMissedCallSubtitle = 'Пропущенный видеозвонок';
|
||||
static const String iosMissedCallCallbackText = 'Перезвонить';
|
||||
static const MethodChannel _voipChannel = MethodChannel(
|
||||
'com.eatvioapp/voip_diagnostics',
|
||||
);
|
||||
static const MethodChannel _androidPushChannel = MethodChannel(
|
||||
'com.eatvioapp/video_call_push',
|
||||
);
|
||||
|
||||
/// Absolute delays from the start of an iOS present attempt.
|
||||
static const List<Duration> iosClaimPollDelays = <Duration>[
|
||||
Duration.zero,
|
||||
Duration(milliseconds: 300),
|
||||
Duration(milliseconds: 800),
|
||||
Duration(milliseconds: 1500),
|
||||
];
|
||||
|
||||
static Future<void> showIncomingCall(
|
||||
VideoCallPushPayload payload, {
|
||||
bool? isIos,
|
||||
VideoCallIncomingClaim? isIncomingCallClaimed,
|
||||
VideoCallIncomingClaim? reserveIncomingCall,
|
||||
VideoCallIncomingShow? showCallkitIncoming,
|
||||
VideoCallIncomingDelay? delay,
|
||||
List<Duration>? claimPollDelays,
|
||||
VideoCallAndroidIncomingShow? showAndroidIncoming,
|
||||
}) async {
|
||||
final callerName = payload.counterpartName?.trim().isNotEmpty == true
|
||||
? payload.counterpartName!.trim()
|
||||
: 'Итвио';
|
||||
|
||||
final callId = Platform.isIOS
|
||||
final ios = isIos ?? Platform.isIOS;
|
||||
if (!ios && (showAndroidIncoming != null || Platform.isAndroid)) {
|
||||
await (showAndroidIncoming ?? _showAndroidIncoming)(payload);
|
||||
return;
|
||||
}
|
||||
final callId = ios
|
||||
? VideoCallKitId.iosUuid(
|
||||
conversationUuid: payload.conversationUuid,
|
||||
messageId: payload.messageId,
|
||||
@@ -21,6 +65,22 @@ abstract final class VideoCallIncomingPushPresenter {
|
||||
payload.conversationUuid
|
||||
: payload.conversationUuid;
|
||||
|
||||
if (ios) {
|
||||
final claimed = isIncomingCallClaimed ?? _isIncomingCallClaimed;
|
||||
final reserve = reserveIncomingCall ?? _reserveIncomingCall;
|
||||
final wait = delay ?? Future<void>.delayed;
|
||||
final polls = claimPollDelays ?? iosClaimPollDelays;
|
||||
final started = DateTime.now();
|
||||
for (final checkpoint in polls) {
|
||||
final remaining = checkpoint - DateTime.now().difference(started);
|
||||
if (remaining > Duration.zero) {
|
||||
await wait(remaining);
|
||||
}
|
||||
if (await _claimOrFalse(claimed, callId)) return;
|
||||
}
|
||||
if (!await _claimOrFalse(reserve, callId)) return;
|
||||
}
|
||||
|
||||
final params = CallKitParams(
|
||||
id: callId,
|
||||
nameCaller: callerName,
|
||||
@@ -41,6 +101,11 @@ abstract final class VideoCallIncomingPushPresenter {
|
||||
'counterpartIsConsultant': payload.counterpartIsConsultant,
|
||||
},
|
||||
callingNotification: const NotificationParams(showNotification: false),
|
||||
missedCallNotification: const NotificationParams(
|
||||
showNotification: true,
|
||||
subtitle: iosMissedCallSubtitle,
|
||||
callbackText: iosMissedCallCallbackText,
|
||||
),
|
||||
android: const AndroidParams(
|
||||
isCustomNotification: true,
|
||||
isShowLogo: false,
|
||||
@@ -57,10 +122,62 @@ abstract final class VideoCallIncomingPushPresenter {
|
||||
ios: const IOSParams(
|
||||
handleType: 'generic',
|
||||
supportsVideo: true,
|
||||
ringtonePath: 'system_ringtone_default',
|
||||
configureAudioSession: false,
|
||||
audioSessionActive: false,
|
||||
audioSessionMode: 'videoChat',
|
||||
),
|
||||
);
|
||||
|
||||
await FlutterCallkitIncoming.showCallkitIncoming(params);
|
||||
await (showCallkitIncoming ?? FlutterCallkitIncoming.showCallkitIncoming)(
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> _showAndroidIncoming(VideoCallPushPayload payload) async {
|
||||
try {
|
||||
await _androidPushChannel
|
||||
.invokeMethod<void>('showIncomingCall', <String, String>{
|
||||
'conversation_uuid': payload.conversationUuid,
|
||||
if (payload.counterpartName != null)
|
||||
'caller_name': payload.counterpartName!,
|
||||
if (payload.counterpartAvatarUrl != null)
|
||||
'caller_avatar': payload.counterpartAvatarUrl!,
|
||||
if (payload.localParticipantName != null)
|
||||
'local_participant_name': payload.localParticipantName!,
|
||||
if (payload.counterpartIsConsultant != null)
|
||||
'is_consultant': payload.counterpartIsConsultant!
|
||||
? 'true'
|
||||
: 'false',
|
||||
if (payload.messageId != null)
|
||||
'message_id': payload.messageId.toString(),
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
static Future<bool> _claimOrFalse(
|
||||
VideoCallIncomingClaim claim,
|
||||
String callId,
|
||||
) async {
|
||||
try {
|
||||
return await claim(callId);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<bool> _isIncomingCallClaimed(String callId) async {
|
||||
final claimed = await _voipChannel.invokeMethod<bool>(
|
||||
'isIncomingCallClaimed',
|
||||
<String, String>{'id': callId},
|
||||
);
|
||||
return claimed ?? false;
|
||||
}
|
||||
|
||||
static Future<bool> _reserveIncomingCall(String callId) async {
|
||||
final reserved = await _voipChannel.invokeMethod<bool>(
|
||||
'reserveIncomingCall',
|
||||
<String, String>{'id': callId},
|
||||
);
|
||||
return reserved ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,17 @@ class VideoCallNotificationNavigator {
|
||||
return isOnCallRoute();
|
||||
}
|
||||
|
||||
static void closeCallRouteIfOpen() {
|
||||
try {
|
||||
if (!isOnCallRoute()) return;
|
||||
final context = AppRouter.navigatorKey.currentContext;
|
||||
if (context == null || !context.mounted) return;
|
||||
if (Navigator.canPop(context)) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
static bool isOnCallRoute() {
|
||||
final matches =
|
||||
AppRouter.appRouter.routerDelegate.currentConfiguration.matches;
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'package:livekit_client/livekit_client.dart' as lk;
|
||||
|
||||
/// Real call participants use the `user-{id}` identity. Egress and other
|
||||
/// bots must not keep the call alive after the other person leaves.
|
||||
bool videoCallRoomStillHasRemoteUser(Iterable<String> remoteIdentities) {
|
||||
for (final identity in remoteIdentities) {
|
||||
if (identity.startsWith('user-')) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool shouldForceEndCallAfterPeerLeft({
|
||||
required bool callStageEntered,
|
||||
required bool sessionConnected,
|
||||
required Iterable<String> remoteIdentities,
|
||||
}) {
|
||||
if (!callStageEntered || !sessionConnected) return false;
|
||||
return !videoCallRoomStillHasRemoteUser(remoteIdentities);
|
||||
}
|
||||
|
||||
/// The participant who taps hang up must not report the end while the other
|
||||
/// person is still in the room. That report and the remaining client's report
|
||||
/// both see an empty room and each one rings the other user again.
|
||||
bool shouldNotifyCallEnded({
|
||||
required bool alreadyNotified,
|
||||
required bool hasConversation,
|
||||
required bool peerStillInRoom,
|
||||
}) {
|
||||
if (!hasConversation || alreadyNotified || peerStillInRoom) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// An ended VoIP push has to be reported to CallKit, then closed. That close
|
||||
/// arrives as a decline. Reporting it to the backend sends another ended VoIP,
|
||||
/// which the other phone shows as a new incoming call.
|
||||
const Duration incomingDeclineNotifySuppression = Duration(seconds: 60);
|
||||
|
||||
bool incomingDeclineShouldNotifyBackend({
|
||||
required DateTime? leftAt,
|
||||
required DateTime now,
|
||||
Duration suppressFor = incomingDeclineNotifySuppression,
|
||||
}) {
|
||||
if (leftAt == null) return true;
|
||||
return now.difference(leftAt) >= suppressFor;
|
||||
}
|
||||
|
||||
/// A token request after hang-up makes the server send a new incoming call.
|
||||
/// Automatic retries stay blocked until the user starts or accepts a call.
|
||||
bool mayRequestParticipantToken({
|
||||
required bool sessionPermitted,
|
||||
required DateTime? leftAt,
|
||||
required DateTime now,
|
||||
Duration suppressFor = incomingDeclineNotifySuppression,
|
||||
}) {
|
||||
if (sessionPermitted) return true;
|
||||
return incomingDeclineShouldNotifyBackend(
|
||||
leftAt: leftAt,
|
||||
now: now,
|
||||
suppressFor: suppressFor,
|
||||
);
|
||||
}
|
||||
|
||||
bool callKitExtraMarksEndedPush(Map<String, dynamic>? extra) {
|
||||
final value = extra?['endedPush'];
|
||||
if (value is bool) return value;
|
||||
if (value is num) return value != 0;
|
||||
if (value is String) {
|
||||
final normalized = value.trim().toLowerCase();
|
||||
return normalized == 'true' || normalized == '1';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Either the cached incoming payload or the decline event itself can carry
|
||||
/// the marker. The decline often arrives with the push payload, while the
|
||||
/// cache still holds the original ringing call.
|
||||
bool callKitDeclineIsEndedPush({
|
||||
Map<String, dynamic>? cachedExtra,
|
||||
Map<String, dynamic>? eventExtra,
|
||||
}) {
|
||||
return callKitExtraMarksEndedPush(cachedExtra) ||
|
||||
callKitExtraMarksEndedPush(eventExtra);
|
||||
}
|
||||
|
||||
bool shouldForceEndCallAfterRoomDisconnect({
|
||||
required bool callStageEntered,
|
||||
required bool sessionConnected,
|
||||
required lk.DisconnectReason? reason,
|
||||
}) {
|
||||
if (!callStageEntered || !sessionConnected) return false;
|
||||
return reason == lk.DisconnectReason.participantRemoved ||
|
||||
reason == lk.DisconnectReason.roomDeleted;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/// CallKit ids that were closed because the call ended remotely.
|
||||
///
|
||||
/// On iOS every close of a ringing call comes back from the plugin as a
|
||||
/// decline, whether the user tapped Decline or the app ended the call after a
|
||||
/// `video_call_ended` signal. Only a decline by the user may be reported to
|
||||
/// the backend; the other kind would make the server send one more ended
|
||||
/// push and the other phone would show it as a new incoming call.
|
||||
abstract final class VideoCallRemoteEndRegistry {
|
||||
/// Longer than the 45 s ring timeout: the plugin still emits a timeout
|
||||
/// event for a call it reported and closed right away.
|
||||
static const Duration ttl = Duration(minutes: 2);
|
||||
|
||||
static final Map<String, DateTime> _markedAt = <String, DateTime>{};
|
||||
|
||||
static void mark(String callKitId, {DateTime? now}) {
|
||||
final id = _normalize(callKitId);
|
||||
if (id.isEmpty) return;
|
||||
final at = now ?? DateTime.now();
|
||||
_prune(at);
|
||||
_markedAt[id] = at;
|
||||
}
|
||||
|
||||
static bool contains(String callKitId, {DateTime? now}) {
|
||||
final id = _normalize(callKitId);
|
||||
if (id.isEmpty) return false;
|
||||
_prune(now ?? DateTime.now());
|
||||
return _markedAt.containsKey(id);
|
||||
}
|
||||
|
||||
static void clear() => _markedAt.clear();
|
||||
|
||||
static String _normalize(String callKitId) => callKitId.trim().toLowerCase();
|
||||
|
||||
static void _prune(DateTime now) {
|
||||
_markedAt.removeWhere((_, markedAt) => now.difference(markedAt) >= ttl);
|
||||
}
|
||||
}
|
||||
22
pubspec.yaml
22
pubspec.yaml
@@ -1,6 +1,6 @@
|
||||
name: eatvio_app
|
||||
description: A new Flutter project.
|
||||
version: 1.3.47+136
|
||||
version: 1.3.58+147
|
||||
publish_to: 'none'
|
||||
|
||||
environment:
|
||||
@@ -175,14 +175,14 @@ flutter_intl:
|
||||
flutter_native_splash:
|
||||
android: true
|
||||
ios: true
|
||||
color: '#FFFFFF'
|
||||
image_android: 'assets/splash/splash.png'
|
||||
image_dark_android: 'assets/splash/splash.png'
|
||||
image_ios: 'assets/splash/splash.png'
|
||||
image_dark_ios: 'assets/splash/splash.png'
|
||||
branding_android: 'assets/splash/branding.png'
|
||||
branding_dark_android: 'assets/splash/branding.png'
|
||||
color: "#FFFFFF"
|
||||
image_android: "assets/splash/splash.png"
|
||||
image_dark_android: "assets/splash/splash.png"
|
||||
image_ios: "assets/splash/splash.png"
|
||||
image_dark_ios: "assets/splash/splash.png"
|
||||
branding_android: "assets/splash/branding.png"
|
||||
branding_dark_android: "assets/splash/branding.png"
|
||||
android_12:
|
||||
image: 'assets/splash/splash.png'
|
||||
image_dark: 'assets/splash/splash.png'
|
||||
branding: 'assets/splash/branding.png'
|
||||
image: "assets/splash/splash.png"
|
||||
image_dark: "assets/splash/splash.png"
|
||||
branding: "assets/splash/branding.png"
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/app_initializer.dart';
|
||||
import 'package:eatvio_app/core/cached_base_url.dart';
|
||||
import 'package:eatvio_app/core/env_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';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class _MockVideoCallSessionCubit extends Mock implements VideoCallSessionCubit {}
|
||||
class _MockVideoCallSessionCubit extends Mock
|
||||
implements VideoCallSessionCubit {}
|
||||
|
||||
void main() {
|
||||
setUpAll(() async {
|
||||
// Initialize dotenv for tests
|
||||
dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost');
|
||||
dotenv.loadFromString(
|
||||
envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost',
|
||||
);
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
@@ -90,6 +98,75 @@ void main() {
|
||||
expect(devConfig.baseUrl, testConfig.baseUrl);
|
||||
});
|
||||
|
||||
test('setBaseUrl saves override to prefs', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final prefs = Prefs(await SharedPreferences.getInstance());
|
||||
sl.registerSingleton<Prefs>(prefs);
|
||||
final config = AppConfig(buildType: AppBuildType.prod);
|
||||
|
||||
config.setBaseUrl('https://custom.example');
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(
|
||||
prefs.getString(PrefsKeys.customBaseUrl),
|
||||
'https://custom.example',
|
||||
);
|
||||
});
|
||||
|
||||
test('setBaseUrl removes cached override when url is blank', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'customBaseUrl': 'https://custom.example',
|
||||
});
|
||||
final prefs = Prefs(await SharedPreferences.getInstance());
|
||||
sl.registerSingleton<Prefs>(prefs);
|
||||
final config = AppConfig(buildType: AppBuildType.prod);
|
||||
|
||||
config.setBaseUrl(' ');
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(prefs.getString(PrefsKeys.customBaseUrl), isNull);
|
||||
expect(config.baseUrl, ' ');
|
||||
});
|
||||
|
||||
test(
|
||||
'applyCachedBaseUrl replaces env base url before dio is created',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'customBaseUrl': 'https://cached.example',
|
||||
});
|
||||
sl.registerLazySingleton<AppConfig>(
|
||||
() => AppConfig(buildType: AppBuildType.prod),
|
||||
);
|
||||
sl.registerSingleton<Prefs>(
|
||||
Prefs(await SharedPreferences.getInstance()),
|
||||
);
|
||||
sl.registerLazySingleton<Dio>(
|
||||
() => Dio(BaseOptions(baseUrl: sl<AppConfig>().baseUrl)),
|
||||
);
|
||||
|
||||
applyCachedBaseUrl();
|
||||
|
||||
expect(sl<AppConfig>().baseUrl, 'https://cached.example');
|
||||
expect(sl<Dio>().options.baseUrl, 'https://cached.example');
|
||||
},
|
||||
);
|
||||
|
||||
test('applyCachedBaseUrl keeps env base url when cache is empty', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
sl.registerLazySingleton<AppConfig>(
|
||||
() => AppConfig(buildType: AppBuildType.prod),
|
||||
);
|
||||
sl.registerSingleton<Prefs>(Prefs(await SharedPreferences.getInstance()));
|
||||
sl.registerLazySingleton<Dio>(
|
||||
() => Dio(BaseOptions(baseUrl: sl<AppConfig>().baseUrl)),
|
||||
);
|
||||
|
||||
applyCachedBaseUrl();
|
||||
|
||||
expect(sl<AppConfig>().baseUrl, EnvConfig.baseUrl);
|
||||
expect(sl<Dio>().options.baseUrl, EnvConfig.baseUrl);
|
||||
});
|
||||
|
||||
test('setBaseUrl can be called multiple times', () {
|
||||
final config = AppConfig(buildType: AppBuildType.prod);
|
||||
const url1 = 'https://url1.com';
|
||||
|
||||
@@ -4,57 +4,46 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
void main() {
|
||||
group('PrefsKeys', () {
|
||||
test('has all expected values', () {
|
||||
expect(PrefsKeys.values.length, 5);
|
||||
expect(PrefsKeys.values.length, 6);
|
||||
expect(PrefsKeys.values, contains(PrefsKeys.hasAnonymousTokenBeenSent));
|
||||
expect(PrefsKeys.values, contains(PrefsKeys.welcomeScreenWasShowed));
|
||||
expect(PrefsKeys.values, contains(PrefsKeys.registrationDate));
|
||||
expect(PrefsKeys.values, contains(PrefsKeys.marketingBottomSheetHasBeenShown));
|
||||
expect(
|
||||
PrefsKeys.values,
|
||||
contains(PrefsKeys.marketingBottomSheetHasBeenShown),
|
||||
);
|
||||
expect(PrefsKeys.values, contains(PrefsKeys.isEmailVerificationSkipped));
|
||||
expect(PrefsKeys.values, contains(PrefsKeys.customBaseUrl));
|
||||
});
|
||||
|
||||
test('can be compared', () {
|
||||
expect(PrefsKeys.hasAnonymousTokenBeenSent == PrefsKeys.hasAnonymousTokenBeenSent, true);
|
||||
expect(PrefsKeys.hasAnonymousTokenBeenSent == PrefsKeys.welcomeScreenWasShowed, false);
|
||||
expect(
|
||||
PrefsKeys.hasAnonymousTokenBeenSent ==
|
||||
PrefsKeys.hasAnonymousTokenBeenSent,
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
PrefsKeys.hasAnonymousTokenBeenSent == PrefsKeys.welcomeScreenWasShowed,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('has correct name property', () {
|
||||
expect(PrefsKeys.hasAnonymousTokenBeenSent.name, 'hasAnonymousTokenBeenSent');
|
||||
expect(
|
||||
PrefsKeys.hasAnonymousTokenBeenSent.name,
|
||||
'hasAnonymousTokenBeenSent',
|
||||
);
|
||||
expect(PrefsKeys.welcomeScreenWasShowed.name, 'welcomeScreenWasShowed');
|
||||
expect(PrefsKeys.registrationDate.name, 'registrationDate');
|
||||
expect(PrefsKeys.marketingBottomSheetHasBeenShown.name, 'marketingBottomSheetHasBeenShown');
|
||||
expect(PrefsKeys.isEmailVerificationSkipped.name, 'isEmailVerificationSkipped');
|
||||
expect(
|
||||
PrefsKeys.marketingBottomSheetHasBeenShown.name,
|
||||
'marketingBottomSheetHasBeenShown',
|
||||
);
|
||||
expect(
|
||||
PrefsKeys.isEmailVerificationSkipped.name,
|
||||
'isEmailVerificationSkipped',
|
||||
);
|
||||
expect(PrefsKeys.customBaseUrl.name, 'customBaseUrl');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/error_reporting/error_report_policy.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
||||
@@ -72,6 +73,24 @@ void main() {
|
||||
test('returns true for arbitrary exceptions', () {
|
||||
expect(ErrorReportPolicy.shouldReport(StateError('x')), true);
|
||||
});
|
||||
|
||||
test('returns false for HMS client api invalid', () {
|
||||
expect(
|
||||
ErrorReportPolicy.shouldReport(
|
||||
PlatformException(code: 'HMS_TOKEN_ERROR', message: '907135003: client api invalid'),
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('returns true for other HMS token errors', () {
|
||||
expect(
|
||||
ErrorReportPolicy.shouldReport(
|
||||
PlatformException(code: 'HMS_TOKEN_ERROR', message: 'Missing Huawei appId'),
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('ErrorReportPolicy.allowSentryEvent', () {
|
||||
@@ -121,6 +140,13 @@ void main() {
|
||||
expect(ErrorReportPolicy.allowSentryEvent(event, hint), true);
|
||||
});
|
||||
|
||||
test('drops HMS client api invalid before send', () {
|
||||
final event = SentryEvent(
|
||||
throwable: PlatformException(code: 'HMS_TOKEN_ERROR', message: '907135003: client api invalid'),
|
||||
);
|
||||
expect(ErrorReportPolicy.allowSentryEvent(event, hint), false);
|
||||
});
|
||||
|
||||
test('drops cancelled Dio from SDK', () {
|
||||
final e = DioException(
|
||||
requestOptions: RequestOptions(path: '/x'),
|
||||
|
||||
@@ -4,25 +4,46 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
void main() {
|
||||
group('PrefsKeys', () {
|
||||
test('все значения enum определены', () {
|
||||
expect(PrefsKeys.values.length, 5);
|
||||
expect(PrefsKeys.values.length, 6);
|
||||
expect(PrefsKeys.values, contains(PrefsKeys.hasAnonymousTokenBeenSent));
|
||||
expect(PrefsKeys.values, contains(PrefsKeys.welcomeScreenWasShowed));
|
||||
expect(PrefsKeys.values, contains(PrefsKeys.registrationDate));
|
||||
expect(PrefsKeys.values, contains(PrefsKeys.marketingBottomSheetHasBeenShown));
|
||||
expect(
|
||||
PrefsKeys.values,
|
||||
contains(PrefsKeys.marketingBottomSheetHasBeenShown),
|
||||
);
|
||||
expect(PrefsKeys.values, contains(PrefsKeys.isEmailVerificationSkipped));
|
||||
expect(PrefsKeys.values, contains(PrefsKeys.customBaseUrl));
|
||||
});
|
||||
|
||||
test('имена ключей корректны', () {
|
||||
expect(PrefsKeys.hasAnonymousTokenBeenSent.name, 'hasAnonymousTokenBeenSent');
|
||||
expect(
|
||||
PrefsKeys.hasAnonymousTokenBeenSent.name,
|
||||
'hasAnonymousTokenBeenSent',
|
||||
);
|
||||
expect(PrefsKeys.welcomeScreenWasShowed.name, 'welcomeScreenWasShowed');
|
||||
expect(PrefsKeys.registrationDate.name, 'registrationDate');
|
||||
expect(PrefsKeys.marketingBottomSheetHasBeenShown.name, 'marketingBottomSheetHasBeenShown');
|
||||
expect(PrefsKeys.isEmailVerificationSkipped.name, 'isEmailVerificationSkipped');
|
||||
expect(
|
||||
PrefsKeys.marketingBottomSheetHasBeenShown.name,
|
||||
'marketingBottomSheetHasBeenShown',
|
||||
);
|
||||
expect(
|
||||
PrefsKeys.isEmailVerificationSkipped.name,
|
||||
'isEmailVerificationSkipped',
|
||||
);
|
||||
expect(PrefsKeys.customBaseUrl.name, 'customBaseUrl');
|
||||
});
|
||||
|
||||
test('can be compared', () {
|
||||
expect(PrefsKeys.hasAnonymousTokenBeenSent == PrefsKeys.hasAnonymousTokenBeenSent, true);
|
||||
expect(PrefsKeys.hasAnonymousTokenBeenSent == PrefsKeys.welcomeScreenWasShowed, false);
|
||||
expect(
|
||||
PrefsKeys.hasAnonymousTokenBeenSent ==
|
||||
PrefsKeys.hasAnonymousTokenBeenSent,
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
PrefsKeys.hasAnonymousTokenBeenSent == PrefsKeys.welcomeScreenWasShowed,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('has correct index', () {
|
||||
@@ -33,43 +54,22 @@ void main() {
|
||||
});
|
||||
|
||||
test('can access by name', () {
|
||||
expect(PrefsKeys.values.byName('hasAnonymousTokenBeenSent'), PrefsKeys.hasAnonymousTokenBeenSent);
|
||||
expect(PrefsKeys.values.byName('welcomeScreenWasShowed'), PrefsKeys.welcomeScreenWasShowed);
|
||||
expect(PrefsKeys.values.byName('registrationDate'), PrefsKeys.registrationDate);
|
||||
expect(PrefsKeys.values.byName('marketingBottomSheetHasBeenShown'), PrefsKeys.marketingBottomSheetHasBeenShown);
|
||||
expect(
|
||||
PrefsKeys.values.byName('hasAnonymousTokenBeenSent'),
|
||||
PrefsKeys.hasAnonymousTokenBeenSent,
|
||||
);
|
||||
expect(
|
||||
PrefsKeys.values.byName('welcomeScreenWasShowed'),
|
||||
PrefsKeys.welcomeScreenWasShowed,
|
||||
);
|
||||
expect(
|
||||
PrefsKeys.values.byName('registrationDate'),
|
||||
PrefsKeys.registrationDate,
|
||||
);
|
||||
expect(
|
||||
PrefsKeys.values.byName('marketingBottomSheetHasBeenShown'),
|
||||
PrefsKeys.marketingBottomSheetHasBeenShown,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
112
test/core/push/hms_client_error_test.dart
Normal file
112
test/core/push/hms_client_error_test.dart
Normal file
@@ -0,0 +1,112 @@
|
||||
import 'package:eatvio_app/core/push/hms_client_error.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('isHuaweiFamilyBrand', () {
|
||||
test('matches Huawei and Honor brand or manufacturer', () {
|
||||
expect(
|
||||
isHuaweiFamilyBrand(brand: 'HONOR', manufacturer: 'HONOR'),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
isHuaweiFamilyBrand(brand: 'Huawei', manufacturer: 'HUAWEI'),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
isHuaweiFamilyBrand(brand: 'samsung', manufacturer: 'HONOR'),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not match other Android brands', () {
|
||||
expect(
|
||||
isHuaweiFamilyBrand(brand: 'samsung', manufacturer: 'Samsung'),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('isHmsClientUnavailable', () {
|
||||
test('matches 907135003 client api invalid', () {
|
||||
expect(
|
||||
isHmsClientUnavailable(
|
||||
PlatformException(
|
||||
code: 'HMS_TOKEN_ERROR',
|
||||
message: '907135003: client api invalid',
|
||||
),
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not match a missing app id', () {
|
||||
expect(
|
||||
isHmsClientUnavailable(
|
||||
PlatformException(
|
||||
code: 'HMS_TOKEN_ERROR',
|
||||
message: 'Missing Huawei appId',
|
||||
),
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
expect(isHmsClientUnavailable(StateError('907135003')), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('pushPlatform', () {
|
||||
test('uses huawei only when an HMS token exists', () {
|
||||
expect(
|
||||
pushPlatform(
|
||||
isIos: false,
|
||||
huaweiFamily: true,
|
||||
hmsAvailable: true,
|
||||
hasHmsToken: true,
|
||||
),
|
||||
'huawei',
|
||||
);
|
||||
});
|
||||
|
||||
test('falls back to android on Honor without a valid HMS client', () {
|
||||
expect(
|
||||
pushPlatform(
|
||||
isIos: false,
|
||||
huaweiFamily: true,
|
||||
hmsAvailable: false,
|
||||
hasHmsToken: false,
|
||||
),
|
||||
'android',
|
||||
);
|
||||
expect(
|
||||
pushPlatform(
|
||||
isIos: false,
|
||||
huaweiFamily: true,
|
||||
hmsAvailable: true,
|
||||
hasHmsToken: false,
|
||||
),
|
||||
'android',
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps ios and plain android', () {
|
||||
expect(
|
||||
pushPlatform(
|
||||
isIos: true,
|
||||
huaweiFamily: false,
|
||||
hmsAvailable: false,
|
||||
hasHmsToken: false,
|
||||
),
|
||||
'ios',
|
||||
);
|
||||
expect(
|
||||
pushPlatform(
|
||||
isIos: false,
|
||||
huaweiFamily: false,
|
||||
hmsAvailable: false,
|
||||
hasHmsToken: false,
|
||||
),
|
||||
'android',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
26
test/core/utils/url_utils_test.dart
Normal file
26
test/core/utils/url_utils_test.dart
Normal file
@@ -0,0 +1,26 @@
|
||||
import 'package:eatvio_app/core/utils/url_utils.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
const baseUrl = 'https://dev2.eatvio.com';
|
||||
|
||||
test('keeps an absolute default avatar url unchanged', () {
|
||||
const avatar =
|
||||
'https://ui-avatars.dev2.eatvio.com/api/?name=%D0%9A+%D0%90&color=7F9CF5&background=EBF4FF';
|
||||
|
||||
expect(joinBaseUrl(baseUrl, avatar), avatar);
|
||||
});
|
||||
|
||||
test('keeps an absolute http url unchanged', () {
|
||||
const photo = 'http://localhost/storage/avatar.jpg';
|
||||
|
||||
expect(joinBaseUrl(baseUrl, photo), photo);
|
||||
});
|
||||
|
||||
test('prefixes a relative storage path with the base url', () {
|
||||
expect(
|
||||
joinBaseUrl(baseUrl, '/storage/profile-photos/photo.jpg'),
|
||||
'https://dev2.eatvio.com/storage/profile-photos/photo.jpg',
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -4,6 +4,9 @@ import 'package:eatvio_app/core/enums/complained_content.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/core/widgets/custom_popup.dart';
|
||||
import 'package:eatvio_app/features/feed/models/diets_pagination_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/generated/l10n.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -185,6 +188,68 @@ void main() {
|
||||
expect(find.text('Удалить'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('CustomPopup for author recipe shows edit when recipe is provided', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
wrap(
|
||||
CustomPopup(
|
||||
isAuthor: true,
|
||||
complainedContentType: ComplainedContent.recipe,
|
||||
path: 'recipes/slug',
|
||||
slug: 'slug',
|
||||
recipe: _recipe(),
|
||||
userSlug: 'author',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.more_horiz_rounded));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Редактировать'), findsOneWidget);
|
||||
expect(find.text('Удалить'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('CustomPopup for author diet shows edit when diet is provided', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
wrap(
|
||||
CustomPopup(
|
||||
isAuthor: true,
|
||||
complainedContentType: ComplainedContent.diet,
|
||||
path: 'diets/slug',
|
||||
id: 1,
|
||||
diet: _diet(),
|
||||
userSlug: 'author',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.more_horiz_rounded));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Редактировать'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('CustomPopup for author plan shows edit when plan is provided', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
wrap(
|
||||
CustomPopup(
|
||||
isAuthor: true,
|
||||
complainedContentType: ComplainedContent.plan,
|
||||
path: 'plans/slug',
|
||||
slug: 'slug',
|
||||
plan: _plan(),
|
||||
userSlug: 'author',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.more_horiz_rounded));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Редактировать'), findsOneWidget);
|
||||
expect(find.text('Удалить'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('CustomPopup for non-author without complained type still renders static actions', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
wrap(
|
||||
@@ -206,5 +271,72 @@ void main() {
|
||||
expect(find.text('Пожаловаться'), findsOneWidget);
|
||||
expect(find.text('Заблокировать автора'), findsOneWidget);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
Recipe _recipe() {
|
||||
return const Recipe(
|
||||
id: 1,
|
||||
image: 'image.jpg',
|
||||
state: 'active',
|
||||
cookingTime: 30,
|
||||
activeCookingTime: 15,
|
||||
ingredients: null,
|
||||
description: 'Описание',
|
||||
steps: null,
|
||||
videoUrl: null,
|
||||
title: 'Рецепт',
|
||||
type: 'main',
|
||||
slug: 'recipe-slug',
|
||||
commentsCount: 0,
|
||||
wasLikedByMe: false,
|
||||
isFavorite: false,
|
||||
calories: 100,
|
||||
likesCount: 0,
|
||||
viewsCount: 0,
|
||||
tags: [],
|
||||
author: Author(authorSlug: 'author', authorName: 'Author', authorProfileIcon: 'icon.jpg', authorIsMe: true),
|
||||
);
|
||||
}
|
||||
|
||||
Diet _diet() {
|
||||
return const Diet(
|
||||
id: 1,
|
||||
slug: 'diet-slug',
|
||||
name: 'Диета',
|
||||
image: 'image.jpg',
|
||||
duration: 7,
|
||||
state: DietState.active,
|
||||
type: DietType(),
|
||||
details: 'Описание',
|
||||
dailySchedule: {},
|
||||
author: Author(authorSlug: 'author', authorName: 'Author', authorProfileIcon: 'icon.jpg', authorIsMe: true),
|
||||
totalLikes: 0,
|
||||
totalViews: 0,
|
||||
totalComments: 0,
|
||||
wasLikedByMe: false,
|
||||
isFavorite: false,
|
||||
dailyNutrition: [],
|
||||
);
|
||||
}
|
||||
|
||||
PlanModel _plan() {
|
||||
return const PlanModel(
|
||||
id: 1,
|
||||
slug: 'plan-slug',
|
||||
name: 'План',
|
||||
image: 'image.jpg',
|
||||
duration: 7,
|
||||
state: 'active',
|
||||
type: PlanType(fit: true, slim: false, behavior: false, wellness: false),
|
||||
details: 'Описание',
|
||||
packetsCount: 1,
|
||||
author: Author(authorSlug: 'author', authorName: 'Author', authorProfileIcon: 'icon.jpg', authorIsMe: true),
|
||||
totalLikes: 0,
|
||||
totalViews: 0,
|
||||
totalComments: 0,
|
||||
isFavorite: false,
|
||||
minPriceOfOptions: 1200,
|
||||
maxPriceOfOptions: 1200,
|
||||
wasLikedByMe: false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_state
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
@@ -60,6 +61,7 @@ void main() {
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
KeyboardVisibilityTesting.setVisibilityForTesting(false);
|
||||
blogPostCreateCubit.close();
|
||||
});
|
||||
|
||||
@@ -128,12 +130,16 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('does not add extra space above the title field', (tester) async {
|
||||
testWidgets('does not add extra space above the title field', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(createTestWidget());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final titleTop = tester.getTopLeft(find.text('Заголовок')).dy;
|
||||
final scrollTop = tester.getTopLeft(find.byType(SingleChildScrollView)).dy;
|
||||
final scrollTop = tester
|
||||
.getTopLeft(find.byType(SingleChildScrollView))
|
||||
.dy;
|
||||
expect(titleTop - scrollTop, 16);
|
||||
});
|
||||
|
||||
@@ -281,6 +287,44 @@ void main() {
|
||||
expect(find.text('Создать'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'keeps create button above the editor toolbar when the keyboard is visible',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(createTestWidget());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
KeyboardVisibilityTesting.setVisibilityForTesting(true);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
final createButton = find.byKey(
|
||||
AppKeys.blogPostCreateScreenCreateButton,
|
||||
);
|
||||
final editorToolbar = find.byKey(
|
||||
const ValueKey('blogPostCreateEditorToolbar'),
|
||||
);
|
||||
|
||||
expect(createButton, findsOneWidget);
|
||||
expect(editorToolbar, findsOneWidget);
|
||||
|
||||
final buttonRect = tester.getRect(createButton);
|
||||
final toolbarRect = tester.getRect(editorToolbar);
|
||||
|
||||
expect(buttonRect.overlaps(toolbarRect), isFalse);
|
||||
expect(buttonRect.top, greaterThanOrEqualTo(toolbarRect.bottom));
|
||||
|
||||
await tester.tap(createButton);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.text(
|
||||
'Добавьте заголовок, описание, изображение или ссылку на видео',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('does not submit when form is empty', (tester) async {
|
||||
when(
|
||||
() => mockBlogPostRepository.createBlogPost(
|
||||
|
||||
@@ -479,6 +479,39 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'_handleNewMessageViaSocket - завершение видеозвонка закрывает только этот чат',
|
||||
() async {
|
||||
const chatId = 'chat-id';
|
||||
final dismissed = <String?>[];
|
||||
chatsCubit.dismissIncomingVideoCall = (uuid) async {
|
||||
dismissed.add(uuid);
|
||||
};
|
||||
chatsCubit.emit(
|
||||
ChatsState(
|
||||
state: BaseState.success,
|
||||
chatRooms: [buildChatRoom(chatId)],
|
||||
),
|
||||
);
|
||||
when(mockWsManager.userId).thenReturn(42);
|
||||
|
||||
final controller = await connectToSocketStream();
|
||||
controller.add(
|
||||
socketEvent(
|
||||
id: 502,
|
||||
authorId: 7,
|
||||
roomId: chatId,
|
||||
data: 'Анна завершил(а) видеозвонок.',
|
||||
),
|
||||
);
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
expect(dismissed, [chatId]);
|
||||
|
||||
await controller.close();
|
||||
},
|
||||
);
|
||||
|
||||
test('close - закрывает cubit и отписывается от подписок', () async {
|
||||
final mockSubscription1 = MockStreamSubscription();
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_cubit.dart';
|
||||
import 'package:eatvio_app/features/create_plan/repositories/create_plan_repository.dart';
|
||||
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mockito/annotations.dart';
|
||||
@@ -703,6 +705,51 @@ void main() {
|
||||
expect(cubit.state.planCreated, false);
|
||||
});
|
||||
|
||||
test('loadExisting заполняет форму и обновляет существующий план', () async {
|
||||
const plan = PlanModel(
|
||||
id: 7,
|
||||
slug: 'test-plan',
|
||||
name: 'Test Plan',
|
||||
image: 'cover.jpg',
|
||||
duration: 14,
|
||||
state: 'active',
|
||||
type: PlanType(fit: true, slim: true, behavior: false, wellness: false),
|
||||
details: 'Описание плана',
|
||||
packetsCount: 1,
|
||||
packet1Options: PacketOptions(
|
||||
items: ['Консультации'],
|
||||
price: 1500,
|
||||
short: 'Базовый',
|
||||
description: 'Описание опции',
|
||||
hasPersonalDiet: false,
|
||||
),
|
||||
author: Author(authorSlug: 'author', authorName: 'Author', authorProfileIcon: 'icon.jpg', authorIsMe: true),
|
||||
totalLikes: 0,
|
||||
totalViews: 0,
|
||||
totalComments: 0,
|
||||
isFavorite: false,
|
||||
minPriceOfOptions: 1500,
|
||||
maxPriceOfOptions: 1500,
|
||||
wasLikedByMe: false,
|
||||
);
|
||||
|
||||
when(repository.updatePublishedPlan(slug: anyNamed('slug'), model: anyNamed('model'))).thenAnswer((_) async {});
|
||||
|
||||
cubit.loadExisting(plan);
|
||||
expect(cubit.sourcePlan?.slug, 'test-plan');
|
||||
expect(cubit.state.planData.name, 'Test Plan');
|
||||
expect(cubit.state.planData.duration, 14);
|
||||
expect(cubit.state.durationIndex, 1);
|
||||
expect(cubit.state.planData.type, [true, true, false, false]);
|
||||
expect(cubit.validateImage(), true);
|
||||
|
||||
await cubit.publishPlan();
|
||||
|
||||
verify(repository.updatePublishedPlan(slug: 'test-plan', model: anyNamed('model'))).called(1);
|
||||
verifyNever(repository.createPlan(model: anyNamed('model')));
|
||||
expect(cubit.state.planCreated, true);
|
||||
});
|
||||
|
||||
test('setPlanType - валидирует тип когда хотя бы один выбран', () {
|
||||
cubit.setPlanType([true, false, false, false]);
|
||||
expect(cubit.state.validationState.typeHasError, false);
|
||||
|
||||
@@ -4,14 +4,48 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
void main() {
|
||||
group('PortionState enum', () {
|
||||
test('has all expected values', () {
|
||||
expect(PortionState.values.length, 7);
|
||||
expect(PortionState.values.length, 10);
|
||||
expect(PortionState.filledDiet, isNotNull);
|
||||
expect(PortionState.eatenFromDiet, isNotNull);
|
||||
expect(PortionState.eaten, isNotNull);
|
||||
expect(PortionState.eatenDeleted, isNotNull);
|
||||
expect(PortionState.eatenEdited, isNotNull);
|
||||
expect(PortionState.commented, isNotNull);
|
||||
expect(PortionState.commentedFromDiet, isNotNull);
|
||||
expect(PortionState.reviewedByUser, isNotNull);
|
||||
expect(PortionState.reviewedFromDiet, isNotNull);
|
||||
expect(PortionState.unknown, isNotNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('PortionModel.fromJson', () {
|
||||
Map<String, dynamic> portionJson({required String state}) => {
|
||||
'id': 1,
|
||||
'product_nutritional_value_id': null,
|
||||
'product_calories_info': null,
|
||||
'product_name': 'Oatmeal',
|
||||
'quantity': 1,
|
||||
'portion': '100g',
|
||||
'from_diet': true,
|
||||
'state': state,
|
||||
};
|
||||
|
||||
test('parses commented_from_diet state', () {
|
||||
final model = PortionModel.fromJson(portionJson(state: 'commented_from_diet'));
|
||||
|
||||
expect(model.portionState, PortionState.commentedFromDiet);
|
||||
});
|
||||
|
||||
test('parses reviewed_from_diet state', () {
|
||||
final model = PortionModel.fromJson(portionJson(state: 'reviewed_from_diet'));
|
||||
|
||||
expect(model.portionState, PortionState.reviewedFromDiet);
|
||||
});
|
||||
|
||||
test('does not throw on unknown state from backend', () {
|
||||
final model = PortionModel.fromJson(portionJson(state: 'some_future_state'));
|
||||
|
||||
expect(model.portionState, PortionState.unknown);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
60
test/features/diary/presentation/diary_edit_access_test.dart
Normal file
60
test/features/diary/presentation/diary_edit_access_test.dart
Normal file
@@ -0,0 +1,60 @@
|
||||
import 'package:eatvio_app/features/diary/presentation/diary_edit_access.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
final now = DateTime(2026, 9, 30, 12);
|
||||
|
||||
bool canEdit({
|
||||
bool isConsultantView = false,
|
||||
bool isDiaryCommented = false,
|
||||
bool isDiaryFinished = false,
|
||||
int closed = 0,
|
||||
String diaryDate = '2026-09-28',
|
||||
}) {
|
||||
return canEditDiaryDay(
|
||||
isConsultantView: isConsultantView,
|
||||
isDiaryCommented: isDiaryCommented,
|
||||
isDiaryFinished: isDiaryFinished,
|
||||
closed: closed,
|
||||
diaryDate: diaryDate,
|
||||
now: now,
|
||||
);
|
||||
}
|
||||
|
||||
test('finished days inside the seven-day window stay editable', () {
|
||||
expect(canEdit(isDiaryFinished: true, diaryDate: '2026-09-30'), isTrue);
|
||||
expect(canEdit(isDiaryFinished: true, diaryDate: '2026-09-29'), isTrue);
|
||||
expect(canEdit(isDiaryFinished: true, diaryDate: '2026-09-23'), isTrue);
|
||||
});
|
||||
|
||||
test('finished days older than the seven-day window stay read-only', () {
|
||||
expect(canEdit(isDiaryFinished: true, diaryDate: '2026-09-22'), isFalse);
|
||||
});
|
||||
|
||||
test('commented days stay read-only inside the window', () {
|
||||
expect(canEdit(isDiaryCommented: true, diaryDate: '2026-09-30'), isFalse);
|
||||
expect(
|
||||
canEdit(
|
||||
isDiaryCommented: true,
|
||||
isDiaryFinished: true,
|
||||
diaryDate: '2026-09-29',
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('an open day outside the window stays editable', () {
|
||||
expect(canEdit(diaryDate: '2026-09-01'), isTrue);
|
||||
});
|
||||
|
||||
test('a closed flag blocks editing even inside the window', () {
|
||||
expect(
|
||||
canEdit(isDiaryFinished: true, closed: 1, diaryDate: '2026-09-30'),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('consultant view stays read-only', () {
|
||||
expect(canEdit(isConsultantView: true, diaryDate: '2026-09-30'), isFalse);
|
||||
});
|
||||
}
|
||||
@@ -25,16 +25,25 @@ import 'package:mockito/mockito.dart';
|
||||
|
||||
import 'diary_screen_smoke_test.mocks.dart';
|
||||
|
||||
@GenerateNiceMocks([MockSpec<DiaryCubit>(), MockSpec<ConsultantCommentsCubit>()])
|
||||
@GenerateNiceMocks([
|
||||
MockSpec<DiaryCubit>(),
|
||||
MockSpec<ConsultantCommentsCubit>(),
|
||||
])
|
||||
void main() {
|
||||
group('DiaryScreen smoke', () {
|
||||
setUpAll(() async {
|
||||
dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost');
|
||||
dotenv.loadFromString(
|
||||
envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost',
|
||||
);
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
await sl.reset();
|
||||
sl.registerLazySingleton<AppConfig>(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost'));
|
||||
sl.registerLazySingleton<AppConfig>(
|
||||
() =>
|
||||
AppConfig(buildType: AppBuildType.tests)
|
||||
..setBaseUrl('http://localhost'),
|
||||
);
|
||||
});
|
||||
|
||||
Widget wrap(Widget child) {
|
||||
@@ -51,23 +60,53 @@ void main() {
|
||||
);
|
||||
}
|
||||
|
||||
DiaryStateLoaded loadedState({required bool hasChosenPlan}) {
|
||||
DiaryStateLoaded loadedState({
|
||||
required bool hasChosenPlan,
|
||||
String date = '2025-01-01',
|
||||
bool isDiaryFinished = false,
|
||||
bool isDiaryCommented = false,
|
||||
int closed = 0,
|
||||
}) {
|
||||
final diary = DiaryDayEntity(
|
||||
id: 1,
|
||||
date: '2025-01-01',
|
||||
date: date,
|
||||
wakeup: '07:00',
|
||||
retire: '23:00',
|
||||
closed: 0,
|
||||
closed: closed,
|
||||
comment: '',
|
||||
typeOfDiary: TypeOfDiaryModel(hasChosenDiet: false, hasChosenPlan: hasChosenPlan, diaryState: null),
|
||||
meals: const [MealModel(id: 1, time: '08:00', typeNumber: 1, satiety: 0, mood: 0, portions: [], photos: [])],
|
||||
typeOfDiary: TypeOfDiaryModel(
|
||||
hasChosenDiet: false,
|
||||
hasChosenPlan: hasChosenPlan,
|
||||
diaryState: null,
|
||||
),
|
||||
meals: const [
|
||||
MealModel(
|
||||
id: 1,
|
||||
time: '08:00',
|
||||
typeNumber: 1,
|
||||
satiety: 0,
|
||||
mood: 0,
|
||||
portions: [],
|
||||
photos: [],
|
||||
),
|
||||
],
|
||||
chosenPlanId: hasChosenPlan ? 10 : null,
|
||||
dayOfChosenDiet: null,
|
||||
diet: null,
|
||||
);
|
||||
const nutrition = NutritionDataModel(
|
||||
dailyNutritionIntake: NutritionModel(energy: 0, proteins: 0, fats: 0, carbohydrates: 0),
|
||||
dailyNutritionRecommendation: NutritionModel(energy: 0, proteins: 0, fats: 0, carbohydrates: 0),
|
||||
dailyNutritionIntake: NutritionModel(
|
||||
energy: 0,
|
||||
proteins: 0,
|
||||
fats: 0,
|
||||
carbohydrates: 0,
|
||||
),
|
||||
dailyNutritionRecommendation: NutritionModel(
|
||||
energy: 0,
|
||||
proteins: 0,
|
||||
fats: 0,
|
||||
carbohydrates: 0,
|
||||
),
|
||||
);
|
||||
return DiaryState.loaded(
|
||||
diary: diary,
|
||||
@@ -75,8 +114,8 @@ void main() {
|
||||
selectedDay: DateTime(2025, 1, 1),
|
||||
selectedMeal: 1,
|
||||
isConsultantView: false,
|
||||
isDiaryFinished: false,
|
||||
isDiaryCommented: false,
|
||||
isDiaryFinished: isDiaryFinished,
|
||||
isDiaryCommented: isDiaryCommented,
|
||||
isWakeUpAndRetireEnabled: true,
|
||||
isMoodEnabled: true,
|
||||
)
|
||||
@@ -86,13 +125,26 @@ void main() {
|
||||
testWidgets('renders loading state', (tester) async {
|
||||
final diaryCubit = MockDiaryCubit();
|
||||
when(diaryCubit.state).thenReturn(const DiaryState.loading());
|
||||
when(diaryCubit.stream).thenAnswer((_) => const Stream<DiaryState>.empty());
|
||||
when(
|
||||
diaryCubit.stream,
|
||||
).thenAnswer((_) => const Stream<DiaryState>.empty());
|
||||
when(diaryCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(diaryCubit.fetchDiaryV2(needLoading: anyNamed('needLoading'), diaryId: anyNamed('diaryId'))).thenAnswer((_) async => true);
|
||||
when(
|
||||
diaryCubit.fetchDiaryV2(
|
||||
needLoading: anyNamed('needLoading'),
|
||||
diaryId: anyNamed('diaryId'),
|
||||
),
|
||||
).thenAnswer((_) async => true);
|
||||
|
||||
final commentsCubit = MockConsultantCommentsCubit();
|
||||
when(commentsCubit.state).thenReturn(ConsultantCommentsState(commentsModel: ConsultantCommentModel(comment: '')));
|
||||
when(commentsCubit.stream).thenAnswer((_) => const Stream<ConsultantCommentsState>.empty());
|
||||
when(commentsCubit.state).thenReturn(
|
||||
ConsultantCommentsState(
|
||||
commentsModel: ConsultantCommentModel(comment: ''),
|
||||
),
|
||||
);
|
||||
when(
|
||||
commentsCubit.stream,
|
||||
).thenAnswer((_) => const Stream<ConsultantCommentsState>.empty());
|
||||
when(commentsCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
|
||||
sl.registerFactory<DiaryCubit>(() => diaryCubit);
|
||||
@@ -107,13 +159,27 @@ void main() {
|
||||
testWidgets('renders loaded state (chosen plan)', (tester) async {
|
||||
final diaryCubit = MockDiaryCubit();
|
||||
when(diaryCubit.state).thenReturn(loadedState(hasChosenPlan: true));
|
||||
when(diaryCubit.stream).thenAnswer((_) => const Stream<DiaryState>.empty());
|
||||
when(
|
||||
diaryCubit.stream,
|
||||
).thenAnswer((_) => const Stream<DiaryState>.empty());
|
||||
when(diaryCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(diaryCubit.fetchDiaryV2(needLoading: anyNamed('needLoading'), diaryId: anyNamed('diaryId'))).thenAnswer((_) async => true);
|
||||
when(
|
||||
diaryCubit.fetchDiaryV2(
|
||||
needLoading: anyNamed('needLoading'),
|
||||
diaryId: anyNamed('diaryId'),
|
||||
),
|
||||
).thenAnswer((_) async => true);
|
||||
|
||||
final commentsCubit = MockConsultantCommentsCubit();
|
||||
when(commentsCubit.state).thenReturn(ConsultantCommentsState(commentsModel: ConsultantCommentModel(comment: ''), dayCommentNeedsToBeFilled: false));
|
||||
when(commentsCubit.stream).thenAnswer((_) => const Stream<ConsultantCommentsState>.empty());
|
||||
when(commentsCubit.state).thenReturn(
|
||||
ConsultantCommentsState(
|
||||
commentsModel: ConsultantCommentModel(comment: ''),
|
||||
dayCommentNeedsToBeFilled: false,
|
||||
),
|
||||
);
|
||||
when(
|
||||
commentsCubit.stream,
|
||||
).thenAnswer((_) => const Stream<ConsultantCommentsState>.empty());
|
||||
when(commentsCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
|
||||
sl.registerFactory<DiaryCubit>(() => diaryCubit);
|
||||
@@ -124,19 +190,37 @@ void main() {
|
||||
|
||||
expect(find.byType(DiaryScreen), findsOneWidget);
|
||||
|
||||
expect(find.text('Дополнительная информация для консультанта'), findsOneWidget);
|
||||
expect(
|
||||
find.text('Дополнительная информация для консультанта'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('renders error state and reload button', (tester) async {
|
||||
final diaryCubit = MockDiaryCubit();
|
||||
when(diaryCubit.state).thenReturn(const DiaryState.error(errorMessage: 'err'));
|
||||
when(diaryCubit.stream).thenAnswer((_) => const Stream<DiaryState>.empty());
|
||||
when(
|
||||
diaryCubit.state,
|
||||
).thenReturn(const DiaryState.error(errorMessage: 'err'));
|
||||
when(
|
||||
diaryCubit.stream,
|
||||
).thenAnswer((_) => const Stream<DiaryState>.empty());
|
||||
when(diaryCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(diaryCubit.fetchDiaryV2(needLoading: anyNamed('needLoading'), diaryId: anyNamed('diaryId'))).thenAnswer((_) async => true);
|
||||
when(
|
||||
diaryCubit.fetchDiaryV2(
|
||||
needLoading: anyNamed('needLoading'),
|
||||
diaryId: anyNamed('diaryId'),
|
||||
),
|
||||
).thenAnswer((_) async => true);
|
||||
|
||||
final commentsCubit = MockConsultantCommentsCubit();
|
||||
when(commentsCubit.state).thenReturn(ConsultantCommentsState(commentsModel: ConsultantCommentModel(comment: '')));
|
||||
when(commentsCubit.stream).thenAnswer((_) => const Stream<ConsultantCommentsState>.empty());
|
||||
when(commentsCubit.state).thenReturn(
|
||||
ConsultantCommentsState(
|
||||
commentsModel: ConsultantCommentModel(comment: ''),
|
||||
),
|
||||
);
|
||||
when(
|
||||
commentsCubit.stream,
|
||||
).thenAnswer((_) => const Stream<ConsultantCommentsState>.empty());
|
||||
when(commentsCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
|
||||
sl.registerFactory<DiaryCubit>(() => diaryCubit);
|
||||
@@ -153,19 +237,52 @@ void main() {
|
||||
final diaryCubit = MockDiaryCubit();
|
||||
const futureDiary = DiaryFutureDietDayEntity(
|
||||
date: '2025-01-02',
|
||||
typeOfDiary: TypeOfDiaryModel(hasChosenDiet: true, hasChosenPlan: false, diaryState: null),
|
||||
meals: [MealModel(id: 1, time: '08:00', typeNumber: 1, satiety: 0, mood: 0, portions: [], photos: [])],
|
||||
typeOfDiary: TypeOfDiaryModel(
|
||||
hasChosenDiet: true,
|
||||
hasChosenPlan: false,
|
||||
diaryState: null,
|
||||
),
|
||||
meals: [
|
||||
MealModel(
|
||||
id: 1,
|
||||
time: '08:00',
|
||||
typeNumber: 1,
|
||||
satiety: 0,
|
||||
mood: 0,
|
||||
portions: [],
|
||||
photos: [],
|
||||
),
|
||||
],
|
||||
diet: null,
|
||||
dayOfChosenDiet: 1,
|
||||
);
|
||||
when(diaryCubit.state).thenReturn(DiaryState.loadedTomorrowWithDiet(diary: futureDiary, selectedDay: DateTime(2025, 1, 2), selectedMeal: 1));
|
||||
when(diaryCubit.stream).thenAnswer((_) => const Stream<DiaryState>.empty());
|
||||
when(diaryCubit.state).thenReturn(
|
||||
DiaryState.loadedTomorrowWithDiet(
|
||||
diary: futureDiary,
|
||||
selectedDay: DateTime(2025, 1, 2),
|
||||
selectedMeal: 1,
|
||||
),
|
||||
);
|
||||
when(
|
||||
diaryCubit.stream,
|
||||
).thenAnswer((_) => const Stream<DiaryState>.empty());
|
||||
when(diaryCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(diaryCubit.fetchDiaryV2(needLoading: anyNamed('needLoading'), diaryId: anyNamed('diaryId'))).thenAnswer((_) async => true);
|
||||
when(
|
||||
diaryCubit.fetchDiaryV2(
|
||||
needLoading: anyNamed('needLoading'),
|
||||
diaryId: anyNamed('diaryId'),
|
||||
),
|
||||
).thenAnswer((_) async => true);
|
||||
|
||||
final commentsCubit = MockConsultantCommentsCubit();
|
||||
when(commentsCubit.state).thenReturn(ConsultantCommentsState(commentsModel: ConsultantCommentModel(comment: '')));
|
||||
when(commentsCubit.stream).thenAnswer((_) => const Stream<ConsultantCommentsState>.empty());
|
||||
when(commentsCubit.state).thenReturn(
|
||||
ConsultantCommentsState(
|
||||
commentsModel: ConsultantCommentModel(comment: ''),
|
||||
),
|
||||
);
|
||||
when(
|
||||
commentsCubit.stream,
|
||||
).thenAnswer((_) => const Stream<ConsultantCommentsState>.empty());
|
||||
when(commentsCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
|
||||
sl.registerFactory<DiaryCubit>(() => diaryCubit);
|
||||
@@ -187,17 +304,35 @@ void main() {
|
||||
planName: 'Plan',
|
||||
planDuration: 30,
|
||||
currentPlanDay: 1,
|
||||
consultantComments: ConsultantComments(dayComment: 'Nice', mealsComments: const [], portionsComments: const []),
|
||||
consultantComments: ConsultantComments(
|
||||
dayComment: 'Nice',
|
||||
mealsComments: const [],
|
||||
portionsComments: const [],
|
||||
),
|
||||
),
|
||||
);
|
||||
when(diaryCubit.state).thenReturn(state);
|
||||
when(diaryCubit.stream).thenAnswer((_) => const Stream<DiaryState>.empty());
|
||||
when(
|
||||
diaryCubit.stream,
|
||||
).thenAnswer((_) => const Stream<DiaryState>.empty());
|
||||
when(diaryCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(diaryCubit.fetchDiaryV2(needLoading: anyNamed('needLoading'), diaryId: anyNamed('diaryId'))).thenAnswer((_) async => true);
|
||||
when(
|
||||
diaryCubit.fetchDiaryV2(
|
||||
needLoading: anyNamed('needLoading'),
|
||||
diaryId: anyNamed('diaryId'),
|
||||
),
|
||||
).thenAnswer((_) async => true);
|
||||
|
||||
final commentsCubit = MockConsultantCommentsCubit();
|
||||
when(commentsCubit.state).thenReturn(ConsultantCommentsState(commentsModel: ConsultantCommentModel(comment: ''), dayCommentNeedsToBeFilled: false));
|
||||
when(commentsCubit.stream).thenAnswer((_) => const Stream<ConsultantCommentsState>.empty());
|
||||
when(commentsCubit.state).thenReturn(
|
||||
ConsultantCommentsState(
|
||||
commentsModel: ConsultantCommentModel(comment: ''),
|
||||
dayCommentNeedsToBeFilled: false,
|
||||
),
|
||||
);
|
||||
when(
|
||||
commentsCubit.stream,
|
||||
).thenAnswer((_) => const Stream<ConsultantCommentsState>.empty());
|
||||
when(commentsCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(commentsCubit.submitComments(any)).thenAnswer((_) async => true);
|
||||
|
||||
@@ -215,13 +350,26 @@ void main() {
|
||||
final diaryCubit = MockDiaryCubit();
|
||||
final state = loadedState(hasChosenPlan: false);
|
||||
when(diaryCubit.state).thenReturn(state);
|
||||
when(diaryCubit.stream).thenAnswer((_) => const Stream<DiaryState>.empty());
|
||||
when(
|
||||
diaryCubit.stream,
|
||||
).thenAnswer((_) => const Stream<DiaryState>.empty());
|
||||
when(diaryCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(diaryCubit.fetchDiaryV2(needLoading: anyNamed('needLoading'), diaryId: anyNamed('diaryId'))).thenAnswer((_) async => true);
|
||||
when(
|
||||
diaryCubit.fetchDiaryV2(
|
||||
needLoading: anyNamed('needLoading'),
|
||||
diaryId: anyNamed('diaryId'),
|
||||
),
|
||||
).thenAnswer((_) async => true);
|
||||
|
||||
final commentsCubit = MockConsultantCommentsCubit();
|
||||
when(commentsCubit.state).thenReturn(ConsultantCommentsState(commentsModel: ConsultantCommentModel(comment: '')));
|
||||
when(commentsCubit.stream).thenAnswer((_) => const Stream<ConsultantCommentsState>.empty());
|
||||
when(commentsCubit.state).thenReturn(
|
||||
ConsultantCommentsState(
|
||||
commentsModel: ConsultantCommentModel(comment: ''),
|
||||
),
|
||||
);
|
||||
when(
|
||||
commentsCubit.stream,
|
||||
).thenAnswer((_) => const Stream<ConsultantCommentsState>.empty());
|
||||
when(commentsCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
|
||||
sl.registerFactory<DiaryCubit>(() => diaryCubit);
|
||||
@@ -234,5 +382,96 @@ void main() {
|
||||
|
||||
expect(find.byType(Text), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('shows add button for a finished day inside the edit window', (
|
||||
tester,
|
||||
) async {
|
||||
final yesterday = DateTime.now().subtract(const Duration(days: 1));
|
||||
final diaryCubit = MockDiaryCubit();
|
||||
when(diaryCubit.state).thenReturn(
|
||||
loadedState(
|
||||
hasChosenPlan: false,
|
||||
date: _calendarDate(yesterday),
|
||||
isDiaryFinished: true,
|
||||
),
|
||||
);
|
||||
when(
|
||||
diaryCubit.stream,
|
||||
).thenAnswer((_) => const Stream<DiaryState>.empty());
|
||||
when(diaryCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(
|
||||
diaryCubit.fetchDiaryV2(
|
||||
needLoading: anyNamed('needLoading'),
|
||||
diaryId: anyNamed('diaryId'),
|
||||
),
|
||||
).thenAnswer((_) async => true);
|
||||
|
||||
final commentsCubit = MockConsultantCommentsCubit();
|
||||
when(commentsCubit.state).thenReturn(
|
||||
ConsultantCommentsState(
|
||||
commentsModel: ConsultantCommentModel(comment: ''),
|
||||
),
|
||||
);
|
||||
when(
|
||||
commentsCubit.stream,
|
||||
).thenAnswer((_) => const Stream<ConsultantCommentsState>.empty());
|
||||
when(commentsCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
|
||||
sl.registerFactory<DiaryCubit>(() => diaryCubit);
|
||||
sl.registerFactory<ConsultantCommentsCubit>(() => commentsCubit);
|
||||
|
||||
await tester.pumpWidget(wrap(DiaryScreen(date: yesterday)));
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
|
||||
expect(find.text('Добавить'), findsOneWidget);
|
||||
expect(find.text('Закончить день'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('hides add button for a commented day', (tester) async {
|
||||
final yesterday = DateTime.now().subtract(const Duration(days: 1));
|
||||
final diaryCubit = MockDiaryCubit();
|
||||
when(diaryCubit.state).thenReturn(
|
||||
loadedState(
|
||||
hasChosenPlan: false,
|
||||
date: _calendarDate(yesterday),
|
||||
isDiaryCommented: true,
|
||||
),
|
||||
);
|
||||
when(
|
||||
diaryCubit.stream,
|
||||
).thenAnswer((_) => const Stream<DiaryState>.empty());
|
||||
when(diaryCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(
|
||||
diaryCubit.fetchDiaryV2(
|
||||
needLoading: anyNamed('needLoading'),
|
||||
diaryId: anyNamed('diaryId'),
|
||||
),
|
||||
).thenAnswer((_) async => true);
|
||||
|
||||
final commentsCubit = MockConsultantCommentsCubit();
|
||||
when(commentsCubit.state).thenReturn(
|
||||
ConsultantCommentsState(
|
||||
commentsModel: ConsultantCommentModel(comment: ''),
|
||||
),
|
||||
);
|
||||
when(
|
||||
commentsCubit.stream,
|
||||
).thenAnswer((_) => const Stream<ConsultantCommentsState>.empty());
|
||||
when(commentsCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
|
||||
sl.registerFactory<DiaryCubit>(() => diaryCubit);
|
||||
sl.registerFactory<ConsultantCommentsCubit>(() => commentsCubit);
|
||||
|
||||
await tester.pumpWidget(wrap(DiaryScreen(date: yesterday)));
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
|
||||
expect(find.text('Добавить'), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
String _calendarDate(DateTime date) {
|
||||
final month = date.month.toString().padLeft(2, '0');
|
||||
final day = date.day.toString().padLeft(2, '0');
|
||||
return '${date.year}-$month-$day';
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
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/entities/diary_day_entity.dart';
|
||||
@@ -27,7 +26,8 @@ class FakeDiaryCubit extends Cubit<DiaryState> implements DiaryCubit {
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class FakeConsultantCommentsCubit extends Cubit<ConsultantCommentsState> implements ConsultantCommentsCubit {
|
||||
class FakeConsultantCommentsCubit extends Cubit<ConsultantCommentsState>
|
||||
implements ConsultantCommentsCubit {
|
||||
FakeConsultantCommentsCubit(super.initialState);
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
@@ -60,9 +60,16 @@ void main() {
|
||||
</svg>
|
||||
''';
|
||||
|
||||
final bundle = _FakeAssetBundle({'assets/icons/revert_product.svg': fakeSvg, 'assets/icons/edit.svg': fakeSvg});
|
||||
final bundle = _FakeAssetBundle({
|
||||
'assets/icons/revert_product.svg': fakeSvg,
|
||||
'assets/icons/edit.svg': fakeSvg,
|
||||
});
|
||||
|
||||
Widget wrap({required DiaryCubit diaryCubit, required ConsultantCommentsCubit consultantCommentsCubit, required Widget child}) {
|
||||
Widget wrap({
|
||||
required DiaryCubit diaryCubit,
|
||||
required ConsultantCommentsCubit consultantCommentsCubit,
|
||||
required Widget child,
|
||||
}) {
|
||||
return DefaultAssetBundle(
|
||||
bundle: bundle,
|
||||
child: MaterialApp(
|
||||
@@ -77,7 +84,9 @@ void main() {
|
||||
home: MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider<DiaryCubit>.value(value: diaryCubit),
|
||||
BlocProvider<ConsultantCommentsCubit>.value(value: consultantCommentsCubit),
|
||||
BlocProvider<ConsultantCommentsCubit>.value(
|
||||
value: consultantCommentsCubit,
|
||||
),
|
||||
],
|
||||
child: Scaffold(body: child),
|
||||
),
|
||||
@@ -85,17 +94,36 @@ void main() {
|
||||
);
|
||||
}
|
||||
|
||||
DiaryStateLoaded loadedState({required PortionModel portion, required bool consultantView}) {
|
||||
DiaryStateLoaded loadedState({
|
||||
required PortionModel portion,
|
||||
required bool consultantView,
|
||||
String date = '2025-01-01',
|
||||
bool isDiaryFinished = false,
|
||||
bool isDiaryCommented = false,
|
||||
int closed = 0,
|
||||
}) {
|
||||
final diary = DiaryDayEntity(
|
||||
id: 1,
|
||||
date: '2025-01-01',
|
||||
date: date,
|
||||
wakeup: '07:00',
|
||||
retire: '23:00',
|
||||
closed: 0,
|
||||
closed: closed,
|
||||
comment: '',
|
||||
typeOfDiary: const TypeOfDiaryModel(hasChosenDiet: false, hasChosenPlan: true, diaryState: DiaryStateEnum.started),
|
||||
typeOfDiary: const TypeOfDiaryModel(
|
||||
hasChosenDiet: false,
|
||||
hasChosenPlan: true,
|
||||
diaryState: DiaryStateEnum.started,
|
||||
),
|
||||
meals: [
|
||||
MealModel(id: 1, time: '08:00', typeNumber: 1, satiety: 0, mood: 0, portions: [portion], photos: const []),
|
||||
MealModel(
|
||||
id: 1,
|
||||
time: '08:00',
|
||||
typeNumber: 1,
|
||||
satiety: 0,
|
||||
mood: 0,
|
||||
portions: [portion],
|
||||
photos: const [],
|
||||
),
|
||||
],
|
||||
chosenPlanId: 1,
|
||||
dayOfChosenDiet: null,
|
||||
@@ -103,8 +131,18 @@ void main() {
|
||||
);
|
||||
|
||||
const nutrition = NutritionDataModel(
|
||||
dailyNutritionIntake: NutritionModel(energy: 0, proteins: 0, fats: 0, carbohydrates: 0),
|
||||
dailyNutritionRecommendation: NutritionModel(energy: 0, proteins: 0, fats: 0, carbohydrates: 0),
|
||||
dailyNutritionIntake: NutritionModel(
|
||||
energy: 0,
|
||||
proteins: 0,
|
||||
fats: 0,
|
||||
carbohydrates: 0,
|
||||
),
|
||||
dailyNutritionRecommendation: NutritionModel(
|
||||
energy: 0,
|
||||
proteins: 0,
|
||||
fats: 0,
|
||||
carbohydrates: 0,
|
||||
),
|
||||
);
|
||||
|
||||
return DiaryState.loaded(
|
||||
@@ -113,13 +151,17 @@ void main() {
|
||||
selectedDay: DateTime(2025, 1, 1),
|
||||
selectedMeal: 1,
|
||||
isConsultantView: consultantView,
|
||||
isDiaryFinished: false,
|
||||
isDiaryCommented: false,
|
||||
isDiaryFinished: isDiaryFinished,
|
||||
isDiaryCommented: isDiaryCommented,
|
||||
consultantReview: ConsultantReviewModel(
|
||||
planName: 'Plan',
|
||||
planDuration: 10,
|
||||
currentPlanDay: 1,
|
||||
consultantComments: ConsultantComments(dayComment: null, mealsComments: const [], portionsComments: const []),
|
||||
consultantComments: ConsultantComments(
|
||||
dayComment: null,
|
||||
mealsComments: const [],
|
||||
portionsComments: const [],
|
||||
),
|
||||
),
|
||||
)
|
||||
as DiaryStateLoaded;
|
||||
@@ -140,9 +182,21 @@ void main() {
|
||||
DiaryState.loadedTomorrowWithDiet(
|
||||
diary: const DiaryFutureDietDayEntity(
|
||||
date: '2025-01-02',
|
||||
typeOfDiary: TypeOfDiaryModel(hasChosenDiet: true, hasChosenPlan: false, diaryState: null),
|
||||
typeOfDiary: TypeOfDiaryModel(
|
||||
hasChosenDiet: true,
|
||||
hasChosenPlan: false,
|
||||
diaryState: null,
|
||||
),
|
||||
meals: [
|
||||
MealModel(id: 1, time: '08:00', typeNumber: 1, satiety: 0, mood: 0, portions: [portion], photos: []),
|
||||
MealModel(
|
||||
id: 1,
|
||||
time: '08:00',
|
||||
typeNumber: 1,
|
||||
satiety: 0,
|
||||
mood: 0,
|
||||
portions: [portion],
|
||||
photos: [],
|
||||
),
|
||||
],
|
||||
diet: null,
|
||||
dayOfChosenDiet: 1,
|
||||
@@ -151,7 +205,11 @@ void main() {
|
||||
selectedMeal: 1,
|
||||
),
|
||||
);
|
||||
final commentsCubit = FakeConsultantCommentsCubit(ConsultantCommentsState(commentsModel: ConsultantCommentModel(comment: '')));
|
||||
final commentsCubit = FakeConsultantCommentsCubit(
|
||||
ConsultantCommentsState(
|
||||
commentsModel: ConsultantCommentModel(comment: ''),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
wrap(
|
||||
@@ -177,8 +235,14 @@ void main() {
|
||||
fromDiet: false,
|
||||
portionState: PortionState.eatenDeleted,
|
||||
);
|
||||
final diaryCubit = FakeDiaryCubit(loadedState(portion: portion, consultantView: false));
|
||||
final commentsCubit = FakeConsultantCommentsCubit(ConsultantCommentsState(commentsModel: ConsultantCommentModel(comment: '')));
|
||||
final diaryCubit = FakeDiaryCubit(
|
||||
loadedState(portion: portion, consultantView: false),
|
||||
);
|
||||
final commentsCubit = FakeConsultantCommentsCubit(
|
||||
ConsultantCommentsState(
|
||||
commentsModel: ConsultantCommentModel(comment: ''),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
wrap(
|
||||
@@ -194,32 +258,89 @@ void main() {
|
||||
expect(find.byType(ProductWidget), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('consultant view toggles comment widget and shows existing comment container', (tester) async {
|
||||
testWidgets(
|
||||
'consultant view toggles comment widget and shows existing comment container',
|
||||
(tester) async {
|
||||
const portion = PortionModel(
|
||||
id: 1,
|
||||
productNutritionalValueId: null,
|
||||
productCaloriesInfo: null,
|
||||
productName: 'Portion',
|
||||
quantity: 100,
|
||||
portion: 'gr',
|
||||
fromDiet: false,
|
||||
portionState: PortionState.filledDiet,
|
||||
);
|
||||
final state = loadedState(portion: portion, consultantView: true)
|
||||
.copyWith(
|
||||
consultantReview: ConsultantReviewModel(
|
||||
planName: 'Plan',
|
||||
planDuration: 10,
|
||||
currentPlanDay: 1,
|
||||
consultantComments: ConsultantComments(
|
||||
dayComment: null,
|
||||
mealsComments: const [],
|
||||
portionsComments: [
|
||||
PortionComment(portionId: 1, comment: 'Looks good'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final diaryCubit = FakeDiaryCubit(state);
|
||||
final commentsCubit = FakeConsultantCommentsCubit(
|
||||
ConsultantCommentsState(
|
||||
commentsModel: ConsultantCommentModel(comment: ''),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
wrap(
|
||||
diaryCubit: diaryCubit,
|
||||
consultantCommentsCubit: commentsCubit,
|
||||
child: const ProductWidget(portion: portion),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(CommentContainer), findsOneWidget);
|
||||
expect(find.text('Looks good'), findsOneWidget);
|
||||
|
||||
// Tap edit icon to enable PortionCommentWidget.
|
||||
await tester.tap(find.byType(GestureDetector).first);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(PortionCommentWidget), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('can edit a portion on a finished day inside the edit window', (
|
||||
tester,
|
||||
) async {
|
||||
const portion = PortionModel(
|
||||
id: 1,
|
||||
productNutritionalValueId: null,
|
||||
productCaloriesInfo: null,
|
||||
productName: 'Portion',
|
||||
productName: 'Apple',
|
||||
quantity: 100,
|
||||
portion: 'gr',
|
||||
fromDiet: false,
|
||||
portionState: PortionState.filledDiet,
|
||||
portionState: PortionState.eaten,
|
||||
);
|
||||
final state = loadedState(portion: portion, consultantView: true).copyWith(
|
||||
consultantReview: ConsultantReviewModel(
|
||||
planName: 'Plan',
|
||||
planDuration: 10,
|
||||
currentPlanDay: 1,
|
||||
consultantComments: ConsultantComments(
|
||||
dayComment: null,
|
||||
mealsComments: const [],
|
||||
portionsComments: [PortionComment(portionId: 1, comment: 'Looks good')],
|
||||
),
|
||||
final yesterday = DateTime.now().subtract(const Duration(days: 1));
|
||||
final diaryCubit = FakeDiaryCubit(
|
||||
loadedState(
|
||||
portion: portion,
|
||||
consultantView: false,
|
||||
date: _calendarDate(yesterday),
|
||||
isDiaryFinished: true,
|
||||
),
|
||||
);
|
||||
final commentsCubit = FakeConsultantCommentsCubit(
|
||||
ConsultantCommentsState(
|
||||
commentsModel: ConsultantCommentModel(comment: ''),
|
||||
),
|
||||
);
|
||||
|
||||
final diaryCubit = FakeDiaryCubit(state);
|
||||
final commentsCubit = FakeConsultantCommentsCubit(ConsultantCommentsState(commentsModel: ConsultantCommentModel(comment: '')));
|
||||
|
||||
await tester.pumpWidget(
|
||||
wrap(
|
||||
@@ -230,15 +351,51 @@ void main() {
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(tester.widget<ListTile>(find.byType(ListTile)).onTap, isNotNull);
|
||||
});
|
||||
|
||||
expect(find.byType(CommentContainer), findsOneWidget);
|
||||
expect(find.text('Looks good'), findsOneWidget);
|
||||
testWidgets('cannot edit a portion on a commented day', (tester) async {
|
||||
const portion = PortionModel(
|
||||
id: 1,
|
||||
productNutritionalValueId: null,
|
||||
productCaloriesInfo: null,
|
||||
productName: 'Apple',
|
||||
quantity: 100,
|
||||
portion: 'gr',
|
||||
fromDiet: false,
|
||||
portionState: PortionState.eaten,
|
||||
);
|
||||
final yesterday = DateTime.now().subtract(const Duration(days: 1));
|
||||
final diaryCubit = FakeDiaryCubit(
|
||||
loadedState(
|
||||
portion: portion,
|
||||
consultantView: false,
|
||||
date: _calendarDate(yesterday),
|
||||
isDiaryCommented: true,
|
||||
),
|
||||
);
|
||||
final commentsCubit = FakeConsultantCommentsCubit(
|
||||
ConsultantCommentsState(
|
||||
commentsModel: ConsultantCommentModel(comment: ''),
|
||||
),
|
||||
);
|
||||
|
||||
// Tap edit icon to enable PortionCommentWidget.
|
||||
await tester.tap(find.byType(GestureDetector).first);
|
||||
await tester.pumpWidget(
|
||||
wrap(
|
||||
diaryCubit: diaryCubit,
|
||||
consultantCommentsCubit: commentsCubit,
|
||||
child: const ProductWidget(portion: portion),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(PortionCommentWidget), findsOneWidget);
|
||||
expect(tester.widget<ListTile>(find.byType(ListTile)).onTap, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
String _calendarDate(DateTime date) {
|
||||
final month = date.month.toString().padLeft(2, '0');
|
||||
final day = date.day.toString().padLeft(2, '0');
|
||||
return '${date.year}-$month-$day';
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ void main() {
|
||||
);
|
||||
const state = NotificationSettingsStateLoaded(notificationSettings: settings);
|
||||
|
||||
expect(state.props, [settings]);
|
||||
expect(state.props, [settings, null, null]);
|
||||
expect(state.notificationSettings, settings);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/features/menu/presentation/bloc/notification_settings_cubit/notification_settings_cubit.dart';
|
||||
import 'package:eatvio_app/features/menu/presentation/bloc/notification_settings_cubit/notification_settings_state.dart';
|
||||
import 'package:eatvio_app/features/menu/repositories/notification_settings_repository.dart';
|
||||
@@ -103,6 +104,51 @@ void main() {
|
||||
final errorState = notificationSettingsCubit!.state as NotificationSettingsStateError;
|
||||
expect(errorState.errorMessage, contains('Ошибка обновления'));
|
||||
});
|
||||
|
||||
test('updateNotificationSettings - validation error keeps the screen and exposes an alert', () async {
|
||||
notificationSettingsCubit = NotificationSettingsCubit(
|
||||
notificationSettingsRepository: mockNotificationSettingsRepository,
|
||||
);
|
||||
when(mockNotificationSettingsRepository.getNotificationSettings()).thenAnswer((_) async => MenuFixtures.notificationSettingsModel());
|
||||
await notificationSettingsCubit!.getNotificationSettings();
|
||||
|
||||
const message = 'Между приемами пищи должна быть разница минимум час';
|
||||
when(mockNotificationSettingsRepository.updateNotificationSettings(any)).thenThrow(
|
||||
DioException(
|
||||
requestOptions: RequestOptions(path: '/api/profile/update_notification_settings'),
|
||||
type: DioExceptionType.badResponse,
|
||||
response: Response(
|
||||
requestOptions: RequestOptions(path: '/api/profile/update_notification_settings'),
|
||||
statusCode: 422,
|
||||
data: {
|
||||
'message': message,
|
||||
'errors': {
|
||||
'diary_reminder_notifications.second_reminder': [message],
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await notificationSettingsCubit!.updateNotificationSettings({
|
||||
'diary_reminder_notifications': {
|
||||
'second_reminder': {'enabled': true, 'local_time_hours': 9, 'local_time_mins': 30},
|
||||
},
|
||||
});
|
||||
|
||||
expect(notificationSettingsCubit!.state, isA<NotificationSettingsStateLoaded>());
|
||||
final loadedState = notificationSettingsCubit!.state as NotificationSettingsStateLoaded;
|
||||
expect(loadedState.alertMessage, message);
|
||||
expect(loadedState.notificationSettings.newUpdatesNotification, true);
|
||||
|
||||
when(mockNotificationSettingsRepository.updateNotificationSettings(any)).thenThrow(Exception('сеть'));
|
||||
await notificationSettingsCubit!.updateNotificationSettings({'new_updates_notification': false});
|
||||
|
||||
final retried = notificationSettingsCubit!.state as NotificationSettingsStateLoaded;
|
||||
expect(retried.alertMessage, 'Не удалось сохранить настройки уведомлений');
|
||||
expect(retried.alertId, isNot(loadedState.alertId));
|
||||
expect(retried.notificationSettings, loadedState.notificationSettings);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/features/menu/presentation/notification_settings_user_message.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('notificationSettingsUpdateAlertMessage', () {
|
||||
test('uses the Laravel validation message for a meal reminder gap', () {
|
||||
const message = 'Между приемами пищи должна быть разница минимум час';
|
||||
final error = DioException(
|
||||
requestOptions: RequestOptions(
|
||||
path: '/api/profile/update_notification_settings',
|
||||
),
|
||||
type: DioExceptionType.badResponse,
|
||||
response: Response(
|
||||
requestOptions: RequestOptions(
|
||||
path: '/api/profile/update_notification_settings',
|
||||
),
|
||||
statusCode: 422,
|
||||
data: {
|
||||
'message': message,
|
||||
'errors': {
|
||||
'diary_reminder_notifications.second_reminder': [message],
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(notificationSettingsUpdateAlertMessage(error), message);
|
||||
});
|
||||
|
||||
test('uses a short message for other failures', () {
|
||||
expect(
|
||||
notificationSettingsUpdateAlertMessage(Exception('stack')),
|
||||
notificationSettingsSaveFailedMessage,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/app_initializer.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
@@ -7,6 +8,7 @@ import 'package:eatvio_app/features/menu/presentation/bloc/notification_settings
|
||||
import 'package:eatvio_app/features/menu/presentation/pages/notifications_settings_screen.dart';
|
||||
import 'package:eatvio_app/features/menu/repositories/notification_settings_repository.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
@@ -111,5 +113,55 @@ void main() {
|
||||
expect(find.textContaining('Ошибка'), findsOneWidget);
|
||||
expect(find.text('Повторить'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows an alert instead of replacing the screen when reminder times are too close', (tester) async {
|
||||
tester.view.physicalSize = const Size(800, 1600);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(() => tester.view.resetPhysicalSize());
|
||||
|
||||
const message = 'Между приемами пищи должна быть разница минимум час';
|
||||
final repository = MockNotificationSettingsRepository();
|
||||
final cubit = NotificationSettingsCubit(notificationSettingsRepository: repository);
|
||||
|
||||
when(repository.getNotificationSettings()).thenAnswer((_) async => mockNotificationSettings);
|
||||
when(repository.updateNotificationSettings(any)).thenThrow(
|
||||
DioException(
|
||||
requestOptions: RequestOptions(path: '/api/profile/update_notification_settings'),
|
||||
type: DioExceptionType.badResponse,
|
||||
response: Response(
|
||||
requestOptions: RequestOptions(path: '/api/profile/update_notification_settings'),
|
||||
statusCode: 422,
|
||||
data: {
|
||||
'message': message,
|
||||
'errors': {
|
||||
'diary_reminder_notifications.second_reminder': [message],
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
sl.registerLazySingleton<NotificationSettingsRepository>(() => repository);
|
||||
sl.registerLazySingleton<NotificationSettingsCubit>(() => cubit);
|
||||
|
||||
await tester.pumpWidget(wrap(const NotificationsSettingsScreen()));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.text('Новые обновления'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byType(CupertinoSwitch).first);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
expect(find.text(message), findsOneWidget);
|
||||
expect(find.text('Новые обновления'), findsOneWidget);
|
||||
expect(find.text('Повторить'), findsNothing);
|
||||
|
||||
await tester.tap(find.text('OK'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Новые обновления'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart';
|
||||
import 'package:eatvio_app/features/video_calls/models/video_call_models.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_foreground_coordinator.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_lifecycle_binding.dart';
|
||||
import 'package:eatvio_app/features/video_calls/repositories/video_call_repository.dart';
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:livekit_client/livekit_client.dart' as lk;
|
||||
@@ -53,6 +56,9 @@ void main() {
|
||||
mockRoom = MockRoom();
|
||||
when(mockRoom.connectionState).thenReturn(lk.ConnectionState.connected);
|
||||
when(mockRoom.localParticipant).thenReturn(null);
|
||||
when(
|
||||
mockRoom.remoteParticipants,
|
||||
).thenReturn(UnmodifiableMapView(<String, lk.RemoteParticipant>{}));
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
@@ -77,6 +83,47 @@ void main() {
|
||||
expect(foreground.startCount, 0);
|
||||
});
|
||||
|
||||
test(
|
||||
'decline right after hang up does not notify the backend again',
|
||||
() async {
|
||||
manager.markActiveCallForTesting(mockRoom);
|
||||
|
||||
await manager.endCall();
|
||||
clearInteractions(mockRepository);
|
||||
|
||||
await manager.declineIncomingCall('test-uuid');
|
||||
|
||||
verifyNever(mockRepository.notifyCallEnded(any));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'startCall right after hang up does not request a new token',
|
||||
() async {
|
||||
manager.markActiveCallForTesting(mockRoom);
|
||||
await manager.endCall();
|
||||
clearInteractions(mockRepository);
|
||||
|
||||
final started = await manager.startCall(conversationUuid: 'test-uuid');
|
||||
|
||||
expect(started, isFalse);
|
||||
verifyNever(mockRepository.getParticipantToken(any));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'decline of a call that was never joined notifies the backend',
|
||||
() async {
|
||||
when(
|
||||
mockRepository.notifyCallEnded(any),
|
||||
).thenAnswer((_) async => const VideoCallEndedResponse(sent: true));
|
||||
|
||||
await manager.declineIncomingCall('other-uuid');
|
||||
|
||||
verify(mockRepository.notifyCallEnded('other-uuid')).called(1);
|
||||
},
|
||||
);
|
||||
|
||||
test('endCall stops foreground service', () async {
|
||||
manager.markActiveCallForTesting(mockRoom);
|
||||
|
||||
|
||||
@@ -124,6 +124,36 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test('force end from the other participant closes the call', () async {
|
||||
when(mockManager.callEndedNotified).thenReturn(false);
|
||||
when(mockManager.endCall()).thenAnswer((_) async {});
|
||||
cubit.emit(
|
||||
const VideoCallSessionState(
|
||||
conversationUuid: 'uuid',
|
||||
connectPhase: VideoCallConnectPhase.connected,
|
||||
callStageEntered: true,
|
||||
uiMode: VideoCallUiMode.fullscreen,
|
||||
),
|
||||
);
|
||||
|
||||
managerListener(
|
||||
const VideoCallManagerSnapshot(
|
||||
conversationUuid: 'uuid',
|
||||
micEnabled: true,
|
||||
videoEnabled: true,
|
||||
connectPhase: VideoCallConnectPhase.connected,
|
||||
linkIssue: VideoCallLinkIssue.none,
|
||||
forceEndCall: true,
|
||||
),
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
verify(mockManager.endCall()).called(1);
|
||||
expect(cubit.state.connectPhase, VideoCallConnectPhase.idle);
|
||||
expect(cubit.state.callStageEntered, isFalse);
|
||||
expect(cubit.state.uiMode, VideoCallUiMode.hidden);
|
||||
});
|
||||
|
||||
test('isMicActiveForUi mirrors micEnabled', () {
|
||||
const state = VideoCallSessionState(micEnabled: true);
|
||||
expect(state.isMicActiveForUi, isTrue);
|
||||
@@ -145,6 +175,18 @@ void main() {
|
||||
});
|
||||
|
||||
test('syncPipUiStateFromSystem skips overlay when call screen is visible', () async {
|
||||
final mockRoom = MockRoom();
|
||||
when(mockManager.room).thenReturn(mockRoom);
|
||||
when(
|
||||
mockManager.onCallScreenVisible(
|
||||
conversationUuid: anyNamed('conversationUuid'),
|
||||
counterpartName: anyNamed('counterpartName'),
|
||||
localParticipantName: anyNamed('localParticipantName'),
|
||||
micEnabled: anyNamed('micEnabled'),
|
||||
videoEnabled: anyNamed('videoEnabled'),
|
||||
),
|
||||
).thenAnswer((_) async => true);
|
||||
|
||||
cubit.emit(
|
||||
const VideoCallSessionState(
|
||||
conversationUuid: 'uuid',
|
||||
@@ -169,7 +211,7 @@ void main() {
|
||||
micEnabled: anyNamed('micEnabled'),
|
||||
videoEnabled: anyNamed('videoEnabled'),
|
||||
),
|
||||
).thenAnswer((_) async {});
|
||||
).thenAnswer((_) async => true);
|
||||
|
||||
await cubit.onCallScreenVisible(
|
||||
conversationUuid: 'uuid',
|
||||
@@ -201,6 +243,7 @@ void main() {
|
||||
linkIssue: VideoCallLinkIssue.none,
|
||||
),
|
||||
);
|
||||
return true;
|
||||
});
|
||||
when(mockManager.onEnterCallStage()).thenAnswer((_) async => true);
|
||||
|
||||
@@ -232,7 +275,7 @@ void main() {
|
||||
micEnabled: anyNamed('micEnabled'),
|
||||
videoEnabled: anyNamed('videoEnabled'),
|
||||
),
|
||||
).thenAnswer((_) async {});
|
||||
).thenAnswer((_) async => true);
|
||||
when(mockManager.ensureLobbyRoomJoined()).thenAnswer((_) async {});
|
||||
when(mockManager.onEnterCallStage()).thenAnswer((_) async => true);
|
||||
|
||||
@@ -267,7 +310,7 @@ void main() {
|
||||
micEnabled: anyNamed('micEnabled'),
|
||||
videoEnabled: anyNamed('videoEnabled'),
|
||||
),
|
||||
).thenAnswer((_) async {});
|
||||
).thenAnswer((_) async => true);
|
||||
when(mockManager.ensureLobbyRoomJoined()).thenAnswer((_) async {});
|
||||
when(mockManager.onEnterCallStage()).thenAnswer((_) async => true);
|
||||
|
||||
@@ -298,7 +341,7 @@ void main() {
|
||||
micEnabled: anyNamed('micEnabled'),
|
||||
videoEnabled: anyNamed('videoEnabled'),
|
||||
),
|
||||
).thenAnswer((_) async {});
|
||||
).thenAnswer((_) async => true);
|
||||
|
||||
await cubit.onCallScreenVisible(
|
||||
conversationUuid: 'uuid',
|
||||
@@ -332,7 +375,7 @@ void main() {
|
||||
micEnabled: anyNamed('micEnabled'),
|
||||
videoEnabled: anyNamed('videoEnabled'),
|
||||
),
|
||||
).thenAnswer((_) async {});
|
||||
).thenAnswer((_) async => true);
|
||||
|
||||
cubit.emit(
|
||||
const VideoCallSessionState(
|
||||
@@ -360,7 +403,7 @@ void main() {
|
||||
micEnabled: anyNamed('micEnabled'),
|
||||
videoEnabled: anyNamed('videoEnabled'),
|
||||
),
|
||||
).thenAnswer((_) async {});
|
||||
).thenAnswer((_) async => true);
|
||||
|
||||
await cubit.onCallScreenVisible(conversationUuid: 'uuid');
|
||||
expect(cubit.isCallScreenRouteVisible, isTrue);
|
||||
|
||||
@@ -20,9 +20,12 @@ void main() {
|
||||
final resumed = await coordinator.reactivate();
|
||||
expect(resumed, isTrue);
|
||||
|
||||
await coordinator.restartCapture();
|
||||
expect(coordinator.isActive, isTrue);
|
||||
|
||||
await coordinator.stop();
|
||||
expect(coordinator.isActive, isFalse);
|
||||
expect(calls, ['activate', 'activate', 'deactivate']);
|
||||
expect(calls, ['activate', 'activate', 'restart', 'deactivate']);
|
||||
});
|
||||
|
||||
test('stop clears the active session when deactivate fails', () async {
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_ended_push_handler.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_remote_end_registry.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_callkit_incoming/entities/call_kit_params.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
const conversation = '11111111-1111-4111-8111-111111111111';
|
||||
const otherConversation = '22222222-2222-4222-8222-222222222222';
|
||||
const callKitChannel = MethodChannel('flutter_callkit_incoming');
|
||||
|
||||
group('VideoCallEndedPushHandler.callMatchesConversation', () {
|
||||
test('matches the conversation stored as the call handle', () {
|
||||
const call = CallKitParams(id: 'derived-id', handle: conversation);
|
||||
|
||||
expect(
|
||||
VideoCallEndedPushHandler.callMatchesConversation(call, conversation),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('matches the conversation stored in the call payload', () {
|
||||
const call = CallKitParams(
|
||||
id: 'derived-id',
|
||||
handle: 'Итвио',
|
||||
extra: <String, dynamic>{'conversationUuid': conversation},
|
||||
);
|
||||
|
||||
expect(
|
||||
VideoCallEndedPushHandler.callMatchesConversation(
|
||||
call,
|
||||
conversation.toUpperCase(),
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('leaves a call from another chat alone', () {
|
||||
const call = CallKitParams(id: 'other-id', handle: otherConversation);
|
||||
|
||||
expect(
|
||||
VideoCallEndedPushHandler.callMatchesConversation(call, conversation),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('VideoCallEndedPushHandler.dismissIncomingCallUi (CallKit)', () {
|
||||
final calls = <MethodCall>[];
|
||||
late List<Map<String, Object?>> activeCalls;
|
||||
|
||||
setUp(() {
|
||||
calls.clear();
|
||||
VideoCallRemoteEndRegistry.clear();
|
||||
activeCalls = <Map<String, Object?>>[
|
||||
<String, Object?>{
|
||||
'id': 'ringing-id',
|
||||
'handle': conversation,
|
||||
'extra': <String, Object?>{'conversationUuid': conversation},
|
||||
},
|
||||
<String, Object?>{
|
||||
'id': 'other-id',
|
||||
'handle': otherConversation,
|
||||
'extra': <String, Object?>{'conversationUuid': otherConversation},
|
||||
},
|
||||
];
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(callKitChannel, (call) async {
|
||||
calls.add(call);
|
||||
if (call.method == 'activeCalls') return activeCalls;
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(callKitChannel, null);
|
||||
});
|
||||
|
||||
test('ends only the matching call and marks it as a remote end', () async {
|
||||
await VideoCallEndedPushHandler.dismissIncomingCallUi(
|
||||
conversationUuid: conversation,
|
||||
);
|
||||
|
||||
final ended = calls
|
||||
.where((call) => call.method == 'endCall')
|
||||
.map((call) => (call.arguments as Map)['id'])
|
||||
.toList();
|
||||
expect(ended, <String>['ringing-id']);
|
||||
expect(VideoCallRemoteEndRegistry.contains('ringing-id'), isTrue);
|
||||
expect(VideoCallRemoteEndRegistry.contains('other-id'), isFalse);
|
||||
});
|
||||
|
||||
test(
|
||||
'without a conversation ends every call and marks all of them',
|
||||
() async {
|
||||
await VideoCallEndedPushHandler.dismissIncomingCallUi();
|
||||
|
||||
expect(calls.map((call) => call.method), contains('endAllCalls'));
|
||||
expect(VideoCallRemoteEndRegistry.contains('ringing-id'), isTrue);
|
||||
expect(VideoCallRemoteEndRegistry.contains('other-id'), isTrue);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_incoming_push_presenter.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_kit_id.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart';
|
||||
import 'package:flutter_callkit_incoming/entities/entities.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
const payload = VideoCallPushPayload(
|
||||
conversationUuid: '11111111-1111-4111-8111-111111111111',
|
||||
counterpartName: ' Anna ',
|
||||
messageId: 26,
|
||||
);
|
||||
|
||||
final callId = VideoCallKitId.iosUuid(
|
||||
conversationUuid: payload.conversationUuid,
|
||||
messageId: payload.messageId,
|
||||
);
|
||||
|
||||
group('VideoCallIncomingPushPresenter', () {
|
||||
test(
|
||||
'iOS keeps the push-reported call and does not show it again',
|
||||
() async {
|
||||
final shown = <CallKitParams>[];
|
||||
final delays = <Duration>[];
|
||||
|
||||
await VideoCallIncomingPushPresenter.showIncomingCall(
|
||||
payload,
|
||||
isIos: true,
|
||||
isIncomingCallClaimed: (_) async => true,
|
||||
reserveIncomingCall: (_) async => true,
|
||||
showCallkitIncoming: (params) async {
|
||||
shown.add(params);
|
||||
},
|
||||
delay: (duration) async {
|
||||
delays.add(duration);
|
||||
},
|
||||
);
|
||||
|
||||
expect(shown, isEmpty);
|
||||
expect(delays, isEmpty);
|
||||
expect(callId, isNotNull);
|
||||
},
|
||||
);
|
||||
|
||||
test('iOS shows the system call once the push does not claim it', () async {
|
||||
final shown = <CallKitParams>[];
|
||||
var polls = 0;
|
||||
|
||||
await VideoCallIncomingPushPresenter.showIncomingCall(
|
||||
payload,
|
||||
isIos: true,
|
||||
claimPollDelays: const <Duration>[
|
||||
Duration.zero,
|
||||
Duration(milliseconds: 300),
|
||||
],
|
||||
isIncomingCallClaimed: (_) async {
|
||||
polls += 1;
|
||||
return false;
|
||||
},
|
||||
reserveIncomingCall: (id) async {
|
||||
expect(id, callId);
|
||||
return true;
|
||||
},
|
||||
showCallkitIncoming: (params) async {
|
||||
shown.add(params);
|
||||
},
|
||||
delay: (_) async {},
|
||||
);
|
||||
|
||||
expect(polls, 2);
|
||||
expect(shown, hasLength(1));
|
||||
expect(shown.single.id, callId);
|
||||
expect(shown.single.nameCaller, 'Anna');
|
||||
expect(shown.single.ios?.configureAudioSession, isFalse);
|
||||
expect(shown.single.ios?.audioSessionActive, isFalse);
|
||||
expect(shown.single.ios?.audioSessionMode, 'videoChat');
|
||||
expect(shown.single.ios?.ringtonePath, isNull);
|
||||
expect(shown.single.missedCallNotification?.showNotification, isTrue);
|
||||
expect(
|
||||
shown.single.missedCallNotification?.subtitle,
|
||||
VideoCallIncomingPushPresenter.iosMissedCallSubtitle,
|
||||
);
|
||||
expect(
|
||||
shown.single.missedCallNotification?.callbackText,
|
||||
VideoCallIncomingPushPresenter.iosMissedCallCallbackText,
|
||||
);
|
||||
});
|
||||
|
||||
test('iOS stops polling when the push claims the call', () async {
|
||||
final shown = <CallKitParams>[];
|
||||
var polls = 0;
|
||||
|
||||
await VideoCallIncomingPushPresenter.showIncomingCall(
|
||||
payload,
|
||||
isIos: true,
|
||||
claimPollDelays: const <Duration>[
|
||||
Duration.zero,
|
||||
Duration(milliseconds: 300),
|
||||
Duration(milliseconds: 800),
|
||||
],
|
||||
isIncomingCallClaimed: (_) async {
|
||||
polls += 1;
|
||||
return polls == 2;
|
||||
},
|
||||
reserveIncomingCall: (_) async => true,
|
||||
showCallkitIncoming: (params) async {
|
||||
shown.add(params);
|
||||
},
|
||||
delay: (_) async {},
|
||||
);
|
||||
|
||||
expect(polls, 2);
|
||||
expect(shown, isEmpty);
|
||||
});
|
||||
|
||||
test('iOS does not show when the call was reserved by the push', () async {
|
||||
final shown = <CallKitParams>[];
|
||||
|
||||
await VideoCallIncomingPushPresenter.showIncomingCall(
|
||||
payload,
|
||||
isIos: true,
|
||||
claimPollDelays: const <Duration>[Duration.zero],
|
||||
isIncomingCallClaimed: (_) async => false,
|
||||
reserveIncomingCall: (_) async => false,
|
||||
showCallkitIncoming: (params) async {
|
||||
shown.add(params);
|
||||
},
|
||||
delay: (_) async {},
|
||||
);
|
||||
|
||||
expect(shown, isEmpty);
|
||||
});
|
||||
|
||||
test('Android does not open a second CallKit UI', () async {
|
||||
final shown = <CallKitParams>[];
|
||||
final native = <VideoCallPushPayload>[];
|
||||
|
||||
await VideoCallIncomingPushPresenter.showIncomingCall(
|
||||
payload,
|
||||
isIos: false,
|
||||
showAndroidIncoming: (incoming) async {
|
||||
native.add(incoming);
|
||||
},
|
||||
showCallkitIncoming: (params) async {
|
||||
shown.add(params);
|
||||
},
|
||||
);
|
||||
|
||||
expect(shown, isEmpty);
|
||||
expect(native, [payload]);
|
||||
});
|
||||
|
||||
test(
|
||||
'Android shows the incoming call without waiting for a VoIP claim',
|
||||
() async {
|
||||
final shown = <CallKitParams>[];
|
||||
var claimed = false;
|
||||
|
||||
await VideoCallIncomingPushPresenter.showIncomingCall(
|
||||
payload,
|
||||
isIos: false,
|
||||
isIncomingCallClaimed: (_) async {
|
||||
claimed = true;
|
||||
return true;
|
||||
},
|
||||
showCallkitIncoming: (params) async {
|
||||
shown.add(params);
|
||||
},
|
||||
);
|
||||
|
||||
expect(claimed, isFalse);
|
||||
expect(shown, hasLength(1));
|
||||
expect(shown.single.id, payload.conversationUuid);
|
||||
expect(shown.single.android?.ringtonePath, 'system_ringtone_default');
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_peer_departure.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:livekit_client/livekit_client.dart' as lk;
|
||||
|
||||
void main() {
|
||||
test('ends the call when the other user leaves the room', () {
|
||||
expect(
|
||||
shouldForceEndCallAfterPeerLeft(
|
||||
callStageEntered: true,
|
||||
sessionConnected: true,
|
||||
remoteIdentities: const [],
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps the call when another user is still in the room', () {
|
||||
expect(
|
||||
shouldForceEndCallAfterPeerLeft(
|
||||
callStageEntered: true,
|
||||
sessionConnected: true,
|
||||
remoteIdentities: const ['user-7', 'EG_recorder'],
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('ignores an egress bot after the other user has left', () {
|
||||
expect(
|
||||
shouldForceEndCallAfterPeerLeft(
|
||||
callStageEntered: true,
|
||||
sessionConnected: true,
|
||||
remoteIdentities: const ['EG_recorder'],
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not end a call that has not entered the stage', () {
|
||||
expect(
|
||||
shouldForceEndCallAfterPeerLeft(
|
||||
callStageEntered: false,
|
||||
sessionConnected: true,
|
||||
remoteIdentities: const [],
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('ends the call when the server removes this participant', () {
|
||||
expect(
|
||||
shouldForceEndCallAfterRoomDisconnect(
|
||||
callStageEntered: true,
|
||||
sessionConnected: true,
|
||||
reason: lk.DisconnectReason.participantRemoved,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
shouldForceEndCallAfterRoomDisconnect(
|
||||
callStageEntered: true,
|
||||
sessionConnected: true,
|
||||
reason: lk.DisconnectReason.roomDeleted,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not report the end while the other user is still in the room', () {
|
||||
expect(
|
||||
shouldNotifyCallEnded(
|
||||
alreadyNotified: false,
|
||||
hasConversation: true,
|
||||
peerStillInRoom: true,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('reports the end when this client is the last participant', () {
|
||||
expect(
|
||||
shouldNotifyCallEnded(
|
||||
alreadyNotified: false,
|
||||
hasConversation: true,
|
||||
peerStillInRoom: false,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
shouldNotifyCallEnded(
|
||||
alreadyNotified: true,
|
||||
hasConversation: true,
|
||||
peerStillInRoom: false,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('a decline just after leaving does not report the end again', () {
|
||||
final leftAt = DateTime(2026, 10, 16, 12);
|
||||
expect(
|
||||
incomingDeclineShouldNotifyBackend(
|
||||
leftAt: leftAt,
|
||||
now: leftAt.add(const Duration(seconds: 5)),
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
incomingDeclineShouldNotifyBackend(
|
||||
leftAt: leftAt,
|
||||
now: leftAt.add(incomingDeclineNotifySuppression),
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
incomingDeclineShouldNotifyBackend(leftAt: null, now: leftAt),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('a token is not requested again right after leaving', () {
|
||||
final leftAt = DateTime(2026, 10, 16, 12);
|
||||
expect(
|
||||
mayRequestParticipantToken(
|
||||
sessionPermitted: false,
|
||||
leftAt: leftAt,
|
||||
now: leftAt.add(const Duration(seconds: 2)),
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
mayRequestParticipantToken(
|
||||
sessionPermitted: true,
|
||||
leftAt: leftAt,
|
||||
now: leftAt.add(const Duration(seconds: 2)),
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
mayRequestParticipantToken(
|
||||
sessionPermitted: false,
|
||||
leftAt: null,
|
||||
now: leftAt,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'an ended VoIP push is marked so its CallKit close is not a decline',
|
||||
() {
|
||||
expect(
|
||||
callKitExtraMarksEndedPush(<String, dynamic>{'endedPush': true}),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
callKitExtraMarksEndedPush(<String, dynamic>{'endedPush': 'true'}),
|
||||
isTrue,
|
||||
);
|
||||
expect(callKitExtraMarksEndedPush(<String, dynamic>{}), isFalse);
|
||||
expect(
|
||||
callKitDeclineIsEndedPush(
|
||||
cachedExtra: <String, dynamic>{},
|
||||
eventExtra: <String, dynamic>{'endedPush': true},
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
callKitDeclineIsEndedPush(
|
||||
cachedExtra: <String, dynamic>{'endedPush': true},
|
||||
eventExtra: <String, dynamic>{},
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
callKitDeclineIsEndedPush(
|
||||
cachedExtra: <String, dynamic>{},
|
||||
eventExtra: <String, dynamic>{},
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('keeps a connection-loss screen when the link drops', () {
|
||||
expect(
|
||||
shouldForceEndCallAfterRoomDisconnect(
|
||||
callStageEntered: true,
|
||||
sessionConnected: true,
|
||||
reason: lk.DisconnectReason.disconnected,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -57,6 +57,7 @@ void main() {
|
||||
});
|
||||
|
||||
expect(payload, isNotNull);
|
||||
expect(payload!.conversationUuid, 'abc-123');
|
||||
});
|
||||
|
||||
test('parses message id for a stable CallKit uuid', () {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_remote_end_registry.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
setUp(VideoCallRemoteEndRegistry.clear);
|
||||
|
||||
group('VideoCallRemoteEndRegistry', () {
|
||||
test('remembers a marked id regardless of case and whitespace', () {
|
||||
VideoCallRemoteEndRegistry.mark(' 11111111-1111-4111-8111-111111111111 ');
|
||||
|
||||
expect(
|
||||
VideoCallRemoteEndRegistry.contains(
|
||||
'11111111-1111-4111-8111-111111111111'.toUpperCase(),
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not know an id that was never marked', () {
|
||||
expect(VideoCallRemoteEndRegistry.contains('other'), isFalse);
|
||||
expect(VideoCallRemoteEndRegistry.contains(''), isFalse);
|
||||
});
|
||||
|
||||
test('outlives the 45 s ring timeout and expires after the ttl', () {
|
||||
final markedAt = DateTime(2026, 10, 19, 12);
|
||||
VideoCallRemoteEndRegistry.mark('call', now: markedAt);
|
||||
|
||||
expect(
|
||||
VideoCallRemoteEndRegistry.contains(
|
||||
'call',
|
||||
now: markedAt.add(const Duration(seconds: 50)),
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
VideoCallRemoteEndRegistry.contains(
|
||||
'call',
|
||||
now: markedAt.add(VideoCallRemoteEndRegistry.ttl),
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user