forked from eatvio/eatvio_app
Compare commits
82 Commits
6d2935400a
...
issue-1123
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45d072cff4 | ||
| 67920b2016 | |||
|
|
fc28062bb5 | ||
| f947182632 | |||
|
|
27b8128a19 | ||
| 924dc2c16a | |||
| 53ce73b41e | |||
|
|
1829ad611d | ||
| 2b2991012d | |||
| 7a456c2d9d | |||
| 36079de81c | |||
| 3825a974f9 | |||
| 678d73932c | |||
| 8942e11be9 | |||
|
|
55e1633d1c | ||
| 2668907f60 | |||
| 56ab9b65f9 | |||
| 5b2ac4f228 | |||
| 51aff6c175 | |||
| cba2689406 | |||
| 5f0b7e026b | |||
| 85bc2c6d5c | |||
| f58892bdeb | |||
| f14f426381 | |||
| 6a31880a85 | |||
| 3a41a85f9d | |||
| 194b8d5b16 | |||
|
|
508c321913 | ||
|
|
4a5c67632e | ||
|
|
f6f9764811 | ||
| e2d287eb9e | |||
| cf5c42ff4b | |||
|
|
5e6e114d5d | ||
|
|
e9cfcb9637 | ||
| 28c62bf775 | |||
| 67e6526552 | |||
|
|
1c8709fc50 | ||
| dedb55ecca | |||
| 6b4c88068e | |||
|
|
d30c63be85 | ||
|
|
0ddd4c6b14 | ||
| 04dab02a6d | |||
| c45612decb | |||
|
|
2779f79464 | ||
| db293bf13f | |||
| 05eaa3caa9 | |||
|
|
d8d7a211bd | ||
| fe54037cef | |||
| e6601e2fe6 | |||
|
|
992573aead | ||
| c70c9beb20 | |||
| d2fc087699 | |||
|
|
3122394037 | ||
| 18925fd95e | |||
|
|
9916828956 | ||
| 37a787c8d0 | |||
| fb68f02ca4 | |||
|
|
8cb3a184b0 | ||
| 4b72258b1f | |||
|
|
592f387221 | ||
|
|
dcf695777a | ||
| 3b8d1bf651 | |||
|
|
141cf5543d | ||
| 40ba5f304e | |||
|
|
996cf07fb8 | ||
|
|
1766314bbe | ||
| d1575d7ac4 | |||
| 94bca6a793 | |||
|
|
a6b94f835c | ||
|
|
4c38026748 | ||
|
|
355ec2df48 | ||
| 04195dd95b | |||
|
|
d3336b1263 | ||
| 82f5c9a7fe | |||
|
|
39a512193e | ||
| 4dc5839317 | |||
|
|
da6158625f | ||
|
|
5a8f8aee3e | ||
|
|
86e9d0cc31 | ||
| 60080c7407 | |||
| 293601f1e1 | |||
| 6a259e5452 |
@@ -14,7 +14,7 @@ env:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: [ubuntu-latest]
|
||||
runs-on: [debian-12]
|
||||
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@v3
|
||||
@@ -155,10 +155,10 @@ jobs:
|
||||
|
||||
- name: Get dependencies
|
||||
run: |
|
||||
flutter clean
|
||||
flutter pub cache repair
|
||||
flutter pub get
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
flutter clean
|
||||
flutter pub cache repair
|
||||
flutter pub get
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
|
||||
- name: Native Analyze
|
||||
run: flutter analyze
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -16,6 +16,8 @@
|
||||
*.mocks.dart
|
||||
android/build/
|
||||
ios/XCBuildData/
|
||||
docs/
|
||||
specs/
|
||||
env/.env
|
||||
.env
|
||||
android/key.properties
|
||||
@@ -25,4 +27,4 @@ backend/
|
||||
.fvm/
|
||||
.fvmrc
|
||||
/eatvio
|
||||
*.code-workspace
|
||||
*.code-workspace
|
||||
|
||||
@@ -39,7 +39,7 @@ android {
|
||||
versionName = flutter.versionName
|
||||
manifestPlaceholders = [
|
||||
YANDEX_CLIENT_ID: yandexClientId,
|
||||
applicationName: "io.flutter.app.FlutterApplication"
|
||||
applicationName: "com.eatvioapp.app.EatvioApplication"
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,12 @@ object CallKitIntentBridge {
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.eatvioapp.app
|
||||
|
||||
import android.app.Application
|
||||
|
||||
class EatvioApplication : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
VideoCallDeclineReporter.install(this)
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,71 @@
|
||||
package com.eatvioapp.app
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -25,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"
|
||||
@@ -38,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()
|
||||
}
|
||||
@@ -55,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()
|
||||
}
|
||||
}
|
||||
@@ -226,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
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.eatvioapp.app
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
|
||||
/// Stores a decline until Flutter can POST /video-call/decline.
|
||||
///
|
||||
/// The plugin broadcasts the decline. If the Flutter engine is not attached,
|
||||
/// that broadcast never reaches the backend and the call keeps ringing on the
|
||||
/// user's other devices.
|
||||
object PendingCallKitDeclineBridge {
|
||||
private const val TAG = "PendingCallKitDecline"
|
||||
|
||||
@Volatile
|
||||
private var pendingPayload: Map<String, Any?>? = null
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
fun store(payload: Map<String, Any?>) {
|
||||
pendingPayload = payload
|
||||
Log.i(TAG, "stored pending decline id=${payload["id"]}")
|
||||
}
|
||||
|
||||
fun peek(): Map<String, Any?>? = pendingPayload
|
||||
|
||||
fun drain(): Map<String, Any?>? {
|
||||
val payload = pendingPayload
|
||||
pendingPayload = null
|
||||
if (payload != null) {
|
||||
Log.i(TAG, "drained pending decline id=${payload["id"]}")
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
fun scheduleDeliverToFlutter(
|
||||
deliver: () -> Unit,
|
||||
delaysMs: LongArray = longArrayOf(0L, 250L, 700L, 1500L, 2800L, 4200L, 6000L),
|
||||
) {
|
||||
for (delayMs in delaysMs) {
|
||||
mainHandler.postDelayed({ deliver() }, delayMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.eatvioapp.app
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
internal object FlutterSharedPrefs {
|
||||
const val NAME = "FlutterSharedPreferences"
|
||||
private const val PREFIX = "flutter."
|
||||
|
||||
fun string(context: Context, key: String): String? {
|
||||
return context
|
||||
.getSharedPreferences(NAME, Context.MODE_PRIVATE)
|
||||
.getString(PREFIX + key, null)
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
}
|
||||
|
||||
internal object VideoCallDeclineClient {
|
||||
private const val TAG = "VideoCallDecline"
|
||||
private const val TIMEOUT_MS = 4_000
|
||||
|
||||
fun send(context: Context, handle: String?, extra: Map<*, *>?) {
|
||||
val fields = VideoCallDeclineRequestFactory.fields(handle, extra)
|
||||
val baseUrl = FlutterSharedPrefs.string(context, "apiBaseUrl")
|
||||
?: FlutterSharedPrefs.string(context, "customBaseUrl")
|
||||
val request = VideoCallDeclineRequestFactory.build(
|
||||
baseUrl = baseUrl,
|
||||
token = FlutterSharedPrefs.string(context, "token"),
|
||||
hwId = FlutterSharedPrefs.string(context, "eatvio.android_install_hw_id"),
|
||||
fields = fields,
|
||||
)
|
||||
if (request == null) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"skip decline POST conversation=${fields.conversationUuid} ended=${fields.endedPush}",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val code = post(request)
|
||||
Log.i(TAG, "decline POST status=$code conversation=${fields.conversationUuid}")
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "decline POST failed conversation=${fields.conversationUuid}", error)
|
||||
}
|
||||
}
|
||||
|
||||
private fun post(request: VideoCallDeclineRequest): Int {
|
||||
val connection = (URL(request.url).openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = "POST"
|
||||
connectTimeout = TIMEOUT_MS
|
||||
readTimeout = TIMEOUT_MS
|
||||
doOutput = true
|
||||
setRequestProperty("Authorization", "Bearer ${request.token}")
|
||||
setRequestProperty("Accept", "application/json")
|
||||
setRequestProperty("Content-Type", "application/json; charset=utf-8")
|
||||
}
|
||||
try {
|
||||
connection.outputStream.use { stream ->
|
||||
stream.write(request.body.toByteArray(Charsets.UTF_8))
|
||||
}
|
||||
val code = connection.responseCode
|
||||
val response = if (code in 200..299) connection.inputStream else connection.errorStream
|
||||
response?.use { stream -> stream.readBytes() }
|
||||
return code
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.eatvioapp.app
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.core.os.BundleCompat
|
||||
import com.hiennv.flutter_callkit_incoming.CallkitConstants
|
||||
import com.hiennv.flutter_callkit_incoming.CallkitEventCallback
|
||||
import com.hiennv.flutter_callkit_incoming.FlutterCallkitIncomingPlugin
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
/**
|
||||
* Sends `POST /video-call/decline` from the process that handled the tap.
|
||||
*
|
||||
* The notification Decline button is an explicit broadcast into the CallKit
|
||||
* plugin. That broadcast starts the process when the app was killed and does
|
||||
* not start Flutter. The plugin notifies this callback before it returns, so
|
||||
* the request can finish while the process is still alive.
|
||||
*/
|
||||
object VideoCallDeclineReporter : CallkitEventCallback {
|
||||
private const val TAG = "VideoCallDecline"
|
||||
private const val WAIT_FOR_HTTP_MS = 9_000L
|
||||
|
||||
@Volatile
|
||||
private var appContext: Context? = null
|
||||
|
||||
@Volatile
|
||||
private var installed = false
|
||||
|
||||
fun install(context: Context) {
|
||||
if (installed) return
|
||||
installed = true
|
||||
appContext = context.applicationContext
|
||||
FlutterCallkitIncomingPlugin.registerEventCallback(this)
|
||||
Log.i(TAG, "installed")
|
||||
}
|
||||
|
||||
override fun onCallEvent(event: CallkitEventCallback.CallEvent, callData: Bundle) {
|
||||
if (event != CallkitEventCallback.CallEvent.DECLINE) return
|
||||
val context = appContext ?: return
|
||||
val handle = callData.getString(CallkitConstants.EXTRA_CALLKIT_HANDLE)
|
||||
val extra = copyExtra(callData)
|
||||
if (MainActivity.isInForeground) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
VideoCallDeclineClient.send(context, handle, extra)
|
||||
}
|
||||
return
|
||||
}
|
||||
// The Decline button starts this process and then the broadcast ends.
|
||||
// Wait here so Android does not kill the process before the POST returns.
|
||||
runBlocking(Dispatchers.IO) {
|
||||
withTimeoutOrNull(WAIT_FOR_HTTP_MS) {
|
||||
VideoCallDeclineClient.send(context, handle, extra)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyExtra(callData: Bundle): Map<String, Any?> {
|
||||
val raw = BundleCompat.getSerializable(
|
||||
callData,
|
||||
CallkitConstants.EXTRA_CALLKIT_EXTRA,
|
||||
HashMap::class.java,
|
||||
) ?: return emptyMap()
|
||||
return buildMap {
|
||||
for ((key, value) in raw) {
|
||||
if (key is String) put(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package com.eatvioapp.app
|
||||
|
||||
/**
|
||||
* Body of `POST /api/v2/chat/{conversation}/video-call/decline`.
|
||||
*
|
||||
* [callId] is omitted when it is missing, not a uuid, or equal to the
|
||||
* conversation uuid: the server treats `call_id` as the session uuid.
|
||||
*/
|
||||
internal data class VideoCallDeclineRequest(
|
||||
val url: String,
|
||||
val token: String,
|
||||
val body: String,
|
||||
)
|
||||
|
||||
internal data class VideoCallDeclineFields(
|
||||
val conversationUuid: String,
|
||||
val callId: String?,
|
||||
val endedPush: Boolean,
|
||||
)
|
||||
|
||||
internal object VideoCallDeclineRequestFactory {
|
||||
private val HEX = "0123456789ABCDEF".toCharArray()
|
||||
private val uuidPattern =
|
||||
Regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
|
||||
|
||||
fun fields(handle: String?, extra: Map<*, *>?): VideoCallDeclineFields {
|
||||
val conversation = firstString(
|
||||
extra,
|
||||
listOf("conversationUuid", "conversation_uuid"),
|
||||
) ?: handle?.trim().orEmpty()
|
||||
val rawCallId = firstString(extra, listOf("call_id", "callId"))
|
||||
return VideoCallDeclineFields(
|
||||
conversationUuid = conversation,
|
||||
callId = backendCallId(rawCallId, conversation),
|
||||
endedPush = marksEndedPush(extra),
|
||||
)
|
||||
}
|
||||
|
||||
fun build(
|
||||
baseUrl: String?,
|
||||
token: String?,
|
||||
hwId: String?,
|
||||
fields: VideoCallDeclineFields,
|
||||
): VideoCallDeclineRequest? {
|
||||
if (fields.endedPush) return null
|
||||
val conversation = fields.conversationUuid.trim()
|
||||
val authToken = token?.trim().orEmpty()
|
||||
val deviceId = hwId?.trim().orEmpty()
|
||||
val root = baseUrl?.trim()?.trimEnd('/').orEmpty()
|
||||
if (conversation.isEmpty() || authToken.isEmpty()) return null
|
||||
if (deviceId.length < 8 || deviceId.length > 64) return null
|
||||
if (!root.startsWith("http://") && !root.startsWith("https://")) return null
|
||||
|
||||
val encoded = encodePathSegment(conversation)
|
||||
return VideoCallDeclineRequest(
|
||||
url = "$root/api/v2/chat/$encoded/video-call/decline",
|
||||
token = authToken,
|
||||
body = jsonBody(deviceId, fields.callId),
|
||||
)
|
||||
}
|
||||
|
||||
private fun backendCallId(callId: String?, conversationUuid: String): String? {
|
||||
val id = callId?.trim().orEmpty()
|
||||
if (!uuidPattern.matches(id)) return null
|
||||
if (id.equals(conversationUuid.trim(), ignoreCase = true)) return null
|
||||
return id
|
||||
}
|
||||
|
||||
private fun firstString(extra: Map<*, *>?, keys: List<String>): String? {
|
||||
if (extra == null) return null
|
||||
for (key in keys) {
|
||||
val value = extra[key] as? String ?: continue
|
||||
val trimmed = value.trim()
|
||||
if (trimmed.isNotEmpty()) return trimmed
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun marksEndedPush(extra: Map<*, *>?): Boolean {
|
||||
val value = extra?.get("endedPush") ?: return false
|
||||
return when (value) {
|
||||
is Boolean -> value
|
||||
is Number -> value.toInt() != 0
|
||||
is String -> {
|
||||
val normalized = value.trim().lowercase()
|
||||
normalized == "true" || normalized == "1"
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun jsonBody(hwId: String, callId: String?): String {
|
||||
val hw = jsonString(hwId)
|
||||
if (callId == null) return """{"hw_id":$hw}"""
|
||||
return """{"hw_id":$hw,"call_id":${jsonString(callId)}}"""
|
||||
}
|
||||
|
||||
private fun encodePathSegment(value: String): String {
|
||||
val bytes = value.encodeToByteArray()
|
||||
return buildString(bytes.size) {
|
||||
for (byte in bytes) {
|
||||
val code = byte.toInt() and 0xFF
|
||||
val allowed = code in 'A'.code..'Z'.code ||
|
||||
code in 'a'.code..'z'.code ||
|
||||
code in '0'.code..'9'.code ||
|
||||
code == '-'.code ||
|
||||
code == '_'.code ||
|
||||
code == '.'.code ||
|
||||
code == '~'.code
|
||||
if (allowed) {
|
||||
append(code.toChar())
|
||||
} else {
|
||||
append('%')
|
||||
append(HEX[code ushr 4])
|
||||
append(HEX[code and 0x0F])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun jsonString(value: String): String {
|
||||
val escaped = buildString {
|
||||
for (char in value) {
|
||||
when (char) {
|
||||
'\\' -> append("\\\\")
|
||||
'"' -> append("\\\"")
|
||||
'\n' -> append("\\n")
|
||||
'\r' -> append("\\r")
|
||||
'\t' -> append("\\t")
|
||||
else -> append(char)
|
||||
}
|
||||
}
|
||||
}
|
||||
return "\"$escaped\""
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ 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
|
||||
@@ -17,6 +18,7 @@ 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
|
||||
|
||||
@@ -24,16 +26,20 @@ 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
|
||||
|
||||
/**
|
||||
@@ -115,23 +121,38 @@ object VideoCallPushBridge {
|
||||
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
|
||||
}
|
||||
|
||||
val generation = playbackGeneration.incrementAndGet()
|
||||
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 (generation != playbackGeneration.get()) {
|
||||
Log.i(TAG, "showIncomingCall: skipped stale playback generation=$generation")
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -158,17 +179,53 @@ object VideoCallPushBridge {
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissAllIncomingCalls(context: Context) {
|
||||
/**
|
||||
* 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, "dismissAllIncomingCalls: active calls=${calls.size}")
|
||||
Log.i(
|
||||
TAG,
|
||||
"dismissIncomingCalls: active=${calls.size} call_id=${normalizedCallId ?: "-"} " +
|
||||
"conversation=${normalizedConversation ?: "-"}",
|
||||
)
|
||||
playbackGeneration.incrementAndGet()
|
||||
|
||||
val dismiss = Runnable {
|
||||
synchronized(playbackLock) {
|
||||
val notificationManager = resolveNotificationManager(appContext)
|
||||
val bundles = calls.map { it.toBundle() }
|
||||
stopPlaybackLocked(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 {
|
||||
@@ -176,25 +233,39 @@ object VideoCallPushBridge {
|
||||
} catch (error: Exception) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"dismissAllIncomingCalls: failed for call ${bundle.getString(CallkitConstants.EXTRA_CALLKIT_ID)}",
|
||||
"dismissIncomingCalls: failed for call ${bundle.getString(CallkitConstants.EXTRA_CALLKIT_ID)}",
|
||||
error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
CallkitNotificationService.stopService(appContext)
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "dismissAllIncomingCalls: failed to stop callkit service", error)
|
||||
if (!targeted || bundles.isNotEmpty()) {
|
||||
try {
|
||||
CallkitNotificationService.stopService(appContext)
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "dismissIncomingCalls: failed to stop callkit service", error)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
removeAllCalls(appContext)
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "dismissAllIncomingCalls: failed to remove persisted calls", 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stopIncomingRingtone()
|
||||
if (activeMatches || !targeted) {
|
||||
stopIncomingRingtone()
|
||||
}
|
||||
scheduleMissedCallCleanup(notificationManager, bundles)
|
||||
}
|
||||
}
|
||||
@@ -206,6 +277,18 @@ object VideoCallPushBridge {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -230,6 +313,7 @@ object VideoCallPushBridge {
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -243,8 +327,43 @@ object VideoCallPushBridge {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopPlaybackLocked(appContext: Context) {
|
||||
cancelRingtoneTimeoutLocked()
|
||||
releaseScreenWakeLockLocked()
|
||||
val notificationManager = activeNotificationManager
|
||||
val bundle = activeCallBundle
|
||||
stopIncomingRingtone()
|
||||
@@ -258,6 +377,7 @@ object VideoCallPushBridge {
|
||||
activeSoundManager = null
|
||||
activeNotificationManager = null
|
||||
activeCallBundle = null
|
||||
EatvioCallkitIncomingBridgeActivity.dismissRingingScreen()
|
||||
}
|
||||
|
||||
private fun cancelRingtoneTimeoutLocked() {
|
||||
@@ -344,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"]
|
||||
@@ -369,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
|
||||
@@ -379,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)
|
||||
@@ -406,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"]
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.eatvioapp.app
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class VideoCallDeclineRequestTest {
|
||||
private val baseUrl = "https://eatvio.example"
|
||||
private val token = "sanctum-token"
|
||||
private val hwId = "install-hw-id-01"
|
||||
private val conversation = "11111111-1111-1111-1111-111111111111"
|
||||
private val callId = "22222222-2222-2222-2222-222222222222"
|
||||
|
||||
@Test
|
||||
fun buildsDeclinePostWithCallId() {
|
||||
val request = request(callId = callId)
|
||||
|
||||
assertEquals(
|
||||
"https://eatvio.example/api/v2/chat/$conversation/video-call/decline",
|
||||
request?.url,
|
||||
)
|
||||
assertEquals(token, request?.token)
|
||||
assertEquals(
|
||||
"""{"hw_id":"$hwId","call_id":"$callId"}""",
|
||||
request?.body,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun omitsCallIdWhenItIsTheConversationUuid() {
|
||||
val request = request(callId = conversation.uppercase())
|
||||
|
||||
assertEquals("""{"hw_id":"$hwId"}""", request?.body)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun omitsCallIdThatIsNotAUuid() {
|
||||
val request = request(callId = "message-15")
|
||||
|
||||
assertEquals("""{"hw_id":"$hwId"}""", request?.body)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readsConversationFromHandleWhenExtraHasNone() {
|
||||
val fields = VideoCallDeclineRequestFactory.fields(conversation, emptyMap<String, Any?>())
|
||||
val request = VideoCallDeclineRequestFactory.build(baseUrl, token, hwId, fields)
|
||||
|
||||
assertTrue(request?.url?.contains(conversation) == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun skipsEndedPush() {
|
||||
val fields = VideoCallDeclineRequestFactory.fields(
|
||||
conversation,
|
||||
mapOf("conversationUuid" to conversation, "endedPush" to true),
|
||||
)
|
||||
|
||||
assertNull(VideoCallDeclineRequestFactory.build(baseUrl, token, hwId, fields))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun skipsMissingTokenBaseUrlOrShortHwId() {
|
||||
val fields = VideoCallDeclineRequestFactory.fields(
|
||||
null,
|
||||
mapOf("conversationUuid" to conversation, "call_id" to callId),
|
||||
)
|
||||
|
||||
assertNull(VideoCallDeclineRequestFactory.build(null, token, hwId, fields))
|
||||
assertNull(VideoCallDeclineRequestFactory.build(baseUrl, " ", hwId, fields))
|
||||
assertNull(VideoCallDeclineRequestFactory.build(baseUrl, token, "short", fields))
|
||||
assertNull(VideoCallDeclineRequestFactory.build("eatvio.example", token, hwId, fields))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun trimsATrailingSlashOnTheBaseUrl() {
|
||||
val request = VideoCallDeclineRequestFactory.build(
|
||||
"$baseUrl/",
|
||||
token,
|
||||
hwId,
|
||||
VideoCallDeclineRequestFactory.fields(
|
||||
null,
|
||||
mapOf("conversationUuid" to conversation),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
"https://eatvio.example/api/v2/chat/$conversation/video-call/decline",
|
||||
request?.url,
|
||||
)
|
||||
}
|
||||
|
||||
private fun request(callId: String?): VideoCallDeclineRequest? {
|
||||
val fields = VideoCallDeclineRequestFactory.fields(
|
||||
null,
|
||||
mapOf(
|
||||
"conversationUuid" to conversation,
|
||||
"call_id" to callId,
|
||||
),
|
||||
)
|
||||
return VideoCallDeclineRequestFactory.build(baseUrl, token, hwId, fields)
|
||||
}
|
||||
}
|
||||
@@ -650,7 +650,7 @@ code + .copy-button {
|
||||
<script type="text/javascript">
|
||||
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>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||
46B43B96F809CEC6058EFDD3 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 15ADDB2A35F334AF0AE50011 /* Pods_Runner.framework */; };
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||
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 */; };
|
||||
@@ -38,6 +39,7 @@
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
@@ -117,6 +119,7 @@
|
||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||
E47D0C1A2F0B4D1000EA7A01 /* VideoCallDeclineClient.swift */,
|
||||
C4A8E2012E9A4B1000123456 /* SceneDelegate.swift */,
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||
FEE92A0E2D772EA300E6E989 /* Runner.entitlements */,
|
||||
@@ -303,6 +306,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||
E47D0C1B2F0B4D1000EA7A01 /* VideoCallDeclineClient.swift in Sources */,
|
||||
C4A8E2022E9A4B1000123456 /* SceneDelegate.swift in Sources */,
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||
);
|
||||
|
||||
@@ -15,11 +15,14 @@ import flutter_foreground_task
|
||||
private var voipDiagnosticsChannel: FlutterMethodChannel?
|
||||
private let voipDiagnosticsStorageKey = "EatvioVoipDiagnostics"
|
||||
private let pendingAcceptedVoipCallKey = "EatvioPendingAcceptedVoipCall"
|
||||
private let pendingAcceptedAtKey = "eatvioAcceptedAt"
|
||||
private let pendingAcceptedCallTtl: TimeInterval = 120
|
||||
private let voipDiagnosticsChannelName = "com.eatvioapp/voip_diagnostics"
|
||||
private let callAudioSessionChannelName = "com.eatvioapp/call_audio_session"
|
||||
private var callAudioChannel: FlutterMethodChannel?
|
||||
private let appUrlScheme = "eatvio"
|
||||
/// CallKit is only the ringing UI. After accept the in-app call continues,
|
||||
/// so the audio session must be handed back to WebRTC when CallKit deactivates.
|
||||
/// True only if CallKit ended while the in-app call must keep audio.
|
||||
/// Incoming accept keeps CallKit up, so this stays false after answer.
|
||||
private var resumeAudioAfterCallKitEnds = false
|
||||
/// The push handler and the Flutter channel touch this map on different
|
||||
/// queues. The push writes the id before CallKit has the call, so the
|
||||
@@ -27,9 +30,20 @@ import flutter_foreground_task
|
||||
private var reservedIncomingCallIds: [String: Date] = [:]
|
||||
private let incomingCallClaimLock = NSLock()
|
||||
private let incomingCallClaimTtl: TimeInterval = 90
|
||||
/// Cancelled / declined server call ids. A late `video_call_started` with the
|
||||
/// same id must not show ringing again (same TTL as Android / Dart).
|
||||
private var tombstonedCallIds: [String: Date] = [:]
|
||||
/// CallKit ids closed by an ended VoIP push. `onDecline` sees the original
|
||||
/// ring, which has no `endedPush` marker, and must not POST /decline.
|
||||
private var remoteEndedCallKitIds = Set<String>()
|
||||
private let remoteEndedCallLock = NSLock()
|
||||
private let callTombstoneTtl: TimeInterval = 120
|
||||
private let voipPushQueue = DispatchQueue(label: "com.eatvioapp.voip")
|
||||
private var voipCallReporterStorage: VoipCallReporter?
|
||||
private var audioRestartGeneration = 0
|
||||
/// CallKit owns AVAudioSession from answer until the CallKit call ends.
|
||||
/// WebRTC must not call setActive(true) on top of that session.
|
||||
private var callKitOwnsAudioSession = false
|
||||
|
||||
private var voipCallReporter: VoipCallReporter {
|
||||
if let voipCallReporterStorage {
|
||||
@@ -106,6 +120,7 @@ import flutter_foreground_task
|
||||
name: callAudioSessionChannelName,
|
||||
binaryMessenger: flutterController.binaryMessenger
|
||||
)
|
||||
self.callAudioChannel = callAudioChannel
|
||||
callAudioChannel.setMethodCallHandler { [weak self] call, result in
|
||||
guard let self else {
|
||||
result(nil)
|
||||
@@ -185,14 +200,19 @@ import flutter_foreground_task
|
||||
]
|
||||
)
|
||||
if let callKitId, !callKitId.isEmpty, !conversationUuid.isEmpty {
|
||||
var extra: [String: Any] = ["conversationUuid": conversationUuid]
|
||||
if let rawCallId = queryItems.first(where: { $0.name == "call_id" })?.value {
|
||||
let serverCallId = rawCallId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !serverCallId.isEmpty {
|
||||
extra["call_id"] = serverCallId
|
||||
}
|
||||
}
|
||||
voipDiagnosticsChannel?.invokeMethod(
|
||||
"acceptedVoipCall",
|
||||
arguments: [
|
||||
"id": callKitId,
|
||||
"handle": conversationUuid,
|
||||
"extra": [
|
||||
"conversationUuid": conversationUuid
|
||||
],
|
||||
"extra": extra,
|
||||
]
|
||||
)
|
||||
}
|
||||
@@ -221,6 +241,78 @@ import flutter_foreground_task
|
||||
return (String(conversationUuid[prefixRange]) + suffix).lowercased()
|
||||
}
|
||||
|
||||
/// Canonical CallKit id: prefer backend `call_id`, then legacy message-derived
|
||||
/// uuid, then conversation uuid (same resolve order as Dart `VideoCallKitId`).
|
||||
private func resolveCallKitId(
|
||||
serverCallId: String?,
|
||||
conversationUuid: String,
|
||||
messageId: UInt64?
|
||||
) -> (id: String, source: String) {
|
||||
if let serverCallId, !serverCallId.isEmpty {
|
||||
return (serverCallId, "call_id")
|
||||
}
|
||||
if let derived = derivedCallKitId(
|
||||
conversationUuid: conversationUuid,
|
||||
messageId: messageId
|
||||
) {
|
||||
return (derived, "conversation_and_message")
|
||||
}
|
||||
if UUID(uuidString: conversationUuid) != nil {
|
||||
return (conversationUuid.lowercased(), "conversation")
|
||||
}
|
||||
return (UUID().uuidString.lowercased(), "generated")
|
||||
}
|
||||
|
||||
private func voipServerCallId(from dict: [AnyHashable: Any]) -> String? {
|
||||
for key in ["call_id", "callId"] {
|
||||
if let value = dict[key] as? String {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func voipEndReason(from dict: [AnyHashable: Any]) -> String? {
|
||||
for key in ["reason", "end_reason", "endReason"] {
|
||||
if let value = dict[key] as? String {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty {
|
||||
return trimmed.lowercased()
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func pruneCallTombstones(now: Date = Date()) {
|
||||
tombstonedCallIds = tombstonedCallIds.filter {
|
||||
now.timeIntervalSince($0.value) < callTombstoneTtl
|
||||
}
|
||||
}
|
||||
|
||||
private func markCallTombstone(_ callId: String?) {
|
||||
let normalized = callId?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? ""
|
||||
guard !normalized.isEmpty else { return }
|
||||
pruneCallTombstones()
|
||||
tombstonedCallIds[normalized] = Date()
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.voipDiagnosticsChannel?.invokeMethod(
|
||||
"tombstoneCallId",
|
||||
arguments: ["id": normalized]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func isCallTombstoned(_ callId: String?) -> Bool {
|
||||
let normalized = callId?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? ""
|
||||
guard !normalized.isEmpty else { return false }
|
||||
pruneCallTombstones()
|
||||
return tombstonedCallIds[normalized] != nil
|
||||
}
|
||||
|
||||
private func voipMessageId(_ value: Any?) -> UInt64? {
|
||||
if let number = value as? NSNumber {
|
||||
let parsed = number.uint64Value
|
||||
@@ -234,6 +326,41 @@ import flutter_foreground_task
|
||||
return nil
|
||||
}
|
||||
|
||||
private func extraMarksEndedPush(_ extra: [String: Any]) -> Bool {
|
||||
let value = extra["endedPush"]
|
||||
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 func voipBool(_ value: Any?) -> Bool? {
|
||||
if let bool = value as? Bool {
|
||||
return bool
|
||||
}
|
||||
if let number = value as? NSNumber {
|
||||
return number.intValue != 0
|
||||
}
|
||||
if let text = value as? String {
|
||||
switch text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
|
||||
case "1", "true", "yes":
|
||||
return true
|
||||
case "0", "false", "no":
|
||||
return false
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func conversationUuid(fromActionUrl actionUrl: String?) -> String? {
|
||||
guard
|
||||
let actionUrl = actionUrl,
|
||||
@@ -282,81 +409,49 @@ import flutter_foreground_task
|
||||
}
|
||||
|
||||
private func storePendingAcceptedCall(_ call: Call) {
|
||||
let payload = sanitizedDiagnosticValue(call.data.toJSON()) as? [String: Any] ?? [:]
|
||||
var payload = sanitizedDiagnosticValue(call.data.toJSON()) as? [String: Any] ?? [:]
|
||||
payload[pendingAcceptedAtKey] = Date().timeIntervalSince1970
|
||||
UserDefaults.standard.set(payload, forKey: pendingAcceptedVoipCallKey)
|
||||
}
|
||||
|
||||
/// An accept that Flutter never consumed (the process died before the
|
||||
/// scene opened) must not open the call screen on a later launch: that
|
||||
/// screen requests a token, and the token request rings the other person.
|
||||
private func drainPendingAcceptedVoipCall() -> [String: Any]? {
|
||||
let defaults = UserDefaults.standard
|
||||
guard let payload = defaults.dictionary(forKey: pendingAcceptedVoipCallKey) else {
|
||||
guard var payload = defaults.dictionary(forKey: pendingAcceptedVoipCallKey) else {
|
||||
return nil
|
||||
}
|
||||
defaults.removeObject(forKey: pendingAcceptedVoipCallKey)
|
||||
let acceptedAt = (payload.removeValue(forKey: pendingAcceptedAtKey) as? NSNumber)?.doubleValue
|
||||
if let acceptedAt, Date().timeIntervalSince1970 - acceptedAt > pendingAcceptedCallTtl {
|
||||
recordVoipDiagnostic(
|
||||
"pending_accepted_call_expired",
|
||||
data: [
|
||||
"callKitId": payload["id"] as? String ?? "",
|
||||
"ageSeconds": Date().timeIntervalSince1970 - acceptedAt,
|
||||
]
|
||||
)
|
||||
return nil
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
private func bringAppToForeground() {
|
||||
DispatchQueue.main.async {
|
||||
for scene in UIApplication.shared.connectedScenes {
|
||||
guard let windowScene = scene as? UIWindowScene else { continue }
|
||||
for window in windowScene.windows {
|
||||
window.makeKeyAndVisible()
|
||||
}
|
||||
}
|
||||
self.window?.makeKeyAndVisible()
|
||||
}
|
||||
}
|
||||
|
||||
private func openAppForAcceptedCall(_ call: Call) {
|
||||
guard let conversationUuid = conversationUuid(fromCall: call), !conversationUuid.isEmpty else {
|
||||
recordVoipDiagnostic(
|
||||
"accepted_call_missing_conversation_uuid",
|
||||
data: ["callKitId": call.data.uuid]
|
||||
)
|
||||
/// The answer callback runs before the scene is visible. A custom-scheme
|
||||
/// open from here is an untrusted user action and SpringBoard denies it.
|
||||
/// CallKit foregrounds a video call itself after `fulfill`. Flutter reads
|
||||
/// the stored payload once that scene is actually active.
|
||||
func deliverStoredAcceptedCall() {
|
||||
guard voipDiagnosticsChannel != nil else { return }
|
||||
guard let payload = UserDefaults.standard.dictionary(forKey: pendingAcceptedVoipCallKey) else {
|
||||
return
|
||||
}
|
||||
|
||||
var components = URLComponents()
|
||||
components.scheme = appUrlScheme
|
||||
components.host = "video-call"
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "callKitId", value: call.data.uuid),
|
||||
URLQueryItem(name: "conversationUuid", value: conversationUuid)
|
||||
]
|
||||
|
||||
guard let url = components.url else {
|
||||
recordVoipDiagnostic(
|
||||
"accepted_call_url_build_failed",
|
||||
data: [
|
||||
"callKitId": call.data.uuid,
|
||||
"conversationUuid": conversationUuid,
|
||||
]
|
||||
)
|
||||
let acceptedAt = (payload[pendingAcceptedAtKey] as? NSNumber)?.doubleValue
|
||||
if let acceptedAt, Date().timeIntervalSince1970 - acceptedAt > pendingAcceptedCallTtl {
|
||||
UserDefaults.standard.removeObject(forKey: pendingAcceptedVoipCallKey)
|
||||
return
|
||||
}
|
||||
|
||||
recordVoipDiagnostic(
|
||||
"accepted_call_open_app_requested",
|
||||
data: [
|
||||
"callKitId": call.data.uuid,
|
||||
"conversationUuid": conversationUuid,
|
||||
"url": url.absoluteString,
|
||||
"appState": UIApplication.shared.applicationState.rawValue,
|
||||
]
|
||||
)
|
||||
|
||||
bringAppToForeground()
|
||||
|
||||
UIApplication.shared.open(url, options: [:]) { [weak self] success in
|
||||
self?.recordVoipDiagnostic(
|
||||
success ? "accepted_call_open_app_succeeded" : "accepted_call_open_app_failed",
|
||||
data: [
|
||||
"callKitId": call.data.uuid,
|
||||
"conversationUuid": conversationUuid,
|
||||
"url": url.absoluteString,
|
||||
]
|
||||
)
|
||||
}
|
||||
voipDiagnosticsChannel?.invokeMethod("acceptedVoipCall", arguments: payload)
|
||||
}
|
||||
|
||||
private func sanitizedDiagnosticValue(_ value: Any?) -> Any {
|
||||
@@ -475,16 +570,31 @@ import flutter_foreground_task
|
||||
}
|
||||
}
|
||||
|
||||
/// An ended VoIP push has no message id, so its CallKit id is the
|
||||
/// conversation uuid, not the ringing call. End that conversation only.
|
||||
/// The first endCall while the push flag is set closes the call this push
|
||||
/// just reported; the next ones close the call that is actually ringing.
|
||||
private func endCallsForEndedPush(reportedId: String, conversationUuid: String) {
|
||||
/// Ends the ringing CallKit call for an ended VoIP push.
|
||||
///
|
||||
/// A push that carries `call_id` closes that call. A ring shown from the
|
||||
/// open-app socket used to have only a derived id and an empty `call_id`.
|
||||
/// When nothing matches the server id, those rings still close by
|
||||
/// conversation. A ring that already stores a different `call_id` stays up.
|
||||
private func endCallsForEndedPush(
|
||||
reportedId: String,
|
||||
conversationUuid: String,
|
||||
serverCallId: String? = nil,
|
||||
reason: String? = nil
|
||||
) {
|
||||
guard let plugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance else {
|
||||
endReportedCallWithoutPlugin(reportedId)
|
||||
releaseIncomingCallClaim(reportedId)
|
||||
return
|
||||
}
|
||||
let conversation = conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalizedServerCallId = serverCallId?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let hasServerCallId = !normalizedServerCallId.isEmpty
|
||||
if hasServerCallId {
|
||||
markCallTombstone(normalizedServerCallId)
|
||||
}
|
||||
|
||||
var ids: [String] = []
|
||||
func append(_ id: String) {
|
||||
let normalized = id.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -495,19 +605,48 @@ import flutter_foreground_task
|
||||
ids.append(normalized)
|
||||
}
|
||||
append(reportedId)
|
||||
var matchedServerCall = false
|
||||
for call in plugin.activeCalls() {
|
||||
let callId = (call["id"] as? String) ?? ""
|
||||
let handle = (call["handle"] as? String) ?? ""
|
||||
let extraUuid = extraConversationUuid(call["extra"])
|
||||
let extraCallId = extraServerCallId(call["extra"])
|
||||
if hasServerCallId {
|
||||
let matchesServerCall =
|
||||
callId.caseInsensitiveCompare(normalizedServerCallId) == .orderedSame
|
||||
|| extraCallId.caseInsensitiveCompare(normalizedServerCallId) == .orderedSame
|
||||
if matchesServerCall {
|
||||
append(callId)
|
||||
matchedServerCall = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
let sameId = callId.caseInsensitiveCompare(reportedId) == .orderedSame
|
||||
let sameConversation = !conversation.isEmpty && (
|
||||
handle.caseInsensitiveCompare(conversation) == .orderedSame
|
||||
|| extraUuid.caseInsensitiveCompare(conversation) == .orderedSame
|
||||
let matchesConversation = sameConversation(
|
||||
handle: handle,
|
||||
extraUuid: extraUuid,
|
||||
conversation: conversation
|
||||
)
|
||||
if sameId || sameConversation {
|
||||
if sameId || matchesConversation {
|
||||
append(callId)
|
||||
}
|
||||
}
|
||||
if hasServerCallId && !matchedServerCall {
|
||||
for call in plugin.activeCalls() {
|
||||
let callId = (call["id"] as? String) ?? ""
|
||||
let extraCallId = extraServerCallId(call["extra"])
|
||||
guard extraCallId.isEmpty else { continue }
|
||||
let handle = (call["handle"] as? String) ?? ""
|
||||
let extraUuid = extraConversationUuid(call["extra"])
|
||||
if sameConversation(
|
||||
handle: handle,
|
||||
extraUuid: extraUuid,
|
||||
conversation: conversation
|
||||
) {
|
||||
append(callId)
|
||||
}
|
||||
}
|
||||
}
|
||||
for id in ids {
|
||||
let endData = flutter_callkit_incoming.Data(
|
||||
id: id,
|
||||
@@ -515,22 +654,35 @@ import flutter_foreground_task
|
||||
handle: conversation,
|
||||
type: 1
|
||||
)
|
||||
// The plugin sends this payload with its "ended" event. Dart uses the
|
||||
// marker to tell a remote end from a decline by the user.
|
||||
var endExtra: [String: Any] = ["endedPush": true]
|
||||
if !conversation.isEmpty {
|
||||
endExtra["conversationUuid"] = conversation
|
||||
}
|
||||
if hasServerCallId {
|
||||
endExtra["call_id"] = normalizedServerCallId
|
||||
}
|
||||
if let reason, !reason.isEmpty {
|
||||
endExtra["reason"] = reason
|
||||
}
|
||||
endData.extra = endExtra as NSDictionary
|
||||
markRemoteEndedCallKitId(id)
|
||||
plugin.endCall(endData)
|
||||
releaseIncomingCallClaim(id)
|
||||
}
|
||||
}
|
||||
|
||||
private func activeCallId(forConversation conversationUuid: String) -> String? {
|
||||
let conversation = conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !conversation.isEmpty else { return nil }
|
||||
private func activeCallId(matchingServerCallId serverCallId: String) -> String? {
|
||||
let normalized = serverCallId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalized.isEmpty else { return nil }
|
||||
let calls = SwiftFlutterCallkitIncomingPlugin.sharedInstance?.activeCalls() ?? []
|
||||
for call in calls {
|
||||
let callId = (call["id"] as? String) ?? ""
|
||||
guard !callId.isEmpty else { continue }
|
||||
let handle = (call["handle"] as? String) ?? ""
|
||||
let extraUuid = extraConversationUuid(call["extra"])
|
||||
if handle.caseInsensitiveCompare(conversation) == .orderedSame
|
||||
|| extraUuid.caseInsensitiveCompare(conversation) == .orderedSame
|
||||
let extraCallId = extraServerCallId(call["extra"])
|
||||
if callId.caseInsensitiveCompare(normalized) == .orderedSame
|
||||
|| extraCallId.caseInsensitiveCompare(normalized) == .orderedSame
|
||||
{
|
||||
return callId
|
||||
}
|
||||
@@ -538,21 +690,74 @@ import flutter_foreground_task
|
||||
return nil
|
||||
}
|
||||
|
||||
private func extraConversationUuid(_ extra: Any?) -> String {
|
||||
let map: [String: Any]
|
||||
private func activeCallId(
|
||||
forConversation conversationUuid: String,
|
||||
onlyWithoutServerCallId: Bool = false
|
||||
) -> String? {
|
||||
let conversation = conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !conversation.isEmpty else { return nil }
|
||||
let calls = SwiftFlutterCallkitIncomingPlugin.sharedInstance?.activeCalls() ?? []
|
||||
for call in calls {
|
||||
let callId = (call["id"] as? String) ?? ""
|
||||
guard !callId.isEmpty else { continue }
|
||||
if onlyWithoutServerCallId && !extraServerCallId(call["extra"]).isEmpty {
|
||||
continue
|
||||
}
|
||||
let handle = (call["handle"] as? String) ?? ""
|
||||
let extraUuid = extraConversationUuid(call["extra"])
|
||||
if sameConversation(handle: handle, extraUuid: extraUuid, conversation: conversation) {
|
||||
return callId
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func sameConversation(handle: String, extraUuid: String, conversation: String) -> Bool {
|
||||
guard !conversation.isEmpty else { return false }
|
||||
return handle.caseInsensitiveCompare(conversation) == .orderedSame
|
||||
|| extraUuid.caseInsensitiveCompare(conversation) == .orderedSame
|
||||
}
|
||||
|
||||
private func markRemoteEndedCallKitId(_ id: String) {
|
||||
let normalized = id.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard !normalized.isEmpty else { return }
|
||||
remoteEndedCallLock.lock()
|
||||
remoteEndedCallKitIds.insert(normalized)
|
||||
remoteEndedCallLock.unlock()
|
||||
}
|
||||
|
||||
private func consumeRemoteEndedCallKitId(_ id: String) -> Bool {
|
||||
let normalized = id.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard !normalized.isEmpty else { return false }
|
||||
remoteEndedCallLock.lock()
|
||||
defer { remoteEndedCallLock.unlock() }
|
||||
return remoteEndedCallKitIds.remove(normalized) != nil
|
||||
}
|
||||
|
||||
private func endReportedCallWithoutPlugin(_ id: String) {
|
||||
let normalized = id.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let uuid = UUID(uuidString: normalized) else { return }
|
||||
voipCallReporter.provider.reportCall(with: uuid, endedAt: Date(), reason: .remoteEnded)
|
||||
}
|
||||
|
||||
private func extraMap(_ extra: Any?) -> [String: Any] {
|
||||
if let typed = extra as? [String: Any] {
|
||||
map = typed
|
||||
} else if let object = extra as? NSDictionary {
|
||||
return typed
|
||||
}
|
||||
if let object = extra as? NSDictionary {
|
||||
var converted: [String: Any] = [:]
|
||||
for (key, value) in object {
|
||||
if let key = key as? String {
|
||||
converted[key] = value
|
||||
}
|
||||
}
|
||||
map = converted
|
||||
} else {
|
||||
return ""
|
||||
return converted
|
||||
}
|
||||
return [:]
|
||||
}
|
||||
|
||||
private func extraConversationUuid(_ extra: Any?) -> String {
|
||||
let map = extraMap(extra)
|
||||
for key in ["conversationUuid", "conversation_uuid"] {
|
||||
if let value = map[key] as? String {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -564,6 +769,19 @@ import flutter_foreground_task
|
||||
return ""
|
||||
}
|
||||
|
||||
private func extraServerCallId(_ extra: Any?) -> String {
|
||||
let map = extraMap(extra)
|
||||
for key in ["call_id", "callId"] {
|
||||
if let value = map[key] as? String {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func pushRegistry(
|
||||
_ registry: PKPushRegistry,
|
||||
didUpdate credentials: PKPushCredentials,
|
||||
@@ -604,11 +822,16 @@ import flutter_foreground_task
|
||||
.compactMap { $0?.lowercased().replacingOccurrences(of: "-", with: "_") }
|
||||
|
||||
let isEndedPush = pushMarkers.contains(where: { self.isEndedVideoCallMarker($0) })
|
||||
let serverCallId = voipServerCallId(from: dict)
|
||||
let endReason = voipEndReason(from: dict)
|
||||
if isEndedPush {
|
||||
markCallTombstone(serverCallId)
|
||||
recordVoipDiagnostic(
|
||||
"voip_end_or_cancel_received",
|
||||
data: [
|
||||
"markers": pushMarkers,
|
||||
"call_id": serverCallId ?? "",
|
||||
"reason": endReason ?? "",
|
||||
"payload": dict,
|
||||
]
|
||||
)
|
||||
@@ -634,23 +857,38 @@ import flutter_foreground_task
|
||||
?? ""
|
||||
let trimmedConversationUuid = conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let parsedMessageId = voipMessageId(dict["message_id"])
|
||||
let tombstonedIncoming = !isEndedPush && isCallTombstoned(serverCallId)
|
||||
let id: String
|
||||
let callKitIdSourceName: String
|
||||
if isEndedPush, let existing = activeCallId(forConversation: trimmedConversationUuid) {
|
||||
if isEndedPush, let serverCallId,
|
||||
let existing = activeCallId(matchingServerCallId: serverCallId)
|
||||
{
|
||||
id = existing
|
||||
callKitIdSourceName = "active_call_id"
|
||||
} else if isEndedPush, serverCallId?.isEmpty == false,
|
||||
let existing = activeCallId(
|
||||
forConversation: trimmedConversationUuid,
|
||||
onlyWithoutServerCallId: true
|
||||
)
|
||||
{
|
||||
// The open app reported this ring from the chat socket before `call_id`
|
||||
// was stored. End that ring instead of a new phantom call.
|
||||
id = existing
|
||||
callKitIdSourceName = "active_legacy_ring"
|
||||
} else if isEndedPush, serverCallId == nil,
|
||||
let existing = activeCallId(forConversation: trimmedConversationUuid)
|
||||
{
|
||||
// Legacy ended push without call_id.
|
||||
id = existing
|
||||
callKitIdSourceName = "active_conversation"
|
||||
} else if let derived = derivedCallKitId(
|
||||
conversationUuid: trimmedConversationUuid,
|
||||
messageId: parsedMessageId
|
||||
) {
|
||||
id = derived
|
||||
callKitIdSourceName = "conversation_and_message"
|
||||
} else if UUID(uuidString: trimmedConversationUuid) != nil {
|
||||
id = trimmedConversationUuid.lowercased()
|
||||
callKitIdSourceName = "conversation"
|
||||
} else {
|
||||
id = UUID().uuidString.lowercased()
|
||||
callKitIdSourceName = "generated"
|
||||
let resolved = resolveCallKitId(
|
||||
serverCallId: serverCallId,
|
||||
conversationUuid: trimmedConversationUuid,
|
||||
messageId: parsedMessageId
|
||||
)
|
||||
id = resolved.id
|
||||
callKitIdSourceName = resolved.source
|
||||
}
|
||||
let isVideo = (dict["isVideo"] as? Bool)
|
||||
?? (dict["is_video"] as? Bool)
|
||||
@@ -660,6 +898,12 @@ import flutter_foreground_task
|
||||
if !trimmedConversationUuid.isEmpty {
|
||||
extra["conversationUuid"] = trimmedConversationUuid
|
||||
}
|
||||
if let serverCallId, !serverCallId.isEmpty {
|
||||
extra["call_id"] = serverCallId
|
||||
}
|
||||
if let endReason, !endReason.isEmpty {
|
||||
extra["reason"] = endReason
|
||||
}
|
||||
if let avatar = dict["caller_avatar"] as? String ?? dict["avatar"] as? String
|
||||
?? dict["avatarUrl"] as? String
|
||||
?? dict["avatar_url"] as? String
|
||||
@@ -670,6 +914,8 @@ import flutter_foreground_task
|
||||
}
|
||||
if let messageId = dict["message_id"] as? String {
|
||||
extra["messageId"] = messageId
|
||||
} else if let messageId = parsedMessageId {
|
||||
extra["messageId"] = String(messageId)
|
||||
}
|
||||
if let roomName = dict["room_name"] as? String {
|
||||
extra["roomName"] = roomName
|
||||
@@ -686,6 +932,23 @@ import flutter_foreground_task
|
||||
} else if !nameCaller.isEmpty {
|
||||
extra["counterpartName"] = nameCaller
|
||||
}
|
||||
if let isConsultant = voipBool(dict["is_consultant"] ?? dict["isConsultant"]) {
|
||||
extra["counterpartIsConsultant"] = isConsultant
|
||||
}
|
||||
// Closing this report looks like a user decline. Dart must not tell the
|
||||
// backend, or the server sends another ended VoIP and the other phone
|
||||
// shows it as a new incoming call.
|
||||
if isEndedPush || tombstonedIncoming {
|
||||
extra["endedPush"] = true
|
||||
}
|
||||
// An ended push with no ringing call still has to be reported. That
|
||||
// report is closed at once, so it must not leave a trace anywhere.
|
||||
// Tombstoned late incoming is treated the same way.
|
||||
let matchedActiveRing =
|
||||
callKitIdSourceName == "active_call_id"
|
||||
|| callKitIdSourceName == "active_conversation"
|
||||
|| callKitIdSourceName == "active_legacy_ring"
|
||||
let isPhantomEndedReport = (isEndedPush && !matchedActiveRing) || tombstonedIncoming
|
||||
|
||||
let data = flutter_callkit_incoming.Data(
|
||||
id: id,
|
||||
@@ -693,27 +956,39 @@ import flutter_foreground_task
|
||||
handle: trimmedConversationUuid.isEmpty ? nameCaller : trimmedConversationUuid,
|
||||
type: isVideo ? 1 : 0
|
||||
)
|
||||
data.appName = "Итвио"
|
||||
data.handleType = "generic"
|
||||
data.supportsVideo = true
|
||||
data.duration = 45000
|
||||
data.extra = extra as NSDictionary
|
||||
data.missedNotificationSubtitle = "Пропущенный видеозвонок"
|
||||
data.missedNotificationCallbackText = "Перезвонить"
|
||||
data.isShowMissedCallNotification = !(isEndedPush || tombstonedIncoming)
|
||||
data.includesCallsInRecents = !isPhantomEndedReport
|
||||
// The plugin activates AVAudioSession itself on answer and again 1.2s
|
||||
// later. That drops the ringing UI and leaves one side silent. CallKit
|
||||
// and WebRTC own the session instead.
|
||||
data.configureAudioSession = false
|
||||
data.audioSessionActive = false
|
||||
data.audioSessionMode = "videoChat"
|
||||
data.ringtonePath = "system_ringtone_default"
|
||||
// Do not set ringtonePath. The plugin stores that string as the ringtone
|
||||
// file name. "system_ringtone_default" is not in the app, so CallKit ends
|
||||
// the call at once and PushKit delivers the same push again.
|
||||
// Remember the id so the in-app socket path does not report it again.
|
||||
// The push itself must still be reported, or iOS kills the process.
|
||||
_ = reserveIncomingCall(id)
|
||||
|
||||
NSLog(
|
||||
"[EatvioVoIP] report incoming call id=\(id) conversation=\(conversationUuid) state=\(UIApplication.shared.applicationState.rawValue) markers=\(pushMarkers.joined(separator: ",")) payload=\(dict)"
|
||||
"[EatvioVoIP] report incoming call id=\(id) call_id=\(serverCallId ?? "-") conversation=\(conversationUuid) reason=\(endReason ?? "-") state=\(UIApplication.shared.applicationState.rawValue) markers=\(pushMarkers.joined(separator: ",")) payload=\(dict)"
|
||||
)
|
||||
recordVoipDiagnostic(
|
||||
"voip_incoming_received",
|
||||
data: [
|
||||
"callKitId": id,
|
||||
"callKitIdSource": callKitIdSourceName,
|
||||
"call_id": serverCallId ?? "",
|
||||
"reason": endReason ?? "",
|
||||
"tombstoned": tombstonedIncoming,
|
||||
"conversationUuid": conversationUuid,
|
||||
"nameCaller": nameCaller,
|
||||
"isVideo": isVideo,
|
||||
@@ -727,19 +1002,30 @@ import flutter_foreground_task
|
||||
callKitId: id,
|
||||
callKitIdSourceName: callKitIdSourceName,
|
||||
conversationUuid: conversationUuid,
|
||||
endAfterReport: isEndedPush,
|
||||
serverCallId: serverCallId,
|
||||
reason: endReason,
|
||||
endAfterReport: isEndedPush || tombstonedIncoming,
|
||||
endImmediately: isPhantomEndedReport,
|
||||
completion: completion
|
||||
)
|
||||
}
|
||||
|
||||
/// PushKit kills the app unless every VoIP push is reported to CallKit and
|
||||
/// its completion handler runs. Both happen here, including an "ended" push.
|
||||
///
|
||||
/// `endImmediately` is for an ended push that matched no ringing call. The
|
||||
/// report is closed inside its own completion, before the incoming UI has a
|
||||
/// chance to show. An end transaction would take one more round trip and
|
||||
/// would also reach `onDecline`, which resets the in-app audio restart.
|
||||
private func reportVoipPushToCallKit(
|
||||
_ data: flutter_callkit_incoming.Data,
|
||||
callKitId: String,
|
||||
callKitIdSourceName: String,
|
||||
conversationUuid: String,
|
||||
serverCallId: String? = nil,
|
||||
reason: String? = nil,
|
||||
endAfterReport: Bool,
|
||||
endImmediately: Bool,
|
||||
completion: @escaping () -> Void
|
||||
) {
|
||||
let gate = VoipPushCompletion(completion)
|
||||
@@ -749,32 +1035,50 @@ import flutter_foreground_task
|
||||
}
|
||||
|
||||
let afterReport = {
|
||||
if endAfterReport {
|
||||
DispatchQueue.main.async {
|
||||
if endAfterReport, endImmediately,
|
||||
let plugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance
|
||||
{
|
||||
// Reason 6 is CXCallEndedReason.remoteEnded in the plugin.
|
||||
plugin.saveEndCall(callKitId, 6)
|
||||
}
|
||||
let complete = {
|
||||
if endAfterReport {
|
||||
self.endCallsForEndedPush(
|
||||
reportedId: callKitId,
|
||||
conversationUuid: conversationUuid
|
||||
conversationUuid: conversationUuid,
|
||||
serverCallId: serverCallId,
|
||||
reason: reason
|
||||
)
|
||||
}
|
||||
if let plugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance {
|
||||
let activeCalls = plugin.activeCalls()
|
||||
let activeIds = activeCalls.compactMap { $0["id"] as? String }.joined(separator: ",")
|
||||
self.recordVoipDiagnostic(
|
||||
"callkit_report_completed",
|
||||
data: [
|
||||
"callKitId": callKitId,
|
||||
"callKitIdSource": callKitIdSourceName,
|
||||
"call_id": serverCallId ?? "",
|
||||
"reason": reason ?? "",
|
||||
"conversationUuid": conversationUuid,
|
||||
"activeCallIds": activeIds,
|
||||
"activeCallsCount": activeCalls.count,
|
||||
"endAfterReport": endAfterReport,
|
||||
]
|
||||
)
|
||||
}
|
||||
finish()
|
||||
}
|
||||
if let plugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance {
|
||||
let activeCalls = plugin.activeCalls()
|
||||
let activeIds = activeCalls.compactMap { $0["id"] as? String }.joined(separator: ",")
|
||||
self.recordVoipDiagnostic(
|
||||
"callkit_report_completed",
|
||||
data: [
|
||||
"callKitId": callKitId,
|
||||
"callKitIdSource": callKitIdSourceName,
|
||||
"conversationUuid": conversationUuid,
|
||||
"activeCallIds": activeIds,
|
||||
"activeCallsCount": activeCalls.count,
|
||||
"endAfterReport": endAfterReport,
|
||||
]
|
||||
)
|
||||
if Thread.isMainThread {
|
||||
complete()
|
||||
} else {
|
||||
DispatchQueue.main.async(execute: complete)
|
||||
}
|
||||
finish()
|
||||
}
|
||||
|
||||
if !endAfterReport {
|
||||
prepareCallAudioCategory()
|
||||
}
|
||||
if let plugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance {
|
||||
plugin.showCallkitIncoming(data, fromPushKit: true, completion: afterReport)
|
||||
return
|
||||
@@ -792,6 +1096,9 @@ import flutter_foreground_task
|
||||
finish()
|
||||
return
|
||||
}
|
||||
if !endAfterReport {
|
||||
prepareCallAudioCategory()
|
||||
}
|
||||
voipCallReporter.report(
|
||||
uuid: uuid,
|
||||
callerName: data.nameCaller,
|
||||
@@ -810,9 +1117,16 @@ import flutter_foreground_task
|
||||
|
||||
|
||||
func onAccept(_ call: Call, _ action: CXAnswerCallAction) {
|
||||
resumeAudioAfterCallKitEnds = true
|
||||
prepareCallAudioCategory()
|
||||
// CallKit stays up for the whole incoming call. Do not take the session
|
||||
// back after a later deactivate — that path is hang-up.
|
||||
resumeAudioAfterCallKitEnds = false
|
||||
callKitOwnsAudioSession = true
|
||||
// Category only. RTCAudioSession.setConfiguration here races CallKit's
|
||||
// activation and can start I/O before didActivate.
|
||||
prepareAvAudioSessionCategory()
|
||||
storePendingAcceptedCall(call)
|
||||
let extra = call.data.extra as? [String: Any] ?? [:]
|
||||
VideoCallDeclineClient.sendAccept(extra: extra, handle: call.data.handle)
|
||||
recordVoipDiagnostic(
|
||||
"callkit_accept_native",
|
||||
data: [
|
||||
@@ -821,35 +1135,68 @@ import flutter_foreground_task
|
||||
"data": call.data.toJSON(),
|
||||
]
|
||||
)
|
||||
voipDiagnosticsChannel?.invokeMethod("acceptedVoipCall", arguments: call.data.toJSON())
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.bringAppToForeground()
|
||||
self?.openAppForAcceptedCall(call)
|
||||
}
|
||||
deliverStoredAcceptedCall()
|
||||
action.fulfill()
|
||||
}
|
||||
|
||||
func onDecline(_ call: Call, _ action: CXEndCallAction) {
|
||||
resumeAudioAfterCallKitEnds = false
|
||||
audioRestartGeneration += 1
|
||||
callKitOwnsAudioSession = false
|
||||
// POST /decline from this callback. The Flutter event is only local
|
||||
// cleanup and does not run when the user never opens the app.
|
||||
let extra = call.data.extra as? [String: Any] ?? [:]
|
||||
let markedRemoteEnd = consumeRemoteEndedCallKitId(call.data.uuid)
|
||||
let closedByEndedPush = extraMarksEndedPush(extra) || markedRemoteEnd
|
||||
if !closedByEndedPush {
|
||||
VideoCallDeclineClient.send(extra: extra, handle: call.data.handle)
|
||||
recordVoipDiagnostic(
|
||||
"callkit_decline_native",
|
||||
data: [
|
||||
"callKitId": call.data.uuid,
|
||||
"conversationUuid": conversationUuid(fromCall: call) ?? "",
|
||||
]
|
||||
)
|
||||
voipDiagnosticsChannel?.invokeMethod(
|
||||
"declinedVoipCall",
|
||||
arguments: call.data.toJSON()
|
||||
)
|
||||
}
|
||||
action.fulfill()
|
||||
}
|
||||
|
||||
func onEnd(_ call: Call, _ action: CXEndCallAction) {
|
||||
// The in-app call is already leaving, or the user hung up from CallKit.
|
||||
// Do not restart the audio unit after this deactivation.
|
||||
resumeAudioAfterCallKitEnds = false
|
||||
audioRestartGeneration += 1
|
||||
callKitOwnsAudioSession = false
|
||||
action.fulfill()
|
||||
}
|
||||
|
||||
func onTimeOut(_ call: Call) {}
|
||||
|
||||
func didActivateAudioSession(_ audioSession: AVAudioSession) {
|
||||
callKitOwnsAudioSession = true
|
||||
let rtcSession = RTCAudioSession.sharedInstance()
|
||||
rtcSession.useManualAudio = true
|
||||
rtcSession.audioSessionDidActivate(audioSession)
|
||||
applyCallAudioConfiguration(active: false)
|
||||
// WebRTC must start I/O inside this CallKit callback. Enabling later
|
||||
// misses the session CallKit just activated; toggling after that
|
||||
// does not restart the ADM, so the incoming microphone stays silent.
|
||||
rtcSession.isAudioEnabled = true
|
||||
// setCallConnected requests a second CXAnswerCallAction. That fires
|
||||
// didActivate again after the microphone track already exists and can
|
||||
// stop AudioEngine. Dart starts recording on each activation once the
|
||||
// track is published. The first activation (accept, no track yet) is a
|
||||
// no-op on the Dart side.
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.callAudioChannel?.invokeMethod("audioSessionActivated", arguments: nil)
|
||||
}
|
||||
}
|
||||
|
||||
func didDeactivateAudioSession(_ audioSession: AVAudioSession) {
|
||||
callKitOwnsAudioSession = false
|
||||
let rtcSession = RTCAudioSession.sharedInstance()
|
||||
rtcSession.audioSessionDidDeactivate(audioSession)
|
||||
// The audio unit is stopped, but isAudioEnabled stays true. A later
|
||||
@@ -865,6 +1212,7 @@ import flutter_foreground_task
|
||||
func providerDidReset() {
|
||||
resumeAudioAfterCallKitEnds = false
|
||||
audioRestartGeneration += 1
|
||||
callKitOwnsAudioSession = false
|
||||
}
|
||||
|
||||
private func configureWebRTCAudioSession() {
|
||||
@@ -872,33 +1220,62 @@ import flutter_foreground_task
|
||||
RTCAudioSessionConfiguration.setWebRTC(configuration)
|
||||
}
|
||||
|
||||
private func prepareAvAudioSessionCategory() {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
do {
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .videoChat,
|
||||
options: [.allowBluetooth, .defaultToSpeaker]
|
||||
)
|
||||
} catch {
|
||||
NSLog("[EatvioVoIP] prepare call audio category failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func prepareCallAudioCategory() {
|
||||
prepareAvAudioSessionCategory()
|
||||
applyCallAudioConfiguration(active: false)
|
||||
}
|
||||
|
||||
private func activateInAppCallAudioSession() {
|
||||
let rtcSession = RTCAudioSession.sharedInstance()
|
||||
rtcSession.useManualAudio = true
|
||||
if callKitOwnsAudioSession {
|
||||
rtcSession.isAudioEnabled = true
|
||||
return
|
||||
}
|
||||
applyCallAudioConfiguration(active: true)
|
||||
// Starting the audio unit while the configuration lock is held deadlocks
|
||||
// the capture thread, so the mic stays silent.
|
||||
rtcSession.isAudioEnabled = true
|
||||
}
|
||||
|
||||
/// Restarts capture after the previous audio graph has disconnected.
|
||||
/// Toggling the unit in the same turn makes voice processing throw.
|
||||
/// Outgoing (no CallKit) may need a teardown to attach capture. Incoming
|
||||
/// already started the unit in didActivate — toggling it here kills ADM.
|
||||
private func restartInAppCallAudioUnit() {
|
||||
audioRestartGeneration += 1
|
||||
let generation = audioRestartGeneration
|
||||
let rtcSession = RTCAudioSession.sharedInstance()
|
||||
rtcSession.useManualAudio = true
|
||||
if callKitOwnsAudioSession {
|
||||
rtcSession.isAudioEnabled = true
|
||||
return
|
||||
}
|
||||
audioRestartGeneration += 1
|
||||
let generation = audioRestartGeneration
|
||||
rtcSession.isAudioEnabled = false
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
|
||||
guard let self, generation == self.audioRestartGeneration else { return }
|
||||
self.activateInAppCallAudioSession()
|
||||
self.enableInAppCallAudioUnit()
|
||||
}
|
||||
}
|
||||
|
||||
private func enableInAppCallAudioUnit() {
|
||||
let rtcSession = RTCAudioSession.sharedInstance()
|
||||
rtcSession.useManualAudio = true
|
||||
if !callKitOwnsAudioSession {
|
||||
applyCallAudioConfiguration(active: true)
|
||||
}
|
||||
rtcSession.isAudioEnabled = true
|
||||
}
|
||||
|
||||
private func applyCallAudioConfiguration(active: Bool) {
|
||||
let rtcSession = RTCAudioSession.sharedInstance()
|
||||
rtcSession.lockForConfiguration()
|
||||
@@ -915,6 +1292,10 @@ import flutter_foreground_task
|
||||
audioRestartGeneration += 1
|
||||
let rtcSession = RTCAudioSession.sharedInstance()
|
||||
rtcSession.isAudioEnabled = false
|
||||
// Hang-up stops the unit first, then Dart closes CallKit. A second
|
||||
// setActive(false) while CallKit still owns the session fails or
|
||||
// tears the graph down in the wrong order.
|
||||
if callKitOwnsAudioSession { return }
|
||||
rtcSession.lockForConfiguration()
|
||||
defer { rtcSession.unlockForConfiguration() }
|
||||
do {
|
||||
|
||||
@@ -19,6 +19,10 @@ class SceneDelegate: FlutterSceneDelegate {
|
||||
override func sceneDidBecomeActive(_ scene: UIScene) {
|
||||
super.sceneDidBecomeActive(scene)
|
||||
registerCallChannels()
|
||||
guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else {
|
||||
return
|
||||
}
|
||||
appDelegate.deliverStoredAcceptedCall()
|
||||
}
|
||||
|
||||
override func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
|
||||
|
||||
163
ios/Runner/VideoCallDeclineClient.swift
Normal file
163
ios/Runner/VideoCallDeclineClient.swift
Normal file
@@ -0,0 +1,163 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
enum VideoCallDeclineClient {
|
||||
private static let uuidPattern =
|
||||
#"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"#
|
||||
|
||||
/// `POST /video-call/decline` from the CallKit callback. The process is
|
||||
/// awake here even when the user never opens the app afterwards.
|
||||
static func send(extra: [String: Any], handle: String) {
|
||||
post(action: "decline", extra: extra, handle: handle)
|
||||
}
|
||||
|
||||
/// `POST /video-call/accept` from the CallKit answer callback. Flutter may
|
||||
/// still be frozen on the splash snapshot, so the other device must be
|
||||
/// told from here. A later Dart retry with the same hw_id is idempotent.
|
||||
static func sendAccept(extra: [String: Any], handle: String) {
|
||||
post(action: "accept", extra: extra, handle: handle)
|
||||
}
|
||||
|
||||
private static func post(action: String, extra: [String: Any], handle: String) {
|
||||
let fields = declineFields(extra: extra, handle: handle)
|
||||
guard let request = makeRequest(
|
||||
baseUrl: prefsString("apiBaseUrl") ?? prefsString("customBaseUrl"),
|
||||
token: prefsString("token"),
|
||||
hwId: UIDevice.current.identifierForVendor?.uuidString,
|
||||
fields: fields,
|
||||
action: action
|
||||
) else {
|
||||
NSLog(
|
||||
"[EatvioVoIP] \(action) POST skipped conversation=\(fields.conversationUuid) ended=\(fields.endedPush)"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let task = EatvioBackgroundTask()
|
||||
task.begin(name: "video-call-\(action)")
|
||||
let urlTask = URLSession.shared.dataTask(with: request) { _, response, error in
|
||||
let code = (response as? HTTPURLResponse)?.statusCode ?? -1
|
||||
NSLog(
|
||||
"[EatvioVoIP] \(action) POST status=\(code) conversation=\(fields.conversationUuid) error=\(error?.localizedDescription ?? "-")"
|
||||
)
|
||||
task.end()
|
||||
}
|
||||
urlTask.resume()
|
||||
}
|
||||
|
||||
static func makeRequest(
|
||||
baseUrl: String?,
|
||||
token: String?,
|
||||
hwId: String?,
|
||||
fields: VideoCallDeclineFields,
|
||||
action: String = "decline"
|
||||
) -> URLRequest? {
|
||||
if fields.endedPush { return nil }
|
||||
let conversation = fields.conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let authToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let deviceId = hwId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
var root = baseUrl?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
while root.hasSuffix("/") {
|
||||
root.removeLast()
|
||||
}
|
||||
guard !conversation.isEmpty, !authToken.isEmpty else { return nil }
|
||||
guard deviceId.count >= 8, deviceId.count <= 64 else { return nil }
|
||||
guard root.hasPrefix("http://") || root.hasPrefix("https://") else { return nil }
|
||||
guard action == "accept" || action == "decline" else { return nil }
|
||||
guard let encoded = conversation.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed),
|
||||
let url = URL(string: "\(root)/api/v2/chat/\(encoded)/video-call/\(action)")
|
||||
else { return nil }
|
||||
|
||||
var body: [String: String] = ["hw_id": deviceId]
|
||||
if let callId = fields.callId {
|
||||
body["call_id"] = callId
|
||||
}
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: body) else { return nil }
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.timeoutInterval = 10
|
||||
request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = data
|
||||
return request
|
||||
}
|
||||
|
||||
private static func declineFields(extra: [String: Any], handle: String) -> VideoCallDeclineFields {
|
||||
let conversation = firstString(extra, keys: ["conversationUuid", "conversation_uuid"])
|
||||
?? handle.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let rawCallId = firstString(extra, keys: ["call_id", "callId"])
|
||||
return VideoCallDeclineFields(
|
||||
conversationUuid: conversation,
|
||||
callId: backendCallId(rawCallId, conversation: conversation),
|
||||
endedPush: marksEndedPush(extra["endedPush"])
|
||||
)
|
||||
}
|
||||
|
||||
private static func backendCallId(_ callId: String?, conversation: String) -> String? {
|
||||
let id = callId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard let regex = try? NSRegularExpression(pattern: uuidPattern),
|
||||
regex.firstMatch(in: id, range: NSRange(id.startIndex..., in: id)) != nil
|
||||
else { return nil }
|
||||
if id.compare(conversation.trimmingCharacters(in: .whitespacesAndNewlines), options: .caseInsensitive) == .orderedSame {
|
||||
return nil
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
private static func firstString(_ extra: [String: Any], keys: [String]) -> String? {
|
||||
for key in keys {
|
||||
guard let value = extra[key] as? String else { continue }
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty { return trimmed }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func marksEndedPush(_ value: Any?) -> Bool {
|
||||
if let bool = value as? Bool { return bool }
|
||||
if let number = value as? NSNumber { return number.intValue != 0 }
|
||||
if let text = value as? String {
|
||||
let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return normalized == "true" || normalized == "1"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private static func prefsString(_ key: String) -> String? {
|
||||
let value = UserDefaults.standard.string(forKey: "flutter.\(key)")?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let value, !value.isEmpty { return value }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
struct VideoCallDeclineFields {
|
||||
var conversationUuid: String
|
||||
var callId: String?
|
||||
var endedPush: Bool
|
||||
}
|
||||
|
||||
private final class EatvioBackgroundTask {
|
||||
private let lock = NSLock()
|
||||
private var id: UIBackgroundTaskIdentifier = .invalid
|
||||
|
||||
func begin(name: String) {
|
||||
let taskId = UIApplication.shared.beginBackgroundTask(withName: name) { [weak self] in
|
||||
self?.end()
|
||||
}
|
||||
lock.lock()
|
||||
id = taskId
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func end() {
|
||||
lock.lock()
|
||||
let current = id
|
||||
id = .invalid
|
||||
lock.unlock()
|
||||
guard current != .invalid else { return }
|
||||
UIApplication.shared.endBackgroundTask(current)
|
||||
}
|
||||
}
|
||||
@@ -43,37 +43,95 @@ platform :ios do
|
||||
end
|
||||
|
||||
UI.message "Using auth_key_path: #{auth_key_path}"
|
||||
|
||||
|
||||
# Authenticate to App Store Connect early:
|
||||
# needed here by latest_testflight_build_number, not only by upload_to_testflight.
|
||||
app_store_connect_api_key(
|
||||
key_id: "325JS9JCZH",
|
||||
issuer_id: "dfa27d1d-2bb8-4d4d-847e-0b32f9b880b3",
|
||||
key_filepath: auth_key_path,
|
||||
duration: 1200,
|
||||
in_house: false
|
||||
)
|
||||
|
||||
# Configure Flutter for release mode
|
||||
UI.message "Configuring Flutter for release mode..."
|
||||
sh("cd .. && flutter build ios --config-only --release")
|
||||
|
||||
# Read build number from Flutter Generated.xcconfig
|
||||
# Read version and build number from Flutter Generated.xcconfig.
|
||||
# They are only a floor: the actual build number is derived from TestFlight below,
|
||||
# so repeated CI runs on the same commit can never hit
|
||||
# ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE ("bundle version must be higher").
|
||||
generated_xcconfig_path = File.join("..", "Flutter", "Generated.xcconfig")
|
||||
flutter_build_name = nil
|
||||
flutter_build_number = nil
|
||||
|
||||
|
||||
if File.exist?(generated_xcconfig_path)
|
||||
File.foreach(generated_xcconfig_path) do |line|
|
||||
matches = line.match(/FLUTTER_BUILD_NUMBER=(.*)/)
|
||||
if matches
|
||||
flutter_build_number = matches[1].strip
|
||||
break
|
||||
if (m = line.match(/FLUTTER_BUILD_NAME=(.*)/))
|
||||
flutter_build_name = m[1].strip
|
||||
elsif (m = line.match(/FLUTTER_BUILD_NUMBER=(.*)/))
|
||||
flutter_build_number = m[1].strip
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if flutter_build_number.nil?
|
||||
UI.user_error!("Could not find FLUTTER_BUILD_NUMBER in #{generated_xcconfig_path}")
|
||||
|
||||
if flutter_build_name.nil? || flutter_build_number.nil?
|
||||
UI.user_error!("Could not find FLUTTER_BUILD_NAME/FLUTTER_BUILD_NUMBER in #{generated_xcconfig_path}")
|
||||
end
|
||||
|
||||
UI.message "Flutter build number: #{flutter_build_number}"
|
||||
|
||||
|
||||
UI.message "Flutter version from pubspec: #{flutter_build_name} (#{flutter_build_number})"
|
||||
|
||||
# Apple closes a version train once that version is approved/released.
|
||||
# Uploading into a closed train wastes a full ~8 min build and fails only
|
||||
# at the upload step with altool 90186 ("closed for new build submissions")
|
||||
# / 90062 ("must be higher than the previously approved version").
|
||||
# Fail fast here, before building.
|
||||
closed_train_states = [
|
||||
Spaceship::ConnectAPI::AppStoreVersion::AppVersionState::READY_FOR_DISTRIBUTION,
|
||||
Spaceship::ConnectAPI::AppStoreVersion::AppVersionState::PROCESSING_FOR_DISTRIBUTION,
|
||||
Spaceship::ConnectAPI::AppStoreVersion::AppVersionState::PENDING_DEVELOPER_RELEASE,
|
||||
Spaceship::ConnectAPI::AppStoreVersion::AppVersionState::PENDING_APPLE_RELEASE
|
||||
].join(",")
|
||||
asc_app = Spaceship::ConnectAPI::App.find("com.eatvioapp.app")
|
||||
closed_train = nil
|
||||
if asc_app.nil?
|
||||
UI.important("App com.eatvioapp.app not found in App Store Connect, skipping closed-train guard")
|
||||
else
|
||||
closed_train = asc_app.get_app_store_versions(
|
||||
filter: {
|
||||
appVersionState: closed_train_states,
|
||||
platform: Spaceship::ConnectAPI::Platform::IOS
|
||||
}
|
||||
).find do |v|
|
||||
(Gem::Version.new(v.version_string) >= Gem::Version.new(flutter_build_name))
|
||||
rescue ArgumentError
|
||||
false
|
||||
end
|
||||
end
|
||||
if closed_train
|
||||
UI.user_error!(
|
||||
"Version train #{closed_train.version_string} is closed for new submissions " \
|
||||
"(#{closed_train.app_version_state}), pubspec version is #{flutter_build_name}. " \
|
||||
"Bump the version in pubspec.yaml above #{closed_train.version_string} and re-run."
|
||||
)
|
||||
end
|
||||
|
||||
asc_latest_build_number = latest_testflight_build_number(
|
||||
app_identifier: "com.eatvioapp.app",
|
||||
version: flutter_build_name,
|
||||
initial_build_number: flutter_build_number.to_i
|
||||
)
|
||||
|
||||
new_build_number = [flutter_build_number.to_i, asc_latest_build_number].max + 1
|
||||
UI.message "Latest TestFlight build number: #{asc_latest_build_number} -> uploading build #{new_build_number}"
|
||||
|
||||
match(type: "appstore", readonly: true)
|
||||
|
||||
|
||||
# Set build number explicitly instead of incrementing
|
||||
increment_build_number(
|
||||
xcodeproj: "Runner.xcodeproj",
|
||||
build_number: flutter_build_number
|
||||
build_number: new_build_number
|
||||
)
|
||||
|
||||
update_code_signing_settings(
|
||||
@@ -93,28 +151,33 @@ platform :ios do
|
||||
export_team_id: "VL68C36HYS"
|
||||
)
|
||||
|
||||
app_store_connect_api_key(
|
||||
key_id: "325JS9JCZH",
|
||||
issuer_id: "dfa27d1d-2bb8-4d4d-847e-0b32f9b880b3",
|
||||
key_filepath: auth_key_path,
|
||||
duration: 1200,
|
||||
in_house: false
|
||||
)
|
||||
|
||||
|
||||
begin
|
||||
upload_to_testflight(
|
||||
skip_waiting_for_build_processing: true,
|
||||
skip_submission: true,
|
||||
distribute_external: false,
|
||||
notify_external_testers: false,
|
||||
changelog: "TestFlight build #{flutter_build_number}",
|
||||
changelog: "TestFlight build #{new_build_number}",
|
||||
verbose: true
|
||||
)
|
||||
rescue => ex
|
||||
UI.error "Upload to TestFlight failed: #{ex.message}"
|
||||
UI.error "Full error: #{ex.backtrace.join("\n")}"
|
||||
raise ex
|
||||
# Xcode 26 altool prints a harmless "ERROR: ... CDUploaderStateInfoResumeFilePath"
|
||||
# line even when the package was delivered fine (UPLOAD SUCCEEDED appears in the
|
||||
# same output). fastlane < 2.240.0 treats every ERROR line as a failure
|
||||
# (fastlane#30130), so the lane would die after a successful upload.
|
||||
# Real Apple rejections (90186 closed train, 90062 version too low, ...) always
|
||||
# contain UPLOAD FAILED and/or iris-code - those must still fail the lane.
|
||||
if ex.message.include?("CDUploaderStateInfoResumeFilePath") &&
|
||||
!ex.message.include?("UPLOAD FAILED") &&
|
||||
!ex.message.include?("iris-code")
|
||||
UI.important("altool delivered the package but reported a bogus resume-file ERROR " \
|
||||
"(fastlane#30130). Treating upload as successful - " \
|
||||
"verify the build appears in TestFlight.")
|
||||
else
|
||||
UI.error "Upload to TestFlight failed: #{ex.message}"
|
||||
UI.error "Full error: #{ex.backtrace.join("\n")}"
|
||||
raise ex
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -33,6 +33,20 @@ class AppConfig {
|
||||
late String _baseUrl;
|
||||
String get baseUrl => _baseUrl;
|
||||
|
||||
/// Продуктовые хосты API. Сравнение по хосту целиком: поддомены
|
||||
/// (например `*-hasan.eatvio.ru`) — это стенды разработки, не прод.
|
||||
static const List<String> prodApiHosts = ['eatvio.ru', 'eatvio.com'];
|
||||
|
||||
/// Текущий base URL указывает на продуктовый API?
|
||||
bool get isProdApi {
|
||||
final host = Uri.tryParse(baseUrl)?.host.toLowerCase() ?? '';
|
||||
if (host.isEmpty) return false;
|
||||
return prodApiHosts.any((prodHost) => host == prodHost || host == 'www.$prodHost');
|
||||
}
|
||||
|
||||
/// Работаем на стенде разработки/stage (не на продуктовом API)?
|
||||
bool get isDevOrStageStand => !isProdApi;
|
||||
|
||||
void setBaseUrl(String baseUrls) {
|
||||
_baseUrl = baseUrls;
|
||||
_persistBaseUrl(baseUrls);
|
||||
@@ -48,6 +62,7 @@ class AppConfig {
|
||||
return;
|
||||
}
|
||||
unawaited(sl<Prefs>().setString(PrefsKeys.customBaseUrl, normalized));
|
||||
unawaited(sl<Prefs>().setString(PrefsKeys.apiBaseUrl, normalized));
|
||||
}
|
||||
|
||||
/// Список глобальных BlocProvider'ов
|
||||
|
||||
@@ -1,16 +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.isEmpty) {
|
||||
if (cached != null && cached.isNotEmpty) {
|
||||
sl<AppConfig>().setBaseUrl(cached);
|
||||
return;
|
||||
}
|
||||
sl<AppConfig>().setBaseUrl(cached);
|
||||
_persistApiBaseUrl(sl<AppConfig>().baseUrl);
|
||||
}
|
||||
|
||||
void _persistApiBaseUrl(String baseUrl) {
|
||||
final normalized = baseUrl.trim();
|
||||
if (normalized.isEmpty || !sl.isRegistered<Prefs>()) {
|
||||
return;
|
||||
}
|
||||
unawaited(sl<Prefs>().setString(PrefsKeys.apiBaseUrl, normalized));
|
||||
}
|
||||
|
||||
@@ -1,16 +1,70 @@
|
||||
import 'dart:io';
|
||||
import 'dart: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)}';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,8 @@ import 'package:eatvio_app/features/order_info/cubit/order_info_cubit.dart';
|
||||
import 'package:eatvio_app/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_cubit.dart';
|
||||
import 'package:eatvio_app/features/personal_diet_suggestion/cubit/personal_diet_suggestion_cubit.dart';
|
||||
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_cubit.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/plans_cubit/plans_cubit.dart' as profile_plans;
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/plans_cubit/plans_cubit.dart'
|
||||
as profile_plans;
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/sub_cubit/sub_cubit.dart';
|
||||
import 'package:eatvio_app/features/recipe/presentation/cubit/about_recipe_cubit.dart';
|
||||
@@ -70,69 +71,174 @@ class CubitRegistrations {
|
||||
/// Регистрация Cubit'ов как синглтонов
|
||||
static void _registerSingletonCubits() {
|
||||
sl.registerLazySingleton<NewMessagesCubit>(() => NewMessagesCubit());
|
||||
sl.registerLazySingleton<NotificationsCubit>(() => NotificationsCubit(wsManager: sl(), notificationsRepository: sl()));
|
||||
sl.registerLazySingleton<ProfileAvatarCubit>(() => ProfileAvatarCubit(profileRepository: sl()));
|
||||
sl.registerLazySingleton<ChatsCubit>(() => ChatsCubit(chatRepository: sl(), wsManager: sl(), profileRepository: sl()));
|
||||
sl.registerLazySingleton<InternetConnectionCubit>(() => InternetConnectionCubit());
|
||||
sl.registerLazySingleton<VideoCallManager>(
|
||||
() => VideoCallManager(
|
||||
videoCallRepository: sl(),
|
||||
sl.registerLazySingleton<NotificationsCubit>(
|
||||
() => NotificationsCubit(wsManager: sl(), notificationsRepository: sl()),
|
||||
);
|
||||
sl.registerLazySingleton<ProfileAvatarCubit>(
|
||||
() => ProfileAvatarCubit(profileRepository: sl()),
|
||||
);
|
||||
sl.registerLazySingleton<ChatsCubit>(
|
||||
() => ChatsCubit(
|
||||
chatRepository: sl(),
|
||||
wsManager: sl(),
|
||||
profileRepository: sl(),
|
||||
),
|
||||
);
|
||||
sl.registerLazySingleton<InternetConnectionCubit>(
|
||||
() => InternetConnectionCubit(),
|
||||
);
|
||||
sl.registerLazySingleton<VideoCallManager>(
|
||||
() =>
|
||||
VideoCallManager(videoCallRepository: sl(), profileRepository: sl()),
|
||||
);
|
||||
sl.registerLazySingleton<VideoCallSessionCubit>(
|
||||
() => VideoCallSessionCubit(
|
||||
videoCallManager: sl(),
|
||||
systemPipCoordinator: sl(),
|
||||
),
|
||||
);
|
||||
sl.registerLazySingleton<FeedCubit>(() => FeedCubit(feedRepository: sl()));
|
||||
}
|
||||
|
||||
/// Регистрация Cubit'ов как фабрик
|
||||
static void _registerFactoryCubits() {
|
||||
// Auth related cubits
|
||||
sl.registerFactory<AuthCubit>(() => AuthCubit(authRepository: sl(), firebaseApi: sl(), prefs: sl(), profileRepository: sl()));
|
||||
sl.registerFactory<RegistrationCubit>(() => RegistrationCubit(registrationRepository: sl(), prefs: sl()));
|
||||
sl.registerFactory<ResetPasswordCubit>(() => ResetPasswordCubit(resetPasswordRepository: sl()));
|
||||
sl.registerFactory<VerifyEmailCubit>(() => VerifyEmailCubit(profileRepository: sl(), prefs: sl()));
|
||||
sl.registerFactory<OAuthCubit>(() => OAuthCubit(authRepository: sl(), prefs: sl()));
|
||||
sl.registerFactory<RegistrationCubitV2>(() => RegistrationCubitV2(registrationRepository: sl()));
|
||||
sl.registerFactory<AuthCubit>(
|
||||
() => AuthCubit(
|
||||
authRepository: sl(),
|
||||
firebaseApi: sl(),
|
||||
prefs: sl(),
|
||||
profileRepository: sl(),
|
||||
sessionLifecycleService: sl(),
|
||||
),
|
||||
);
|
||||
sl.registerFactory<RegistrationCubit>(
|
||||
() => RegistrationCubit(
|
||||
registrationRepository: sl(),
|
||||
prefs: sl(),
|
||||
sessionLifecycleService: sl(),
|
||||
),
|
||||
);
|
||||
sl.registerFactory<ResetPasswordCubit>(
|
||||
() => ResetPasswordCubit(resetPasswordRepository: sl()),
|
||||
);
|
||||
sl.registerFactory<VerifyEmailCubit>(
|
||||
() => VerifyEmailCubit(profileRepository: sl(), prefs: sl()),
|
||||
);
|
||||
sl.registerFactory<OAuthCubit>(
|
||||
() => OAuthCubit(
|
||||
authRepository: sl(),
|
||||
firebaseApi: sl(),
|
||||
prefs: sl(),
|
||||
sessionLifecycleService: sl(),
|
||||
),
|
||||
);
|
||||
sl.registerFactory<RegistrationCubitV2>(
|
||||
() => RegistrationCubitV2(
|
||||
registrationRepository: sl(),
|
||||
firebaseApi: sl(),
|
||||
sessionLifecycleService: sl(),
|
||||
),
|
||||
);
|
||||
// Feed related cubits
|
||||
sl.registerFactory<FeedCubit>(() => FeedCubit(feedRepository: sl()));
|
||||
sl.registerFactory<RecipesCubit>(() => RecipesCubit(feedRepository: sl(), recipeRepository: sl(), aboutRecipeRepository: sl()));
|
||||
sl.registerFactory<DietsCubit>(() => DietsCubit(feedRepository: sl(), dietsRepository: sl()));
|
||||
sl.registerFactory<PlansCubit>(() => PlansCubit(feedRepository: sl(), planRepository: sl()));
|
||||
sl.registerFactory<ConsultantsCubit>(() => ConsultantsCubit(feedRepository: sl()));
|
||||
sl.registerFactory<CommentsCubit>(() => CommentsCubit(feedRepository: sl(), commentsRepository: sl()));
|
||||
sl.registerFactory<FeedProfileCubit>(() => FeedProfileCubit(profileRepository: sl()));
|
||||
sl.registerFactory<RecipesCubit>(
|
||||
() => RecipesCubit(
|
||||
feedRepository: sl(),
|
||||
recipeRepository: sl(),
|
||||
aboutRecipeRepository: sl(),
|
||||
),
|
||||
);
|
||||
sl.registerFactory<DietsCubit>(
|
||||
() => DietsCubit(feedRepository: sl(), dietsRepository: sl()),
|
||||
);
|
||||
sl.registerFactory<PlansCubit>(
|
||||
() => PlansCubit(feedRepository: sl(), planRepository: sl()),
|
||||
);
|
||||
sl.registerFactory<ConsultantsCubit>(
|
||||
() => ConsultantsCubit(feedRepository: sl()),
|
||||
);
|
||||
sl.registerFactory<CommentsCubit>(
|
||||
() => CommentsCubit(feedRepository: sl(), commentsRepository: sl()),
|
||||
);
|
||||
sl.registerFactory<FeedProfileCubit>(
|
||||
() => FeedProfileCubit(profileRepository: sl()),
|
||||
);
|
||||
|
||||
// Chat related cubits
|
||||
sl.registerFactory<ChatRoomCubit>(() => ChatRoomCubit(cardsRepository: sl(), chatRepository: sl(), wsManager: sl()));
|
||||
sl.registerFactory<ChatRoomCubit>(
|
||||
() => ChatRoomCubit(
|
||||
cardsRepository: sl(),
|
||||
chatRepository: sl(),
|
||||
wsManager: sl(),
|
||||
),
|
||||
);
|
||||
|
||||
// Profile related cubits
|
||||
sl.registerFactory<ProfileCubit>(() => ProfileCubit(profileRepository: sl(), chatRepository: sl(), consultantRepository: sl()));
|
||||
sl.registerFactory<profile_plans.PlansCubit>(() => profile_plans.PlansCubit(plansRepository: sl()));
|
||||
sl.registerFactory<ProfileCubit>(
|
||||
() => ProfileCubit(
|
||||
profileRepository: sl(),
|
||||
chatRepository: sl(),
|
||||
consultantRepository: sl(),
|
||||
),
|
||||
);
|
||||
sl.registerFactory<profile_plans.PlansCubit>(
|
||||
() => profile_plans.PlansCubit(plansRepository: sl()),
|
||||
);
|
||||
sl.registerFactory<SubCubit>(() => SubCubit(profileRepository: sl()));
|
||||
|
||||
// Content creation cubits
|
||||
sl.registerFactory<CreateDietCubit>(() => CreateDietCubit(profileRepository: sl(), createDietRepository: sl(), glossaryRepository: sl()));
|
||||
sl.registerFactory<CreatePlanCubit>(() => CreatePlanCubit(createPlanRepository: sl()));
|
||||
sl.registerFactory<CreateRecipeCubit>(() => CreateRecipeCubit(recipeRepository: sl(), glossaryRepository: sl()));
|
||||
sl.registerFactory<BlogPostCreateCubit>(() => BlogPostCreateCubit(blogPostRepository: sl()));
|
||||
sl.registerFactory<CreateDietCubit>(
|
||||
() => CreateDietCubit(
|
||||
profileRepository: sl(),
|
||||
createDietRepository: sl(),
|
||||
glossaryRepository: sl(),
|
||||
),
|
||||
);
|
||||
sl.registerFactory<CreatePlanCubit>(
|
||||
() => CreatePlanCubit(createPlanRepository: sl()),
|
||||
);
|
||||
sl.registerFactory<CreateRecipeCubit>(
|
||||
() => CreateRecipeCubit(recipeRepository: sl(), glossaryRepository: sl()),
|
||||
);
|
||||
sl.registerFactory<BlogPostCreateCubit>(
|
||||
() => BlogPostCreateCubit(blogPostRepository: sl()),
|
||||
);
|
||||
|
||||
// Content viewing cubits
|
||||
sl.registerFactory<AboutDietCubit>(() => AboutDietCubit(dietsRepository: sl(), profileRepository: sl()));
|
||||
sl.registerFactory<AboutPlanCubit>(() => AboutPlanCubit(planRepository: sl(), profileRepository: sl(), dietRepository: sl()));
|
||||
sl.registerFactory<AboutRecipeCubit>(() => AboutRecipeCubit(recipeRepository: sl(), profileRepository: sl()));
|
||||
sl.registerFactory<AboutDietCubit>(
|
||||
() => AboutDietCubit(dietsRepository: sl(), profileRepository: sl()),
|
||||
);
|
||||
sl.registerFactory<AboutPlanCubit>(
|
||||
() => AboutPlanCubit(
|
||||
planRepository: sl(),
|
||||
profileRepository: sl(),
|
||||
dietRepository: sl(),
|
||||
),
|
||||
);
|
||||
sl.registerFactory<AboutRecipeCubit>(
|
||||
() => AboutRecipeCubit(recipeRepository: sl(), profileRepository: sl()),
|
||||
);
|
||||
sl.registerFactory<BlogPostCubit>(() => BlogPostCubit(sl()));
|
||||
|
||||
// User content cubits
|
||||
sl.registerFactory<MyDietsCubit>(() => MyDietsCubit(myDietsRepository: sl(), createDietRepository: sl()));
|
||||
sl.registerFactory<MyPlansCubit>(() => MyPlansCubit(myPlansRepository: sl()));
|
||||
sl.registerFactory<MyRecipesCubit>(() => MyRecipesCubit(myRecipesRepository: sl(), aboutRecipeRepository: sl(), recipeRepository: sl()));
|
||||
sl.registerFactory<MyDietsCubit>(
|
||||
() => MyDietsCubit(myDietsRepository: sl(), createDietRepository: sl()),
|
||||
);
|
||||
sl.registerFactory<MyPlansCubit>(
|
||||
() => MyPlansCubit(myPlansRepository: sl()),
|
||||
);
|
||||
sl.registerFactory<MyRecipesCubit>(
|
||||
() => MyRecipesCubit(
|
||||
myRecipesRepository: sl(),
|
||||
aboutRecipeRepository: sl(),
|
||||
recipeRepository: sl(),
|
||||
),
|
||||
);
|
||||
|
||||
// Other feature cubits
|
||||
sl.registerFactory<CalendarCubit>(() => CalendarCubit(calendarRepository: sl()));
|
||||
sl.registerFactory<CalendarCubit>(
|
||||
() => CalendarCubit(calendarRepository: sl()),
|
||||
);
|
||||
sl.registerFactory<DiaryCubit>(
|
||||
() => DiaryCubit(
|
||||
chatRepository: sl(),
|
||||
@@ -144,28 +250,74 @@ class CubitRegistrations {
|
||||
),
|
||||
);
|
||||
sl.registerFactory<SettingsCubit>(
|
||||
() => SettingsCubit(profileRepository: sl(), settingsRepository: sl(), consultantRepository: sl(), consultationsRepository: sl()),
|
||||
() => SettingsCubit(
|
||||
profileRepository: sl(),
|
||||
settingsRepository: sl(),
|
||||
consultantRepository: sl(),
|
||||
consultationsRepository: sl(),
|
||||
sessionLifecycleService: sl(),
|
||||
),
|
||||
);
|
||||
sl.registerFactory<RegisterConsultantCubit>(
|
||||
() => RegisterConsultantCubit(consultantRepository: sl()),
|
||||
);
|
||||
sl.registerFactory<ComplainingCubit>(
|
||||
() => ComplainingCubit(repository: sl()),
|
||||
);
|
||||
sl.registerFactory<UserCardsCubit>(
|
||||
() => UserCardsCubit(cardsRepository: sl()),
|
||||
);
|
||||
sl.registerFactory<OrderInfoCubit>(
|
||||
() => OrderInfoCubit(chatRepository: sl(), profileRepository: sl()),
|
||||
);
|
||||
sl.registerFactory<RegisterConsultantCubit>(() => RegisterConsultantCubit(consultantRepository: sl()));
|
||||
sl.registerFactory<ComplainingCubit>(() => ComplainingCubit(repository: sl()));
|
||||
sl.registerFactory<UserCardsCubit>(() => UserCardsCubit(cardsRepository: sl()));
|
||||
sl.registerFactory<OrderInfoCubit>(() => OrderInfoCubit(chatRepository: sl(), profileRepository: sl()));
|
||||
sl.registerFactory<ConsultationPaymentCubit>(
|
||||
() => ConsultationPaymentCubit(paymentRepository: sl(), sbpRepository: sl()),
|
||||
() => ConsultationPaymentCubit(
|
||||
paymentRepository: sl(),
|
||||
sbpRepository: sl(),
|
||||
),
|
||||
);
|
||||
sl.registerFactory<ConsultationsCubit>(
|
||||
() => ConsultationsCubit(consultationsRepository: sl(), chatRepository: sl()),
|
||||
);
|
||||
sl.registerFactory<ConsultantCommentsCubit>(
|
||||
() => ConsultantCommentsCubit(diaryRepository: sl()),
|
||||
);
|
||||
sl.registerFactory<CancelPlanCubit>(
|
||||
() => CancelPlanCubit(cancelPlanRepository: sl()),
|
||||
);
|
||||
sl.registerFactory<ConsultationsArchiveCubit>(
|
||||
() => ConsultationsArchiveCubit(consultationsRepository: sl()),
|
||||
);
|
||||
sl.registerFactory<ConsultantFinancesCubit>(
|
||||
() => ConsultantFinancesCubit(consultationsRepository: sl()),
|
||||
);
|
||||
sl.registerFactory<UserPurchasesCubit>(
|
||||
() => UserPurchasesCubit(consultationsRepository: sl()),
|
||||
);
|
||||
sl.registerFactory<BlockedUsersCubit>(
|
||||
() => BlockedUsersCubit(blockedUsersRepository: sl()),
|
||||
);
|
||||
sl.registerFactory<NotificationSettingsCubit>(
|
||||
() => NotificationSettingsCubit(notificationSettingsRepository: sl()),
|
||||
);
|
||||
sl.registerFactory<ConsultantRatingCubit>(
|
||||
() => ConsultantRatingCubit(
|
||||
feedRepository: sl(),
|
||||
repository: sl(),
|
||||
profileRepository: sl(),
|
||||
),
|
||||
);
|
||||
sl.registerFactory<ConsultationsCubit>(() => ConsultationsCubit(consultationsRepository: sl()));
|
||||
sl.registerFactory<ConsultantCommentsCubit>(() => ConsultantCommentsCubit(diaryRepository: sl()));
|
||||
sl.registerFactory<CancelPlanCubit>(() => CancelPlanCubit(cancelPlanRepository: sl()));
|
||||
sl.registerFactory<ConsultationsArchiveCubit>(() => ConsultationsArchiveCubit(consultationsRepository: sl()));
|
||||
sl.registerFactory<ConsultantFinancesCubit>(() => ConsultantFinancesCubit(consultationsRepository: sl()));
|
||||
sl.registerFactory<UserPurchasesCubit>(() => UserPurchasesCubit(consultationsRepository: sl()));
|
||||
sl.registerFactory<BlockedUsersCubit>(() => BlockedUsersCubit(blockedUsersRepository: sl()));
|
||||
sl.registerFactory<NotificationSettingsCubit>(() => NotificationSettingsCubit(notificationSettingsRepository: sl()));
|
||||
sl.registerFactory<ConsultantRatingCubit>(() => ConsultantRatingCubit(feedRepository: sl(), repository: sl(), profileRepository: sl()));
|
||||
sl.registerFactory<ViewsCubit>(() => ViewsCubit(viewsRepository: sl()));
|
||||
sl.registerFactory<UserDetailsCubit>(() => UserDetailsCubit(profileRepository: sl()));
|
||||
sl.registerFactory<MenuCubit>(() => MenuCubit(sl()));
|
||||
sl.registerFactory<UserDetailsCubit>(
|
||||
() => UserDetailsCubit(profileRepository: sl()),
|
||||
);
|
||||
sl.registerFactory<MenuCubit>(() => MenuCubit(sl(), sl()));
|
||||
sl.registerFactory<MarketingCubit>(() => MarketingCubit(sl()));
|
||||
sl.registerFactory<PersonalDietSuggestionCubit>(() => PersonalDietSuggestionCubit(dietRepository: sl(), personalDietSuggestionService: sl()));
|
||||
sl.registerFactory<PersonalDietSuggestionCubit>(
|
||||
() => PersonalDietSuggestionCubit(
|
||||
dietRepository: sl(),
|
||||
personalDietSuggestionService: sl(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import 'package:eatvio_app/features/diets/repositories/diet_repository.dart';
|
||||
import 'package:eatvio_app/features/feed/repositories/feed_repository.dart';
|
||||
import 'package:eatvio_app/features/glossary/repositories/glossary_repository.dart';
|
||||
import 'package:eatvio_app/features/barcode/repositories/barcode_repository.dart';
|
||||
import 'package:eatvio_app/features/insights/repositories/insights_repository.dart';
|
||||
import 'package:eatvio_app/features/menu/presentation/repositories/blocked_users_repository.dart';
|
||||
import 'package:eatvio_app/features/menu/repositories/notification_settings_repository.dart';
|
||||
import 'package:eatvio_app/features/my_diets/repositories/my_diets_repository.dart';
|
||||
@@ -55,6 +56,7 @@ class RepositoryRegistrations {
|
||||
sl.registerLazySingleton<VideoCallRepository>(() => VideoCallRepository(videoCallService: sl()));
|
||||
sl.registerLazySingleton<FeedRepository>(() => FeedRepository(feedService: sl()));
|
||||
sl.registerLazySingleton<ProfileRepository>(() => ProfileRepository(profileService: sl()));
|
||||
sl.registerLazySingleton<InsightsRepository>(() => InsightsRepository(profileRepository: sl()));
|
||||
sl.registerLazySingleton<CalendarRepository>(() => CalendarRepository(calendarService: sl()));
|
||||
sl.registerLazySingleton<SettingsRepository>(() => SettingsRepository(settingsService: sl()));
|
||||
sl.registerLazySingleton<ConsultantRepository>(() => ConsultantRepository(consultantService: sl()));
|
||||
|
||||
15
lib/core/di/session_registrations.dart
Normal file
15
lib/core/di/session_registrations.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
|
||||
import 'package:eatvio_app/core/websockets/ws_manager.dart';
|
||||
|
||||
class SessionRegistrations {
|
||||
static void registerAll() {
|
||||
sl.registerLazySingleton<SessionLifecycleService>(
|
||||
() => SessionLifecycleService(
|
||||
wsManager: sl<WsManager>(),
|
||||
errorReporter: sl<ErrorReporter>(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ 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/push/hms_client_error.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
@@ -44,15 +45,16 @@ 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': credentials.platform,
|
||||
'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id,
|
||||
'hw_id': hwId,
|
||||
'token': token,
|
||||
},
|
||||
).timeout(
|
||||
@@ -69,34 +71,52 @@ class FirebaseApi {
|
||||
}
|
||||
|
||||
Future<void> registerPushToken() async {
|
||||
if (await TokenStorage.haveTokenInStorage()) {
|
||||
try {
|
||||
await _requestNotificationPermission();
|
||||
final deviceInfo = DeviceInfoPlugin();
|
||||
final credentials = await _resolvePushCredentials();
|
||||
final token = credentials.token;
|
||||
if (token == null || token.isEmpty) return;
|
||||
await sl<Dio>().post(
|
||||
'/api/profile/notification/token/register',
|
||||
data: {
|
||||
'platform': credentials.platform,
|
||||
'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id,
|
||||
'token': token,
|
||||
},
|
||||
).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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/app_router.dart';
|
||||
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
|
||||
import 'package:eatvio_app/core/token_storage.dart';
|
||||
import 'package:eatvio_app/core/websockets/ws_manager.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class TokenInterceptor extends Interceptor {
|
||||
@override
|
||||
@@ -14,6 +12,7 @@ class TokenInterceptor extends Interceptor {
|
||||
final token = await TokenStorage.getTokenFromStorage();
|
||||
if (token != null && token.isNotEmpty) {
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
options.extra['authToken'] = token;
|
||||
}
|
||||
|
||||
super.onRequest(options, handler);
|
||||
@@ -25,9 +24,18 @@ class TokenInterceptor extends Interceptor {
|
||||
ErrorInterceptorHandler handler,
|
||||
) async {
|
||||
if (err.response?.statusCode == 401) {
|
||||
await sl<WsManager>().clear();
|
||||
await TokenStorage.removeTokenInStorage();
|
||||
AppRouter.navigatorKey.currentContext?.go('/auth-screen');
|
||||
final requestCarriedAuth = err.requestOptions.headers.containsKey(
|
||||
'Authorization',
|
||||
);
|
||||
final requestToken = err.requestOptions.extra['authToken'];
|
||||
final currentToken = await TokenStorage.getTokenFromStorage();
|
||||
if (requestCarriedAuth &&
|
||||
requestToken != null &&
|
||||
requestToken == currentToken) {
|
||||
await sl<SessionLifecycleService>().endSession(
|
||||
reason: SessionEndReason.unauthorized,
|
||||
);
|
||||
}
|
||||
}
|
||||
super.onError(err, handler);
|
||||
}
|
||||
|
||||
61
lib/core/interceptors/verification_requirement.dart
Normal file
61
lib/core/interceptors/verification_requirement.dart
Normal file
@@ -0,0 +1,61 @@
|
||||
/// Канал верификации, которого не хватает для публичного действия.
|
||||
enum VerificationChannel { phone, email }
|
||||
|
||||
/// Разбор 405-контракта верификации (type/reason/missing) в список каналов.
|
||||
///
|
||||
/// Бэкенд отвечает на публичное действие без подтверждения так:
|
||||
/// `{"type": "unverificated_phone", "reason": "verification_required", "missing": ["email"]}`.
|
||||
/// Старые ответы не содержали поля `missing` — тогда не хватало только телефона.
|
||||
class VerificationRequirement {
|
||||
const VerificationRequirement({required this.missing});
|
||||
|
||||
/// Каналы, подтверждение которых требует бэкенд.
|
||||
final List<VerificationChannel> missing;
|
||||
|
||||
bool get needsEmail => missing.contains(VerificationChannel.email);
|
||||
|
||||
bool get needsPhone => missing.contains(VerificationChannel.phone);
|
||||
|
||||
/// Возвращает null, если ответ не является отказом верификации.
|
||||
static VerificationRequirement? tryParse(Object? data) {
|
||||
if (data is! Map) {
|
||||
return null;
|
||||
}
|
||||
if (data['type'] != 'unverificated_phone') {
|
||||
return null;
|
||||
}
|
||||
|
||||
final Object? rawMissing = data['missing'];
|
||||
if (rawMissing is! List) {
|
||||
// Legacy-контракт: поля missing не было, значит не подтверждён телефон.
|
||||
return const VerificationRequirement(
|
||||
missing: [VerificationChannel.phone],
|
||||
);
|
||||
}
|
||||
|
||||
var hasPhone = false;
|
||||
var hasEmail = false;
|
||||
for (final Object? value in rawMissing) {
|
||||
// Неизвестные значения игнорируем, чтобы новые каналы бэкенда не ломали разбор.
|
||||
if (value == 'phone') {
|
||||
hasPhone = true;
|
||||
} else if (value == 'email') {
|
||||
hasEmail = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Пустой или нераспознанный missing трактуем как legacy-контракт «телефон».
|
||||
if (!hasPhone && !hasEmail) {
|
||||
return const VerificationRequirement(
|
||||
missing: [VerificationChannel.phone],
|
||||
);
|
||||
}
|
||||
|
||||
return VerificationRequirement(
|
||||
missing: [
|
||||
if (hasPhone) VerificationChannel.phone,
|
||||
if (hasEmail) VerificationChannel.email,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import 'package:eatvio_app/core/di/cubit_registrations.dart';
|
||||
import 'package:eatvio_app/core/di/external_registrations.dart';
|
||||
import 'package:eatvio_app/core/di/repository_registrations.dart';
|
||||
import 'package:eatvio_app/core/di/service_registrations.dart';
|
||||
import 'package:eatvio_app/core/di/session_registrations.dart';
|
||||
import 'package:eatvio_app/core/prefs.dart';
|
||||
import 'package:eatvio_app/core/websockets/ws_manager.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
@@ -38,4 +39,6 @@ Future<void> init({required AppBuildType buildType}) async {
|
||||
sl.registerLazySingleton<WsManager>(
|
||||
() => WsManager(wsService: sl(), errorReporter: sl()),
|
||||
);
|
||||
|
||||
SessionRegistrations.registerAll();
|
||||
}
|
||||
|
||||
@@ -62,12 +62,16 @@ 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(
|
||||
conversationUuid: endedPayload.conversationUuid,
|
||||
callId: endedPayload.callId,
|
||||
reason: endedPayload.reason,
|
||||
),
|
||||
);
|
||||
return;
|
||||
|
||||
@@ -5,4 +5,5 @@ enum PrefsKeys {
|
||||
marketingBottomSheetHasBeenShown,
|
||||
isEmailVerificationSkipped,
|
||||
customBaseUrl,
|
||||
apiBaseUrl,
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'package:eatvio_app/core/routing/route_constants.dart';
|
||||
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
|
||||
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_state.dart';
|
||||
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/widgets/bottom_nav_scope.dart';
|
||||
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/pages/chats_screen.dart';
|
||||
@@ -24,14 +23,10 @@ class MainNavigationRoutes {
|
||||
return [
|
||||
ShellRoute(
|
||||
builder: (context, state, child) {
|
||||
// Инициализация ProfileAvatarCubit при первом использовании ShellRoute
|
||||
final profileAvatarCubit = context.read<ProfileAvatarCubit>();
|
||||
if (profileAvatarCubit.state is ProfileAvatarStateLoading) {
|
||||
// Вызываем getMyProfile() только если состояние loading (первый запуск)
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
profileAvatarCubit.getMyProfile();
|
||||
});
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
profileAvatarCubit.ensureFreshForCurrentSession();
|
||||
});
|
||||
|
||||
return _MainShellWithNavScope(currentLocation: state.matchedLocation, child: child);
|
||||
},
|
||||
|
||||
@@ -354,9 +354,10 @@ class OtherRoutes {
|
||||
path: UserDiaryScreen.path,
|
||||
name: UserDiaryScreen.name,
|
||||
builder: (context, state) {
|
||||
final data = state.extra as Map<String, int>;
|
||||
final data = state.extra as Map<String, Object?>;
|
||||
final diaryId = data['diaryId'] as int;
|
||||
return UserDiaryScreen(diaryId: diaryId);
|
||||
final ownerSlug = data['ownerSlug'] as String?;
|
||||
return UserDiaryScreen(diaryId: diaryId, ownerSlug: ownerSlug);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
|
||||
122
lib/core/session/session_lifecycle_service.dart
Normal file
122
lib/core/session/session_lifecycle_service.dart
Normal file
@@ -0,0 +1,122 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:appmetrica_plugin/appmetrica_plugin.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/app_router.dart';
|
||||
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
|
||||
import 'package:eatvio_app/core/firebase_api.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/routing/route_constants.dart';
|
||||
import 'package:eatvio_app/core/token_storage.dart';
|
||||
import 'package:eatvio_app/core/websockets/ws_manager.dart';
|
||||
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/new_messages_cubit/new_messages_cubit.dart';
|
||||
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
||||
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
enum SessionEndReason { logout, unauthorized, accountDeleted }
|
||||
|
||||
class SessionLifecycleService {
|
||||
SessionLifecycleService({
|
||||
required WsManager wsManager,
|
||||
required ErrorReporter errorReporter,
|
||||
}) : _wsManager = wsManager,
|
||||
_errorReporter = errorReporter;
|
||||
|
||||
final WsManager _wsManager;
|
||||
final ErrorReporter _errorReporter;
|
||||
|
||||
int? _authenticatedFingerprint;
|
||||
bool _endingSession = false;
|
||||
|
||||
Future<void> endSession({required SessionEndReason reason}) async {
|
||||
if (_endingSession) return;
|
||||
_endingSession = true;
|
||||
try {
|
||||
await _run(() => _wsManager.clear());
|
||||
await _run(TokenStorage.removeTokenInStorage);
|
||||
await _run(SocketIdStorage.removeSocketIdInStorage);
|
||||
await _run(() => sl<ProfileAvatarCubit>().reset());
|
||||
await _run(() => sl<ChatsCubit>().reset());
|
||||
await _run(() => sl<NotificationsCubit>().reset());
|
||||
await _run(() => sl<NewMessagesCubit>().reset());
|
||||
await _run(() => sl<FeedCubit>().reset());
|
||||
await _run(() async => ProfileCubit.clearMyProfileReference());
|
||||
await _run(() => sl<VideoCallSessionCubit>().endCall());
|
||||
await _run(() async => AppMetrica.setUserProfileID(null));
|
||||
await _run(
|
||||
() => sl<Prefs>().remove(PrefsKeys.isEmailVerificationSkipped),
|
||||
);
|
||||
await _run(() => sl<Prefs>().remove(PrefsKeys.registrationDate));
|
||||
_authenticatedFingerprint = null;
|
||||
await _run(() async {
|
||||
final context = AppRouter.navigatorKey.currentContext;
|
||||
if (context == null) return;
|
||||
final path =
|
||||
AppRouter.appRouter.routeInformationProvider.value.uri.path;
|
||||
if (path != RouteConstants.authScreen) {
|
||||
context.go(RouteConstants.authScreen);
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
_endingSession = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> onAuthenticated() async {
|
||||
String? token;
|
||||
try {
|
||||
token = await TokenStorage.getTokenFromStorage();
|
||||
} catch (e, st) {
|
||||
await _report(e, st);
|
||||
return;
|
||||
}
|
||||
if (token == null || token.isEmpty) return;
|
||||
final fingerprint = token.hashCode;
|
||||
if (_authenticatedFingerprint == fingerprint) return;
|
||||
_authenticatedFingerprint = fingerprint;
|
||||
|
||||
await _run(() async {
|
||||
await _wsManager.clear();
|
||||
await _wsManager.initialize();
|
||||
});
|
||||
await _run(() => sl<ProfileAvatarCubit>().getMyProfile());
|
||||
await _run(() => sl<ChatsCubit>().getChatRoomsWithLoading());
|
||||
await _run(() => sl<NotificationsCubit>().getNotifications());
|
||||
await _run(() => sl<FirebaseApi>().registerPushToken());
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
try {
|
||||
await _run(() => sl<FirebaseApi>().unregisterPushTokens());
|
||||
await _run(
|
||||
() => sl<Dio>().post('${sl<AppConfig>().baseUrl}/api/profile/logout'),
|
||||
);
|
||||
} finally {
|
||||
await endSession(reason: SessionEndReason.logout);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _run(FutureOr<void> Function() action) async {
|
||||
try {
|
||||
await action();
|
||||
} catch (e, st) {
|
||||
await _report(e, st);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _report(Object error, StackTrace stackTrace) {
|
||||
return _errorReporter.report(
|
||||
error,
|
||||
stackTrace,
|
||||
origin: 'SessionLifecycleService',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -3,7 +3,11 @@ import 'dart:ui';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
Future<void> showBottomSheetWithBlur({
|
||||
/// Показывает modal bottom sheet с blur-фоном.
|
||||
///
|
||||
/// Дженерик [T] пробрасывает значение, переданное в `Navigator.pop(T)`
|
||||
/// внутри шита (по умолчанию `null`).
|
||||
Future<T?> showBottomSheetWithBlur<T>({
|
||||
required BuildContext context,
|
||||
required Widget child,
|
||||
double? height = 200,
|
||||
@@ -11,7 +15,7 @@ Future<void> showBottomSheetWithBlur({
|
||||
bool enableDrag = true,
|
||||
bool userRootNavigator = false,
|
||||
}) {
|
||||
return showModalBottomSheet(
|
||||
return showModalBottomSheet<T>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
barrierColor: appTheme.transparentBlack,
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:developer';
|
||||
import 'package:eatvio_app/core/firebase_api.dart';
|
||||
import 'package:eatvio_app/core/prefs.dart';
|
||||
import 'package:eatvio_app/core/prefs_keys.dart';
|
||||
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
|
||||
import 'package:eatvio_app/core/token_storage.dart';
|
||||
import 'package:eatvio_app/core/utils/toasts.dart';
|
||||
import 'package:eatvio_app/features/auth/presentation/bloc/auth_cubit/auth_state.dart';
|
||||
@@ -17,15 +18,26 @@ class AuthCubit extends Cubit<AuthState> {
|
||||
final ProfileRepository profileRepository;
|
||||
final FirebaseApi firebaseApi;
|
||||
final Prefs prefs;
|
||||
final SessionLifecycleService sessionLifecycleService;
|
||||
|
||||
AuthCubit({required this.authRepository, required this.prefs, required this.profileRepository, required this.firebaseApi}) : super(const AuthState());
|
||||
AuthCubit({
|
||||
required this.authRepository,
|
||||
required this.prefs,
|
||||
required this.profileRepository,
|
||||
required this.firebaseApi,
|
||||
required this.sessionLifecycleService,
|
||||
}) : super(const AuthState());
|
||||
|
||||
Future<bool> getTokenByCredentials(String email, String password) async {
|
||||
emit(const AuthState.loading());
|
||||
try {
|
||||
final token = (await authRepository.getTokenByCredentials(email, password)).token;
|
||||
final token = (await authRepository.getTokenByCredentials(
|
||||
email,
|
||||
password,
|
||||
)).token;
|
||||
emit(AuthState.loaded(token: token));
|
||||
await TokenStorage.saveTokenInStorage(token);
|
||||
await sessionLifecycleService.onAuthenticated();
|
||||
return true;
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
@@ -48,7 +60,8 @@ class AuthCubit extends Cubit<AuthState> {
|
||||
|
||||
Future<void> registerAnonymousPushToken() async {
|
||||
try {
|
||||
final hasAnonymousTokenBeenSent = prefs.getBool(PrefsKeys.hasAnonymousTokenBeenSent) ?? false;
|
||||
final hasAnonymousTokenBeenSent =
|
||||
prefs.getBool(PrefsKeys.hasAnonymousTokenBeenSent) ?? false;
|
||||
if (!hasAnonymousTokenBeenSent) {
|
||||
await firebaseApi.registerAnonymousPushToken();
|
||||
prefs.setBool(PrefsKeys.hasAnonymousTokenBeenSent, true);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'dart:developer';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/firebase_api.dart';
|
||||
import 'package:eatvio_app/core/mixins/registration_date_cache_mixin.dart';
|
||||
import 'package:eatvio_app/core/prefs.dart';
|
||||
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
|
||||
import 'package:eatvio_app/core/token_storage.dart';
|
||||
import 'package:eatvio_app/features/auth/models/oauth_verification_request_response.dart';
|
||||
import 'package:eatvio_app/features/auth/presentation/bloc/oauth_cubit/oauth_state.dart';
|
||||
@@ -11,12 +13,24 @@ import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||
|
||||
class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
|
||||
final AuthRepository authRepository;
|
||||
final FirebaseApi firebaseApi;
|
||||
final SessionLifecycleService sessionLifecycleService;
|
||||
|
||||
OAuthCubit({required this.authRepository, required Prefs prefs}) : super(const OAuthState()) {
|
||||
OAuthCubit({
|
||||
required this.authRepository,
|
||||
required this.firebaseApi,
|
||||
required this.sessionLifecycleService,
|
||||
required Prefs prefs,
|
||||
}) : super(const OAuthState()) {
|
||||
this.prefs = prefs;
|
||||
}
|
||||
|
||||
Future<void> signInViaSocialMedia({required String provider, required String accessToken, required String refreshToken, required String clientId}) async {
|
||||
Future<void> signInViaSocialMedia({
|
||||
required String provider,
|
||||
required String accessToken,
|
||||
required String refreshToken,
|
||||
required String clientId,
|
||||
}) async {
|
||||
try {
|
||||
emit(const OAuthState.loading());
|
||||
|
||||
@@ -28,7 +42,8 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
|
||||
);
|
||||
|
||||
log('OAuthCubit: Got token model: ${tokenModel.token}');
|
||||
TokenStorage.saveTokenInStorage(tokenModel.token);
|
||||
await TokenStorage.saveTokenInStorage(tokenModel.token);
|
||||
await sessionLifecycleService.onAuthenticated();
|
||||
emit(OAuthState.loaded(token: tokenModel.token));
|
||||
} on DioException catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
@@ -45,7 +60,8 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
|
||||
if (errorStr.contains('Некорректный формат email')) {
|
||||
errorsList.add(RegistrationValidationErrors.incorrectEmailFormat);
|
||||
}
|
||||
if (errorStr.contains('почта уже существует') || errorStr.contains('Эл. почта уже существует')) {
|
||||
if (errorStr.contains('почта уже существует') ||
|
||||
errorStr.contains('Эл. почта уже существует')) {
|
||||
errorsList.add(RegistrationValidationErrors.emailExist);
|
||||
}
|
||||
}
|
||||
@@ -53,14 +69,20 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
|
||||
if (phoneErrors != null && phoneErrors.isNotEmpty) {
|
||||
for (final error in phoneErrors) {
|
||||
final errorStr = error.toString();
|
||||
if (errorStr.contains('phone уже существует') || errorStr.contains('телефон уже существует')) {
|
||||
if (errorStr.contains('phone уже существует') ||
|
||||
errorStr.contains('телефон уже существует')) {
|
||||
errorsList.add(RegistrationValidationErrors.phoneExist);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errorsList.isNotEmpty) {
|
||||
emit(OAuthState.error(errorMessage: 'validation_error', registrationValidationErrors: errorsList));
|
||||
emit(
|
||||
OAuthState.error(
|
||||
errorMessage: 'validation_error',
|
||||
registrationValidationErrors: errorsList,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
emit(OAuthState.error(errorMessage: e.toString()));
|
||||
}
|
||||
@@ -103,7 +125,8 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
|
||||
height: height,
|
||||
clientId: clientId,
|
||||
);
|
||||
TokenStorage.saveTokenInStorage(token);
|
||||
await TokenStorage.saveTokenInStorage(token);
|
||||
await sessionLifecycleService.onAuthenticated();
|
||||
cacheRegistrationDate(DateTime.now());
|
||||
emit(OAuthState.loaded(token: token));
|
||||
} on DioException catch (e, st) {
|
||||
@@ -120,7 +143,8 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
|
||||
if (errorStr.contains('Некорректный формат email')) {
|
||||
errorsList.add(RegistrationValidationErrors.incorrectEmailFormat);
|
||||
}
|
||||
if (errorStr.contains('почта уже существует') || errorStr.contains('Эл. почта уже существует')) {
|
||||
if (errorStr.contains('почта уже существует') ||
|
||||
errorStr.contains('Эл. почта уже существует')) {
|
||||
errorsList.add(RegistrationValidationErrors.emailExist);
|
||||
}
|
||||
}
|
||||
@@ -128,14 +152,17 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
|
||||
if (phoneErrors != null && phoneErrors.isNotEmpty) {
|
||||
for (final error in phoneErrors) {
|
||||
final errorStr = error.toString();
|
||||
if (errorStr.contains('phone уже существует') || errorStr.contains('телефон уже существует')) {
|
||||
if (errorStr.contains('phone уже существует') ||
|
||||
errorStr.contains('телефон уже существует')) {
|
||||
errorsList.add(RegistrationValidationErrors.phoneExist);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (dateOfBirth != null && dateOfBirth.isNotEmpty) {
|
||||
if (dateOfBirth.contains('Некорректный формат даты рождения')) {
|
||||
errorsList.add(RegistrationValidationErrors.incorrectDateOfBirthFormat);
|
||||
errorsList.add(
|
||||
RegistrationValidationErrors.incorrectDateOfBirthFormat,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (errorsList.isNotEmpty) {
|
||||
@@ -146,13 +173,23 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<OAuthVerificationRequestResponse?> requestOAuthVerification({String? email, String? phone}) async {
|
||||
Future<OAuthVerificationRequestResponse?> requestOAuthVerification({
|
||||
String? email,
|
||||
String? phone,
|
||||
}) async {
|
||||
try {
|
||||
emit(const OAuthState.loading());
|
||||
log('OAuthCubit: Requesting verification code for email: $email, phone: $phone');
|
||||
final response = await authRepository.requestOAuthVerification(email: email, phone: phone);
|
||||
log(
|
||||
'OAuthCubit: Requesting verification code for email: $email, phone: $phone',
|
||||
);
|
||||
final response = await authRepository.requestOAuthVerification(
|
||||
email: email,
|
||||
phone: phone,
|
||||
);
|
||||
if (response != null) {
|
||||
log('OAuthCubit: Verification code requested successfully, masked email: ${response.email}');
|
||||
log(
|
||||
'OAuthCubit: Verification code requested successfully, masked email: ${response.email}',
|
||||
);
|
||||
}
|
||||
// Сбрасываем состояние после успешного запроса кода
|
||||
emit(const OAuthState());
|
||||
@@ -184,7 +221,9 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
|
||||
}) async {
|
||||
try {
|
||||
emit(const OAuthState.loading());
|
||||
log('OAuthCubit: Confirming OAuth verification - provider: $provider, verificationType: $verificationType, code: $code');
|
||||
log(
|
||||
'OAuthCubit: Confirming OAuth verification - provider: $provider, verificationType: $verificationType, code: $code',
|
||||
);
|
||||
final response = await authRepository.confirmOAuthVerification(
|
||||
provider: provider,
|
||||
accessToken: accessToken,
|
||||
@@ -202,8 +241,11 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
|
||||
weight: weight,
|
||||
height: height,
|
||||
);
|
||||
log('OAuthCubit: OAuth verification confirmed successfully, token received');
|
||||
TokenStorage.saveTokenInStorage(response.token);
|
||||
log(
|
||||
'OAuthCubit: OAuth verification confirmed successfully, token received',
|
||||
);
|
||||
await TokenStorage.saveTokenInStorage(response.token);
|
||||
await sessionLifecycleService.onAuthenticated();
|
||||
emit(OAuthState.loaded(token: response.token));
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
@@ -212,4 +254,3 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,12 +4,10 @@ import 'package:eatvio_app/app_keys.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/app_router.dart';
|
||||
import 'package:eatvio_app/core/const.dart';
|
||||
import 'package:eatvio_app/core/firebase_api.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/core/utils/image_constant.dart';
|
||||
import 'package:eatvio_app/core/utils/toasts.dart';
|
||||
import 'package:eatvio_app/core/websockets/ws_manager.dart';
|
||||
import 'package:eatvio_app/core/widgets/custom_divider.dart';
|
||||
import 'package:eatvio_app/core/widgets/custom_text_form_field.dart';
|
||||
import 'package:eatvio_app/features/auth/presentation/bloc/auth_cubit/auth_cubit.dart';
|
||||
@@ -18,8 +16,6 @@ import 'package:eatvio_app/features/auth/presentation/bloc/oauth_cubit/oauth_cub
|
||||
import 'package:eatvio_app/features/auth/presentation/bloc/oauth_cubit/oauth_state.dart';
|
||||
import 'package:eatvio_app/features/auth/entities/auth_result_entity.dart';
|
||||
import 'package:eatvio_app/core/di/cubit_registrations.dart';
|
||||
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
|
||||
import 'package:eatvio_app/features/dev_tools/presentation/dev_tools_screen.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
@@ -68,23 +64,8 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _reconnectWebSocket(BuildContext context) async {
|
||||
try {
|
||||
await sl<WsManager>().clear();
|
||||
await sl<WsManager>().initialize();
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
if (context.mounted) {
|
||||
try {
|
||||
await sl<ProfileAvatarCubit>().getMyProfile();
|
||||
await sl<ChatsCubit>().getChatRoomsWithLoading();
|
||||
await sl<FirebaseApi>().registerPushToken();
|
||||
showSuccessToast('Успешный вход');
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
Future<void> _reconnectWebSocket() async {
|
||||
showSuccessToast('Успешный вход');
|
||||
AppRouter.navigatorKey.currentContext?.go('/feed-screen', extra: {'needToInitializeWebSocket': false});
|
||||
}
|
||||
|
||||
@@ -202,18 +183,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
}
|
||||
|
||||
Future<void> _reconnectAfterAuth() async {
|
||||
final context = AppRouter.navigatorKey.currentContext;
|
||||
if (context == null) return;
|
||||
try {
|
||||
await sl<WsManager>().clear();
|
||||
await sl<WsManager>().initialize();
|
||||
await sl<ProfileAvatarCubit>().getMyProfile();
|
||||
await sl<ChatsCubit>().getChatRoomsWithLoading();
|
||||
await sl<FirebaseApi>().registerPushToken();
|
||||
showSuccessToast('Успешный вход');
|
||||
} catch (e) {
|
||||
log(e.toString());
|
||||
}
|
||||
showSuccessToast('Успешный вход');
|
||||
AppRouter.navigatorKey.currentContext?.go('/feed-screen', extra: {'needToInitializeWebSocket': false});
|
||||
}
|
||||
|
||||
@@ -395,7 +365,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
final isAuthenticated = await authCubit.getTokenByCredentials(emailController.text, passwordController.text);
|
||||
if (isAuthenticated) {
|
||||
if (context.mounted) {
|
||||
await _reconnectWebSocket(context);
|
||||
await _reconnectWebSocket();
|
||||
}
|
||||
}
|
||||
setState(() {
|
||||
|
||||
@@ -9,11 +9,7 @@ import 'package:eatvio_app/features/auth/presentation/bloc/oauth_cubit/oauth_sta
|
||||
import 'package:eatvio_app/features/auth/entities/auth_result_entity.dart';
|
||||
import 'package:eatvio_app/core/app_router.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/websockets/ws_manager.dart';
|
||||
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
|
||||
import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart';
|
||||
import 'package:eatvio_app/core/firebase_api.dart';
|
||||
import 'package:eatvio_app/core/utils/toasts.dart';
|
||||
import 'package:eatvio_app/core/utils/validation_utils.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
@@ -62,23 +58,8 @@ class _RegisterViaSocialScreenState extends State<RegisterViaSocialScreen> {
|
||||
_initializeFieldsFromAuthResult();
|
||||
}
|
||||
|
||||
Future<void> _reconnectWebSocket(BuildContext context) async {
|
||||
try {
|
||||
await sl<WsManager>().clear();
|
||||
await sl<WsManager>().initialize();
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
if (context.mounted) {
|
||||
try {
|
||||
await sl<ProfileAvatarCubit>().getMyProfile();
|
||||
await sl<ChatsCubit>().getChatRoomsWithLoading();
|
||||
await sl<FirebaseApi>().registerPushToken();
|
||||
showSuccessToast('Успешная регистрация');
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
Future<void> _reconnectWebSocket() async {
|
||||
showSuccessToast('Успешная регистрация');
|
||||
AppRouter.appRouter.go('/feed-screen', extra: {'needToInitializeWebSocket': false});
|
||||
}
|
||||
|
||||
@@ -451,7 +432,7 @@ class _RegisterViaSocialScreenState extends State<RegisterViaSocialScreen> {
|
||||
Builder(
|
||||
builder: (context) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_reconnectWebSocket(context);
|
||||
_reconnectWebSocket();
|
||||
});
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
@@ -795,7 +776,7 @@ class _RegisterViaSocialScreenState extends State<RegisterViaSocialScreen> {
|
||||
|
||||
// Перезагружаем веб-сокет и переходим на главный экран
|
||||
if (widgetContext.mounted) {
|
||||
_reconnectWebSocket(widgetContext);
|
||||
_reconnectWebSocket();
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
log('OAuth Verification Confirm Error: $e', error: e, stackTrace: stackTrace);
|
||||
|
||||
@@ -3,13 +3,10 @@ import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/app_router.dart';
|
||||
import 'package:eatvio_app/core/firebase_api.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
|
||||
import 'package:eatvio_app/core/token_storage.dart';
|
||||
import 'package:eatvio_app/core/utils/toasts.dart';
|
||||
import 'package:eatvio_app/core/websockets/ws_manager.dart';
|
||||
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:qr_code_scanner_plus/qr_code_scanner_plus.dart';
|
||||
@@ -39,7 +36,10 @@ class _ScanQRCodeScreenState extends State<ScanQRCodeScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Сканирование QR-кода'), centerTitle: true),
|
||||
appBar: AppBar(
|
||||
title: const Text('Сканирование QR-кода'),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: Column(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
@@ -60,30 +60,33 @@ class _ScanQRCodeScreenState extends State<ScanQRCodeScreen> {
|
||||
try {
|
||||
final scannedUrl = scanData.code;
|
||||
final uri = Uri.parse(scannedUrl ?? '');
|
||||
final scannedBaseUrl = (uri.hasAuthority) ? '${uri.scheme}://${uri.host}' : '';
|
||||
final scannedBaseUrl = (uri.hasAuthority)
|
||||
? '${uri.scheme}://${uri.host}'
|
||||
: '';
|
||||
sl<AppConfig>().setBaseUrl(scannedBaseUrl);
|
||||
sl<Dio>().options.baseUrl = scannedBaseUrl;
|
||||
final response = await Dio().getUri(uri, options: Options(headers: {'accept': 'application/json', 'content-type': 'application/json'}));
|
||||
final response = await Dio().getUri(
|
||||
uri,
|
||||
options: Options(
|
||||
headers: {
|
||||
'accept': 'application/json',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
),
|
||||
);
|
||||
final token = response.data['token'];
|
||||
await TokenStorage.saveTokenInStorage(token);
|
||||
try {
|
||||
await sl<WsManager>().clear();
|
||||
await sl<WsManager>().initialize();
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
try {
|
||||
await sl<ProfileAvatarCubit>().getMyProfile();
|
||||
await sl<ChatsCubit>().getChatRoomsWithLoading();
|
||||
await sl<FirebaseApi>().registerPushToken();
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
AppRouter.navigatorKey.currentContext?.go('/feed-screen', extra: {'needToInitializeWebSocket': false});
|
||||
await sl<SessionLifecycleService>().onAuthenticated();
|
||||
AppRouter.navigatorKey.currentContext?.go(
|
||||
'/feed-screen',
|
||||
extra: {'needToInitializeWebSocket': false},
|
||||
);
|
||||
showSuccessToast('Добро пожаловать!');
|
||||
} catch (e) {
|
||||
await controller.resumeCamera();
|
||||
showErrorToast('Что-то пошло не так. Попробуйте обновить страницу с QR-кодом или войдите с помощью логина и пароля.');
|
||||
showErrorToast(
|
||||
'Что-то пошло не так. Попробуйте обновить страницу с QR-кодом или войдите с помощью логина и пароля.',
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,13 @@ class NewMessagesCubit extends Cubit<NewMessagesState> {
|
||||
|
||||
NewMessagesCubit() : super(const NewMessagesState.empty());
|
||||
|
||||
void reset() {
|
||||
_messagesCount = 0;
|
||||
if (!isClosed) {
|
||||
emit(const NewMessagesState.empty());
|
||||
}
|
||||
}
|
||||
|
||||
void incrementCounter() {
|
||||
_messagesCount++;
|
||||
emit(NewMessagesState.loaded(messagesCount: _messagesCount));
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:appmetrica_plugin/appmetrica_plugin.dart';
|
||||
import 'package:eatvio_app/core/app_router.dart';
|
||||
import 'package:eatvio_app/core/token_storage.dart';
|
||||
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_state.dart';
|
||||
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
class ProfileAvatarCubit extends Cubit<ProfileAvatarState> {
|
||||
final ProfileRepository profileRepository;
|
||||
|
||||
ProfileAvatarCubit({required this.profileRepository}) : super(const ProfileAvatarState.loading());
|
||||
ProfileAvatarCubit({required this.profileRepository})
|
||||
: super(const ProfileAvatarState.loading());
|
||||
|
||||
int _requestId = 0;
|
||||
int? _sessionFingerprint;
|
||||
bool _ensuring = false;
|
||||
|
||||
int _calculateAge(String birthdate) {
|
||||
try {
|
||||
@@ -25,7 +29,8 @@ class ProfileAvatarCubit extends Cubit<ProfileAvatarState> {
|
||||
final now = DateTime.now();
|
||||
int age = now.year - birthYear;
|
||||
|
||||
if (now.month < birthMonth || (now.month == birthMonth && now.day < birthDay)) {
|
||||
if (now.month < birthMonth ||
|
||||
(now.month == birthMonth && now.day < birthDay)) {
|
||||
age--;
|
||||
}
|
||||
|
||||
@@ -38,30 +43,82 @@ class ProfileAvatarCubit extends Cubit<ProfileAvatarState> {
|
||||
return 0;
|
||||
}
|
||||
|
||||
void reset() {
|
||||
_requestId++;
|
||||
_sessionFingerprint = null;
|
||||
unawaited(AppMetrica.setUserProfileID(null).catchError((Object _) {}));
|
||||
if (!isClosed) emit(const ProfileAvatarState.loading());
|
||||
}
|
||||
|
||||
Future<void> ensureFreshForCurrentSession() async {
|
||||
final token = await TokenStorage.getTokenFromStorage();
|
||||
if (token == null || token.isEmpty) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
|
||||
final fingerprint = token.hashCode;
|
||||
if (_sessionFingerprint == fingerprint &&
|
||||
state is ProfileAvatarStateLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_ensuring) {
|
||||
return;
|
||||
}
|
||||
|
||||
_ensuring = true;
|
||||
try {
|
||||
await getMyProfile();
|
||||
} finally {
|
||||
_ensuring = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> getMyProfile() async {
|
||||
final requestId = ++_requestId;
|
||||
final token = await TokenStorage.getTokenFromStorage();
|
||||
final fingerprint = token?.hashCode;
|
||||
if (isClosed || requestId != _requestId) return;
|
||||
|
||||
emit(const ProfileAvatarState.loading());
|
||||
try {
|
||||
final profile = await profileRepository.getMyProfile();
|
||||
if (isClosed || requestId != _requestId) return;
|
||||
|
||||
final profileDetails = await profileRepository.getMyProfileDetails();
|
||||
if (isClosed || requestId != _requestId) return;
|
||||
|
||||
_sessionFingerprint = fingerprint;
|
||||
emit(ProfileAvatarState.loaded(profile: profile));
|
||||
|
||||
try {
|
||||
final notificationStatus = await Permission.notification.status;
|
||||
if (isClosed || requestId != _requestId) return;
|
||||
final isNotificationEnabled = notificationStatus.isGranted;
|
||||
AppMetricaUserProfile userProfile = AppMetricaUserProfile([
|
||||
AppMetricaNameAttribute.withValue(profile.name),
|
||||
AppMetricaGenderAttribute.withValue(profileDetails.gender == 'male' ? AppMetricaGender.male : AppMetricaGender.female),
|
||||
AppMetricaBirthDateAttribute.withAge(_calculateAge(profileDetails.birthdate)),
|
||||
AppMetricaNotificationEnabledAttribute.withValue(isNotificationEnabled),
|
||||
AppMetricaGenderAttribute.withValue(
|
||||
profileDetails.gender == 'male'
|
||||
? AppMetricaGender.male
|
||||
: AppMetricaGender.female,
|
||||
),
|
||||
AppMetricaBirthDateAttribute.withAge(
|
||||
_calculateAge(profileDetails.birthdate),
|
||||
),
|
||||
AppMetricaNotificationEnabledAttribute.withValue(
|
||||
isNotificationEnabled,
|
||||
),
|
||||
]);
|
||||
AppMetrica.setUserProfileID(profile.slug);
|
||||
AppMetrica.reportUserProfile(userProfile);
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
// Игнорируем ошибки плагинов, профиль уже загружен
|
||||
}
|
||||
} catch (e, st) {
|
||||
if (isClosed || requestId != _requestId) return;
|
||||
await reportFrom(e, st, this);
|
||||
AppRouter.navigatorKey.currentContext?.go('/auth-screen');
|
||||
if (isClosed || requestId != _requestId) return;
|
||||
emit(ProfileAvatarState.error(errorMessage: e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,8 @@ class BottomNavigationBarWidget extends StatefulWidget {
|
||||
class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
int currentBottomNavBarIndex = 1;
|
||||
List<String>? pages;
|
||||
int? _pagesProfileId;
|
||||
bool? _pagesIsConsultant;
|
||||
int tapsCount = 0;
|
||||
|
||||
List<String> _getPages(bool isConsultant) {
|
||||
@@ -179,7 +181,16 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
final isConsultant = profileModel.roles.contains('consultant');
|
||||
|
||||
// Инициализируем pages на основе profileModel
|
||||
pages ??= _getPages(isConsultant);
|
||||
if (pages == null ||
|
||||
_pagesProfileId != profileModel.id ||
|
||||
_pagesIsConsultant != isConsultant) {
|
||||
_pagesProfileId = profileModel.id;
|
||||
_pagesIsConsultant = isConsultant;
|
||||
pages = _getPages(isConsultant);
|
||||
if (currentBottomNavBarIndex >= pages!.length) {
|
||||
currentBottomNavBarIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Expose tab switching to descendants (e.g. notification widget)
|
||||
final controller = BottomNavScope.maybeOf(context);
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// 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,
|
||||
@@ -46,6 +57,17 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
int page = 1;
|
||||
late int lastPage;
|
||||
|
||||
/// Clears all user-scoped state. WS subscriptions stay intact so that
|
||||
/// `getChatRoomsWithLoading` can re-load the new user's chats.
|
||||
void reset() {
|
||||
_query = null;
|
||||
page = 1;
|
||||
lastPage = 1;
|
||||
if (!isClosed) {
|
||||
emit(ChatsState());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> connectToWebSocket() async {
|
||||
try {
|
||||
await newIncomingMessagesSubscription?.cancel();
|
||||
@@ -55,6 +77,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 +223,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 +271,7 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
message,
|
||||
isOwn: isOwn,
|
||||
chatRooms: currentChatRooms,
|
||||
callId: callId,
|
||||
);
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
@@ -227,12 +282,15 @@ 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(message.roomId));
|
||||
unawaited(
|
||||
VideoCallEndedPushHandler.endActiveCallIfMatches(message.roomId),
|
||||
VideoCallEndedPushHandler.endActiveCallIfMatches(
|
||||
conversationUuid: message.roomId,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -246,6 +304,7 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
message: message,
|
||||
callerName: caller?.name,
|
||||
callerAvatarUrl: caller?.profilePhotoUrl,
|
||||
callId: callId,
|
||||
);
|
||||
if (payload == null) return;
|
||||
unawaited(_showIncomingVideoCall(payload));
|
||||
@@ -260,6 +319,35 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _closeCallFromSocket(VideoCallSocketSignal? signal) async {
|
||||
if (signal == null) return;
|
||||
if (await _acceptedOnThisDevice(signal)) return;
|
||||
if (signal.endsActiveCall) {
|
||||
final endActive = endActiveVideoCall;
|
||||
if (endActive != null) {
|
||||
await endActive(
|
||||
conversationUuid: signal.conversationUuid,
|
||||
callId: signal.callId,
|
||||
);
|
||||
} else {
|
||||
await VideoCallEndedPushHandler.endActiveCallIfMatches(
|
||||
conversationUuid: signal.conversationUuid,
|
||||
callId: signal.callId,
|
||||
);
|
||||
}
|
||||
}
|
||||
final dismiss = dismissIncomingVideoCall;
|
||||
if (dismiss != null) {
|
||||
await dismiss(signal.conversationUuid);
|
||||
return;
|
||||
}
|
||||
await VideoCallEndedPushHandler.dismissIncomingCallUi(
|
||||
conversationUuid: signal.conversationUuid,
|
||||
callId: signal.callId,
|
||||
reason: signal.reason,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _dismissIncomingVideoCallUi(String? conversationUuid) async {
|
||||
try {
|
||||
final dismiss = dismissIncomingVideoCall;
|
||||
@@ -278,6 +366,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,
|
||||
@@ -306,6 +422,9 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
@override
|
||||
Future<void> close() {
|
||||
newIncomingMessagesSubscription?.cancel();
|
||||
videoCallDeclinedSubscription?.cancel();
|
||||
videoCallEndedSubscription?.cancel();
|
||||
videoCallAcceptedSubscription?.cancel();
|
||||
reconnectedSubscription?.cancel();
|
||||
return super.close();
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/order_info/presentation/info_by_order_screen.dart';
|
||||
import 'package:eatvio_app/features/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>{
|
||||
|
||||
@@ -135,9 +135,9 @@ class _EmptyChatScreenState extends State<EmptyChatScreen> {
|
||||
textEditingController.clear();
|
||||
});
|
||||
}
|
||||
final chatIdOrError = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
|
||||
if (context.mounted) {
|
||||
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatIdOrError});
|
||||
final chatId = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
|
||||
if (chatId != null && chatId.isNotEmpty && context.mounted) {
|
||||
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatId});
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -189,9 +189,9 @@ class _EmptyChatScreenState extends State<EmptyChatScreen> {
|
||||
if (images.isNotEmpty) {
|
||||
List<File> files = images.map((image) => File(image.path)).toList();
|
||||
await sl<ChatRepository>().sendMessage(files: files, slug: widget.user.slug);
|
||||
final chatIdOrError = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
|
||||
if (mounted) {
|
||||
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatIdOrError});
|
||||
final chatId = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
|
||||
if (chatId != null && chatId.isNotEmpty && mounted) {
|
||||
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatId});
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -205,9 +205,9 @@ class _EmptyChatScreenState extends State<EmptyChatScreen> {
|
||||
if (images.isNotEmpty) {
|
||||
List<File> files = images.map((image) => File(image.path)).toList();
|
||||
await sl<ChatRepository>().sendMessage(files: files, slug: widget.user.slug);
|
||||
final chatIdOrError = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
|
||||
if (mounted) {
|
||||
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatIdOrError});
|
||||
final chatId = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
|
||||
if (chatId != null && chatId.isNotEmpty && mounted) {
|
||||
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatId});
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -243,9 +243,9 @@ class _EmptyChatScreenState extends State<EmptyChatScreen> {
|
||||
if (result != null) {
|
||||
List<File> files = result.paths.map((path) => File(path!)).toList();
|
||||
await sl<ChatRepository>().sendMessage(files: files, slug: widget.user.slug);
|
||||
final chatIdOrError = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
|
||||
if (mounted) {
|
||||
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatIdOrError});
|
||||
final chatId = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
|
||||
if (chatId != null && chatId.isNotEmpty && mounted) {
|
||||
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatId});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -106,7 +106,11 @@ class _NotificationBannerState extends State<NotificationBanner> {
|
||||
onTap: () {
|
||||
final planId = (context.read<ChatRoomCubit>().state as ChatRoomStateLoaded).chatRoom.chosenPlan?.id;
|
||||
if (planId != null) {
|
||||
showConsultationPaymentFlow(context, planId: planId);
|
||||
showConsultationPaymentFlow(
|
||||
context,
|
||||
planId: planId,
|
||||
amount: widget.planPrice,
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Text(s.pay, style: TextStyles.textLSemibold.copyWith(color: appTheme.blueA400)),
|
||||
|
||||
@@ -137,11 +137,12 @@ class ChatRepository {
|
||||
|
||||
}
|
||||
|
||||
Future<String> getChatByUserSlug(String slug) async {
|
||||
Future<String?> getChatByUserSlug(String slug) async {
|
||||
final response = await chatService.getChatByUserSlug(slug: slug);
|
||||
if (response.response.statusCode == 404) {
|
||||
return '404';
|
||||
final data = response.data;
|
||||
if (data is Map && data['id'] is String && (data['id'] as String).isNotEmpty) {
|
||||
return data['id'] as String;
|
||||
}
|
||||
return response.data['id'];
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ class BecomeConsultantScreen extends StatefulWidget {
|
||||
class _BecomeConsultantScreenState extends State<BecomeConsultantScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bottomInset = MediaQuery.paddingOf(context).bottom;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Стать консультантом'),
|
||||
@@ -24,14 +26,18 @@ class _BecomeConsultantScreenState extends State<BecomeConsultantScreen> {
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
),
|
||||
body: const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Text(
|
||||
body: SingleChildScrollView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
padding: EdgeInsets.fromLTRB(16, 16, 16, 16 + 52 + 8 + bottomInset),
|
||||
child: const Text(
|
||||
'Чтобы стать консультантом необходимо пройти короткую регистрацию. Подготовьте свои дипломы и/или сертификаты в формате jpg, они Вам пригодятся для проверки и подтверждения. Также необходимо будет указать ссылки на Ваши соц. сети и указать тип лица (физ. лицо или юр. лицо). На данный момент доступна регистрация в качестве физ. лица.\n\nВнутри Итвио разница между обычным пользователем и консультантом заключается в дополнительных возможностях последнего создавать публичные диеты, рецепты и планы питания; участвовать в ежемесячных рейтингах.',
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
bottomSheet: _startRegistrationButton(),
|
||||
bottomSheet: Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 0, 16, 8 + bottomInset),
|
||||
child: _startRegistrationButton(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,7 +50,7 @@ class _BecomeConsultantScreenState extends State<BecomeConsultantScreen> {
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Ink(
|
||||
height: 52,
|
||||
width: MediaQuery.of(context).size.width - 32,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
color: appTheme.blueA400,
|
||||
|
||||
@@ -9,6 +9,7 @@ part 'consultation_model.g.dart';
|
||||
abstract class ConsultationModel with _$ConsultationModel {
|
||||
factory ConsultationModel({
|
||||
required int id,
|
||||
@JsonKey(name: 'chat_id') String? chatId,
|
||||
@JsonKey(name: 'last_state') required ChosenPlanStateEnum chosenPlanState,
|
||||
@JsonKey(name: 'plan_title') required String planTitle,
|
||||
@JsonKey(name: 'user_name') required String userName,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:eatvio_app/core/enums/base_state.dart';
|
||||
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
|
||||
import 'package:eatvio_app/features/consultations/presentation/cubit/consultations_state.dart';
|
||||
import 'package:eatvio_app/features/consultations/repositories/consultations_repository.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -8,7 +9,8 @@ import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||
|
||||
class ConsultationsCubit extends Cubit<ConsultationsState> {
|
||||
final ConsultationsRepository consultationsRepository;
|
||||
ConsultationsCubit({required this.consultationsRepository}) : super(ConsultationsState());
|
||||
final ChatRepository chatRepository;
|
||||
ConsultationsCubit({required this.consultationsRepository, required this.chatRepository}) : super(ConsultationsState());
|
||||
|
||||
Future<void> loadConsultations() async {
|
||||
try {
|
||||
@@ -20,4 +22,14 @@ class ConsultationsCubit extends Cubit<ConsultationsState> {
|
||||
emit(state.copyWith(state: BaseState.error));
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> getChatIdByUserSlug(String slug) async {
|
||||
try {
|
||||
return await chatRepository.getChatByUserSlug(slug);
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString(), stackTrace: st);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,17 @@ import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/core/widgets/custom_divider.dart';
|
||||
import 'package:eatvio_app/core/widgets/custom_outlined_button.dart';
|
||||
import 'package:eatvio_app/core/utils/toasts.dart';
|
||||
import 'package:eatvio_app/core/widgets/show_error_dialog.dart';
|
||||
import 'package:eatvio_app/features/consultations/models/consultation_model.dart';
|
||||
import 'package:eatvio_app/features/consultations/presentation/cubit/consultations_cubit.dart';
|
||||
import 'package:eatvio_app/features/diary/presentation/pages/diary_screen.dart';
|
||||
import 'package:eatvio_app/features/my_finance/presentation/pages/card_settings.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/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class ConsultationCardWidget extends StatefulWidget {
|
||||
@@ -34,6 +37,7 @@ class _ConsultationCardWidgetState extends State<ConsultationCardWidget> {
|
||||
final List<Color> legendColors = const [Colors.white, Color(0xFFF0C497), Color(0xFFFF9500), Color(0xFF2F80ED), Color(0xFF4CD964)];
|
||||
|
||||
late final List<(int, DiaryModel)> givenDaysWithDayNumber;
|
||||
bool _isOpeningChat = false;
|
||||
final List<List<DiaryStateEnum>> legendStates = [
|
||||
[DiaryStateEnum.init],
|
||||
[DiaryStateEnum.started, DiaryStateEnum.lastDayStarted],
|
||||
@@ -172,7 +176,7 @@ class _ConsultationCardWidgetState extends State<ConsultationCardWidget> {
|
||||
);
|
||||
return;
|
||||
}
|
||||
GoRouter.of(context).push(UserDiaryScreen.path, extra: {'diaryId': day.$2.id,});
|
||||
GoRouter.of(context).push(UserDiaryScreen.path, extra: {'diaryId': day.$2.id, 'ownerSlug': widget.consultationsModel.userSlug});
|
||||
},
|
||||
borderRadius: BorderRadius.circular(100),
|
||||
child: Ink(
|
||||
@@ -255,7 +259,7 @@ class _ConsultationCardWidgetState extends State<ConsultationCardWidget> {
|
||||
.where((e) => e.$2.state == DiaryStateEnum.finished || e.$2.state == DiaryStateEnum.lastDayFinished)
|
||||
.lastOrNull;
|
||||
if (lastActiveDay != null) {
|
||||
GoRouter.of(context).push(UserDiaryScreen.path, extra: {'diaryId': lastActiveDay.$2.id,});
|
||||
GoRouter.of(context).push(UserDiaryScreen.path, extra: {'diaryId': lastActiveDay.$2.id, 'ownerSlug': widget.consultationsModel.userSlug});
|
||||
return;
|
||||
}
|
||||
showErrorDialog(
|
||||
@@ -279,7 +283,7 @@ class _ConsultationCardWidgetState extends State<ConsultationCardWidget> {
|
||||
const SizedBox(width: 8),
|
||||
InkWell(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: () {},
|
||||
onTap: _isOpeningChat ? null : _openChat,
|
||||
child: Ink(
|
||||
height: 52,
|
||||
width: 52,
|
||||
@@ -308,6 +312,22 @@ class _ConsultationCardWidgetState extends State<ConsultationCardWidget> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openChat() async {
|
||||
if (_isOpeningChat) return;
|
||||
setState(() => _isOpeningChat = true);
|
||||
var chatId = widget.consultationsModel.chatId;
|
||||
if (chatId == null || chatId.isEmpty) {
|
||||
chatId = await context.read<ConsultationsCubit>().getChatIdByUserSlug(widget.consultationsModel.userSlug);
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() => _isOpeningChat = false);
|
||||
if (chatId == null || chatId.isEmpty) {
|
||||
showErrorToast(S.of(context).msg_something_went_wrong);
|
||||
return;
|
||||
}
|
||||
GoRouter.of(context).pushNamed('chatScreen', extra: {'uuid': chatId});
|
||||
}
|
||||
|
||||
Row _legendTitle(String text, Color color) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -20,6 +20,7 @@ import 'package:eatvio_app/features/diary/presentation/widgets/day_comment_widge
|
||||
import 'package:eatvio_app/features/diary/presentation/widgets/finish_day_button_widget.dart';
|
||||
import 'package:eatvio_app/features/diary/presentation/widgets/meal_widget.dart';
|
||||
import 'package:eatvio_app/features/diary/presentation/widgets/wakeup_and_retire_widget.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_card.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -36,18 +37,22 @@ class DiaryScreen extends StatelessWidget {
|
||||
class UserDiaryScreen extends StatelessWidget {
|
||||
static const path = '/user-diary-screen';
|
||||
static const name = 'userDiaryScreen';
|
||||
const UserDiaryScreen({super.key, required this.diaryId});
|
||||
const UserDiaryScreen({super.key, required this.diaryId, this.ownerSlug});
|
||||
final int diaryId;
|
||||
|
||||
/// Slug владельца дневника (клиента) — нужен консультанту для блока «Аналитика».
|
||||
final String? ownerSlug;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DiaryScreenContent(diaryId: diaryId);
|
||||
return DiaryScreenContent(diaryId: diaryId, ownerSlug: ownerSlug);
|
||||
}
|
||||
}
|
||||
|
||||
class DiaryScreenContent extends StatefulWidget {
|
||||
const DiaryScreenContent({super.key, this.diaryId, this.date});
|
||||
const DiaryScreenContent({super.key, this.diaryId, this.date, this.ownerSlug});
|
||||
final int? diaryId;
|
||||
final DateTime? date;
|
||||
final String? ownerSlug;
|
||||
@override
|
||||
State<DiaryScreenContent> createState() => _DiaryScreenContentState();
|
||||
}
|
||||
@@ -55,6 +60,11 @@ class DiaryScreenContent extends StatefulWidget {
|
||||
class _DiaryScreenContentState extends State<DiaryScreenContent> {
|
||||
bool _isDiaryFinishing = false;
|
||||
|
||||
/// Консультант видит аналитику клиента только при известном slug владельца.
|
||||
bool _canShowInsights(DiaryStateLoaded state) {
|
||||
return !state.isConsultantView || widget.ownerSlug != null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
@@ -110,6 +120,14 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
|
||||
const SizedBox(height: 60),
|
||||
const Loader(),
|
||||
],
|
||||
if (state is DiaryStateLoaded && _canShowInsights(state))
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: InsightsCard(
|
||||
slug: state.isConsultantView ? widget.ownerSlug : null,
|
||||
canAdd: !state.isConsultantView,
|
||||
),
|
||||
),
|
||||
if (state is DiaryStateLoaded)
|
||||
WakeupAndRetireWidget(
|
||||
isEnabled:
|
||||
|
||||
@@ -12,9 +12,19 @@ class FeedCubit extends Cubit<FeedState> {
|
||||
bool _hasMore = true;
|
||||
bool isLoading = false;
|
||||
String? _query;
|
||||
int _requestId = 0;
|
||||
|
||||
FeedCubit({required this.feedRepository}) : super(const FeedState.loading());
|
||||
|
||||
void reset() {
|
||||
_requestId++;
|
||||
_nextCursor = null;
|
||||
_hasMore = true;
|
||||
isLoading = false;
|
||||
_query = null;
|
||||
if (!isClosed) emit(const FeedState.loading());
|
||||
}
|
||||
|
||||
void updateFeedBlogPostList(FeedBlogPostModel? feedBlogPostModel, bool wasLikedByMe) {
|
||||
if (feedBlogPostModel == null || isClosed || state is! FeedStateLoaded) return;
|
||||
final List<FeedBlogPostModel> feedBlogPostList = [...(state as FeedStateLoaded).feedBlogPostList];
|
||||
@@ -39,17 +49,18 @@ class FeedCubit extends Cubit<FeedState> {
|
||||
}
|
||||
|
||||
Future<void> loadFeedBlogPostList({String? query, int? currentUserId}) async {
|
||||
final requestId = ++_requestId;
|
||||
try {
|
||||
_query = query;
|
||||
_nextCursor = null;
|
||||
_hasMore = true;
|
||||
final feed = await feedRepository.getFeed(search: query, currentUserId: currentUserId);
|
||||
if (isClosed || requestId != _requestId) return;
|
||||
_nextCursor = feed.nextCursor;
|
||||
_hasMore = _nextCursor != null;
|
||||
if (!isClosed) {
|
||||
emit(FeedState.loaded(feedBlogPostList: feed.posts, isLoading: false));
|
||||
}
|
||||
emit(FeedState.loaded(feedBlogPostList: feed.posts, isLoading: false));
|
||||
} catch (e, st) {
|
||||
if (isClosed || requestId != _requestId) return;
|
||||
await reportFrom(e, st, this);
|
||||
if (!isClosed) {
|
||||
emit(FeedState.error(errorMessage: e.toString()));
|
||||
@@ -62,14 +73,16 @@ class FeedCubit extends Cubit<FeedState> {
|
||||
return;
|
||||
}
|
||||
|
||||
final requestId = _requestId;
|
||||
isLoading = true;
|
||||
emit((state as FeedStateLoaded).copyWith(isLoading: true));
|
||||
|
||||
try {
|
||||
final feed = await feedRepository.getFeed(search: _query, cursor: _nextCursor, currentUserId: currentUserId);
|
||||
if (isClosed || requestId != _requestId) return;
|
||||
_nextCursor = feed.nextCursor;
|
||||
_hasMore = _nextCursor != null;
|
||||
if (!isClosed && state is FeedStateLoaded) {
|
||||
if (state is FeedStateLoaded) {
|
||||
emit(
|
||||
FeedState.loaded(
|
||||
feedBlogPostList: [...(state as FeedStateLoaded).feedBlogPostList, ...feed.posts],
|
||||
@@ -78,14 +91,15 @@ class FeedCubit extends Cubit<FeedState> {
|
||||
);
|
||||
}
|
||||
} catch (e, st) {
|
||||
if (isClosed || requestId != _requestId) return;
|
||||
await reportFrom(e, st, this);
|
||||
if (!isClosed && state is FeedStateLoaded) {
|
||||
if (state is FeedStateLoaded) {
|
||||
emit((state as FeedStateLoaded).copyWith(isLoading: false));
|
||||
} else if (!isClosed) {
|
||||
} else {
|
||||
emit(FeedState.error(errorMessage: e.toString()));
|
||||
}
|
||||
} finally {
|
||||
isLoading = false;
|
||||
if (requestId == _requestId) isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
222
lib/features/insights/domain/insights_text_builder.dart
Normal file
222
lib/features/insights/domain/insights_text_builder.dart
Normal file
@@ -0,0 +1,222 @@
|
||||
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
|
||||
import 'package:eatvio_app/features/insights/models/insights_period.dart';
|
||||
import 'package:eatvio_app/features/profile/models/circumference_model.dart';
|
||||
import 'package:eatvio_app/features/profile/models/weight_model.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
|
||||
/// Собирает [InsightsPayloadModel] из сырых точек мобильного API.
|
||||
///
|
||||
/// Зеркало веб-`UserInsightsService`: порог изменения [deltaEpsilon], минимум две
|
||||
/// точки для дельты, те же категории ИМТ и формат подписей.
|
||||
class InsightsTextBuilder {
|
||||
InsightsTextBuilder(this.s);
|
||||
|
||||
final S s;
|
||||
|
||||
static const double deltaEpsilon = 0.05;
|
||||
static const String _emptyHeadline = '—';
|
||||
|
||||
InsightsPayloadModel build({
|
||||
required List<WeightModel> weights,
|
||||
required List<CircumferenceModel> circumferences,
|
||||
required BodyPeriod period,
|
||||
int? heightCm,
|
||||
}) {
|
||||
final sortedWeights = [...weights]
|
||||
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
||||
final sortedCircumferences = [...circumferences]
|
||||
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
||||
|
||||
return InsightsPayloadModel(
|
||||
weight: buildWeight(sortedWeights, period),
|
||||
bmi: buildBmi(sortedWeights, period, heightCm),
|
||||
measure: buildMeasure(sortedCircumferences, period),
|
||||
);
|
||||
}
|
||||
|
||||
WeightChartModel buildWeight(List<WeightModel> weights, BodyPeriod period) {
|
||||
final values = weights.map((e) => e.weight).toList();
|
||||
final last = values.isEmpty ? null : values.last;
|
||||
final delta = values.length < 2 ? null : _round(values.last - values.first);
|
||||
|
||||
return WeightChartModel(
|
||||
labels: weights.map((e) => formatLabel(e.createdAt)).toList(),
|
||||
values: values,
|
||||
last: last,
|
||||
delta: delta,
|
||||
headline: last == null ? _emptyHeadline : last.toStringAsFixed(1),
|
||||
unit: s.insights_unit_kg,
|
||||
deltaText: _weightDeltaText(values, period),
|
||||
);
|
||||
}
|
||||
|
||||
BmiChartModel buildBmi(
|
||||
List<WeightModel> weights,
|
||||
BodyPeriod period,
|
||||
int? heightCm,
|
||||
) {
|
||||
final hasHeight = heightCm != null && heightCm > 0;
|
||||
if (!hasHeight) {
|
||||
return BmiChartModel(
|
||||
headline: _emptyHeadline,
|
||||
deltaText: s.insights_need_height,
|
||||
hint: s.insights_need_height,
|
||||
);
|
||||
}
|
||||
|
||||
final points = weights.where((e) => e.bmi != null).toList();
|
||||
final values = points.map((e) => e.bmi!).toList();
|
||||
final last = values.isEmpty ? null : values.last;
|
||||
final delta = values.length < 2 ? null : _round(values.last - values.first);
|
||||
final category = last == null ? null : bmiCategory(last);
|
||||
|
||||
return BmiChartModel(
|
||||
labels: points.map((e) => formatLabel(e.createdAt)).toList(),
|
||||
values: values,
|
||||
last: last,
|
||||
delta: delta,
|
||||
headline: last == null ? _emptyHeadline : last.toStringAsFixed(1),
|
||||
category: category,
|
||||
deltaText: _bmiDeltaText(values, period, category),
|
||||
hint: s.insights_bmi_hint(heightCm.toString()),
|
||||
);
|
||||
}
|
||||
|
||||
MeasureChartModel buildMeasure(
|
||||
List<CircumferenceModel> circumferences,
|
||||
BodyPeriod period,
|
||||
) {
|
||||
final waist = circumferences.map((e) => e.waistCircumference).toList();
|
||||
final chest = circumferences.map((e) => e.chestCircumference).toList();
|
||||
final hips = circumferences.map((e) => e.hipCircumference).toList();
|
||||
|
||||
return MeasureChartModel(
|
||||
labels: circumferences.map((e) => formatLabel(e.createdAt)).toList(),
|
||||
waist: waist,
|
||||
chest: chest,
|
||||
hips: hips,
|
||||
headline: waist.isEmpty ? _emptyHeadline : waist.last.round().toString(),
|
||||
deltaText: _measureDeltaText(
|
||||
waist: waist,
|
||||
chest: chest,
|
||||
hips: hips,
|
||||
period: period,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Категория по таблице ВОЗ из постановки #2120.
|
||||
String bmiCategory(double bmi) {
|
||||
if (bmi < 16) {
|
||||
return s.insights_bmi_severe_underweight;
|
||||
}
|
||||
if (bmi < 18.5) {
|
||||
return s.insights_bmi_underweight;
|
||||
}
|
||||
if (bmi < 25) {
|
||||
return s.insights_bmi_normal;
|
||||
}
|
||||
if (bmi < 30) {
|
||||
return s.insights_bmi_overweight;
|
||||
}
|
||||
if (bmi < 35) {
|
||||
return s.insights_bmi_obesity_1;
|
||||
}
|
||||
if (bmi < 40) {
|
||||
return s.insights_bmi_obesity_2;
|
||||
}
|
||||
return s.insights_bmi_obesity_3;
|
||||
}
|
||||
|
||||
String periodLabel(BodyPeriod period) {
|
||||
switch (period) {
|
||||
case BodyPeriod.month:
|
||||
return s.insights_period_label_1;
|
||||
case BodyPeriod.threeMonths:
|
||||
return s.insights_period_label_3;
|
||||
case BodyPeriod.sixMonths:
|
||||
return s.insights_period_label_6;
|
||||
case BodyPeriod.year:
|
||||
return s.insights_period_label_12;
|
||||
case BodyPeriod.all:
|
||||
return s.insights_period_label_all;
|
||||
}
|
||||
}
|
||||
|
||||
String formatLabel(DateTime date) {
|
||||
final local = date.toLocal();
|
||||
final day = local.day.toString().padLeft(2, '0');
|
||||
final month = local.month.toString().padLeft(2, '0');
|
||||
return '$day.$month';
|
||||
}
|
||||
|
||||
/// Одна цифра после запятой без хвостового нуля: `3`, `3.5`.
|
||||
String formatNumber(double value) {
|
||||
final text = value.toStringAsFixed(1);
|
||||
return text.endsWith('.0') ? text.substring(0, text.length - 2) : text;
|
||||
}
|
||||
|
||||
String _weightDeltaText(List<double> values, BodyPeriod period) {
|
||||
if (values.length < 2) {
|
||||
return s.insights_not_enough;
|
||||
}
|
||||
final diff = values.last - values.first;
|
||||
final periodText = periodLabel(period);
|
||||
if (diff.abs() < deltaEpsilon) {
|
||||
return s.insights_weight_same(periodText);
|
||||
}
|
||||
final value = formatNumber(diff.abs());
|
||||
return diff < 0
|
||||
? s.insights_weight_lost(value, periodText)
|
||||
: s.insights_weight_gained(value, periodText);
|
||||
}
|
||||
|
||||
String _bmiDeltaText(
|
||||
List<double> values,
|
||||
BodyPeriod period,
|
||||
String? category,
|
||||
) {
|
||||
if (values.length < 2 || category == null) {
|
||||
return s.insights_not_enough;
|
||||
}
|
||||
final diff = values.last - values.first;
|
||||
final periodText = periodLabel(period);
|
||||
if (diff.abs() < deltaEpsilon) {
|
||||
return s.insights_bmi_same(periodText, category);
|
||||
}
|
||||
final value = formatNumber(diff.abs());
|
||||
return diff < 0
|
||||
? s.insights_bmi_lower(value, periodText, category)
|
||||
: s.insights_bmi_higher(value, periodText, category);
|
||||
}
|
||||
|
||||
String _measureDeltaText({
|
||||
required List<double> waist,
|
||||
required List<double> chest,
|
||||
required List<double> hips,
|
||||
required BodyPeriod period,
|
||||
}) {
|
||||
if (waist.length < 2) {
|
||||
return s.insights_not_enough;
|
||||
}
|
||||
final parts = [
|
||||
_measurePart(s.insights_series_waist, waist),
|
||||
_measurePart(s.insights_series_chest, chest),
|
||||
_measurePart(s.insights_series_hips, hips),
|
||||
];
|
||||
return s.insights_measures_summary(parts.join(', '), periodLabel(period));
|
||||
}
|
||||
|
||||
String _measurePart(String name, List<double> values) {
|
||||
final diff = values.last - values.first;
|
||||
if (diff.abs() < deltaEpsilon) {
|
||||
return s.insights_measure_same(name);
|
||||
}
|
||||
final value = formatNumber(diff.abs());
|
||||
return diff < 0
|
||||
? s.insights_measure_less(name, value)
|
||||
: s.insights_measure_more(name, value);
|
||||
}
|
||||
|
||||
double _round(double value) => (value * 10).round() / 10;
|
||||
}
|
||||
6
lib/features/insights/insights_feature_flags.dart
Normal file
6
lib/features/insights/insights_feature_flags.dart
Normal file
@@ -0,0 +1,6 @@
|
||||
/// Флаги поэтапного включения аналитики.
|
||||
abstract final class InsightsFeatureFlags {
|
||||
/// Вкладки «КБЖУ по дням» и «В течение дня» требуют бэкенд-эндпоинта
|
||||
/// `GET /api/profile/insights` и включаются во второй итерации.
|
||||
static const bool kbjuEnabled = false;
|
||||
}
|
||||
132
lib/features/insights/models/insights_payload_model.dart
Normal file
132
lib/features/insights/models/insights_payload_model.dart
Normal file
@@ -0,0 +1,132 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'insights_payload_model.freezed.dart';
|
||||
part 'insights_payload_model.g.dart';
|
||||
|
||||
/// Структура повторяет веб-`UserInsightsService::chartPayload()`, чтобы во второй
|
||||
/// итерации заменить только источник данных.
|
||||
@freezed
|
||||
abstract class InsightsPayloadModel with _$InsightsPayloadModel {
|
||||
const factory InsightsPayloadModel({
|
||||
required WeightChartModel weight,
|
||||
required BmiChartModel bmi,
|
||||
required MeasureChartModel measure,
|
||||
DailyChartModel? daily,
|
||||
IntraChartModel? intra,
|
||||
}) = _InsightsPayloadModel;
|
||||
|
||||
factory InsightsPayloadModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$InsightsPayloadModelFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class WeightChartModel with _$WeightChartModel {
|
||||
const factory WeightChartModel({
|
||||
@Default([]) List<String> labels,
|
||||
@Default([]) List<double> values,
|
||||
double? last,
|
||||
double? delta,
|
||||
@Default('') String headline,
|
||||
@Default('') String unit,
|
||||
@Default('') String deltaText,
|
||||
}) = _WeightChartModel;
|
||||
|
||||
factory WeightChartModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$WeightChartModelFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class BmiChartModel with _$BmiChartModel {
|
||||
const factory BmiChartModel({
|
||||
@Default([]) List<String> labels,
|
||||
@Default([]) List<double> values,
|
||||
double? last,
|
||||
double? delta,
|
||||
@Default('') String headline,
|
||||
String? category,
|
||||
@Default('') String deltaText,
|
||||
@Default('') String hint,
|
||||
}) = _BmiChartModel;
|
||||
|
||||
factory BmiChartModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$BmiChartModelFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class MeasureChartModel with _$MeasureChartModel {
|
||||
const factory MeasureChartModel({
|
||||
@Default([]) List<String> labels,
|
||||
@Default([]) List<double> waist,
|
||||
@Default([]) List<double> chest,
|
||||
@Default([]) List<double> hips,
|
||||
@Default('') String headline,
|
||||
@Default('') String deltaText,
|
||||
}) = _MeasureChartModel;
|
||||
|
||||
factory MeasureChartModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$MeasureChartModelFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class DailyChartModel with _$DailyChartModel {
|
||||
const factory DailyChartModel({
|
||||
@Default([]) List<String> labels,
|
||||
@Default([]) List<double> kcal,
|
||||
@Default([]) List<double> proteins,
|
||||
@Default([]) List<double> fats,
|
||||
@Default([]) List<double> carbs,
|
||||
double? average,
|
||||
double? percent,
|
||||
@Default('') String headline,
|
||||
@Default('') String deltaText,
|
||||
}) = _DailyChartModel;
|
||||
|
||||
factory DailyChartModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$DailyChartModelFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class IntraChartModel with _$IntraChartModel {
|
||||
const factory IntraChartModel({
|
||||
@Default([]) List<IntraBarModel> bars,
|
||||
@Default([]) List<ScalePointModel> satiety,
|
||||
@Default([]) List<ScalePointModel> mood,
|
||||
double? wake,
|
||||
double? sleep,
|
||||
String? wakeLabel,
|
||||
String? sleepLabel,
|
||||
String? wakeText,
|
||||
String? sleepText,
|
||||
@Default('') String date,
|
||||
@Default('') String maxDate,
|
||||
@Default('') String dateLabel,
|
||||
@Default(false) bool isToday,
|
||||
}) = _IntraChartModel;
|
||||
|
||||
factory IntraChartModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$IntraChartModelFromJson(json);
|
||||
}
|
||||
|
||||
/// Приём пищи: время `x` (часы 0–24) и калории из белков/жиров/углеводов.
|
||||
@freezed
|
||||
abstract class IntraBarModel with _$IntraBarModel {
|
||||
const factory IntraBarModel({
|
||||
required double x,
|
||||
@Default(0) double p,
|
||||
@Default(0) double f,
|
||||
@Default(0) double c,
|
||||
}) = _IntraBarModel;
|
||||
|
||||
factory IntraBarModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$IntraBarModelFromJson(json);
|
||||
}
|
||||
|
||||
/// Точка шкалы 0–5 (сытость/настроение) в момент времени `x`.
|
||||
@freezed
|
||||
abstract class ScalePointModel with _$ScalePointModel {
|
||||
const factory ScalePointModel({required double x, required double y}) =
|
||||
_ScalePointModel;
|
||||
|
||||
factory ScalePointModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$ScalePointModelFromJson(json);
|
||||
}
|
||||
26
lib/features/insights/models/insights_period.dart
Normal file
26
lib/features/insights/models/insights_period.dart
Normal file
@@ -0,0 +1,26 @@
|
||||
/// Период для вкладок «Вес», «ИМТ» и «Замеры». Значение совпадает с `{period}` в
|
||||
/// `/api/profile/measure/weight/{period}`.
|
||||
enum BodyPeriod {
|
||||
month('1'),
|
||||
threeMonths('3'),
|
||||
sixMonths('6'),
|
||||
year('12'),
|
||||
all('all');
|
||||
|
||||
const BodyPeriod(this.apiValue);
|
||||
|
||||
final String apiValue;
|
||||
}
|
||||
|
||||
/// Период для вкладки «КБЖУ по дням» (используется со второй итерации).
|
||||
enum DailyPeriod {
|
||||
week('7'),
|
||||
twoWeeks('14'),
|
||||
month('30'),
|
||||
threeMonths('90'),
|
||||
all('all');
|
||||
|
||||
const DailyPeriod(this.apiValue);
|
||||
|
||||
final String apiValue;
|
||||
}
|
||||
84
lib/features/insights/presentation/charts/bmi_chart.dart
Normal file
84
lib/features/insights/presentation/charts/bmi_chart.dart
Normal file
@@ -0,0 +1,84 @@
|
||||
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/charts/insights_chart_common.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||
|
||||
/// График ИМТ с цветными зонами: норма 18.5–25, избыточная масса 25–30, ожирение 30–40.
|
||||
class BmiChart extends StatelessWidget {
|
||||
const BmiChart({super.key, required this.model});
|
||||
|
||||
final BmiChartModel model;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final points = buildPoints(model.labels, model.values);
|
||||
if (points.isEmpty) {
|
||||
return InsightsChartPlaceholder(text: model.deltaText);
|
||||
}
|
||||
final s = S.of(context);
|
||||
final range = paddedRange(points.map((e) => e.value), minPadding: 2);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: insightsChartHeight,
|
||||
child: SfCartesianChart(
|
||||
key: const Key('insights_bmi_chart'),
|
||||
margin: EdgeInsets.zero,
|
||||
plotAreaBorderWidth: 0,
|
||||
primaryXAxis: buildInsightsCategoryAxis(),
|
||||
primaryYAxis: buildInsightsValueAxis(
|
||||
unit: s.insights_unit_bmi,
|
||||
min: range.min,
|
||||
max: range.max,
|
||||
plotBands: const [
|
||||
PlotBand(
|
||||
start: 18.5,
|
||||
end: 25,
|
||||
color: InsightsColors.bmiNormalZone,
|
||||
),
|
||||
PlotBand(
|
||||
start: 25,
|
||||
end: 30,
|
||||
color: InsightsColors.bmiOverweightZone,
|
||||
),
|
||||
PlotBand(
|
||||
start: 30,
|
||||
end: 40,
|
||||
color: InsightsColors.bmiObesityZone,
|
||||
),
|
||||
],
|
||||
),
|
||||
trackballBehavior: buildInsightsTrackball(),
|
||||
series: <CartesianSeries<InsightsPoint, String>>[
|
||||
SplineAreaSeries<InsightsPoint, String>(
|
||||
name: s.insights_tab_bmi,
|
||||
dataSource: points,
|
||||
xValueMapper: (point, _) => point.label,
|
||||
yValueMapper: (point, _) => point.value,
|
||||
borderColor: InsightsColors.blue,
|
||||
borderWidth: 3,
|
||||
gradient: buildInsightsAreaGradient(InsightsColors.blue),
|
||||
markerSettings: buildInsightsMarker(InsightsColors.blue),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (model.hint.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
model.hint,
|
||||
key: const Key('insights_bmi_hint'),
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: InsightsColors.subtitle,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
113
lib/features/insights/presentation/charts/daily_kbju_chart.dart
Normal file
113
lib/features/insights/presentation/charts/daily_kbju_chart.dart
Normal file
@@ -0,0 +1,113 @@
|
||||
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/charts/insights_chart_common.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||
|
||||
/// КБЖУ по дням: калории — площадь на левой оси («ккал»), Б/Ж/У — линии на
|
||||
/// правой оси («граммы»). [visibility] — Калории, Белки, Жиры, Углеводы.
|
||||
class DailyKbjuChart extends StatelessWidget {
|
||||
const DailyKbjuChart({
|
||||
super.key,
|
||||
required this.model,
|
||||
required this.visibility,
|
||||
});
|
||||
|
||||
final DailyChartModel model;
|
||||
final List<bool> visibility;
|
||||
|
||||
static const String _gramsAxisName = 'grams';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
final calories = buildPoints(model.labels, model.kcal);
|
||||
if (calories.isEmpty) {
|
||||
return InsightsChartPlaceholder(text: s.insights_not_enough);
|
||||
}
|
||||
final proteins = buildPoints(model.labels, model.proteins);
|
||||
final fats = buildPoints(model.labels, model.fats);
|
||||
final carbs = buildPoints(model.labels, model.carbs);
|
||||
|
||||
final grams = [
|
||||
(
|
||||
name: s.insights_series_proteins,
|
||||
color: InsightsColors.proteins,
|
||||
points: proteins,
|
||||
),
|
||||
(name: s.insights_series_fats, color: InsightsColors.fats, points: fats),
|
||||
(
|
||||
name: s.insights_series_carbs,
|
||||
color: InsightsColors.carbs,
|
||||
points: carbs,
|
||||
),
|
||||
];
|
||||
|
||||
return SizedBox(
|
||||
height: insightsChartHeight,
|
||||
child: SfCartesianChart(
|
||||
key: const Key('insights_daily_chart'),
|
||||
margin: EdgeInsets.zero,
|
||||
plotAreaBorderWidth: 0,
|
||||
primaryXAxis: buildInsightsCategoryAxis(),
|
||||
primaryYAxis: NumericAxis(
|
||||
title: AxisTitle(
|
||||
text: s.insights_unit_kcal,
|
||||
textStyle: insightsAxisLabelStyle,
|
||||
),
|
||||
labelStyle: insightsAxisLabelStyle,
|
||||
minimum: 0,
|
||||
majorTickLines: const MajorTickLines(size: 0),
|
||||
axisLine: const AxisLine(width: 0),
|
||||
majorGridLines: MajorGridLines(
|
||||
color: InsightsColors.border.withValues(alpha: 0.8),
|
||||
dashArray: const [4, 4],
|
||||
),
|
||||
),
|
||||
axes: <ChartAxis>[
|
||||
NumericAxis(
|
||||
name: _gramsAxisName,
|
||||
opposedPosition: true,
|
||||
minimum: 0,
|
||||
title: AxisTitle(
|
||||
text: s.insights_unit_grams,
|
||||
textStyle: insightsAxisLabelStyle,
|
||||
),
|
||||
labelStyle: insightsAxisLabelStyle,
|
||||
majorTickLines: const MajorTickLines(size: 0),
|
||||
axisLine: const AxisLine(width: 0),
|
||||
majorGridLines: const MajorGridLines(width: 0),
|
||||
),
|
||||
],
|
||||
trackballBehavior: buildInsightsTrackball(),
|
||||
series: <CartesianSeries<InsightsPoint, String>>[
|
||||
if (_isVisible(0))
|
||||
SplineAreaSeries<InsightsPoint, String>(
|
||||
name: s.insights_series_calories,
|
||||
dataSource: calories,
|
||||
xValueMapper: (point, _) => point.label,
|
||||
yValueMapper: (point, _) => point.value,
|
||||
borderColor: InsightsColors.calories,
|
||||
borderWidth: 3,
|
||||
gradient: buildInsightsAreaGradient(InsightsColors.calories),
|
||||
),
|
||||
for (var i = 0; i < grams.length; i++)
|
||||
if (_isVisible(i + 1))
|
||||
SplineSeries<InsightsPoint, String>(
|
||||
name: grams[i].name,
|
||||
yAxisName: _gramsAxisName,
|
||||
dataSource: grams[i].points,
|
||||
xValueMapper: (point, _) => point.label,
|
||||
yValueMapper: (point, _) => point.value,
|
||||
color: grams[i].color,
|
||||
width: 2.5,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool _isVisible(int index) =>
|
||||
index < visibility.length ? visibility[index] : true;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||
|
||||
/// Точка графика: подпись оси X (`dd.MM`) и значение.
|
||||
class InsightsPoint {
|
||||
const InsightsPoint(this.label, this.value);
|
||||
|
||||
final String label;
|
||||
final double value;
|
||||
}
|
||||
|
||||
/// Склеивает `labels[]` и `values[]` (по минимальной длине).
|
||||
List<InsightsPoint> buildPoints(List<String> labels, List<double> values) {
|
||||
final count = math.min(labels.length, values.length);
|
||||
return [for (var i = 0; i < count; i++) InsightsPoint(labels[i], values[i])];
|
||||
}
|
||||
|
||||
/// Границы оси Y с отступом, чтобы линия не прижималась к краям (и ось не начиналась с нуля).
|
||||
({double min, double max}) paddedRange(
|
||||
Iterable<double> values, {
|
||||
double minPadding = 1,
|
||||
}) {
|
||||
if (values.isEmpty) {
|
||||
return (min: 0, max: 1);
|
||||
}
|
||||
final lowest = values.reduce(math.min);
|
||||
final highest = values.reduce(math.max);
|
||||
final padding = math.max((highest - lowest) * 0.2, minPadding);
|
||||
return (
|
||||
min: (lowest - padding).floorToDouble(),
|
||||
max: (highest + padding).ceilToDouble(),
|
||||
);
|
||||
}
|
||||
|
||||
const TextStyle insightsAxisLabelStyle = TextStyle(
|
||||
fontSize: 11,
|
||||
color: InsightsColors.subtitle,
|
||||
);
|
||||
|
||||
CategoryAxis buildInsightsCategoryAxis() {
|
||||
return const CategoryAxis(
|
||||
arrangeByIndex: true,
|
||||
labelStyle: insightsAxisLabelStyle,
|
||||
majorGridLines: MajorGridLines(width: 0),
|
||||
majorTickLines: MajorTickLines(size: 0),
|
||||
axisLine: AxisLine(color: InsightsColors.border, width: 1),
|
||||
edgeLabelPlacement: EdgeLabelPlacement.shift,
|
||||
labelIntersectAction: AxisLabelIntersectAction.hide,
|
||||
);
|
||||
}
|
||||
|
||||
NumericAxis buildInsightsValueAxis({
|
||||
required String unit,
|
||||
required double min,
|
||||
required double max,
|
||||
List<PlotBand> plotBands = const [],
|
||||
}) {
|
||||
return NumericAxis(
|
||||
minimum: min,
|
||||
maximum: max,
|
||||
labelFormat: '{value} $unit',
|
||||
labelStyle: insightsAxisLabelStyle,
|
||||
majorTickLines: const MajorTickLines(size: 0),
|
||||
axisLine: const AxisLine(width: 0),
|
||||
majorGridLines: MajorGridLines(
|
||||
color: InsightsColors.border.withValues(alpha: 0.8),
|
||||
dashArray: const [4, 4],
|
||||
),
|
||||
plotBands: plotBands,
|
||||
);
|
||||
}
|
||||
|
||||
TrackballBehavior buildInsightsTrackball() {
|
||||
return TrackballBehavior(
|
||||
enable: true,
|
||||
activationMode: ActivationMode.singleTap,
|
||||
tooltipDisplayMode: TrackballDisplayMode.groupAllPoints,
|
||||
lineType: TrackballLineType.vertical,
|
||||
tooltipSettings: const InteractiveTooltip(),
|
||||
);
|
||||
}
|
||||
|
||||
MarkerSettings buildInsightsMarker(Color color) {
|
||||
return MarkerSettings(
|
||||
isVisible: true,
|
||||
width: 4,
|
||||
height: 4,
|
||||
color: Colors.white,
|
||||
borderColor: color,
|
||||
borderWidth: 2,
|
||||
);
|
||||
}
|
||||
|
||||
LinearGradient buildInsightsAreaGradient(Color color) {
|
||||
return LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [color.withValues(alpha: 0.18), color.withValues(alpha: 0)],
|
||||
);
|
||||
}
|
||||
|
||||
/// Пустое состояние графика (нет данных).
|
||||
class InsightsChartPlaceholder extends StatelessWidget {
|
||||
const InsightsChartPlaceholder({super.key, required this.text});
|
||||
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: insightsChartHeight,
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Text(
|
||||
text,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: InsightsColors.subtitle,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
210
lib/features/insights/presentation/charts/intra_day_chart.dart
Normal file
210
lib/features/insights/presentation/charts/intra_day_chart.dart
Normal file
@@ -0,0 +1,210 @@
|
||||
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/charts/insights_chart_common.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||
|
||||
/// График «В течение дня»: столбики Б/Ж/У (ккал) по приёмам пищи на оси 0–24 ч,
|
||||
/// линии сытости и настроения (шкала 0–5) и затемнение времени сна.
|
||||
///
|
||||
/// [visibility] — Белки, Жиры, Углеводы, Сытость, Настроение.
|
||||
class IntraDayChart extends StatelessWidget {
|
||||
const IntraDayChart({
|
||||
super.key,
|
||||
required this.model,
|
||||
required this.visibility,
|
||||
required this.showSleep,
|
||||
});
|
||||
|
||||
final IntraChartModel model;
|
||||
final List<bool> visibility;
|
||||
final bool showSleep;
|
||||
|
||||
static const String _scaleAxisName = 'scale';
|
||||
static const double _barWidthPx = 26;
|
||||
static const double _axesReservedWidthPx = 80;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
if (model.bars.isEmpty && model.satiety.isEmpty && model.mood.isEmpty) {
|
||||
return InsightsChartPlaceholder(text: s.insights_not_enough);
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
height: insightsChartHeight,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final plotWidth = (constraints.maxWidth - _axesReservedWidthPx).clamp(
|
||||
120.0,
|
||||
double.infinity,
|
||||
);
|
||||
final barFraction = (_barWidthPx / (plotWidth / 24)).clamp(0.1, 1.0);
|
||||
return SfCartesianChart(
|
||||
key: const Key('insights_intra_chart'),
|
||||
margin: EdgeInsets.zero,
|
||||
plotAreaBorderWidth: 0,
|
||||
primaryXAxis: NumericAxis(
|
||||
minimum: 0,
|
||||
maximum: 24,
|
||||
interval: 2,
|
||||
labelStyle: insightsAxisLabelStyle,
|
||||
majorGridLines: const MajorGridLines(width: 0),
|
||||
majorTickLines: const MajorTickLines(size: 0),
|
||||
axisLine: const AxisLine(color: InsightsColors.border, width: 1),
|
||||
axisLabelFormatter: (details) => ChartAxisLabel(
|
||||
'${details.value.toInt().toString().padLeft(2, '0')}:00',
|
||||
details.textStyle,
|
||||
),
|
||||
plotBands: _sleepBands(),
|
||||
),
|
||||
primaryYAxis: NumericAxis(
|
||||
minimum: 0,
|
||||
title: AxisTitle(
|
||||
text: s.insights_unit_kcal_per_meal,
|
||||
textStyle: insightsAxisLabelStyle,
|
||||
),
|
||||
labelStyle: insightsAxisLabelStyle,
|
||||
majorTickLines: const MajorTickLines(size: 0),
|
||||
axisLine: const AxisLine(width: 0),
|
||||
majorGridLines: MajorGridLines(
|
||||
color: InsightsColors.border.withValues(alpha: 0.8),
|
||||
dashArray: const [4, 4],
|
||||
),
|
||||
),
|
||||
axes: const <ChartAxis>[
|
||||
NumericAxis(
|
||||
name: _scaleAxisName,
|
||||
opposedPosition: true,
|
||||
minimum: 0,
|
||||
maximum: 5,
|
||||
interval: 1,
|
||||
labelStyle: insightsAxisLabelStyle,
|
||||
majorTickLines: MajorTickLines(size: 0),
|
||||
axisLine: AxisLine(width: 0),
|
||||
majorGridLines: MajorGridLines(width: 0),
|
||||
),
|
||||
],
|
||||
trackballBehavior: buildInsightsTrackball(),
|
||||
series: <CartesianSeries<dynamic, double>>[
|
||||
if (_isVisible(0))
|
||||
_barSeries(
|
||||
s.insights_series_proteins,
|
||||
InsightsColors.proteins,
|
||||
(bar) => bar.p,
|
||||
barFraction,
|
||||
),
|
||||
if (_isVisible(1))
|
||||
_barSeries(
|
||||
s.insights_series_fats,
|
||||
InsightsColors.fats,
|
||||
(bar) => bar.f,
|
||||
barFraction,
|
||||
),
|
||||
if (_isVisible(2))
|
||||
_barSeries(
|
||||
s.insights_series_carbs,
|
||||
InsightsColors.carbs,
|
||||
(bar) => bar.c,
|
||||
barFraction,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(6),
|
||||
),
|
||||
),
|
||||
if (_isVisible(3) && model.satiety.isNotEmpty)
|
||||
_scaleSeries(
|
||||
s.insights_series_satiety,
|
||||
InsightsColors.satiety,
|
||||
model.satiety,
|
||||
),
|
||||
if (_isVisible(4) && model.mood.isNotEmpty)
|
||||
_scaleSeries(
|
||||
s.insights_series_mood,
|
||||
InsightsColors.mood,
|
||||
model.mood,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool _isVisible(int index) =>
|
||||
index < visibility.length ? visibility[index] : true;
|
||||
|
||||
CartesianSeries<IntraBarModel, double> _barSeries(
|
||||
String name,
|
||||
Color color,
|
||||
double Function(IntraBarModel bar) valueOf,
|
||||
double widthFraction, {
|
||||
BorderRadius borderRadius = BorderRadius.zero,
|
||||
}) {
|
||||
return StackedColumnSeries<IntraBarModel, double>(
|
||||
name: name,
|
||||
dataSource: model.bars,
|
||||
xValueMapper: (bar, _) => bar.x,
|
||||
yValueMapper: (bar, _) => valueOf(bar),
|
||||
color: color,
|
||||
width: widthFraction,
|
||||
spacing: 0,
|
||||
borderRadius: borderRadius,
|
||||
);
|
||||
}
|
||||
|
||||
CartesianSeries<ScalePointModel, double> _scaleSeries(
|
||||
String name,
|
||||
Color color,
|
||||
List<ScalePointModel> points,
|
||||
) {
|
||||
return SplineSeries<ScalePointModel, double>(
|
||||
name: name,
|
||||
yAxisName: _scaleAxisName,
|
||||
dataSource: points,
|
||||
xValueMapper: (point, _) => point.x,
|
||||
yValueMapper: (point, _) => point.y,
|
||||
splineType: SplineType.monotonic,
|
||||
color: color,
|
||||
width: 2.5,
|
||||
markerSettings: MarkerSettings(
|
||||
isVisible: true,
|
||||
width: 3,
|
||||
height: 3,
|
||||
color: color,
|
||||
borderColor: color,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Затемнение `[0, wake]` и `[sleep, 24]` плюс пунктиры подъёма и отхода ко сну.
|
||||
List<PlotBand> _sleepBands() {
|
||||
if (!showSleep) {
|
||||
return const [];
|
||||
}
|
||||
final wake = model.wake;
|
||||
final sleep = model.sleep;
|
||||
return [
|
||||
if (wake != null && wake > 0)
|
||||
PlotBand(start: 0, end: wake, color: InsightsColors.sleepShade),
|
||||
if (sleep != null && sleep < 24 && (wake == null || sleep > wake))
|
||||
PlotBand(start: sleep, end: 24, color: InsightsColors.sleepShade),
|
||||
if (wake != null)
|
||||
PlotBand(
|
||||
start: wake,
|
||||
end: wake,
|
||||
borderColor: InsightsColors.wake,
|
||||
borderWidth: 1.5,
|
||||
dashArray: const [5, 4],
|
||||
),
|
||||
if (sleep != null)
|
||||
PlotBand(
|
||||
start: sleep,
|
||||
end: sleep,
|
||||
borderColor: InsightsColors.sleepLine,
|
||||
borderWidth: 1.5,
|
||||
dashArray: const [5, 4],
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
86
lib/features/insights/presentation/charts/measure_chart.dart
Normal file
86
lib/features/insights/presentation/charts/measure_chart.dart
Normal file
@@ -0,0 +1,86 @@
|
||||
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/charts/insights_chart_common.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||
|
||||
/// Три линии: талия, грудь, бёдра. [visibility] — видимость серий в этом порядке.
|
||||
class MeasureChart extends StatelessWidget {
|
||||
const MeasureChart({
|
||||
super.key,
|
||||
required this.model,
|
||||
required this.visibility,
|
||||
required this.emptyText,
|
||||
});
|
||||
|
||||
final MeasureChartModel model;
|
||||
final List<bool> visibility;
|
||||
final String emptyText;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final waist = buildPoints(model.labels, model.waist);
|
||||
final chest = buildPoints(model.labels, model.chest);
|
||||
final hips = buildPoints(model.labels, model.hips);
|
||||
if (waist.isEmpty && chest.isEmpty && hips.isEmpty) {
|
||||
return InsightsChartPlaceholder(text: emptyText);
|
||||
}
|
||||
final s = S.of(context);
|
||||
final series = [
|
||||
(
|
||||
name: s.insights_series_waist,
|
||||
color: InsightsColors.waist,
|
||||
points: waist,
|
||||
),
|
||||
(
|
||||
name: s.insights_series_chest,
|
||||
color: InsightsColors.chest,
|
||||
points: chest,
|
||||
),
|
||||
(name: s.insights_series_hips, color: InsightsColors.hips, points: hips),
|
||||
];
|
||||
final visibleValues = <double>[
|
||||
for (var i = 0; i < series.length; i++)
|
||||
if (_isVisible(i)) ...series[i].points.map((e) => e.value),
|
||||
];
|
||||
final range = paddedRange(
|
||||
visibleValues.isEmpty
|
||||
? series.expand((e) => e.points).map((e) => e.value)
|
||||
: visibleValues,
|
||||
minPadding: 2,
|
||||
);
|
||||
|
||||
return SizedBox(
|
||||
height: insightsChartHeight,
|
||||
child: SfCartesianChart(
|
||||
key: const Key('insights_measure_chart'),
|
||||
margin: EdgeInsets.zero,
|
||||
plotAreaBorderWidth: 0,
|
||||
primaryXAxis: buildInsightsCategoryAxis(),
|
||||
primaryYAxis: buildInsightsValueAxis(
|
||||
unit: s.insights_unit_cm,
|
||||
min: range.min,
|
||||
max: range.max,
|
||||
),
|
||||
trackballBehavior: buildInsightsTrackball(),
|
||||
series: <CartesianSeries<InsightsPoint, String>>[
|
||||
for (var i = 0; i < series.length; i++)
|
||||
if (_isVisible(i))
|
||||
SplineSeries<InsightsPoint, String>(
|
||||
name: series[i].name,
|
||||
dataSource: series[i].points,
|
||||
xValueMapper: (point, _) => point.label,
|
||||
yValueMapper: (point, _) => point.value,
|
||||
color: series[i].color,
|
||||
width: 3,
|
||||
markerSettings: buildInsightsMarker(series[i].color),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool _isVisible(int index) =>
|
||||
index < visibility.length ? visibility[index] : true;
|
||||
}
|
||||
53
lib/features/insights/presentation/charts/weight_chart.dart
Normal file
53
lib/features/insights/presentation/charts/weight_chart.dart
Normal file
@@ -0,0 +1,53 @@
|
||||
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/charts/insights_chart_common.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||
|
||||
class WeightChart extends StatelessWidget {
|
||||
const WeightChart({super.key, required this.model, required this.emptyText});
|
||||
|
||||
final WeightChartModel model;
|
||||
|
||||
/// Текст при отсутствии точек («Добавьте Ваш вес»).
|
||||
final String emptyText;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final points = buildPoints(model.labels, model.values);
|
||||
if (points.isEmpty) {
|
||||
return InsightsChartPlaceholder(text: emptyText);
|
||||
}
|
||||
final s = S.of(context);
|
||||
final range = paddedRange(points.map((e) => e.value));
|
||||
|
||||
return SizedBox(
|
||||
height: insightsChartHeight,
|
||||
child: SfCartesianChart(
|
||||
key: const Key('insights_weight_chart'),
|
||||
margin: EdgeInsets.zero,
|
||||
plotAreaBorderWidth: 0,
|
||||
primaryXAxis: buildInsightsCategoryAxis(),
|
||||
primaryYAxis: buildInsightsValueAxis(
|
||||
unit: s.insights_unit_kg,
|
||||
min: range.min,
|
||||
max: range.max,
|
||||
),
|
||||
trackballBehavior: buildInsightsTrackball(),
|
||||
series: <CartesianSeries<InsightsPoint, String>>[
|
||||
SplineAreaSeries<InsightsPoint, String>(
|
||||
name: s.insights_tab_weight,
|
||||
dataSource: points,
|
||||
xValueMapper: (point, _) => point.label,
|
||||
yValueMapper: (point, _) => point.value,
|
||||
borderColor: InsightsColors.blue,
|
||||
borderWidth: 3,
|
||||
gradient: buildInsightsAreaGradient(InsightsColors.blue),
|
||||
markerSettings: buildInsightsMarker(InsightsColors.blue),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
184
lib/features/insights/presentation/cubit/insights_cubit.dart
Normal file
184
lib/features/insights/presentation/cubit/insights_cubit.dart
Normal file
@@ -0,0 +1,184 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||
import 'package:eatvio_app/features/insights/insights_feature_flags.dart';
|
||||
import 'package:eatvio_app/features/insights/models/insights_period.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/cubit/insights_panel.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/cubit/insights_state.dart';
|
||||
import 'package:eatvio_app/features/insights/repositories/insights_repository.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class InsightsCubit extends Cubit<InsightsState> {
|
||||
InsightsCubit({
|
||||
required this.repository,
|
||||
this.slug,
|
||||
this.canAdd = false,
|
||||
this.kbjuEnabled = InsightsFeatureFlags.kbjuEnabled,
|
||||
}) : super(const InsightsState.initial());
|
||||
|
||||
final InsightsRepository repository;
|
||||
|
||||
/// `null` — собственные данные, иначе slug владельца (консультант → клиент).
|
||||
final String? slug;
|
||||
|
||||
/// Показывать ли кнопку «+» (только владелец).
|
||||
final bool canAdd;
|
||||
|
||||
final bool kbjuEnabled;
|
||||
|
||||
bool _isHeightLoaded = false;
|
||||
int? _heightCm;
|
||||
int _requestId = 0;
|
||||
|
||||
List<InsightsPanel> get panels =>
|
||||
InsightsPanel.available(kbjuEnabled: kbjuEnabled);
|
||||
|
||||
Future<void> load() async {
|
||||
emit(const InsightsState.loading());
|
||||
await _fetch(bodyPeriod: BodyPeriod.threeMonths, previous: null);
|
||||
}
|
||||
|
||||
/// Перезапрашивает данные, сохраняя вкладку, период, серии и раскрытие.
|
||||
Future<void> refresh() async {
|
||||
final current = state;
|
||||
if (current is! InsightsStateLoaded) {
|
||||
return load();
|
||||
}
|
||||
emit(current.copyWith(isRefreshing: true));
|
||||
await _fetch(bodyPeriod: current.bodyPeriod, previous: current);
|
||||
}
|
||||
|
||||
Future<void> setBodyPeriod(BodyPeriod period) async {
|
||||
final current = state;
|
||||
if (current is! InsightsStateLoaded || current.bodyPeriod == period) {
|
||||
return;
|
||||
}
|
||||
emit(current.copyWith(bodyPeriod: period, isRefreshing: true));
|
||||
await _fetch(bodyPeriod: period, previous: current);
|
||||
}
|
||||
|
||||
void toggleExpanded() {
|
||||
final current = state;
|
||||
if (current is InsightsStateLoaded) {
|
||||
emit(current.copyWith(isExpanded: !current.isExpanded));
|
||||
}
|
||||
}
|
||||
|
||||
void setPanel(InsightsPanel panel) {
|
||||
final current = state;
|
||||
if (current is! InsightsStateLoaded || !panels.contains(panel)) {
|
||||
return;
|
||||
}
|
||||
emit(current.copyWith(panel: panel));
|
||||
}
|
||||
|
||||
/// Включает/выключает серию [index] на вкладке [panel].
|
||||
void toggleSeries(InsightsPanel panel, int index) {
|
||||
final current = state;
|
||||
if (current is! InsightsStateLoaded) {
|
||||
return;
|
||||
}
|
||||
final visibility = current.seriesVisibility;
|
||||
final source = switch (panel) {
|
||||
InsightsPanel.measures => visibility.measure,
|
||||
InsightsPanel.daily => visibility.daily,
|
||||
InsightsPanel.intra => visibility.intra,
|
||||
_ => null,
|
||||
};
|
||||
if (source == null || index < 0 || index >= source.length) {
|
||||
return;
|
||||
}
|
||||
final updated = [...source]..[index] = !source[index];
|
||||
final nextVisibility = switch (panel) {
|
||||
InsightsPanel.measures => visibility.copyWith(measure: updated),
|
||||
InsightsPanel.daily => visibility.copyWith(daily: updated),
|
||||
_ => visibility.copyWith(intra: updated),
|
||||
};
|
||||
emit(current.copyWith(seriesVisibility: nextVisibility));
|
||||
}
|
||||
|
||||
void toggleSleep() {
|
||||
final current = state;
|
||||
if (current is InsightsStateLoaded) {
|
||||
emit(current.copyWith(showSleep: !current.showSleep));
|
||||
}
|
||||
}
|
||||
|
||||
/// Сохраняет вес и обновляет данные. Возвращает `false`, если сохранить не удалось.
|
||||
Future<bool> addWeight({required double weight}) {
|
||||
return _add(() => repository.addWeight(weight: weight));
|
||||
}
|
||||
|
||||
/// Сохраняет замеры и обновляет данные. Возвращает `false`, если сохранить не удалось.
|
||||
Future<bool> addCircumference({
|
||||
required int chestCircumference,
|
||||
required int waistCircumference,
|
||||
required int hipCircumference,
|
||||
}) {
|
||||
return _add(
|
||||
() => repository.addCircumference(
|
||||
chestCircumference: chestCircumference,
|
||||
waistCircumference: waistCircumference,
|
||||
hipCircumference: hipCircumference,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _add(Future<void> Function() action) async {
|
||||
try {
|
||||
await action();
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString(), stackTrace: st);
|
||||
return false;
|
||||
}
|
||||
await refresh();
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _fetch({
|
||||
required BodyPeriod bodyPeriod,
|
||||
required InsightsStateLoaded? previous,
|
||||
}) async {
|
||||
final requestId = ++_requestId;
|
||||
try {
|
||||
if (!_isHeightLoaded) {
|
||||
_heightCm = await repository.getHeightCm(slug: slug);
|
||||
_isHeightLoaded = true;
|
||||
}
|
||||
final payload = await repository.getInsights(
|
||||
slug: slug,
|
||||
bodyPeriod: bodyPeriod,
|
||||
heightCm: _heightCm,
|
||||
);
|
||||
if (requestId != _requestId || isClosed) {
|
||||
return;
|
||||
}
|
||||
emit(
|
||||
previous == null
|
||||
? InsightsState.loaded(payload: payload, bodyPeriod: bodyPeriod)
|
||||
: previous.copyWith(
|
||||
payload: payload,
|
||||
bodyPeriod: bodyPeriod,
|
||||
isRefreshing: false,
|
||||
),
|
||||
);
|
||||
} on InsightsForbidden {
|
||||
if (requestId == _requestId && !isClosed) {
|
||||
emit(const InsightsState.hidden());
|
||||
}
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString(), stackTrace: st);
|
||||
if (requestId != _requestId || isClosed) {
|
||||
return;
|
||||
}
|
||||
// Ошибка перезапроса не должна стирать уже показанные данные.
|
||||
emit(
|
||||
previous == null
|
||||
? InsightsState.error(message: e.toString())
|
||||
: previous.copyWith(isRefreshing: false),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
18
lib/features/insights/presentation/cubit/insights_panel.dart
Normal file
18
lib/features/insights/presentation/cubit/insights_panel.dart
Normal file
@@ -0,0 +1,18 @@
|
||||
/// Вкладки блока «Аналитика».
|
||||
enum InsightsPanel {
|
||||
weight,
|
||||
bmi,
|
||||
measures,
|
||||
daily,
|
||||
intra;
|
||||
|
||||
/// Вкладки КБЖУ доступны только при включённом флаге.
|
||||
bool get isKbju => this == daily || this == intra;
|
||||
|
||||
/// Кнопка «+» есть только у вкладок с ручным вводом.
|
||||
bool get supportsAdd => this == weight || this == measures;
|
||||
|
||||
static List<InsightsPanel> available({required bool kbjuEnabled}) {
|
||||
return values.where((panel) => kbjuEnabled || !panel.isKbju).toList();
|
||||
}
|
||||
}
|
||||
47
lib/features/insights/presentation/cubit/insights_state.dart
Normal file
47
lib/features/insights/presentation/cubit/insights_state.dart
Normal file
@@ -0,0 +1,47 @@
|
||||
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
|
||||
import 'package:eatvio_app/features/insights/models/insights_period.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/cubit/insights_panel.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'insights_state.freezed.dart';
|
||||
|
||||
/// Видимость серий (чипы под графиком): `true` — серия показана.
|
||||
@freezed
|
||||
abstract class InsightsSeriesVisibility with _$InsightsSeriesVisibility {
|
||||
const factory InsightsSeriesVisibility({
|
||||
/// Талия, Грудь, Бёдра.
|
||||
@Default([true, true, true]) List<bool> measure,
|
||||
|
||||
/// Калории, Белки, Жиры, Углеводы.
|
||||
@Default([true, true, true, true]) List<bool> daily,
|
||||
|
||||
/// Белки, Жиры, Углеводы, Сытость, Настроение.
|
||||
@Default([true, true, true, true, true]) List<bool> intra,
|
||||
}) = _InsightsSeriesVisibility;
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class InsightsState with _$InsightsState {
|
||||
const factory InsightsState.initial() = InsightsStateInitial;
|
||||
|
||||
const factory InsightsState.loading() = InsightsStateLoading;
|
||||
|
||||
/// 403: данные недоступны, блок не показывается.
|
||||
const factory InsightsState.hidden() = InsightsStateHidden;
|
||||
|
||||
const factory InsightsState.error({required String message}) =
|
||||
InsightsStateError;
|
||||
|
||||
const factory InsightsState.loaded({
|
||||
required InsightsPayloadModel payload,
|
||||
@Default(BodyPeriod.threeMonths) BodyPeriod bodyPeriod,
|
||||
@Default(InsightsPanel.weight) InsightsPanel panel,
|
||||
@Default(InsightsSeriesVisibility())
|
||||
InsightsSeriesVisibility seriesVisibility,
|
||||
@Default(true) bool showSleep,
|
||||
@Default(false) bool isExpanded,
|
||||
|
||||
/// Идёт перезапрос (смена периода / обновление) — старые данные остаются на экране.
|
||||
@Default(false) bool isRefreshing,
|
||||
}) = InsightsStateLoaded;
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
const int _minValue = 20;
|
||||
const int _maxValue = 199;
|
||||
|
||||
/// Значения замеров в сантиметрах.
|
||||
typedef CircumferenceValues = ({int chest, int waist, int hip});
|
||||
|
||||
/// Выбор замеров груди, талии и бёдер. [onSave] вызывается после закрытия шторки.
|
||||
Future<void> showAddCircumferenceBottomSheet(
|
||||
BuildContext context, {
|
||||
CircumferenceValues? initial,
|
||||
required Future<void> Function(CircumferenceValues values) onSave,
|
||||
}) {
|
||||
return showModalBottomSheet<void>(
|
||||
useRootNavigator: true,
|
||||
context: context,
|
||||
builder: (_) =>
|
||||
AddCircumferenceBottomSheet(initial: initial, onSave: onSave),
|
||||
);
|
||||
}
|
||||
|
||||
class AddCircumferenceBottomSheet extends StatefulWidget {
|
||||
const AddCircumferenceBottomSheet({
|
||||
super.key,
|
||||
this.initial,
|
||||
required this.onSave,
|
||||
});
|
||||
|
||||
final CircumferenceValues? initial;
|
||||
final Future<void> Function(CircumferenceValues values) onSave;
|
||||
|
||||
@override
|
||||
State<AddCircumferenceBottomSheet> createState() =>
|
||||
_AddCircumferenceBottomSheetState();
|
||||
}
|
||||
|
||||
class _AddCircumferenceBottomSheetState
|
||||
extends State<AddCircumferenceBottomSheet> {
|
||||
late final FixedExtentScrollController _chestController;
|
||||
late final FixedExtentScrollController _waistController;
|
||||
late final FixedExtentScrollController _hipController;
|
||||
late int _chest;
|
||||
late int _waist;
|
||||
late int _hip;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_chest = _clamp(widget.initial?.chest);
|
||||
_waist = _clamp(widget.initial?.waist);
|
||||
_hip = _clamp(widget.initial?.hip);
|
||||
_chestController = FixedExtentScrollController(
|
||||
initialItem: _chest - _minValue,
|
||||
);
|
||||
_waistController = FixedExtentScrollController(
|
||||
initialItem: _waist - _minValue,
|
||||
);
|
||||
_hipController = FixedExtentScrollController(initialItem: _hip - _minValue);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_chestController.dispose();
|
||||
_waistController.dispose();
|
||||
_hipController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
int _clamp(int? value) => (value ?? _minValue).clamp(_minValue, _maxValue);
|
||||
|
||||
void _save() {
|
||||
final values = (chest: _chest, waist: _waist, hip: _hip);
|
||||
Navigator.of(context).pop();
|
||||
widget.onSave(values);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
for (final title in [
|
||||
s.insights_series_chest,
|
||||
s.insights_series_waist,
|
||||
s.insights_series_hips,
|
||||
])
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 200,
|
||||
child: Row(
|
||||
children: [
|
||||
_picker(
|
||||
const Key('add_circumference_chest_picker'),
|
||||
_chestController,
|
||||
(value) => _chest = value,
|
||||
),
|
||||
_picker(
|
||||
const Key('add_circumference_waist_picker'),
|
||||
_waistController,
|
||||
(value) => _waist = value,
|
||||
),
|
||||
_picker(
|
||||
const Key('add_circumference_hip_picker'),
|
||||
_hipController,
|
||||
(value) => _hip = value,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: InkWell(
|
||||
key: const Key('add_sheet_save'),
|
||||
onTap: _save,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Ink(
|
||||
width: MediaQuery.of(context).size.width - 32,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: appTheme.blueA400,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
s.save,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _picker(
|
||||
Key key,
|
||||
FixedExtentScrollController controller,
|
||||
ValueChanged<int> onChanged,
|
||||
) {
|
||||
return Expanded(
|
||||
child: CupertinoPicker(
|
||||
key: key,
|
||||
magnification: 1.22,
|
||||
squeeze: 1.2,
|
||||
useMagnifier: true,
|
||||
scrollController: controller,
|
||||
itemExtent: 24,
|
||||
onSelectedItemChanged: (index) => onChanged(index + _minValue),
|
||||
children: [for (int i = _minValue; i <= _maxValue; i++) Text('$i')],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
const int _minKilo = 20;
|
||||
const int _maxKilo = 299;
|
||||
|
||||
/// Выбор веса (кг + десятые). [onSave] вызывается после закрытия шторки.
|
||||
Future<void> showAddWeightBottomSheet(
|
||||
BuildContext context, {
|
||||
double? initialWeight,
|
||||
required Future<void> Function(double weight) onSave,
|
||||
}) {
|
||||
return showModalBottomSheet<void>(
|
||||
useRootNavigator: true,
|
||||
context: context,
|
||||
builder: (_) =>
|
||||
AddWeightBottomSheet(initialWeight: initialWeight, onSave: onSave),
|
||||
);
|
||||
}
|
||||
|
||||
class AddWeightBottomSheet extends StatefulWidget {
|
||||
const AddWeightBottomSheet({
|
||||
super.key,
|
||||
this.initialWeight,
|
||||
required this.onSave,
|
||||
});
|
||||
|
||||
final double? initialWeight;
|
||||
final Future<void> Function(double weight) onSave;
|
||||
|
||||
@override
|
||||
State<AddWeightBottomSheet> createState() => _AddWeightBottomSheetState();
|
||||
}
|
||||
|
||||
class _AddWeightBottomSheetState extends State<AddWeightBottomSheet> {
|
||||
late final FixedExtentScrollController _kiloController;
|
||||
late final FixedExtentScrollController _gramsController;
|
||||
late int _kilo;
|
||||
late int _grams;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final initial = widget.initialWeight;
|
||||
final tenths = initial == null
|
||||
? 0
|
||||
: (initial * 10).round().clamp(_minKilo * 10, _maxKilo * 10 + 9);
|
||||
_kilo = initial == null ? _minKilo : tenths ~/ 10;
|
||||
_grams = initial == null ? 0 : tenths % 10;
|
||||
_kiloController = FixedExtentScrollController(
|
||||
initialItem: _kilo - _minKilo,
|
||||
);
|
||||
_gramsController = FixedExtentScrollController(initialItem: _grams);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_kiloController.dispose();
|
||||
_gramsController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _save() {
|
||||
final weight = double.parse('$_kilo.$_grams');
|
||||
Navigator.of(context).pop();
|
||||
widget.onSave(weight);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 200,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: CupertinoPicker(
|
||||
key: const Key('add_weight_kilo_picker'),
|
||||
magnification: 1.22,
|
||||
squeeze: 1.2,
|
||||
useMagnifier: true,
|
||||
scrollController: _kiloController,
|
||||
itemExtent: 24,
|
||||
onSelectedItemChanged: (value) => _kilo = value + _minKilo,
|
||||
children: [
|
||||
for (int i = _minKilo; i <= _maxKilo; i++) Text('$i'),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: CupertinoPicker(
|
||||
key: const Key('add_weight_grams_picker'),
|
||||
magnification: 1.22,
|
||||
squeeze: 1.2,
|
||||
useMagnifier: true,
|
||||
scrollController: _gramsController,
|
||||
itemExtent: 24,
|
||||
onSelectedItemChanged: (value) => _grams = value,
|
||||
children: [for (int i = 0; i < 10; i++) Text('$i')],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_SaveButton(onTap: _save),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SaveButton extends StatelessWidget {
|
||||
const _SaveButton({required this.onTap});
|
||||
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: InkWell(
|
||||
key: const Key('add_sheet_save'),
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Ink(
|
||||
width: MediaQuery.of(context).size.width - 32,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: appTheme.blueA400,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
S.of(context).save,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
593
lib/features/insights/presentation/widgets/insights_card.dart
Normal file
593
lib/features/insights/presentation/widgets/insights_card.dart
Normal file
@@ -0,0 +1,593 @@
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/utils/toasts.dart';
|
||||
import 'package:eatvio_app/features/insights/insights_feature_flags.dart';
|
||||
import 'package:eatvio_app/features/insights/models/insights_period.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/charts/bmi_chart.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/charts/daily_kbju_chart.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/charts/insights_chart_common.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/charts/intra_day_chart.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/charts/measure_chart.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/charts/weight_chart.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/cubit/insights_cubit.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/cubit/insights_panel.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/cubit/insights_state.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/add_circumference_bottom_sheet.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/add_weight_bottom_sheet.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_fab.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_head.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_tabs.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/intra_date_nav.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/intra_legend.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/period_pills.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/series_chips.dart';
|
||||
import 'package:eatvio_app/features/insights/repositories/insights_repository.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
/// Сворачиваемый блок «Аналитика» (вес, ИМТ, замеры).
|
||||
///
|
||||
/// [slug] — владелец данных (`null` — текущий пользователь). При 403 блок
|
||||
/// скрывается целиком. [canAdd] включает кнопку «+» на вкладках «Вес» и «Замеры».
|
||||
class InsightsCard extends StatelessWidget {
|
||||
const InsightsCard({
|
||||
super.key,
|
||||
this.slug,
|
||||
this.canAdd = false,
|
||||
this.repository,
|
||||
this.kbjuEnabled = InsightsFeatureFlags.kbjuEnabled,
|
||||
});
|
||||
|
||||
final String? slug;
|
||||
final bool canAdd;
|
||||
final InsightsRepository? repository;
|
||||
final bool kbjuEnabled;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return KeyedSubtree(
|
||||
key: ValueKey('insights_card_$slug'),
|
||||
child: BlocProvider<InsightsCubit>(
|
||||
create: (_) => InsightsCubit(
|
||||
repository: repository ?? sl<InsightsRepository>(),
|
||||
slug: slug,
|
||||
canAdd: canAdd,
|
||||
kbjuEnabled: kbjuEnabled,
|
||||
)..load(),
|
||||
child: const _InsightsView(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InsightsView extends StatelessWidget {
|
||||
const _InsightsView();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<InsightsCubit, InsightsState>(
|
||||
builder: (context, state) {
|
||||
return switch (state) {
|
||||
InsightsStateLoaded() => _CardShell(
|
||||
child: _LoadedContent(state: state),
|
||||
),
|
||||
InsightsStateError() => const _CardShell(child: _ErrorContent()),
|
||||
_ => const SizedBox.shrink(),
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CardShell extends StatelessWidget {
|
||||
const _CardShell({required this.child});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
key: const Key('insights_card'),
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: InsightsColors.cardShadow,
|
||||
blurRadius: 4,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ErrorContent extends StatelessWidget {
|
||||
const _ErrorContent();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
s.insights_load_error,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: InsightsColors.subtitle,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
key: const Key('insights_retry'),
|
||||
onPressed: () => context.read<InsightsCubit>().load(),
|
||||
child: Text(s.insights_retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LoadedContent extends StatelessWidget {
|
||||
const _LoadedContent({required this.state});
|
||||
|
||||
final InsightsStateLoaded state;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cubit = context.read<InsightsCubit>();
|
||||
final showFab = cubit.canAdd && state.panel.supportsAdd;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_Toggle(isExpanded: state.isExpanded, onTap: cubit.toggleExpanded),
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
alignment: Alignment.topCenter,
|
||||
child: state.isExpanded
|
||||
? Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
20,
|
||||
0,
|
||||
20,
|
||||
showFab ? 24 + 44 : 24,
|
||||
),
|
||||
child: _Body(state: state),
|
||||
)
|
||||
: const SizedBox(width: double.infinity),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (showFab && state.isExpanded)
|
||||
Positioned(
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
child: InsightsFab(
|
||||
tooltip: S.of(context).insights_add_record,
|
||||
onPressed: () => _onAddPressed(context, cubit, state),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _onAddPressed(
|
||||
BuildContext context,
|
||||
InsightsCubit cubit,
|
||||
InsightsStateLoaded state,
|
||||
) {
|
||||
final s = S.of(context);
|
||||
if (state.panel == InsightsPanel.weight) {
|
||||
showAddWeightBottomSheet(
|
||||
context,
|
||||
initialWeight: state.payload.weight.last,
|
||||
onSave: (weight) async {
|
||||
if (!await cubit.addWeight(weight: weight)) {
|
||||
showErrorToast(s.insights_save_error);
|
||||
}
|
||||
},
|
||||
);
|
||||
} else if (state.panel == InsightsPanel.measures) {
|
||||
final measure = state.payload.measure;
|
||||
showAddCircumferenceBottomSheet(
|
||||
context,
|
||||
initial:
|
||||
measure.waist.isEmpty ||
|
||||
measure.chest.isEmpty ||
|
||||
measure.hips.isEmpty
|
||||
? null
|
||||
: (
|
||||
chest: measure.chest.last.round(),
|
||||
waist: measure.waist.last.round(),
|
||||
hip: measure.hips.last.round(),
|
||||
),
|
||||
onSave: (values) async {
|
||||
final isSaved = await cubit.addCircumference(
|
||||
chestCircumference: values.chest,
|
||||
waistCircumference: values.waist,
|
||||
hipCircumference: values.hip,
|
||||
);
|
||||
if (!isSaved) {
|
||||
showErrorToast(s.insights_save_error);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _Toggle extends StatelessWidget {
|
||||
const _Toggle({required this.isExpanded, required this.onTap});
|
||||
|
||||
final bool isExpanded;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
return InkWell(
|
||||
key: const Key('insights_toggle'),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 34,
|
||||
height: 34,
|
||||
decoration: BoxDecoration(
|
||||
color: InsightsColors.accentBackground,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.show_chart_rounded,
|
||||
size: 20,
|
||||
color: InsightsColors.blue,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
s.insights_title,
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: InsightsColors.title,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
key: const Key('insights_subtitle'),
|
||||
isExpanded
|
||||
? s.insights_subtitle_open
|
||||
: s.insights_subtitle_closed,
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: InsightsColors.subtitle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
AnimatedRotation(
|
||||
turns: isExpanded ? 0 : 0.5,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: const Icon(
|
||||
Icons.keyboard_arrow_up_rounded,
|
||||
size: 26,
|
||||
color: InsightsColors.blue,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Body extends StatelessWidget {
|
||||
const _Body({required this.state});
|
||||
|
||||
final InsightsStateLoaded state;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cubit = context.read<InsightsCubit>();
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
InsightsTabs(
|
||||
panels: cubit.panels,
|
||||
selected: state.panel,
|
||||
onSelected: cubit.setPanel,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
opacity: state.isRefreshing ? 0.5 : 1,
|
||||
child: _PanelContent(state: state, canAdd: cubit.canAdd),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PanelContent extends StatelessWidget {
|
||||
const _PanelContent({required this.state, required this.canAdd});
|
||||
|
||||
final InsightsStateLoaded state;
|
||||
final bool canAdd;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cubit = context.read<InsightsCubit>();
|
||||
final s = S.of(context);
|
||||
final payload = state.payload;
|
||||
|
||||
final Widget content;
|
||||
switch (state.panel) {
|
||||
case InsightsPanel.weight:
|
||||
content = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
InsightsHead(
|
||||
headline: payload.weight.headline,
|
||||
unit: payload.weight.unit,
|
||||
deltaText: payload.weight.deltaText,
|
||||
isDeltaHighlighted: payload.weight.delta != null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
WeightChart(
|
||||
model: payload.weight,
|
||||
emptyText: canAdd
|
||||
? s.insights_add_weight_empty
|
||||
: s.insights_not_enough,
|
||||
),
|
||||
],
|
||||
);
|
||||
case InsightsPanel.bmi:
|
||||
content = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (payload.bmi.values.isNotEmpty) ...[
|
||||
InsightsHead(
|
||||
headline: payload.bmi.headline,
|
||||
unit: s.insights_unit_bmi,
|
||||
deltaText: payload.bmi.deltaText,
|
||||
isDeltaHighlighted: payload.bmi.delta != null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
BmiChart(model: payload.bmi),
|
||||
],
|
||||
);
|
||||
case InsightsPanel.measures:
|
||||
final visibility = state.seriesVisibility.measure;
|
||||
content = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
InsightsHead(
|
||||
headline: payload.measure.headline,
|
||||
unit: s.insights_unit_cm_waist,
|
||||
deltaText: payload.measure.deltaText,
|
||||
isDeltaHighlighted: payload.measure.waist.length > 1,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SeriesChips(
|
||||
chips: [
|
||||
SeriesChipData(
|
||||
label: s.insights_series_waist,
|
||||
color: InsightsColors.waist,
|
||||
isVisible: visibility[0],
|
||||
),
|
||||
SeriesChipData(
|
||||
label: s.insights_series_chest,
|
||||
color: InsightsColors.chest,
|
||||
isVisible: visibility[1],
|
||||
),
|
||||
SeriesChipData(
|
||||
label: s.insights_series_hips,
|
||||
color: InsightsColors.hips,
|
||||
isVisible: visibility[2],
|
||||
),
|
||||
],
|
||||
onToggle: (index) =>
|
||||
cubit.toggleSeries(InsightsPanel.measures, index),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
MeasureChart(
|
||||
model: payload.measure,
|
||||
visibility: visibility,
|
||||
emptyText: canAdd
|
||||
? s.insights_add_measures_empty
|
||||
: s.insights_not_enough,
|
||||
),
|
||||
],
|
||||
);
|
||||
case InsightsPanel.daily:
|
||||
return _DailyPanel(state: state);
|
||||
case InsightsPanel.intra:
|
||||
return _IntraPanel(state: state);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
PeriodPills<BodyPeriod>(
|
||||
periods: BodyPeriod.values,
|
||||
selected: state.bodyPeriod,
|
||||
labelOf: (period) => period.pillLabel(s),
|
||||
keyOf: (period) => period.apiValue,
|
||||
onSelected: cubit.setBodyPeriod,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
content,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// «КБЖУ по дням». Период пока фиксирован: перезапрос появится вместе с
|
||||
/// эндпоинтом `GET /api/profile/insights` (итерация 2).
|
||||
class _DailyPanel extends StatelessWidget {
|
||||
const _DailyPanel({required this.state});
|
||||
|
||||
final InsightsStateLoaded state;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
final cubit = context.read<InsightsCubit>();
|
||||
final daily = state.payload.daily;
|
||||
final visibility = state.seriesVisibility.daily;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
PeriodPills<DailyPeriod>(
|
||||
periods: DailyPeriod.values,
|
||||
selected: DailyPeriod.twoWeeks,
|
||||
labelOf: (period) => period.pillLabel(s),
|
||||
keyOf: (period) => period.apiValue,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (daily == null)
|
||||
InsightsChartPlaceholder(text: s.insights_not_enough)
|
||||
else ...[
|
||||
InsightsHead(
|
||||
headline: daily.headline,
|
||||
unit: s.insights_unit_kcal_avg,
|
||||
deltaText: daily.deltaText,
|
||||
isDeltaHighlighted: daily.percent != null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SeriesChips(
|
||||
chips: [
|
||||
SeriesChipData(
|
||||
label: s.insights_series_calories,
|
||||
color: InsightsColors.calories,
|
||||
isVisible: visibility[0],
|
||||
),
|
||||
SeriesChipData(
|
||||
label: s.insights_series_proteins,
|
||||
color: InsightsColors.proteins,
|
||||
isVisible: visibility[1],
|
||||
),
|
||||
SeriesChipData(
|
||||
label: s.insights_series_fats,
|
||||
color: InsightsColors.fats,
|
||||
isVisible: visibility[2],
|
||||
),
|
||||
SeriesChipData(
|
||||
label: s.insights_series_carbs,
|
||||
color: InsightsColors.carbs,
|
||||
isVisible: visibility[3],
|
||||
),
|
||||
],
|
||||
onToggle: (index) => cubit.toggleSeries(InsightsPanel.daily, index),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DailyKbjuChart(model: daily, visibility: visibility),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// «В течение дня». Навигация по датам неактивна до итерации 2.
|
||||
class _IntraPanel extends StatelessWidget {
|
||||
const _IntraPanel({required this.state});
|
||||
|
||||
final InsightsStateLoaded state;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
final cubit = context.read<InsightsCubit>();
|
||||
final intra = state.payload.intra;
|
||||
final visibility = state.seriesVisibility.intra;
|
||||
|
||||
if (intra == null) {
|
||||
return InsightsChartPlaceholder(text: s.insights_not_enough);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
IntraDateNav(model: intra),
|
||||
const SizedBox(height: 16),
|
||||
SeriesChips(
|
||||
chips: [
|
||||
SeriesChipData(
|
||||
label: s.insights_series_proteins,
|
||||
color: InsightsColors.proteins,
|
||||
isVisible: visibility[0],
|
||||
),
|
||||
SeriesChipData(
|
||||
label: s.insights_series_fats,
|
||||
color: InsightsColors.fats,
|
||||
isVisible: visibility[1],
|
||||
),
|
||||
SeriesChipData(
|
||||
label: s.insights_series_carbs,
|
||||
color: InsightsColors.carbs,
|
||||
isVisible: visibility[2],
|
||||
),
|
||||
SeriesChipData(
|
||||
label: s.insights_series_satiety,
|
||||
color: InsightsColors.satiety,
|
||||
isVisible: visibility[3],
|
||||
),
|
||||
SeriesChipData(
|
||||
label: s.insights_series_mood,
|
||||
color: InsightsColors.mood,
|
||||
isVisible: visibility[4],
|
||||
),
|
||||
SeriesChipData(
|
||||
label: s.insights_series_sleep,
|
||||
color: InsightsColors.sleepLine,
|
||||
isVisible: state.showSleep,
|
||||
),
|
||||
],
|
||||
onToggle: (index) {
|
||||
if (index == 5) {
|
||||
cubit.toggleSleep();
|
||||
} else {
|
||||
cubit.toggleSeries(InsightsPanel.intra, index);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
IntraDayChart(
|
||||
model: intra,
|
||||
visibility: visibility,
|
||||
showSleep: state.showSleep,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
IntraLegend(model: intra),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
s.insights_intra_hint,
|
||||
key: const Key('insights_intra_hint'),
|
||||
style: const TextStyle(fontSize: 13, color: InsightsColors.subtitle),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'package:flutter/painting.dart';
|
||||
|
||||
/// Токены из `latest-tmpls/src/scss/pages/diary.scss` (`&__analytics*`).
|
||||
abstract final class InsightsColors {
|
||||
static const Color blue = Color(0xFF2F80ED);
|
||||
static const Color accentBackground = Color(0xFFF6FAFF);
|
||||
static const Color title = Color(0xFF323F4B);
|
||||
static const Color subtitle = Color(0xFF7B8794);
|
||||
static const Color border = Color(0xFFE6E6E6);
|
||||
static const Color positive = Color(0xFF019A3E);
|
||||
|
||||
static const Color calories = Color(0xFF323F4B);
|
||||
static const Color proteins = Color(0xFF2F80ED);
|
||||
static const Color fats = Color(0xFFF2994A);
|
||||
static const Color carbs = Color(0xFF0D6EFD);
|
||||
static const Color satiety = Color(0xFF019A3E);
|
||||
static const Color mood = Color(0xFFEB555C);
|
||||
|
||||
static const Color waist = Color(0xFF2F80ED);
|
||||
static const Color chest = Color(0xFF9B51E0);
|
||||
static const Color hips = Color(0xFFF2994A);
|
||||
|
||||
static const Color wake = Color(0xFFF2994A);
|
||||
static const Color sleepLine = Color(0xFF7B8794);
|
||||
static const Color sleepShade = Color(0x1A7B8794);
|
||||
|
||||
static const Color bmiNormalZone = Color(0x14019A3E);
|
||||
static const Color bmiOverweightZone = Color(0x1AF2994A);
|
||||
static const Color bmiObesityZone = Color(0x14EB555C);
|
||||
|
||||
static const Color cardShadow = Color(0x26000000);
|
||||
static const Color fabShadow = Color(0x592F80ED);
|
||||
}
|
||||
|
||||
/// Высота области графика.
|
||||
const double insightsChartHeight = 270;
|
||||
42
lib/features/insights/presentation/widgets/insights_fab.dart
Normal file
42
lib/features/insights/presentation/widgets/insights_fab.dart
Normal file
@@ -0,0 +1,42 @@
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Круглая кнопка «+» (52px) справа-снизу карточки.
|
||||
class InsightsFab extends StatelessWidget {
|
||||
const InsightsFab({super.key, required this.onPressed, this.tooltip});
|
||||
|
||||
final VoidCallback onPressed;
|
||||
final String? tooltip;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Tooltip(
|
||||
message: tooltip ?? '',
|
||||
child: Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
decoration: const BoxDecoration(
|
||||
color: InsightsColors.blue,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: InsightsColors.fabShadow,
|
||||
blurRadius: 20,
|
||||
offset: Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
shape: const CircleBorder(),
|
||||
child: InkWell(
|
||||
key: const Key('insights_fab'),
|
||||
customBorder: const CircleBorder(),
|
||||
onTap: onPressed,
|
||||
child: const Icon(Icons.add, color: Colors.white, size: 28),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Крупное число + единица + строка с дельтой.
|
||||
class InsightsHead extends StatelessWidget {
|
||||
const InsightsHead({
|
||||
super.key,
|
||||
required this.headline,
|
||||
required this.unit,
|
||||
required this.deltaText,
|
||||
this.isDeltaHighlighted = true,
|
||||
});
|
||||
|
||||
final String headline;
|
||||
final String unit;
|
||||
final String deltaText;
|
||||
|
||||
/// Зелёная дельта при наличии изменения, иначе нейтральный серый.
|
||||
final bool isDeltaHighlighted;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(
|
||||
headline,
|
||||
key: const Key('insights_headline'),
|
||||
style: const TextStyle(
|
||||
fontSize: 34,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: InsightsColors.title,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
if (unit.isNotEmpty) ...[
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
unit,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: InsightsColors.subtitle,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (deltaText.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
deltaText,
|
||||
key: const Key('insights_delta'),
|
||||
style: TextStyle(
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: isDeltaHighlighted
|
||||
? InsightsColors.positive
|
||||
: InsightsColors.subtitle,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'package:eatvio_app/features/insights/presentation/cubit/insights_panel.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
extension InsightsPanelLabel on InsightsPanel {
|
||||
String label(S s) {
|
||||
switch (this) {
|
||||
case InsightsPanel.weight:
|
||||
return s.insights_tab_weight;
|
||||
case InsightsPanel.bmi:
|
||||
return s.insights_tab_bmi;
|
||||
case InsightsPanel.measures:
|
||||
return s.insights_tab_measures;
|
||||
case InsightsPanel.daily:
|
||||
return s.insights_tab_daily;
|
||||
case InsightsPanel.intra:
|
||||
return s.insights_tab_intra;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Горизонтально прокручиваемые вкладки (`l-tab`).
|
||||
class InsightsTabs extends StatelessWidget {
|
||||
const InsightsTabs({
|
||||
super.key,
|
||||
required this.panels,
|
||||
required this.selected,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
final List<InsightsPanel> panels;
|
||||
final InsightsPanel selected;
|
||||
final ValueChanged<InsightsPanel> onSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
for (final panel in panels)
|
||||
InkWell(
|
||||
key: Key('insights_tab_${panel.name}'),
|
||||
onTap: () => onSelected(panel),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: panel == selected
|
||||
? InsightsColors.blue
|
||||
: Colors.transparent,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
panel.label(s),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: panel == selected
|
||||
? FontWeight.w700
|
||||
: FontWeight.w600,
|
||||
color: panel == selected
|
||||
? InsightsColors.blue
|
||||
: InsightsColors.subtitle,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
130
lib/features/insights/presentation/widgets/intra_date_nav.dart
Normal file
130
lib/features/insights/presentation/widgets/intra_date_nav.dart
Normal file
@@ -0,0 +1,130 @@
|
||||
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Навигация по дням для вкладки «В течение дня»: `‹ дата ›` и выбор даты.
|
||||
///
|
||||
/// Колбэки `null` — навигация неактивна (данные других дней ещё не подключены).
|
||||
class IntraDateNav extends StatelessWidget {
|
||||
const IntraDateNav({
|
||||
super.key,
|
||||
required this.model,
|
||||
this.onPreviousDay,
|
||||
this.onNextDay,
|
||||
this.onDatePicked,
|
||||
});
|
||||
|
||||
final IntraChartModel model;
|
||||
final VoidCallback? onPreviousDay;
|
||||
final VoidCallback? onNextDay;
|
||||
final ValueChanged<DateTime>? onDatePicked;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
final canGoNext = !model.isToday && onNextDay != null;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: InsightsColors.accentBackground,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_NavButton(
|
||||
key: const Key('insights_intra_prev'),
|
||||
icon: Icons.chevron_left_rounded,
|
||||
tooltip: s.insights_prev_day,
|
||||
onPressed: onPreviousDay,
|
||||
),
|
||||
InkWell(
|
||||
key: const Key('insights_intra_pick'),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
onTap: onDatePicked == null ? null : () => _pickDate(context),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minWidth: 72),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.calendar_today_rounded,
|
||||
size: 14,
|
||||
color: InsightsColors.blue,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
model.dateLabel,
|
||||
key: const Key('insights_intra_date_label'),
|
||||
style: const TextStyle(
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: InsightsColors.title,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_NavButton(
|
||||
key: const Key('insights_intra_next'),
|
||||
icon: Icons.chevron_right_rounded,
|
||||
tooltip: s.insights_next_day,
|
||||
onPressed: canGoNext ? onNextDay : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickDate(BuildContext context) async {
|
||||
final lastDate = DateTime.tryParse(model.maxDate) ?? DateTime.now();
|
||||
final current = DateTime.tryParse(model.date) ?? lastDate;
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: current.isAfter(lastDate) ? lastDate : current,
|
||||
firstDate: DateTime(2000),
|
||||
lastDate: lastDate,
|
||||
helpText: S.of(context).insights_pick_date,
|
||||
);
|
||||
if (picked != null) {
|
||||
onDatePicked?.call(picked);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _NavButton extends StatelessWidget {
|
||||
const _NavButton({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.tooltip,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String tooltip;
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Opacity(
|
||||
opacity: onPressed == null ? 0.35 : 1,
|
||||
child: SizedBox(
|
||||
width: 36,
|
||||
height: 36,
|
||||
child: IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
tooltip: tooltip,
|
||||
onPressed: onPressed,
|
||||
icon: Icon(icon, color: InsightsColors.blue),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
96
lib/features/insights/presentation/widgets/intra_legend.dart
Normal file
96
lib/features/insights/presentation/widgets/intra_legend.dart
Normal file
@@ -0,0 +1,96 @@
|
||||
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Легенда под графиком «В течение дня»: подъём, отход ко сну, затемнение сна.
|
||||
class IntraLegend extends StatelessWidget {
|
||||
const IntraLegend({super.key, required this.model});
|
||||
|
||||
final IntraChartModel model;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
final wakeText =
|
||||
model.wakeText ??
|
||||
(model.wakeLabel == null ? null : s.insights_wake_at(model.wakeLabel!));
|
||||
final sleepText =
|
||||
model.sleepText ??
|
||||
(model.sleepLabel == null
|
||||
? null
|
||||
: s.insights_sleep_at(model.sleepLabel!));
|
||||
|
||||
return Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
if (wakeText != null)
|
||||
_LegendItem(
|
||||
key: const Key('insights_legend_wake'),
|
||||
color: InsightsColors.wake,
|
||||
text: wakeText,
|
||||
isDashed: true,
|
||||
),
|
||||
if (sleepText != null)
|
||||
_LegendItem(
|
||||
key: const Key('insights_legend_sleep'),
|
||||
color: InsightsColors.sleepLine,
|
||||
text: sleepText,
|
||||
isDashed: true,
|
||||
),
|
||||
_LegendItem(
|
||||
key: const Key('insights_legend_shade'),
|
||||
color: InsightsColors.sleepShade,
|
||||
text: s.insights_sleep_shade,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LegendItem extends StatelessWidget {
|
||||
const _LegendItem({
|
||||
super.key,
|
||||
required this.color,
|
||||
required this.text,
|
||||
this.isDashed = false,
|
||||
});
|
||||
|
||||
final Color color;
|
||||
final String text;
|
||||
final bool isDashed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
isDashed
|
||||
? Container(
|
||||
width: 18,
|
||||
height: 0,
|
||||
decoration: BoxDecoration(
|
||||
border: Border(top: BorderSide(color: color, width: 2)),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
width: 18,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5,
|
||||
color: InsightsColors.subtitle,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
115
lib/features/insights/presentation/widgets/period_pills.dart
Normal file
115
lib/features/insights/presentation/widgets/period_pills.dart
Normal file
@@ -0,0 +1,115 @@
|
||||
import 'package:eatvio_app/features/insights/models/insights_period.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
extension BodyPeriodLabel on BodyPeriod {
|
||||
String pillLabel(S s) {
|
||||
switch (this) {
|
||||
case BodyPeriod.month:
|
||||
return s.insights_period_1m;
|
||||
case BodyPeriod.threeMonths:
|
||||
return s.insights_period_3m;
|
||||
case BodyPeriod.sixMonths:
|
||||
return s.insights_period_6m;
|
||||
case BodyPeriod.year:
|
||||
return s.insights_period_1y;
|
||||
case BodyPeriod.all:
|
||||
return s.insights_period_all;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension DailyPeriodLabel on DailyPeriod {
|
||||
String pillLabel(S s) {
|
||||
switch (this) {
|
||||
case DailyPeriod.week:
|
||||
return s.insights_period_1w;
|
||||
case DailyPeriod.twoWeeks:
|
||||
return s.insights_period_2w;
|
||||
case DailyPeriod.month:
|
||||
return s.insights_period_1m;
|
||||
case DailyPeriod.threeMonths:
|
||||
return s.insights_period_3m;
|
||||
case DailyPeriod.all:
|
||||
return s.insights_period_all;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Пилюли выбора периода (`1М 3М 6М 1Г Всё время`).
|
||||
class PeriodPills<T> extends StatelessWidget {
|
||||
const PeriodPills({
|
||||
super.key,
|
||||
required this.periods,
|
||||
required this.selected,
|
||||
required this.labelOf,
|
||||
required this.keyOf,
|
||||
this.onSelected,
|
||||
});
|
||||
|
||||
final List<T> periods;
|
||||
final T selected;
|
||||
final String Function(T period) labelOf;
|
||||
final String Function(T period) keyOf;
|
||||
|
||||
/// `null` — пилюли неинтерактивны (данные для периода ещё не подключены).
|
||||
final ValueChanged<T>? onSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
for (final period in periods)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 6),
|
||||
child: _Pill(
|
||||
key: Key('insights_period_${keyOf(period)}'),
|
||||
label: labelOf(period),
|
||||
isActive: period == selected,
|
||||
onTap: onSelected == null ? null : () => onSelected!(period),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Pill extends StatelessWidget {
|
||||
const _Pill({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.isActive,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final bool isActive;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: isActive ? InsightsColors.blue : InsightsColors.accentBackground,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 7),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: isActive ? Colors.white : InsightsColors.subtitle,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
76
lib/features/insights/presentation/widgets/series_chips.dart
Normal file
76
lib/features/insights/presentation/widgets/series_chips.dart
Normal file
@@ -0,0 +1,76 @@
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class SeriesChipData {
|
||||
const SeriesChipData({
|
||||
required this.label,
|
||||
required this.color,
|
||||
required this.isVisible,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final Color color;
|
||||
final bool isVisible;
|
||||
}
|
||||
|
||||
/// Чипы-переключатели серий под графиком.
|
||||
class SeriesChips extends StatelessWidget {
|
||||
const SeriesChips({super.key, required this.chips, required this.onToggle});
|
||||
|
||||
final List<SeriesChipData> chips;
|
||||
final ValueChanged<int> onToggle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (var i = 0; i < chips.length; i++)
|
||||
Opacity(
|
||||
opacity: chips[i].isVisible ? 1 : 0.45,
|
||||
child: InkWell(
|
||||
key: Key('insights_series_$i'),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
onTap: () => onToggle(i),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 7,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: InsightsColors.border, width: 1.5),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: chips[i].color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
chips[i].label,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: InsightsColors.title,
|
||||
decoration: chips[i].isVisible
|
||||
? TextDecoration.none
|
||||
: TextDecoration.lineThrough,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
94
lib/features/insights/repositories/insights_repository.dart
Normal file
94
lib/features/insights/repositories/insights_repository.dart
Normal file
@@ -0,0 +1,94 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/features/insights/domain/insights_text_builder.dart';
|
||||
import 'package:eatvio_app/features/insights/models/insights_payload_model.dart';
|
||||
import 'package:eatvio_app/features/insights/models/insights_period.dart';
|
||||
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
|
||||
/// Данные аналитики недоступны текущему пользователю (HTTP 403): консультация
|
||||
/// не активна либо это чужой профиль без доступа.
|
||||
class InsightsForbidden implements Exception {
|
||||
const InsightsForbidden();
|
||||
|
||||
@override
|
||||
String toString() => 'InsightsForbidden';
|
||||
}
|
||||
|
||||
typedef InsightsTextBuilderFactory = InsightsTextBuilder Function();
|
||||
|
||||
/// Итерация 1: собирает [InsightsPayloadModel] из существующих эндпоинтов
|
||||
/// профиля. Во второй итерации источник заменяется одним запросом
|
||||
/// `GET /api/profile/insights`.
|
||||
class InsightsRepository {
|
||||
InsightsRepository({
|
||||
required this.profileRepository,
|
||||
InsightsTextBuilderFactory? textBuilderFactory,
|
||||
}) : _textBuilderFactory =
|
||||
textBuilderFactory ?? (() => InsightsTextBuilder(S.current));
|
||||
|
||||
final ProfileRepository profileRepository;
|
||||
final InsightsTextBuilderFactory _textBuilderFactory;
|
||||
|
||||
/// Рост в сантиметрах для расчёта подсказки ИМТ; `null`, если не указан или
|
||||
/// профиль не удалось загрузить (кроме 403 — он пробрасывается как [InsightsForbidden]).
|
||||
Future<int?> getHeightCm({String? slug}) async {
|
||||
try {
|
||||
final details = slug == null
|
||||
? await profileRepository.getMyProfileDetails()
|
||||
: await profileRepository.getProfileDetailsBySlug(slug);
|
||||
return details.height > 0 ? details.height : null;
|
||||
} on DioException catch (e) {
|
||||
_throwIfForbidden(e);
|
||||
return null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<InsightsPayloadModel> getInsights({
|
||||
String? slug,
|
||||
required BodyPeriod bodyPeriod,
|
||||
int? heightCm,
|
||||
}) async {
|
||||
try {
|
||||
final (weights, circumferences) = await (
|
||||
profileRepository.getWeight(period: bodyPeriod.apiValue, slug: slug),
|
||||
profileRepository.getCircumferences(
|
||||
period: bodyPeriod.apiValue,
|
||||
slug: slug,
|
||||
),
|
||||
).wait;
|
||||
return _textBuilderFactory().build(
|
||||
weights: weights,
|
||||
circumferences: circumferences,
|
||||
period: bodyPeriod,
|
||||
heightCm: heightCm,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
_throwIfForbidden(e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> addWeight({required double weight}) {
|
||||
return profileRepository.addWeight(weight: weight);
|
||||
}
|
||||
|
||||
Future<void> addCircumference({
|
||||
required int chestCircumference,
|
||||
required int waistCircumference,
|
||||
required int hipCircumference,
|
||||
}) {
|
||||
return profileRepository.addCircumference(
|
||||
chestCircumference: chestCircumference,
|
||||
waistCircumference: waistCircumference,
|
||||
hipCircumference: hipCircumference,
|
||||
);
|
||||
}
|
||||
|
||||
void _throwIfForbidden(DioException e) {
|
||||
if (e.response?.statusCode == 403) {
|
||||
throw const InsightsForbidden();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
|
||||
import 'package:eatvio_app/features/menu/presentation/bloc/menu_cubit/menu_state.dart';
|
||||
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -7,7 +8,11 @@ import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||
|
||||
class MenuCubit extends Cubit<MenuState> {
|
||||
final ProfileRepository profileRepository;
|
||||
MenuCubit(this.profileRepository) : super(const MenuState.initial());
|
||||
final SessionLifecycleService sessionLifecycleService;
|
||||
MenuCubit(this.profileRepository, this.sessionLifecycleService)
|
||||
: super(const MenuState.initial());
|
||||
|
||||
Future<void> logout() => sessionLifecycleService.logout();
|
||||
|
||||
Future<void> getMyProfile() async {
|
||||
emit(const MenuState.loading());
|
||||
@@ -23,7 +28,10 @@ class MenuCubit extends Cubit<MenuState> {
|
||||
|
||||
Future<String?> makePassKey() async {
|
||||
try {
|
||||
final passKey = await profileRepository.getPassKey(routeName: 'product_nutritional_value.index', routeParams: {});
|
||||
final passKey = await profileRepository.getPassKey(
|
||||
routeName: 'product_nutritional_value.index',
|
||||
routeParams: {},
|
||||
);
|
||||
return passKey;
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
|
||||
import 'package:eatvio_app/core/const.dart';
|
||||
import 'package:eatvio_app/core/firebase_api.dart';
|
||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||
import 'package:eatvio_app/core/token_storage.dart';
|
||||
import 'package:eatvio_app/core/utils/image_constant.dart';
|
||||
import 'package:eatvio_app/core/utils/date_time_utils.dart';
|
||||
import 'package:eatvio_app/core/widgets/data_error_widget.dart';
|
||||
import 'package:eatvio_app/core/widgets/loader.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/core/websockets/ws_manager.dart';
|
||||
import 'package:eatvio_app/features/consultant_rating/presentation/consultant_rating_screen.dart';
|
||||
import 'package:eatvio_app/features/finances_and_consultations_archive/presentation/consultant_finances_screen.dart';
|
||||
import 'package:eatvio_app/features/finances_and_consultations_archive/presentation/user_purchases_screen.dart';
|
||||
import 'package:eatvio_app/features/menu/presentation/bloc/menu_cubit/menu_cubit.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
|
||||
import 'package:eatvio_app/features/menu/presentation/bloc/menu_cubit/menu_state.dart';
|
||||
import 'package:eatvio_app/features/my_diets/presentation/my_diets_screen.dart';
|
||||
import 'package:eatvio_app/features/my_plans/presentation/my_plans_screen.dart';
|
||||
@@ -155,6 +148,7 @@ class MenuScreen extends StatelessWidget {
|
||||
|
||||
Future<Null> Function() _onTapLogout(BuildContext context) {
|
||||
return () async {
|
||||
final menuCubit = context.read<MenuCubit>();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => CupertinoAlertDialog(
|
||||
@@ -162,24 +156,8 @@ class MenuScreen extends StatelessWidget {
|
||||
actions: [
|
||||
CupertinoDialogAction(
|
||||
onPressed: () async {
|
||||
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();
|
||||
if (context.mounted) {
|
||||
GoRouter.of(context).go('/auth-screen', extra: true);
|
||||
}
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
await menuCubit.logout();
|
||||
},
|
||||
isDestructiveAction: true,
|
||||
child: const Text("Выйти"),
|
||||
|
||||
@@ -16,6 +16,11 @@ class NotificationsCubit extends Cubit<NotificationsState> {
|
||||
StreamSubscription? _newNotificationsSubscription;
|
||||
NotificationsCubit({required this.wsManager, required this.notificationsRepository}) : super(const NotificationsState.empty());
|
||||
|
||||
void reset() {
|
||||
_notifications.clear();
|
||||
if (!isClosed) emit(const NotificationsState.empty());
|
||||
}
|
||||
|
||||
Future<void> connectToWebSocket() async {
|
||||
try {
|
||||
await _newNotificationsSubscription?.cancel();
|
||||
|
||||
@@ -14,6 +14,7 @@ import 'package:flutter/material.dart';
|
||||
Future<void> showConsultationPaymentFlow(
|
||||
BuildContext context, {
|
||||
required int planId,
|
||||
double? amount,
|
||||
Future<bool?> Function(BuildContext)? showGate,
|
||||
Future<PromoCodeResult?> Function(
|
||||
BuildContext, {
|
||||
@@ -21,7 +22,7 @@ Future<void> showConsultationPaymentFlow(
|
||||
Future<bool> Function()? onRemove,
|
||||
PromoCodeModel? initialPromo,
|
||||
})? showForm,
|
||||
Future<void> Function(BuildContext, {required int planId})? showSbp,
|
||||
Future<void> Function(BuildContext, {required int planId, double? amount})? showSbp,
|
||||
Future<PromoValidateResponse> Function({
|
||||
required int planId,
|
||||
required String promoCode,
|
||||
@@ -65,7 +66,7 @@ Future<void> showConsultationPaymentFlow(
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
await sbp(context, planId: planId);
|
||||
await sbp(context, planId: planId, amount: amount);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -92,7 +93,7 @@ Future<void> showConsultationPaymentFlow(
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
await sbp(context, planId: planId);
|
||||
await sbp(context, planId: planId, amount: amount);
|
||||
}
|
||||
|
||||
Future<bool?> _showGate(BuildContext context) {
|
||||
@@ -113,8 +114,8 @@ Future<PromoCodeResult?> _showForm(
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showSbp(BuildContext context, {required int planId}) {
|
||||
return showSbpBanksBottomSheet(context, planId: planId);
|
||||
Future<void> _showSbp(BuildContext context, {required int planId, double? amount}) {
|
||||
return showSbpBanksBottomSheet(context, planId: planId, amount: amount);
|
||||
}
|
||||
|
||||
Future<PromoValidateResponse> _defaultValidatePromo({
|
||||
|
||||
@@ -8,18 +8,23 @@ import 'package:eatvio_app/core/widgets/custom_outlined_button.dart';
|
||||
import 'package:eatvio_app/core/widgets/loader.dart';
|
||||
import 'package:eatvio_app/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_cubit.dart';
|
||||
import 'package:eatvio_app/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_state.dart';
|
||||
import 'package:eatvio_app/features/payment/presentation/widgets/sbp_qr_bottom_sheet.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:sbp/models/c2bmembers_model.dart';
|
||||
|
||||
/// Показывает bottom sheet выбора банка СБП для оплаты консультации.
|
||||
Future<void> showSbpBanksBottomSheet(BuildContext context, {required int planId}) {
|
||||
Future<void> showSbpBanksBottomSheet(
|
||||
BuildContext context, {
|
||||
required int planId,
|
||||
double? amount,
|
||||
}) {
|
||||
final height = MediaQuery.sizeOf(context).height * 0.55;
|
||||
return showBottomSheetWithBlur(
|
||||
context: context,
|
||||
height: height,
|
||||
child: SbpBanksBottomSheet(planId: planId),
|
||||
child: SbpBanksBottomSheet(planId: planId, amount: amount),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,9 +33,13 @@ Future<void> showSbpBanksBottomSheet(BuildContext context, {required int planId}
|
||||
/// При открытии вызывает [ConsultationPaymentCubit.startPayment] с [planId]
|
||||
/// и показывает [Loader], пока загружаются checkout_url и список банков.
|
||||
class SbpBanksBottomSheet extends StatefulWidget {
|
||||
const SbpBanksBottomSheet({super.key, required this.planId, this.cubit});
|
||||
const SbpBanksBottomSheet({super.key, required this.planId, this.amount, this.cubit});
|
||||
|
||||
final int planId;
|
||||
|
||||
/// Сумма к оплате в рублях — передаётся в QR-шит для отображения.
|
||||
final double? amount;
|
||||
|
||||
final ConsultationPaymentCubit? cubit;
|
||||
|
||||
@override
|
||||
@@ -77,6 +86,32 @@ class _SbpBanksBottomSheetState extends State<SbpBanksBottomSheet> {
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
// QR-кнопка доступна сразу после получения checkout_url,
|
||||
// не дожидаясь списка банков: на устройствах без банковских
|
||||
// приложений (iOS в РФ) это единственный путь оплаты.
|
||||
BlocBuilder<ConsultationPaymentCubit, ConsultationPaymentState>(
|
||||
buildWhen: (previous, current) =>
|
||||
previous.checkoutUrl != current.checkoutUrl,
|
||||
builder: (context, state) {
|
||||
final checkoutUrl = state.checkoutUrl;
|
||||
if (checkoutUrl == null || checkoutUrl.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
child: CustomOutlinedButton(
|
||||
text: s.pay_by_qr_code,
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
||||
),
|
||||
onPressed: _onShowQrTap,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: BlocBuilder<ConsultationPaymentCubit, ConsultationPaymentState>(
|
||||
@@ -122,6 +157,23 @@ class _SbpBanksBottomSheetState extends State<SbpBanksBottomSheet> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Открывает QR-шит поверх шита банков; при подтверждённой оплате
|
||||
/// закрывает и родительский шит.
|
||||
Future<void> _onShowQrTap() async {
|
||||
final paid = await showSbpQrBottomSheet(
|
||||
context,
|
||||
planId: widget.planId,
|
||||
cubit: _cubit,
|
||||
amount: widget.amount,
|
||||
);
|
||||
if (paid == true && mounted) {
|
||||
final navigator = Navigator.of(context);
|
||||
if (navigator.canPop()) {
|
||||
navigator.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onBankTap(C2bmemberModel bank) async {
|
||||
final opened = await _cubit.openBank(bank: bank);
|
||||
if (opened && mounted) {
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/core/widgets/bottom_sheet_handler_bar.dart';
|
||||
import 'package:eatvio_app/core/widgets/bottom_sheet_with_blur.dart';
|
||||
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
|
||||
import 'package:eatvio_app/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_cubit.dart';
|
||||
import 'package:eatvio_app/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_state.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
/// Тестовый симулятор оплаты СБП (только для стендов разработки/stage).
|
||||
const String _testPayUrlBase = 'https://testpay.alfabank.ru/sbp-page?qrcId=';
|
||||
const String _nspkUrlPrefix = 'https://qr.nspk.ru/';
|
||||
const Duration _pollInterval = Duration(seconds: 3);
|
||||
const Duration _autoCloseDelay = Duration(seconds: 2);
|
||||
|
||||
/// Показывает bottom sheet с QR-кодом СБП для оплаты консультации
|
||||
/// с другого устройства.
|
||||
///
|
||||
/// Возвращает `true`, если оплата подтверждена polling-ом статуса плана.
|
||||
Future<bool?> showSbpQrBottomSheet(
|
||||
BuildContext context, {
|
||||
required int planId,
|
||||
required ConsultationPaymentCubit cubit,
|
||||
double? amount,
|
||||
}) {
|
||||
return showBottomSheetWithBlur<bool>(
|
||||
context: context,
|
||||
height: null,
|
||||
child: SbpQrBottomSheet(
|
||||
planId: planId,
|
||||
cubit: cubit,
|
||||
amount: amount,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// QR-код оплаты СБП: рендерит [ConsultationPaymentState.checkoutUrl]
|
||||
/// (payload вида `https://qr.nspk.ru/{qrcId}`), ожидает оплату через
|
||||
/// polling статуса chosen_plan и показывает успех.
|
||||
class SbpQrBottomSheet extends StatefulWidget {
|
||||
const SbpQrBottomSheet({
|
||||
super.key,
|
||||
required this.planId,
|
||||
required this.cubit,
|
||||
this.amount,
|
||||
this.chatRepository,
|
||||
this.pollInterval = _pollInterval,
|
||||
});
|
||||
|
||||
final int planId;
|
||||
final ConsultationPaymentCubit cubit;
|
||||
|
||||
/// Сумма к оплате в рублях (показывается над QR, если передана).
|
||||
final double? amount;
|
||||
|
||||
/// Переопределяется в тестах; по умолчанию берётся из DI.
|
||||
final ChatRepository? chatRepository;
|
||||
|
||||
final Duration pollInterval;
|
||||
|
||||
@override
|
||||
State<SbpQrBottomSheet> createState() => _SbpQrBottomSheetState();
|
||||
}
|
||||
|
||||
class _SbpQrBottomSheetState extends State<SbpQrBottomSheet> {
|
||||
late final ChatRepository _chatRepository;
|
||||
late final Timer _pollTimer;
|
||||
bool _paid = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_chatRepository = widget.chatRepository ?? sl<ChatRepository>();
|
||||
_pollTimer = Timer.periodic(widget.pollInterval, (_) => _pollStatus());
|
||||
_pollStatus();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pollTimer.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _pollStatus() async {
|
||||
if (_paid || !mounted) return;
|
||||
try {
|
||||
final plan = await _chatRepository.getChosenPlan(id: widget.planId);
|
||||
if (plan.chosenPlanState.isStateAfterPaidState()) {
|
||||
_onPaid();
|
||||
}
|
||||
} catch (_) {
|
||||
// Сетевые сбои polling-а игнорируем — следующий тик повторит проверку.
|
||||
}
|
||||
}
|
||||
|
||||
void _onPaid() {
|
||||
if (_paid || !mounted) return;
|
||||
setState(() => _paid = true);
|
||||
Future.delayed(_autoCloseDelay, () {
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop(true);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
return BlocProvider<ConsultationPaymentCubit>.value(
|
||||
value: widget.cubit,
|
||||
child: BlocBuilder<ConsultationPaymentCubit, ConsultationPaymentState>(
|
||||
builder: (context, state) {
|
||||
final checkoutUrl = state.checkoutUrl;
|
||||
if (checkoutUrl == null || checkoutUrl.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
const BottomSheetHandlerBar(),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
_paid ? s.sbp_qr_paid_title : s.sbp_qr_title,
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (_paid)
|
||||
_PaidContent(description: s.sbp_qr_paid_description)
|
||||
else ...[
|
||||
if (widget.amount != null) ...[
|
||||
Text(
|
||||
s.sbp_qr_amount_label,
|
||||
style: TextStyles.textMMedium.copyWith(color: appTheme.gray600),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${widget.amount!.toStringAsFixed(0)} ₽',
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
_QrCodeCard(checkoutUrl: checkoutUrl),
|
||||
const SizedBox(height: 16),
|
||||
_HowToPay(
|
||||
step1: s.sbp_qr_step1,
|
||||
step2: s.sbp_qr_step2,
|
||||
step3: s.sbp_qr_step3,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_TestPayButton(checkoutUrl: checkoutUrl),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
s.sbp_qr_waiting_payment,
|
||||
style: TextStyles.textMMedium.copyWith(color: appTheme.gray600),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _QrCodeCard extends StatelessWidget {
|
||||
const _QrCodeCard({required this.checkoutUrl});
|
||||
|
||||
final String checkoutUrl;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: appTheme.blueGray300),
|
||||
),
|
||||
child: QrImageView(
|
||||
key: const ValueKey('sbp_qr_code'),
|
||||
data: checkoutUrl,
|
||||
size: 200,
|
||||
backgroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HowToPay extends StatelessWidget {
|
||||
const _HowToPay({required this.step1, required this.step2, required this.step3});
|
||||
|
||||
final String step1;
|
||||
final String step2;
|
||||
final String step3;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: appTheme.blueA400.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(step1, style: TextStyles.textSMedium.copyWith(color: appTheme.black)),
|
||||
const SizedBox(height: 4),
|
||||
Text(step2, style: TextStyles.textSMedium.copyWith(color: appTheme.black)),
|
||||
const SizedBox(height: 4),
|
||||
Text(step3, style: TextStyles.textSMedium.copyWith(color: appTheme.black)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Кнопка открытия тестового симулятора оплаты Альфа-Банка.
|
||||
///
|
||||
/// Видна только когда QR выдан непродовым бэкендом: тестовый qrcId от
|
||||
/// продового в приложении не отличить (формат одинаковый), различает их
|
||||
/// именно то, какой бэкенд выдал checkout_url — на стендах разработки/stage
|
||||
/// Альфа подключена в sandbox и выдаёт QR тестового терминала. Поэтому
|
||||
/// критерий — текущий base URL API не продуктовый (см. [AppConfig.isProdApi]).
|
||||
class _TestPayButton extends StatelessWidget {
|
||||
const _TestPayButton({required this.checkoutUrl});
|
||||
|
||||
final String checkoutUrl;
|
||||
|
||||
bool get _visible {
|
||||
if (sl<AppConfig>().isProdApi) return false;
|
||||
return checkoutUrl.startsWith(_nspkUrlPrefix);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_visible) return const SizedBox.shrink();
|
||||
final s = S.of(context);
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF3E0),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
s.sbp_qr_test_hint,
|
||||
style: TextStyles.textXSSemibold.copyWith(color: appTheme.gray600),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton(
|
||||
onPressed: () => _openTestPayment(),
|
||||
child: Text(s.sbp_qr_open_test_payment),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openTestPayment() async {
|
||||
final qrcId = checkoutUrl.replaceFirst(_nspkUrlPrefix, '');
|
||||
final uri = Uri.parse('$_testPayUrlBase$qrcId');
|
||||
try {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
} catch (_) {
|
||||
// Не смогли открыть браузер — пользователь оплатит по QR.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _PaidContent extends StatelessWidget {
|
||||
const _PaidContent({required this.description});
|
||||
|
||||
final String description;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: const BoxDecoration(color: Color(0xFFE8F5E9), shape: BoxShape.circle),
|
||||
child: const Icon(Icons.check_rounded, color: Color(0xFF4CAF50), size: 40),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
description,
|
||||
style: TextStyles.textMMedium.copyWith(color: appTheme.gray600),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,9 @@ abstract class CircumferenceModel with _$CircumferenceModel {
|
||||
const factory CircumferenceModel({
|
||||
@JsonKey(name: "id") required int id,
|
||||
@JsonKey(name: "user_id") required int userId,
|
||||
@JsonKey(name: "chest_circumference") required int chestCircumference,
|
||||
@JsonKey(name: "waist_circumference") required int waistCircumference,
|
||||
@JsonKey(name: "hip_circumference") required int hipCircumference,
|
||||
@JsonKey(name: "chest_circumference") required double chestCircumference,
|
||||
@JsonKey(name: "waist_circumference") required double waistCircumference,
|
||||
@JsonKey(name: "hip_circumference") required double hipCircumference,
|
||||
@JsonKey(name: "created_at") required DateTime createdAt,
|
||||
@JsonKey(name: "updated_at") required DateTime updatedAt,
|
||||
}) = _CircumferenceModel;
|
||||
|
||||
@@ -9,10 +9,10 @@ abstract class WeightModel with _$WeightModel {
|
||||
const factory WeightModel({
|
||||
@JsonKey(name: "id") required int id,
|
||||
@JsonKey(name: "user_id") required int userId,
|
||||
@JsonKey(name: "weight") required int weight,
|
||||
@JsonKey(name: "weight") required double weight,
|
||||
@JsonKey(name: "created_at") required DateTime createdAt,
|
||||
@JsonKey(name: "updated_at") required DateTime updatedAt,
|
||||
@JsonKey(name: "bmi") required double bmi,
|
||||
@JsonKey(name: "bmi") double? bmi,
|
||||
}) = _WeightModel;
|
||||
|
||||
factory WeightModel.fromJson(Map<String, dynamic> json) => _$WeightModelFromJson(json);
|
||||
|
||||
@@ -17,11 +17,14 @@ class ProfileCubit extends Cubit<ProfileState> {
|
||||
final ProfileRepository profileRepository;
|
||||
final ChatRepository chatRepository;
|
||||
final ConsultantRepository consultantRepository;
|
||||
String _currentPeriod = '1';
|
||||
|
||||
/// Bottom-nav / own profile instance, used to refresh posts after create/update/delete.
|
||||
static ProfileCubit? myProfileCubit;
|
||||
|
||||
static void clearMyProfileReference() {
|
||||
myProfileCubit = null;
|
||||
}
|
||||
|
||||
ProfileCubit({required this.profileRepository, required this.chatRepository, required this.consultantRepository}) : super(const ProfileState.loading());
|
||||
|
||||
Future<void> getProfileBySlug(String slug) async {
|
||||
@@ -51,10 +54,9 @@ class ProfileCubit extends Cubit<ProfileState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> getMyProfile({String period = '1'}) async {
|
||||
Future<void> getMyProfile() async {
|
||||
myProfileCubit = this;
|
||||
emit(const ProfileState.loading());
|
||||
_currentPeriod = period;
|
||||
try {
|
||||
final profile = await profileRepository.getMyProfile();
|
||||
List<ConsultantCertPhotoModel> certPhotos = await consultantRepository.getConsultantCertPhotos();
|
||||
@@ -65,8 +67,6 @@ class ProfileCubit extends Cubit<ProfileState> {
|
||||
isMyProfile: true,
|
||||
profileDetails: await profileRepository.getMyProfileDetails(),
|
||||
blogPosts: await profileRepository.getBlogPostsByAuthorSlug(slug: profile.slug),
|
||||
weightPoints: await profileRepository.getWeight(period: period),
|
||||
circumferencePoints: await profileRepository.getCircumferences(period: period),
|
||||
consultantInfo: profile.roles.contains('consultant') ? await consultantRepository.getConsultantRequestStatus(profile.slug) : null,
|
||||
certPhotos: certPhotos,
|
||||
),
|
||||
@@ -106,8 +106,6 @@ class ProfileCubit extends Cubit<ProfileState> {
|
||||
profile: (state as ProfileStateLoaded).profile,
|
||||
profileDetails: (state as ProfileStateLoaded).profileDetails,
|
||||
blogPosts: feedBlogPostList,
|
||||
weightPoints: (state as ProfileStateLoaded).weightPoints,
|
||||
circumferencePoints: (state as ProfileStateLoaded).circumferencePoints,
|
||||
consultantInfo: (state as ProfileStateLoaded).consultantInfo,
|
||||
isMyProfile: (state as ProfileStateLoaded).isMyProfile,
|
||||
certPhotos: (state as ProfileStateLoaded).certPhotos,
|
||||
@@ -122,7 +120,6 @@ class ProfileCubit extends Cubit<ProfileState> {
|
||||
waistCircumference: waistCircumference,
|
||||
hipCircumference: hipCircumference,
|
||||
);
|
||||
await getMyProfile(period: _currentPeriod);
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
emit(ProfileState.error(errorMessage: e.toString()));
|
||||
@@ -132,7 +129,6 @@ class ProfileCubit extends Cubit<ProfileState> {
|
||||
Future<void> addWeight({required double weight}) async {
|
||||
try {
|
||||
await profileRepository.addWeight(weight: weight);
|
||||
await getMyProfile(period: _currentPeriod);
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
emit(ProfileState.error(errorMessage: e.toString()));
|
||||
@@ -141,13 +137,13 @@ class ProfileCubit extends Cubit<ProfileState> {
|
||||
|
||||
Future<String?> getChatRoomIdBySlug({required String slug}) async {
|
||||
try {
|
||||
final chatIdOrError = await chatRepository.getChatByUserSlug(slug);
|
||||
final userProfile = await profileRepository.getProfileBySlug(slug);
|
||||
if (chatIdOrError == '404') {
|
||||
final chatId = await chatRepository.getChatByUserSlug(slug);
|
||||
if (chatId == null || chatId.isEmpty) {
|
||||
final userProfile = await profileRepository.getProfileBySlug(slug);
|
||||
AppRouter.navigatorKey.currentContext?.pushNamed('emptyChatScreen', extra: userProfile);
|
||||
} else {
|
||||
AppRouter.navigatorKey.currentContext?.pushNamed('chatScreen', extra: {'uuid': chatIdOrError});
|
||||
return chatIdOrError;
|
||||
AppRouter.navigatorKey.currentContext?.pushNamed('chatScreen', extra: {'uuid': chatId});
|
||||
return chatId;
|
||||
}
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
||||
import 'package:eatvio_app/features/profile/models/circumference_model.dart';
|
||||
import 'package:eatvio_app/features/profile/models/profile_model.dart';
|
||||
import 'package:eatvio_app/features/profile/models/weight_model.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'profile_state.freezed.dart';
|
||||
@@ -20,8 +18,6 @@ abstract class ProfileState with _$ProfileState {
|
||||
required List<FeedBlogPostModel> blogPosts,
|
||||
required bool isMyProfile,
|
||||
required List<ConsultantCertPhotoModel> certPhotos,
|
||||
List<WeightModel>? weightPoints,
|
||||
List<CircumferenceModel>? circumferencePoints,
|
||||
ConsultationRequestModel? consultantInfo,
|
||||
}) = ProfileStateLoaded;
|
||||
|
||||
|
||||
@@ -18,21 +18,17 @@ import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/prof
|
||||
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart';
|
||||
import 'package:eatvio_app/features/complaining/presentation/complaining_bottom_sheet.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/widgets/plan_widget.dart';
|
||||
import 'package:eatvio_app/features/insights/presentation/widgets/insights_card.dart';
|
||||
import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_cubit.dart';
|
||||
import 'package:eatvio_app/features/profile/models/circumference_model.dart';
|
||||
import 'package:eatvio_app/features/profile/models/weight_model.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/plans_cubit/plans_cubit.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/plans_cubit/plans_state.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_state.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/sub_cubit/sub_cubit.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/pages/subs_screen.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/widgets/circumference_chart_widget.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/widgets/expandable_page_view.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/widgets/imt_chart_widget.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/widgets/profile_blog_post_widget.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/widgets/toggle_cert_photo_bottom_sheet.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/widgets/weight_chart_widget.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
@@ -60,21 +56,8 @@ class ProfileScreen extends StatefulWidget {
|
||||
class _ProfileScreenState extends State<ProfileScreen> {
|
||||
late final ProfileCubit profileCubit = sl<ProfileCubit>();
|
||||
final PlansCubit plansCubit = sl<PlansCubit>();
|
||||
final List<String> chipsLabelList = ['Вес', 'Замеры', 'ИМТ'];
|
||||
final List<String> chipsIntervalLabelList = [
|
||||
'1М',
|
||||
'3М',
|
||||
'6М',
|
||||
'1Г',
|
||||
'ВСЁ ВРЕМЯ',
|
||||
];
|
||||
final List<String> chipsIntervalList = ['1', '3', '6', '12', 'all'];
|
||||
final TextEditingController controller = TextEditingController();
|
||||
|
||||
List<WeightModel> weightPoints = [];
|
||||
List<CircumferenceModel> circumferencePoints = [];
|
||||
int _selectedChipIndex = 0;
|
||||
int _selectedIntervalChipIndex = 0;
|
||||
late bool canSub;
|
||||
bool isFirstFetch = true;
|
||||
int _currentBullet = 0;
|
||||
@@ -96,10 +79,6 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
||||
bloc: profileCubit,
|
||||
builder: (BuildContext context, ProfileState state) {
|
||||
if (state is ProfileStateLoaded) {
|
||||
if (state.circumferencePoints != null) {
|
||||
circumferencePoints = state.circumferencePoints!;
|
||||
weightPoints = state.weightPoints!;
|
||||
}
|
||||
if (isFirstFetch) {
|
||||
canSub = !state.profile.isEntitySubscribedByMe;
|
||||
if (state.profile.roles.contains('consultant') && !hidePlans) {
|
||||
@@ -442,10 +421,11 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
||||
child: _badges(state),
|
||||
),
|
||||
if (state.profile.roles.contains('consultant')) _socialLinks(state),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: _charts(),
|
||||
),
|
||||
if (state.isMyProfile)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(bottom: 16),
|
||||
child: InsightsCard(canAdd: true),
|
||||
),
|
||||
if (state.profile.roles.contains('consultant')) _education(state),
|
||||
if (state.profile.roles.contains('consultant'))
|
||||
_diplomasAndCertificates(state),
|
||||
@@ -1722,114 +1702,4 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Material _charts() {
|
||||
return Material(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
_choiceChipWidget(0),
|
||||
const SizedBox(width: 8),
|
||||
_choiceChipWidget(1),
|
||||
const SizedBox(width: 8),
|
||||
_choiceChipWidget(2),
|
||||
],
|
||||
),
|
||||
),
|
||||
Builder(
|
||||
builder: (context) {
|
||||
switch (_selectedChipIndex) {
|
||||
case 0:
|
||||
return WeightChartWidget(
|
||||
weightPoints: weightPoints,
|
||||
profileCubit: profileCubit,
|
||||
);
|
||||
case 1:
|
||||
return CircumferenceChartWidget(
|
||||
circumferencePoints: circumferencePoints,
|
||||
profileCubit: profileCubit,
|
||||
);
|
||||
case 2:
|
||||
return ImtChartWidget(weightPoints: weightPoints);
|
||||
default:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
const SizedBox(width: 16),
|
||||
_choiceIntervalChipWidget(0),
|
||||
const SizedBox(width: 8),
|
||||
_choiceIntervalChipWidget(1),
|
||||
const SizedBox(width: 8),
|
||||
_choiceIntervalChipWidget(2),
|
||||
const SizedBox(width: 8),
|
||||
_choiceIntervalChipWidget(3),
|
||||
const SizedBox(width: 8),
|
||||
_choiceIntervalChipWidget(4),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
ChoiceChip _choiceIntervalChipWidget(int index) {
|
||||
return ChoiceChip(
|
||||
selectedColor: Colors.grey.withValues(alpha: 0.1),
|
||||
pressElevation: 0,
|
||||
showCheckmark: false,
|
||||
side: BorderSide.none,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
label: Text(chipsIntervalLabelList[index]),
|
||||
labelStyle: TextStyle(
|
||||
color: appTheme.gray600,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 12,
|
||||
),
|
||||
selected: _selectedIntervalChipIndex == index,
|
||||
onSelected: (bool selected) {
|
||||
profileCubit.getMyProfile(period: chipsIntervalList[index]);
|
||||
setState(() {
|
||||
_selectedIntervalChipIndex = index;
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
ChoiceChip _choiceChipWidget(int index) {
|
||||
return ChoiceChip(
|
||||
selectedColor: appTheme.blueA400,
|
||||
pressElevation: 0,
|
||||
showCheckmark: false,
|
||||
side: BorderSide.none,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
label: Text(chipsLabelList[index]),
|
||||
labelStyle: TextStyle(
|
||||
color: index == _selectedChipIndex ? Colors.white : appTheme.gray600,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 12,
|
||||
),
|
||||
selected: _selectedChipIndex == index,
|
||||
backgroundColor: appTheme.gray100,
|
||||
onSelected: (bool selected) {
|
||||
setState(() {
|
||||
_selectedChipIndex = index;
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,389 +0,0 @@
|
||||
import 'package:eatvio_app/features/profile/models/circumference_model.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||
|
||||
class CircumferenceChartWidget extends StatefulWidget {
|
||||
final List<CircumferenceModel> circumferencePoints;
|
||||
final ProfileCubit? profileCubit;
|
||||
|
||||
const CircumferenceChartWidget({
|
||||
super.key,
|
||||
required this.circumferencePoints,
|
||||
this.profileCubit,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CircumferenceChartWidget> createState() => _CircumferenceChartWidgetState();
|
||||
}
|
||||
|
||||
class _CircumferenceChartWidgetState extends State<CircumferenceChartWidget> {
|
||||
late FixedExtentScrollController chestController;
|
||||
late FixedExtentScrollController waistController;
|
||||
late FixedExtentScrollController hipController;
|
||||
late int chest;
|
||||
late int waist;
|
||||
late int hip;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
if (widget.circumferencePoints.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'${widget.circumferencePoints.last.chestCircumference}',
|
||||
style: const TextStyle(
|
||||
color: Colors.black,
|
||||
fontSize: 40,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (widget.circumferencePoints.length == 1)
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12),
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.black12,
|
||||
),
|
||||
),
|
||||
if (widget.circumferencePoints.length > 1)
|
||||
widget.circumferencePoints.last.chestCircumference > widget.circumferencePoints[widget.circumferencePoints.length - 2].chestCircumference
|
||||
? const Icon(
|
||||
Icons.arrow_drop_up,
|
||||
size: 40,
|
||||
color: Colors.grey,
|
||||
)
|
||||
: const Icon(
|
||||
Icons.arrow_drop_down,
|
||||
size: 40,
|
||||
color: Colors.grey,
|
||||
),
|
||||
Text(
|
||||
'${widget.circumferencePoints.last.waistCircumference}',
|
||||
style: const TextStyle(
|
||||
color: Colors.black,
|
||||
fontSize: 40,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (widget.circumferencePoints.length == 1)
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12),
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.black12,
|
||||
),
|
||||
),
|
||||
if (widget.circumferencePoints.length > 1)
|
||||
widget.circumferencePoints.last.waistCircumference > widget.circumferencePoints[widget.circumferencePoints.length - 2].waistCircumference
|
||||
? const Icon(
|
||||
Icons.arrow_drop_up,
|
||||
size: 40,
|
||||
color: Colors.grey,
|
||||
)
|
||||
: const Icon(
|
||||
Icons.arrow_drop_down,
|
||||
size: 40,
|
||||
color: Colors.grey,
|
||||
),
|
||||
Text(
|
||||
'${widget.circumferencePoints.last.hipCircumference}',
|
||||
style: const TextStyle(
|
||||
color: Colors.black,
|
||||
fontSize: 40,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (widget.circumferencePoints.length == 1)
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12),
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.black12,
|
||||
),
|
||||
),
|
||||
if (widget.circumferencePoints.length > 1)
|
||||
widget.circumferencePoints.last.hipCircumference > widget.circumferencePoints[widget.circumferencePoints.length - 2].hipCircumference
|
||||
? const Icon(
|
||||
Icons.arrow_drop_up,
|
||||
size: 40,
|
||||
color: Colors.grey,
|
||||
)
|
||||
: const Icon(
|
||||
Icons.arrow_drop_down,
|
||||
size: 40,
|
||||
color: Colors.grey,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
SfCartesianChart(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||||
enableAxisAnimation: true,
|
||||
plotAreaBorderWidth: 0,
|
||||
trackballBehavior: TrackballBehavior(
|
||||
enable: true,
|
||||
activationMode: ActivationMode.singleTap,
|
||||
tooltipDisplayMode: TrackballDisplayMode.groupAllPoints,
|
||||
tooltipSettings: const InteractiveTooltip(),
|
||||
lineType: TrackballLineType.vertical,
|
||||
),
|
||||
primaryXAxis: DateTimeCategoryAxis(
|
||||
tickPosition: TickPosition.inside,
|
||||
majorTickLines: const MajorTickLines(
|
||||
size: 0,
|
||||
),
|
||||
edgeLabelPlacement: EdgeLabelPlacement.shift,
|
||||
dateFormat: DateFormat.MMMd('ru'),
|
||||
labelStyle: const TextStyle(
|
||||
color: Colors.grey,
|
||||
),
|
||||
majorGridLines: MajorGridLines(dashArray: const [1], color: Colors.grey.withValues(alpha: 0.3)),
|
||||
axisLine: AxisLine(
|
||||
color: Colors.grey.withValues(alpha: 0.3),
|
||||
width: 0.4,
|
||||
),
|
||||
),
|
||||
primaryYAxis: const NumericAxis(
|
||||
isVisible: false,
|
||||
labelFormat: '{value} см',
|
||||
axisLine: AxisLine(
|
||||
width: 1,
|
||||
),
|
||||
majorTickLines: MajorTickLines(size: 0),
|
||||
),
|
||||
series: _getSplineSeries(),
|
||||
tooltipBehavior: TooltipBehavior(enable: true),
|
||||
),
|
||||
if (widget.profileCubit != null)
|
||||
Positioned(
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
child: FloatingActionButton.small(
|
||||
heroTag: 'addCircumference',
|
||||
onPressed: () {
|
||||
if (widget.circumferencePoints.isNotEmpty) {
|
||||
chest = widget.circumferencePoints.last.chestCircumference;
|
||||
waist = widget.circumferencePoints.last.waistCircumference;
|
||||
hip = widget.circumferencePoints.last.hipCircumference;
|
||||
chestController = FixedExtentScrollController(initialItem: chest - 20);
|
||||
waistController = FixedExtentScrollController(initialItem: waist - 20);
|
||||
hipController = FixedExtentScrollController(initialItem: hip - 20);
|
||||
} else {
|
||||
chestController = FixedExtentScrollController();
|
||||
waistController = FixedExtentScrollController();
|
||||
hipController = FixedExtentScrollController();
|
||||
}
|
||||
showModalBottomSheet(
|
||||
useRootNavigator: true,
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: CupertinoPicker(
|
||||
magnification: 1.22,
|
||||
squeeze: 1.2,
|
||||
useMagnifier: true,
|
||||
scrollController: chestController,
|
||||
itemExtent: 24,
|
||||
onSelectedItemChanged: (int value) {
|
||||
chest = value;
|
||||
},
|
||||
children: [
|
||||
for (int i = 20; i < 200; i++) Text('$i'),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: CupertinoPicker(
|
||||
magnification: 1.22,
|
||||
squeeze: 1.2,
|
||||
useMagnifier: true,
|
||||
scrollController: waistController,
|
||||
itemExtent: 24,
|
||||
onSelectedItemChanged: (int value) {
|
||||
waist = value;
|
||||
},
|
||||
children: [
|
||||
for (int i = 20; i < 200; i++) Text('$i'),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: CupertinoPicker(
|
||||
magnification: 1.22,
|
||||
squeeze: 1.2,
|
||||
useMagnifier: true,
|
||||
scrollController: hipController,
|
||||
itemExtent: 24,
|
||||
onSelectedItemChanged: (int value) {
|
||||
hip = value;
|
||||
},
|
||||
children: [
|
||||
for (int i = 20; i < 200; i++) Text('$i'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
widget.profileCubit?.addCircumference(
|
||||
chestCircumference: chest + 20,
|
||||
waistCircumference: waist + 20,
|
||||
hipCircumference: hip + 20,
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Ink(
|
||||
width: MediaQuery.of(context).size.width - 32,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: appTheme.blueA400,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'Сохранить',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
backgroundColor: appTheme.blueA400,
|
||||
shape: const CircleBorder(),
|
||||
child: const Icon(
|
||||
Icons.add,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.circumferencePoints.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 48),
|
||||
child: Text(
|
||||
'Добавьте Ваши замеры',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<CartesianSeries<CircumferenceModel, DateTime>> _getSplineSeries() {
|
||||
const Color seriesColor1 = Colors.red;
|
||||
final Color seriesColor2 = appTheme.blueA400;
|
||||
const Color seriesColor3 = Colors.green;
|
||||
|
||||
return <CartesianSeries<CircumferenceModel, DateTime>>[
|
||||
SplineAreaSeries<CircumferenceModel, DateTime>(
|
||||
markerSettings: const MarkerSettings(
|
||||
isVisible: true,
|
||||
color: seriesColor1,
|
||||
height: 8,
|
||||
width: 8,
|
||||
),
|
||||
dataLabelSettings: const DataLabelSettings(
|
||||
labelAlignment: ChartDataLabelAlignment.top,
|
||||
isVisible: true,
|
||||
),
|
||||
dataSource: widget.circumferencePoints,
|
||||
borderColor: seriesColor1,
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
seriesColor1.withValues(alpha: 0.14),
|
||||
Colors.transparent,
|
||||
],
|
||||
begin: Alignment.topCenter,
|
||||
end: const Alignment(0, 0.6),
|
||||
),
|
||||
name: 'Грудь',
|
||||
xValueMapper: (CircumferenceModel value, int index) => value.createdAt,
|
||||
yValueMapper: (CircumferenceModel value, int index) => value.chestCircumference,
|
||||
),
|
||||
SplineAreaSeries<CircumferenceModel, DateTime>(
|
||||
markerSettings: MarkerSettings(
|
||||
isVisible: true,
|
||||
color: seriesColor2,
|
||||
height: 8,
|
||||
width: 8,
|
||||
),
|
||||
dataLabelSettings: const DataLabelSettings(
|
||||
labelAlignment: ChartDataLabelAlignment.top,
|
||||
isVisible: true,
|
||||
),
|
||||
dataSource: widget.circumferencePoints,
|
||||
borderColor: seriesColor2,
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
seriesColor2.withValues(alpha: 0.14),
|
||||
Colors.transparent,
|
||||
],
|
||||
begin: Alignment.topCenter,
|
||||
end: const Alignment(0, 0.6),
|
||||
),
|
||||
name: 'Талия',
|
||||
xValueMapper: (CircumferenceModel value, int index) => value.createdAt,
|
||||
yValueMapper: (CircumferenceModel value, int index) => value.waistCircumference,
|
||||
),
|
||||
SplineAreaSeries<CircumferenceModel, DateTime>(
|
||||
markerSettings: const MarkerSettings(
|
||||
isVisible: true,
|
||||
color: seriesColor3,
|
||||
height: 8,
|
||||
width: 8,
|
||||
),
|
||||
dataLabelSettings: const DataLabelSettings(
|
||||
labelAlignment: ChartDataLabelAlignment.top,
|
||||
isVisible: true,
|
||||
),
|
||||
dataSource: widget.circumferencePoints,
|
||||
borderColor: seriesColor3,
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
seriesColor3.withValues(alpha: 0.14),
|
||||
Colors.transparent,
|
||||
],
|
||||
begin: Alignment.topCenter,
|
||||
end: const Alignment(0, 0.6),
|
||||
),
|
||||
name: 'Бёдра',
|
||||
xValueMapper: (CircumferenceModel value, int index) => value.createdAt,
|
||||
yValueMapper: (CircumferenceModel value, int index) => value.hipCircumference,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import 'package:eatvio_app/features/profile/models/weight_model.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class ImtChartWidget extends StatelessWidget {
|
||||
final List<WeightModel> weightPoints;
|
||||
|
||||
const ImtChartWidget({
|
||||
super.key,
|
||||
required this.weightPoints,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
if (weightPoints.isNotEmpty)
|
||||
Text(
|
||||
weightPoints.last.bmi.toStringAsFixed(2),
|
||||
style: const TextStyle(
|
||||
color: Colors.black,
|
||||
fontSize: 40,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (weightPoints.length > 1)
|
||||
weightPoints.last.bmi > weightPoints[weightPoints.length - 2].bmi
|
||||
? const Icon(
|
||||
Icons.arrow_drop_up,
|
||||
size: 40,
|
||||
color: Colors.grey,
|
||||
)
|
||||
: const Icon(
|
||||
Icons.arrow_drop_down,
|
||||
size: 40,
|
||||
color: Colors.grey,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
SfCartesianChart(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||||
enableAxisAnimation: true,
|
||||
plotAreaBorderWidth: 0,
|
||||
trackballBehavior: TrackballBehavior(
|
||||
enable: true,
|
||||
activationMode: ActivationMode.singleTap,
|
||||
tooltipDisplayMode: TrackballDisplayMode.groupAllPoints,
|
||||
tooltipSettings: const InteractiveTooltip(),
|
||||
lineType: TrackballLineType.vertical,
|
||||
),
|
||||
primaryXAxis: DateTimeCategoryAxis(
|
||||
tickPosition: TickPosition.inside,
|
||||
majorTickLines: const MajorTickLines(
|
||||
size: 0,
|
||||
),
|
||||
edgeLabelPlacement: EdgeLabelPlacement.shift,
|
||||
dateFormat: DateFormat.MMMd('ru'),
|
||||
labelStyle: const TextStyle(
|
||||
color: Colors.grey,
|
||||
),
|
||||
majorGridLines: MajorGridLines(dashArray: const [1], color: Colors.grey.withValues(alpha: 0.3)),
|
||||
axisLine: AxisLine(
|
||||
color: Colors.grey.withValues(alpha: 0.3),
|
||||
width: 0.4,
|
||||
),
|
||||
),
|
||||
primaryYAxis: const NumericAxis(
|
||||
isVisible: false,
|
||||
),
|
||||
series: _getSplineSeries(),
|
||||
tooltipBehavior: TooltipBehavior(enable: true),
|
||||
),
|
||||
if (weightPoints.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Text(
|
||||
'Индекс Массы Тела рассчитывается при указанных весе и росте',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<CartesianSeries<WeightModel, DateTime>> _getSplineSeries() {
|
||||
return <CartesianSeries<WeightModel, DateTime>>[
|
||||
SplineAreaSeries<WeightModel, DateTime>(
|
||||
markerSettings: MarkerSettings(
|
||||
isVisible: true,
|
||||
color: appTheme.blueA400,
|
||||
height: 8,
|
||||
width: 8,
|
||||
),
|
||||
dataLabelSettings: const DataLabelSettings(
|
||||
labelAlignment: ChartDataLabelAlignment.top,
|
||||
isVisible: true,
|
||||
),
|
||||
dataSource: weightPoints,
|
||||
borderColor: appTheme.blueA400,
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
appTheme.blueA400.withValues(alpha: 0.14),
|
||||
Colors.transparent,
|
||||
],
|
||||
begin: Alignment.topCenter,
|
||||
end: const Alignment(0, 0.6),
|
||||
),
|
||||
name: 'ИМТ',
|
||||
xValueMapper: (WeightModel value, int index) => value.createdAt,
|
||||
yValueMapper: (WeightModel value, int index) => double.parse(value.bmi.toStringAsFixed(2)),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user