Compare commits

..

56 Commits

Author SHA1 Message Date
misha
1c8709fc50 fix: analyzer fix 2026-10-23 15:29:38 +03:00
misha
d30c63be85 version changed 2026-10-23 14:11:52 +03:00
misha
0ddd4c6b14 feat(video_calls): call accepting and declingn handling 2026-10-23 14:11:20 +03:00
misha
2779f79464 feat(video_calls): ios call declining notification for other devices fix 2026-10-22 17:44:46 +03:00
misha
d8d7a211bd feat(video_calls): ios calle mic fix, infinite loading after accepting call on an oppened app fix, incaming call declining after declining on a different device 2026-10-22 14:56:15 +03:00
misha
992573aead feat(video_calls): ios calle mic fix 2026-10-22 12:16:09 +03:00
misha
3122394037 feat(video_calls): ios calle mic fix 2026-10-21 17:48:53 +03:00
misha
9916828956 feat(video_calls): ios mic fix 2026-10-21 17:11:25 +03:00
misha
8cb3a184b0 feat(video_calls): ios mic fix, tokens clearing fix 2026-10-21 16:24:23 +03:00
misha
592f387221 Merge remote-tracking branch 'upstream/main' into issue-1089 2026-10-21 15:03:59 +03:00
misha
dcf695777a version changed 2026-10-21 14:58:11 +03:00
misha
141cf5543d feat(video_calls): continous ringing on a defferent device fix 2026-10-21 14:25:59 +03:00
40ba5f304e Merge pull request 'fix(verification): show email confirmation when 405 reports missing email' (#1088) from apoloz/eatvio_app:fix/1087-email-verification-channel into main
Reviewed-on: eatvio/eatvio_app#1088
Reviewed-by: misha <misha@noreply.localhost>
2026-10-20 11:42:45 +00:00
Anatoliy Poloz
996cf07fb8 merege from main + version bump 2026-10-20 13:05:57 +03:00
Anatoliy Poloz
1766314bbe bump version 2026-10-20 13:03:23 +03:00
d1575d7ac4 fix(verification): show email confirmation when 405 reports missing email (#1087) 2026-10-19 18:11:34 +03:00
94bca6a793 Merge pull request 'feat(video_calls): screen waking up' (#1083) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1083
2026-10-19 13:15:30 +00:00
misha
a6b94f835c feat(video_calls): ios voip push handling fix 2026-10-19 16:09:20 +03:00
misha
4c38026748 Merge remote-tracking branch 'upstream/main' 2026-10-19 12:50:07 +03:00
misha
355ec2df48 feat(video_calls): added phone waking up after it gets call push 2026-10-19 12:49:09 +03:00
04195dd95b Merge pull request 'bump version for TF' (#1082) from apoloz/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1082
2026-10-19 07:55:43 +00:00
Anatoliy Poloz
d3336b1263 bump version for TF 2026-10-19 10:48:32 +03:00
82f5c9a7fe Merge pull request 'fix(diary): parse commented_from_diet/reviewed_from_diet portion states, fallback unknown states to PortionState.unknown' (#1081) from apoloz/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1081
Reviewed-by: misha <misha@noreply.localhost>
2026-10-19 07:32:51 +00:00
Anatoliy Poloz
39a512193e fix(diary): parse commented_from_diet/reviewed_from_diet portion states, fallback unknown states to PortionState.unknown 2026-10-16 21:12:40 +03:00
4dc5839317 Merge pull request 'feat(video_calls): android repeated call after hanging up fix and ios repeated calls after call initiation fix' (#1079) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1079
2026-10-16 15:21:13 +00:00
misha
da6158625f fix: tests 2026-10-16 16:30:56 +03:00
misha
5a8f8aee3e feat(video_calls): ios repeated call fix 2026-10-16 15:41:13 +03:00
misha
86e9d0cc31 feat(video_calls): android repeated call after hanging up fix and ios repeated calls after call initiation fix 2026-10-16 15:40:41 +03:00
60080c7407 Merge pull request 'feat(video_calls): ios voip push, mix and crushes fix' (#1078) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1078
2026-10-16 10:57:37 +00:00
misha
6d2935400a feat(video_calls): ios voip push, mix and crushes fix 2026-10-16 13:49:48 +03:00
293601f1e1 Merge pull request 'feat(ios): env migration for ios 27' (#1077) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1077
2026-10-16 05:27:39 +00:00
misha
ff17b813ac feat(ios): env migration for ios 27 2026-10-16 08:26:26 +03:00
6a259e5452 Merge pull request 'feat(video_calls): ios mic fix, android call fix. feat(dev_tools): base url caching' (#1076) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1076
Reviewed-by: Анатолий Полоз <t0ly.od@ya.ru>
2026-10-15 13:32:29 +00:00
misha
9288a5c849 version changed 2026-10-15 15:22:04 +03:00
misha
4ef174ebde feat(video_calls): repeated calls after call termination fix 2026-10-15 15:21:38 +03:00
misha
1901248539 feat(video_calls): ios mic fix, android call fix. feat(dev_tools): base url caching 2026-10-15 14:06:37 +03:00
b2e700a4eb Merge pull request 'fix: ci/cd' (#1074) from misha/eatvio_app:issue-1062 into main
Reviewed-on: eatvio/eatvio_app#1074
2026-10-14 10:17:37 +00:00
misha
1bbb08f2a2 fix: ci/cd 2026-10-14 13:16:57 +03:00
61f1b76f99 Merge pull request 'issue-1062' (#1068) from misha/eatvio_app:issue-1062 into main
Reviewed-on: eatvio/eatvio_app#1068
Reviewed-by: Анатолий Полоз <t0ly.od@ya.ru>
2026-10-14 09:53:01 +00:00
misha
ef4df92f58 fix: analyzer fixes 2026-10-14 10:53:38 +03:00
misha
dcd343e765 feat(push_notifications): issue-660, fix: issue-908 2026-10-14 08:16:20 +03:00
misha
98c175df44 fix: test fixed 2026-10-12 11:19:18 +03:00
misha
cb312037b2 feat(UGPC): issue-777 2026-10-12 08:50:33 +03:00
572a6f1943 Merge pull request 'update(doc): bug report issue template, desc field added' (#1073) from apoloz/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1073
2026-10-09 09:47:54 +00:00
Anatoliy Poloz
a6ae9abb57 update(doc): bug report issue template, desc field added 2026-10-09 12:47:22 +03:00
431354170e Merge pull request 'description field added to feature issue template' (#1072) from apoloz/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1072
2026-10-09 09:43:28 +00:00
Anatoliy Poloz
c38e82f558 description field added to feature issue template 2026-10-09 12:43:00 +03:00
afd6f9a93a Merge pull request 'update bug issue template' (#1071) from apoloz/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1071
2026-10-09 09:34:46 +00:00
Anatoliy Poloz
641eb4d2e2 update bug issue template 2026-10-09 12:34:08 +03:00
74c66c9600 Merge pull request 'feat(doc): issue templates' (#1070) from apoloz/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1070
2026-10-09 09:12:18 +00:00
Anatoliy Poloz
73e89ff7a9 feat(doc): issue templates 2026-10-09 12:11:30 +03:00
misha
f8e06b6d7a version changed 2026-10-08 07:20:00 +03:00
misha
866ea2fe3b Merge remote-tracking branch 'upstream/main' into issue-1062 2026-10-08 07:18:53 +03:00
d73f68e24b Merge pull request 'video_calls_fixes' (#1066) from misha/eatvio_app:video_calls_fixes into main
Reviewed-on: eatvio/eatvio_app#1066
2026-10-05 11:56:40 +00:00
misha
603a158cf2 feat(diary): editing of previous and current finished days 2026-10-01 10:44:52 +03:00
a1bae68307 Merge pull request 'feat(video_calls, welcome_screen): ios mic fix, welcome scrin layoute fix' (#1064) from misha/eatvio_app:video_calls_fixes into main
Reviewed-on: eatvio/eatvio_app#1064
2026-09-28 09:01:24 +00:00
137 changed files with 10038 additions and 1465 deletions

View File

@@ -0,0 +1,62 @@
name: "🐞 Bug Report"
description: "Report a bug to help us improve"
title: "[Bug]: "
labels: ["bug"]
body:
- type: dropdown
id: stand
attributes:
label: "Stand"
description: "Select the environment where the bug occurs"
options:
- "prod"
- "stage-dev2"
- "local"
- "all"
validations:
required: true
- type: dropdown
id: platform
attributes:
label: "Platform"
description: "Select the platform where the bug occurs"
options:
- "Android"
- "iPhone"
- "Huawei"
- "All"
validations:
required: true
- type: input
id: device_model
attributes:
label: "Device Model"
description: "Optional. Specify the device where the bug occurs"
placeholder: "e.g., Samsung Galaxy S23, iPhone 15 Pro, Huawei P60"
- type: textarea
id: reproduction_steps
attributes:
label: "Steps to Reproduce"
description: "Clear numbered steps"
placeholder: "1. Click X\n2. Navigate to Y..."
validations:
required: true
- type: textarea
id: expected_actual
attributes:
label: "Expected vs Actual Behavior"
description: "What should happen vs what happens"
validations:
required: true
- type: textarea
id: description
attributes:
label: "Description"
description: "Дополнительная информация описывающая проблему"
validations:
required: true

View File

@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: "💬 Community Support"
url: "https://pepe.eatv.io/channel/general"
about: "Ask questions here"

View File

@@ -0,0 +1,26 @@
name: "✨ Feature Request"
description: "Suggest an idea for this project"
title: "[Feature]: "
labels: ["enhancement"]
body:
- type: textarea
id: use_case
attributes:
label: "Use Case"
description: "Problem solved or value added"
validations:
required: true
- type: textarea
id: description
attributes:
label: "Description"
description: "Дополнительная информация описывающая функциональность"
validations:
required: true
- type: dropdown
id: priority
attributes:
label: "Priority Level"
options: ["Low", "Medium", "High"]

View File

@@ -8,8 +8,16 @@ jobs:
build:
runs-on: [macos-arm64]
steps:
# launchd does not load the login-shell PATH, so Homebrew git is invisible to act.
- name: Add Homebrew to PATH
run: |
echo "/opt/homebrew/bin" >> "$GITHUB_PATH"
echo "/opt/homebrew/sbin" >> "$GITHUB_PATH"
- name: Fastlane version
run: fastlane --version
run: |
git --version
fastlane --version
- name: Checkout
uses: https://github.com/actions/checkout@v2

View File

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

View File

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

View File

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

View File

@@ -8,10 +8,20 @@ import com.hiennv.flutter_callkit_incoming.CallkitIncomingBroadcastReceiver
object CallKitIntentBridge {
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)
}
CallKitAcceptIntentMapper.declinePayloadFromBundle(data, action)?.let { payload ->
PendingCallKitDeclineBridge.store(payload)
if (MainActivity.isInForeground) {
MainActivity.onPendingDecline?.invoke()
}
}
}
val broadcastIntent = CallkitIncomingBroadcastReceiver.getIntent(context, action, data)

View File

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

View File

@@ -1,12 +1,71 @@
package com.eatvioapp.app
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
}
}
}

View File

@@ -16,7 +16,7 @@ class EatvioFcmMessagingService : FirebaseMessagingService() {
Log.i(TAG, "onMessageReceived: ${message.data}")
if (VideoCallPushBridge.isEndedVideoCall(message.data)) {
VideoCallPushBridge.dismissAllIncomingCalls(this)
VideoCallPushBridge.dismissEndedVideoCall(this, message.data)
return
}

View File

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

View File

@@ -0,0 +1,15 @@
package com.eatvioapp.app
/**
* Call actions that must stop the incoming ringtone.
* Accept is included: answering the call is not the same as ending it,
* but the ringing player is a separate object from the in-call audio.
*/
internal object IncomingCallRingtonePolicy {
fun actionStopsRingtone(action: String): Boolean {
return action.contains("ACTION_CALL_ACCEPT") ||
action.contains("ACTION_CALL_DECLINE") ||
action.contains("ACTION_CALL_ENDED") ||
action.contains("ACTION_CALL_TIMEOUT")
}
}

View File

@@ -13,6 +13,9 @@ import io.flutter.plugin.common.MethodChannel
import com.hiennv.flutter_callkit_incoming.FlutterCallkitIncomingPlugin
import com.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
@@ -22,6 +25,9 @@ class MainActivity: FlutterActivity() {
@JvmStatic
var isInForeground: Boolean = false
private set
@JvmStatic
var onPendingDecline: (() -> Unit)? = null
}
private val channelName = "com.eatvioapp/hms_push"
@@ -35,13 +41,18 @@ class MainActivity: FlutterActivity() {
override fun onResume() {
super.onResume()
isInForeground = true
onPendingDecline = { scheduleDeliverPendingCallKitDecline() }
captureCallKitLaunchIntent(intent)
if (PendingCallKitAcceptBridge.peek() != null) {
scheduleDeliverPendingCallKitAccept()
}
if (PendingCallKitDeclineBridge.peek() != null) {
scheduleDeliverPendingCallKitDecline()
}
}
override fun onPause() {
onPendingDecline = null
isInForeground = false
super.onPause()
}
@@ -52,22 +63,50 @@ class MainActivity: FlutterActivity() {
methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName)
videoCallPipChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, videoCallPipChannelName)
videoCallPushChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, videoCallPushChannelName)
onPendingDecline = { scheduleDeliverPendingCallKitDecline() }
videoCallPushChannel?.setMethodCallHandler { call, result ->
when (call.method) {
"dismissIncomingCalls" -> {
try {
VideoCallPushBridge.dismissAllIncomingCalls(applicationContext)
val args = call.arguments as? Map<*, *>
val callId = args?.get("call_id")?.toString()
?: args?.get("callId")?.toString()
val conversationUuid = args?.get("conversation_uuid")?.toString()
?: args?.get("conversationUuid")?.toString()
VideoCallPushBridge.dismissIncomingCalls(
applicationContext,
callId = callId,
conversationUuid = conversationUuid,
)
result.success(null)
} catch (e: Exception) {
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())
}
"peekPendingCallKitAccept" -> {
result.success(PendingCallKitAcceptBridge.peekPending())
}
"drainPendingCallKitDecline" -> {
result.success(PendingCallKitDeclineBridge.drain())
}
else -> result.notImplemented()
}
}
@@ -82,6 +121,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 +174,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)
}
}
}
}
@@ -189,6 +262,30 @@ class MainActivity: FlutterActivity() {
channel.invokeMethod("onCallKitLaunchIntent", payload)
}
private fun scheduleDeliverPendingCallKitDecline() {
PendingCallKitDeclineBridge.scheduleDeliverToFlutter(
deliver = { deliverPendingCallKitDeclineToFlutter() },
)
}
private fun deliverPendingCallKitDeclineToFlutter() {
val payload = PendingCallKitDeclineBridge.peek() ?: return
val channel = videoCallPushChannel
if (channel == null) {
android.util.Log.w(
"VideoCallPush",
"deliverPendingCallKitDeclineToFlutter: channel not ready",
)
return
}
android.util.Log.i(
"VideoCallPush",
"deliverPendingCallKitDeclineToFlutter: id=${payload["id"]}",
)
channel.invokeMethod("onCallKitDecline", payload)
}
private fun handleIntentForActionUrl(intent: Intent?) {
if (intent == null) return

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,10 +4,12 @@ import android.content.Context
import android.os.Bundle
import android.os.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
@@ -16,15 +18,42 @@ import com.hiennv.flutter_callkit_incoming.FlutterCallkitIncomingPlugin
import com.hiennv.flutter_callkit_incoming.addCall
import com.hiennv.flutter_callkit_incoming.getDataActiveCalls
import com.hiennv.flutter_callkit_incoming.removeAllCalls
import com.hiennv.flutter_callkit_incoming.removeCall
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 TOMBSTONE_TTL_MS = 2 * 60 * 1000L
private const val WAKE_LOCK_TAG = "eatvio:incoming_call_ring"
private val mainHandler = Handler(Looper.getMainLooper())
private val playbackLock = Any()
private val playbackGeneration = AtomicInteger(0)
private val tombstonedCallIds = HashMap<String, Long>()
private var activeSoundManager: CallkitSoundPlayerManager? = null
private var 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 +118,191 @@ object VideoCallPushBridge {
fun showIncomingCall(context: Context, data: Map<String, String>) {
val appContext = context.applicationContext
storedAppContext = appContext
ensureIncomingCallCallbackRegistered()
val serverCallId = callId(data)
if (isTombstoned(serverCallId)) {
Log.i(TAG, "showIncomingCall: skip tombstoned call_id=$serverCallId")
return
}
val callData = buildCallDataBundle(data)
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: id=$callId conversation=${conversationUuid(data)}")
// Ringtone.play() and stop() must run on the same thread. FCM delivers
// pushes on a background thread, while Answer runs on the main thread.
// A second show for the same conversation must not bump the generation:
// that would cancel the timeout of the call that is already ringing.
mainHandler.post {
synchronized(playbackLock) {
if (seenGeneration != playbackGeneration.get()) {
Log.i(TAG, "showIncomingCall: skipped after silence")
return@post
}
val alreadyRinging = activeCallBundle?.getString(CallkitConstants.EXTRA_CALLKIT_ID)
if (!callId.isNullOrBlank() && callId == alreadyRinging) {
Log.i(TAG, "showIncomingCall: already ringing $callId")
return@post
}
val generation = playbackGeneration.incrementAndGet()
startIncomingCallLocked(appContext, callData, generation)
}
}
}
/**
* Stops the ringtone that [showIncomingCall] started and removes the incoming
* notification. Safe to call more than once.
*/
fun silenceIncomingCall(context: Context) {
val appContext = context.applicationContext
storedAppContext = appContext
Log.i(TAG, "silenceIncomingCall")
playbackGeneration.incrementAndGet()
val stop = Runnable {
synchronized(playbackLock) {
stopPlaybackLocked(appContext)
}
}
if (Looper.myLooper() == Looper.getMainLooper()) {
stop.run()
} else {
mainHandler.post(stop)
}
}
/**
* The plugin's sound player stops on ACTION_SCREEN_OFF. The ringing screen turns
* the display on itself, so keep ringing through that transition.
*/
fun keepRingingOnFullScreen() {
synchronized(playbackLock) {
activeSoundManager?.keepRingingOnFullScreen()
}
}
fun dismissIncomingCalls(
context: Context,
callId: String? = null,
conversationUuid: String? = null,
) {
val normalizedCallId = callId?.trim()?.takeIf { it.isNotEmpty() }
val normalizedConversation = conversationUuid?.trim()?.takeIf { it.isNotEmpty() }
if (normalizedCallId != null) {
markTombstone(normalizedCallId)
}
val appContext = context.applicationContext
val calls = getDataActiveCalls(appContext)
Log.i(
TAG,
"dismissIncomingCalls: active=${calls.size} call_id=${normalizedCallId ?: "-"} " +
"conversation=${normalizedConversation ?: "-"}",
)
playbackGeneration.incrementAndGet()
val dismiss = Runnable {
synchronized(playbackLock) {
val notificationManager = resolveNotificationManager(appContext)
val allBundles = calls.map { it.toBundle() }
val targeted = hasDismissTarget(normalizedCallId, normalizedConversation)
val bundles = if (targeted) {
allBundles.filter { bundleMatches(it, normalizedCallId, normalizedConversation) }
} else {
allBundles
}
val activeMatches = activeCallBundle?.let { bundle ->
!targeted || bundleMatches(bundle, normalizedCallId, normalizedConversation)
} == true
if (activeMatches || !targeted) {
stopPlaybackLocked(appContext)
}
for (bundle in bundles) {
try {
dismissIncomingCallNotification(appContext, notificationManager, bundle)
} catch (error: Exception) {
Log.w(
TAG,
"dismissIncomingCalls: failed for call ${bundle.getString(CallkitConstants.EXTRA_CALLKIT_ID)}",
error,
)
}
}
if (!targeted || bundles.isNotEmpty()) {
try {
CallkitNotificationService.stopService(appContext)
} catch (error: Exception) {
Log.w(TAG, "dismissIncomingCalls: failed to stop callkit service", error)
}
}
if (!targeted) {
try {
removeAllCalls(appContext)
} catch (error: Exception) {
Log.w(TAG, "dismissIncomingCalls: failed to remove persisted calls", error)
}
} else {
for (bundle in bundles) {
try {
removeCall(appContext, Data.fromBundle(bundle))
} catch (error: Exception) {
Log.w(TAG, "dismissIncomingCalls: failed to remove targeted call", error)
}
}
}
if (activeMatches || !targeted) {
stopIncomingRingtone()
}
scheduleMissedCallCleanup(notificationManager, bundles)
}
}
if (Looper.myLooper() == Looper.getMainLooper()) {
dismiss.run()
} else {
mainHandler.post(dismiss)
}
}
fun dismissAllIncomingCalls(context: Context) {
dismissIncomingCalls(context)
}
fun dismissEndedVideoCall(context: Context, data: Map<String, String>) {
dismissIncomingCalls(
context = context,
callId = callId(data),
conversationUuid = conversationUuid(data),
)
}
private fun ensureIncomingCallCallbackRegistered() {
if (callbackRegistered) return
FlutterCallkitIncomingPlugin.registerEventCallback(incomingCallEventCallback)
callbackRegistered = true
}
private fun startIncomingCallLocked(appContext: Context, callData: Bundle, generation: Int) {
stopPlaybackLocked(appContext)
val soundManager = CallkitSoundPlayerManager(appContext)
val notificationManager = CallkitNotificationManager(appContext, soundManager)
activeSoundManager = soundManager
activeNotificationManager = notificationManager
activeCallBundle = callData
notificationManager.createNotificationChanel(callData)
notificationManager.showIncomingNotification(callData)
@@ -109,46 +311,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 +395,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)
}
}
/**
@@ -222,6 +464,8 @@ object VideoCallPushBridge {
private fun buildCallDataBundle(data: Map<String, String>): Bundle? {
val conversationUuid = conversationUuid(data) ?: return null
val serverCallId = callId(data)
val callKitId = serverCallId ?: conversationUuid
val callerName = data["counterpartName"]
?: data["counterpart_name"]
?: data["caller_name"]
@@ -247,6 +491,9 @@ object VideoCallPushBridge {
val extra = HashMap<String, Any?>()
extra["conversationUuid"] = conversationUuid
if (!serverCallId.isNullOrBlank()) {
extra["call_id"] = serverCallId
}
extra["counterpartName"] = callerName
if (avatarUrl.isNotBlank()) {
extra["counterpartAvatarUrl"] = avatarUrl
@@ -257,9 +504,13 @@ object VideoCallPushBridge {
if (counterpartIsConsultant) {
extra["counterpartIsConsultant"] = true
}
val messageId = data["message_id"] ?: data["messageId"]
if (!messageId.isNullOrBlank()) {
extra["message_id"] = messageId
}
return Bundle().apply {
putString(CallkitConstants.EXTRA_CALLKIT_ID, conversationUuid)
putString(CallkitConstants.EXTRA_CALLKIT_ID, callKitId)
putString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, callerName)
putString(CallkitConstants.EXTRA_CALLKIT_APP_NAME, "Итвио")
putString(CallkitConstants.EXTRA_CALLKIT_HANDLE, conversationUuid)
@@ -284,6 +535,77 @@ object VideoCallPushBridge {
}
}
private fun callId(data: Map<String, String>): String? {
return data["call_id"]?.trim()?.takeIf { it.isNotEmpty() }
?: data["callId"]?.trim()?.takeIf { it.isNotEmpty() }
}
private fun hasDismissTarget(callId: String?, conversationUuid: String?): Boolean {
return !callId.isNullOrBlank() || !conversationUuid.isNullOrBlank()
}
private fun bundleMatches(
bundle: Bundle,
callId: String?,
conversationUuid: String?,
): Boolean {
val normalizedCallId = callId?.trim()?.lowercase()
if (!normalizedCallId.isNullOrEmpty()) {
val id = bundle.getString(CallkitConstants.EXTRA_CALLKIT_ID)?.trim()?.lowercase()
if (id != null && id == normalizedCallId) return true
val extraCallId = extraString(bundle, "call_id")
?: extraString(bundle, "callId")
if (extraCallId != null && extraCallId.lowercase() == normalizedCallId) {
return true
}
// A ring that already stores its own call id must not be closed by
// another call in the same chat. Legacy rings have no call id and
// still match the conversation.
if (!extraCallId.isNullOrBlank()) return false
}
val normalizedConversation = conversationUuid?.trim()?.lowercase() ?: return false
val handle = bundle.getString(CallkitConstants.EXTRA_CALLKIT_HANDLE)?.trim()?.lowercase()
if (handle != null && handle == normalizedConversation) return true
val extraConversation = extraString(bundle, "conversationUuid")
?: extraString(bundle, "conversation_uuid")
return extraConversation?.lowercase() == normalizedConversation
}
@Suppress("UNCHECKED_CAST")
private fun extraString(bundle: Bundle, key: String): String? {
val extra = bundle.getSerializable(CallkitConstants.EXTRA_CALLKIT_EXTRA) as? HashMap<String, Any?>
?: return null
val value = extra[key] ?: return null
val text = value.toString().trim()
return text.takeIf { it.isNotEmpty() }
}
private fun markTombstone(callId: String) {
val normalized = callId.trim().lowercase()
if (normalized.isEmpty()) return
pruneTombstones()
tombstonedCallIds[normalized] = System.currentTimeMillis() + TOMBSTONE_TTL_MS
}
private fun isTombstoned(callId: String?): Boolean {
val normalized = callId?.trim()?.lowercase() ?: return false
if (normalized.isEmpty()) return false
pruneTombstones()
return tombstonedCallIds.containsKey(normalized)
}
private fun pruneTombstones() {
val now = System.currentTimeMillis()
val iterator = tombstonedCallIds.entries.iterator()
while (iterator.hasNext()) {
val entry = iterator.next()
if (entry.value <= now) {
iterator.remove()
}
}
}
private fun conversationUuid(data: Map<String, String>): String? {
val fromData = data["conversationUuid"]
?: data["conversation_uuid"]

View File

@@ -0,0 +1,45 @@
package com.eatvioapp.app
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class IncomingCallRingtonePolicyTest {
@Test
fun answerDeclineEndAndTimeoutStopTheRingtone() {
assertTrue(
IncomingCallRingtonePolicy.actionStopsRingtone(
"com.hiennv.flutter_callkit_incoming.ACTION_CALL_ACCEPT",
),
)
assertTrue(
IncomingCallRingtonePolicy.actionStopsRingtone(
"com.hiennv.flutter_callkit_incoming.ACTION_CALL_DECLINE",
),
)
assertTrue(
IncomingCallRingtonePolicy.actionStopsRingtone(
"com.hiennv.flutter_callkit_incoming.ACTION_CALL_ENDED",
),
)
assertTrue(
IncomingCallRingtonePolicy.actionStopsRingtone(
"com.hiennv.flutter_callkit_incoming.ACTION_CALL_TIMEOUT",
),
)
}
@Test
fun incomingAndStartActionsKeepTheRingtonePlaying() {
assertFalse(
IncomingCallRingtonePolicy.actionStopsRingtone(
"com.hiennv.flutter_callkit_incoming.ACTION_CALL_INCOMING",
),
)
assertFalse(
IncomingCallRingtonePolicy.actionStopsRingtone(
"com.hiennv.flutter_callkit_incoming.ACTION_CALL_START",
),
)
}
}

View File

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

View File

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

View File

@@ -11,6 +11,8 @@
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 */; };
E47D0C1B2F0B4D1000EA7A01 /* VideoCallDeclineClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = E47D0C1A2F0B4D1000EA7A01 /* VideoCallDeclineClient.swift */; };
C4A8E2022E9A4B1000123456 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A8E2012E9A4B1000123456 /* SceneDelegate.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
@@ -37,6 +39,8 @@
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
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>"; };
E47D0C1A2F0B4D1000EA7A01 /* VideoCallDeclineClient.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoCallDeclineClient.swift; sourceTree = "<group>"; };
C4A8E2012E9A4B1000123456 /* SceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
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 +119,8 @@
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
E47D0C1A2F0B4D1000EA7A01 /* VideoCallDeclineClient.swift */,
C4A8E2012E9A4B1000123456 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
FEE92A0E2D772EA300E6E989 /* Runner.entitlements */,
);
@@ -300,6 +306,8 @@
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
E47D0C1B2F0B4D1000EA7A01 /* VideoCallDeclineClient.swift in Sources */,
C4A8E2022E9A4B1000123456 /* SceneDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,86 @@
import Flutter
import UIKit
import YandexLoginSDK
/// iOS 27 requires a UIScene lifecycle. Flutter 3.38 does not yet have
/// `FlutterImplicitEngineDelegate`, so channels and URL handling are wired
/// here after the scene creates the Flutter view.
class SceneDelegate: FlutterSceneDelegate {
override func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
super.scene(scene, willConnectTo: session, options: connectionOptions)
registerCallChannels()
handleConnectionOptions(connectionOptions)
}
override func sceneDidBecomeActive(_ scene: UIScene) {
super.sceneDidBecomeActive(scene)
registerCallChannels()
}
override func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
var handledCustomURL = false
if let appDelegate = UIApplication.shared.delegate as? AppDelegate {
for context in URLContexts {
if appDelegate.handleOpenURL(
context.url,
sourceApplication: context.options.sourceApplication
) {
handledCustomURL = true
}
}
}
if !handledCustomURL {
super.scene(scene, openURLContexts: URLContexts)
}
}
override func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
if YXLSdk.shared.processUserActivity(userActivity) {
return
}
super.scene(scene, continue: userActivity)
}
private func registerCallChannels() {
guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else {
return
}
appDelegate.registerCallChannels(from: flutterViewController)
}
private var flutterViewController: FlutterViewController? {
if let controller = window?.rootViewController as? FlutterViewController {
return controller
}
for scene in UIApplication.shared.connectedScenes {
guard let windowScene = scene as? UIWindowScene else { continue }
for sceneWindow in windowScene.windows {
if let controller = sceneWindow.rootViewController as? FlutterViewController {
return controller
}
}
}
return nil
}
private func handleConnectionOptions(_ options: UIScene.ConnectionOptions) {
guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else {
return
}
for context in options.urlContexts {
_ = appDelegate.handleOpenURL(
context.url,
sourceApplication: context.options.sourceApplication
)
}
for activity in options.userActivities {
if YXLSdk.shared.processUserActivity(activity) {
continue
}
}
}
}

View File

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

View File

@@ -1,8 +1,12 @@
import 'dart:async';
import 'package:eatvio_app/core/app_initializer.dart';
import 'package:eatvio_app/core/cubit/marketing/marketing_cubit.dart';
import 'package:eatvio_app/core/env_config.dart';
import 'package:eatvio_app/core/internet_connection_cubit/internet_connection_cubit.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/prefs.dart';
import 'package:eatvio_app/core/prefs_keys.dart';
import 'package:eatvio_app/core/views/cubit/views_cubit.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/new_messages_cubit/new_messages_cubit.dart';
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
@@ -31,20 +35,51 @@ 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));
unawaited(sl<Prefs>().setString(PrefsKeys.apiBaseUrl, 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 +91,7 @@ class AppConfig {
];
/// Поддерживаемые локали
List<Locale> get supportedLocales => [const Locale.fromSubtags(languageCode: 'ru')];
List<Locale> get supportedLocales => [
const Locale.fromSubtags(languageCode: 'ru'),
];
}

View File

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

View File

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

View File

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

View File

@@ -3,15 +3,15 @@ import 'dart:developer';
import 'dart:io';
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/app_initializer.dart';
import 'package:eatvio_app/core/device/device_hw_id_provider.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 +27,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 {
@@ -57,13 +45,17 @@ class FirebaseApi {
Future<void> registerAnonymousPushToken() async {
try {
await _requestNotificationPermission();
final deviceInfo = DeviceInfoPlugin();
final credentials = await _resolvePushCredentials();
final token = credentials.token;
if (token == null || token.isEmpty) return;
final hwId = await DeviceHwIdProvider.current();
if (hwId == null || hwId.length < 8) return;
await sl<Dio>().post(
'/api/anonymous_token',
data: {
'platform': Platform.isIOS ? 'ios' : (await _isHuaweiDevice()) ? 'huawei' : 'android',
'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id,
'token': await getToken(),
'platform': credentials.platform,
'hw_id': hwId,
'token': token,
},
).timeout(
const Duration(seconds: 10),
@@ -79,32 +71,52 @@ class FirebaseApi {
}
Future<void> registerPushToken() async {
if (await TokenStorage.haveTokenInStorage()) {
try {
await _requestNotificationPermission();
final deviceInfo = DeviceInfoPlugin();
final isHuawei = Platform.isAndroid ? await _isHuaweiDevice() : false;
await sl<Dio>().post(
'/api/profile/notification/token/register',
data: {
'platform': Platform.isIOS ? 'ios' : (isHuawei ? 'huawei' : 'android'),
'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id,
'token': await getToken(),
},
).timeout(
const Duration(seconds: 10),
onTimeout: () {
throw TimeoutException('registerPushToken timeout');
},
);
if (Platform.isIOS) {
await _registerIosVoipPushToken();
if (!await TokenStorage.haveTokenInStorage()) return;
try {
await _requestNotificationPermission();
final credentials = await _resolvePushCredentials();
final token = credentials.token;
final hwId = await DeviceHwIdProvider.current();
final reregisterAndroid =
await DeviceHwIdProvider.androidTokenNeedsReregister();
if (token != null &&
token.isNotEmpty &&
hwId != null &&
hwId.length >= 8) {
if (reregisterAndroid) {
try {
await unregisterPushToken();
} catch (_) {}
}
await sl<Dio>()
.post(
'/api/profile/notification/token/register',
data: {
'platform': credentials.platform,
'hw_id': hwId,
'token': token,
},
)
.timeout(
const Duration(seconds: 10),
onTimeout: () {
throw TimeoutException('registerPushToken timeout');
},
);
if (reregisterAndroid) {
await DeviceHwIdProvider.markAndroidTokenReregistered();
}
} catch (e, st) {
log(e.toString(), stackTrace: st);
await _errorReporter.report(e, st, origin: 'FirebaseApi');
// Не пробрасываем ошибку дальше, так как это не критичная операция
}
} catch (e, st) {
log(e.toString(), stackTrace: st);
await _errorReporter.report(e, st, origin: 'FirebaseApi');
// Не пробрасываем ошибку дальше, так как это не критичная операция
}
// Incoming calls on iOS come only through the VoIP token. It does not
// depend on the FCM token, so a missing or failed FCM registration must
// not leave the device without calls.
if (Platform.isIOS) {
await _registerIosVoipPushToken();
}
}
@@ -124,13 +136,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(
@@ -150,12 +162,13 @@ class FirebaseApi {
final token = (await FlutterCallkitIncoming.getDevicePushTokenVoIP())?.trim();
if (token == null || token.isEmpty) return;
final deviceInfo = DeviceInfoPlugin();
final hwId = await DeviceHwIdProvider.current();
if (hwId == null || hwId.length < 8) return;
await sl<Dio>().post(
'/api/profile/notification/token/voip/register',
data: {
'platform': 'ios',
'hw_id': (await deviceInfo.iosInfo).identifierForVendor,
'hw_id': hwId,
'token': token,
},
).timeout(
@@ -190,30 +203,74 @@ class FirebaseApi {
}
}
Future<({String? token, String platform})> _resolvePushCredentials() async {
if (Platform.isIOS) {
return (
token: await _getFcmToken(),
platform: pushPlatform(isIos: true, huaweiFamily: false, hmsAvailable: false, hasHmsToken: false),
);
}
final huaweiFamily = await _isHuaweiDevice();
if (huaweiFamily && await _isHmsAvailable()) {
final token = await _getHuaweiPushTokenNative().timeout(
const Duration(seconds: 5),
onTimeout: () => null,
);
if (token != null && token.isNotEmpty) {
return (
token: token,
platform: pushPlatform(isIos: false, huaweiFamily: true, hmsAvailable: true, hasHmsToken: true),
);
}
}
return (
token: await _getFcmToken(),
platform: pushPlatform(isIos: false, huaweiFamily: huaweiFamily, hmsAvailable: false, hasHmsToken: false),
);
}
Future<String?> _getFcmToken() {
return _firebaseMessaging.getToken().timeout(
const Duration(seconds: 5),
onTimeout: () => null,
);
}
Future<bool> _isHuaweiDevice() async {
if (!Platform.isAndroid) return false;
try {
final info = await DeviceInfoPlugin().androidInfo;
final brand = info.brand.toLowerCase();
final manufacturer = info.manufacturer.toLowerCase();
return brand.contains('huawei') || brand.contains('honor') || manufacturer.contains('huawei') || manufacturer.contains('honor');
return isHuaweiFamilyBrand(brand: info.brand, manufacturer: info.manufacturer);
} catch (_) {
return false;
}
}
Future<bool> _isHmsAvailable() async {
try {
const channel = MethodChannel('com.eatvioapp/hms_push');
final available = await channel.invokeMethod<bool>('isHmsAvailable').timeout(const Duration(seconds: 5));
return available ?? false;
} catch (e, st) {
log('HMS availability check failed: $e', stackTrace: st);
return false;
}
}
Future<String?> _getHuaweiPushTokenNative() async {
try {
const channel = MethodChannel('com.eatvioapp/hms_push');
final String? token = await channel.invokeMethod<String>('getHuaweiToken');
return token;
} on PlatformException catch (e, st) {
showErrorToast('$e $st');
log(e.toString(), stackTrace: st);
await _errorReporter.report(e, st, origin: 'FirebaseApi');
if (!isHmsClientUnavailable(e)) {
await _errorReporter.report(e, st, origin: 'FirebaseApi');
}
return null;
} catch (e, st) {
showErrorToast('$e $st');
log(e.toString(), stackTrace: st);
await _errorReporter.report(e, st, origin: 'FirebaseApi');
return null;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,36 @@
import 'package:flutter/services.dart';
bool isHuaweiFamilyBrand({
required String brand,
required String manufacturer,
}) {
final normalizedBrand = brand.toLowerCase();
final normalizedManufacturer = manufacturer.toLowerCase();
return normalizedBrand.contains('huawei') ||
normalizedBrand.contains('honor') ||
normalizedManufacturer.contains('huawei') ||
normalizedManufacturer.contains('honor');
}
/// HMS Core is missing or rejects this app.
///
/// Honor devices with Google services return `907135003: client api invalid`.
/// That is a device capability, not an app crash: the caller should use FCM.
bool isHmsClientUnavailable(Object error) {
if (error is! PlatformException) return false;
final message = '${error.message ?? ''} ${error.details ?? ''}'.toLowerCase();
return message.contains('907135003') ||
message.contains('client api invalid');
}
/// `huawei` only when an HMS token was actually issued. Otherwise Android uses FCM.
String pushPlatform({
required bool isIos,
required bool huaweiFamily,
required bool hmsAvailable,
required bool hasHmsToken,
}) {
if (isIos) return 'ios';
if (huaweiFamily && hmsAvailable && hasHmsToken) return 'huawei';
return 'android';
}

View File

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

View File

@@ -24,6 +24,7 @@ import 'package:eatvio_app/features/create_diet/presentation/diet_preview_screen
import 'package:eatvio_app/features/create_plan/presentation/create_plan_screen.dart';
import 'package:eatvio_app/features/create_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()),

View File

@@ -0,0 +1,11 @@
/// Joins an API media path with [baseUrl].
///
/// Uploaded photos come back as relative paths (`/storage/...`).
/// Default avatars are already absolute (`https://ui-avatars...`) and must stay as-is.
String joinBaseUrl(String baseUrl, String path) {
final normalized = path.toLowerCase();
if (normalized.startsWith('https://') || normalized.startsWith('http://')) {
return path;
}
return '$baseUrl$path';
}

View File

@@ -18,7 +18,7 @@ class WsManager {
final ErrorReporter _errorReporter;
WsManager({required this.wsService, required ErrorReporter errorReporter})
: _errorReporter = errorReporter;
: _errorReporter = errorReporter;
PusherChannelsClient? client;
int? userId;
@@ -43,14 +43,32 @@ class WsManager {
/// Контроллер создаётся один раз и не закрывается в [clear]: подписчики
/// (чаты, окно чата) переживают логаут/повторный вход и продолжают получать
/// сигналы. Если контроллер всё же закрыт, он пересоздаётся в [initialize].
StreamController<void> _reconnectController = StreamController<void>.broadcast();
StreamController<void> _reconnectController =
StreamController<void>.broadcast();
Stream<ChannelReadEvent>? _directChatStream;
Stream<ChannelReadEvent>? _clientTypingStream;
Stream<ChannelReadEvent>? get newMessagesStream => _privateMessagesChannel?.bind('App\\Events\\ChatMessageUpdated');
Stream<ChannelReadEvent>? get newMessagesStream =>
_privateMessagesChannel?.bind('App\\Events\\ChatMessageUpdated');
Stream<ChannelReadEvent>? get newNotificationsStream => _privateNotificationChannel?.bind('Illuminate\\Notifications\\Events\\BroadcastNotificationCreated');
/// The callee rejected the ring. Broadcast to the caller and the callee.
Stream<ChannelReadEvent>? get videoCallDeclinedStream =>
_privateMessagesChannel?.bind('video-call-declined');
/// The call was cancelled or hung up. Same channel the web client uses.
Stream<ChannelReadEvent>? get videoCallEndedStream =>
_privateMessagesChannel?.bind('video-call-ended');
/// Another device accepted the ring. Ringing on this device must stop,
/// except on the device that accepted.
Stream<ChannelReadEvent>? get videoCallAcceptedStream =>
_privateMessagesChannel?.bind('video-call-accepted');
Stream<ChannelReadEvent>? get newNotificationsStream =>
_privateNotificationChannel?.bind(
'Illuminate\\Notifications\\Events\\BroadcastNotificationCreated',
);
Stream<ChannelReadEvent>? get directChatStream => _directChatStream;
@@ -86,15 +104,9 @@ class WsManager {
connectionErrorHandler: (exception, trace, refresh) {
refresh();
},
minimumReconnectDelayDuration: const Duration(
seconds: 1,
),
defaultActivityDuration: const Duration(
seconds: 30,
),
waitForPongDuration: const Duration(
seconds: 30,
),
minimumReconnectDelayDuration: const Duration(seconds: 1),
defaultActivityDuration: const Duration(seconds: 30),
waitForPongDuration: const Duration(seconds: 30),
);
String? extractSocketId(String? data) {
@@ -107,7 +119,9 @@ class WsManager {
}
}
_connectionEstablishedSubscription = client?.eventStream.listen((event) async {
_connectionEstablishedSubscription = client?.eventStream.listen((
event,
) async {
if (event.name == 'pusher:connection_established') {
final data = event.data;
final socketId = extractSocketId(data);
@@ -124,7 +138,8 @@ class WsManager {
await _initPrivateMessagesChannel();
_lifecycleSubscription = client?.lifecycleStream.listen((data) {
if (data == PusherChannelsClientLifeCycleState.establishedConnection) {
if (data ==
PusherChannelsClientLifeCycleState.establishedConnection) {
handleConnectionEstablished();
}
});
@@ -166,12 +181,13 @@ class WsManager {
final token = await TokenStorage.getTokenFromStorage();
_privateNotificationChannel = client?.privateChannel(
'$newNotificationsChannelName.$userId',
authorizationDelegate: EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel(
authorizationEndpoint: Uri.parse('${sl<AppConfig>().baseUrl}/broadcasting/auth'),
headers: {
'Authorization': 'Bearer $token',
},
),
authorizationDelegate:
EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel(
authorizationEndpoint: Uri.parse(
'${sl<AppConfig>().baseUrl}/broadcasting/auth',
),
headers: {'Authorization': 'Bearer $token'},
),
);
}
@@ -179,12 +195,13 @@ class WsManager {
final token = await TokenStorage.getTokenFromStorage();
_privateMessagesChannel = client?.privateChannel(
'$newMessagesChannelName.$userId',
authorizationDelegate: EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel(
authorizationEndpoint: Uri.parse('${sl<AppConfig>().baseUrl}/broadcasting/auth'),
headers: {
'Authorization': 'Bearer $token',
},
),
authorizationDelegate:
EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel(
authorizationEndpoint: Uri.parse(
'${sl<AppConfig>().baseUrl}/broadcasting/auth',
),
headers: {'Authorization': 'Bearer $token'},
),
);
}
@@ -193,14 +210,17 @@ class WsManager {
final token = await TokenStorage.getTokenFromStorage();
_privateDirectChatChannel = client?.privateChannel(
'$directChatChannelName.$chatId',
authorizationDelegate: EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel(
authorizationEndpoint: Uri.parse('${sl<AppConfig>().baseUrl}/broadcasting/auth'),
headers: {
'Authorization': 'Bearer $token',
},
),
authorizationDelegate:
EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel(
authorizationEndpoint: Uri.parse(
'${sl<AppConfig>().baseUrl}/broadcasting/auth',
),
headers: {'Authorization': 'Bearer $token'},
),
);
_directChatStream = _privateDirectChatChannel?.bind(
'App\\Events\\ChatMessageUpdated',
);
_directChatStream = _privateDirectChatChannel?.bind('App\\Events\\ChatMessageUpdated');
_clientTypingStream = _privateDirectChatChannel?.bind('client-typing');
}
@@ -208,12 +228,13 @@ class WsManager {
final token = await TokenStorage.getTokenFromStorage();
_presenceDirectChatChannel = client?.presenceChannel(
'$presenceDirectChatChannelName.$chatId',
authorizationDelegate: EndpointAuthorizableChannelTokenAuthorizationDelegate.forPresenceChannel(
authorizationEndpoint: Uri.parse('${sl<AppConfig>().baseUrl}/broadcasting/auth'),
headers: {
'Authorization': 'Bearer $token',
},
),
authorizationDelegate:
EndpointAuthorizableChannelTokenAuthorizationDelegate.forPresenceChannel(
authorizationEndpoint: Uri.parse(
'${sl<AppConfig>().baseUrl}/broadcasting/auth',
),
headers: {'Authorization': 'Bearer $token'},
),
);
}
@@ -240,7 +261,8 @@ class WsManager {
void Function(bool isOnline) onCounterpartOnlineChanged,
) {
_presenceDirectChatListeners.removeWhere(
(listener) => listener.onCounterpartOnlineChanged == onCounterpartOnlineChanged,
(listener) =>
listener.onCounterpartOnlineChanged == onCounterpartOnlineChanged,
);
if (_presenceDirectChatListeners.isEmpty) {
unsubscribePresenceDirectChatChannel();
@@ -254,21 +276,26 @@ class WsManager {
_presenceChannelEventsBound = true;
_presenceSubscriptionSucceededSubscription =
channel.whenSubscriptionSucceeded().listen((_) {
final counterpartIds = _presenceDirectChatListeners
.map((listener) => listener.counterpartUserId)
.toSet();
for (final counterpartUserId in counterpartIds) {
_emitCounterpartPresence(counterpartUserId);
}
});
_presenceMemberAddedSubscription = channel.whenMemberAdded().listen((event) {
_presenceSubscriptionSucceededSubscription = channel
.whenSubscriptionSucceeded()
.listen((_) {
final counterpartIds = _presenceDirectChatListeners
.map((listener) => listener.counterpartUserId)
.toSet();
for (final counterpartUserId in counterpartIds) {
_emitCounterpartPresence(counterpartUserId);
}
});
_presenceMemberAddedSubscription = channel.whenMemberAdded().listen((
event,
) {
final memberUserId = _parsePresenceMemberUserId(event.data);
if (memberUserId == null) return;
_notifyPresenceListeners(memberUserId, isOnline: true);
});
_presenceMemberRemovedSubscription = channel.whenMemberRemoved().listen((event) {
_presenceMemberRemovedSubscription = channel.whenMemberRemoved().listen((
event,
) {
final memberUserId = _parsePresenceMemberUserId(event.data);
if (memberUserId == null) return;
_notifyPresenceListeners(memberUserId, isOnline: false);
@@ -280,11 +307,15 @@ class WsManager {
if (channel == null) return;
final members = channel.state?.members;
if (members == null) return;
final isOnline = members.getMemberInfo(counterpartUserId.toString()) != null;
final isOnline =
members.getMemberInfo(counterpartUserId.toString()) != null;
_notifyPresenceListeners(counterpartUserId, isOnline: isOnline);
}
void _notifyPresenceListeners(int counterpartUserId, {required bool isOnline}) {
void _notifyPresenceListeners(
int counterpartUserId, {
required bool isOnline,
}) {
for (final listener in _presenceDirectChatListeners) {
if (listener.counterpartUserId == counterpartUserId) {
listener.onCounterpartOnlineChanged(isOnline);

View File

@@ -6,12 +6,20 @@ import 'package:eatvio_app/core/utils/toasts.dart';
import 'package:eatvio_app/core/widgets/show_error_dialog.dart';
import 'package:eatvio_app/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 {

View File

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

View File

@@ -2,6 +2,7 @@ import 'package:dismissible_page/dismissible_page.dart';
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/enums/complained_content.dart';
import 'package:eatvio_app/core/utils/date_time_utils.dart';
import 'package:eatvio_app/core/utils/url_utils.dart';
import 'package:eatvio_app/core/widgets/custom_popup.dart';
import 'package:eatvio_app/core/widgets/loader.dart';
import 'package:eatvio_app/core/widgets/markdown_html_widget.dart';
@@ -26,7 +27,11 @@ class BlogPostScreen extends StatefulWidget {
final int id;
final int? highlightedCommentId;
const BlogPostScreen({super.key, required this.id, this.highlightedCommentId});
const BlogPostScreen({
super.key,
required this.id,
this.highlightedCommentId,
});
@override
State<BlogPostScreen> createState() => _BlogPostScreenState();
@@ -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,
),
),
],
),

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'package:eatvio_app/core/device/device_hw_id_provider.dart';
import 'package:eatvio_app/core/enums/base_state.dart';
import 'package:eatvio_app/core/websockets/ws_manager.dart';
import 'package:eatvio_app/features/chat/models/chat_message_model.dart';
@@ -10,7 +11,9 @@ import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_sta
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_chat_signal.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_end_reason.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_ended_push_handler.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_socket_signal.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_incoming_push_presenter.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -23,11 +26,19 @@ class ChatsCubit extends Cubit<ChatsState> {
StreamSubscription? newIncomingMessagesSubscription;
StreamSubscription? newLastMessageSubscription;
StreamSubscription<void>? reconnectedSubscription;
StreamSubscription? videoCallDeclinedSubscription;
StreamSubscription? videoCallEndedSubscription;
StreamSubscription? videoCallAcceptedSubscription;
final Set<int> _presentedVideoCallMessageIds = {};
/// Test seam. Production uses CallKit.
Future<void> Function(VideoCallPushPayload payload)? presentIncomingVideoCall;
Future<void> Function()? dismissIncomingVideoCall;
/// Test seam. Production reads [DeviceHwIdProvider.current].
Future<String?> Function()? currentDeviceHwId;
Future<void> Function(String? conversationUuid)? dismissIncomingVideoCall;
Future<void> Function({String? conversationUuid, String? callId})?
endActiveVideoCall;
ChatsCubit({
required this.chatRepository,
@@ -55,6 +66,37 @@ class ChatsCubit extends Cubit<ChatsState> {
_handleNewMessageViaSocket(event.data);
});
await videoCallDeclinedSubscription?.cancel();
videoCallDeclinedSubscription = wsManager.videoCallDeclinedStream?.listen(
(event) {
unawaited(
_closeCallFromSocket(
VideoCallSocketSignal.tryParseDeclined(event.data),
),
);
},
);
await videoCallEndedSubscription?.cancel();
videoCallEndedSubscription = wsManager.videoCallEndedStream?.listen((
event,
) {
unawaited(
_closeCallFromSocket(VideoCallSocketSignal.tryParseEnded(event.data)),
);
});
await videoCallAcceptedSubscription?.cancel();
videoCallAcceptedSubscription = wsManager.videoCallAcceptedStream?.listen(
(event) {
unawaited(
_closeCallFromSocket(
VideoCallSocketSignal.tryParseEnded(event.data),
),
);
},
);
await reconnectedSubscription?.cancel();
reconnectedSubscription = wsManager.reconnected.listen((_) {
unawaited(getChatRooms());
@@ -170,6 +212,7 @@ class ChatsCubit extends Cubit<ChatsState> {
try {
final json = jsonDecode(data) as Map<String, dynamic>;
final message = ChatMessageModel.fromJson(json);
final callId = _stringField(json, const ['call_id', 'callId']);
final currentUserId = wsManager.userId;
final isOwn = currentUserId != null && message.authorId == currentUserId;
@@ -217,6 +260,7 @@ class ChatsCubit extends Cubit<ChatsState> {
message,
isOwn: isOwn,
chatRooms: currentChatRooms,
callId: callId,
);
} catch (e, st) {
await reportFrom(e, st, this);
@@ -227,10 +271,16 @@ class ChatsCubit extends Cubit<ChatsState> {
ChatMessageModel message, {
required bool isOwn,
required List<ChatRoom> chatRooms,
String? callId,
}) {
final kind = VideoCallChatSignal.kindOf(message);
if (kind == VideoCallChatSignalKind.ended) {
unawaited(_dismissIncomingVideoCallUi());
unawaited(_dismissIncomingVideoCallUi(message.roomId));
unawaited(
VideoCallEndedPushHandler.endActiveCallIfMatches(
conversationUuid: message.roomId,
),
);
return;
}
if (isOwn || kind != VideoCallChatSignalKind.started) return;
@@ -243,6 +293,7 @@ class ChatsCubit extends Cubit<ChatsState> {
message: message,
callerName: caller?.name,
callerAvatarUrl: caller?.profilePhotoUrl,
callId: callId,
);
if (payload == null) return;
unawaited(_showIncomingVideoCall(payload));
@@ -257,10 +308,45 @@ class ChatsCubit extends Cubit<ChatsState> {
}
}
Future<void> _dismissIncomingVideoCallUi() async {
Future<void> _closeCallFromSocket(VideoCallSocketSignal? signal) async {
if (signal == null) return;
if (await _acceptedOnThisDevice(signal)) return;
if (signal.endsActiveCall) {
final endActive = endActiveVideoCall;
if (endActive != null) {
await endActive(
conversationUuid: signal.conversationUuid,
callId: signal.callId,
);
} else {
await VideoCallEndedPushHandler.endActiveCallIfMatches(
conversationUuid: signal.conversationUuid,
callId: signal.callId,
);
}
}
final dismiss = dismissIncomingVideoCall;
if (dismiss != null) {
await dismiss(signal.conversationUuid);
return;
}
await VideoCallEndedPushHandler.dismissIncomingCallUi(
conversationUuid: signal.conversationUuid,
callId: signal.callId,
reason: signal.reason,
);
}
Future<void> _dismissIncomingVideoCallUi(String? conversationUuid) async {
try {
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',
@@ -269,6 +355,34 @@ class ChatsCubit extends Cubit<ChatsState> {
}
}
/// `video-call-accepted` also reaches the device that just answered.
/// Closing CallKit there hangs up the call that is starting.
Future<bool> _acceptedOnThisDevice(VideoCallSocketSignal signal) async {
if (signal.reason != VideoCallEndReason.accepted) return false;
final acceptedId = signal.acceptedDeviceId?.trim();
if (acceptedId == null || acceptedId.isEmpty) return false;
try {
final current =
(await (currentDeviceHwId ?? DeviceHwIdProvider.current)())?.trim();
if (current == null || current.isEmpty) return false;
return current.toLowerCase() == acceptedId.toLowerCase();
} catch (e, st) {
log(
'ChatsCubit: failed to read hw id for accepted call: $e',
stackTrace: st,
);
return false;
}
}
String? _stringField(Map<String, dynamic> json, List<String> keys) {
for (final key in keys) {
final value = json[key];
if (value is String && value.trim().isNotEmpty) return value.trim();
}
return null;
}
ChatUser? _callerFromRooms(
ChatMessageModel message,
List<ChatRoom> chatRooms,
@@ -297,6 +411,9 @@ class ChatsCubit extends Cubit<ChatsState> {
@override
Future<void> close() {
newIncomingMessagesSubscription?.cancel();
videoCallDeclinedSubscription?.cancel();
videoCallEndedSubscription?.cancel();
videoCallAcceptedSubscription?.cancel();
reconnectedSubscription?.cancel();
return super.close();
}

View File

@@ -30,6 +30,7 @@ import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
import 'package:eatvio_app/features/order_info/presentation/info_by_order_screen.dart';
import 'package:eatvio_app/features/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>{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ import 'package:eatvio_app/core/widgets/markdown_html_widget.dart';
import 'package:eatvio_app/features/diets/presentation/widgets/info_bouble.dart';
import 'package:eatvio_app/features/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) {

View File

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

View File

@@ -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 {

View File

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

View File

@@ -162,17 +162,18 @@ class MenuScreen extends StatelessWidget {
actions: [
CupertinoDialogAction(
onPressed: () async {
try {
// Logout revokes the Sanctum token. Unregister first, or
// FCM / VoIP tokens stay on the server and calls still ring.
await sl<FirebaseApi>().unregisterPushTokens();
} catch (e, s) {
await sl<ErrorReporter>().report(e, s, origin: 'MenuScreen');
}
try {
await sl<Dio>().post('${sl<AppConfig>().baseUrl}/api/profile/logout');
} catch (e, s) {
await sl<ErrorReporter>().report(e, s, origin: 'MenuScreen');
} finally {
try {
// Needs Authorization header → must run before removing token.
await sl<FirebaseApi>().unregisterPushTokens();
} catch (e, s) {
await sl<ErrorReporter>().report(e, s, origin: 'MenuScreen');
}
await sl<VideoCallSessionCubit>().endCall();
await sl<WsManager>().clear();
await TokenStorage.removeTokenInStorage();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,7 +15,11 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
class VerifyEmailScreen extends StatefulWidget {
const VerifyEmailScreen({super.key});
const VerifyEmailScreen({super.key, this.returnOnSuccess = false});
/// Вернуться на предыдущий экран вместо перехода в ленту после подтверждения.
/// Используется, когда экран открыт из публичного действия (пост, лайк, комментарий).
final bool returnOnSuccess;
@override
State<VerifyEmailScreen> createState() => _VerifyEmailScreenState();
@@ -89,7 +93,12 @@ class _VerifyEmailScreenState extends State<VerifyEmailScreen> {
_startTimer();
} else if (state is VerifyEmailStateVerifySuccess) {
showSuccessToast('Почта успешно подтверждена');
goRouter.pushNamed('feedScreen');
if (widget.returnOnSuccess) {
// Экран открыт из публичного действия — возвращаемся назад с результатом.
goRouter.pop(true);
} else {
goRouter.pushNamed('feedScreen');
}
} else if (state is VerifyEmailStateVerifyError) {
showErrorToast("Неверный код. Попробуйте снова.");
for (var controller in _controllers) {
@@ -166,18 +175,20 @@ class _VerifyEmailScreenState extends State<VerifyEmailScreen> {
},
),
const SizedBox(height: 16),
CustomOutlinedButton(
text: 'Позже',
height: 52,
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.black900),
buttonStyle: ButtonStyle(
side: const WidgetStatePropertyAll(BorderSide.none),
backgroundColor: WidgetStatePropertyAll(appTheme.gray.withValues(alpha: 0.1)),
// При переходе из публичного действия уйти без подтверждения нельзя.
if (!widget.returnOnSuccess)
CustomOutlinedButton(
text: 'Позже',
height: 52,
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.black900),
buttonStyle: ButtonStyle(
side: const WidgetStatePropertyAll(BorderSide.none),
backgroundColor: WidgetStatePropertyAll(appTheme.gray.withValues(alpha: 0.1)),
),
onPressed: () {
context.read<VerifyEmailCubit>().skipVerification();
},
),
onPressed: () {
context.read<VerifyEmailCubit>().skipVerification();
},
),
],
),
),

View File

@@ -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,7 +9,9 @@ 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_remote_end_registry.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';
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_top_overlay.dart';
@@ -16,6 +19,7 @@ import 'package:eatvio_app/features/video_calls/repositories/video_call_reposito
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
import 'package:livekit_client/livekit_client.dart' as lk;
import 'package:permission_handler/permission_handler.dart';
@@ -30,13 +34,22 @@ class VideoCallManager {
VideoCallForegroundCoordinator? foregroundCoordinator,
VideoCallLifecycleBinding? lifecycleBinding,
VideoCallAudioSessionCoordinator? audioSessionCoordinator,
Future<String?> Function()? resolveHwId,
}) : _videoCallRepository = videoCallRepository,
_profileRepository = profileRepository,
_foreground = foregroundCoordinator ?? VideoCallForegroundCoordinator(),
_lifecycleBinding = lifecycleBinding ?? VideoCallLifecycleBinding(),
_audioSession =
audioSessionCoordinator ?? VideoCallAudioSessionCoordinator() {
audioSessionCoordinator ?? VideoCallAudioSessionCoordinator(),
_resolveHwId = resolveHwId ?? DeviceHwIdProvider.current {
_audioSession.onIosAudioSessionActivated = _restartIosCaptureIfMicOn;
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;
@@ -44,9 +57,13 @@ class VideoCallManager {
final VideoCallForegroundCoordinator _foreground;
final VideoCallLifecycleBinding _lifecycleBinding;
final VideoCallAudioSessionCoordinator _audioSession;
final Future<String?> Function() _resolveHwId;
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 +82,10 @@ class VideoCallManager {
num? _lastMutedMicTotalEnergy;
bool _liveKitDisposed = true;
bool _callEndedNotified = true;
bool _forceEndRequested = false;
bool _sessionMayRequestToken = false;
String? _activeCallId;
final Map<String, DateTime> _leftConversationAt = {};
final Set<String> _callAcceptNotifiedConversationUuids = {};
bool _callStageEntered = false;
bool _joinInProgress = false;
@@ -86,6 +107,16 @@ class VideoCallManager {
String? get counterpartName => _snapshot.counterpartName;
String? get localParticipantName => _snapshot.localParticipantName;
bool get callEndedNotified => _callEndedNotified;
String? get activeCallId => _activeCallId;
void rememberCallId(String? callId) {
final id = callId?.trim();
if (id == null || id.isEmpty) return;
_activeCallId = id;
}
bool get isActiveCall =>
_snapshot.connectPhase == VideoCallConnectPhase.connected &&
_callStageEntered &&
@@ -164,13 +195,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 +232,7 @@ class VideoCallManager {
);
_ensureLifecycleAttached();
unawaited(_startCallAudioSession());
return;
return true;
}
if (!_callStageEntered &&
_snapshot.connectPhase == VideoCallConnectPhase.connected &&
@@ -199,14 +246,32 @@ class VideoCallManager {
),
);
_ensureLifecycleAttached();
return;
return true;
}
}
await endCall();
final previousUuid = _snapshot.conversationUuid;
final hasLiveRoom = _room != null && !_liveKitDisposed;
if (shouldPreserveCallKitWhenStartingCall(
previousConversationUuid: previousUuid,
nextConversationUuid: conversationUuid,
)) {
// The accepted CallKit call owns the iOS microphone. A full endCall
// here deactivates that session before the mic track exists.
if (hasLiveRoom) {
_disposeRoomEphemerals();
await _disconnectLiveKitWithTimeout();
_liveKitDisposed = false;
_callStageEntered = false;
}
} else {
await endCall();
}
_sessionMayRequestToken = true;
_resetSessionFlagsForNewRoom();
_callEndedNotified = false;
_forceEndRequested = false;
_liveKitDisposed = false;
_callStageEntered = false;
_room = lk.Room(
@@ -240,9 +305,10 @@ class VideoCallManager {
);
await _prepareLiveKitConnection();
return true;
}
Future<void> onCallScreenVisible({
Future<bool> onCallScreenVisible({
required String conversationUuid,
String? counterpartName,
String? localParticipantName,
@@ -252,7 +318,7 @@ class VideoCallManager {
if (_snapshot.conversationUuid != conversationUuid ||
_room == null ||
_liveKitDisposed) {
await startCall(
return startCall(
conversationUuid: conversationUuid,
counterpartName: counterpartName,
localParticipantName: localParticipantName,
@@ -271,6 +337,7 @@ class VideoCallManager {
unawaited(_ensureLocalParticipantNamePublished());
_syncRemoteParticipantNameFromRoom();
}
return true;
}
}
@@ -391,32 +458,52 @@ class VideoCallManager {
Future<void> endCall() async {
final uuid = _snapshot.conversationUuid;
_disposeRoomEphemerals();
await _disconnectLiveKitOnly();
if (!_callEndedNotified && uuid.isNotEmpty) {
final shouldNotifyBackend = shouldNotifyCallEnded(
alreadyNotified: _callEndedNotified,
hasConversation: uuid.isNotEmpty,
);
if (uuid.isNotEmpty) {
_callEndedNotified = true;
_leftConversationAt[uuid] = DateTime.now();
}
_sessionMayRequestToken = false;
_disposeRoomEphemerals();
await _disconnectLiveKitWithTimeout();
if (shouldNotifyBackend) {
try {
await _videoCallRepository.notifyCallEnded(uuid);
await _videoCallRepository.notifyCallEnded(
uuid,
callId: backendVideoCallId(
callId: _activeCallId,
conversationUuid: uuid,
),
);
} catch (_) {}
}
await _foreground.stopOngoingCall();
await _stopCallAudioSession();
await _dismissIosCallKitAfterHangup(uuid);
_lifecycleBinding.detach();
_callStageEntered = false;
_videoPausedForBackground = false;
_liveKitDisposed = true;
_myProfileNameFuture = null;
_activeCallId = null;
_emitSnapshot(VideoCallManagerSnapshot.initial);
}
/// Notifies backend that this device accepted an incoming call so ringing
/// stops on the user's other devices.
Future<void> notifyIncomingCallAccepted(String conversationUuid) async {
Future<void> notifyIncomingCallAccepted(
String conversationUuid, {
String? callId,
}) async {
final uuid = conversationUuid.trim();
if (uuid.isEmpty) return;
rememberCallId(callId);
if (_callAcceptNotifiedConversationUuids.contains(uuid)) return;
final hwId = (await DeviceHwIdProvider.current())?.trim();
final hwId = (await _resolveHwId())?.trim();
if (hwId == null || hwId.length < 8) return;
_callAcceptNotifiedConversationUuids.add(uuid);
@@ -424,29 +511,43 @@ class VideoCallManager {
await _videoCallRepository.notifyCallAccepted(
conversationUuid: uuid,
hwId: hwId,
callId: _activeCallId ?? callId,
);
} catch (_) {
_callAcceptNotifiedConversationUuids.remove(uuid);
}
}
/// Decline from incoming UI before joining the LiveKit room.
/// Drops local call state after the system decline button.
///
/// `POST /video-call/decline` is sent from iOS and Android at the tap, so
/// other devices stop ringing even when this isolate is not running.
Future<void> declineIncomingCall(String conversationUuid) async {
final uuid = conversationUuid.trim();
if (uuid.isEmpty) {
await endCall();
return;
}
await _cleanupLocalCallAfterDecline(
conversationUuid: uuid.isEmpty ? null : uuid,
);
}
if (_snapshot.conversationUuid == uuid) {
await endCall();
return;
Future<void> _cleanupLocalCallAfterDecline({String? conversationUuid}) async {
final uuid = conversationUuid?.trim();
if (uuid != null && uuid.isNotEmpty) {
_leftConversationAt[uuid] = DateTime.now();
}
await endCall();
try {
await _videoCallRepository.notifyCallEnded(uuid);
} catch (_) {}
_callEndedNotified = true;
_sessionMayRequestToken = false;
_disposeRoomEphemerals();
await _disconnectLiveKitWithTimeout();
await _foreground.stopOngoingCall();
await _stopCallAudioSession();
await _dismissIosCallKitAfterHangup(uuid);
_lifecycleBinding.detach();
_callStageEntered = false;
_videoPausedForBackground = false;
_liveKitDisposed = true;
_myProfileNameFuture = null;
_activeCallId = null;
_emitSnapshot(VideoCallManagerSnapshot.initial);
}
/// [currentUiEnabled] reflects what the user sees on the mic control
@@ -477,6 +578,10 @@ class VideoCallManager {
next,
audioCaptureOptions: _micCaptureOptions,
);
if (next) {
await _bindIosCallKitAudioEngine();
await _audioSession.restartCapture();
}
} catch (_) {
return;
}
@@ -674,6 +779,8 @@ class VideoCallManager {
true,
audioCaptureOptions: _micCaptureOptions,
);
await _bindIosCallKitAudioEngine();
await _audioSession.restartCapture();
} catch (_) {}
}
await _resumeVideoAfterForeground();
@@ -814,6 +921,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 +938,35 @@ 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>((event) {
final room = _room;
if (room == null) return;
final departedIdentity = event.participant.identity;
if (!shouldForceEndCallAfterPeerLeft(
callStageEntered: _callStageEntered,
sessionConnected:
_snapshot.connectPhase == VideoCallConnectPhase.connected,
remoteIdentities: room.remoteParticipants.values
.map((participant) => participant.identity)
.where((identity) => identity != departedIdentity),
)) {
return;
}
_requestForceEnd();
})
..on<lk.ParticipantConnectionQualityUpdatedEvent>((e) {
if (e.participant != room.localParticipant) return;
_syncPoorConnectionOverlay(e.connectionQuality);
@@ -922,13 +1059,21 @@ 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(
_snapshot.conversationUuid,
);
final tokenResponse = await _videoCallRepository
.getParticipantToken(
_snapshot.conversationUuid,
callId: backendVideoCallId(
callId: _activeCallId,
conversationUuid: _snapshot.conversationUuid,
),
)
.timeout(const Duration(seconds: 12));
if (_liveKitDisposed) return;
rememberCallId(tokenResponse.callId);
_preparedWsUrl = tokenResponse.wsUrl;
_preparedToken = tokenResponse.token;
@@ -970,17 +1115,25 @@ class VideoCallManager {
var wsUrl = _preparedWsUrl;
var token = _preparedToken;
if (wsUrl == null || token == null) {
final tokenResponse = await _videoCallRepository.getParticipantToken(
_snapshot.conversationUuid,
);
if (!_tokenRequestAllowed(_snapshot.conversationUuid)) return;
final tokenResponse = await _videoCallRepository
.getParticipantToken(
_snapshot.conversationUuid,
callId: backendVideoCallId(
callId: _activeCallId,
conversationUuid: _snapshot.conversationUuid,
),
)
.timeout(const Duration(seconds: 12));
if (_liveKitDisposed) return;
rememberCallId(tokenResponse.callId);
wsUrl = tokenResponse.wsUrl;
token = tokenResponse.token;
_preparedWsUrl = wsUrl;
_preparedToken = token;
}
await room.connect(wsUrl, token);
await room.connect(wsUrl, token).timeout(const Duration(seconds: 15));
if (_liveKitDisposed) return;
await _ensureLocalParticipantNamePublished();
@@ -991,6 +1144,13 @@ class VideoCallManager {
);
}
Future<void> _disconnectLiveKitWithTimeout() {
return _disconnectLiveKitOnly().timeout(
const Duration(seconds: 3),
onTimeout: () {},
);
}
Future<void> _disconnectLiveKitOnly() async {
if (_room == null) return;
await _stopCallAudioSession();
@@ -1034,6 +1194,11 @@ class VideoCallManager {
if (status.isGranted) return true;
status = await Permission.microphone.request();
if (status.isGranted) return true;
if (Platform.isIOS) {
// CallKit already opened a recording session. A stale permission
// status must not block publishing the incoming microphone.
return true;
}
final ctx2 = _navigatorContext;
if (ctx2 == null || !ctx2.mounted) return false;
final s = S.of(ctx2);
@@ -1048,6 +1213,14 @@ class VideoCallManager {
return false;
}
bool _tokenRequestAllowed(String conversationUuid) {
return mayRequestParticipantToken(
sessionPermitted: _sessionMayRequestToken,
leftAt: _leftConversationAt[conversationUuid],
now: DateTime.now(),
);
}
bool get _canHandleCallAudio =>
_callStageEntered &&
!_liveKitDisposed &&
@@ -1085,10 +1258,14 @@ class VideoCallManager {
if (micOn) {
try {
await lp?.setMicrophoneEnabled(
true,
audioCaptureOptions: _micCaptureOptions,
);
await lp
?.setMicrophoneEnabled(
true,
audioCaptureOptions: _micCaptureOptions,
)
.timeout(const Duration(seconds: 8));
await _bindIosCallKitAudioEngine();
await _audioSession.restartCapture();
} catch (_) {
effectiveMic = false;
}
@@ -1129,6 +1306,26 @@ class VideoCallManager {
_lastMutedMicTotalEnergy = null;
}
Future<void> _bindIosCallKitAudioEngine() async {
if (!Platform.isIOS) return;
await _audioSession.startCallKitCapture();
}
/// CallKit activates the session on accept and again when the call is
/// marked connected. The second activation can stop AudioEngine after the
/// microphone track is already published, so recording has to start again.
Future<void> _restartIosCaptureIfMicOn() async {
if (!_iosMicCaptureReady) return;
// The plugin posts an audio-interruption ended notice in the same
// didActivate. Starting immediately loses to that notice.
await Future<void>.delayed(const Duration(milliseconds: 300));
if (!_iosMicCaptureReady) return;
await _audioSession.startCallKitCapture();
}
bool get _iosMicCaptureReady =>
Platform.isIOS && _snapshot.micEnabled && _callStageEntered;
Future<void> _startCallAudioSession() async {
if (!_canHandleCallAudio) return;
try {
@@ -1141,4 +1338,31 @@ class VideoCallManager {
await _audioSession.stop();
} catch (_) {}
}
/// Incoming iOS keeps CallKit up for the whole call so the microphone
/// session stays. Hang-up has to close that CallKit call.
///
/// An empty id used to match every active call, including the one the
/// callee just answered, and CallKit then deactivated the microphone.
Future<void> _dismissIosCallKitAfterHangup(String? conversationUuid) async {
if (!Platform.isIOS) return;
final uuid = conversationUuid?.trim().toLowerCase() ?? '';
if (uuid.isEmpty) return;
try {
final activeCalls = await FlutterCallkitIncoming.activeCalls();
if (activeCalls.isEmpty) return;
for (final call in activeCalls) {
final handle = call.handle?.trim().toLowerCase();
final extraUuid =
call.extra?['conversationUuid']?.toString().trim().toLowerCase() ??
call.extra?['conversation_uuid']?.toString().trim().toLowerCase();
final matches = handle == uuid || extraUuid == uuid;
if (!matches) continue;
VideoCallRemoteEndRegistry.mark(call.id);
try {
await FlutterCallkitIncoming.endCall(call.id);
} catch (_) {}
}
} catch (_) {}
}
}

View File

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

View File

@@ -9,6 +9,9 @@ abstract class VideoCallTokenResponse with _$VideoCallTokenResponse {
required String token,
required String roomName,
required String wsUrl,
@JsonKey(name: 'call_id') String? callId,
@JsonKey(name: 'call_status') String? callStatus,
@JsonKey(name: 'call_expires_at') String? callExpiresAt,
}) = _VideoCallTokenResponse;
factory VideoCallTokenResponse.fromJson(Map<String, Object?> json) =>
@@ -29,8 +32,11 @@ abstract class VideoCallAdminTokenResponse with _$VideoCallAdminTokenResponse {
@freezed
abstract class VideoCallEndedResponse with _$VideoCallEndedResponse {
const factory VideoCallEndedResponse({required bool sent}) =
_VideoCallEndedResponse;
const factory VideoCallEndedResponse({
required bool sent,
@JsonKey(name: 'call_id') String? callId,
String? status,
}) = _VideoCallEndedResponse;
factory VideoCallEndedResponse.fromJson(Map<String, Object?> json) =>
_$VideoCallEndedResponseFromJson(json);
@@ -41,8 +47,24 @@ abstract class VideoCallAcceptResponse with _$VideoCallAcceptResponse {
const factory VideoCallAcceptResponse({
required bool sent,
@JsonKey(name: 'devices_notified') required int devicesNotified,
@JsonKey(name: 'call_id') String? callId,
String? status,
@JsonKey(name: 'already_accepted') bool? alreadyAccepted,
}) = _VideoCallAcceptResponse;
factory VideoCallAcceptResponse.fromJson(Map<String, Object?> json) =>
_$VideoCallAcceptResponseFromJson(json);
}
@freezed
abstract class VideoCallDeclineResponse with _$VideoCallDeclineResponse {
const factory VideoCallDeclineResponse({
required bool sent,
@JsonKey(name: 'devices_notified') required int devicesNotified,
@JsonKey(name: 'call_id') String? callId,
String? status,
}) = _VideoCallDeclineResponse;
factory VideoCallDeclineResponse.fromJson(Map<String, Object?> json) =>
_$VideoCallDeclineResponseFromJson(json);
}

View File

@@ -4,6 +4,8 @@ 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_peer_departure.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 +32,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,12 +78,25 @@ 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) {
if (isTransientIdleSnapshotWhileCallScreenVisible(
callScreenVisible: _callScreenRouteVisible,
conversationUuid: snapshot.conversationUuid,
needsManualReconnect: snapshot.linkIssue.needsManualReconnect,
connectError: snapshot.connectError,
)) {
// [VideoCallManager.startCall] briefly emits idle while replacing the room.
return;
}
if (_callScreenRouteVisible) {
emit(_sessionStateFromSnapshot(snapshot));
return;
}
_resetLobbyReadyTracking();
emit(const VideoCallSessionState());
unawaited(_restoreSystemUiAfterCall());
@@ -164,7 +181,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
);
}
Future<void> onCallScreenVisible({
Future<bool> onCallScreenVisible({
required String conversationUuid,
String? counterpartName,
String? localParticipantName,
@@ -218,7 +235,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
WidgetsBinding.instance.addPostFrameCallback((_) {
unawaited(_applyVideoCallSystemUi());
});
return;
return true;
}
if (isResumingActiveCall) {
@@ -239,7 +256,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
counterpartName: counterpartName,
localParticipantName: localParticipantName,
counterpartIsConsultant: counterpartIsConsultant ?? false,
micEnabled: false,
micEnabled: isIncomingAccepted,
videoEnabled: false,
callStageEntered: false,
connectError: null,
@@ -248,13 +265,17 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
entryMode: effectiveEntryMode,
),
);
await _videoCallManager.startCall(
final started = await _videoCallManager.startCall(
conversationUuid: conversationUuid,
counterpartName: counterpartName,
localParticipantName: localParticipantName,
micEnabled: false,
micEnabled: isIncomingAccepted,
videoEnabled: false,
);
if (!started) {
if (!isClosed) emit(const VideoCallSessionState());
return false;
}
emit(state.copyWith(entryMode: effectiveEntryMode));
if (isIncomingAccepted) {
await _tryAutoEnterCallStage();
@@ -266,6 +287,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
WidgetsBinding.instance.addPostFrameCallback((_) {
unawaited(_applyVideoCallSystemUi());
});
return true;
}
Future<void> _ensureSystemPipArmedIfInCall() async {
@@ -349,7 +371,14 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
_autoEnterInFlight = true;
try {
await _requestMicBeforeIncomingEnter();
await enterCallStage();
for (var attempt = 0; attempt < 3; attempt++) {
if (isClosed || state.callStageEntered) return;
if (state.entryMode != VideoCallEntryMode.incomingAccepted) return;
if (state.connectPhase != VideoCallConnectPhase.connected) return;
await enterCallStage();
if (state.callStageEntered) return;
await Future<void>.delayed(Duration(milliseconds: 350 * (attempt + 1)));
}
} finally {
_autoEnterInFlight = false;
}
@@ -359,10 +388,11 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
Future<void> _requestMicBeforeIncomingEnter() async {
if (state.entryMode != VideoCallEntryMode.incomingAccepted) return;
final granted = await _requestMicPermissionForCall();
if (!granted || isClosed) return;
await _requestMicPermissionForCall();
if (isClosed) return;
if (state.isMicActiveForUi) return;
await _videoCallManager.toggleMic(state.isMicActiveForUi);
await _videoCallManager.toggleMic(false);
}
@visibleForTesting
@@ -370,15 +400,47 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
Future<bool> _requestMicPermissionForCall() async {
try {
final status = await Permission.microphone.request();
final current = await Permission.microphone.status;
if (current.isGranted) return true;
// The incoming-call activity can keep this future pending when the app
// was already open. The call has to leave the loading screen anyway.
final status = await Permission.microphone.request().timeout(
const Duration(seconds: 3),
onTimeout: () => PermissionStatus.denied,
);
return status.isGranted;
} catch (_) {
return false;
}
}
/// 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 ||
state.connectPhase == VideoCallConnectPhase.connecting;
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 +572,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() {

View File

@@ -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 ?? '';

View File

@@ -8,9 +8,13 @@ class VideoCallRepository {
static const Map<String, dynamic> _emptyJsonBody = <String, dynamic>{};
Future<VideoCallTokenResponse> getParticipantToken(String conversationUuid) {
Future<VideoCallTokenResponse> getParticipantToken(
String conversationUuid, {
String? callId,
}) {
return videoCallService.getParticipantToken(
conversationUuid: conversationUuid,
callId: callId,
);
}
@@ -18,20 +22,46 @@ class VideoCallRepository {
return videoCallService.getAdminToken(conversationUuid: conversationUuid);
}
Future<VideoCallEndedResponse> notifyCallEnded(String conversationUuid) {
Future<VideoCallEndedResponse> notifyCallEnded(
String conversationUuid, {
String? callId,
}) {
final body = <String, dynamic>{
if (callId != null && callId.trim().isNotEmpty) 'call_id': callId.trim(),
};
return videoCallService.notifyCallEnded(
conversationUuid: conversationUuid,
body: _emptyJsonBody,
body: body.isEmpty ? _emptyJsonBody : body,
);
}
Future<VideoCallAcceptResponse> notifyCallAccepted({
required String conversationUuid,
required String hwId,
String? callId,
}) {
return videoCallService.notifyCallAccepted(
conversationUuid: conversationUuid,
body: <String, dynamic>{'hw_id': hwId},
body: <String, dynamic>{
'hw_id': hwId,
if (callId != null && callId.trim().isNotEmpty)
'call_id': callId.trim(),
},
);
}
Future<VideoCallDeclineResponse> notifyCallDeclined({
required String conversationUuid,
required String hwId,
String? callId,
}) {
return videoCallService.notifyCallDeclined(
conversationUuid: conversationUuid,
body: <String, dynamic>{
'hw_id': hwId,
if (callId != null && callId.trim().isNotEmpty)
'call_id': callId.trim(),
},
);
}
}

View File

@@ -5,6 +5,7 @@ import 'package:flutter/services.dart';
typedef VideoCallAudioSessionFactory = Future<AudioSession> Function();
typedef IosCallAudioSessionHandler = Future<void> Function(String method);
typedef IosAdmHandler = Future<void> Function();
/// Configures and owns the platform audio session for an active VoIP call.
class VideoCallAudioSessionCoordinator {
@@ -12,18 +13,38 @@ class VideoCallAudioSessionCoordinator {
VideoCallAudioSessionFactory? audioSessionFactory,
bool? isIos,
IosCallAudioSessionHandler? iosCallAudioSession,
IosAdmHandler? stopLocalRecording,
IosAdmHandler? startLocalRecording,
}) : _audioSessionFactory =
audioSessionFactory ?? (() => AudioSession.instance),
_isIos = isIos ?? Platform.isIOS,
_iosCallAudioSession = iosCallAudioSession ?? _invokeIosCallAudioSession;
_iosCallAudioSession = iosCallAudioSession ?? _invokeIosCallAudioSession,
_stopLocalRecording = stopLocalRecording ?? _invokeStopLocalRecording,
_startLocalRecording =
startLocalRecording ?? _invokeStartLocalRecording {
if (iosCallAudioSession == null && _isIos) {
_activationListener = this;
_iosChannel.setMethodCallHandler(_onIosAudioSessionCall);
}
}
static const MethodChannel _iosChannel = MethodChannel(
'com.eatvioapp/call_audio_session',
);
static const MethodChannel _webrtcChannel = MethodChannel(
'FlutterWebRTC.Method',
);
final VideoCallAudioSessionFactory _audioSessionFactory;
final bool _isIos;
final IosCallAudioSessionHandler _iosCallAudioSession;
final IosAdmHandler _stopLocalRecording;
final IosAdmHandler _startLocalRecording;
static VideoCallAudioSessionCoordinator? _activationListener;
/// Called when CallKit activates the audio session, including the second
/// activation from `setCallConnected`.
Future<void> Function()? onIosAudioSessionActivated;
static final AudioSessionConfiguration callConfiguration =
AudioSessionConfiguration(
@@ -31,7 +52,7 @@ class VideoCallAudioSessionCoordinator {
avAudioSessionCategoryOptions:
AVAudioSessionCategoryOptions.allowBluetooth |
AVAudioSessionCategoryOptions.defaultToSpeaker,
avAudioSessionMode: AVAudioSessionMode.voiceChat,
avAudioSessionMode: AVAudioSessionMode.videoChat,
avAudioSessionRouteSharingPolicy:
AVAudioSessionRouteSharingPolicy.defaultPolicy,
avAudioSessionSetActiveOptions: AVAudioSessionSetActiveOptions.none,
@@ -54,6 +75,36 @@ class VideoCallAudioSessionCoordinator {
return _iosChannel.invokeMethod<void>(method);
}
static Future<void> _onIosAudioSessionCall(MethodCall call) async {
if (call.method != 'audioSessionActivated') return;
await _activationListener?.onIosAudioSessionActivated?.call();
}
static Future<void> _ensureWebRtcInitialized() async {
try {
await _webrtcChannel.invokeMethod<void>(
'initialize',
<String, dynamic>{'options': <String, dynamic>{}},
);
} catch (_) {}
}
static Future<void> _invokeStopLocalRecording() async {
await _ensureWebRtcInitialized();
await _webrtcChannel.invokeMethod<void>(
'stopLocalRecording',
<String, dynamic>{},
);
}
static Future<void> _invokeStartLocalRecording() async {
await _ensureWebRtcInitialized();
await _webrtcChannel.invokeMethod<void>(
'startLocalRecording',
<String, dynamic>{},
);
}
Future<void> start() async {
if (_isIos) {
await _iosCallAudioSession('activate');
@@ -66,7 +117,7 @@ class VideoCallAudioSessionCoordinator {
await session.configure(callConfiguration);
_configured = true;
}
await session.setActive(true);
await session.setActive(true).timeout(const Duration(seconds: 5));
}
Future<bool> reactivate() async {
@@ -88,9 +139,33 @@ 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 (_) {}
}
/// flutter-webrtc 1.2 uses AudioEngine ADM. `isAudioEnabled` does not start
/// that engine. `initAndStartRecording` has to run after the microphone
/// track exists. Do not stop first: that tears the capture graph down and
/// the published track stays silent.
Future<void> startCallKitCapture() async {
if (!_isIos) return;
try {
await _startLocalRecording();
_iosActive = true;
} catch (_) {}
}
Future<void> stop() async {
if (_isIos) {
_iosActive = false;
try {
await _stopLocalRecording();
} catch (_) {}
try {
await _iosCallAudioSession('deactivate');
} catch (_) {}

View File

@@ -8,7 +8,10 @@ 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:eatvio_app/features/video_calls/services/video_call_tombstone_registry.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_callkit_incoming/entities/call_event.dart';
@@ -34,6 +37,7 @@ class VideoCallCallKitCoordinator {
final Map<String, CallKitParams> _callKitParamsById = {};
final Set<String> _acceptNavigationCompletedIds = {};
final Map<String, Future<void>> _acceptDeliveriesInFlight = {};
final Map<String, Future<void>> _acceptDeliveriesByConversation = {};
final Set<String> _suppressedEndCallKitIds = {};
String? _activeCallKitId;
bool _initialized = false;
@@ -75,6 +79,10 @@ class VideoCallCallKitCoordinator {
_scheduleNativeVoipDiagnosticsDrain();
case 'acceptedVoipCall':
await _handleNativeAcceptedVoipCall(call.arguments);
case 'declinedVoipCall':
await _handleNativeDeclinedVoipCall(call.arguments);
case 'tombstoneCallId':
_handleNativeTombstoneCallId(call.arguments);
}
});
await _firebaseApi.registerPushToken();
@@ -102,12 +110,19 @@ class VideoCallCallKitCoordinator {
}
Future<void> _onAndroidCallPushMethodCall(MethodCall call) async {
if (call.method == 'onCallKitLaunchIntent') {
log(
'VideoCallCallKitCoordinator: received onCallKitLaunchIntent push from native',
name: 'VideoCallCallKit',
);
await _handleAndroidCallKitLaunchIntent(call.arguments);
switch (call.method) {
case 'onCallKitLaunchIntent':
log(
'VideoCallCallKitCoordinator: received onCallKitLaunchIntent push from native',
name: 'VideoCallCallKit',
);
await _handleAndroidCallKitLaunchIntent(call.arguments);
case 'onCallKitDecline':
log(
'VideoCallCallKitCoordinator: received onCallKitDecline from native',
name: 'VideoCallCallKit',
);
await _handleAndroidCallKitDecline(call.arguments);
}
}
@@ -221,6 +236,21 @@ class VideoCallCallKitCoordinator {
);
}
Future<void> _handleAndroidCallKitDecline(Object? arguments) async {
final data = _stringKeyedMap(arguments);
final extra = _stringKeyedMap(data['extra']);
final callKitId =
_parseString(data, const ['id', 'uuid']) ??
_parseString(extra, const ['conversationUuid', 'conversation_uuid']);
if (callKitId == null) return;
try {
await _androidCallPushChannel.invokeMethod<Object?>('drainPendingCallKitDecline');
} catch (_) {}
await _handleDecline(callKitId, eventExtra: extra);
}
void dispose() {
final observer = _androidResumeObserver;
if (observer != null) {
@@ -362,7 +392,11 @@ class VideoCallCallKitCoordinator {
);
return;
}
await _handleDecline(id);
await _handleDecline(
id,
eventExtra: _callKitEventParams(event)?.extra,
skipBackendNotify: Platform.isIOS && event is CallEventActionCallDecline,
);
}
Future<void> _handleEndedEvent(CallEvent event) async {
@@ -374,7 +408,7 @@ class VideoCallCallKitCoordinator {
);
return;
}
await _handleEnded(id);
await _handleEnded(id, eventExtra: _callKitEventParams(event)?.extra);
}
Future<void> _captureCallKitEventToSentry(CallEvent event) async {
@@ -488,8 +522,9 @@ class VideoCallCallKitCoordinator {
return;
}
final callId = _parseCallId(extra);
await _deliverIncomingAccept(
callKitId: conversationUuid,
callKitId: callId ?? conversationUuid,
conversationUuid: conversationUuid,
counterpartName: _parseString(extra, const [
'counterpartName',
@@ -515,6 +550,17 @@ class VideoCallCallKitCoordinator {
);
}
void _handleNativeTombstoneCallId(Object? arguments) {
final data = _stringKeyedMap(arguments);
final callId = _parseString(data, const ['id', 'call_id', 'callId']);
if (callId == null || callId.isEmpty) return;
VideoCallTombstoneRegistry.mark(callId);
log(
'VideoCallCallKitCoordinator: native tombstone call_id=$callId',
name: 'VideoCallCallKit',
);
}
Future<void> _handleNativeAcceptedVoipCall(Object? arguments) async {
if (!Platform.isIOS) return;
@@ -541,6 +587,9 @@ class VideoCallCallKitCoordinator {
return;
}
final callId = _parseCallId(extra) ?? callKitId;
_manager.rememberCallId(callId);
await _deliverIncomingAccept(
callKitId: callKitId,
conversationUuid: conversationUuid,
@@ -572,6 +621,39 @@ class VideoCallCallKitCoordinator {
);
}
/// Local cleanup after iOS CallKit decline. The backend request is already
/// sent from AppDelegate, including when this isolate is suspended.
Future<void> _handleNativeDeclinedVoipCall(Object? arguments) async {
if (!Platform.isIOS) return;
final data = _stringKeyedMap(arguments);
final extra = _stringKeyedMap(data['extra']);
if (callKitExtraMarksEndedPush(Map<String, dynamic>.from(extra))) return;
final callKitId = _parseString(data, const ['id', 'uuid']);
if (callKitId != null &&
(_suppressedEndCallKitIds.contains(callKitId) ||
VideoCallRemoteEndRegistry.contains(callKitId))) {
return;
}
final conversationUuid = _parseConversationUuid(
extra,
handle: _parseString(data, const ['handle']),
);
final callId = backendVideoCallId(
callId:
_parseCallId(extra) ??
(callKitId != null && callKitId.contains('-') ? callKitId : null),
conversationUuid: conversationUuid,
);
log(
'VideoCallCallKitCoordinator: native declinedVoipCall conversation=$conversationUuid callId=$callId',
name: 'VideoCallCallKit',
);
await _manager.declineIncomingCall(conversationUuid);
}
Future<void> _handleAccept(String callKitId) async {
if (_acceptNavigationCompletedIds.contains(callKitId)) return;
@@ -653,6 +735,13 @@ class VideoCallCallKitCoordinator {
name: 'VideoCallCallKit',
);
final conversationKey = conversationUuid.trim().toLowerCase();
final conversationDelivery = _acceptDeliveriesByConversation[conversationKey];
if (conversationDelivery != null) {
await conversationDelivery;
return;
}
_rememberPendingIncomingAccept(
callKitId: callKitId,
conversationUuid: conversationUuid,
@@ -679,12 +768,16 @@ class VideoCallCallKitCoordinator {
counterpartIsConsultant: counterpartIsConsultant,
);
_acceptDeliveriesInFlight[callKitId] = delivery;
_acceptDeliveriesByConversation[conversationKey] = delivery;
try {
await delivery;
} finally {
if (identical(_acceptDeliveriesInFlight[callKitId], delivery)) {
_acceptDeliveriesInFlight.remove(callKitId);
}
if (identical(_acceptDeliveriesByConversation[conversationKey], delivery)) {
_acceptDeliveriesByConversation.remove(conversationKey);
}
}
}
@@ -698,10 +791,26 @@ class VideoCallCallKitCoordinator {
}) async {
if (_acceptNavigationCompletedIds.contains(callKitId)) return;
_suppressedEndCallKitIds.add(callKitId);
unawaited(_manager.notifyIncomingCallAccepted(conversationUuid));
final params = _paramsForCallId(callKitId);
final callId =
_parseCallId(params?.extra ?? const <String, Object?>{}) ??
(callKitId.contains('-') ? callKitId : null);
if (callId != null) {
_manager.rememberCallId(callId);
}
unawaited(
_manager.notifyIncomingCallAccepted(conversationUuid, callId: callId),
);
// Android accept starts an ongoing-call service that holds the mic.
// Joining LiveKit while that service is still up never leaves the
// loading screen when the app was already in the foreground.
_suppressCallEnd(callKitId);
_suppressCallEnd(conversationUuid);
if (callId != null) _suppressCallEnd(callId);
try {
await _finishCallKitAfterAccept(callKitId);
await _waitForAcceptNavigationReady();
final navigated = await _openConnectingScreenAfterAccept(
@@ -737,8 +846,6 @@ class VideoCallCallKitCoordinator {
);
unawaited(_drainPendingIncomingAcceptWithRetries());
}
await FlutterCallkitIncoming.endCall(callKitId);
} catch (error, stackTrace) {
log(
'VideoCallCallKitCoordinator: accept delivery failed, callKitId=$callKitId: $error',
@@ -746,12 +853,22 @@ class VideoCallCallKitCoordinator {
stackTrace: stackTrace,
);
} finally {
unawaited(
Future<void>.delayed(const Duration(seconds: 2), () {
_suppressedEndCallKitIds.remove(callKitId);
_acceptNavigationCompletedIds.remove(callKitId);
}),
);
// Android ends CallKit right after accept, so the plugin ENDED event
// must be ignored briefly. iOS keeps CallKit for the whole call: a
// later ENDED is a real hang-up and must not be swallowed.
if (Platform.isAndroid) {
final suppressedIds = <String>{
callKitId,
conversationUuid,
?callId,
};
unawaited(
Future<void>.delayed(const Duration(seconds: 2), () {
_suppressedEndCallKitIds.removeAll(suppressedIds);
_acceptNavigationCompletedIds.remove(callKitId);
}),
);
}
}
}
@@ -783,6 +900,12 @@ class VideoCallCallKitCoordinator {
return;
}
_suppressCallEnd(pending.callKitId);
_suppressCallEnd(pending.conversationUuid);
try {
await _finishCallKitAfterAccept(pending.callKitId);
} catch (_) {}
await _waitForAcceptNavigationReady();
await _settleUiAfterResume();
@@ -805,9 +928,44 @@ class VideoCallCallKitCoordinator {
);
} catch (_) {}
}
try {
await FlutterCallkitIncoming.endCall(pending.callKitId);
} catch (_) {}
}
/// After answer: Android dismisses the incoming notification. iOS keeps the
/// CallKit call so the audio session stays, and marks it connected so the
/// plugin stops treating every later `endCall` as the last PushKit report.
Future<void> _finishCallKitAfterAccept(String callKitId) async {
if (Platform.isIOS) {
try {
await FlutterCallkitIncoming.setCallConnected(callKitId);
} catch (_) {}
return;
}
_suppressCallEnd(callKitId);
await FlutterCallkitIncoming.endCall(callKitId);
if (!Platform.isAndroid) return;
await _waitUntilAndroidCallKitReleased();
}
/// Telecom keeps the microphone while the incoming connection is active.
/// Joining LiveKit before it is gone never leaves the loading screen when
/// the app was already in the foreground.
Future<void> _waitUntilAndroidCallKitReleased() async {
final deadline = DateTime.now().add(const Duration(milliseconds: 1500));
while (DateTime.now().isBefore(deadline)) {
try {
final calls = await FlutterCallkitIncoming.activeCalls();
if (calls.isEmpty) return;
} catch (_) {
return;
}
await Future<void>.delayed(const Duration(milliseconds: 100));
}
}
void _suppressCallEnd(String id) {
final trimmed = id.trim();
if (trimmed.isEmpty) return;
_suppressedEndCallKitIds.add(trimmed);
}
Future<void> _settleUiAfterResume() async {
@@ -842,6 +1000,7 @@ class VideoCallCallKitCoordinator {
bool? counterpartIsConsultant,
}) async {
FlutterNativeSplash.remove();
_manager.permitParticipantTokenRequest();
return VideoCallNotificationNavigator.openIncomingCall(
conversationUuid: conversationUuid,
@@ -853,7 +1012,11 @@ class VideoCallCallKitCoordinator {
);
}
Future<void> _handleDecline(String callKitId) async {
Future<void> _handleDecline(
String callKitId, {
Map<String, dynamic>? eventExtra,
bool skipBackendNotify = false,
}) async {
if (_suppressedEndCallKitIds.contains(callKitId)) {
log(
'VideoCallCallKitCoordinator: ignore spurious end/decline during accept, callKitId=$callKitId',
@@ -863,11 +1026,36 @@ 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;
}
if (skipBackendNotify) {
// AppDelegate already POSTed /decline. This plugin event must not send
// /ended for the same tap.
_forgetCallKitId(callKitId);
return;
}
_suppressedEndCallKitIds.add(callKitId);
try {
@@ -879,14 +1067,12 @@ class VideoCallCallKitCoordinator {
}),
);
}
_callKitParamsById.remove(callKitId);
if (_activeCallKitId == callKitId) {
_activeCallKitId = null;
}
_forgetCallKitId(callKitId);
if (_manager.isActiveCall ||
_cubit.state.connectPhase != VideoCallConnectPhase.idle) {
await _cubit.endCall();
VideoCallNotificationNavigator.closeCallRouteIfOpen();
return;
}
@@ -896,7 +1082,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 +1094,53 @@ 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,
) ||
VideoCallRemoteEndRegistry.contains(callKitId);
if (remoteEnd) {
VideoCallRemoteEndRegistry.mark(callKitId);
}
_forgetCallKitId(callKitId);
final inAppCall =
_manager.isActiveCall ||
_cubit.state.connectPhase != VideoCallConnectPhase.idle;
if (inAppCall) {
if (remoteEnd) {
await _cubit.handleRemoteCallEnded();
} else {
await _cubit.endCall();
VideoCallNotificationNavigator.closeCallRouteIfOpen();
}
return;
}
// Native already ends a remote-ended ringing 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);
_acceptNavigationCompletedIds.remove(callKitId);
if (_activeCallKitId == callKitId) {
_activeCallKitId = null;
}
}
static String _parseConversationUuid(
Map<String, Object?> extra, {
String? handle,
@@ -933,6 +1159,10 @@ class VideoCallCallKitCoordinator {
return '';
}
static String? _parseCallId(Map<String, Object?> extra) {
return _parseString(extra, const ['call_id', 'callId']);
}
static String? _parseString(Map<String, Object?> map, List<String> keys) {
for (final key in keys) {
final value = map[key];

View File

@@ -57,6 +57,7 @@ abstract final class VideoCallChatSignal {
required ChatMessageModel message,
String? callerName,
String? callerAvatarUrl,
String? callId,
}) {
if (kindOf(message) != VideoCallChatSignalKind.started) return null;
final conversationUuid = message.roomId?.trim();
@@ -64,6 +65,7 @@ abstract final class VideoCallChatSignal {
final resolvedName = callerName?.trim();
final avatar = callerAvatarUrl?.trim();
final serverCallId = callId?.trim();
return VideoCallPushPayload(
conversationUuid: conversationUuid,
counterpartName: resolvedName != null && resolvedName.isNotEmpty
@@ -71,6 +73,9 @@ abstract final class VideoCallChatSignal {
: callerNameFromBody(message.data),
counterpartAvatarUrl: avatar != null && avatar.isNotEmpty ? avatar : null,
messageId: message.id > 0 ? message.id : null,
callId: serverCallId != null && serverCallId.isNotEmpty
? serverCallId
: null,
);
}

View File

@@ -0,0 +1,35 @@
/// Cause of a `video_call_ended` push / Echo event.
///
/// This is not the session status. It tells the client how to close ringing
/// or an active call.
enum VideoCallEndReason {
accepted,
declined,
cancelled,
ended,
timeout;
static VideoCallEndReason? tryParse(Object? raw) {
if (raw == null) return null;
final normalized = raw.toString().trim().toLowerCase().replaceAll('-', '_');
if (normalized.isEmpty) return null;
for (final value in VideoCallEndReason.values) {
if (value.name == normalized) return value;
}
if (normalized == 'hangup' || normalized == 'hang_up') {
return VideoCallEndReason.ended;
}
return null;
}
/// Close ringing UI; do not tear down an already-accepted LiveKit call.
bool get dismissesRingingOnly =>
this == VideoCallEndReason.accepted ||
this == VideoCallEndReason.declined ||
this == VideoCallEndReason.cancelled ||
this == VideoCallEndReason.timeout;
/// Tear down an accepted in-progress call.
bool get endsActiveCall =>
this == VideoCallEndReason.ended || this == VideoCallEndReason.timeout;
}

View File

@@ -1,6 +1,13 @@
import 'dart:developer';
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_end_reason.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_remote_end_registry.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_tombstone_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,29 +16,176 @@ abstract final class VideoCallEndedPushHandler {
'com.eatvioapp/video_call_push',
);
static Future<void> dismissIncomingCallUi() async {
static Future<void> dismissIncomingCallUi({
String? conversationUuid,
String? callId,
VideoCallEndReason? reason,
}) async {
final normalizedCallId = callId?.trim();
if (normalizedCallId != null && normalizedCallId.isNotEmpty) {
VideoCallTombstoneRegistry.mark(normalizedCallId);
}
log(
'VideoCallEndedPushHandler: dismiss '
'call_id=${normalizedCallId ?? '-'} '
'conversation=${conversationUuid ?? '-'} '
'reason=${reason?.name ?? '-'}',
name: 'VideoCallEndedPush',
);
final shouldEndActive = reason == null || reason.endsActiveCall;
if (shouldEndActive && reason != VideoCallEndReason.accepted) {
await endActiveCallIfMatches(
conversationUuid: conversationUuid,
callId: normalizedCallId,
);
}
if (Platform.isAndroid) {
try {
await _androidPushChannel.invokeMethod<void>('dismissIncomingCalls');
await _androidPushChannel.invokeMethod<void>(
'dismissIncomingCalls',
<String, String?>{
if (normalizedCallId != null && normalizedCallId.isNotEmpty)
'call_id': normalizedCallId,
if (conversationUuid != null && conversationUuid.trim().isNotEmpty)
'conversation_uuid': conversationUuid.trim(),
},
);
return;
} catch (_) {}
}
await _dismissViaCallKit();
await _dismissViaCallKit(
conversationUuid: conversationUuid,
callId: normalizedCallId,
);
}
static Future<void> _dismissViaCallKit() async {
/// Closes the CallKit / incoming UI without ending the LiveKit session.
/// Used when this client hangs up: CallKit stays up for the whole incoming
/// call on iOS and must be dismissed with the in-app hang-up.
static Future<void> dismissCallKitOnly({
String? conversationUuid,
String? callId,
}) {
return _dismissViaCallKit(
conversationUuid: conversationUuid,
callId: callId,
);
}
static Future<void> endActiveCallIfMatches({
String? conversationUuid,
String? callId,
}) 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;
// callId is reserved for ringing dismiss matching; active sessions are
// still keyed by conversation until the cubit stores call_id.
try {
await cubit.handleRemoteCallEnded();
} catch (_) {}
}
static bool callMatches({
required CallKitParams call,
String? conversationUuid,
String? callId,
}) {
final normalizedCallId = callId?.trim().toLowerCase();
if (normalizedCallId != null && normalizedCallId.isNotEmpty) {
final id = call.id.trim().toLowerCase();
if (id.isNotEmpty && id == normalizedCallId) return true;
final extra = call.extra;
if (extra != null) {
for (final key in const ['call_id', 'callId']) {
final value = extra[key];
if (value is String &&
value.trim().toLowerCase() == normalizedCallId) {
return true;
}
}
}
// A ring that already has its own call id must not be closed by a
// stale ended event for another call in the same chat. A legacy ring
// whose id is only the conversation still has to close.
if (_callHasDistinctCallId(call)) return false;
}
final uuid = conversationUuid?.trim().toLowerCase();
if (uuid == null || uuid.isEmpty) return false;
return callMatchesConversation(call, uuid);
}
/// True when this ring already carries a server call id. Conversation
/// fallback would close a newer call in the same chat.
static bool _callHasDistinctCallId(CallKitParams call) {
final extra = call.extra;
if (extra == null) return false;
for (final key in const ['call_id', 'callId']) {
final value = extra[key];
if (value is String && value.trim().isNotEmpty) return true;
}
return false;
}
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,
String? callId,
}) async {
final uuid = conversationUuid?.trim();
final normalizedCallId = callId?.trim();
try {
final activeCalls = await FlutterCallkitIncoming.activeCalls();
final hasTarget =
(normalizedCallId != null && normalizedCallId.isNotEmpty) ||
(uuid != null && uuid.isNotEmpty);
if (!hasTarget) {
for (final call in activeCalls) {
VideoCallRemoteEndRegistry.mark(call.id);
}
await FlutterCallkitIncoming.endAllCalls();
return;
}
for (final call in activeCalls) {
if (!callMatches(
call: call,
conversationUuid: uuid,
callId: normalizedCallId,
)) {
continue;
}
VideoCallRemoteEndRegistry.mark(call.id);
try {
await FlutterCallkitIncoming.endCall(call.id);
} catch (_) {}
}
} catch (_) {}
try {
await FlutterCallkitIncoming.endAllCalls();
} catch (_) {}
}
}

View File

@@ -1,11 +1,58 @@
import 'package:eatvio_app/features/video_calls/services/video_call_end_reason.dart';
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,
this.callId,
this.reason,
this.acceptedDeviceId,
this.acceptedClient,
});
final String? conversationUuid;
final String? callId;
final VideoCallEndReason? reason;
final String? acceptedDeviceId;
final String? acceptedClient;
static VideoCallEndedPushPayload? tryParse(Map<String, dynamic> data) {
if (!VideoCallPushPayload.isEndedVideoCall(data)) return null;
return const VideoCallEndedPushPayload._();
final conversationUuid = _string(data, const [
'conversationUuid',
'conversation_uuid',
'uuid',
]);
final callId = _string(data, const ['call_id', 'callId']);
final acceptedDeviceId = _string(data, const [
'accepted_device_id',
'acceptedDeviceId',
]);
final acceptedClient = _string(data, const [
'accepted_client',
'acceptedClient',
]);
return VideoCallEndedPushPayload(
conversationUuid: conversationUuid,
callId: callId,
reason: VideoCallEndReason.tryParse(
data['reason'] ?? data['end_reason'] ?? data['endReason'],
),
acceptedDeviceId: acceptedDeviceId,
acceptedClient: acceptedClient,
);
}
static String? _string(Map<String, dynamic> data, List<String> keys) {
for (final key in keys) {
final value = data[key];
if (value is String && value.trim().isNotEmpty) {
return value.trim();
}
}
return null;
}
}

View File

@@ -58,11 +58,17 @@ abstract final class VideoCallFcmHandler {
}
log(
'VideoCallFcmHandler: dismiss incoming call UI, source=$source',
'VideoCallFcmHandler: dismiss incoming call UI, source=$source '
'call_id=${endedPayload.callId ?? '-'} '
'reason=${endedPayload.reason?.name ?? '-'}',
name: 'VideoCallFcm',
);
try {
await VideoCallEndedPushHandler.dismissIncomingCallUi();
await VideoCallEndedPushHandler.dismissIncomingCallUi(
conversationUuid: endedPayload.conversationUuid,
callId: endedPayload.callId,
reason: endedPayload.reason,
);
} catch (e, st) {
log(
'VideoCallFcmHandler: failed to dismiss incoming call UI: $e',

View File

@@ -1,25 +1,93 @@
import 'dart:developer';
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:eatvio_app/features/video_calls/services/video_call_tombstone_registry.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 ios = isIos ?? Platform.isIOS;
if (VideoCallTombstoneRegistry.contains(payload.callId)) {
log(
'VideoCallIncomingPushPresenter: skip tombstoned call_id=${payload.callId}',
name: 'VideoCallIncoming',
);
return;
}
if (!ios && (showAndroidIncoming != null || Platform.isAndroid)) {
await (showAndroidIncoming ?? _showAndroidIncoming)(payload);
return;
}
final callId = VideoCallKitId.resolve(
callId: payload.callId,
conversationUuid: payload.conversationUuid,
messageId: payload.messageId,
isIos: ios,
);
final callId = Platform.isIOS
? VideoCallKitId.iosUuid(
conversationUuid: payload.conversationUuid,
messageId: payload.messageId,
) ??
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,
@@ -31,6 +99,7 @@ abstract final class VideoCallIncomingPushPresenter {
duration: 45000,
extra: <String, dynamic>{
'conversationUuid': payload.conversationUuid,
if (payload.callId != null) 'call_id': payload.callId,
if (payload.counterpartName != null)
'counterpartName': payload.counterpartName,
if (payload.counterpartAvatarUrl != null)
@@ -39,8 +108,14 @@ abstract final class VideoCallIncomingPushPresenter {
'localParticipantName': payload.localParticipantName,
if (payload.counterpartIsConsultant != null)
'counterpartIsConsultant': payload.counterpartIsConsultant,
if (payload.messageId != null) 'message_id': payload.messageId,
},
callingNotification: const NotificationParams(showNotification: false),
missedCallNotification: const NotificationParams(
showNotification: true,
subtitle: iosMissedCallSubtitle,
callbackText: iosMissedCallCallbackText,
),
android: const AndroidParams(
isCustomNotification: true,
isShowLogo: false,
@@ -57,10 +132,65 @@ 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.callId != null) 'call_id': payload.callId!,
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(),
if (payload.expiresAt != null)
'expires_at': payload.expiresAt!.toUtc().toIso8601String(),
});
} 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;
}
}

View File

@@ -1,14 +1,33 @@
/// CallKit on iOS rejects a second incoming call that reuses a UUID.
/// Resolves the CallKit / ConnectionService id for an incoming call.
///
/// The conversation uuid stays in the call payload for navigation. The CallKit
/// id is derived from that uuid plus the chat message id, so one ring is one
/// call and the next ring in the same chat still appears. Android notifications
/// keep the conversation uuid: the same id updates one incoming notification.
/// Prefer the backend `call_id` (stable for the whole session). Legacy
/// fallback: conversation uuid + message id on iOS, conversation uuid on
/// Android.
abstract final class VideoCallKitId {
static final RegExp _uuidPrefix = RegExp(
r'^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-)[0-9a-fA-F]{12}$',
);
static String resolve({
String? callId,
required String conversationUuid,
int? messageId,
required bool isIos,
}) {
final serverCallId = callId?.trim();
if (serverCallId != null && serverCallId.isNotEmpty) {
return serverCallId;
}
if (isIos) {
return iosUuid(
conversationUuid: conversationUuid,
messageId: messageId,
) ??
conversationUuid;
}
return conversationUuid;
}
static String? iosUuid({
required String conversationUuid,
required int? messageId,

View File

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

View File

@@ -0,0 +1,133 @@
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);
}
/// Hang up reports the end the same way the web client does after it leaves
/// the room. The server then sends the ended chat message and push when no
/// other person is left in the room, which drops the call on the other devices.
bool shouldNotifyCallEnded({
required bool alreadyNotified,
required bool hasConversation,
}) {
if (!hasConversation || alreadyNotified) 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;
}
/// Starting an incoming call must not run [endCall]. That dismisses the
/// CallKit call which just activated the microphone, and an empty
/// conversation id dismisses every active CallKit call.
bool shouldPreserveCallKitWhenStartingCall({
required String previousConversationUuid,
required String nextConversationUuid,
}) {
final previous = previousConversationUuid.trim();
final next = nextConversationUuid.trim();
if (previous.isEmpty) return true;
return previous == next;
}
/// `POST /decline` and `POST /ended` treat `call_id` as the session uuid.
/// The conversation uuid is a different id; sending it makes the request 404
/// and the other devices never hear about the hang-up.
String? backendVideoCallId({
required String? callId,
required String conversationUuid,
}) {
final id = callId?.trim() ?? '';
if (id.isEmpty) return null;
if (id == conversationUuid.trim()) return null;
return id;
}
/// [startCall] emits an empty idle snapshot while it replaces the room.
/// A real failure (lost link or a connect error) still has to reach the
/// visible call screen, otherwise the loading screen never leaves.
bool isTransientIdleSnapshotWhileCallScreenVisible({
required bool callScreenVisible,
required String conversationUuid,
required bool needsManualReconnect,
required String? connectError,
}) {
if (!callScreenVisible) return false;
if (needsManualReconnect) return false;
if (connectError != null && connectError.trim().isNotEmpty) return false;
return conversationUuid.trim().isEmpty;
}

View File

@@ -6,6 +6,8 @@ class VideoCallPushPayload {
this.localParticipantName,
this.counterpartIsConsultant,
this.messageId,
this.callId,
this.expiresAt,
});
final String conversationUuid;
@@ -14,6 +16,8 @@ class VideoCallPushPayload {
final String? localParticipantName;
final bool? counterpartIsConsultant;
final int? messageId;
final String? callId;
final DateTime? expiresAt;
static VideoCallPushPayload? tryParse(Map<String, dynamic> data) {
if (!isIncomingVideoCall(data)) return null;
@@ -52,6 +56,8 @@ class VideoCallPushPayload {
'isConsultant',
]),
messageId: _messageId(data),
callId: _string(data, const ['call_id', 'callId']),
expiresAt: _dateTime(data, const ['expires_at', 'expiresAt']),
);
}
@@ -119,6 +125,9 @@ class VideoCallPushPayload {
static String? conversationUuidFromData(Map<String, dynamic> data) =>
_conversationUuid(data);
static String? callIdFromData(Map<String, dynamic> data) =>
_string(data, const ['call_id', 'callId']);
static String? _conversationUuid(Map<String, dynamic> data) {
final fromData = _string(data, const [
'conversationUuid',
@@ -181,6 +190,17 @@ class VideoCallPushPayload {
return null;
}
static DateTime? _dateTime(Map<String, dynamic> data, List<String> keys) {
for (final key in keys) {
final value = data[key];
if (value is DateTime) return value;
if (value is String && value.trim().isNotEmpty) {
return DateTime.tryParse(value.trim());
}
}
return null;
}
static bool _isTruthy(Object? value) {
if (value == null) return false;
if (value is bool) return value;

View File

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

View File

@@ -11,6 +11,7 @@ abstract class VideoCallService {
@GET('/api/v2/chat/{conversation}/video-call/token')
Future<VideoCallTokenResponse> getParticipantToken({
@Path('conversation') required String conversationUuid,
@Query('call_id') String? callId,
});
@GET('/api/v2/chat/{conversation}/video-call/admin-token')
@@ -29,4 +30,10 @@ abstract class VideoCallService {
@Path('conversation') required String conversationUuid,
@Body() required Map<String, dynamic> body,
});
@POST('/api/v2/chat/{conversation}/video-call/decline')
Future<VideoCallDeclineResponse> notifyCallDeclined({
@Path('conversation') required String conversationUuid,
@Body() required Map<String, dynamic> body,
});
}

View File

@@ -0,0 +1,93 @@
import 'dart:convert';
import 'package:eatvio_app/features/video_calls/services/video_call_end_reason.dart';
/// `video-call-declined` / `video-call-ended` on `private-new-chat-messages`.
///
/// The web client closes every other device from these events. A decline does
/// not create a chat message, so the open app has to read the socket itself.
class VideoCallSocketSignal {
const VideoCallSocketSignal({
required this.endsActiveCall,
this.conversationUuid,
this.callId,
this.reason,
this.acceptedDeviceId,
this.acceptedClient,
});
final bool endsActiveCall;
final String? conversationUuid;
final String? callId;
final VideoCallEndReason? reason;
final String? acceptedDeviceId;
final String? acceptedClient;
static VideoCallSocketSignal? tryParseDeclined(Object? raw) {
return _tryParse(raw, declined: true);
}
static VideoCallSocketSignal? tryParseEnded(Object? raw) {
return _tryParse(raw, declined: false);
}
static VideoCallSocketSignal? _tryParse(
Object? raw, {
required bool declined,
}) {
final data = _asMap(raw);
if (data == null) return null;
final conversationUuid = _string(data, const [
'conversation_uuid',
'conversationUuid',
]);
final callId = _string(data, const ['call_id', 'callId']);
if ((conversationUuid == null || conversationUuid.isEmpty) &&
(callId == null || callId.isEmpty)) {
return null;
}
final reason = declined
? VideoCallEndReason.declined
: VideoCallEndReason.tryParse(data['reason'] ?? data['status']);
final endsActiveCall = declined || reason != VideoCallEndReason.accepted;
return VideoCallSocketSignal(
endsActiveCall: endsActiveCall,
conversationUuid: conversationUuid,
callId: callId,
reason: reason,
acceptedDeviceId: _string(data, const [
'accepted_device_id',
'acceptedDeviceId',
]),
acceptedClient: _string(data, const [
'accepted_client',
'acceptedClient',
]),
);
}
static Map<String, dynamic>? _asMap(Object? raw) {
if (raw is Map) {
return raw.map((key, value) => MapEntry(key.toString(), value));
}
if (raw is! String || raw.trim().isEmpty) return null;
try {
final decoded = jsonDecode(raw);
if (decoded is Map) {
return decoded.map((key, value) => MapEntry(key.toString(), value));
}
} catch (_) {}
return null;
}
static String? _string(Map<String, dynamic> data, List<String> keys) {
for (final key in keys) {
final value = data[key];
if (value is String && value.trim().isNotEmpty) return value.trim();
}
return null;
}
}

View File

@@ -0,0 +1,37 @@
/// Remembers cancelled / declined call ids so a late `video_call_started`
/// push does not show an already-finished ring.
abstract final class VideoCallTombstoneRegistry {
static const Duration defaultTtl = Duration(minutes: 2);
static final Map<String, DateTime> _expiresAtByCallId = <String, DateTime>{};
static void mark(
String? callId, {
DateTime? now,
Duration ttl = defaultTtl,
}) {
final id = _normalize(callId);
if (id == null) return;
final at = now ?? DateTime.now();
_expiresAtByCallId[id] = at.add(ttl);
}
static bool contains(String? callId, {DateTime? now}) {
final id = _normalize(callId);
if (id == null) return false;
_prune(now ?? DateTime.now());
return _expiresAtByCallId.containsKey(id);
}
static void clear() => _expiresAtByCallId.clear();
static void _prune(DateTime now) {
_expiresAtByCallId.removeWhere((_, expiresAt) => !expiresAt.isAfter(now));
}
static String? _normalize(String? callId) {
final id = callId?.trim().toLowerCase();
if (id == null || id.isEmpty) return null;
return id;
}
}

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