1 Commits

Author SHA1 Message Date
Anatoliy
95a09ecdbf fix(payments, tests): flutter analyser warns 2026-07-14 16:05:18 +03:00
525 changed files with 9075 additions and 51601 deletions

View File

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

View File

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

View File

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

View File

@@ -8,27 +8,18 @@ jobs:
build:
runs-on: [macos-arm64]
steps:
# launchd does not load the login-shell PATH, so Homebrew git is invisible to act.
- name: Add Homebrew to PATH
run: |
echo "/opt/homebrew/bin" >> "$GITHUB_PATH"
echo "/opt/homebrew/sbin" >> "$GITHUB_PATH"
- name: Fastlane version
run: |
git --version
fastlane --version
run: fastlane --version
- name: Checkout
uses: https://github.com/actions/checkout@v2
- name: Checkout env repository
uses: https://github.com/actions/checkout@v3
uses: https://github.com/actions/checkout@v2
with:
repository: eatvio/eatvio_app_env
repository: mso7nueh/eatvio_app_env
path: env_repo
# Здесь может быть токен любого юзера с доступом в gitea
token: ${{ vars.READ_USER_GITEA_TOKEN }}
token: ${{ vars.MSO7NUEH_TOKEN }}
ref: main
- name: Copy .env file
@@ -66,24 +57,23 @@ jobs:
- name: Flutter build
run: |
flutter --version
flutter config --no-enable-swift-package-manager
rm -rf ios/Podfile.lock
flutter upgrade
flutter clean
flutter pub get
flutter pub upgrade
flutter pub run build_runner build --delete-conflicting-outputs
cd ios
pod install --repo-update
cd ..
# Configure Flutter for release mode
pod repo update
flutter build ios --config-only --release
cd ios
cd ios
pod update
fastlane beta
env:
LANG: en_US.UTF-8
LC_ALL: en_US.UTF-8
MATCH_PASSWORD: ${{ vars.IOS_RUNNER_CI_PASSWORD }}
FASTLANE_PASSWORD: ${{ vars.FASTLANE_PASSWORD }}
MATCH_GIT_URL: http://eatvio:${{ vars.READ_USER_GITEA_TOKEN }}@gitea.office.local:3000/eatvio/fastlane.git
MATCH_GIT_URL: http://gitea.office.local:3000/mso7nueh/fastlane
FASTLANE_USER: eatvio2023@icloud.com
FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD: ${{ vars.FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD }}
API_APPSTORE_KEY: ${{ vars.API_APPSTORE_KEY }}

View File

@@ -1,9 +1,6 @@
name: Eatvio App Android Test
on:
pull_request:
branches:
- main
on: [pull_request]
env:
flutter_version: '3.41.2'
@@ -14,7 +11,7 @@ env:
jobs:
build:
runs-on: [debian-12]
runs-on: [ubuntu-latest]
steps:
- uses: https://github.com/actions/checkout@v3
@@ -156,7 +153,6 @@ jobs:
- name: Get dependencies
run: |
flutter clean
flutter pub cache repair
flutter pub get
dart run build_runner build --delete-conflicting-outputs
@@ -166,66 +162,66 @@ jobs:
- name: Test
run: flutter test --coverage
# - name: Test coverage
# run: |
# echo "📊 Analyzing test coverage..."
# echo ""
# awk -F: '
# /^SF:/ {
# file = $0
# sub(/^SF:/, "", file)
# sub(/^[[:space:]]+/, "", file)
# is_generated = 0
# if (file ~ /\.(g|freezed|gr|mocks)\.dart$/) {
# is_generated = 1
# generated_files++
# } else if (file ~ /\/(build|\.dart_tool)\//) {
# is_generated = 1
# generated_files++
# } else {
# source_files++
# }
# }
# /^LF:/ {
# if (!is_generated) {
# total += $2
# } else {
# generated_lines += $2
# }
# }
# /^LH:/ {
# if (!is_generated) {
# covered += $2
# } else {
# generated_covered += $2
# }
# }
# END {
# print "📁 Files:"
# printf " Source files: %d\n", source_files
# printf " Generated files: %d (excluded)\n", generated_files
# print ""
# print "📝 Lines:"
# printf " Total lines: %d\n", total
# printf " Covered lines: %d\n", covered
# printf " Uncovered lines: %d\n", total - covered
# print ""
# if (total == 0) {
# print "⚠️ Coverage: 0%"
# exit 1
# } else {
# percentage = (covered / total) * 100
# printf "✅ Coverage: %.2f%%\n", percentage
# print ""
# if (percentage < 70) {
# printf "❌ FAIL: Coverage is below 70%%\n"
# exit 1
# } else {
# printf "💚 Excellent: Coverage is above 70%%\n"
# }
# }
# }
# ' coverage/lcov.info
- name: Test coverage
run: |
echo "📊 Analyzing test coverage..."
echo ""
awk -F: '
/^SF:/ {
file = $0
sub(/^SF:/, "", file)
sub(/^[[:space:]]+/, "", file)
is_generated = 0
if (file ~ /\.(g|freezed|gr|mocks)\.dart$/) {
is_generated = 1
generated_files++
} else if (file ~ /\/(build|\.dart_tool)\//) {
is_generated = 1
generated_files++
} else {
source_files++
}
}
/^LF:/ {
if (!is_generated) {
total += $2
} else {
generated_lines += $2
}
}
/^LH:/ {
if (!is_generated) {
covered += $2
} else {
generated_covered += $2
}
}
END {
print "📁 Files:"
printf " Source files: %d\n", source_files
printf " Generated files: %d (excluded)\n", generated_files
print ""
print "📝 Lines:"
printf " Total lines: %d\n", total
printf " Covered lines: %d\n", covered
printf " Uncovered lines: %d\n", total - covered
print ""
if (total == 0) {
print "⚠️ Coverage: 0%"
exit 1
} else {
percentage = (covered / total) * 100
printf "✅ Coverage: %.2f%%\n", percentage
print ""
if (percentage < 70) {
printf "❌ FAIL: Coverage is below 70%%\n"
exit 1
} else {
printf "💚 Excellent: Coverage is above 70%%\n"
}
}
}
' coverage/lcov.info
- name: Build apk
run: |

5
.gitignore vendored
View File

@@ -16,8 +16,6 @@
*.mocks.dart
android/build/
ios/XCBuildData/
docs/
specs/
env/.env
.env
android/key.properties
@@ -26,5 +24,4 @@ coverage/
backend/
.fvm/
.fvmrc
/eatvio
*.code-workspace
/eatvio

View File

@@ -1,7 +1,7 @@
# Current Context
## Project Status
Memory Bank initialized on 2025-10-16. This is a mature Flutter application (v1.3.24+106) currently in production.
Memory Bank initialized on 2025-10-16. This is a mature Flutter application (v1.3.0+79) currently in production.
## Recent Activity
- Memory Bank initialization completed
@@ -9,19 +9,10 @@ Memory Bank initialized on 2025-10-16. This is a mature Flutter application (v1.
- Architecture and technology stack catalogued
## Current Focus
Виджет видеоплеера ленты (`lib/features/feed/presentation/widgets/feed_video_player_widget.dart`)
мигрирован с `webview_flutter` v4 на `flutter_inappwebview` 6.1.5. Цель — фикс
fullscreen-кнопки внутри embed-плеера (Rutube/YouTube): `webview_flutter` не реализует
Android `WebChromeClient.onShowCustomView/onHideCustomView`, а `flutter_inappwebview`
делает это нативно (`InAppWebViewChromeClient`). Паритет UX сохранён (превью → play →
скрытая загрузка → fade-in по Rutube `player:canPlay`, плюс страховочный таймер 4с
для провайдеров без этого протокола). Добавлена ориентация landscape на fullscreen.
Остальные 4 экрана на `webview_flutter` не трогались; обе зависимости в `pubspec.yaml`.
Ready for development tasks. No active work in progress.
## Known Issues
- 2 теста в `test/features/feed/` (`post_feed_pagination_model_test`,
`post_feed_mapper_test`) падают на ветке `personalized_feed` — пре-существующие,
связаны с fixtures/маппингом, не с видеоплеером.
None documented at initialization.
## Next Steps
Awaiting user direction for:

View File

@@ -39,7 +39,7 @@ android {
versionName = flutter.versionName
manifestPlaceholders = [
YANDEX_CLIENT_ID: yandexClientId,
applicationName: "com.eatvioapp.app.EatvioApplication"
applicationName: "io.flutter.app.FlutterApplication"
]
}
@@ -69,9 +69,6 @@ android {
}
dependencies {
testImplementation 'junit:junit:4.13.2'
implementation platform('com.google.firebase:firebase-bom:34.4.0')
implementation 'com.google.firebase:firebase-messaging'
implementation 'com.huawei.agconnect:agconnect-core:1.9.5.300'
implementation 'com.huawei.hms:push:6.13.0.301'
// Huawei Analytics dependency to satisfy com.huawei.hianalytics.* references during R8

View File

@@ -4,12 +4,6 @@
-keep class org.bouncycastle.** { *; }
-dontwarn org.bouncycastle.**
-keep class com.hiennv.flutter_callkit_incoming.** { *; }
-dontwarn com.hiennv.flutter_callkit_incoming.**
-keep class com.pravera.flutter_foreground_task.** { *; }
-dontwarn com.pravera.flutter_foreground_task.**
-keep class io.flutter.** { *; }
-dontwarn io.flutter.**

View File

@@ -1,13 +1,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
@@ -17,16 +13,6 @@
android:usesCleartextTraffic="true"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<service
android:name=".EatvioFcmMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<service
android:name="io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingService"
tools:node="remove" />
<service
android:name=".HmsPushService"
android:exported="false">
@@ -47,7 +33,7 @@
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:supportsPictureInPicture="true"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
@@ -78,7 +64,12 @@
<data android:host="eatvio.com" />
<data android:host="www.eatvio.com" />
<!-- Development/Subdomains -->
<data android:host="*.eatvio.ru" />
<data android:host="*.eatvio.com" />
<!-- Simplify patterns to allow navigation within the app for all matching hosts -->
<data android:pathPattern="/.*" />
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
@@ -86,77 +77,6 @@
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<!-- 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" />
<activity
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="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="singleInstance"
android:configChanges="orientation"
android:permission="${applicationId}.PERMISSION_CALL">
<intent-filter>
<action android:name="${applicationId}.com.hiennv.flutter_callkit_incoming.ACTION_CALL_INCOMING" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity-alias>
<activity
android:name="com.hiennv.flutter_callkit_incoming.TransparentActivity"
tools:node="remove" />
<activity
android:name=".EatvioCallkitTransparentBridgeActivity"
android:exported="false"
android:theme="@style/TranslucentTheme"
android:showWhenLocked="true"
android:turnScreenOn="true"
android:excludeFromRecents="true"
android:noHistory="true"
android:launchMode="singleTop" />
<activity-alias
android:name="com.hiennv.flutter_callkit_incoming.TransparentActivity"
android:targetActivity=".EatvioCallkitTransparentBridgeActivity"
android:exported="false"
android:theme="@style/TranslucentTheme"
android:showWhenLocked="true"
android:turnScreenOn="true"
android:excludeFromRecents="true"
android:noHistory="true"
android:launchMode="singleTop" />
<service
android:name="com.pravera.flutter_foreground_task.service.ForegroundService"
android:foregroundServiceType="microphone"
android:exported="false"
android:stopWithTask="true" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and

View File

@@ -1,55 +0,0 @@
package com.eatvioapp.app
import android.content.Intent
import android.os.Bundle
import com.hiennv.flutter_callkit_incoming.CallkitConstants
import com.hiennv.flutter_callkit_incoming.FlutterCallkitIncomingPlugin
object CallKitAcceptIntentMapper {
fun payloadFromBundle(data: Bundle, action: String): Map<String, Any?>? {
if (!action.contains(CallkitConstants.ACTION_CALL_ACCEPT) &&
!action.contains(CallkitConstants.ACTION_CALL_CALLBACK)
) {
return null
}
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?>? {
if (intent == null) return null
val data = intent.getBundleExtra(FlutterCallkitIncomingPlugin.EXTRA_CALLKIT_CALL_DATA)
?: return null
val action = intent.action ?: return null
if (!action.contains(CallkitConstants.ACTION_CALL_ACCEPT) &&
!action.contains(CallkitConstants.ACTION_CALL_CALLBACK)
) {
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, ""),
"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),
)
}
}

View File

@@ -1,48 +0,0 @@
package com.eatvioapp.app
import android.content.Context
import android.content.Intent
import android.os.Bundle
import com.hiennv.flutter_callkit_incoming.AppUtils
import com.hiennv.flutter_callkit_incoming.CallkitIncomingBroadcastReceiver
object CallKitIntentBridge {
fun dispatchCallKitAction(context: Context, action: String, data: Bundle?) {
if (IncomingCallRingtonePolicy.actionStopsRingtone(action)) {
VideoCallPushBridge.silenceIncomingCall(context)
}
if (data != null) {
CallKitAcceptIntentMapper.payloadFromBundle(data, action)?.let { payload ->
PendingCallKitAcceptBridge.store(payload)
}
CallKitAcceptIntentMapper.declinePayloadFromBundle(data, action)?.let { payload ->
PendingCallKitDeclineBridge.store(payload)
if (MainActivity.isInForeground) {
MainActivity.onPendingDecline?.invoke()
}
}
}
val broadcastIntent = CallkitIncomingBroadcastReceiver.getIntent(context, action, data)
broadcastIntent.addFlags(Intent.FLAG_RECEIVER_FOREGROUND)
context.sendBroadcast(broadcastIntent)
val shouldLaunchMainActivity =
action == com.hiennv.flutter_callkit_incoming.CallkitConstants.ACTION_CALL_ACCEPT ||
action == com.hiennv.flutter_callkit_incoming.CallkitConstants.ACTION_CALL_CALLBACK ||
!MainActivity.isInForeground
if (!shouldLaunchMainActivity) {
return
}
val activityIntent = AppUtils.getAppIntent(context, action, data) ?: return
activityIntent.addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_SINGLE_TOP or
Intent.FLAG_ACTIVITY_REORDER_TO_FRONT,
)
context.startActivity(activityIntent)
}
}

View File

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

View File

@@ -1,329 +0,0 @@
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
/// 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)
val data = intent.extras?.getBundle(CallkitConstants.EXTRA_CALLKIT_INCOMING_DATA)
if (data == null) {
finish()
return
}
callData = 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")),
)
val avatarUrl = data.getString(CallkitConstants.EXTRA_CALLKIT_AVATAR, "")
if (!avatarUrl.isNullOrEmpty()) {
avatar.visibility = View.VISIBLE
@Suppress("DEPRECATION", "UNCHECKED_CAST")
val headers = data.getSerializable(CallkitConstants.EXTRA_CALLKIT_HEADERS) as? HashMap<String, Any?>
try {
ImageLoaderProvider.loadImage(this, avatarUrl, headers, CallkitR.drawable.ic_default_avatar, avatar)
} catch (error: Exception) {
Log.w(TAG, "failed to load caller avatar", error)
}
}
if (data.getInt(CallkitConstants.EXTRA_CALLKIT_TYPE, 0) > 0) {
acceptButton.setImageResource(CallkitR.drawable.ic_video)
}
acceptButton.background = AppUtils.createCircleDrawable(
parseColor(data.getString(CallkitConstants.EXTRA_CALLKIT_ACCEPT_COLOR), Color.parseColor("#4CAF50")),
)
declineButton.background = AppUtils.createCircleDrawable(
parseColor(data.getString(CallkitConstants.EXTRA_CALLKIT_DECLINE_COLOR), Color.parseColor("#F44336")),
)
val acceptText = data.getString(CallkitConstants.EXTRA_CALLKIT_TEXT_ACCEPT, "")
acceptLabel.text = if (acceptText.isNullOrEmpty()) getString(CallkitR.string.text_accept) else acceptText
val declineText = data.getString(CallkitConstants.EXTRA_CALLKIT_TEXT_DECLINE, "")
declineLabel.text = if (declineText.isNullOrEmpty()) getString(CallkitR.string.text_decline) else declineText
acceptButton.setOnClickListener { onAcceptClick() }
declineButton.setOnClickListener { onDeclineClick() }
}
private fun onAcceptClick() {
val data = callData ?: return
if (actionTaken) return
actionTaken = true
cancelRingTimeout()
Log.i(TAG, "ringing screen: accept")
// Same order as the plugin's own incoming activity: dispatch the accept, then ask
// the keyguard to unlock so MainActivity (which cannot show over the keyguard)
// becomes visible as soon as the user unlocks.
CallKitIntentBridge.dispatchCallKitAction(this, CallkitConstants.ACTION_CALL_ACCEPT, data)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
if (keyguardManager.isKeyguardLocked) {
keyguardManager.requestDismissKeyguard(this, null)
}
}
finishRingingTask()
}
private fun onDeclineClick() {
val data = callData ?: return
if (actionTaken) return
actionTaken = true
cancelRingTimeout()
Log.i(TAG, "ringing screen: decline")
CallKitIntentBridge.dispatchCallKitAction(this, CallkitConstants.ACTION_CALL_DECLINE, data)
finishRingingTask()
}
private fun closeRingingScreen() {
if (isFinishing) return
cancelRingTimeout()
finishRingingTask()
}
private fun finishRingingTask() {
if (isFinishing) return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
finishAndRemoveTask()
} else {
finish()
}
}
private fun ringDuration(data: Bundle): Long {
val duration = data.getLong(CallkitConstants.EXTRA_CALLKIT_DURATION, DEFAULT_RING_DURATION_MS)
return if (duration > 0L) duration else DEFAULT_RING_DURATION_MS
}
/**
* The notification was posted before this screen appeared, so ring only for the
* remaining part of the call duration.
*/
private fun scheduleRingTimeout(data: Bundle) {
cancelRingTimeout()
val now = System.currentTimeMillis()
val startedAt = data.getLong(CallkitNotificationManager.EXTRA_TIME_START_CALL, now)
val remaining = (ringDuration(data) - abs(now - startedAt)).coerceAtLeast(MIN_RING_SCREEN_MS)
val timeout = Runnable {
Log.i(TAG, "ringing screen: timed out")
closeRingingScreen()
}
ringTimeout = timeout
mainHandler.postDelayed(timeout, remaining)
}
private fun cancelRingTimeout() {
ringTimeout?.let { mainHandler.removeCallbacks(it) }
ringTimeout = null
}
@Suppress("DEPRECATION")
private fun acquireWakeLock(durationMs: Long) {
try {
val powerManager = applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager
val lock = powerManager.newWakeLock(
PowerManager.SCREEN_BRIGHT_WAKE_LOCK or
PowerManager.ACQUIRE_CAUSES_WAKEUP or
PowerManager.ON_AFTER_RELEASE,
WAKE_LOCK_TAG,
)
lock.acquire(durationMs)
wakeLock = lock
} catch (error: Exception) {
Log.w(TAG, "failed to acquire wake lock", error)
}
}
private fun releaseWakeLock() {
val lock = wakeLock ?: return
wakeLock = null
try {
if (lock.isHeld) lock.release()
} catch (error: Exception) {
Log.w(TAG, "failed to release wake lock", error)
}
}
private fun parseColor(value: String?, fallback: Int): Int {
if (value.isNullOrBlank()) return fallback
return try {
Color.parseColor(value)
} catch (error: IllegalArgumentException) {
fallback
}
}
}

View File

@@ -1,25 +0,0 @@
package com.eatvioapp.app
import android.app.Activity
import android.os.Bundle
/// Replaces plugin TransparentActivity: forwards CallKit actions without relaunching
/// MainActivity when the Flutter app is already in the foreground.
class EatvioCallkitTransparentBridgeActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val action = intent.action
if (action.isNullOrBlank()) {
finish()
return
}
val data = intent.getBundleExtra("data")
CallKitIntentBridge.dispatchCallKitAction(this, action, data)
finish()
@Suppress("DEPRECATION")
overridePendingTransition(0, 0)
}
}

View File

@@ -1,36 +0,0 @@
package com.eatvioapp.app
import android.util.Log
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import io.flutter.plugins.firebase.messaging.FlutterFirebaseTokenLiveData
class EatvioFcmMessagingService : FirebaseMessagingService() {
override fun onNewToken(token: String) {
super.onNewToken(token)
FlutterFirebaseTokenLiveData.getInstance().postToken(token)
Log.i(TAG, "onNewToken")
}
override fun onMessageReceived(message: RemoteMessage) {
Log.i(TAG, "onMessageReceived: ${message.data}")
if (VideoCallPushBridge.isEndedVideoCall(message.data)) {
VideoCallPushBridge.dismissEndedVideoCall(this, message.data)
return
}
if (!VideoCallPushBridge.isIncomingVideoCall(message.data)) {
return
}
// Data-only call pushes are delivered only to this service.
// FlutterFirebaseMessagingService is removed, so the Dart listener does
// not see them. Show the incoming UI here for both foreground and background.
VideoCallPushBridge.showIncomingCall(this, message.data)
}
companion object {
private const val TAG = "EatvioFCM"
}
}

View File

@@ -20,23 +20,17 @@ class HmsPushService : HmsMessageService() {
if (message == null) return
if (VideoCallPushBridge.isEndedVideoCall(message.dataOfMap)) {
VideoCallPushBridge.dismissEndedVideoCall(this, message.dataOfMap)
return
}
if (VideoCallPushBridge.isIncomingVideoCall(message.dataOfMap)) {
VideoCallPushBridge.showIncomingCall(this, message.dataOfMap)
return
}
// Берём action_url из data-поля, если оно есть.
val actionUrl = message.dataOfMap["action_url"]
// Строим системное уведомление, по тапу по которому откроется MainActivity с action_url.
showNotification(message, actionUrl)
}
private fun showNotification(message: RemoteMessage, actionUrl: String?) {
val context = applicationContext
// Интент для запуска Flutter-активити.
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
if (!actionUrl.isNullOrBlank()) {
@@ -54,7 +48,7 @@ class HmsPushService : HmsMessageService() {
val channelId = "hms_push_channel"
val notification = NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.mipmap.ic_launcher)
.setSmallIcon(R.mipmap.ic_launcher) // используем иконку приложения
.setContentTitle(message.notification?.title ?: context.getString(R.string.app_name))
.setContentText(message.notification?.body ?: "")
.setAutoCancel(true)
@@ -64,3 +58,5 @@ class HmsPushService : HmsMessageService() {
NotificationManagerCompat.from(context).notify(message.messageId.hashCode(), notification)
}
}

View File

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

View File

@@ -1,140 +1,27 @@
package com.eatvioapp.app
import android.app.PictureInPictureParams
import android.content.Intent
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.util.Rational
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import com.hiennv.flutter_callkit_incoming.FlutterCallkitIncomingPlugin
import com.huawei.agconnect.config.AGConnectServicesConfig
import com.huawei.hms.aaid.HmsInstanceId
import com.huawei.hms.api.ConnectionResult
import com.huawei.hms.api.HuaweiApiAvailability
import com.huawei.hms.common.ApiException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class MainActivity: FlutterActivity() {
companion object {
@JvmStatic
var isInForeground: Boolean = false
private set
@JvmStatic
var onPendingDecline: (() -> Unit)? = null
}
private val channelName = "com.eatvioapp/hms_push"
private val videoCallPipChannelName = "com.eatvioapp/video_call_pip"
private val videoCallPushChannelName = "com.eatvioapp/video_call_push"
private var methodChannel: MethodChannel? = null
private var videoCallPipChannel: MethodChannel? = null
private var videoCallPushChannel: MethodChannel? = null
private var videoCallPipEnabled: Boolean = false
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()
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
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 {
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()
}
}
videoCallPipChannel?.setMethodCallHandler { call, result ->
when (call.method) {
"setPipEnabled" -> {
videoCallPipEnabled = call.arguments as? Boolean ?: false
result.success(null)
}
else -> result.notImplemented()
}
}
methodChannel?.setMethodCallHandler { call, result ->
when (call.method) {
"isHmsAvailable" -> {
CoroutineScope(Dispatchers.IO).launch {
val available = try {
HuaweiApiAvailability.getInstance()
.isHuaweiMobileServicesAvailable(applicationContext) == ConnectionResult.SUCCESS
} catch (e: Exception) {
android.util.Log.w("HMS", "HMS availability check failed: ${e.message}")
false
}
runOnUiThread {
result.success(available)
}
}
}
"getHuaweiToken" -> {
CoroutineScope(Dispatchers.IO).launch {
try {
@@ -174,30 +61,10 @@ class MainActivity: FlutterActivity() {
runOnUiThread {
result.success(token)
}
} catch (e: ApiException) {
if (e.statusCode == 907135003) {
android.util.Log.w("HMS", "HMS client API invalid (${e.statusCode}), falling back to FCM")
runOnUiThread {
result.success(null)
}
} else {
android.util.Log.e("HMS", "Error getting HMS token: ${e.statusCode}: ${e.message}", e)
runOnUiThread {
result.error("HMS_TOKEN_ERROR", "${e.statusCode}: ${e.message}", null)
}
}
} catch (e: Exception) {
val message = e.message.orEmpty()
if (message.contains("907135003") || message.contains("client api invalid", ignoreCase = true)) {
android.util.Log.w("HMS", "HMS client API invalid, falling back to FCM")
runOnUiThread {
result.success(null)
}
} else {
android.util.Log.e("HMS", "Error getting HMS token: $message", e)
runOnUiThread {
result.error("HMS_TOKEN_ERROR", e.message, null)
}
android.util.Log.e("HMS", "Error getting HMS token: ${e.message}", e)
runOnUiThread {
result.error("HMS_TOKEN_ERROR", e.message, null)
}
}
}
@@ -208,91 +75,17 @@ class MainActivity: FlutterActivity() {
// Обрабатываем возможный action_url, с которым была открыта Activity (например, из HMS уведомления).
handleIntentForActionUrl(intent)
captureCallKitLaunchIntent(intent)
}
override fun onUserLeaveHint() {
super.onUserLeaveHint()
enterVideoCallPictureInPictureIfNeeded()
videoCallPipChannel?.invokeMethod("onUserLeaveHint", null)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
captureCallKitLaunchIntent(intent)
// Обработка нового интента, когда Activity уже запущена (тап по уведомлению).
handleIntentForActionUrl(intent)
}
private fun captureCallKitLaunchIntent(intent: Intent?) {
val payload = CallKitAcceptIntentMapper.payloadFromIntent(intent) ?: return
android.util.Log.i(
"VideoCallPush",
"captureCallKitLaunchIntent: action=${payload["action"]} id=${payload["id"]}",
)
PendingCallKitAcceptBridge.store(payload)
CallKitAcceptIntentMapper.clearCallKitLaunchIntent(intent)
scheduleDeliverPendingCallKitAccept()
}
private fun scheduleDeliverPendingCallKitAccept() {
PendingCallKitAcceptBridge.scheduleDeliverToFlutter(
deliver = { deliverPendingCallKitAcceptToFlutter() },
)
}
private fun deliverPendingCallKitAcceptToFlutter() {
val payload = PendingCallKitAcceptBridge.peek() ?: return
val channel = videoCallPushChannel
if (channel == null) {
android.util.Log.w(
"VideoCallPush",
"deliverPendingCallKitAcceptToFlutter: channel not ready",
)
return
}
android.util.Log.i(
"VideoCallPush",
"deliverPendingCallKitAcceptToFlutter: id=${payload["id"]}",
)
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
if (intent.hasExtra(FlutterCallkitIncomingPlugin.EXTRA_CALLKIT_CALL_DATA)) {
return
}
// Вариант 1: action_url лежит в extras.
val fromExtras = intent.extras?.getString("action_url")
@@ -308,19 +101,4 @@ class MainActivity: FlutterActivity() {
// Проксируем URL во Flutter, где уже есть общая логика навигации по диплинкам.
methodChannel?.invokeMethod("openActionUrl", actionUrl)
}
private fun enterVideoCallPictureInPictureIfNeeded() {
if (!videoCallPipEnabled) return
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
if (isInPictureInPictureMode) return
try {
val params = PictureInPictureParams.Builder()
.setAspectRatio(Rational(2, 3))
.build()
enterPictureInPictureMode(params)
} catch (e: Exception) {
android.util.Log.w("VideoCallPiP", "Unable to enter PiP from onUserLeaveHint", e)
}
}
}

View File

@@ -1,42 +0,0 @@
package com.eatvioapp.app
import android.os.Handler
import android.os.Looper
import android.util.Log
/// Stores accepted call payloads until the Flutter engine is ready to drain them.
object PendingCallKitAcceptBridge {
private const val TAG = "PendingCallKitAccept"
@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 accept 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 accept id=${payload["id"]}")
}
return payload
}
fun peekPending(): Map<String, Any?>? = pendingPayload
fun scheduleDeliverToFlutter(
deliver: () -> Unit,
delaysMs: LongArray = longArrayOf(0L, 250L, 700L, 1500L, 2800L, 4200L, 6000L),
) {
for (delayMs in delaysMs) {
mainHandler.postDelayed({ deliver() }, delayMs)
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,640 +0,0 @@
package com.eatvioapp.app
import android.content.Context
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.PowerManager
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.hiennv.flutter_callkit_incoming.CallkitConstants
import com.hiennv.flutter_callkit_incoming.CallkitEventCallback
import com.hiennv.flutter_callkit_incoming.CallkitNotificationManager
import com.hiennv.flutter_callkit_incoming.CallkitNotificationService
import com.hiennv.flutter_callkit_incoming.CallkitSoundPlayerManager
import com.hiennv.flutter_callkit_incoming.Data
import com.hiennv.flutter_callkit_incoming.FlutterCallkitIncomingPlugin
import com.hiennv.flutter_callkit_incoming.addCall
import com.hiennv.flutter_callkit_incoming.getDataActiveCalls
import com.hiennv.flutter_callkit_incoming.removeAllCalls
import com.hiennv.flutter_callkit_incoming.removeCall
import java.util.HashMap
import java.util.concurrent.atomic.AtomicInteger
object VideoCallPushBridge {
private const val TAG = "VideoCallPushBridge"
private const val ANDROID_ACCENT_COLOR = "#2787F5"
private const val MISSED_CALL_CLEANUP_DELAY_MS = 250L
private const val TOMBSTONE_TTL_MS = 2 * 60 * 1000L
private const val WAKE_LOCK_TAG = "eatvio:incoming_call_ring"
private val mainHandler = Handler(Looper.getMainLooper())
private val playbackLock = Any()
private val playbackGeneration = AtomicInteger(0)
private val tombstonedCallIds = HashMap<String, Long>()
private var activeSoundManager: CallkitSoundPlayerManager? = null
private var activeNotificationManager: CallkitNotificationManager? = null
private var activeCallBundle: Bundle? = null
private var storedAppContext: Context? = null
private var ringtoneTimeout: Runnable? = null
private var ringWakeLock: PowerManager.WakeLock? = null
private var callbackRegistered = false
/**
* The plugin stops only the sound player owned by [FlutterCallkitIncomingPlugin].
* Incoming pushes start a different player, so accept/decline never reached it
* and the ringtone kept looping until the process died.
*/
private val incomingCallEventCallback = object : CallkitEventCallback {
override fun onCallEvent(event: CallkitEventCallback.CallEvent, callData: Bundle) {
val context = storedAppContext ?: return
Log.i(TAG, "incoming call event=$event, stopping ringtone")
silenceIncomingCall(context)
}
}
fun isIncomingVideoCall(data: Map<String, String>): Boolean {
val rawType = data["type"]
?: data["notification_type"]
?: data["event"]
?: data["action"]
?: data["push_type"]
?: data["call_type"]
if (rawType != null) {
val normalized = rawType.lowercase().replace("-", "_")
if (normalized.contains("video_call")) {
return !normalized.contains("end") && !normalized.contains("ended")
}
if (normalized == "incoming_call" ||
normalized == "video_call_incoming" ||
normalized == "incoming_video_call"
) {
return true
}
}
if (isTruthy(data["is_video_call"]) ||
isTruthy(data["incoming_call"]) ||
isTruthy(data["is_incoming_call"])
) {
return conversationUuid(data) != null
}
val actionUrl = data["action_url"]
if (!actionUrl.isNullOrBlank() &&
(actionUrl.contains("video-call") || actionUrl.contains("videoCall"))
) {
return true
}
return false
}
fun isEndedVideoCall(data: Map<String, String>): Boolean {
val rawType = data["type"]
?: data["notification_type"]
?: data["event"]
?: data["action"]
?: data["push_type"]
?: data["call_type"]
if (rawType != null) {
val normalized = rawType.lowercase().replace("-", "_")
if (normalized == "video_call_ended" || normalized == "video_call_end") {
return true
}
if (normalized.contains("video_call") &&
(normalized.contains("ended") || normalized.endsWith("_end"))
) {
return true
}
}
return false
}
fun showIncomingCall(context: Context, data: Map<String, String>) {
val appContext = context.applicationContext
storedAppContext = appContext
ensureIncomingCallCallbackRegistered()
val serverCallId = callId(data)
if (isTombstoned(serverCallId)) {
Log.i(TAG, "showIncomingCall: skip tombstoned call_id=$serverCallId")
return
}
val callData = buildCallDataBundle(data)
if (callData == null) {
Log.w(TAG, "showIncomingCall: missing conversation uuid, data=$data")
return
}
val callId = callData.getString(CallkitConstants.EXTRA_CALLKIT_ID)
val seenGeneration = playbackGeneration.get()
Log.i(TAG, "showIncomingCall: id=$callId conversation=${conversationUuid(data)}")
// Ringtone.play() and stop() must run on the same thread. FCM delivers
// pushes on a background thread, while Answer runs on the main thread.
// A second show for the same conversation must not bump the generation:
// that would cancel the timeout of the call that is already ringing.
mainHandler.post {
synchronized(playbackLock) {
if (seenGeneration != playbackGeneration.get()) {
Log.i(TAG, "showIncomingCall: skipped after silence")
return@post
}
val alreadyRinging = activeCallBundle?.getString(CallkitConstants.EXTRA_CALLKIT_ID)
if (!callId.isNullOrBlank() && callId == alreadyRinging) {
Log.i(TAG, "showIncomingCall: already ringing $callId")
return@post
}
val generation = playbackGeneration.incrementAndGet()
startIncomingCallLocked(appContext, callData, generation)
}
}
}
/**
* Stops the ringtone that [showIncomingCall] started and removes the incoming
* notification. Safe to call more than once.
*/
fun silenceIncomingCall(context: Context) {
val appContext = context.applicationContext
storedAppContext = appContext
Log.i(TAG, "silenceIncomingCall")
playbackGeneration.incrementAndGet()
val stop = Runnable {
synchronized(playbackLock) {
stopPlaybackLocked(appContext)
}
}
if (Looper.myLooper() == Looper.getMainLooper()) {
stop.run()
} else {
mainHandler.post(stop)
}
}
/**
* The plugin's sound player stops on ACTION_SCREEN_OFF. The ringing screen turns
* the display on itself, so keep ringing through that transition.
*/
fun keepRingingOnFullScreen() {
synchronized(playbackLock) {
activeSoundManager?.keepRingingOnFullScreen()
}
}
fun dismissIncomingCalls(
context: Context,
callId: String? = null,
conversationUuid: String? = null,
) {
val normalizedCallId = callId?.trim()?.takeIf { it.isNotEmpty() }
val normalizedConversation = conversationUuid?.trim()?.takeIf { it.isNotEmpty() }
if (normalizedCallId != null) {
markTombstone(normalizedCallId)
}
val appContext = context.applicationContext
val calls = getDataActiveCalls(appContext)
Log.i(
TAG,
"dismissIncomingCalls: active=${calls.size} call_id=${normalizedCallId ?: "-"} " +
"conversation=${normalizedConversation ?: "-"}",
)
playbackGeneration.incrementAndGet()
val dismiss = Runnable {
synchronized(playbackLock) {
val notificationManager = resolveNotificationManager(appContext)
val allBundles = calls.map { it.toBundle() }
val targeted = hasDismissTarget(normalizedCallId, normalizedConversation)
val bundles = if (targeted) {
allBundles.filter { bundleMatches(it, normalizedCallId, normalizedConversation) }
} else {
allBundles
}
val activeMatches = activeCallBundle?.let { bundle ->
!targeted || bundleMatches(bundle, normalizedCallId, normalizedConversation)
} == true
if (activeMatches || !targeted) {
stopPlaybackLocked(appContext)
}
for (bundle in bundles) {
try {
dismissIncomingCallNotification(appContext, notificationManager, bundle)
} catch (error: Exception) {
Log.w(
TAG,
"dismissIncomingCalls: failed for call ${bundle.getString(CallkitConstants.EXTRA_CALLKIT_ID)}",
error,
)
}
}
if (!targeted || bundles.isNotEmpty()) {
try {
CallkitNotificationService.stopService(appContext)
} catch (error: Exception) {
Log.w(TAG, "dismissIncomingCalls: failed to stop callkit service", error)
}
}
if (!targeted) {
try {
removeAllCalls(appContext)
} catch (error: Exception) {
Log.w(TAG, "dismissIncomingCalls: failed to remove persisted calls", error)
}
} else {
for (bundle in bundles) {
try {
removeCall(appContext, Data.fromBundle(bundle))
} catch (error: Exception) {
Log.w(TAG, "dismissIncomingCalls: failed to remove targeted call", error)
}
}
}
if (activeMatches || !targeted) {
stopIncomingRingtone()
}
scheduleMissedCallCleanup(notificationManager, bundles)
}
}
if (Looper.myLooper() == Looper.getMainLooper()) {
dismiss.run()
} else {
mainHandler.post(dismiss)
}
}
fun dismissAllIncomingCalls(context: Context) {
dismissIncomingCalls(context)
}
fun dismissEndedVideoCall(context: Context, data: Map<String, String>) {
dismissIncomingCalls(
context = context,
callId = callId(data),
conversationUuid = conversationUuid(data),
)
}
private fun ensureIncomingCallCallbackRegistered() {
if (callbackRegistered) return
FlutterCallkitIncomingPlugin.registerEventCallback(incomingCallEventCallback)
callbackRegistered = true
}
private fun startIncomingCallLocked(appContext: Context, callData: Bundle, generation: Int) {
stopPlaybackLocked(appContext)
val soundManager = CallkitSoundPlayerManager(appContext)
val notificationManager = CallkitNotificationManager(appContext, soundManager)
activeSoundManager = soundManager
activeNotificationManager = notificationManager
activeCallBundle = callData
notificationManager.createNotificationChanel(callData)
notificationManager.showIncomingNotification(callData)
try {
addCall(appContext, Data.fromBundle(callData))
} catch (error: Exception) {
Log.w(TAG, "showIncomingCall: failed to persist active call", error)
}
val durationMs = callData.getLong(CallkitConstants.EXTRA_CALLKIT_DURATION, 45_000L)
wakeScreenLocked(appContext, if (durationMs > 0L) durationMs else 45_000L)
if (durationMs > 0L) {
val timeout = Runnable {
synchronized(playbackLock) {
if (generation != playbackGeneration.get()) return@Runnable
Log.i(TAG, "incoming ringtone timed out")
stopPlaybackLocked(appContext)
}
}
ringtoneTimeout = timeout
mainHandler.postDelayed(timeout, durationMs)
}
}
/**
* 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()
if (notificationManager != null && bundle != null) {
try {
dismissIncomingCallNotification(appContext, notificationManager, bundle)
} catch (error: Exception) {
Log.w(TAG, "stopPlayback: failed to dismiss incoming notification", error)
}
}
activeSoundManager = null
activeNotificationManager = null
activeCallBundle = null
EatvioCallkitIncomingBridgeActivity.dismissRingingScreen()
}
private fun cancelRingtoneTimeoutLocked() {
ringtoneTimeout?.let { mainHandler.removeCallbacks(it) }
ringtoneTimeout = null
}
private fun resolveNotificationManager(context: Context): CallkitNotificationManager {
activeNotificationManager?.let { return it }
FlutterCallkitIncomingPlugin.getInstance()
?.getCallkitNotificationManager()
?.let { return it }
val soundManager = CallkitSoundPlayerManager(context)
return CallkitNotificationManager(context, soundManager)
}
private fun stopIncomingRingtone() {
try {
activeSoundManager?.stop()
} catch (error: Exception) {
Log.w(TAG, "stopIncomingRingtone: active player", error)
}
try {
FlutterCallkitIncomingPlugin.getInstance()
?.getCallkitSoundPlayerManager()
?.stop()
} catch (error: Exception) {
Log.w(TAG, "stopIncomingRingtone: plugin player", error)
}
}
/**
* Replaces the incoming notification before cancel so Android does not fire the
* timeout delete intent (which would show a missed-call notification).
*/
private fun dismissIncomingCallNotification(
context: Context,
notificationManager: CallkitNotificationManager,
bundle: Bundle,
) {
val notificationId = incomingNotificationId(bundle)
val silentNotification = NotificationCompat.Builder(
context,
CallkitNotificationManager.NOTIFICATION_CHANNEL_ID_INCOMING,
)
.setSmallIcon(context.applicationInfo.icon)
.setContentTitle("")
.setSilent(true)
.setOngoing(false)
.setPriority(NotificationCompat.PRIORITY_MIN)
.build()
val notificationManagerCompat = NotificationManagerCompat.from(context)
notificationManagerCompat.notify(notificationId, silentNotification)
notificationManagerCompat.cancel(notificationId)
bundle.putBoolean(CallkitConstants.EXTRA_CALLKIT_MISSED_CALL_SHOW, false)
notificationManager.clearIncomingNotification(bundle, isAccepted = true)
notificationManager.clearMissCallNotification(bundle)
}
private fun scheduleMissedCallCleanup(
notificationManager: CallkitNotificationManager,
bundles: List<Bundle>,
) {
if (bundles.isEmpty()) return
Handler(Looper.getMainLooper()).postDelayed({
for (bundle in bundles) {
try {
notificationManager.clearMissCallNotification(bundle)
} catch (error: Exception) {
Log.w(TAG, "dismissAllIncomingCalls: delayed missed-call cleanup failed", error)
}
}
}, MISSED_CALL_CLEANUP_DELAY_MS)
}
private fun incomingNotificationId(bundle: Bundle): Int {
return bundle.getString(CallkitConstants.EXTRA_CALLKIT_ID, "callkit_incoming").hashCode()
}
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"]
?: data["callerName"]
?: data["nameCaller"]
?: data["caller"]
?: "Итвио"
val avatarUrl = data["counterpartAvatarUrl"]
?: data["counterpart_avatar_url"]
?: data["avatar"]
?: data["avatarUrl"]
?: data["avatar_url"]
?: data["caller_avatar"]
?: ""
val localParticipantName = data["localParticipantName"]
?: data["local_participant_name"]
val counterpartIsConsultant = isTruthy(
data["counterpartIsConsultant"]
?: data["counterpart_is_consultant"]
?: data["is_consultant"]
?: data["isConsultant"]
)
val extra = HashMap<String, Any?>()
extra["conversationUuid"] = conversationUuid
if (!serverCallId.isNullOrBlank()) {
extra["call_id"] = serverCallId
}
extra["counterpartName"] = callerName
if (avatarUrl.isNotBlank()) {
extra["counterpartAvatarUrl"] = avatarUrl
}
if (!localParticipantName.isNullOrBlank()) {
extra["localParticipantName"] = localParticipantName
}
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, callKitId)
putString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, callerName)
putString(CallkitConstants.EXTRA_CALLKIT_APP_NAME, "Итвио")
putString(CallkitConstants.EXTRA_CALLKIT_HANDLE, conversationUuid)
putString(CallkitConstants.EXTRA_CALLKIT_AVATAR, avatarUrl)
putInt(CallkitConstants.EXTRA_CALLKIT_TYPE, 1)
putLong(CallkitConstants.EXTRA_CALLKIT_DURATION, 45000L)
putString(CallkitConstants.EXTRA_CALLKIT_TEXT_ACCEPT, "Answer")
putString(CallkitConstants.EXTRA_CALLKIT_TEXT_DECLINE, "Decline")
putSerializable(CallkitConstants.EXTRA_CALLKIT_EXTRA, extra)
putSerializable(CallkitConstants.EXTRA_CALLKIT_HEADERS, HashMap<String, Any?>())
putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_CUSTOM_NOTIFICATION, true)
putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_CUSTOM_SMALL_EX_NOTIFICATION, false)
putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_SHOW_LOGO, false)
putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_SHOW_CALL_ID, false)
putString(CallkitConstants.EXTRA_CALLKIT_RINGTONE_PATH, "system_ringtone_default")
putString(CallkitConstants.EXTRA_CALLKIT_BACKGROUND_COLOR, ANDROID_ACCENT_COLOR)
putString(CallkitConstants.EXTRA_CALLKIT_ACTION_COLOR, ANDROID_ACCENT_COLOR)
putString(CallkitConstants.EXTRA_CALLKIT_TEXT_COLOR, "#ffffff")
putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_SHOW_FULL_LOCKED_SCREEN, false)
putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_FULL_SCREEN, false)
putBoolean(CallkitConstants.EXTRA_CALLKIT_CALLING_SHOW, false)
}
}
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"]
?: data["conversation_id"]
?: data["chat_uuid"]
?: data["uuid"]
if (!fromData.isNullOrBlank()) {
return fromData
}
val actionUrl = data["action_url"] ?: return null
val uuidFromQuery = Regex("""[?&](?:uuid|conversationUuid|conversation_uuid)=([^&]+)""")
.find(actionUrl)
?.groupValues
?.getOrNull(1)
?.trim()
if (!uuidFromQuery.isNullOrEmpty()) {
return uuidFromQuery
}
val pathMatch = Regex("""/video-call/([^/?#]+)""").find(actionUrl)
return pathMatch?.groupValues?.getOrNull(1)?.trim()?.takeIf { it.isNotEmpty() }
}
private fun isTruthy(value: String?): Boolean {
if (value.isNullOrBlank()) return false
return when (value.trim().lowercase()) {
"1", "true", "yes" -> true
else -> false
}
}
}

View File

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

View File

@@ -1,103 +0,0 @@
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)
}
}

File diff suppressed because one or more lines are too long

View File

@@ -1,3 +0,0 @@
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.3438 5.34375C14.9405 5.34375 15.5128 5.5808 15.9347 6.00276C16.3567 6.42472 16.5938 6.99701 16.5938 7.59375M14.3438 0.84375C16.134 0.84375 17.8509 1.55491 19.1167 2.82078C20.3826 4.08665 21.0938 5.80354 21.0938 7.59375M3.09375 1.96875H7.59375L9.84375 7.59375L7.03125 9.28125C8.23608 11.7242 10.2133 13.7014 12.6562 14.9062L14.3438 12.0938L19.9688 14.3438V18.8438C19.9688 19.4405 19.7317 20.0128 19.3097 20.4347C18.8878 20.8567 18.3155 21.0938 17.7188 21.0938C13.3304 20.8271 9.19142 18.9636 6.08268 15.8548C2.97394 12.7461 1.11043 8.60708 0.84375 4.21875C0.84375 3.62201 1.0808 3.04972 1.50276 2.62776C1.92472 2.2058 2.49701 1.96875 3.09375 1.96875Z" stroke="white" stroke-width="1.6875" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 853 B

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -31,43 +31,20 @@
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSAppleMusicUsageDescription</key>
<string>This permission is not needed by the app, but it is required by an underlying API. If you see this dialog, contact us.</string>
<key>NSCameraUsageDescription</key>
<string>Предоставление доступа позволит Вам делать фотографии и добавлять к записям</string>
<key>NSMicrophoneUsageDescription</key>
<string>Нужен для звука в видеозвонках с консультантом</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>Может использоваться для стабильного поддержания связи во время звонка.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Может использоваться для стабильного поддержания связи во время звонка.</string>
<string></string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Предоставление доступа позволит Вам добавлять фото к записям</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
<string>voip</string>
<string>fetch</string>
<string>remote-notification</string>
</array>
@@ -161,14 +138,6 @@
<string>yx5399b03636104c6d920b87c1ad329254</string>
</array>
</dict>
<dict>
<key>CFBundleURLName</key>
<string>Eatvio</string>
<key>CFBundleURLSchemes</key>
<array>
<string>eatvio</string>
</array>
</dict>
</array>
</dict>
</plist>

View File

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

View File

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

View File

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

View File

@@ -43,141 +43,69 @@ 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 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").
# Read build number from Flutter Generated.xcconfig
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|
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
matches = line.match(/FLUTTER_BUILD_NUMBER=(.*)/)
if matches
flutter_build_number = matches[1].strip
break
end
end
end
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}")
if flutter_build_number.nil?
UI.user_error!("Could not find FLUTTER_BUILD_NUMBER in #{generated_xcconfig_path}")
end
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}"
UI.message "Flutter build number: #{flutter_build_number}"
match(type: "appstore", readonly: true)
# Set build number explicitly instead of incrementing
increment_build_number(
xcodeproj: "Runner.xcodeproj",
build_number: new_build_number
)
update_code_signing_settings(
use_automatic_signing: false,
path: "Runner.xcodeproj",
team_id: "VL68C36HYS",
targets: ["Runner"],
code_sign_identity: "Apple Distribution",
profile_name: "match AppStore com.eatvioapp.app 1781098260"
build_number: flutter_build_number
)
build_app(
workspace: "Runner.xcworkspace",
scheme: "Runner",
export_method: "app-store",
# codesigning_identity: "Apple Distribution",
export_team_id: "VL68C36HYS"
codesigning_identity: "Apple Distribution",
)
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 #{new_build_number}",
changelog: "TestFlight build #{flutter_build_number}",
verbose: true
)
rescue => 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
UI.error "Upload to TestFlight failed: #{ex.message}"
UI.error "Full error: #{ex.backtrace.join("\n")}"
raise ex
end
end
end

View File

@@ -1,18 +1,13 @@
import 'dart:async';
import 'package:eatvio_app/core/app_initializer.dart';
import 'package:eatvio_app/core/cubit/marketing/marketing_cubit.dart';
import 'package:eatvio_app/core/env_config.dart';
import 'package:eatvio_app/core/internet_connection_cubit/internet_connection_cubit.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/prefs.dart';
import 'package:eatvio_app/core/prefs_keys.dart';
import 'package:eatvio_app/core/views/cubit/views_cubit.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/new_messages_cubit/new_messages_cubit.dart';
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
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/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
@@ -33,67 +28,21 @@ 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);
}
void _persistBaseUrl(String baseUrl) {
if (!sl.isRegistered<Prefs>()) {
return;
}
final normalized = baseUrl.trim();
if (normalized.isEmpty) {
unawaited(sl<Prefs>().remove(PrefsKeys.customBaseUrl));
return;
}
unawaited(sl<Prefs>().setString(PrefsKeys.customBaseUrl, normalized));
unawaited(sl<Prefs>().setString(PrefsKeys.apiBaseUrl, normalized));
}
/// Список глобальных BlocProvider'ов
/// Используются lazy: true для отложенной инициализации, чтобы не блокировать старт приложения
List<BlocProvider> get globalBlocProviders => [
BlocProvider<VideoCallSessionCubit>.value(
value: sl<VideoCallSessionCubit>(),
),
BlocProvider<NewMessagesCubit>(
lazy: true,
create: (context) => sl<NewMessagesCubit>(),
),
BlocProvider<NotificationsCubit>(
lazy: true,
create: (context) => sl<NotificationsCubit>(),
),
BlocProvider<NewMessagesCubit>(lazy: true, create: (context) => sl<NewMessagesCubit>()),
BlocProvider<NotificationsCubit>(lazy: true, create: (context) => sl<NotificationsCubit>()),
BlocProvider<FeedCubit>(lazy: true, create: (context) => sl<FeedCubit>()),
BlocProvider<ProfileAvatarCubit>(
lazy: true,
create: (context) => sl<ProfileAvatarCubit>(),
),
BlocProvider<ProfileAvatarCubit>(lazy: true, create: (context) => sl<ProfileAvatarCubit>()),
BlocProvider<ChatsCubit>(lazy: true, create: (context) => sl<ChatsCubit>()),
BlocProvider<ViewsCubit>(lazy: true, create: (context) => sl<ViewsCubit>()),
BlocProvider<InternetConnectionCubit>(
lazy: false,
create: (context) => sl<InternetConnectionCubit>(),
),
BlocProvider<MarketingCubit>(
lazy: true,
create: (context) => sl<MarketingCubit>(),
),
BlocProvider<InternetConnectionCubit>(lazy: false, create: (context) => sl<InternetConnectionCubit>()),
BlocProvider<MarketingCubit>(lazy: true, create: (context) => sl<MarketingCubit>()),
];
/// Делегаты локализации
@@ -105,7 +54,5 @@ class AppConfig {
];
/// Поддерживаемые локали
List<Locale> get supportedLocales => [
const Locale.fromSubtags(languageCode: 'ru'),
];
List<Locale> get supportedLocales => [const Locale.fromSubtags(languageCode: 'ru')];
}

View File

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

View File

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

View File

@@ -9,5 +9,4 @@ const List<String> planTypeValues = ['fit', 'slim', 'wellness', 'behavior'];
const newMessagesChannelName = 'private-new-chat-messages';
const newNotificationsChannelName = 'private-message-events';
const directChatChannelName = 'private-direct-chat';
const presenceDirectChatChannelName = 'presence-direct-chat';
const thresholdForPagination = 150;

View File

@@ -1,70 +0,0 @@
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 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 DeviceInfoPlugin().iosInfo).identifierForVendor;
}
if (Platform.isAndroid) {
return androidInstallId(preferences: preferences);
}
return null;
}
static Future<String> androidInstallId({
SharedPreferences? preferences,
}) async {
final prefs = preferences ?? await SharedPreferences.getInstance();
final existing = prefs.getString(androidInstallIdKey)?.trim();
if (existing != null && existing.length >= 8) return existing;
final created = _newInstallId();
await prefs.setString(androidInstallIdKey, created);
return created;
}
/// The previous Android registration used `Build.ID`. Drop that token once
/// and register again with the install id.
static Future<bool> androidTokenNeedsReregister({
SharedPreferences? preferences,
}) async {
if (!Platform.isAndroid) return false;
final prefs = preferences ?? await SharedPreferences.getInstance();
return prefs.getBool(androidTokenMigratedKey) != true;
}
static Future<void> markAndroidTokenReregistered({
SharedPreferences? preferences,
}) async {
final prefs = preferences ?? await SharedPreferences.getInstance();
await prefs.setBool(androidTokenMigratedKey, true);
}
static String _newInstallId() {
final random = Random.secure();
final bytes = List<int>.generate(16, (_) => random.nextInt(256));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
final hex = bytes
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join();
return '${hex.substring(0, 8)}-'
'${hex.substring(8, 12)}-'
'${hex.substring(12, 16)}-'
'${hex.substring(16, 20)}-'
'${hex.substring(20)}';
}
}

View File

@@ -42,8 +42,7 @@ 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';
@@ -53,8 +52,6 @@ import 'package:eatvio_app/features/registration/presentation/bloc/verify_email_
import 'package:eatvio_app/features/registration_v2/cubit/registration_cubit.dart';
import 'package:eatvio_app/features/settings/presentation/bloc/settings_cubit/settings_cubit.dart';
import 'package:eatvio_app/features/user_details/presentation/cubit/user_details_cubit.dart';
import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart';
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
import 'package:eatvio_app/core/comments/comments_cubit/comments_cubit.dart';
import 'package:get_it/get_it.dart';
@@ -71,174 +68,57 @@ 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(), profileRepository: sl()),
);
sl.registerLazySingleton<VideoCallSessionCubit>(
() => VideoCallSessionCubit(
videoCallManager: sl(),
systemPipCoordinator: sl(),
),
);
sl.registerLazySingleton<FeedCubit>(() => FeedCubit(feedRepository: 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());
}
/// Регистрация Cubit'ов как фабрик
static void _registerFactoryCubits() {
// Auth related cubits
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(),
),
);
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()));
// Feed related cubits
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<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()));
// 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()));
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(),
@@ -250,74 +130,28 @@ class CubitRegistrations {
),
);
sl.registerFactory<SettingsCubit>(
() => 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()),
() => SettingsCubit(profileRepository: sl(), settingsRepository: sl(), consultantRepository: sl(), consultationsRepository: sl()),
);
sl.registerFactory<RegisterConsultantCubit>(() => RegisterConsultantCubit(consultantRepository: sl()));
sl.registerFactory<ComplainingCubit>(() => ComplainingCubit(repository: sl()));
sl.registerFactory<UserCardsCubit>(() => UserCardsCubit(cardsRepository: sl()));
sl.registerFactory<OrderInfoCubit>(() => OrderInfoCubit(chatRepository: sl(), profileRepository: sl()));
sl.registerFactory<ConsultationPaymentCubit>(
() => 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(),
),
() => ConsultationPaymentCubit(paymentRepository: sl(), sbpRepository: sl()),
);
sl.registerFactory<ConsultationsCubit>(() => ConsultationsCubit(consultationsRepository: sl()));
sl.registerFactory<ConsultantCommentsCubit>(() => ConsultantCommentsCubit(diaryRepository: sl()));
sl.registerFactory<CancelPlanCubit>(() => CancelPlanCubit(cancelPlanRepository: sl()));
sl.registerFactory<ConsultationsArchiveCubit>(() => ConsultationsArchiveCubit(consultationsRepository: sl()));
sl.registerFactory<ConsultantFinancesCubit>(() => ConsultantFinancesCubit(consultationsRepository: sl()));
sl.registerFactory<UserPurchasesCubit>(() => UserPurchasesCubit(consultationsRepository: sl()));
sl.registerFactory<BlockedUsersCubit>(() => BlockedUsersCubit(blockedUsersRepository: sl()));
sl.registerFactory<NotificationSettingsCubit>(() => NotificationSettingsCubit(notificationSettingsRepository: sl()));
sl.registerFactory<ConsultantRatingCubit>(() => ConsultantRatingCubit(feedRepository: sl(), repository: sl(), profileRepository: sl()));
sl.registerFactory<ViewsCubit>(() => ViewsCubit(viewsRepository: sl()));
sl.registerFactory<UserDetailsCubit>(
() => UserDetailsCubit(profileRepository: sl()),
);
sl.registerFactory<MenuCubit>(() => MenuCubit(sl(), sl()));
sl.registerFactory<UserDetailsCubit>(() => UserDetailsCubit(profileRepository: sl()));
sl.registerFactory<MenuCubit>(() => MenuCubit(sl()));
sl.registerFactory<MarketingCubit>(() => MarketingCubit(sl()));
sl.registerFactory<PersonalDietSuggestionCubit>(
() => PersonalDietSuggestionCubit(
dietRepository: sl(),
personalDietSuggestionService: sl(),
),
);
sl.registerFactory<PersonalDietSuggestionCubit>(() => PersonalDietSuggestionCubit(dietRepository: sl(), personalDietSuggestionService: sl()));
}
}

View File

@@ -18,7 +18,6 @@ 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';
@@ -56,7 +55,6 @@ 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(), insightsService: sl()));
sl.registerLazySingleton<CalendarRepository>(() => CalendarRepository(calendarService: sl()));
sl.registerLazySingleton<SettingsRepository>(() => SettingsRepository(settingsService: sl()));
sl.registerLazySingleton<ConsultantRepository>(() => ConsultantRepository(consultantService: sl()));

View File

@@ -19,7 +19,6 @@ import 'package:eatvio_app/features/diary/services/diary_service.dart';
import 'package:eatvio_app/features/diets/services/diet_service.dart';
import 'package:eatvio_app/features/feed/services/feed_service.dart';
import 'package:eatvio_app/features/glossary/services/glossary_service.dart';
import 'package:eatvio_app/features/insights/services/insights_service.dart';
import 'package:eatvio_app/features/barcode/services/barcode_service.dart';
import 'package:eatvio_app/features/menu/services/blocked_users_service.dart';
import 'package:eatvio_app/features/menu/services/notification_settings_service.dart';
@@ -37,9 +36,7 @@ import 'package:eatvio_app/features/recipe/services/my_recipes_service.dart';
import 'package:eatvio_app/features/registration/services/registration_service.dart';
import 'package:eatvio_app/features/registration_v2/services/registration_service_v2.dart';
import 'package:eatvio_app/features/settings/services/settings_service.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_callkit_coordinator.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_service.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_system_pip_coordinator.dart';
import 'package:get_it/get_it.dart';
/// Регистрация всех Service в системе внедрения зависимостей
@@ -58,7 +55,6 @@ class ServiceRegistrations {
sl.registerLazySingleton<VideoCallService>(() => VideoCallService(sl()));
sl.registerLazySingleton<FeedService>(() => FeedService(sl()));
sl.registerLazySingleton<ProfileService>(() => ProfileService(sl()));
sl.registerLazySingleton<InsightsService>(() => InsightsService(sl()));
sl.registerLazySingleton<CalendarService>(() => CalendarService(sl()));
sl.registerLazySingleton<SettingsService>(() => SettingsService(sl()));
sl.registerLazySingleton<ConsultantService>(() => ConsultantService(sl()));
@@ -80,12 +76,6 @@ class ServiceRegistrations {
sl.registerLazySingleton<CreatePlanService>(() => CreatePlanService(sl()));
sl.registerLazySingleton<NotificationsService>(() => NotificationsService(sl()));
sl.registerLazySingleton<FirebaseApi>(() => FirebaseApi(errorReporter: sl()));
sl.registerLazySingleton<VideoCallCallKitCoordinator>(
() => VideoCallCallKitCoordinator(firebaseApi: sl()),
);
sl.registerLazySingleton<VideoCallSystemPipCoordinator>(
() => VideoCallSystemPipCoordinator(),
);
sl.registerLazySingleton<ResetPasswordService>(() => ResetPasswordService(sl()));
sl.registerLazySingleton<BlockedUserService>(() => BlockedUserService(sl()));
sl.registerLazySingleton<CommentsService>(() => CommentsService(sl()));

View File

@@ -1,15 +0,0 @@
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>(),
),
);
}
}

View File

@@ -1,7 +1,6 @@
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:eatvio_app/core/push/hms_client_error.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
/// Rules for which errors are sent to crash reporting.
@@ -16,7 +15,6 @@ class ErrorReportPolicy {
if (error == null) return false;
if (error is String) return false;
if (error is SocketException) return false;
if (isHmsClientUnavailable(error)) return false;
if (error is DioException) {
return _shouldReportDio(error);
@@ -46,8 +44,8 @@ class ErrorReportPolicy {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
case DioExceptionType.transformTimeout:
case DioExceptionType.connectionError:
case DioExceptionType.transformTimeout:
return true;
case DioExceptionType.unknown:
case DioExceptionType.badResponse:

View File

@@ -3,59 +3,58 @@ 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:eatvio_app/core/utils/toasts.dart';
import 'package:flutter/services.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:dio/dio.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/token_storage.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/services.dart';
import 'package:flutter/services.dart' show MethodChannel, PlatformException;
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
class FirebaseApi {
final ErrorReporter _errorReporter;
final NotificationPermissionRequester _notificationPermissionRequester;
final _firebaseMessaging = FirebaseMessaging.instance;
FirebaseApi({
required ErrorReporter errorReporter,
NotificationPermissionRequester? notificationPermissionRequester,
}) : _errorReporter = errorReporter,
_notificationPermissionRequester = notificationPermissionRequester ?? NotificationPermissionRequester();
FirebaseApi({required ErrorReporter errorReporter}) : _errorReporter = errorReporter;
Future<String?> getToken() async {
final credentials = await _resolvePushCredentials();
return credentials.token;
}
String? token;
Future<void> _requestNotificationPermission() async {
if (sl<AppConfig>().buildType != AppBuildType.prod) return;
try {
await _notificationPermissionRequester.requestOnce(
() => _firebaseMessaging.requestPermission(),
if (await _isHuaweiDevice()) {
token = await _getHuaweiPushTokenNative().timeout(
const Duration(seconds: 5),
onTimeout: () => null,
);
} else {
if ((sl<AppConfig>().buildType == AppBuildType.prod)) {
try {
await _firebaseMessaging.requestPermission().timeout(
const Duration(seconds: 5),
);
} catch (e) {
// Разрешение не критично, продолжаем
log('Failed to request Firebase messaging permission: $e');
}
}
token = await _firebaseMessaging.getToken().timeout(
const Duration(seconds: 5),
onTimeout: () => null,
);
} catch (e) {
log('Failed to request Firebase messaging permission: $e');
}
return token;
}
Future<void> registerAnonymousPushToken() async {
try {
await _requestNotificationPermission();
final credentials = await _resolvePushCredentials();
final token = credentials.token;
if (token == null || token.isEmpty) return;
final hwId = await DeviceHwIdProvider.current();
if (hwId == null || hwId.length < 8) return;
final deviceInfo = DeviceInfoPlugin();
await sl<Dio>().post(
'/api/anonymous_token',
data: {
'platform': credentials.platform,
'hw_id': hwId,
'token': token,
'platform': Platform.isIOS ? 'ios' : (await _isHuaweiDevice()) ? 'huawei' : 'android',
'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id,
'token': await getToken(),
},
).timeout(
const Duration(seconds: 10),
@@ -71,206 +70,55 @@ class FirebaseApi {
}
Future<void> registerPushToken() async {
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');
// Не пробрасываем ошибку дальше, так как это не критичная операция
}
// 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();
}
}
Future<void> unregisterPushTokens() async {
if (!await TokenStorage.haveTokenInStorage()) return;
try {
await unregisterPushToken();
} catch (_) {}
if (Platform.isIOS) {
if (await TokenStorage.haveTokenInStorage()) {
try {
await _unregisterIosVoipPushToken();
} catch (_) {}
}
}
Future<void> unregisterPushToken() async {
if (!await TokenStorage.haveTokenInStorage()) return;
try {
final credentials = await _resolvePushCredentials();
final token = credentials.token ?? '';
if (token.isEmpty) return;
await sl<Dio>().post(
'/api/profile/notification/token/unregister',
data: {
'platform': credentials.platform,
'token': token,
},
).timeout(
const Duration(seconds: 10),
onTimeout: () {
throw TimeoutException('unregisterPushToken timeout');
},
);
} catch (e, st) {
log(e.toString(), stackTrace: st);
await _errorReporter.report(e, st, origin: 'FirebaseApi');
}
}
Future<void> _registerIosVoipPushToken() async {
try {
final token = (await FlutterCallkitIncoming.getDevicePushTokenVoIP())?.trim();
if (token == null || token.isEmpty) return;
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': hwId,
'token': token,
},
).timeout(
const Duration(seconds: 10),
onTimeout: () {
throw TimeoutException('registerPushToken voip timeout');
},
);
} catch (e, st) {
log(e.toString(), stackTrace: st);
await _errorReporter.report(e, st, origin: 'FirebaseApi');
}
}
Future<void> _unregisterIosVoipPushToken() async {
try {
final token = (await FlutterCallkitIncoming.getDevicePushTokenVoIP())?.trim() ?? '';
await sl<Dio>().post(
'/api/profile/notification/token/voip/unregister',
data: {
'token': token,
},
).timeout(
const Duration(seconds: 10),
onTimeout: () {
throw TimeoutException('unregisterPushToken voip timeout');
},
);
} catch (e, st) {
log(e.toString(), stackTrace: st);
await _errorReporter.report(e, st, origin: 'FirebaseApi');
}
}
Future<({String? token, String platform})> _resolvePushCredentials() async {
if (Platform.isIOS) {
return (
token: await _getFcmToken(),
platform: pushPlatform(isIos: true, huaweiFamily: false, hmsAvailable: false, hasHmsToken: false),
);
}
final huaweiFamily = await _isHuaweiDevice();
if (huaweiFamily && await _isHmsAvailable()) {
final token = await _getHuaweiPushTokenNative().timeout(
const Duration(seconds: 5),
onTimeout: () => null,
);
if (token != null && token.isNotEmpty) {
return (
token: token,
platform: pushPlatform(isIos: false, huaweiFamily: true, hmsAvailable: true, hasHmsToken: true),
final deviceInfo = DeviceInfoPlugin();
final isHuawei = Platform.isAndroid ? await _isHuaweiDevice() : false;
await sl<Dio>().post(
'/api/profile/notification/token/register',
data: {
'platform': Platform.isIOS ? 'ios' : (isHuawei ? 'huawei' : 'android'),
'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id,
'token': await getToken(),
},
).timeout(
const Duration(seconds: 10),
onTimeout: () {
throw TimeoutException('registerPushToken timeout');
},
);
} catch (e, st) {
log(e.toString(), stackTrace: st);
await _errorReporter.report(e, st, origin: 'FirebaseApi');
// Не пробрасываем ошибку дальше, так как это не критичная операция
}
}
return (
token: await _getFcmToken(),
platform: pushPlatform(isIos: false, huaweiFamily: huaweiFamily, hmsAvailable: false, hasHmsToken: false),
);
}
Future<String?> _getFcmToken() {
return _firebaseMessaging.getToken().timeout(
const Duration(seconds: 5),
onTimeout: () => null,
);
}
Future<bool> _isHuaweiDevice() async {
if (!Platform.isAndroid) return false;
try {
final info = await DeviceInfoPlugin().androidInfo;
return isHuaweiFamilyBrand(brand: info.brand, manufacturer: info.manufacturer);
final brand = info.brand.toLowerCase();
final manufacturer = info.manufacturer.toLowerCase();
return brand.contains('huawei') || brand.contains('honor') || manufacturer.contains('huawei') || manufacturer.contains('honor');
} catch (_) {
return false;
}
}
Future<bool> _isHmsAvailable() async {
try {
const channel = MethodChannel('com.eatvioapp/hms_push');
final available = await channel.invokeMethod<bool>('isHmsAvailable').timeout(const Duration(seconds: 5));
return available ?? false;
} catch (e, st) {
log('HMS availability check failed: $e', stackTrace: st);
return false;
}
}
Future<String?> _getHuaweiPushTokenNative() async {
try {
const channel = MethodChannel('com.eatvioapp/hms_push');
final String? token = await channel.invokeMethod<String>('getHuaweiToken');
return token;
} on PlatformException catch (e, st) {
showErrorToast('$e $st');
log(e.toString(), stackTrace: st);
if (!isHmsClientUnavailable(e)) {
await _errorReporter.report(e, st, origin: 'FirebaseApi');
}
await _errorReporter.report(e, st, origin: 'FirebaseApi');
return null;
} catch (e, st) {
showErrorToast('$e $st');
log(e.toString(), stackTrace: st);
await _errorReporter.report(e, st, origin: 'FirebaseApi');
return null;

View File

@@ -1,6 +1,5 @@
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';
@@ -10,77 +9,28 @@ import 'package:go_router/go_router.dart';
class PhoneVerifyErrorInterceptor extends Interceptor {
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
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);
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),
);
}
}
return handler.next(err);
}
}

View File

@@ -1,7 +1,9 @@
import 'package:dio/dio.dart';
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
import 'package:eatvio_app/core/app_router.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
@@ -12,7 +14,6 @@ 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);
@@ -24,18 +25,9 @@ class TokenInterceptor extends Interceptor {
ErrorInterceptorHandler handler,
) async {
if (err.response?.statusCode == 401) {
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,
);
}
await sl<WsManager>().clear();
await TokenStorage.removeTokenInStorage();
AppRouter.navigatorKey.currentContext?.go('/auth-screen');
}
super.onError(err, handler);
}

View File

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

View File

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

View File

@@ -1,22 +0,0 @@
class NotificationPermissionRequester {
Future<void>? _inFlight;
/// Coalesces overlapping calls so the OS prompt is shown at most once at a time.
Future<void> requestOnce(Future<void> Function() requestPermission) async {
final inFlight = _inFlight;
if (inFlight != null) {
await inFlight;
return;
}
final future = requestPermission();
_inFlight = future;
try {
await future;
} finally {
if (identical(_inFlight, future)) {
_inFlight = null;
}
}
}
}

View File

@@ -1,13 +1,6 @@
import 'dart:developer' as developer;
import 'dart:async';
import 'package:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_ended_push_handler.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_ended_push_payload.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_notification_navigator.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
@@ -57,45 +50,7 @@ class PushNavigationHandler {
}
static void _handleMessageInternal(RemoteMessage message, {required String source}) {
final data = Map<String, dynamic>.from(message.data);
final endedPayload = VideoCallEndedPushPayload.tryParse(data);
if (endedPayload != null) {
developer.log(
'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;
}
final videoCallPayload = VideoCallPushPayload.tryParse(data);
if (videoCallPayload != null) {
developer.log(
'PushNavigationHandler: opening incoming video call, source=$source, conversation=${videoCallPayload.conversationUuid}',
name: 'PushNavigationHandler',
);
unawaited(
VideoCallNotificationNavigator.openIncomingCall(
conversationUuid: videoCallPayload.conversationUuid,
counterpartName: videoCallPayload.counterpartName,
counterpartAvatarUrl: videoCallPayload.counterpartAvatarUrl,
localParticipantName: videoCallPayload.localParticipantName,
counterpartIsConsultant: videoCallPayload.counterpartIsConsultant,
entryMode: VideoCallEntryMode.incomingAccepted,
),
);
return;
}
final data = message.data;
final actionUrl = data['action_url'] as String?;
if (actionUrl == null || actionUrl.isEmpty) {
developer.log('PushNavigationHandler: no action_url in message data, source=$source, data=$data', name: 'PushNavigationHandler');

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
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';
@@ -23,10 +24,14 @@ class MainNavigationRoutes {
return [
ShellRoute(
builder: (context, state, child) {
// Инициализация ProfileAvatarCubit при первом использовании ShellRoute
final profileAvatarCubit = context.read<ProfileAvatarCubit>();
WidgetsBinding.instance.addPostFrameCallback((_) {
profileAvatarCubit.ensureFreshForCurrentSession();
});
if (profileAvatarCubit.state is ProfileAvatarStateLoading) {
// Вызываем getMyProfile() только если состояние loading (первый запуск)
WidgetsBinding.instance.addPostFrameCallback((_) {
profileAvatarCubit.getMyProfile();
});
}
return _MainShellWithNavScope(currentLocation: state.matchedLocation, child: child);
},

View File

@@ -15,7 +15,6 @@ import 'package:eatvio_app/features/chat/presentation/pages/empty_chat_screen.da
import 'package:eatvio_app/features/chat/presentation/pages/payment_screen.dart';
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart';
import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart';
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/become_consultant_screen.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart';
import 'package:eatvio_app/features/consultant_rating/presentation/consultant_rating_screen.dart';
@@ -24,7 +23,6 @@ import 'package:eatvio_app/features/create_diet/presentation/diet_preview_screen
import 'package:eatvio_app/features/create_plan/presentation/create_plan_screen.dart';
import 'package:eatvio_app/features/create_recipe/presentation/pages/recipe_create_screen.dart';
import 'package:eatvio_app/features/dev_tools/presentation/dev_tools_screen.dart';
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
import 'package:eatvio_app/features/feed/models/recipes_model.dart';
import 'package:eatvio_app/features/glossary/presentation/pages/glossary_screen.dart';
import 'package:eatvio_app/features/menu/presentation/pages/blocked_users_screen.dart';
@@ -184,26 +182,8 @@ class OtherRoutes {
return DietPreviewScreen(diet: diet, isImageInBase64: isImageInBase64);
},
),
GoRoute(
path: RouteConstants.createPlanScreen,
name: 'createPlanScreen',
builder: (context, state) {
final plan = state.extra is PlanModel ? state.extra as PlanModel : null;
return CreatePlanScreen(plan: plan);
},
),
GoRoute(
path: MyDietsScreen.path,
name: MyDietsScreen.name,
builder: (context, state) {
final initialTabIndex = int.tryParse(
state.uri.queryParameters['initialTabIndex'] ?? '',
);
return MyDietsScreen(
initialTabIndex: initialTabIndex ?? 0,
);
},
),
GoRoute(path: RouteConstants.createPlanScreen, name: 'createPlanScreen', builder: (context, state) => const CreatePlanScreen()),
GoRoute(path: MyDietsScreen.path, name: MyDietsScreen.name, builder: (context, state) => const MyDietsScreen()),
GoRoute(path: MyPlansScreen.path, name: MyPlansScreen.name, builder: (context, state) => const MyPlansScreen()),
GoRoute(path: MyRecipesScreen.path, name: MyRecipesScreen.name, builder: (context, state) => const MyRecipesScreen()),
@@ -264,13 +244,7 @@ class OtherRoutes {
return VideoCallScreen(
conversationUuid: extra['conversationUuid'] as String,
counterpartName: extra['counterpartName'] as String?,
counterpartAvatarUrl: extra['counterpartAvatarUrl'] as String?,
counterpartUserId: extra['counterpartUserId'] as int?,
counterpartIsOnline: extra['counterpartIsOnline'] as bool?,
counterpartLastSeenText: extra['counterpartLastSeenText'] as String?,
counterpartIsConsultant: extra['counterpartIsConsultant'] as bool?,
localParticipantName: extra['localParticipantName'] as String?,
entryMode: _videoCallEntryModeFromRouteExtra(extra['entryMode']),
onBackToChat: extra['onBackToChat'] as void Function()?,
onHangUp: extra['onHangUp'] as void Function()?,
);
@@ -284,15 +258,7 @@ class OtherRoutes {
),
// Consultant routes
GoRoute(
path: RouteConstants.becomeConsultantScreen,
name: 'becomeConsultantScreen',
builder: (context, state) {
final data = state.extra as Map<String, dynamic>?;
final slug = data?['slug'] as String?;
return BecomeConsultantScreen(slug: slug);
},
),
GoRoute(path: RouteConstants.becomeConsultantScreen, name: 'becomeConsultantScreen', builder: (context, state) => const BecomeConsultantScreen()),
GoRoute(
path: RouteConstants.stepperBecomeConsultantScreen,
name: 'stepperBecomeConsultantScreen',
@@ -300,12 +266,7 @@ class OtherRoutes {
final data = state.extra as Map<String, dynamic>;
final slug = data['slug'] as String?;
final isCanceled = data['isCanceled'] as bool?;
final isNewRegistration = data['isNewRegistration'] as bool? ?? false;
return StepperBecomeConsultantScreen(
slug: slug,
isCanceled: isCanceled,
isNewRegistration: isNewRegistration,
);
return StepperBecomeConsultantScreen(slug: slug, isCanceled: isCanceled);
},
),
GoRoute(path: RouteConstants.calendarScreen, name: 'calendarScreen', builder: (context, state) => const CalendarScreen()),
@@ -365,10 +326,9 @@ class OtherRoutes {
path: UserDiaryScreen.path,
name: UserDiaryScreen.name,
builder: (context, state) {
final data = state.extra as Map<String, Object?>;
final data = state.extra as Map<String, int>;
final diaryId = data['diaryId'] as int;
final ownerSlug = data['ownerSlug'] as String?;
return UserDiaryScreen(diaryId: diaryId, ownerSlug: ownerSlug);
return UserDiaryScreen(diaryId: diaryId);
},
),
GoRoute(
@@ -379,11 +339,3 @@ class OtherRoutes {
];
}
}
VideoCallEntryMode _videoCallEntryModeFromRouteExtra(Object? raw) {
if (raw is VideoCallEntryMode) return raw;
if (raw == VideoCallEntryMode.incomingAccepted.name) {
return VideoCallEntryMode.incomingAccepted;
}
return VideoCallEntryMode.outgoing;
}

View File

@@ -1,122 +0,0 @@
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',
);
}
}

View File

@@ -80,48 +80,6 @@ class CustomTextStyles {
static TextStyle get titleSmallSecondaryContainer_1 => theme.textTheme.titleSmall!.copyWith(color: theme.colorScheme.secondaryContainer);
static TextStyle get titleSmallSegoeUIOnPrimary => theme.textTheme.titleSmall!.segoeUI.copyWith(color: theme.colorScheme.onPrimary);
static TextStyle get videoCallFlowPlaceholderTitle => theme.textTheme.headlineSmall!.copyWith(
fontWeight: FontWeight.w600,
height: 32 / 24,
color: appTheme.black900,
);
static TextStyle get videoCallFlowPlaceholderConnectionLostBody => theme.textTheme.bodyLarge!.copyWith(
fontWeight: FontWeight.w400,
fontSize: 16,
height: 24 / 16,
letterSpacing: 0.5,
color: appTheme.black900,
);
static TextStyle get videoCallFlowPlaceholderConnectingSubtitle => theme.textTheme.bodyMedium!.copyWith(
fontSize: 13,
fontWeight: FontWeight.w400,
height: 24 / 13,
letterSpacing: 0.5,
color: appTheme.black900,
);
static TextStyle get videoCallFlowPlaceholderReconnectingSubtitle => theme.textTheme.bodyMedium!.copyWith(
fontSize: 14,
fontWeight: FontWeight.w500,
height: 24 / 14,
color: appTheme.gray600,
);
static TextStyle get videoCallFlowPlaceholderConnectionLostDetail => theme.textTheme.bodyMedium!.copyWith(
fontSize: 13,
fontWeight: FontWeight.w400,
height: 1.35,
color: appTheme.red,
);
static TextStyle get videoCallFlowPlaceholderFilledButtonLabel => theme.textTheme.titleMedium!.copyWith(
fontSize: 16,
fontWeight: FontWeight.w600,
color: appTheme.whiteA700,
);
}
extension on TextStyle {

View File

@@ -1,61 +0,0 @@
/// flutter_svg / vector_graphics ignore `<style>` blocks.
/// Paths then get the SVG default fill (black), so badges look like a black blob.
/// This inlines class rules from `<style>` as attributes before parsing.
String inlineSvgCss(String svg) {
final styleBlockPattern = RegExp(
r'<style[^>]*>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?</style>',
caseSensitive: false,
);
final classProps = <String, Map<String, String>>{};
for (final match in styleBlockPattern.allMatches(svg)) {
final css = match.group(1) ?? '';
final rulePattern = RegExp(r'\.([A-Za-z0-9_-]+)\s*\{([^}]*)\}');
for (final rule in rulePattern.allMatches(css)) {
final className = rule.group(1)!;
final body = rule.group(2)!;
final props = classProps.putIfAbsent(className, () => <String, String>{});
for (final declaration in body.split(';')) {
final colon = declaration.indexOf(':');
if (colon <= 0) {
continue;
}
final key = declaration.substring(0, colon).trim();
final value = declaration.substring(colon + 1).trim();
if (key.isNotEmpty && value.isNotEmpty) {
props[key] = value;
}
}
}
}
var result = svg.replaceAll(styleBlockPattern, '');
if (classProps.isEmpty) {
return result;
}
result = result.replaceAllMapped(RegExp(r'''\sclass=(["'])([^"']+)\1'''), (
Match match,
) {
final classes = match
.group(2)!
.split(RegExp(r'\s+'))
.where((name) => name.isNotEmpty);
final attrs = <String, String>{};
for (final className in classes) {
final props = classProps[className];
if (props != null) {
attrs.addAll(props);
}
}
if (attrs.isEmpty) {
return match.group(0)!;
}
final attrString = attrs.entries
.map((entry) => '${entry.key}="${entry.value}"')
.join(' ');
return ' $attrString';
});
return result;
}

View File

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

View File

@@ -18,7 +18,7 @@ class WsManager {
final ErrorReporter _errorReporter;
WsManager({required this.wsService, required ErrorReporter errorReporter})
: _errorReporter = errorReporter;
: _errorReporter = errorReporter;
PusherChannelsClient? client;
int? userId;
@@ -26,69 +26,20 @@ class WsManager {
PrivateChannel? _privateNotificationChannel;
PrivateChannel? _privateMessagesChannel;
PrivateChannel? _privateDirectChatChannel;
PresenceChannel? _presenceDirectChatChannel;
StreamSubscription? _presenceMemberAddedSubscription;
StreamSubscription? _presenceMemberRemovedSubscription;
StreamSubscription? _presenceSubscriptionSucceededSubscription;
final List<_PresenceDirectChatListener> _presenceDirectChatListeners = [];
bool _presenceChannelEventsBound = false;
StreamSubscription? _lifecycleSubscription;
StreamSubscription? _eventSubscription;
StreamSubscription? _connectionEstablishedSubscription;
/// Широковещательный сигнал о восстановлении соединения после обрыва (S5).
///
/// Контроллер создаётся один раз и не закрывается в [clear]: подписчики
/// (чаты, окно чата) переживают логаут/повторный вход и продолжают получать
/// сигналы. Если контроллер всё же закрыт, он пересоздаётся в [initialize].
StreamController<void> _reconnectController =
StreamController<void>.broadcast();
Stream<ChannelReadEvent>? get newMessagesStream => _privateMessagesChannel?.bind('App\\Events\\ChatMessageUpdated');
Stream<ChannelReadEvent>? _directChatStream;
Stream<ChannelReadEvent>? _clientTypingStream;
Stream<ChannelReadEvent>? _chosenPlanStateChangedStream;
Stream<ChannelReadEvent>? get newNotificationsStream => _privateNotificationChannel?.bind('Illuminate\\Notifications\\Events\\BroadcastNotificationCreated');
Stream<ChannelReadEvent>? get newMessagesStream =>
_privateMessagesChannel?.bind('App\\Events\\ChatMessageUpdated');
Stream<ChannelReadEvent>? get directChatStream => _privateDirectChatChannel?.bind('App\\Events\\ChatMessageUpdated');
/// The callee rejected the ring. Broadcast to the caller and the callee.
Stream<ChannelReadEvent>? get videoCallDeclinedStream =>
_privateMessagesChannel?.bind('video-call-declined');
/// The call was cancelled or hung up. Same channel the web client uses.
Stream<ChannelReadEvent>? get videoCallEndedStream =>
_privateMessagesChannel?.bind('video-call-ended');
/// Another device accepted the ring. Ringing on this device must stop,
/// except on the device that accepted.
Stream<ChannelReadEvent>? get videoCallAcceptedStream =>
_privateMessagesChannel?.bind('video-call-accepted');
Stream<ChannelReadEvent>? get newNotificationsStream =>
_privateNotificationChannel?.bind(
'Illuminate\\Notifications\\Events\\BroadcastNotificationCreated',
);
Stream<ChannelReadEvent>? get directChatStream => _directChatStream;
Stream<ChannelReadEvent>? get clientTypingStream => _clientTypingStream;
Stream<ChannelReadEvent>? get chosenPlanStateChangedStream =>
_chosenPlanStateChangedStream;
/// Сигнал о реконнекте сокета: эмитится после повторной подписки на каналы.
///
/// Подписчики используют его, чтобы догрузить пропущенные за время обрыва
/// сообщения (S5).
Stream<void> get reconnected => _reconnectController.stream;
Stream<ChannelReadEvent>? get clientTypingStream => _privateDirectChatChannel?.bind('client-typing');
Future<void> initialize() async {
if (_reconnectController.isClosed) {
_reconnectController = StreamController<void>.broadcast();
}
if (client == null) {
try {
if (!await TokenStorage.haveTokenInStorage()) return;
@@ -108,9 +59,15 @@ 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) {
@@ -123,9 +80,7 @@ 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);
@@ -142,9 +97,9 @@ class WsManager {
await _initPrivateMessagesChannel();
_lifecycleSubscription = client?.lifecycleStream.listen((data) {
if (data ==
PusherChannelsClientLifeCycleState.establishedConnection) {
handleConnectionEstablished();
if (data == PusherChannelsClientLifeCycleState.establishedConnection) {
_unsubscribe();
_subscribe();
}
});
_eventSubscription = client?.eventStream.listen((event) {
@@ -185,13 +140,12 @@ 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',
},
),
);
}
@@ -199,148 +153,26 @@ 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',
},
),
);
}
Future<void> initPrivateDirectChatChannel(String chatId) async {
_privateDirectChatChannel?.unsubscribe();
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'},
),
);
_directChatStream = _privateDirectChatChannel?.bind(
'App\\Events\\ChatMessageUpdated',
);
_clientTypingStream = _privateDirectChatChannel?.bind('client-typing');
_chosenPlanStateChangedStream = _privateDirectChatChannel?.bind(
'chosen-plan-state-changed',
);
}
Future<void> initPresenceDirectChatChannel(String chatId) async {
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'},
),
);
}
void addPresenceDirectChatListener({
required int counterpartUserId,
required void Function(bool isOnline) onCounterpartOnlineChanged,
}) {
final channel = _presenceDirectChatChannel;
if (channel == null) return;
_presenceDirectChatListeners.add(
_PresenceDirectChatListener(
counterpartUserId: counterpartUserId,
onCounterpartOnlineChanged: onCounterpartOnlineChanged,
authorizationDelegate: EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel(
authorizationEndpoint: Uri.parse('${sl<AppConfig>().baseUrl}/broadcasting/auth'),
headers: {
'Authorization': 'Bearer $token',
},
),
);
_bindPresenceChannelEventsIfNeeded();
channel.subscribe();
_emitCounterpartPresence(counterpartUserId);
}
void removePresenceDirectChatListener(
void Function(bool isOnline) onCounterpartOnlineChanged,
) {
_presenceDirectChatListeners.removeWhere(
(listener) =>
listener.onCounterpartOnlineChanged == onCounterpartOnlineChanged,
);
if (_presenceDirectChatListeners.isEmpty) {
unsubscribePresenceDirectChatChannel();
}
}
void _bindPresenceChannelEventsIfNeeded() {
if (_presenceChannelEventsBound) return;
final channel = _presenceDirectChatChannel;
if (channel == null) return;
_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,
) {
final memberUserId = _parsePresenceMemberUserId(event.data);
if (memberUserId == null) return;
_notifyPresenceListeners(memberUserId, isOnline: true);
});
_presenceMemberRemovedSubscription = channel.whenMemberRemoved().listen((
event,
) {
final memberUserId = _parsePresenceMemberUserId(event.data);
if (memberUserId == null) return;
_notifyPresenceListeners(memberUserId, isOnline: false);
});
}
void _emitCounterpartPresence(int counterpartUserId) {
final channel = _presenceDirectChatChannel;
if (channel == null) return;
final members = channel.state?.members;
if (members == null) return;
final isOnline =
members.getMemberInfo(counterpartUserId.toString()) != null;
_notifyPresenceListeners(counterpartUserId, isOnline: isOnline);
}
void _notifyPresenceListeners(
int counterpartUserId, {
required bool isOnline,
}) {
for (final listener in _presenceDirectChatListeners) {
if (listener.counterpartUserId == counterpartUserId) {
listener.onCounterpartOnlineChanged(isOnline);
}
}
}
int? _parsePresenceMemberUserId(String? data) {
if (data == null || data.isEmpty) return null;
try {
final decoded = jsonDecode(data);
if (decoded is! Map) return null;
final rawId = decoded['user_id'] ?? decoded['id'];
if (rawId == null) return null;
return int.tryParse(rawId.toString());
} catch (_) {
return null;
}
}
void _subscribe() {
@@ -353,41 +185,12 @@ class WsManager {
_privateNotificationChannel?.unsubscribe();
}
/// Повторно подписывается на каналы и сигналит подписчикам о реконнекте.
///
/// Вызывается при `establishedConnection` — в том числе после обрыва сети.
/// Публичный, чтобы сигнал [reconnected] покрывался тестами без реального
/// Pusher-клиента.
void handleConnectionEstablished() {
_unsubscribe();
_subscribe();
_privateDirectChatChannel?.subscribe();
_presenceDirectChatChannel?.subscribe();
_reconnectController.add(null);
}
void subscribeDirectChatChannel() {
_privateDirectChatChannel?.subscribe();
}
void unsubscribeDirectChatChannel() {
_privateDirectChatChannel?.unsubscribe();
_directChatStream = null;
_clientTypingStream = null;
_chosenPlanStateChangedStream = null;
}
void unsubscribePresenceDirectChatChannel() {
_presenceSubscriptionSucceededSubscription?.cancel();
_presenceMemberAddedSubscription?.cancel();
_presenceMemberRemovedSubscription?.cancel();
_presenceSubscriptionSucceededSubscription = null;
_presenceMemberAddedSubscription = null;
_presenceMemberRemovedSubscription = null;
_presenceDirectChatListeners.clear();
_presenceChannelEventsBound = false;
_presenceDirectChatChannel?.unsubscribe();
_presenceDirectChatChannel = null;
}
Future<void> clear() async {
@@ -401,24 +204,10 @@ class WsManager {
client = null;
userId = null;
_privateDirectChatChannel?.unsubscribe();
unsubscribePresenceDirectChatChannel();
_privateMessagesChannel?.unsubscribe();
_privateNotificationChannel?.unsubscribe();
_privateDirectChatChannel = null;
_privateMessagesChannel = null;
_privateNotificationChannel = null;
_directChatStream = null;
_clientTypingStream = null;
_chosenPlanStateChangedStream = null;
}
}
class _PresenceDirectChatListener {
const _PresenceDirectChatListener({
required this.counterpartUserId,
required this.onCounterpartOnlineChanged,
});
final int counterpartUserId;
final void Function(bool isOnline) onCounterpartOnlineChanged;
}

View File

@@ -3,11 +3,7 @@ import 'dart:ui';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:flutter/material.dart';
/// Показывает modal bottom sheet с blur-фоном.
///
/// Дженерик [T] пробрасывает значение, переданное в `Navigator.pop(T)`
/// внутри шита (по умолчанию `null`).
Future<T?> showBottomSheetWithBlur<T>({
Future<void> showBottomSheetWithBlur({
required BuildContext context,
required Widget child,
double? height = 200,
@@ -15,7 +11,7 @@ Future<T?> showBottomSheetWithBlur<T>({
bool enableDrag = true,
bool userRootNavigator = false,
}) {
return showModalBottomSheet<T>(
return showModalBottomSheet(
context: context,
isScrollControlled: true,
barrierColor: appTheme.transparentBlack,

View File

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

View File

@@ -1,57 +0,0 @@
import 'package:eatvio_app/core/utils/inline_svg_css.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:http/http.dart' as http;
final Map<String, String> _inlinedSvgCache = <String, String>{};
class NetworkSvgPicture extends StatefulWidget {
const NetworkSvgPicture({
super.key,
required this.url,
this.width,
this.height,
});
final String url;
final double? width;
final double? height;
@override
State<NetworkSvgPicture> createState() => _NetworkSvgPictureState();
}
class _NetworkSvgPictureState extends State<NetworkSvgPicture> {
late final Future<String> _svgFuture = _load(widget.url);
@override
Widget build(BuildContext context) {
return FutureBuilder<String>(
future: _svgFuture,
builder: (BuildContext context, AsyncSnapshot<String> snapshot) {
if (!snapshot.hasData) {
return SizedBox(width: widget.width, height: widget.height);
}
return SvgPicture.string(
snapshot.data!,
width: widget.width,
height: widget.height,
);
},
);
}
static Future<String> _load(String url) async {
final cached = _inlinedSvgCache[url];
if (cached != null) {
return cached;
}
final response = await http.get(Uri.parse(url));
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception('Failed to load SVG from $url');
}
final processed = inlineSvgCss(response.body);
_inlinedSvgCache[url] = processed;
return processed;
}
}

View File

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

View File

@@ -3,7 +3,6 @@ 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';
@@ -18,26 +17,15 @@ 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,
required this.sessionLifecycleService,
}) : super(const AuthState());
AuthCubit({required this.authRepository, required this.prefs, required this.profileRepository, required this.firebaseApi}) : 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);
@@ -60,8 +48,7 @@ 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);

View File

@@ -1,9 +1,7 @@
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';
@@ -13,24 +11,12 @@ 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 this.firebaseApi,
required this.sessionLifecycleService,
required Prefs prefs,
}) : super(const OAuthState()) {
OAuthCubit({required this.authRepository, 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());
@@ -42,8 +28,7 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
);
log('OAuthCubit: Got token model: ${tokenModel.token}');
await TokenStorage.saveTokenInStorage(tokenModel.token);
await sessionLifecycleService.onAuthenticated();
TokenStorage.saveTokenInStorage(tokenModel.token);
emit(OAuthState.loaded(token: tokenModel.token));
} on DioException catch (e, st) {
await reportFrom(e, st, this);
@@ -60,8 +45,7 @@ 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);
}
}
@@ -69,20 +53,14 @@ 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()));
}
@@ -125,8 +103,7 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
height: height,
clientId: clientId,
);
await TokenStorage.saveTokenInStorage(token);
await sessionLifecycleService.onAuthenticated();
TokenStorage.saveTokenInStorage(token);
cacheRegistrationDate(DateTime.now());
emit(OAuthState.loaded(token: token));
} on DioException catch (e, st) {
@@ -143,8 +120,7 @@ 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);
}
}
@@ -152,17 +128,14 @@ 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) {
@@ -173,23 +146,13 @@ 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());
@@ -221,9 +184,7 @@ 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,
@@ -241,11 +202,8 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
weight: weight,
height: height,
);
log(
'OAuthCubit: OAuth verification confirmed successfully, token received',
);
await TokenStorage.saveTokenInStorage(response.token);
await sessionLifecycleService.onAuthenticated();
log('OAuthCubit: OAuth verification confirmed successfully, token received');
TokenStorage.saveTokenInStorage(response.token);
emit(OAuthState.loaded(token: response.token));
} catch (e, st) {
await reportFrom(e, st, this);
@@ -254,3 +212,4 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
}
}
}

View File

@@ -4,10 +4,12 @@ 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';
@@ -16,6 +18,8 @@ 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';
@@ -64,8 +68,23 @@ class _AuthScreenState extends State<AuthScreen> {
super.dispose();
}
Future<void> _reconnectWebSocket() async {
showSuccessToast('Успешный вход');
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;
}
}
AppRouter.navigatorKey.currentContext?.go('/feed-screen', extra: {'needToInitializeWebSocket': false});
}
@@ -183,7 +202,18 @@ class _AuthScreenState extends State<AuthScreen> {
}
Future<void> _reconnectAfterAuth() async {
showSuccessToast('Успешный вход');
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());
}
AppRouter.navigatorKey.currentContext?.go('/feed-screen', extra: {'needToInitializeWebSocket': false});
}
@@ -365,7 +395,7 @@ class _AuthScreenState extends State<AuthScreen> {
final isAuthenticated = await authCubit.getTokenByCredentials(emailController.text, passwordController.text);
if (isAuthenticated) {
if (context.mounted) {
await _reconnectWebSocket();
await _reconnectWebSocket(context);
}
}
setState(() {

View File

@@ -9,7 +9,11 @@ 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';
@@ -58,8 +62,23 @@ class _RegisterViaSocialScreenState extends State<RegisterViaSocialScreen> {
_initializeFieldsFromAuthResult();
}
Future<void> _reconnectWebSocket() async {
showSuccessToast('Успешная регистрация');
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;
}
}
AppRouter.appRouter.go('/feed-screen', extra: {'needToInitializeWebSocket': false});
}
@@ -432,7 +451,7 @@ class _RegisterViaSocialScreenState extends State<RegisterViaSocialScreen> {
Builder(
builder: (context) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_reconnectWebSocket();
_reconnectWebSocket(context);
});
return const SizedBox.shrink();
},
@@ -776,7 +795,7 @@ class _RegisterViaSocialScreenState extends State<RegisterViaSocialScreen> {
// Перезагружаем веб-сокет и переходим на главный экран
if (widgetContext.mounted) {
_reconnectWebSocket();
_reconnectWebSocket(widgetContext);
}
} catch (e, stackTrace) {
log('OAuth Verification Confirm Error: $e', error: e, stackTrace: stackTrace);

View File

@@ -3,10 +3,13 @@ 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';
@@ -36,10 +39,7 @@ 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,33 +60,30 @@ 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);
await sl<SessionLifecycleService>().onAuthenticated();
AppRouter.navigatorKey.currentContext?.go(
'/feed-screen',
extra: {'needToInitializeWebSocket': false},
);
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});
showSuccessToast('Добро пожаловать!');
} catch (e) {
await controller.resumeCamera();
showErrorToast(
'Что-то пошло не так. Попробуйте обновить страницу с QR-кодом или войдите с помощью логина и пароля.',
);
showErrorToast('Что-то пошло не так. Попробуйте обновить страницу с QR-кодом или войдите с помощью логина и пароля.');
}
});
}

View File

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

View File

@@ -3,195 +3,38 @@ import 'dart:developer';
import 'package:eatvio_app/core/error_reporting/report_from.dart';
import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_state.dart';
import 'package:eatvio_app/features/blog_post_create/repositories/blog_post_repository.dart';
import 'package:eatvio_app/features/blog_post_create/validation/blog_post_form_validator.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_image_compress/flutter_image_compress.dart';
class BlogPostCreateCubit extends Cubit<BlogPostCreateState> {
final BlogPostRepository blogPostRepository;
BlogPostCreateCubit({required this.blogPostRepository})
: super(const BlogPostCreateState());
void clearTitleError() {
if (state.titleError != null || state.formError != null) {
emit(state.copyWith(titleError: null, formError: null));
}
}
void clearDescriptionError() {
if (state.descriptionError != null || state.formError != null) {
emit(state.copyWith(descriptionError: null, formError: null));
}
}
void clearVideoUrlError() {
if (state.videoUrlError != null || state.formError != null) {
emit(state.copyWith(videoUrlError: null, formError: null));
}
}
void clearImagesError() {
if (state.imagesError != null || state.formError != null) {
emit(state.copyWith(imagesError: null, formError: null));
}
}
void setImagesError(String? error) {
if (state.imagesError == error &&
state.formError == null &&
state.errorMessage == null) {
return;
}
emit(
state.copyWith(imagesError: error, formError: null, errorMessage: null),
);
}
void resetFormErrors() {
if (!state.hasValidationErrors &&
!state.isSuccess &&
state.errorMessage == null &&
!state.isLoading) {
return;
}
emit(const BlogPostCreateState());
}
/// Validates form fields and emits error messages on [BlogPostCreateState].
/// Returns `true` when the form is valid.
bool validateForm({
required String title,
required String description,
required String videoUrl,
required int imageCount,
Iterable<String> imagePaths = const [],
}) {
final titleError = BlogPostFormValidator.title(title);
final descriptionError = BlogPostFormValidator.description(description);
final videoUrlError = BlogPostFormValidator.videoUrl(videoUrl);
final imagesError =
BlogPostFormValidator.galleryCount(imageCount) ??
BlogPostFormValidator.imagePaths(imagePaths);
final formError = BlogPostFormValidator.contentRequiredMessage(
title: title,
description: description,
videoUrl: videoUrl,
imageCount: imageCount,
);
final hasFieldErrors =
titleError != null ||
descriptionError != null ||
videoUrlError != null ||
imagesError != null;
final resolvedFormError = hasFieldErrors ? null : formError;
emit(
state.copyWith(
titleError: titleError,
descriptionError: descriptionError,
videoUrlError: videoUrlError,
imagesError: imagesError,
// Form-level message only when individual fields are fine but content is empty.
formError: resolvedFormError,
isSuccess: false,
errorMessage: null,
),
);
return titleError == null &&
descriptionError == null &&
videoUrlError == null &&
imagesError == null &&
resolvedFormError == null;
}
Future<bool> createBlogPost({
String? title,
String? videoUrl,
String? description,
List<XFile> images = const [],
}) async {
final trimmedTitle = title?.trim() ?? '';
final trimmedDescription = description?.trim() ?? '';
final trimmedVideoUrl = videoUrl?.trim() ?? '';
final isValid = validateForm(
title: trimmedTitle,
description: trimmedDescription,
videoUrl: trimmedVideoUrl,
imageCount: images.length,
imagePaths: images.map((image) => image.path),
);
if (!isValid) {
return false;
}
BlogPostCreateCubit({required this.blogPostRepository}) : super(const BlogPostCreateState());
Future<bool> createBlogPost({String? title, String? videoUrl, String? description, XFile? image}) async {
try {
emit(
state.copyWith(isLoading: true, isSuccess: false, errorMessage: null),
);
await blogPostRepository.createBlogPost(
title: trimmedTitle.isEmpty ? null : trimmedTitle,
videoUrl: trimmedVideoUrl.isEmpty ? null : trimmedVideoUrl,
description: trimmedDescription.isEmpty ? null : trimmedDescription,
images: images,
);
emit(const BlogPostCreateState(isSuccess: true));
emit(const BlogPostCreateState.loading());
await blogPostRepository.createBlogPost(title, videoUrl, description, image);
emit(const BlogPostCreateState.loaded());
return true;
} catch (error, stackTrace) {
log('BlogPostCreateCubit: $error', stackTrace: stackTrace);
await reportFrom(error, stackTrace, this);
emit(BlogPostCreateState(errorMessage: error.toString()));
emit(BlogPostCreateState.error(errorMessage: error.toString()));
return false;
}
}
Future<bool> updateBlogPost({
required int id,
String? title,
String? videoUrl,
String? description,
List<int> existingMediaIds = const [],
List<XFile> newImages = const [],
List<int> mediaIdsToDelete = const [],
}) async {
final trimmedTitle = title?.trim() ?? '';
final trimmedDescription = description?.trim() ?? '';
final trimmedVideoUrl = videoUrl?.trim() ?? '';
final imageCount = existingMediaIds.length + newImages.length;
final isValid = validateForm(
title: trimmedTitle,
description: trimmedDescription,
videoUrl: trimmedVideoUrl,
imageCount: imageCount,
imagePaths: newImages.map((image) => image.path),
);
if (!isValid) {
return false;
}
Future<bool> updateBlogPost({required int id, String? title, String? videoUrl, String? description, XFile? image, String? oldImageUrl}) async {
try {
emit(
state.copyWith(isLoading: true, isSuccess: false, errorMessage: null),
);
await blogPostRepository.updateBlogPost(
id: id,
title: trimmedTitle,
videoUrl: trimmedVideoUrl,
description: trimmedDescription,
existingMediaIds: existingMediaIds,
newImages: newImages,
mediaIdsToDelete: mediaIdsToDelete,
);
emit(const BlogPostCreateState(isSuccess: true));
emit(const BlogPostCreateState.loading());
await blogPostRepository.updateBlogPost(id, title, videoUrl, description, image, oldImageUrl);
emit(const BlogPostCreateState.loaded());
return true;
} catch (error, stackTrace) {
log('BlogPostCreateCubit: $error', stackTrace: stackTrace);
await reportFrom(error, stackTrace, this);
emit(BlogPostCreateState(errorMessage: error.toString()));
emit(BlogPostCreateState.error(errorMessage: error.toString()));
return false;
}
}

View File

@@ -4,23 +4,13 @@ part 'blog_post_create_state.freezed.dart';
@freezed
abstract class BlogPostCreateState with _$BlogPostCreateState {
const BlogPostCreateState._();
const factory BlogPostCreateState() = _BlogPostCreateState;
const factory BlogPostCreateState({
@Default(false) bool isLoading,
@Default(false) bool isSuccess,
String? errorMessage,
String? titleError,
String? descriptionError,
String? videoUrlError,
String? imagesError,
String? formError,
}) = _BlogPostCreateState;
const factory BlogPostCreateState.loading() = BlogPostCreateStateLoading;
bool get hasValidationErrors =>
titleError != null ||
descriptionError != null ||
videoUrlError != null ||
imagesError != null ||
formError != null;
const factory BlogPostCreateState.loaded() = BlogPostCreateStateLoaded;
const factory BlogPostCreateState.error({
required String errorMessage,
}) = BlogPostCreateStateError;
}

View File

@@ -1,122 +1,79 @@
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:http/http.dart' as http;
import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart';
import 'package:eatvio_app/features/blog_post_create/services/blog_post_service.dart';
import 'package:flutter_image_compress/flutter_image_compress.dart';
import 'package:path_provider/path_provider.dart';
class BlogPostRepository {
static const maxGalleryImages = 10;
final BlogPostService blogPostService;
BlogPostRepository({required this.blogPostService});
BlogPostRepository({
required this.blogPostService,
});
Future<void> createBlogPost({
Future<void> createBlogPost(
String? title,
String? videoUrl,
String? description,
List<XFile> images = const [],
}) async {
final formData = await _buildFormData(
title: title,
videoUrl: videoUrl,
description: description,
newImages: images,
);
XFile? image,
) async {
final formData = FormData.fromMap({
if (title != null && title.isNotEmpty) 'title': title,
if (description != null && description.isNotEmpty) 'description_md': description,
if (videoUrl != null && videoUrl.isNotEmpty) 'video_url': videoUrl,
if (image != null) 'image': await MultipartFile.fromFile((await ImageCompressUtility.compressFile(File(image.path))).path, filename: image.name),
});
return blogPostService.createBlogPost(formData: formData);
return blogPostService.createBlogPost(
formData: formData,
);
}
Future<void> updateBlogPost({
required int id,
String? title,
String? videoUrl,
String? description,
List<int> existingMediaIds = const [],
List<XFile> newImages = const [],
List<int> mediaIdsToDelete = const [],
}) async {
if (mediaIdsToDelete.isNotEmpty) {
await Future.wait(
mediaIdsToDelete.map(
(mediaId) =>
blogPostService.deleteBlogPostMedia(postId: id, mediaId: mediaId),
),
);
Future<XFile?> downloadImage(String imageUrl) async {
final response = await http.get(Uri.parse('${sl<AppConfig>().baseUrl}/storage/$imageUrl'));
if (response.statusCode == 200) {
final directory = await getTemporaryDirectory();
final filePath = '${directory.path}/image.jpg';
final file = File(filePath);
await file.writeAsBytes(response.bodyBytes);
return XFile(file.path);
}
final formData = await _buildFormData(
title: title ?? '',
videoUrl: videoUrl ?? '',
description: description ?? '',
existingMediaIds: existingMediaIds,
newImages: newImages,
includeExistingMediaIds: true,
includeEmptyTextFields: true,
);
return blogPostService.updateBlogPost(id: id, formData: formData);
return null;
}
Future<void> deleteBlogPostMedia({
required int postId,
required int mediaId,
}) async {
await blogPostService.deleteBlogPostMedia(postId: postId, mediaId: mediaId);
Future<void> updateBlogPost(
int id,
String? title,
String? videoUrl,
String? description,
XFile? image,
String? oldImageUrl,
) async {
final formData = FormData.fromMap({
'title': title,
'description_md': description,
'video_url': videoUrl,
'image': image != null
? await MultipartFile.fromFile((await ImageCompressUtility.compressFile(File(image.path))).path, filename: image.name)
: oldImageUrl != '' && oldImageUrl != null
? await MultipartFile.fromFile((await downloadImage(oldImageUrl))?.path ?? '', filename: oldImageUrl.split('/').last)
: null,
});
return blogPostService.updateBlogPost(
id: id,
formData: formData,
);
}
Future<void> deleteBlogPost(int id) async {
await blogPostService.deleteBlogPost(id: id);
}
Future<FormData> _buildFormData({
String? title,
String? videoUrl,
String? description,
List<int> existingMediaIds = const [],
List<XFile> newImages = const [],
bool includeExistingMediaIds = false,
bool includeEmptyTextFields = false,
}) async {
final formData = FormData();
if (includeEmptyTextFields) {
formData.fields.add(MapEntry('title', title ?? ''));
formData.fields.add(MapEntry('description_md', description ?? ''));
formData.fields.add(MapEntry('video_url', videoUrl ?? ''));
} else {
if (title != null && title.isNotEmpty) {
formData.fields.add(MapEntry('title', title));
}
if (description != null && description.isNotEmpty) {
formData.fields.add(MapEntry('description_md', description));
}
if (videoUrl != null && videoUrl.isNotEmpty) {
formData.fields.add(MapEntry('video_url', videoUrl));
}
}
if (includeExistingMediaIds) {
for (final mediaId in existingMediaIds) {
formData.fields.add(
MapEntry('existing_media_ids[]', mediaId.toString()),
);
}
}
for (final image in newImages) {
final compressed = await ImageCompressUtility.compressFile(
File(image.path),
);
formData.files.add(
MapEntry(
'images[]',
await MultipartFile.fromFile(compressed.path, filename: image.name),
),
);
}
return formData;
}
}

View File

@@ -7,23 +7,21 @@ part 'blog_post_service.g.dart';
abstract class BlogPostService {
factory BlogPostService(Dio dio, {String baseUrl}) = _BlogPostService;
@POST('/api/v2/blogPosts/create/published')
@POST('/api/blogPosts/create/published')
@MultiPart()
Future<void> createBlogPost({@Body() required FormData formData});
Future<void> createBlogPost({
@Body() required FormData formData,
});
@PUT('/api/v2/blogPosts/update/published/{id}')
@POST('/api/blogPosts/update/published/{id}')
@MultiPart()
Future<void> updateBlogPost({
@Path('id') required int id,
@Body() required FormData formData,
});
@DELETE('/api/v2/blogPosts/{postId}/media/{mediaId}')
Future<void> deleteBlogPostMedia({
@Path('postId') required int postId,
@Path('mediaId') required int mediaId,
});
@DELETE('/api/blogPosts/delete/{id}')
Future<void> deleteBlogPost({@Path('id') required int id});
Future<void> deleteBlogPost({
@Path('id') required int id,
});
}

View File

@@ -1,158 +0,0 @@
/// Client-side validation aligned with backend BlogPost V2 create/update rules.
abstract final class BlogPostFormValidator {
static const minTextLength = 3;
static const maxVideoUrlLength = 2048;
static const maxGalleryImages = 10;
static const allowedImageExtensions = {'jpg', 'jpeg', 'png'};
static const titleTooShort = 'Заголовок должен содержать минимум 3 символа';
static const descriptionTooShort =
'Описание должно содержать минимум 3 символа';
static const contentRequired =
'Добавьте заголовок, описание, изображение или ссылку на видео';
static const galleryTooLarge = 'Можно загрузить не более 10 изображений';
static const imageTypeInvalid = 'Допустимы только изображения JPG или PNG';
static const videoUrlTooLong = 'Ссылка на видео слишком длинная';
static const videoUrlInvalid = 'Укажите корректную ссылку на видео';
static const videoUrlUnsupportedHost =
'Поддерживаются только Rutube, VK и OK';
static const videoUrlRutubeFormat = 'Неверный формат ссылки Rutube';
static const videoUrlVkFormat = 'Неверный формат ссылки VK';
static const videoUrlOkFormat = 'Неверный формат ссылки OK';
/// Empty is allowed; non-empty must be at least [minTextLength] chars (after trim).
static String? title(String? value) {
final text = value?.trim() ?? '';
if (text.isEmpty) {
return null;
}
if (text.length < minTextLength) {
return titleTooShort;
}
return null;
}
/// Empty is allowed; non-empty must be at least [minTextLength] chars (after trim).
static String? description(String? value) {
final text = value?.trim() ?? '';
if (text.isEmpty) {
return null;
}
if (text.length < minTextLength) {
return descriptionTooShort;
}
return null;
}
/// Empty is allowed; non-empty must match allowed video hosts/paths.
static String? videoUrl(String? value) {
final url = value?.trim() ?? '';
if (url.isEmpty) {
return null;
}
if (url.length > maxVideoUrlLength) {
return videoUrlTooLong;
}
final uri = Uri.tryParse(url);
if (uri == null || !uri.hasScheme || uri.host.isEmpty) {
return videoUrlInvalid;
}
if (uri.scheme != 'http' && uri.scheme != 'https') {
return videoUrlInvalid;
}
final host = uri.host.toLowerCase();
if (!_hostMatchesRootAllowlist(host, const [
'vk.com',
'vkvideo.ru',
'ok.ru',
'rutube.ru',
])) {
return videoUrlUnsupportedHost;
}
final path = uri.path;
if (_hostMatchesRootAllowlist(host, const ['rutube.ru'])) {
if (!_rutubePath.hasMatch(path)) {
return videoUrlRutubeFormat;
}
return null;
}
if (_hostMatchesRootAllowlist(host, const ['vk.com', 'vkvideo.ru'])) {
if (!_vkPath.hasMatch(path)) {
return videoUrlVkFormat;
}
return null;
}
if (_hostMatchesRootAllowlist(host, const ['ok.ru'])) {
if (!_okPath.hasMatch(path)) {
return videoUrlOkFormat;
}
}
return null;
}
/// At least one of title / description / images / video must be meaningfully filled.
static String? contentRequiredMessage({
required String title,
required String description,
required String videoUrl,
required int imageCount,
}) {
final hasTitle = title.trim().length >= minTextLength;
final hasDescription = description.trim().length >= minTextLength;
final trimmedVideo = videoUrl.trim();
final hasVideo =
trimmedVideo.isNotEmpty &&
BlogPostFormValidator.videoUrl(trimmedVideo) == null;
final hasImages = imageCount > 0;
if (hasTitle || hasDescription || hasVideo || hasImages) {
return null;
}
return contentRequired;
}
static String? galleryCount(int count) {
if (count > maxGalleryImages) {
return galleryTooLarge;
}
return null;
}
/// Returns error if any path has a non-allowed extension. Empty path is ignored.
static String? imagePaths(Iterable<String> paths) {
for (final path in paths) {
final name = path.split(RegExp(r'[\\/]')).last;
final dot = name.lastIndexOf('.');
if (dot < 0 || dot == name.length - 1) {
return imageTypeInvalid;
}
final ext = name.substring(dot + 1).toLowerCase();
if (!allowedImageExtensions.contains(ext)) {
return imageTypeInvalid;
}
}
return null;
}
static final _rutubePath = RegExp(
r'^/(video|shorts)/[a-z0-9]+/?$',
caseSensitive: false,
);
static final _vkPath = RegExp(r'^/video-?\d+_\d+/?$', caseSensitive: false);
static final _okPath = RegExp(r'^/video/\d+/?$');
static bool _hostMatchesRootAllowlist(
String host,
List<String> allowedRootHosts,
) {
for (final allowed in allowedRootHosts) {
if (host == allowed || host.endsWith('.$allowed')) {
return true;
}
}
return false;
}
}

View File

@@ -6,13 +6,6 @@ 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));

View File

@@ -1,22 +1,18 @@
import 'dart:async';
import 'package:appmetrica_plugin/appmetrica_plugin.dart';
import 'package:eatvio_app/core/token_storage.dart';
import 'package:eatvio_app/core/app_router.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());
int _requestId = 0;
int? _sessionFingerprint;
bool _ensuring = false;
ProfileAvatarCubit({required this.profileRepository}) : super(const ProfileAvatarState.loading());
int _calculateAge(String birthdate) {
try {
@@ -29,8 +25,7 @@ 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--;
}
@@ -43,82 +38,30 @@ 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);
if (isClosed || requestId != _requestId) return;
AppRouter.navigatorKey.currentContext?.go('/auth-screen');
emit(ProfileAvatarState.error(errorMessage: e.toString()));
}
}

View File

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

View File

@@ -31,14 +31,9 @@ abstract class ChatMessageModel with _$ChatMessageModel {
@JsonKey(name: 'author_id', fromJson: _parseAuthorId) required int authorId,
@JsonKey(name: 'updated_at', fromJson: _parseUpdatedAt) required DateTime updatedAt,
@JsonKey(name: 'read_by_to_user', defaultValue: false) required bool isRead,
// @deprecated Backend BC-поле. В broadcast (ChatMessageUpdated) оно
// recipient-relative и для второго устройства отправителя всегда false,
// поэтому решать «моё/не моё» по нему нельзя. Источник истины — authorId:
// сравнивайте его со своим user_id (wsManager.userId). Поле оставлено
// только для обратной совместимости парсинга.
@JsonKey(name: 'is_author_message_auth_user') required bool? isCurrentUserAuthor,
@JsonKey(name: 'room_id') required String? roomId,
@JsonKey(name: 'type', unknownEnumValue: ChatMessageType.unknown) ChatMessageType? type,
@JsonKey(name: 'type') String? type,
@Default(MessageState.sent) @JsonKey(includeToJson: false, includeFromJson: false) MessageState messageState,
@Default(null) @JsonKey(includeToJson: false, includeFromJson: false) String? uuid,
}) = _ChatMessageModel;
@@ -47,22 +42,3 @@ abstract class ChatMessageModel with _$ChatMessageModel {
}
enum MessageState { loading, sent, error }
enum ChatMessageType {
@JsonValue('text')
text,
@JsonValue('system')
system,
@JsonValue('attachment')
attachment,
@JsonValue('image')
image,
@JsonValue('plan_order')
planOrder,
@JsonValue('video_call_started')
videoCallStarted,
@JsonValue('video_call_ended')
videoCallEnded,
@JsonValue('unknown')
unknown;
}

View File

@@ -54,8 +54,6 @@ abstract class ChatUser with _$ChatUser {
@JsonKey(name: "name") required String name,
@JsonKey(name: "profile_photo_path") required String? profilePhotoPath,
@JsonKey(name: "profile_photo_url") required String profilePhotoUrl,
@Default(false) @JsonKey(name: 'is_online') bool isOnline,
@Default('') @JsonKey(name: 'last_seen_text') String lastSeenText,
}) = _ChatUser;
factory ChatUser.fromJson(Map<String, dynamic> json) => _$ChatUserFromJson(json);

View File

@@ -10,8 +10,6 @@ abstract class ChatUserModel with _$ChatUserModel {
required String name,
required String slug,
@JsonKey(name: 'profile_photo_url') String? profilePhotoUrl,
@Default(false) @JsonKey(name: 'is_online') bool isOnline,
@Default('') @JsonKey(name: 'last_seen_text') String lastSeenText,
}) = _ChatUserModel;
factory ChatUserModel.fromJson(Map<String, Object?> json) => _$ChatUserModelFromJson(json);

View File

@@ -16,7 +16,6 @@ import 'package:eatvio_app/features/chat/models/plan_complain_model.dart';
import 'package:eatvio_app/features/chat/presentation/bloc/chat_room_cubit/chat_room_state.dart';
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
import 'package:eatvio_app/features/chat/utils/chat_counterpart_utils.dart';
import 'package:eatvio_app/features/my_finance/models/card_model.dart';
import 'package:eatvio_app/features/my_finance/repositories/cards_repository.dart';
import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart';
@@ -31,29 +30,17 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
StreamSubscription<ChannelReadEvent>? typingSubscription;
StreamSubscription<ChannelReadEvent>? messagesSubscription;
StreamSubscription<ChannelReadEvent>? _chosenPlanStateSubscription;
StreamSubscription<void>? _reconnectSubscription;
bool _isUpdatingChosenPlan = false;
int currentPage = 1;
late int lastPage;
bool isLoading = false;
Timer? _timer;
String? _activeChatUuid;
void Function(bool isOnline)? _presenceOnlineListener;
var uuid = const Uuid();
ChatRoomCubit({
required this.chatRepository,
required this.wsManager,
required this.cardsRepository,
}) : super(const ChatRoomState.loading());
ChatRoomCubit({required this.chatRepository, required this.wsManager, required this.cardsRepository}) : super(const ChatRoomState.loading());
void setTimer() {
_timer?.cancel();
_timer = Timer.periodic(
const Duration(seconds: 5),
(_) => updateChosenPlanState(),
);
_timer = Timer.periodic(const Duration(seconds: 5), (_) => updateChosenPlanState());
}
void cancelTimer() {
@@ -64,22 +51,16 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
if (currentPage < lastPage && !isLoading) {
isLoading = true;
_emitLoaded((current) => current.copyWith(isLoading: isLoading));
emit((state as ChatRoomStateLoaded).copyWith(isLoading: isLoading));
try {
final chatMessages = await chatRepository.getChatMessages(
uuid,
currentPage + 1,
);
final chatMessages = await chatRepository.getChatMessages(uuid, currentPage + 1);
isLoading = false;
_emitLoaded(
(current) => current.copyWith(
emit(
(state as ChatRoomStateLoaded).copyWith(
isLoading: isLoading,
chatMessages: [
...current.chatMessages,
...chatMessages.messagesList,
],
chatMessages: [...(state as ChatRoomStateLoaded).chatMessages, ...chatMessages.messagesList],
),
);
@@ -102,8 +83,11 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
}
void _setCard({required List<CardModel> cards}) {
if (cards.where((card) => card.isDefault).isNotEmpty) {
_emitLoaded((current) => current.copyWith(hasCard: true));
final currentState = state;
if (currentState is ChatRoomStateLoaded) {
if (cards.where((card) => card.isDefault).isNotEmpty) {
emit(currentState.copyWith(hasCard: true));
}
}
}
@@ -113,11 +97,7 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
/// `GET /api/consultation/resolve/order_id_to_conversation/{orderId}`.
///
/// Возвращает conversation uuid, которым инициализировали чат (для reload без повторного resolve).
Future<String?> initChat(
String? uuid, {
String? orderId,
bool sendInitialMessage = false,
}) async {
Future<String?> initChat(String? uuid, {String? orderId, bool sendInitialMessage = false}) async {
try {
emit(const ChatRoomState.loading());
@@ -125,9 +105,7 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
int? resolvedChosenPlanId;
if (orderId != null && orderId.isNotEmpty) {
final resolved = await chatRepository.resolveOrderIdToConversation(
orderId: orderId,
);
final resolved = await chatRepository.resolveOrderIdToConversation(orderId: orderId);
resolvedUuid = resolved.conversationUuid;
resolvedChosenPlanId = resolved.chosenPlanId;
}
@@ -136,23 +114,15 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
throw ArgumentError('initChat requires uuid or orderId');
}
currentPage = 1;
isLoading = false;
_activeChatUuid = resolvedUuid;
final chatRoom = await chatRepository.getChatRoom(resolvedUuid);
ChosenPlanModel? chosenPlan;
final planId = resolvedChosenPlanId ?? chatRoom.chosenPlanId;
if (planId != null) {
chosenPlan = await chatRepository.getChosenPlan(id: planId);
}
final chatMessages = await chatRepository.getChatMessages(
resolvedUuid,
currentPage,
);
final chatMessages = await chatRepository.getChatMessages(resolvedUuid, currentPage);
lastPage = chatMessages.meta.lastPage;
await _subscribeToChatChannel(uuid: resolvedUuid, chatId: chatRoom.id);
_listenToReconnect();
emit(
ChatRoomState.loaded(
chatRoom: chatRoom.copyWith(chosenPlan: chosenPlan),
@@ -162,7 +132,6 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
hasCard: false,
),
);
await _subscribeToPresenceChannel(chatId: chatRoom.id);
if (chosenPlan?.consultantId == chatRoom.users.sender.id) {
await checkCards();
@@ -173,120 +142,24 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
log(e.toString());
typingSubscription?.cancel();
messagesSubscription?.cancel();
_chosenPlanStateSubscription?.cancel();
_reconnectSubscription?.cancel();
_reconnectSubscription = null;
wsManager.unsubscribeDirectChatChannel();
emit(const ChatRoomState.error(errorMessage: 'Ошибка получеия данных'));
return null;
}
}
/// Подписывается на реконнект сокета, чтобы догрузить пропущенное (S5).
void _listenToReconnect() {
_reconnectSubscription?.cancel();
_reconnectSubscription = wsManager.reconnected.listen((_) {
unawaited(refreshCurrentChat());
});
}
/// Догружает первую страницу сообщений после восстановления соединения (S5).
///
/// Мерж по `id` сохраняет уже подгруженные старые страницы: пропущенные
/// сообщения добавляются, существующие не дублируются. Порядок — по
/// `updatedAt` по убыванию, как его ждёт UI и отдаёт backend (S1).
Future<void> refreshCurrentChat() async {
final uuid = _activeChatUuid;
if (uuid == null) return;
try {
final page = await chatRepository.getChatMessages(uuid, 1);
lastPage = page.meta.lastPage;
_emitLoaded((current) {
final byId = {
for (final message in current.chatMessages) message.id: message,
};
for (final message in page.messagesList) {
byId.putIfAbsent(message.id, () => message);
}
final merged = byId.values.toList()
..sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
return current.copyWith(chatMessages: merged);
});
} catch (e, st) {
await reportFrom(e, st, this);
}
}
/// Перезагружает сообщения, комнату и состояние плана без экрана загрузки.
Future<void> refreshChat() async {
final uuid = _activeChatUuid;
if (uuid == null || uuid.isEmpty) {
return;
}
final currentState = state;
if (currentState is! ChatRoomStateLoaded) {
return;
}
try {
currentPage = 1;
isLoading = false;
final chatRoom = await chatRepository.getChatRoom(uuid);
ChosenPlanModel? chosenPlan;
final planId =
chatRoom.chosenPlanId ?? currentState.chatRoom.chosenPlanId;
if (planId != null) {
chosenPlan = await chatRepository.getChosenPlan(id: planId);
}
final chatMessages = await chatRepository.getChatMessages(
uuid,
currentPage,
);
lastPage = chatMessages.meta.lastPage;
_emitLoaded(
(latest) => latest.copyWith(
chatRoom: chatRoom.copyWith(chosenPlan: chosenPlan),
chatMessages: chatMessages.messagesList,
isLoading: false,
),
);
if (chosenPlan?.consultantId == chatRoom.users.sender.id) {
await checkCards();
}
} catch (e, st) {
await reportFrom(e, st, this);
}
}
Future<void> updateChosenPlanState() async {
if (_isUpdatingChosenPlan) return;
try {
_isUpdatingChosenPlan = true;
final currentState = state;
if (currentState is! ChatRoomStateLoaded) {
return;
if (currentState is ChatRoomStateLoaded) {
final chosenPlanId = currentState.chatRoom.chosenPlanId;
if (chosenPlanId != null) {
final chosenPlan = await chatRepository.getChosenPlan(id: chosenPlanId);
emit(currentState.copyWith.chatRoom(chosenPlan: chosenPlan));
}
}
final chosenPlanId =
currentState.chatRoom.chosenPlanId ??
currentState.chatRoom.chosenPlan?.id;
if (chosenPlanId == null) {
return;
}
final chosenPlan = await chatRepository.getChosenPlan(id: chosenPlanId);
if (isClosed) return;
_emitLoaded(
(latest) => latest.copyWith(
chatRoom: latest.chatRoom.copyWith(chosenPlan: chosenPlan),
),
);
} catch (e, st) {
await reportFrom(e, st, this);
} finally {
_isUpdatingChosenPlan = false;
}
}
@@ -296,16 +169,9 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
final planId = (state as ChatRoomStateLoaded).chatRoom.chosenPlanId;
if (planId == null) return false;
final stringDate = date.format('yyyy-MM-dd');
await chatRepository.choseStartPlanDate(
id: planId,
date: {'start_date': stringDate},
);
await chatRepository.choseStartPlanDate(id: planId, date: {'start_date': stringDate});
final chosenPlan = await chatRepository.getChosenPlan(id: planId);
emit(
(state as ChatRoomStateLoaded).copyWith.chatRoom(
chosenPlan: chosenPlan,
),
);
emit((state as ChatRoomStateLoaded).copyWith.chatRoom(chosenPlan: chosenPlan));
return true;
} catch (e, st) {
await reportFrom(e, st, this);
@@ -314,10 +180,7 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
}
}
Future<void> cancelChosenPlan({
required String chatId,
required int planId,
}) async {
Future<void> cancelChosenPlan({required String chatId, required int planId}) async {
try {
await chatRepository.cancelChosenPlan(id: planId);
emit((state as ChatRoomStateLoaded).copyWith.chatRoom(chosenPlan: null));
@@ -327,310 +190,93 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
}
}
bool _emitLoaded(
ChatRoomStateLoaded Function(ChatRoomStateLoaded current) update,
) {
if (isClosed) {
return false;
}
final currentState = state;
if (currentState is! ChatRoomStateLoaded) {
return false;
}
emit(update(currentState));
return true;
}
Future<void> _subscribeToChatChannel({
required String uuid,
required String chatId,
}) async {
// Активный чат запоминаем до проверки клиента: он нужен и для догрузки
// пропущенного после реконнекта (S5), даже если сокет появится позже.
_activeChatUuid = uuid;
Future<void> _subscribeToChatChannel({required String uuid, required String chatId}) async {
final client = wsManager.client;
if (client == null) return;
await messagesSubscription?.cancel();
await typingSubscription?.cancel();
await _chosenPlanStateSubscription?.cancel();
messagesSubscription = null;
typingSubscription = null;
_chosenPlanStateSubscription = null;
await wsManager.initPrivateDirectChatChannel(chatId);
wsManager.subscribeDirectChatChannel();
messagesSubscription = wsManager.directChatStream?.listen(
_onDirectChatEvent,
onError: (Object e, StackTrace st) {
unawaited(reportFrom(e, st, this));
},
);
messagesSubscription = wsManager.directChatStream?.listen((event) {
AudioPlayer().play((AssetSource('audio/recive_message.wav')));
final message = ChatMessageModel.fromJson(jsonDecode(event.data));
final currentState = state as ChatRoomStateLoaded;
// Сообщения от текущего пользователя не приходят через WebSocket,
// они обрабатываются через ответ API в sendMessage
// Здесь обрабатываем только входящие сообщения от других пользователей
List<ChatMessageModel> messagesList = [message, ...currentState.chatMessages];
chatRepository.emitNewMessageToChats(message);
emit(currentState.copyWith(chatMessages: messagesList, isLoading: isLoading));
EasyDebounce.debounce('searchChatRoom', const Duration(milliseconds: 300), () => sl<ChatsCubit>().getChatRooms());
});
typingSubscription = wsManager.clientTypingStream?.listen((event) {
_emitLoaded(
(current) => current.copyWith(isTyping: true, isLoading: isLoading),
);
emit((state as ChatRoomStateLoaded).copyWith(isTyping: true, isLoading: isLoading));
EasyDebounce.debounce(
'client-typing',
const Duration(seconds: 3),
() => _emitLoaded(
(current) => current.copyWith(isTyping: false, isLoading: isLoading),
),
() => emit((state as ChatRoomStateLoaded).copyWith(isTyping: false, isLoading: isLoading)),
);
});
_chosenPlanStateSubscription = wsManager
.chosenPlanStateChangedStream
?.listen(
(_) {
EasyDebounce.debounce(
'chosen-plan-state-changed',
const Duration(milliseconds: 250),
() => unawaited(updateChosenPlanState()),
);
},
onError: (Object e, StackTrace st) {
unawaited(reportFrom(e, st, this));
},
);
}
void _onDirectChatEvent(ChannelReadEvent event) {
try {
if (addIncomingMessage(
ChatMessageModel.fromJson(
jsonDecode(event.data) as Map<String, dynamic>,
),
)) {
unawaited(_playIncomingSound());
}
} catch (e, st) {
unawaited(reportFrom(e, st, this));
}
}
Future<void> _playIncomingSound() async {
try {
await AudioPlayer().play(AssetSource('audio/recive_message.wav'));
} catch (_) {}
}
/// Добавляет входящее сообщение в текущий список, не затирая более новый state.
///
/// Собственное сообщение, пришедшее по сокету, — это эхо с другого устройства
/// автора (`author_id == wsManager.userId`). Такое сообщение тоже добавляется
/// в открытый чат, но не считается входящим: звук не играет, а список чатов
/// обновляет S3 через `new-chat-messages.{senderId}`.
///
/// Возвращает `true`, только если нужно проиграть звук входящего (чужое
/// сообщение, реально добавленное).
bool addIncomingMessage(ChatMessageModel message) {
final currentState = state;
if (currentState is! ChatRoomStateLoaded) {
return false;
}
final currentUserId = wsManager.userId;
final isOwn = currentUserId != null && message.authorId == currentUserId;
// Дедуп: по серверному id и по uuid (если broadcast вернул наш uuid, т.е.
// это эхо уже добавленного оптимистичного сообщения).
final alreadyExists = currentState.chatMessages.any(
(existing) =>
(message.id != 0 && existing.id == message.id) ||
(message.uuid != null && existing.uuid == message.uuid),
);
if (alreadyExists) {
return false;
}
final added = _emitLoaded((current) {
if (current.chatMessages.any(
(existing) =>
(message.id != 0 && existing.id == message.id) ||
(message.uuid != null && existing.uuid == message.uuid),
)) {
return current;
}
return current.copyWith(
chatMessages: [message, ...current.chatMessages],
isLoading: isLoading,
);
});
if (!added) {
return false;
}
// Список чатов за собственные сообщения обновляет S3 (new-chat-messages.{senderId}).
if (!isOwn) {
chatRepository.emitNewMessageToChats(message);
}
return !isOwn;
}
Future<void> _subscribeToPresenceChannel({required String chatId}) async {
final currentState = state;
if (currentState is! ChatRoomStateLoaded) return;
final counterpart = resolveChatCounterpart(
users: currentState.chatRoom.users,
currentUserId: wsManager.userId,
);
await wsManager.initPresenceDirectChatChannel(chatId);
_presenceOnlineListener = (isOnline) {
unawaited(
_handleCounterpartOnlineChanged(
counterpartField:
counterpart.id == currentState.chatRoom.users.receiver.id
? _ChatCounterpartField.receiver
: _ChatCounterpartField.sender,
isOnline: isOnline,
),
);
};
wsManager.addPresenceDirectChatListener(
counterpartUserId: counterpart.id,
onCounterpartOnlineChanged: _presenceOnlineListener!,
);
}
Future<void> _handleCounterpartOnlineChanged({
required _ChatCounterpartField counterpartField,
required bool isOnline,
}) async {
final currentState = state;
if (currentState is! ChatRoomStateLoaded) return;
final counterpart = counterpartField == _ChatCounterpartField.receiver
? currentState.chatRoom.users.receiver
: currentState.chatRoom.users.sender;
if (counterpart.isOnline == isOnline &&
(isOnline || counterpart.lastSeenText.isNotEmpty)) {
return;
}
if (!isOnline) {
await _refreshCounterpartOnlineStatus(counterpartField: counterpartField);
return;
}
_emitCounterpartOnlineStatus(
counterpartField: counterpartField,
isOnline: true,
lastSeenText: '',
);
}
Future<void> _refreshCounterpartOnlineStatus({
required _ChatCounterpartField counterpartField,
}) async {
final chatUuid = _activeChatUuid;
if (chatUuid == null) return;
try {
final chatRoom = await chatRepository.getChatRoom(chatUuid);
final currentState = state;
if (currentState is! ChatRoomStateLoaded) return;
final counterpart = resolveChatCounterpart(
users: chatRoom.users,
currentUserId: wsManager.userId,
);
final field = counterpart.id == currentState.chatRoom.users.receiver.id
? _ChatCounterpartField.receiver
: _ChatCounterpartField.sender;
if (field != counterpartField) return;
_emitCounterpartOnlineStatus(
counterpartField: counterpartField,
isOnline: counterpart.isOnline,
lastSeenText: counterpart.lastSeenText,
);
} catch (e, st) {
await reportFrom(e, st, this);
}
}
void _emitCounterpartOnlineStatus({
required _ChatCounterpartField counterpartField,
required bool isOnline,
required String lastSeenText,
}) {
final currentState = state;
if (currentState is! ChatRoomStateLoaded) return;
final users = currentState.chatRoom.users;
final updatedUsers = counterpartField == _ChatCounterpartField.receiver
? users.copyWith(
receiver: users.receiver.copyWith(
isOnline: isOnline,
lastSeenText: lastSeenText,
),
)
: users.copyWith(
sender: users.sender.copyWith(
isOnline: isOnline,
lastSeenText: lastSeenText,
),
);
emit(
currentState.copyWith(
chatRoom: currentState.chatRoom.copyWith(users: updatedUsers),
),
);
}
Future<bool> sendMessage({
String? data,
List<File>? files,
required String userSlug,
}) async {
Future<bool> sendMessage({String? data, List<File>? files, required String userSlug}) async {
try {
final messageUuid = uuid.v4();
final compressedFiles = files != null
? await ImageCompressUtility.compressFilesList(List<File>.from(files))
: null;
try {
unawaited(AudioPlayer().play(AssetSource('audio/send_message.wav')));
} catch (_) {}
final compressedFiles = files != null ? await ImageCompressUtility.compressFilesList(List<File>.from(files)) : null;
AudioPlayer().play((AssetSource('audio/send_message.wav')));
_emitLoaded(
(current) => current.copyWith(
// Создаем временное сообщение с состоянием loading
emit(
(state as ChatRoomStateLoaded).copyWith(
chatMessages: [
ChatMessageModel(
id: -messageUuid.hashCode.abs(),
id: 1,
data: data ?? '',
mediaData: compressedFiles,
authorId: wsManager.userId ?? 0,
authorId: 1,
updatedAt: DateTime.now(),
isRead: true,
isCurrentUserAuthor: true,
roomId: current.chatRoom.id,
roomId: '',
messageState: MessageState.loading,
uuid: messageUuid,
),
...current.chatMessages,
...(state as ChatRoomStateLoaded).chatMessages,
],
),
);
try {
final serverMessage = await chatRepository.sendMessage(
data: data,
files: compressedFiles,
slug: userSlug,
);
_replaceOptimisticMessage(
messageUuid: messageUuid,
serverMessage: serverMessage,
// Отправляем сообщение и получаем ответ от сервера с правильными данными
final serverMessage = await chatRepository.sendMessage(data: data, files: compressedFiles, slug: userSlug);
// Заменяем локальное сообщение данными с сервера (с правильными URL файлов)
emit(
(state as ChatRoomStateLoaded).copyWith(
chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) {
if (elem.uuid == messageUuid) {
// Сохраняем UUID, состояние и обновляем данные из ответа сервера
// Используем значения из локального сообщения для полей, которые могут отсутствовать в ответе
return serverMessage.copyWith(
uuid: messageUuid,
messageState: MessageState.sent,
isCurrentUserAuthor: true,
isRead: elem.isRead,
roomId: elem.roomId ?? serverMessage.roomId,
authorId: serverMessage.authorId,
updatedAt: serverMessage.updatedAt,
);
}
return elem;
}).toList(),
),
);
} catch (e, st) {
await reportFrom(e, st, this);
_emitLoaded(
(current) => current.copyWith(
chatMessages: current.chatMessages.map((elem) {
emit(
(state as ChatRoomStateLoaded).copyWith(
chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) {
if (elem.uuid == messageUuid) {
return elem.copyWith(messageState: MessageState.error);
}
@@ -648,25 +294,21 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
}
}
Future<bool> markMessagesAsRead(String chatId, List<int> messageIds) async {
Future<bool> markMessagesAsRead(String chatId, List<({int id, int index})> messagesIds) async {
try {
if (state is! ChatRoomStateLoaded) {
return false;
if (state is ChatRoomStateLoaded) {
final ids = messagesIds.map((e) => e.id).toList();
await chatRepository.markMessagesAsRead(chatId, ids);
final currentMessages = [...(state as ChatRoomStateLoaded).chatMessages];
for (var elem in messagesIds) {
currentMessages[elem.index] = currentMessages[elem.index].copyWith(isRead: true);
}
if (!isClosed) {
emit((state as ChatRoomStateLoaded).copyWith(chatMessages: currentMessages));
}
return true;
}
await chatRepository.markMessagesAsRead(chatId, messageIds);
final idSet = messageIds.toSet();
_emitLoaded(
(current) => current.copyWith(
chatMessages: current.chatMessages
.map(
(message) => idSet.contains(message.id)
? message.copyWith(isRead: true)
: message,
)
.toList(),
),
);
return true;
return false;
} catch (e, st) {
await reportFrom(e, st, this);
log(e.toString(), stackTrace: st);
@@ -674,52 +316,11 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
}
}
void _replaceOptimisticMessage({
required String messageUuid,
required ChatMessageModel serverMessage,
}) {
_emitLoaded((current) {
final sentMessage = serverMessage.copyWith(
uuid: messageUuid,
messageState: MessageState.sent,
isCurrentUserAuthor: true,
);
final withoutDuplicate = current.chatMessages.where(
(message) =>
message.id != serverMessage.id || message.uuid == messageUuid,
);
final hasOptimistic = withoutDuplicate.any(
(message) => message.uuid == messageUuid,
);
if (!hasOptimistic) {
return current.copyWith(
chatMessages: [sentMessage, ...withoutDuplicate],
);
}
return current.copyWith(
chatMessages: withoutDuplicate.map((elem) {
if (elem.uuid == messageUuid) {
return sentMessage.copyWith(
isRead: elem.isRead,
roomId: elem.roomId?.isNotEmpty == true
? elem.roomId
: serverMessage.roomId,
);
}
return elem;
}).toList(),
);
});
}
Future<String?> getPaymentLink() async {
try {
final planId = (state as ChatRoomStateLoaded).chatRoom.chosenPlan?.id;
if (planId == null) return null;
final paymentModel = await chatRepository.getPaymentLink(
id: planId,
paymentProvider: 'alfa-bank',
);
final paymentModel = await chatRepository.getPaymentLink(id: planId, paymentProvider: 'alfa-bank');
return paymentModel.paymentUrl;
} catch (e, st) {
await reportFrom(e, st, this);
@@ -759,95 +360,87 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
@override
Future<void> close() async {
EasyDebounce.cancel('client-typing');
EasyDebounce.cancel('searchChatRoom');
EasyDebounce.cancel('chosen-plan-state-changed');
if (_presenceOnlineListener != null) {
wsManager.removePresenceDirectChatListener(_presenceOnlineListener!);
_presenceOnlineListener = null;
}
wsManager.unsubscribeDirectChatChannel();
_timer?.cancel();
await messagesSubscription?.cancel();
await typingSubscription?.cancel();
await _chosenPlanStateSubscription?.cancel();
await _reconnectSubscription?.cancel();
messagesSubscription?.cancel();
typingSubscription?.cancel();
super.close();
}
void deleteMessage(String uuid) {
final currentState = state;
if (currentState is ChatRoomStateLoaded) {
emit(
currentState.copyWith(
chatMessages: currentState.chatMessages
.where((message) => message.uuid != uuid)
.toList(),
),
);
emit(currentState.copyWith(chatMessages: currentState.chatMessages.where((message) => message.uuid != uuid).toList()));
}
}
Future<void> resendMessage({
required String uuid,
required String userSlug,
}) async {
Future<void> resendMessage({required String uuid, required String userSlug}) async {
final currentState = state;
if (currentState is ChatRoomStateLoaded) {
final message = currentState.chatMessages.firstWhereOrNull(
(message) => message.uuid == uuid,
);
final message = currentState.chatMessages.firstWhereOrNull((message) => message.uuid == uuid);
if (message != null) {
try {
try {
unawaited(
AudioPlayer().play(AssetSource('audio/send_message.wav')),
);
} catch (_) {}
AudioPlayer().play((AssetSource('audio/send_message.wav')));
// Определяем, есть ли файлы для повторной отправки
List<File>? filesToResend;
if (message.mediaData != null && message.mediaData!.isNotEmpty) {
// Если mediaData содержит File объекты, используем их
// Если это строки (URL), то файлы уже на сервере и повторная отправка не нужна
final firstItem = message.mediaData!.first;
if (firstItem is File) {
filesToResend = message.mediaData!.cast<File>();
}
}
_emitLoaded(
(current) => current.copyWith(
emit(
(state as ChatRoomStateLoaded).copyWith(
chatMessages: [
ChatMessageModel(
id: -uuid.hashCode.abs(),
id: 1,
data: message.data,
mediaData: message.mediaData,
authorId: wsManager.userId ?? 0,
authorId: 1,
updatedAt: DateTime.now(),
isRead: true,
isCurrentUserAuthor: true,
roomId: current.chatRoom.id,
roomId: '',
messageState: MessageState.loading,
uuid: uuid,
),
...current.chatMessages.where((item) => item.uuid != uuid),
...(state as ChatRoomStateLoaded).chatMessages.where((message) => message.uuid != uuid),
],
),
);
try {
final serverMessage = await chatRepository.sendMessage(
data: message.data,
files: filesToResend,
slug: userSlug,
);
_replaceOptimisticMessage(
messageUuid: uuid,
serverMessage: serverMessage,
// Отправляем сообщение и получаем ответ от сервера
final serverMessage = await chatRepository.sendMessage(data: message.data, files: filesToResend, slug: userSlug);
// Заменяем локальное сообщение данными с сервера
emit(
(state as ChatRoomStateLoaded).copyWith(
chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) {
if (elem.uuid == uuid) {
return serverMessage.copyWith(
uuid: uuid,
messageState: MessageState.sent,
isCurrentUserAuthor: true,
isRead: elem.isRead,
roomId: elem.roomId ?? serverMessage.roomId,
authorId: serverMessage.authorId,
updatedAt: serverMessage.updatedAt,
);
}
return elem;
}).toList(),
),
);
} catch (e, st) {
await reportFrom(e, st, this);
_emitLoaded(
(current) => current.copyWith(
chatMessages: current.chatMessages.map((elem) {
emit(
(state as ChatRoomStateLoaded).copyWith(
chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) {
if (elem.uuid == uuid) {
return elem.copyWith(messageState: MessageState.error);
}
@@ -865,5 +458,3 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
}
}
}
enum _ChatCounterpartField { sender, receiver }

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -135,9 +135,9 @@ class _EmptyChatScreenState extends State<EmptyChatScreen> {
textEditingController.clear();
});
}
final chatId = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
if (chatId != null && chatId.isNotEmpty && context.mounted) {
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatId});
final chatIdOrError = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
if (context.mounted) {
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatIdOrError});
}
}
},
@@ -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 chatId = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
if (chatId != null && chatId.isNotEmpty && mounted) {
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatId});
final chatIdOrError = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
if (mounted) {
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatIdOrError});
}
}
},
@@ -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 chatId = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
if (chatId != null && chatId.isNotEmpty && mounted) {
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatId});
final chatIdOrError = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
if (mounted) {
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatIdOrError});
}
}
},
@@ -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 chatId = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
if (chatId != null && chatId.isNotEmpty && mounted) {
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatId});
final chatIdOrError = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
if (mounted) {
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatIdOrError});
}
}
},

View File

@@ -14,15 +14,11 @@ class PhotoViewScreen extends StatefulWidget {
final int index;
final bool isLocal;
/// When set, used instead of the image URL so source/destination tags stay unique.
final Object? heroTag;
const PhotoViewScreen({
super.key,
required this.imageUrls,
required this.index,
this.isLocal = false,
this.heroTag,
});
@override
@@ -40,23 +36,6 @@ class _PhotoViewScreenState extends State<PhotoViewScreen> {
super.initState();
}
PhotoViewHeroAttributes? _heroAttributesFor(int index) {
if (widget.heroTag != null) {
if (index != widget.index) {
return null;
}
return PhotoViewHeroAttributes(
tag: widget.heroTag!,
transitionOnUserGestures: true,
);
}
return PhotoViewHeroAttributes(
tag: widget.imageUrls[index],
transitionOnUserGestures: true,
);
}
@override
Widget build(BuildContext context) {
return DismissiblePage(
@@ -65,19 +44,23 @@ class _PhotoViewScreenState extends State<PhotoViewScreen> {
direction: DismissiblePageDismissDirection.vertical,
isFullScreen: true,
child: PhotoViewGallery.builder(
backgroundDecoration: const BoxDecoration(color: Colors.transparent),
backgroundDecoration: const BoxDecoration(
color: Colors.transparent,
),
scrollPhysics: const BouncingScrollPhysics(),
builder: (BuildContext context, int index) {
final imageProvider = widget.isLocal
? FileImage(widget.imageUrls[index]) as ImageProvider<Object>
: CachedNetworkImageProvider(widget.imageUrls[index])
as ImageProvider<Object>;
: CachedNetworkImageProvider(widget.imageUrls[index]) as ImageProvider<Object>;
return PhotoViewGalleryPageOptions(
imageProvider: imageProvider,
initialScale: PhotoViewComputedScale.contained,
minScale: PhotoViewComputedScale.contained,
heroAttributes: _heroAttributesFor(index),
heroAttributes: PhotoViewHeroAttributes(
tag: widget.imageUrls[index],
transitionOnUserGestures: true,
),
errorBuilder: (context, object, stackTrace) {
return Scaffold(
backgroundColor: Colors.black,
@@ -93,12 +76,8 @@ class _PhotoViewScreenState extends State<PhotoViewScreen> {
),
contentPadding: const EdgeInsets.all(16),
onTap: () async {
if (!await launchUrl(
Uri.parse(widget.imageUrls[index]),
)) {
throw Exception(
'Could not launch ${widget.imageUrls[index]}',
);
if (!await launchUrl(Uri.parse(widget.imageUrls[index]))) {
throw Exception('Could not launch ${widget.imageUrls[index]}');
}
},
leading: const Icon(
@@ -110,10 +89,7 @@ class _PhotoViewScreenState extends State<PhotoViewScreen> {
color: Colors.white,
),
title: Text(
widget.imageUrls[index].replaceAll(
'${sl<AppConfig>().baseUrl}/storage/chat_files/',
'',
),
widget.imageUrls[index].replaceAll('${sl<AppConfig>().baseUrl}/storage/chat_files/', ''),
style: const TextStyle(
color: Colors.white,
fontSize: 12,
@@ -133,9 +109,7 @@ class _PhotoViewScreenState extends State<PhotoViewScreen> {
width: 20.0,
height: 20.0,
child: CircularProgressIndicator(
value: event == null
? 0
: event.cumulativeBytesLoaded / event.expectedTotalBytes!,
value: event == null ? 0 : event.cumulativeBytesLoaded / event.expectedTotalBytes!,
),
),
),

View File

@@ -1,52 +0,0 @@
import 'package:eatvio_app/features/chat/presentation/widgets/notification_banner.dart';
import 'package:eatvio_app/features/consultations/models/consultation_progress.dart';
import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart';
BannerType? resolveChatBanner({
required ConsultationProgress? progress,
required bool amIConsultant,
required bool hasCard,
required ChosenPlanStateEnum? state,
}) {
if (progress == null ||
state == null ||
progress.phase != ConsultationPhase.active) {
return null;
}
if (amIConsultant &&
!hasCard &&
state == ChosenPlanStateEnum.stateInCoordination) {
return BannerType.card;
}
if (amIConsultant && state == ChosenPlanStateEnum.stateInCoordination) {
return BannerType.date;
}
if (!amIConsultant &&
(state == ChosenPlanStateEnum.stateStartDateHasBeenSet ||
state == ChosenPlanStateEnum.statePendingPayment)) {
return BannerType.payment;
}
if (state == ChosenPlanStateEnum.stateDone && amIConsultant) {
return BannerType.collectCharge;
}
if (state == ChosenPlanStateEnum.stateLastDayFinished && !amIConsultant) {
return BannerType.finishPlan;
}
if (!amIConsultant) {
return null;
}
if (state == ChosenPlanStateEnum.statePaid &&
progress.dietStatus == PersonalDietStatus.notProposed) {
return BannerType.setPersonalDiet;
}
if ((state == ChosenPlanStateEnum.personalDietSet ||
state == ChosenPlanStateEnum.stateStarted ||
state == ChosenPlanStateEnum.stateInProgress) &&
progress.dietStatus == PersonalDietStatus.proposed) {
return BannerType.changePersonalDiet;
}
return null;
}

View File

@@ -39,7 +39,7 @@ class ChatBubbleWidget extends StatelessWidget {
final MessageState messageState;
final String messageUuid;
final String userSlug;
final ChatMessageType? messageType;
final String? messageType;
final bool? isConsultant;
final List<Map<String, dynamic>>? urlsMediaInfo;
@override
@@ -182,36 +182,33 @@ class ChatBubbleWidget extends StatelessWidget {
}
}
return Material(
color: Colors.transparent,
child: ListTile(
contentPadding: const EdgeInsets.all(4),
horizontalTitleGap: 8,
onTap: () async {
if (mediaItem is File) {
// Для локальных файлов используем file:// URI
final fileUri = Uri.file(mediaItem.path);
if (await canLaunchUrl(fileUri)) {
await launchUrl(fileUri, mode: LaunchMode.externalApplication);
}
} else {
// Для URL файлов используем обычный launchUrl
final urlString = mediaItem.toString();
final uri = urlString.startsWith('http://') || urlString.startsWith('https://')
? Uri.parse(urlString)
: Uri.parse('https://$urlString');
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
return ListTile(
contentPadding: const EdgeInsets.all(4),
horizontalTitleGap: 8,
onTap: () async {
if (mediaItem is File) {
// Для локальных файлов используем file:// URI
final fileUri = Uri.file(mediaItem.path);
if (await canLaunchUrl(fileUri)) {
await launchUrl(fileUri, mode: LaunchMode.externalApplication);
}
},
leading: const Icon(Icons.file_copy_outlined, color: Colors.black),
trailing: const Icon(Icons.download_outlined, color: Colors.black),
title: Text(
fileName,
maxLines: 2,
style: const TextStyle(color: Colors.black, fontSize: 12, overflow: TextOverflow.ellipsis),
),
} else {
// Для URL файлов используем обычный launchUrl
final urlString = mediaItem.toString();
final uri = urlString.startsWith('http://') || urlString.startsWith('https://')
? Uri.parse(urlString)
: Uri.parse('https://$urlString');
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
},
leading: const Icon(Icons.file_copy_outlined, color: Colors.black),
trailing: const Icon(Icons.download_outlined, color: Colors.black),
title: Text(
fileName,
maxLines: 2,
style: const TextStyle(color: Colors.black, fontSize: 12, overflow: TextOverflow.ellipsis),
),
);
}
@@ -258,7 +255,7 @@ class ChatBubbleWidget extends StatelessWidget {
}
String _getDisplayText() {
if (messageType == ChatMessageType.planOrder) {
if (messageType == 'plan_order') {
if (isConsultant == true) {
// Текст для консультанта
return '1. Уточните у подопечного его цели и пожелания\n'

View File

@@ -1,112 +0,0 @@
import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/consultations/models/consultation_progress.dart';
import 'package:eatvio_app/features/consultations/presentation/widgets/consultation_progress_plank.dart';
import 'package:eatvio_app/features/consultations/presentation/widgets/personal_diet_status_chip.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
class ChatConsultationHeader extends StatelessWidget {
const ChatConsultationHeader({
super.key,
required this.progress,
required this.showInfoButton,
required this.onInfoTap,
});
final ConsultationProgress progress;
final bool showInfoButton;
final VoidCallback onInfoTap;
@override
Widget build(BuildContext context) {
final showPlank = progress.showProgressPlank;
final showChip = progress.hasChip;
if (!showPlank && !showChip && !showInfoButton) {
return const SizedBox.shrink();
}
final s = S.of(context);
return Container(
margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: appTheme.commentBg,
borderRadius: BorderRadius.circular(20),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (progress.phase == ConsultationPhase.active || showPlank)
Row(
children: [
Expanded(
child: Text(
_title(s),
style: TextStyles.textMMedium.copyWith(
color: appTheme.black,
),
),
),
if (showInfoButton)
_InfoButton(
onTap: onInfoTap,
),
],
)
else if (showInfoButton)
Align(
alignment: Alignment.centerRight,
child: _InfoButton(
onTap: onInfoTap,
),
),
if (showPlank) ...[
const SizedBox(height: 8),
ConsultationProgressPlank(progress: progress),
],
if (showChip) ...[
const SizedBox(height: 8),
PersonalDietStatusChip(progress: progress),
],
],
),
);
}
String _title(S s) {
if (progress.isCanceled) return s.orderCanceled;
if (progress.isPaymentFailed) return s.payment_failed;
if (progress.isStepCompleted(ConsultationStep.finished)) return s.done;
if (progress.showProgressPlank) return s.order_in_progress;
return s.more_about_order;
}
}
class _InfoButton extends StatelessWidget {
const _InfoButton({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
customBorder: const CircleBorder(),
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: appTheme.whiteA700,
shape: BoxShape.circle,
),
child: Icon(
Icons.info_outline_rounded,
size: 18,
color: appTheme.blueA400,
),
),
);
}
}

View File

@@ -1,70 +0,0 @@
import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
class ChatUserOnlineStatusLine extends StatelessWidget {
const ChatUserOnlineStatusLine({
super.key,
required this.isOnline,
required this.lastSeenText,
this.style,
this.showOnlineDot = true,
});
final bool isOnline;
final String lastSeenText;
final TextStyle? style;
final bool showOnlineDot;
@override
Widget build(BuildContext context) {
final textStyle = style ??
CustomTextStyles.bodySmallMulishBlack900.copyWith(height: 14 / 12);
if (isOnline) {
if (!showOnlineDot) {
return Text(
S.of(context).video_call_status_online,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textStyle,
);
}
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: appTheme.greenA700,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Flexible(
child: Text(
S.of(context).video_call_status_online,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textStyle,
),
),
],
);
}
final trimmedLastSeen = lastSeenText.trim();
if (trimmedLastSeen.isEmpty) {
return const SizedBox.shrink();
}
return Text(
trimmedLastSeen,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textStyle,
);
}
}

View File

@@ -106,11 +106,7 @@ class _NotificationBannerState extends State<NotificationBanner> {
onTap: () {
final planId = (context.read<ChatRoomCubit>().state as ChatRoomStateLoaded).chatRoom.chosenPlan?.id;
if (planId != null) {
showConsultationPaymentFlow(
context,
planId: planId,
amount: widget.planPrice,
);
showConsultationPaymentFlow(context, planId: planId);
}
},
child: Text(s.pay, style: TextStyles.textLSemibold.copyWith(color: appTheme.blueA400)),
@@ -146,37 +142,12 @@ class _NotificationBannerState extends State<NotificationBanner> {
BannerType.setPersonalDiet => [
SizedBox(
width: size.width * 0.6,
child: Text(s.suggestDiet, style: TextStyles.textMMedium.copyWith(color: appTheme.black)),
child: Text(s.createPersonalDiet, style: TextStyles.textMMedium.copyWith(color: appTheme.black)),
),
const SizedBox(height: 12),
GestureDetector(
onTap: () {
GoRouter.of(context).pushNamed(
MyDietsScreen.name,
queryParameters: {'initialTabIndex': '2'},
);
},
child: Text(s.goToMyDiets, style: TextStyles.textLSemibold.copyWith(color: appTheme.blueA400)),
),
const SizedBox(height: 16),
],
BannerType.changePersonalDiet => [
SizedBox(
width: size.width * 0.6,
child: Text(s.change_personal_diet, style: TextStyles.textMMedium.copyWith(color: appTheme.black)),
),
const SizedBox(height: 8),
SizedBox(
width: size.width * 0.6,
child: Text(s.personal_diet_replaces_tomorrow, style: TextStyles.textSMedium.copyWith(color: appTheme.black)),
),
const SizedBox(height: 12),
GestureDetector(
onTap: () {
GoRouter.of(context).pushNamed(
MyDietsScreen.name,
queryParameters: {'initialTabIndex': '2'},
);
GoRouter.of(context).pushNamed(MyDietsScreen.name);
},
child: Text(s.goToMyDiets, style: TextStyles.textLSemibold.copyWith(color: appTheme.blueA400)),
),
@@ -269,4 +240,4 @@ class _StarIcon extends StatelessWidget {
}
}
enum BannerType { date, card, payment, collectCharge, finishPlan, setPersonalDiet, changePersonalDiet }
enum BannerType { date, card, payment, collectCharge, finishPlan, setPersonalDiet }

View File

@@ -1,103 +0,0 @@
import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/utils/date_time_utils.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
class VideoCallChatMessageWidget extends StatelessWidget {
const VideoCallChatMessageWidget({
super.key,
required this.text,
required this.sendTime,
required this.onVideoCallPressed,
});
final String text;
final DateTime sendTime;
final VoidCallback onVideoCallPressed;
@override
Widget build(BuildContext context) {
final size = MediaQuery.sizeOf(context);
final textStyle = TextStyles.textFS13FW400.copyWith(color: appTheme.black, fontSize: 16);
return SizedBox(
width: size.width,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 64, 4),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Container(
constraints: BoxConstraints(maxWidth: size.width * 0.7),
decoration: BoxDecoration(
color: appTheme.gray60004,
borderRadius: const BorderRadius.horizontal(
left: Radius.circular(16),
right: Radius.circular(24),
),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 16, 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (text.isNotEmpty)
Flexible(
child: Text(text, style: textStyle),
),
if (text.isNotEmpty) const SizedBox(width: 8),
_VideoCallBubbleActionButton(onPressed: onVideoCallPressed),
],
),
),
),
Padding(
padding: const EdgeInsets.only(left: 4, bottom: 4),
child: Text(
sendTime.format('HH:mm', 'ru'),
style: const TextStyle(
color: Color(0xFF7B8794),
height: 1,
fontSize: 10,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
);
}
}
class _VideoCallBubbleActionButton extends StatelessWidget {
const _VideoCallBubbleActionButton({required this.onPressed});
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return Material(
color: appTheme.greenA700,
borderRadius: BorderRadius.circular(10),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onPressed,
child: SizedBox(
width: 36,
height: 36,
child: Center(
child: SvgPicture.asset(
'assets/icons/video_call/vc_control_enter.svg',
width: 20,
height: 20,
fit: BoxFit.contain,
),
),
),
),
);
}
}

View File

@@ -137,12 +137,11 @@ class ChatRepository {
}
Future<String?> getChatByUserSlug(String slug) async {
Future<String> getChatByUserSlug(String slug) async {
final response = await chatService.getChatByUserSlug(slug: slug);
final data = response.data;
if (data is Map && data['id'] is String && (data['id'] as String).isNotEmpty) {
return data['id'] as String;
if (response.response.statusCode == 404) {
return '404';
}
return null;
return response.data['id'];
}
}

View File

@@ -6,10 +6,6 @@ import 'package:eatvio_app/features/chat/models/chat_room_pagination_model.dart'
import 'package:eatvio_app/features/chat/models/order_id_to_conversation_model.dart';
import 'package:eatvio_app/features/chat/models/payment_model.dart';
import 'package:eatvio_app/features/chat/models/plan_complain_model.dart';
import 'package:eatvio_app/features/payment/models/promo_applied_list_response.dart';
import 'package:eatvio_app/features/payment/models/promo_apply_response.dart';
import 'package:eatvio_app/features/payment/models/promo_remove_response.dart';
import 'package:eatvio_app/features/payment/models/promo_validate_response.dart';
import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart';
import 'package:retrofit/retrofit.dart';
@@ -73,26 +69,6 @@ abstract class ChatService {
@Body() required Map<String, String> body,
});
@GET('/api/chosen_plans/applied_promos')
Future<PromoAppliedListResponse> listAppliedPromoCodes();
@POST('/api/chosen_plans/{id}/validate_promo')
Future<PromoValidateResponse> validatePromoCode({
@Path() required int id,
@Body() required Map<String, String> body,
});
@POST('/api/chosen_plans/{id}/apply_promo')
Future<PromoApplyResponse> applyPromoCode({
@Path() required int id,
@Body() required Map<String, String> body,
});
@DELETE('/api/chosen_plans/{id}/remove_promo_code')
Future<PromoRemoveResponse> removePromoCode({
@Path() required int id,
});
@GET('/api/consultation/resolve/order_id_to_conversation/{orderId}')
Future<OrderIdToConversationModel> resolveOrderIdToConversation({
@Path() required String orderId,

View File

@@ -1,15 +0,0 @@
import 'package:eatvio_app/features/chat/models/chat_room_model.dart';
import 'package:eatvio_app/features/chat/models/chat_user_model.dart';
ChatUserModel resolveChatCounterpart({
required Users users,
required int? currentUserId,
}) {
if (currentUserId != null && users.sender.id == currentUserId) {
return users.receiver;
}
if (currentUserId != null && users.receiver.id == currentUserId) {
return users.sender;
}
return users.receiver;
}

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