forked from eatvio/eatvio_app
Compare commits
78 Commits
post_creat
...
6d2935400a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d2935400a | ||
|
|
ff17b813ac | ||
|
|
9288a5c849 | ||
|
|
4ef174ebde | ||
|
|
1901248539 | ||
| b2e700a4eb | |||
|
|
1bbb08f2a2 | ||
| 61f1b76f99 | |||
|
|
ef4df92f58 | ||
|
|
dcd343e765 | ||
|
|
98c175df44 | ||
|
|
cb312037b2 | ||
| 572a6f1943 | |||
|
|
a6ae9abb57 | ||
| 431354170e | |||
|
|
c38e82f558 | ||
| afd6f9a93a | |||
|
|
641eb4d2e2 | ||
| 74c66c9600 | |||
|
|
73e89ff7a9 | ||
|
|
f8e06b6d7a | ||
|
|
866ea2fe3b | ||
| d73f68e24b | |||
|
|
61cded0081 | ||
|
|
df634fedd8 | ||
|
|
603a158cf2 | ||
| a1bae68307 | |||
|
|
2dce28b196 | ||
| ae4f9d2e9b | |||
|
|
a00aae36cf | ||
|
|
27e98bdb44 | ||
|
|
8ec6d5b799 | ||
|
|
077cfb3a67 | ||
| c4f5cae36d | |||
|
|
b040c6e020 | ||
|
|
745e77f948 | ||
|
|
c6ea1d414d | ||
|
|
10af279d87 | ||
| d0879e556f | |||
|
|
8f933118a3 | ||
| 7dc7eab774 | |||
|
|
16a13488d1 | ||
|
|
de7bf8dc19 | ||
| eeb261df0a | |||
|
|
1b03a0d5e1 | ||
|
|
0208ed753a | ||
| 184f0417a7 | |||
|
|
6265e865e9 | ||
| 1550b7ea0b | |||
|
|
db10895ebb | ||
|
|
a7361d3ab1 | ||
| 1c54063a35 | |||
| efae7fc958 | |||
|
|
90ac4e750d | ||
|
|
6d7419c283 | ||
| caf45a9050 | |||
|
|
0a5e7d95c4 | ||
|
|
85482173ad | ||
| 65f7992d40 | |||
|
|
52a01bee12 | ||
| 179fc0ad99 | |||
|
|
8494e61eec | ||
|
|
5c2458ecad | ||
| b63bab8bf5 | |||
| 7790014726 | |||
| b679e90ba2 | |||
| 2474c5bea3 | |||
| 7f278d0f59 | |||
|
|
53426c858c | ||
| 88f0cde88f | |||
|
|
a5fcf62524 | ||
| a2e41a2ff5 | |||
|
|
5b1beafbab | ||
| 4eadecd6f2 | |||
|
|
6292db07a1 | ||
| ab29b6b329 | |||
|
|
af5d182a79 | ||
| 191bb74aa4 |
62
.gitea/ISSUE_TEMPLATE/bug_report.yml
Normal file
62
.gitea/ISSUE_TEMPLATE/bug_report.yml
Normal file
@@ -0,0 +1,62 @@
|
||||
name: "🐞 Bug Report"
|
||||
description: "Report a bug to help us improve"
|
||||
title: "[Bug]: "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: dropdown
|
||||
id: stand
|
||||
attributes:
|
||||
label: "Stand"
|
||||
description: "Select the environment where the bug occurs"
|
||||
options:
|
||||
- "prod"
|
||||
- "stage-dev2"
|
||||
- "local"
|
||||
- "all"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: platform
|
||||
attributes:
|
||||
label: "Platform"
|
||||
description: "Select the platform where the bug occurs"
|
||||
options:
|
||||
- "Android"
|
||||
- "iPhone"
|
||||
- "Huawei"
|
||||
- "All"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: device_model
|
||||
attributes:
|
||||
label: "Device Model"
|
||||
description: "Optional. Specify the device where the bug occurs"
|
||||
placeholder: "e.g., Samsung Galaxy S23, iPhone 15 Pro, Huawei P60"
|
||||
|
||||
- type: textarea
|
||||
id: reproduction_steps
|
||||
attributes:
|
||||
label: "Steps to Reproduce"
|
||||
description: "Clear numbered steps"
|
||||
placeholder: "1. Click X\n2. Navigate to Y..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected_actual
|
||||
attributes:
|
||||
label: "Expected vs Actual Behavior"
|
||||
description: "What should happen vs what happens"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: "Description"
|
||||
description: "Дополнительная информация описывающая проблему"
|
||||
validations:
|
||||
required: true
|
||||
5
.gitea/ISSUE_TEMPLATE/config.yml
Normal file
5
.gitea/ISSUE_TEMPLATE/config.yml
Normal file
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: "💬 Community Support"
|
||||
url: "https://pepe.eatv.io/channel/general"
|
||||
about: "Ask questions here"
|
||||
26
.gitea/ISSUE_TEMPLATE/feature_request.yml
Normal file
26
.gitea/ISSUE_TEMPLATE/feature_request.yml
Normal file
@@ -0,0 +1,26 @@
|
||||
name: "✨ Feature Request"
|
||||
description: "Suggest an idea for this project"
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: use_case
|
||||
attributes:
|
||||
label: "Use Case"
|
||||
description: "Problem solved or value added"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: "Description"
|
||||
description: "Дополнительная информация описывающая функциональность"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: priority
|
||||
attributes:
|
||||
label: "Priority Level"
|
||||
options: ["Low", "Medium", "High"]
|
||||
@@ -8,8 +8,16 @@ jobs:
|
||||
build:
|
||||
runs-on: [macos-arm64]
|
||||
steps:
|
||||
# launchd does not load the login-shell PATH, so Homebrew git is invisible to act.
|
||||
- name: Add Homebrew to PATH
|
||||
run: |
|
||||
echo "/opt/homebrew/bin" >> "$GITHUB_PATH"
|
||||
echo "/opt/homebrew/sbin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Fastlane version
|
||||
run: fastlane --version
|
||||
run: |
|
||||
git --version
|
||||
fastlane --version
|
||||
|
||||
- name: Checkout
|
||||
uses: https://github.com/actions/checkout@v2
|
||||
@@ -59,12 +67,16 @@ jobs:
|
||||
- name: Flutter build
|
||||
run: |
|
||||
flutter --version
|
||||
flutter config --no-enable-swift-package-manager
|
||||
rm -rf ios/Podfile.lock
|
||||
flutter clean
|
||||
flutter pub get
|
||||
flutter pub run build_runner build --delete-conflicting-outputs
|
||||
flutter build ios --config-only --release
|
||||
cd ios
|
||||
pod install
|
||||
pod install --repo-update
|
||||
cd ..
|
||||
flutter build ios --config-only --release
|
||||
cd ios
|
||||
fastlane beta
|
||||
env:
|
||||
LANG: en_US.UTF-8
|
||||
|
||||
@@ -69,6 +69,7 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
implementation platform('com.google.firebase:firebase-bom:34.4.0')
|
||||
implementation 'com.google.firebase:firebase-messaging'
|
||||
implementation 'com.huawei.agconnect:agconnect-core:1.9.5.300'
|
||||
|
||||
@@ -78,12 +78,7 @@
|
||||
<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.
|
||||
|
||||
@@ -8,6 +8,10 @@ import com.hiennv.flutter_callkit_incoming.CallkitIncomingBroadcastReceiver
|
||||
|
||||
object CallKitIntentBridge {
|
||||
fun dispatchCallKitAction(context: Context, action: String, data: Bundle?) {
|
||||
if (IncomingCallRingtonePolicy.actionStopsRingtone(action)) {
|
||||
VideoCallPushBridge.silenceIncomingCall(context)
|
||||
}
|
||||
|
||||
if (data != null) {
|
||||
CallKitAcceptIntentMapper.payloadFromBundle(data, action)?.let { payload ->
|
||||
PendingCallKitAcceptBridge.store(payload)
|
||||
|
||||
@@ -24,11 +24,9 @@ class EatvioFcmMessagingService : FirebaseMessagingService() {
|
||||
return
|
||||
}
|
||||
|
||||
if (MainActivity.isInForeground) {
|
||||
Log.i(TAG, "App in foreground, deferring incoming call to Flutter handler")
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.eatvioapp.app
|
||||
|
||||
/**
|
||||
* Call actions that must stop the incoming ringtone.
|
||||
* Accept is included: answering the call is not the same as ending it,
|
||||
* but the ringing player is a separate object from the in-call audio.
|
||||
*/
|
||||
internal object IncomingCallRingtonePolicy {
|
||||
fun actionStopsRingtone(action: String): Boolean {
|
||||
return action.contains("ACTION_CALL_ACCEPT") ||
|
||||
action.contains("ACTION_CALL_DECLINE") ||
|
||||
action.contains("ACTION_CALL_ENDED") ||
|
||||
action.contains("ACTION_CALL_TIMEOUT")
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,9 @@ import io.flutter.plugin.common.MethodChannel
|
||||
import com.hiennv.flutter_callkit_incoming.FlutterCallkitIncomingPlugin
|
||||
import com.huawei.agconnect.config.AGConnectServicesConfig
|
||||
import com.huawei.hms.aaid.HmsInstanceId
|
||||
import com.huawei.hms.api.ConnectionResult
|
||||
import com.huawei.hms.api.HuaweiApiAvailability
|
||||
import com.huawei.hms.common.ApiException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -82,6 +85,20 @@ class MainActivity: FlutterActivity() {
|
||||
}
|
||||
methodChannel?.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"isHmsAvailable" -> {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val available = try {
|
||||
HuaweiApiAvailability.getInstance()
|
||||
.isHuaweiMobileServicesAvailable(applicationContext) == ConnectionResult.SUCCESS
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("HMS", "HMS availability check failed: ${e.message}")
|
||||
false
|
||||
}
|
||||
runOnUiThread {
|
||||
result.success(available)
|
||||
}
|
||||
}
|
||||
}
|
||||
"getHuaweiToken" -> {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
@@ -121,10 +138,30 @@ class MainActivity: FlutterActivity() {
|
||||
runOnUiThread {
|
||||
result.success(token)
|
||||
}
|
||||
} catch (e: ApiException) {
|
||||
if (e.statusCode == 907135003) {
|
||||
android.util.Log.w("HMS", "HMS client API invalid (${e.statusCode}), falling back to FCM")
|
||||
runOnUiThread {
|
||||
result.success(null)
|
||||
}
|
||||
} else {
|
||||
android.util.Log.e("HMS", "Error getting HMS token: ${e.statusCode}: ${e.message}", e)
|
||||
runOnUiThread {
|
||||
result.error("HMS_TOKEN_ERROR", "${e.statusCode}: ${e.message}", null)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("HMS", "Error getting HMS token: ${e.message}", e)
|
||||
runOnUiThread {
|
||||
result.error("HMS_TOKEN_ERROR", e.message, null)
|
||||
val message = e.message.orEmpty()
|
||||
if (message.contains("907135003") || message.contains("client api invalid", ignoreCase = true)) {
|
||||
android.util.Log.w("HMS", "HMS client API invalid, falling back to FCM")
|
||||
runOnUiThread {
|
||||
result.success(null)
|
||||
}
|
||||
} else {
|
||||
android.util.Log.e("HMS", "Error getting HMS token: $message", e)
|
||||
runOnUiThread {
|
||||
result.error("HMS_TOKEN_ERROR", e.message, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.hiennv.flutter_callkit_incoming.CallkitConstants
|
||||
import com.hiennv.flutter_callkit_incoming.CallkitEventCallback
|
||||
import com.hiennv.flutter_callkit_incoming.CallkitNotificationManager
|
||||
import com.hiennv.flutter_callkit_incoming.CallkitNotificationService
|
||||
import com.hiennv.flutter_callkit_incoming.CallkitSoundPlayerManager
|
||||
@@ -17,14 +18,36 @@ import com.hiennv.flutter_callkit_incoming.addCall
|
||||
import com.hiennv.flutter_callkit_incoming.getDataActiveCalls
|
||||
import com.hiennv.flutter_callkit_incoming.removeAllCalls
|
||||
import java.util.HashMap
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
object VideoCallPushBridge {
|
||||
private const val TAG = "VideoCallPushBridge"
|
||||
private const val ANDROID_ACCENT_COLOR = "#2787F5"
|
||||
private const val MISSED_CALL_CLEANUP_DELAY_MS = 250L
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val playbackLock = Any()
|
||||
private val playbackGeneration = AtomicInteger(0)
|
||||
|
||||
private var activeSoundManager: CallkitSoundPlayerManager? = null
|
||||
private var activeNotificationManager: CallkitNotificationManager? = null
|
||||
private var activeCallBundle: Bundle? = null
|
||||
private var storedAppContext: Context? = null
|
||||
private var ringtoneTimeout: Runnable? = null
|
||||
private var callbackRegistered = false
|
||||
|
||||
/**
|
||||
* The plugin stops only the sound player owned by [FlutterCallkitIncomingPlugin].
|
||||
* Incoming pushes start a different player, so accept/decline never reached it
|
||||
* and the ringtone kept looping until the process died.
|
||||
*/
|
||||
private val incomingCallEventCallback = object : CallkitEventCallback {
|
||||
override fun onCallEvent(event: CallkitEventCallback.CallEvent, callData: Bundle) {
|
||||
val context = storedAppContext ?: return
|
||||
Log.i(TAG, "incoming call event=$event, stopping ringtone")
|
||||
silenceIncomingCall(context)
|
||||
}
|
||||
}
|
||||
|
||||
fun isIncomingVideoCall(data: Map<String, String>): Boolean {
|
||||
val rawType = data["type"]
|
||||
@@ -89,18 +112,114 @@ object VideoCallPushBridge {
|
||||
|
||||
fun showIncomingCall(context: Context, data: Map<String, String>) {
|
||||
val appContext = context.applicationContext
|
||||
storedAppContext = appContext
|
||||
ensureIncomingCallCallbackRegistered()
|
||||
|
||||
val callData = buildCallDataBundle(data)
|
||||
if (callData == null) {
|
||||
Log.w(TAG, "showIncomingCall: missing conversation uuid, data=$data")
|
||||
return
|
||||
}
|
||||
|
||||
val generation = playbackGeneration.incrementAndGet()
|
||||
Log.i(TAG, "showIncomingCall: conversation=${callData.getString(CallkitConstants.EXTRA_CALLKIT_ID)}")
|
||||
|
||||
// Ringtone.play() and stop() must run on the same thread. FCM delivers
|
||||
// pushes on a background thread, while Answer runs on the main thread.
|
||||
mainHandler.post {
|
||||
synchronized(playbackLock) {
|
||||
if (generation != playbackGeneration.get()) {
|
||||
Log.i(TAG, "showIncomingCall: skipped stale playback generation=$generation")
|
||||
return@post
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissAllIncomingCalls(context: Context) {
|
||||
val appContext = context.applicationContext
|
||||
val calls = getDataActiveCalls(appContext)
|
||||
Log.i(TAG, "dismissAllIncomingCalls: active calls=${calls.size}")
|
||||
playbackGeneration.incrementAndGet()
|
||||
|
||||
val dismiss = Runnable {
|
||||
synchronized(playbackLock) {
|
||||
val notificationManager = resolveNotificationManager(appContext)
|
||||
val bundles = calls.map { it.toBundle() }
|
||||
stopPlaybackLocked(appContext)
|
||||
|
||||
for (bundle in bundles) {
|
||||
try {
|
||||
dismissIncomingCallNotification(appContext, notificationManager, bundle)
|
||||
} catch (error: Exception) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"dismissAllIncomingCalls: failed for call ${bundle.getString(CallkitConstants.EXTRA_CALLKIT_ID)}",
|
||||
error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
CallkitNotificationService.stopService(appContext)
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "dismissAllIncomingCalls: failed to stop callkit service", error)
|
||||
}
|
||||
|
||||
try {
|
||||
removeAllCalls(appContext)
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "dismissAllIncomingCalls: failed to remove persisted calls", error)
|
||||
}
|
||||
|
||||
stopIncomingRingtone()
|
||||
scheduleMissedCallCleanup(notificationManager, bundles)
|
||||
}
|
||||
}
|
||||
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
dismiss.run()
|
||||
} else {
|
||||
mainHandler.post(dismiss)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureIncomingCallCallbackRegistered() {
|
||||
if (callbackRegistered) return
|
||||
FlutterCallkitIncomingPlugin.registerEventCallback(incomingCallEventCallback)
|
||||
callbackRegistered = true
|
||||
}
|
||||
|
||||
private fun startIncomingCallLocked(appContext: Context, callData: Bundle, generation: Int) {
|
||||
stopPlaybackLocked(appContext)
|
||||
|
||||
val soundManager = CallkitSoundPlayerManager(appContext)
|
||||
val notificationManager = CallkitNotificationManager(appContext, soundManager)
|
||||
activeSoundManager = soundManager
|
||||
activeNotificationManager = notificationManager
|
||||
activeCallBundle = callData
|
||||
notificationManager.createNotificationChanel(callData)
|
||||
notificationManager.showIncomingNotification(callData)
|
||||
|
||||
@@ -109,46 +228,41 @@ object VideoCallPushBridge {
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "showIncomingCall: failed to persist active call", error)
|
||||
}
|
||||
|
||||
val durationMs = callData.getLong(CallkitConstants.EXTRA_CALLKIT_DURATION, 45_000L)
|
||||
if (durationMs > 0L) {
|
||||
val timeout = Runnable {
|
||||
synchronized(playbackLock) {
|
||||
if (generation != playbackGeneration.get()) return@Runnable
|
||||
Log.i(TAG, "incoming ringtone timed out")
|
||||
stopPlaybackLocked(appContext)
|
||||
}
|
||||
}
|
||||
ringtoneTimeout = timeout
|
||||
mainHandler.postDelayed(timeout, durationMs)
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissAllIncomingCalls(context: Context) {
|
||||
val appContext = context.applicationContext
|
||||
val calls = getDataActiveCalls(appContext)
|
||||
Log.i(TAG, "dismissAllIncomingCalls: active calls=${calls.size}")
|
||||
|
||||
stopIncomingRingtone(appContext)
|
||||
|
||||
val notificationManager = resolveNotificationManager(appContext)
|
||||
val bundles = calls.map { it.toBundle() }
|
||||
|
||||
for (bundle in bundles) {
|
||||
private fun stopPlaybackLocked(appContext: Context) {
|
||||
cancelRingtoneTimeoutLocked()
|
||||
val notificationManager = activeNotificationManager
|
||||
val bundle = activeCallBundle
|
||||
stopIncomingRingtone()
|
||||
if (notificationManager != null && bundle != null) {
|
||||
try {
|
||||
dismissIncomingCallNotification(appContext, notificationManager, bundle)
|
||||
} catch (error: Exception) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"dismissAllIncomingCalls: failed for call ${bundle.getString(CallkitConstants.EXTRA_CALLKIT_ID)}",
|
||||
error,
|
||||
)
|
||||
Log.w(TAG, "stopPlayback: failed to dismiss incoming notification", error)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
CallkitNotificationService.stopService(appContext)
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "dismissAllIncomingCalls: failed to stop callkit service", error)
|
||||
}
|
||||
|
||||
try {
|
||||
removeAllCalls(appContext)
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "dismissAllIncomingCalls: failed to remove persisted calls", error)
|
||||
}
|
||||
|
||||
stopIncomingRingtone(appContext)
|
||||
scheduleMissedCallCleanup(notificationManager, bundles)
|
||||
activeSoundManager = null
|
||||
activeNotificationManager = null
|
||||
activeCallBundle = null
|
||||
}
|
||||
|
||||
private fun cancelRingtoneTimeoutLocked() {
|
||||
ringtoneTimeout?.let { mainHandler.removeCallbacks(it) }
|
||||
ringtoneTimeout = null
|
||||
}
|
||||
|
||||
private fun resolveNotificationManager(context: Context): CallkitNotificationManager {
|
||||
@@ -161,11 +275,19 @@ object VideoCallPushBridge {
|
||||
return CallkitNotificationManager(context, soundManager)
|
||||
}
|
||||
|
||||
private fun stopIncomingRingtone(context: Context) {
|
||||
activeSoundManager?.stop()
|
||||
FlutterCallkitIncomingPlugin.getInstance()
|
||||
?.getCallkitSoundPlayerManager()
|
||||
?.stop()
|
||||
private fun stopIncomingRingtone() {
|
||||
try {
|
||||
activeSoundManager?.stop()
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "stopIncomingRingtone: active player", error)
|
||||
}
|
||||
try {
|
||||
FlutterCallkitIncomingPlugin.getInstance()
|
||||
?.getCallkitSoundPlayerManager()
|
||||
?.stop()
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "stopIncomingRingtone: plugin player", error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -264,7 +386,7 @@ object VideoCallPushBridge {
|
||||
putString(CallkitConstants.EXTRA_CALLKIT_APP_NAME, "Итвио")
|
||||
putString(CallkitConstants.EXTRA_CALLKIT_HANDLE, conversationUuid)
|
||||
putString(CallkitConstants.EXTRA_CALLKIT_AVATAR, avatarUrl)
|
||||
putInt(CallkitConstants.EXTRA_CALLKIT_TYPE, 0)
|
||||
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")
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.eatvioapp.app
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class IncomingCallRingtonePolicyTest {
|
||||
@Test
|
||||
fun answerDeclineEndAndTimeoutStopTheRingtone() {
|
||||
assertTrue(
|
||||
IncomingCallRingtonePolicy.actionStopsRingtone(
|
||||
"com.hiennv.flutter_callkit_incoming.ACTION_CALL_ACCEPT",
|
||||
),
|
||||
)
|
||||
assertTrue(
|
||||
IncomingCallRingtonePolicy.actionStopsRingtone(
|
||||
"com.hiennv.flutter_callkit_incoming.ACTION_CALL_DECLINE",
|
||||
),
|
||||
)
|
||||
assertTrue(
|
||||
IncomingCallRingtonePolicy.actionStopsRingtone(
|
||||
"com.hiennv.flutter_callkit_incoming.ACTION_CALL_ENDED",
|
||||
),
|
||||
)
|
||||
assertTrue(
|
||||
IncomingCallRingtonePolicy.actionStopsRingtone(
|
||||
"com.hiennv.flutter_callkit_incoming.ACTION_CALL_TIMEOUT",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun incomingAndStartActionsKeepTheRingtonePlaying() {
|
||||
assertFalse(
|
||||
IncomingCallRingtonePolicy.actionStopsRingtone(
|
||||
"com.hiennv.flutter_callkit_incoming.ACTION_CALL_INCOMING",
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
IncomingCallRingtonePolicy.actionStopsRingtone(
|
||||
"com.hiennv.flutter_callkit_incoming.ACTION_CALL_START",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,8 @@ 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',
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||
46B43B96F809CEC6058EFDD3 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 15ADDB2A35F334AF0AE50011 /* Pods_Runner.framework */; };
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||
C4A8E2022E9A4B1000123456 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A8E2012E9A4B1000123456 /* SceneDelegate.swift */; };
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||
@@ -37,6 +38,7 @@
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
C4A8E2012E9A4B1000123456 /* SceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||
938EE8FB5BAA359AAEDEFFF8 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||
@@ -115,6 +117,7 @@
|
||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||
C4A8E2012E9A4B1000123456 /* SceneDelegate.swift */,
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||
FEE92A0E2D772EA300E6E989 /* Runner.entitlements */,
|
||||
);
|
||||
@@ -300,6 +303,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||
C4A8E2022E9A4B1000123456 /* SceneDelegate.swift in Sources */,
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
||||
@@ -4,6 +4,7 @@ import Flutter
|
||||
import PushKit
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
import WebRTC
|
||||
import YandexLoginSDK
|
||||
import flutter_callkit_incoming
|
||||
import flutter_foreground_task
|
||||
@@ -15,7 +16,29 @@ import flutter_foreground_task
|
||||
private let voipDiagnosticsStorageKey = "EatvioVoipDiagnostics"
|
||||
private let pendingAcceptedVoipCallKey = "EatvioPendingAcceptedVoipCall"
|
||||
private let voipDiagnosticsChannelName = "com.eatvioapp/voip_diagnostics"
|
||||
private let callAudioSessionChannelName = "com.eatvioapp/call_audio_session"
|
||||
private let appUrlScheme = "eatvio"
|
||||
/// CallKit is only the ringing UI. After accept the in-app call continues,
|
||||
/// so the audio session must be handed back to WebRTC when CallKit deactivates.
|
||||
private var resumeAudioAfterCallKitEnds = false
|
||||
/// The push handler and the Flutter channel touch this map on different
|
||||
/// queues. The push writes the id before CallKit has the call, so the
|
||||
/// in-app path can see it and not report the same call again.
|
||||
private var reservedIncomingCallIds: [String: Date] = [:]
|
||||
private let incomingCallClaimLock = NSLock()
|
||||
private let incomingCallClaimTtl: TimeInterval = 90
|
||||
private let voipPushQueue = DispatchQueue(label: "com.eatvioapp.voip")
|
||||
private var voipCallReporterStorage: VoipCallReporter?
|
||||
private var audioRestartGeneration = 0
|
||||
|
||||
private var voipCallReporter: VoipCallReporter {
|
||||
if let voipCallReporterStorage {
|
||||
return voipCallReporterStorage
|
||||
}
|
||||
let reporter = VoipCallReporter()
|
||||
voipCallReporterStorage = reporter
|
||||
return reporter
|
||||
}
|
||||
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
@@ -26,10 +49,12 @@ import flutter_foreground_task
|
||||
GeneratedPluginRegistrant.register(with: registry)
|
||||
}
|
||||
|
||||
let mainQueue = DispatchQueue.main
|
||||
voipRegistry = PKPushRegistry(queue: mainQueue)
|
||||
// Not the main queue. Flutter startup blocks main, and PushKit aborts the
|
||||
// process if this push is not reported before that returns.
|
||||
voipRegistry = PKPushRegistry(queue: voipPushQueue)
|
||||
voipRegistry?.delegate = self
|
||||
voipRegistry?.desiredPushTypes = [PKPushType.voIP]
|
||||
configureWebRTCAudioSession()
|
||||
|
||||
if #available(iOS 10.0, *) {
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
@@ -37,29 +62,81 @@ import flutter_foreground_task
|
||||
|
||||
let launched = super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
|
||||
if let controller = window?.rootViewController as? FlutterViewController {
|
||||
let channel = FlutterMethodChannel(
|
||||
name: voipDiagnosticsChannelName,
|
||||
binaryMessenger: controller.binaryMessenger
|
||||
)
|
||||
voipDiagnosticsChannel = channel
|
||||
channel.setMethodCallHandler { [weak self] call, result in
|
||||
guard let self else {
|
||||
result(nil)
|
||||
return
|
||||
}
|
||||
switch call.method {
|
||||
case "drainVoipDiagnostics":
|
||||
result(self.drainVoipDiagnostics())
|
||||
case "drainPendingAcceptedVoipCall":
|
||||
result(self.drainPendingAcceptedVoipCall())
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
// WebRTC must not activate the session itself. CallKit and the in-app
|
||||
// call both own AVAudioSession, and a second activation drops the mic.
|
||||
let rtcSession = RTCAudioSession.sharedInstance()
|
||||
rtcSession.useManualAudio = true
|
||||
rtcSession.isAudioEnabled = false
|
||||
|
||||
registerCallChannels(from: window?.rootViewController as? FlutterViewController)
|
||||
|
||||
return launched
|
||||
}
|
||||
|
||||
func registerCallChannels(from controller: FlutterViewController?) {
|
||||
guard voipDiagnosticsChannel == nil else { return }
|
||||
let flutterController = controller ?? flutterViewControllerFromScenes()
|
||||
guard let flutterController else { return }
|
||||
|
||||
let channel = FlutterMethodChannel(
|
||||
name: voipDiagnosticsChannelName,
|
||||
binaryMessenger: flutterController.binaryMessenger
|
||||
)
|
||||
voipDiagnosticsChannel = channel
|
||||
channel.setMethodCallHandler { [weak self] call, result in
|
||||
guard let self else {
|
||||
result(nil)
|
||||
return
|
||||
}
|
||||
switch call.method {
|
||||
case "drainVoipDiagnostics":
|
||||
result(self.drainVoipDiagnostics())
|
||||
case "drainPendingAcceptedVoipCall":
|
||||
result(self.drainPendingAcceptedVoipCall())
|
||||
case "isIncomingCallClaimed":
|
||||
result(self.isIncomingCallClaimed(self.incomingCallId(from: call.arguments)))
|
||||
case "reserveIncomingCall":
|
||||
result(self.reserveIncomingCall(self.incomingCallId(from: call.arguments)))
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
|
||||
return launched
|
||||
let callAudioChannel = FlutterMethodChannel(
|
||||
name: callAudioSessionChannelName,
|
||||
binaryMessenger: flutterController.binaryMessenger
|
||||
)
|
||||
callAudioChannel.setMethodCallHandler { [weak self] call, result in
|
||||
guard let self else {
|
||||
result(nil)
|
||||
return
|
||||
}
|
||||
switch call.method {
|
||||
case "activate":
|
||||
self.activateInAppCallAudioSession()
|
||||
result(nil)
|
||||
case "restart":
|
||||
self.restartInAppCallAudioUnit()
|
||||
result(nil)
|
||||
case "deactivate":
|
||||
self.deactivateInAppCallAudioSession()
|
||||
result(nil)
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func flutterViewControllerFromScenes() -> FlutterViewController? {
|
||||
for scene in UIApplication.shared.connectedScenes {
|
||||
guard let windowScene = scene as? UIWindowScene else { continue }
|
||||
for sceneWindow in windowScene.windows {
|
||||
if let controller = sceneWindow.rootViewController as? FlutterViewController {
|
||||
return controller
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@available(iOS 8.0, *)
|
||||
@@ -83,6 +160,14 @@ import flutter_foreground_task
|
||||
open url: URL,
|
||||
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
|
||||
) -> Bool {
|
||||
handleOpenURL(
|
||||
url,
|
||||
sourceApplication: options[UIApplication.OpenURLOptionsKey.sourceApplication] as? String
|
||||
)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func handleOpenURL(_ url: URL, sourceApplication: String?) -> Bool {
|
||||
if url.scheme == appUrlScheme {
|
||||
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||
let queryItems = components?.queryItems ?? []
|
||||
@@ -116,10 +201,39 @@ import flutter_foreground_task
|
||||
|
||||
return YXLSdk.shared.handleOpen(
|
||||
url,
|
||||
sourceApplication: options[UIApplication.OpenURLOptionsKey.sourceApplication] as? String
|
||||
sourceApplication: sourceApplication
|
||||
)
|
||||
}
|
||||
|
||||
/// Same derivation as [VideoCallKitId.iosUuid]: one CallKit UUID per message,
|
||||
/// so the next call in this chat is not rejected as a duplicate UUID.
|
||||
private func derivedCallKitId(conversationUuid: String, messageId: UInt64?) -> String? {
|
||||
guard let messageId, messageId > 0 else { return nil }
|
||||
let pattern =
|
||||
#"^([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}$"#
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
|
||||
let range = NSRange(conversationUuid.startIndex..., in: conversationUuid)
|
||||
guard let match = regex.firstMatch(in: conversationUuid, range: range),
|
||||
let prefixRange = Range(match.range(at: 1), in: conversationUuid)
|
||||
else { return nil }
|
||||
let suffix = String(format: "%012llx", messageId)
|
||||
guard suffix.count == 12 else { return nil }
|
||||
return (String(conversationUuid[prefixRange]) + suffix).lowercased()
|
||||
}
|
||||
|
||||
private func voipMessageId(_ value: Any?) -> UInt64? {
|
||||
if let number = value as? NSNumber {
|
||||
let parsed = number.uint64Value
|
||||
return parsed > 0 ? parsed : nil
|
||||
}
|
||||
if let text = value as? String {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let parsed = UInt64(trimmed), parsed > 0 else { return nil }
|
||||
return parsed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func conversationUuid(fromActionUrl actionUrl: String?) -> String? {
|
||||
guard
|
||||
let actionUrl = actionUrl,
|
||||
@@ -283,7 +397,9 @@ import flutter_foreground_task
|
||||
events = Array(events.suffix(20))
|
||||
}
|
||||
defaults.set(events, forKey: voipDiagnosticsStorageKey)
|
||||
voipDiagnosticsChannel?.invokeMethod("voipDiagnosticsUpdated", arguments: nil)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.voipDiagnosticsChannel?.invokeMethod("voipDiagnosticsUpdated", arguments: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func drainVoipDiagnostics() -> [[String: Any]] {
|
||||
@@ -293,6 +409,160 @@ import flutter_foreground_task
|
||||
return events
|
||||
}
|
||||
|
||||
private func isEndedVideoCallMarker(_ marker: String) -> Bool {
|
||||
if marker == "video_call_ended" || marker == "video_call_end" {
|
||||
return true
|
||||
}
|
||||
return marker.contains("video_call")
|
||||
&& (marker.contains("ended") || marker.hasSuffix("_end") || marker.hasSuffix("_cancel"))
|
||||
}
|
||||
|
||||
private func incomingCallId(from arguments: Any?) -> String {
|
||||
let raw: String?
|
||||
if let id = arguments as? String {
|
||||
raw = id
|
||||
} else if let map = arguments as? [String: Any] {
|
||||
raw = map["id"] as? String
|
||||
} else {
|
||||
raw = nil
|
||||
}
|
||||
return raw?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? ""
|
||||
}
|
||||
|
||||
/// Caller must hold `incomingCallClaimLock`.
|
||||
private func pruneIncomingCallClaims(now: Date = Date()) {
|
||||
reservedIncomingCallIds = reservedIncomingCallIds.filter {
|
||||
now.timeIntervalSince($0.value) < incomingCallClaimTtl
|
||||
}
|
||||
}
|
||||
|
||||
private func activeIncomingCallIds() -> Set<String> {
|
||||
let calls = SwiftFlutterCallkitIncomingPlugin.sharedInstance?.activeCalls() ?? []
|
||||
return Set(calls.compactMap { ($0["id"] as? String)?.lowercased() })
|
||||
}
|
||||
|
||||
private func isIncomingCallClaimed(_ id: String) -> Bool {
|
||||
let normalized = id.lowercased()
|
||||
guard !normalized.isEmpty else { return false }
|
||||
let reserved = incomingCallClaimLock.withLock { () -> Bool in
|
||||
pruneIncomingCallClaims()
|
||||
return reservedIncomingCallIds[normalized] != nil
|
||||
}
|
||||
if reserved {
|
||||
return true
|
||||
}
|
||||
return activeIncomingCallIds().contains(normalized)
|
||||
}
|
||||
|
||||
/// Returns false when this id is already reserved. The VoIP push still
|
||||
/// reports the call; this only stops the in-app path from reporting it too.
|
||||
private func reserveIncomingCall(_ id: String) -> Bool {
|
||||
let normalized = id.lowercased()
|
||||
guard !normalized.isEmpty else { return false }
|
||||
return incomingCallClaimLock.withLock {
|
||||
pruneIncomingCallClaims()
|
||||
if reservedIncomingCallIds[normalized] != nil {
|
||||
return false
|
||||
}
|
||||
reservedIncomingCallIds[normalized] = Date()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private func releaseIncomingCallClaim(_ id: String) {
|
||||
incomingCallClaimLock.withLock {
|
||||
_ = reservedIncomingCallIds.removeValue(forKey: id.lowercased())
|
||||
}
|
||||
}
|
||||
|
||||
/// An ended VoIP push has no message id, so its CallKit id is the
|
||||
/// conversation uuid, not the ringing call. End that conversation only.
|
||||
/// The first endCall while the push flag is set closes the call this push
|
||||
/// just reported; the next ones close the call that is actually ringing.
|
||||
private func endCallsForEndedPush(reportedId: String, conversationUuid: String) {
|
||||
guard let plugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance else {
|
||||
releaseIncomingCallClaim(reportedId)
|
||||
return
|
||||
}
|
||||
let conversation = conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
var ids: [String] = []
|
||||
func append(_ id: String) {
|
||||
let normalized = id.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalized.isEmpty else { return }
|
||||
if ids.contains(where: { $0.caseInsensitiveCompare(normalized) == .orderedSame }) {
|
||||
return
|
||||
}
|
||||
ids.append(normalized)
|
||||
}
|
||||
append(reportedId)
|
||||
for call in plugin.activeCalls() {
|
||||
let callId = (call["id"] as? String) ?? ""
|
||||
let handle = (call["handle"] as? String) ?? ""
|
||||
let extraUuid = extraConversationUuid(call["extra"])
|
||||
let sameId = callId.caseInsensitiveCompare(reportedId) == .orderedSame
|
||||
let sameConversation = !conversation.isEmpty && (
|
||||
handle.caseInsensitiveCompare(conversation) == .orderedSame
|
||||
|| extraUuid.caseInsensitiveCompare(conversation) == .orderedSame
|
||||
)
|
||||
if sameId || sameConversation {
|
||||
append(callId)
|
||||
}
|
||||
}
|
||||
for id in ids {
|
||||
let endData = flutter_callkit_incoming.Data(
|
||||
id: id,
|
||||
nameCaller: "Итвио",
|
||||
handle: conversation,
|
||||
type: 1
|
||||
)
|
||||
plugin.endCall(endData)
|
||||
releaseIncomingCallClaim(id)
|
||||
}
|
||||
}
|
||||
|
||||
private func activeCallId(forConversation conversationUuid: String) -> String? {
|
||||
let conversation = conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !conversation.isEmpty else { return nil }
|
||||
let calls = SwiftFlutterCallkitIncomingPlugin.sharedInstance?.activeCalls() ?? []
|
||||
for call in calls {
|
||||
let callId = (call["id"] as? String) ?? ""
|
||||
guard !callId.isEmpty else { continue }
|
||||
let handle = (call["handle"] as? String) ?? ""
|
||||
let extraUuid = extraConversationUuid(call["extra"])
|
||||
if handle.caseInsensitiveCompare(conversation) == .orderedSame
|
||||
|| extraUuid.caseInsensitiveCompare(conversation) == .orderedSame
|
||||
{
|
||||
return callId
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func extraConversationUuid(_ extra: Any?) -> String {
|
||||
let map: [String: Any]
|
||||
if let typed = extra as? [String: Any] {
|
||||
map = typed
|
||||
} else if let object = extra as? NSDictionary {
|
||||
var converted: [String: Any] = [:]
|
||||
for (key, value) in object {
|
||||
if let key = key as? String {
|
||||
converted[key] = value
|
||||
}
|
||||
}
|
||||
map = converted
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
for key in ["conversationUuid", "conversation_uuid"] {
|
||||
if let value = map[key] as? String {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func pushRegistry(
|
||||
_ registry: PKPushRegistry,
|
||||
@@ -300,11 +570,15 @@ import flutter_foreground_task
|
||||
for type: PKPushType
|
||||
) {
|
||||
let deviceToken = credentials.token.map { String(format: "%02x", $0) }.joined()
|
||||
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP(deviceToken)
|
||||
DispatchQueue.main.async {
|
||||
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP(deviceToken)
|
||||
}
|
||||
}
|
||||
|
||||
func pushRegistry(_ registry: PKPushRegistry, didInvalidatePushTokenFor type: PKPushType) {
|
||||
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP("")
|
||||
DispatchQueue.main.async {
|
||||
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP("")
|
||||
}
|
||||
}
|
||||
|
||||
func pushRegistry(
|
||||
@@ -329,8 +603,8 @@ import flutter_foreground_task
|
||||
]
|
||||
.compactMap { $0?.lowercased().replacingOccurrences(of: "-", with: "_") }
|
||||
|
||||
if pushMarkers.contains(where: { $0.contains("video_call") })
|
||||
&& pushMarkers.contains(where: { $0.contains("end") || $0.contains("cancel") }) {
|
||||
let isEndedPush = pushMarkers.contains(where: { self.isEndedVideoCallMarker($0) })
|
||||
if isEndedPush {
|
||||
recordVoipDiagnostic(
|
||||
"voip_end_or_cancel_received",
|
||||
data: [
|
||||
@@ -338,18 +612,18 @@ import flutter_foreground_task
|
||||
"payload": dict,
|
||||
]
|
||||
)
|
||||
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.endAllCalls()
|
||||
completion()
|
||||
return
|
||||
}
|
||||
|
||||
let nameCaller = (dict["nameCaller"] as? String)
|
||||
let rawNameCaller = (dict["nameCaller"] as? String)
|
||||
?? (dict["counterpartName"] as? String)
|
||||
?? (dict["counterpart_name"] as? String)
|
||||
?? (dict["caller_name"] as? String)
|
||||
?? (dict["callerName"] as? String)
|
||||
?? (dict["caller"] as? String)
|
||||
?? ""
|
||||
let nameCaller = rawNameCaller.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
? "Итвио"
|
||||
: rawNameCaller.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let conversationUuid = (dict["conversation_uuid"] as? String)
|
||||
?? (dict["conversationUuid"] as? String)
|
||||
?? (dict["conversation_id"] as? String)
|
||||
@@ -358,26 +632,33 @@ import flutter_foreground_task
|
||||
?? conversationUuid(fromActionUrl: dict["action_url"] as? String)
|
||||
?? (dict["handle"] as? String)
|
||||
?? ""
|
||||
let callKitIdSource = [
|
||||
(key: "call_id", value: dict["call_id"] as? String),
|
||||
(key: "id", value: dict["id"] as? String),
|
||||
(key: "message_id", value: dict["message_id"] as? String),
|
||||
]
|
||||
.first { item in
|
||||
guard let trimmed = item.value?.trimmingCharacters(in: .whitespacesAndNewlines) else {
|
||||
return false
|
||||
}
|
||||
return !trimmed.isEmpty && UUID(uuidString: trimmed) != nil
|
||||
}
|
||||
let id = callKitIdSource?.value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? UUID().uuidString
|
||||
let callKitIdSourceName = callKitIdSource?.key ?? "generated"
|
||||
let trimmedConversationUuid = conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let parsedMessageId = voipMessageId(dict["message_id"])
|
||||
let id: String
|
||||
let callKitIdSourceName: String
|
||||
if isEndedPush, let existing = activeCallId(forConversation: trimmedConversationUuid) {
|
||||
id = existing
|
||||
callKitIdSourceName = "active_conversation"
|
||||
} else if let derived = derivedCallKitId(
|
||||
conversationUuid: trimmedConversationUuid,
|
||||
messageId: parsedMessageId
|
||||
) {
|
||||
id = derived
|
||||
callKitIdSourceName = "conversation_and_message"
|
||||
} else if UUID(uuidString: trimmedConversationUuid) != nil {
|
||||
id = trimmedConversationUuid.lowercased()
|
||||
callKitIdSourceName = "conversation"
|
||||
} else {
|
||||
id = UUID().uuidString.lowercased()
|
||||
callKitIdSourceName = "generated"
|
||||
}
|
||||
let isVideo = (dict["isVideo"] as? Bool)
|
||||
?? (dict["is_video"] as? Bool)
|
||||
?? true
|
||||
|
||||
var extra: [String: Any] = [:]
|
||||
if !conversationUuid.isEmpty {
|
||||
extra["conversationUuid"] = conversationUuid
|
||||
if !trimmedConversationUuid.isEmpty {
|
||||
extra["conversationUuid"] = trimmedConversationUuid
|
||||
}
|
||||
if let avatar = dict["caller_avatar"] as? String ?? dict["avatar"] as? String
|
||||
?? dict["avatarUrl"] as? String
|
||||
@@ -409,10 +690,21 @@ import flutter_foreground_task
|
||||
let data = flutter_callkit_incoming.Data(
|
||||
id: id,
|
||||
nameCaller: nameCaller,
|
||||
handle: conversationUuid.isEmpty ? nameCaller : conversationUuid,
|
||||
handle: trimmedConversationUuid.isEmpty ? nameCaller : trimmedConversationUuid,
|
||||
type: isVideo ? 1 : 0
|
||||
)
|
||||
data.duration = 45000
|
||||
data.extra = extra as NSDictionary
|
||||
// The plugin activates AVAudioSession itself on answer and again 1.2s
|
||||
// later. That drops the ringing UI and leaves one side silent. CallKit
|
||||
// and WebRTC own the session instead.
|
||||
data.configureAudioSession = false
|
||||
data.audioSessionActive = false
|
||||
data.audioSessionMode = "videoChat"
|
||||
data.ringtonePath = "system_ringtone_default"
|
||||
// Remember the id so the in-app socket path does not report it again.
|
||||
// The push itself must still be reported, or iOS kills the process.
|
||||
_ = reserveIncomingCall(id)
|
||||
|
||||
NSLog(
|
||||
"[EatvioVoIP] report incoming call id=\(id) conversation=\(conversationUuid) state=\(UIApplication.shared.applicationState.rawValue) markers=\(pushMarkers.joined(separator: ",")) payload=\(dict)"
|
||||
@@ -430,58 +722,96 @@ import flutter_foreground_task
|
||||
]
|
||||
)
|
||||
|
||||
guard let callkitPlugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance else {
|
||||
NSLog("[EatvioVoIP] CallKit plugin missing id=\(id)")
|
||||
recordVoipDiagnostic(
|
||||
"callkit_plugin_missing",
|
||||
data: [
|
||||
"callKitId": id,
|
||||
"conversationUuid": conversationUuid,
|
||||
]
|
||||
)
|
||||
completion()
|
||||
return
|
||||
reportVoipPushToCallKit(
|
||||
data,
|
||||
callKitId: id,
|
||||
callKitIdSourceName: callKitIdSourceName,
|
||||
conversationUuid: conversationUuid,
|
||||
endAfterReport: isEndedPush,
|
||||
completion: completion
|
||||
)
|
||||
}
|
||||
|
||||
/// PushKit kills the app unless every VoIP push is reported to CallKit and
|
||||
/// its completion handler runs. Both happen here, including an "ended" push.
|
||||
private func reportVoipPushToCallKit(
|
||||
_ data: flutter_callkit_incoming.Data,
|
||||
callKitId: String,
|
||||
callKitIdSourceName: String,
|
||||
conversationUuid: String,
|
||||
endAfterReport: Bool,
|
||||
completion: @escaping () -> Void
|
||||
) {
|
||||
let gate = VoipPushCompletion(completion)
|
||||
let finish = { self.voipPushQueue.async { gate.call() } }
|
||||
voipPushQueue.asyncAfter(deadline: .now() + 1.5) {
|
||||
finish()
|
||||
}
|
||||
|
||||
callkitPlugin.showCallkitIncoming(
|
||||
data,
|
||||
fromPushKit: true
|
||||
) {
|
||||
let activeCalls = callkitPlugin.activeCalls()
|
||||
let activeIds = activeCalls.compactMap { $0["id"] as? String }.joined(separator: ",")
|
||||
NSLog(
|
||||
"[EatvioVoIP] report incoming completed id=\(id) activeCallIds=\(activeIds)"
|
||||
)
|
||||
self.recordVoipDiagnostic(
|
||||
"callkit_report_completed",
|
||||
data: [
|
||||
"callKitId": id,
|
||||
"callKitIdSource": callKitIdSourceName,
|
||||
"conversationUuid": conversationUuid,
|
||||
"activeCallIds": activeIds,
|
||||
"activeCallsCount": activeCalls.count,
|
||||
]
|
||||
)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||
let delayedActiveCalls = callkitPlugin.activeCalls()
|
||||
let delayedActiveIds = delayedActiveCalls.compactMap { $0["id"] as? String }.joined(separator: ",")
|
||||
let afterReport = {
|
||||
if endAfterReport {
|
||||
DispatchQueue.main.async {
|
||||
self.endCallsForEndedPush(
|
||||
reportedId: callKitId,
|
||||
conversationUuid: conversationUuid
|
||||
)
|
||||
}
|
||||
}
|
||||
if let plugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance {
|
||||
let activeCalls = plugin.activeCalls()
|
||||
let activeIds = activeCalls.compactMap { $0["id"] as? String }.joined(separator: ",")
|
||||
self.recordVoipDiagnostic(
|
||||
"callkit_active_calls_delayed",
|
||||
"callkit_report_completed",
|
||||
data: [
|
||||
"callKitId": id,
|
||||
"callKitId": callKitId,
|
||||
"callKitIdSource": callKitIdSourceName,
|
||||
"conversationUuid": conversationUuid,
|
||||
"activeCallIds": delayedActiveIds,
|
||||
"activeCallsCount": delayedActiveCalls.count,
|
||||
"activeCallIds": activeIds,
|
||||
"activeCallsCount": activeCalls.count,
|
||||
"endAfterReport": endAfterReport,
|
||||
]
|
||||
)
|
||||
}
|
||||
completion()
|
||||
finish()
|
||||
}
|
||||
|
||||
if let plugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance {
|
||||
plugin.showCallkitIncoming(data, fromPushKit: true, completion: afterReport)
|
||||
return
|
||||
}
|
||||
|
||||
NSLog("[EatvioVoIP] CallKit plugin missing id=\(callKitId)")
|
||||
recordVoipDiagnostic(
|
||||
"callkit_plugin_missing",
|
||||
data: [
|
||||
"callKitId": callKitId,
|
||||
"conversationUuid": conversationUuid,
|
||||
]
|
||||
)
|
||||
guard let uuid = UUID(uuidString: data.uuid) else {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
voipCallReporter.report(
|
||||
uuid: uuid,
|
||||
callerName: data.nameCaller,
|
||||
hasVideo: data.type > 0
|
||||
) { _ in
|
||||
if endAfterReport {
|
||||
self.voipCallReporter.provider.reportCall(
|
||||
with: uuid,
|
||||
endedAt: Date(),
|
||||
reason: .remoteEnded
|
||||
)
|
||||
}
|
||||
afterReport()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func onAccept(_ call: Call, _ action: CXAnswerCallAction) {
|
||||
resumeAudioAfterCallKitEnds = true
|
||||
prepareCallAudioCategory()
|
||||
storePendingAcceptedCall(call)
|
||||
recordVoipDiagnostic(
|
||||
"callkit_accept_native",
|
||||
@@ -500,6 +830,8 @@ import flutter_foreground_task
|
||||
}
|
||||
|
||||
func onDecline(_ call: Call, _ action: CXEndCallAction) {
|
||||
resumeAudioAfterCallKitEnds = false
|
||||
audioRestartGeneration += 1
|
||||
action.fulfill()
|
||||
}
|
||||
|
||||
@@ -509,9 +841,151 @@ import flutter_foreground_task
|
||||
|
||||
func onTimeOut(_ call: Call) {}
|
||||
|
||||
func didActivateAudioSession(_ audioSession: AVAudioSession) {}
|
||||
func didActivateAudioSession(_ audioSession: AVAudioSession) {
|
||||
let rtcSession = RTCAudioSession.sharedInstance()
|
||||
rtcSession.useManualAudio = true
|
||||
rtcSession.audioSessionDidActivate(audioSession)
|
||||
applyCallAudioConfiguration(active: false)
|
||||
rtcSession.isAudioEnabled = true
|
||||
}
|
||||
|
||||
func didDeactivateAudioSession(_ audioSession: AVAudioSession) {}
|
||||
func didDeactivateAudioSession(_ audioSession: AVAudioSession) {
|
||||
let rtcSession = RTCAudioSession.sharedInstance()
|
||||
rtcSession.audioSessionDidDeactivate(audioSession)
|
||||
// The audio unit is stopped, but isAudioEnabled stays true. A later
|
||||
// assignment of true is then a no-op and the microphone never restarts.
|
||||
rtcSession.isAudioEnabled = false
|
||||
guard resumeAudioAfterCallKitEnds else { return }
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, self.resumeAudioAfterCallKitEnds else { return }
|
||||
self.restartInAppCallAudioUnit()
|
||||
}
|
||||
}
|
||||
|
||||
func providerDidReset() {}
|
||||
func providerDidReset() {
|
||||
resumeAudioAfterCallKitEnds = false
|
||||
audioRestartGeneration += 1
|
||||
}
|
||||
|
||||
private func configureWebRTCAudioSession() {
|
||||
let configuration = callAudioConfiguration()
|
||||
RTCAudioSessionConfiguration.setWebRTC(configuration)
|
||||
}
|
||||
|
||||
private func prepareCallAudioCategory() {
|
||||
applyCallAudioConfiguration(active: false)
|
||||
}
|
||||
|
||||
private func activateInAppCallAudioSession() {
|
||||
let rtcSession = RTCAudioSession.sharedInstance()
|
||||
rtcSession.useManualAudio = true
|
||||
applyCallAudioConfiguration(active: true)
|
||||
// Starting the audio unit while the configuration lock is held deadlocks
|
||||
// the capture thread, so the mic stays silent.
|
||||
rtcSession.isAudioEnabled = true
|
||||
}
|
||||
|
||||
/// Restarts capture after the previous audio graph has disconnected.
|
||||
/// Toggling the unit in the same turn makes voice processing throw.
|
||||
private func restartInAppCallAudioUnit() {
|
||||
audioRestartGeneration += 1
|
||||
let generation = audioRestartGeneration
|
||||
let rtcSession = RTCAudioSession.sharedInstance()
|
||||
rtcSession.useManualAudio = true
|
||||
rtcSession.isAudioEnabled = false
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
|
||||
guard let self, generation == self.audioRestartGeneration else { return }
|
||||
self.activateInAppCallAudioSession()
|
||||
}
|
||||
}
|
||||
|
||||
private func applyCallAudioConfiguration(active: Bool) {
|
||||
let rtcSession = RTCAudioSession.sharedInstance()
|
||||
rtcSession.lockForConfiguration()
|
||||
defer { rtcSession.unlockForConfiguration() }
|
||||
do {
|
||||
try rtcSession.setConfiguration(callAudioConfiguration(), active: active)
|
||||
} catch {
|
||||
NSLog("[EatvioVoIP] apply call audio configuration failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func deactivateInAppCallAudioSession() {
|
||||
resumeAudioAfterCallKitEnds = false
|
||||
audioRestartGeneration += 1
|
||||
let rtcSession = RTCAudioSession.sharedInstance()
|
||||
rtcSession.isAudioEnabled = false
|
||||
rtcSession.lockForConfiguration()
|
||||
defer { rtcSession.unlockForConfiguration() }
|
||||
do {
|
||||
try rtcSession.setActive(false)
|
||||
} catch {
|
||||
NSLog("[EatvioVoIP] deactivate call audio session failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func callAudioConfiguration() -> RTCAudioSessionConfiguration {
|
||||
let configuration = RTCAudioSessionConfiguration.webRTC()
|
||||
configuration.category = AVAudioSession.Category.playAndRecord.rawValue
|
||||
configuration.categoryOptions = [
|
||||
.allowBluetooth,
|
||||
.defaultToSpeaker,
|
||||
]
|
||||
// videoChat is the speakerphone mode for a video call. voiceChat keeps
|
||||
// the input on the receiver, so the mic is silent once audio goes to
|
||||
// the loudspeaker.
|
||||
configuration.mode = AVAudioSession.Mode.videoChat.rawValue
|
||||
// Voice processing rejects 44.1 kHz and throws from AVAudioEngine.
|
||||
configuration.sampleRate = 48000
|
||||
configuration.ioBufferDuration = 0.02
|
||||
configuration.inputNumberOfChannels = 1
|
||||
configuration.outputNumberOfChannels = 1
|
||||
return configuration
|
||||
}
|
||||
}
|
||||
|
||||
/// Reports a VoIP push when the Flutter CallKit plugin is not registered yet.
|
||||
private final class VoipPushCompletion {
|
||||
private var finished = false
|
||||
private let completion: () -> Void
|
||||
|
||||
init(_ completion: @escaping () -> Void) {
|
||||
self.completion = completion
|
||||
}
|
||||
|
||||
func call() {
|
||||
if finished { return }
|
||||
finished = true
|
||||
completion()
|
||||
}
|
||||
}
|
||||
|
||||
private final class VoipCallReporter: NSObject, CXProviderDelegate {
|
||||
let provider: CXProvider
|
||||
|
||||
override init() {
|
||||
let configuration = CXProviderConfiguration(localizedName: "Итвио")
|
||||
configuration.supportsVideo = true
|
||||
configuration.maximumCallGroups = 1
|
||||
configuration.maximumCallsPerCallGroup = 1
|
||||
configuration.supportedHandleTypes = [.generic]
|
||||
provider = CXProvider(configuration: configuration)
|
||||
super.init()
|
||||
provider.setDelegate(self, queue: nil)
|
||||
}
|
||||
|
||||
func report(
|
||||
uuid: UUID,
|
||||
callerName: String,
|
||||
hasVideo: Bool,
|
||||
completion: @escaping (Error?) -> Void
|
||||
) {
|
||||
let update = CXCallUpdate()
|
||||
update.remoteHandle = CXHandle(type: .generic, value: callerName)
|
||||
update.localizedCallerName = callerName
|
||||
update.hasVideo = hasVideo
|
||||
provider.reportNewIncomingCall(with: uuid, update: update, completion: completion)
|
||||
}
|
||||
|
||||
func providerDidReset(_ provider: CXProvider) {}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,27 @@
|
||||
<string>Может использоваться для стабильного поддержания связи во время звонка.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Предоставление доступа позволит Вам добавлять фото к записям</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
<key>UISceneConfigurations</key>
|
||||
<dict>
|
||||
<key>UIWindowSceneSessionRoleApplication</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UISceneClassName</key>
|
||||
<string>UIWindowScene</string>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>flutter</string>
|
||||
<key>UISceneDelegateClassName</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
|
||||
<key>UISceneStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UIBackgroundModes</key>
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
<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>
|
||||
|
||||
86
ios/Runner/SceneDelegate.swift
Normal file
86
ios/Runner/SceneDelegate.swift
Normal file
@@ -0,0 +1,86 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
import YandexLoginSDK
|
||||
|
||||
/// iOS 27 requires a UIScene lifecycle. Flutter 3.38 does not yet have
|
||||
/// `FlutterImplicitEngineDelegate`, so channels and URL handling are wired
|
||||
/// here after the scene creates the Flutter view.
|
||||
class SceneDelegate: FlutterSceneDelegate {
|
||||
override func scene(
|
||||
_ scene: UIScene,
|
||||
willConnectTo session: UISceneSession,
|
||||
options connectionOptions: UIScene.ConnectionOptions
|
||||
) {
|
||||
super.scene(scene, willConnectTo: session, options: connectionOptions)
|
||||
registerCallChannels()
|
||||
handleConnectionOptions(connectionOptions)
|
||||
}
|
||||
|
||||
override func sceneDidBecomeActive(_ scene: UIScene) {
|
||||
super.sceneDidBecomeActive(scene)
|
||||
registerCallChannels()
|
||||
}
|
||||
|
||||
override func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
|
||||
var handledCustomURL = false
|
||||
if let appDelegate = UIApplication.shared.delegate as? AppDelegate {
|
||||
for context in URLContexts {
|
||||
if appDelegate.handleOpenURL(
|
||||
context.url,
|
||||
sourceApplication: context.options.sourceApplication
|
||||
) {
|
||||
handledCustomURL = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !handledCustomURL {
|
||||
super.scene(scene, openURLContexts: URLContexts)
|
||||
}
|
||||
}
|
||||
|
||||
override func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
|
||||
if YXLSdk.shared.processUserActivity(userActivity) {
|
||||
return
|
||||
}
|
||||
super.scene(scene, continue: userActivity)
|
||||
}
|
||||
|
||||
private func registerCallChannels() {
|
||||
guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else {
|
||||
return
|
||||
}
|
||||
appDelegate.registerCallChannels(from: flutterViewController)
|
||||
}
|
||||
|
||||
private var flutterViewController: FlutterViewController? {
|
||||
if let controller = window?.rootViewController as? FlutterViewController {
|
||||
return controller
|
||||
}
|
||||
for scene in UIApplication.shared.connectedScenes {
|
||||
guard let windowScene = scene as? UIWindowScene else { continue }
|
||||
for sceneWindow in windowScene.windows {
|
||||
if let controller = sceneWindow.rootViewController as? FlutterViewController {
|
||||
return controller
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func handleConnectionOptions(_ options: UIScene.ConnectionOptions) {
|
||||
guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else {
|
||||
return
|
||||
}
|
||||
for context in options.urlContexts {
|
||||
_ = appDelegate.handleOpenURL(
|
||||
context.url,
|
||||
sourceApplication: context.options.sourceApplication
|
||||
)
|
||||
}
|
||||
for activity in options.userActivities {
|
||||
if YXLSdk.shared.processUserActivity(activity) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:eatvio_app/core/app_initializer.dart';
|
||||
import 'package:eatvio_app/core/cubit/marketing/marketing_cubit.dart';
|
||||
import 'package:eatvio_app/core/env_config.dart';
|
||||
import 'package:eatvio_app/core/internet_connection_cubit/internet_connection_cubit.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/prefs.dart';
|
||||
import 'package:eatvio_app/core/prefs_keys.dart';
|
||||
import 'package:eatvio_app/core/views/cubit/views_cubit.dart';
|
||||
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/new_messages_cubit/new_messages_cubit.dart';
|
||||
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
|
||||
@@ -31,20 +35,50 @@ class AppConfig {
|
||||
|
||||
void setBaseUrl(String baseUrls) {
|
||||
_baseUrl = baseUrls;
|
||||
_persistBaseUrl(baseUrls);
|
||||
}
|
||||
|
||||
void _persistBaseUrl(String baseUrl) {
|
||||
if (!sl.isRegistered<Prefs>()) {
|
||||
return;
|
||||
}
|
||||
final normalized = baseUrl.trim();
|
||||
if (normalized.isEmpty) {
|
||||
unawaited(sl<Prefs>().remove(PrefsKeys.customBaseUrl));
|
||||
return;
|
||||
}
|
||||
unawaited(sl<Prefs>().setString(PrefsKeys.customBaseUrl, normalized));
|
||||
}
|
||||
|
||||
/// Список глобальных BlocProvider'ов
|
||||
/// Используются lazy: true для отложенной инициализации, чтобы не блокировать старт приложения
|
||||
List<BlocProvider> get globalBlocProviders => [
|
||||
BlocProvider<VideoCallSessionCubit>.value(value: sl<VideoCallSessionCubit>()),
|
||||
BlocProvider<NewMessagesCubit>(lazy: true, create: (context) => sl<NewMessagesCubit>()),
|
||||
BlocProvider<NotificationsCubit>(lazy: true, create: (context) => sl<NotificationsCubit>()),
|
||||
BlocProvider<VideoCallSessionCubit>.value(
|
||||
value: sl<VideoCallSessionCubit>(),
|
||||
),
|
||||
BlocProvider<NewMessagesCubit>(
|
||||
lazy: true,
|
||||
create: (context) => sl<NewMessagesCubit>(),
|
||||
),
|
||||
BlocProvider<NotificationsCubit>(
|
||||
lazy: true,
|
||||
create: (context) => sl<NotificationsCubit>(),
|
||||
),
|
||||
BlocProvider<FeedCubit>(lazy: true, create: (context) => sl<FeedCubit>()),
|
||||
BlocProvider<ProfileAvatarCubit>(lazy: true, create: (context) => sl<ProfileAvatarCubit>()),
|
||||
BlocProvider<ProfileAvatarCubit>(
|
||||
lazy: true,
|
||||
create: (context) => sl<ProfileAvatarCubit>(),
|
||||
),
|
||||
BlocProvider<ChatsCubit>(lazy: true, create: (context) => sl<ChatsCubit>()),
|
||||
BlocProvider<ViewsCubit>(lazy: true, create: (context) => sl<ViewsCubit>()),
|
||||
BlocProvider<InternetConnectionCubit>(lazy: false, create: (context) => sl<InternetConnectionCubit>()),
|
||||
BlocProvider<MarketingCubit>(lazy: true, create: (context) => sl<MarketingCubit>()),
|
||||
BlocProvider<InternetConnectionCubit>(
|
||||
lazy: false,
|
||||
create: (context) => sl<InternetConnectionCubit>(),
|
||||
),
|
||||
BlocProvider<MarketingCubit>(
|
||||
lazy: true,
|
||||
create: (context) => sl<MarketingCubit>(),
|
||||
),
|
||||
];
|
||||
|
||||
/// Делегаты локализации
|
||||
@@ -56,5 +90,7 @@ class AppConfig {
|
||||
];
|
||||
|
||||
/// Поддерживаемые локали
|
||||
List<Locale> get supportedLocales => [const Locale.fromSubtags(languageCode: 'ru')];
|
||||
List<Locale> get supportedLocales => [
|
||||
const Locale.fromSubtags(languageCode: 'ru'),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
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/core/locator_service.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_fcm_handler.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';
|
||||
@@ -18,7 +14,6 @@ import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
||||
/// Класс для инициализации приложения
|
||||
class AppInitializer {
|
||||
|
||||
/// Инициализация всех сервисов приложения
|
||||
static Future<void> initialize({required AppBuildType buildType}) async {
|
||||
await _initializeEnvironment();
|
||||
@@ -42,25 +37,21 @@ 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);
|
||||
}
|
||||
|
||||
|
||||
16
lib/core/cached_base_url.dart
Normal file
16
lib/core/cached_base_url.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/prefs.dart';
|
||||
import 'package:eatvio_app/core/prefs_keys.dart';
|
||||
|
||||
/// Подставляет сохранённый base URL до создания HTTP-клиента.
|
||||
void applyCachedBaseUrl() {
|
||||
if (!sl.isRegistered<Prefs>() || !sl.isRegistered<AppConfig>()) {
|
||||
return;
|
||||
}
|
||||
final cached = sl<Prefs>().getString(PrefsKeys.customBaseUrl)?.trim();
|
||||
if (cached == null || cached.isEmpty) {
|
||||
return;
|
||||
}
|
||||
sl<AppConfig>().setBaseUrl(cached);
|
||||
}
|
||||
@@ -122,7 +122,7 @@ class CubitRegistrations {
|
||||
|
||||
// Content viewing cubits
|
||||
sl.registerFactory<AboutDietCubit>(() => AboutDietCubit(dietsRepository: sl(), profileRepository: sl()));
|
||||
sl.registerFactory<AboutPlanCubit>(() => AboutPlanCubit(planRepository: sl()));
|
||||
sl.registerFactory<AboutPlanCubit>(() => AboutPlanCubit(planRepository: sl(), profileRepository: sl(), dietRepository: sl()));
|
||||
sl.registerFactory<AboutRecipeCubit>(() => AboutRecipeCubit(recipeRepository: sl(), profileRepository: sl()));
|
||||
sl.registerFactory<BlogPostCubit>(() => BlogPostCubit(sl()));
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/push/hms_client_error.dart';
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
||||
/// Rules for which errors are sent to crash reporting.
|
||||
@@ -15,6 +16,7 @@ class ErrorReportPolicy {
|
||||
if (error == null) return false;
|
||||
if (error is String) return false;
|
||||
if (error is SocketException) return false;
|
||||
if (isHmsClientUnavailable(error)) return false;
|
||||
|
||||
if (error is DioException) {
|
||||
return _shouldReportDio(error);
|
||||
|
||||
@@ -3,59 +3,57 @@ 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/utils/toasts.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:eatvio_app/core/notifications/notification_permission_requester.dart';
|
||||
import 'package:eatvio_app/core/push/hms_client_error.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/token_storage.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/services.dart' show MethodChannel, PlatformException;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
|
||||
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
|
||||
|
||||
class FirebaseApi {
|
||||
final ErrorReporter _errorReporter;
|
||||
final NotificationPermissionRequester _notificationPermissionRequester;
|
||||
final _firebaseMessaging = FirebaseMessaging.instance;
|
||||
|
||||
FirebaseApi({required ErrorReporter errorReporter}) : _errorReporter = errorReporter;
|
||||
FirebaseApi({
|
||||
required ErrorReporter errorReporter,
|
||||
NotificationPermissionRequester? notificationPermissionRequester,
|
||||
}) : _errorReporter = errorReporter,
|
||||
_notificationPermissionRequester = notificationPermissionRequester ?? NotificationPermissionRequester();
|
||||
|
||||
Future<String?> getToken() async {
|
||||
String? token;
|
||||
final credentials = await _resolvePushCredentials();
|
||||
return credentials.token;
|
||||
}
|
||||
|
||||
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,
|
||||
Future<void> _requestNotificationPermission() async {
|
||||
if (sl<AppConfig>().buildType != AppBuildType.prod) return;
|
||||
try {
|
||||
await _notificationPermissionRequester.requestOnce(
|
||||
() => _firebaseMessaging.requestPermission(),
|
||||
);
|
||||
} catch (e) {
|
||||
log('Failed to request Firebase messaging permission: $e');
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
Future<void> registerAnonymousPushToken() async {
|
||||
try {
|
||||
await _requestNotificationPermission();
|
||||
final deviceInfo = DeviceInfoPlugin();
|
||||
final credentials = await _resolvePushCredentials();
|
||||
final token = credentials.token;
|
||||
if (token == null || token.isEmpty) return;
|
||||
await sl<Dio>().post(
|
||||
'/api/anonymous_token',
|
||||
data: {
|
||||
'platform': Platform.isIOS ? 'ios' : (await _isHuaweiDevice()) ? 'huawei' : 'android',
|
||||
'platform': credentials.platform,
|
||||
'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id,
|
||||
'token': await getToken(),
|
||||
'token': token,
|
||||
},
|
||||
).timeout(
|
||||
const Duration(seconds: 10),
|
||||
@@ -73,14 +71,17 @@ class FirebaseApi {
|
||||
Future<void> registerPushToken() async {
|
||||
if (await TokenStorage.haveTokenInStorage()) {
|
||||
try {
|
||||
await _requestNotificationPermission();
|
||||
final deviceInfo = DeviceInfoPlugin();
|
||||
final isHuawei = Platform.isAndroid ? await _isHuaweiDevice() : false;
|
||||
final credentials = await _resolvePushCredentials();
|
||||
final token = credentials.token;
|
||||
if (token == null || token.isEmpty) return;
|
||||
await sl<Dio>().post(
|
||||
'/api/profile/notification/token/register',
|
||||
data: {
|
||||
'platform': Platform.isIOS ? 'ios' : (isHuawei ? 'huawei' : 'android'),
|
||||
'platform': credentials.platform,
|
||||
'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id,
|
||||
'token': await getToken(),
|
||||
'token': token,
|
||||
},
|
||||
).timeout(
|
||||
const Duration(seconds: 10),
|
||||
@@ -115,13 +116,13 @@ class FirebaseApi {
|
||||
Future<void> unregisterPushToken() async {
|
||||
if (!await TokenStorage.haveTokenInStorage()) return;
|
||||
try {
|
||||
final isHuawei = Platform.isAndroid ? await _isHuaweiDevice() : false;
|
||||
final platform = Platform.isIOS ? 'ios' : (isHuawei ? 'huawei' : 'android');
|
||||
final token = (await getToken()) ?? '';
|
||||
final credentials = await _resolvePushCredentials();
|
||||
final token = credentials.token ?? '';
|
||||
if (token.isEmpty) return;
|
||||
await sl<Dio>().post(
|
||||
'/api/profile/notification/token/unregister',
|
||||
data: {
|
||||
'platform': platform,
|
||||
'platform': credentials.platform,
|
||||
'token': token,
|
||||
},
|
||||
).timeout(
|
||||
@@ -181,30 +182,74 @@ class FirebaseApi {
|
||||
}
|
||||
}
|
||||
|
||||
Future<({String? token, String platform})> _resolvePushCredentials() async {
|
||||
if (Platform.isIOS) {
|
||||
return (
|
||||
token: await _getFcmToken(),
|
||||
platform: pushPlatform(isIos: true, huaweiFamily: false, hmsAvailable: false, hasHmsToken: false),
|
||||
);
|
||||
}
|
||||
|
||||
final huaweiFamily = await _isHuaweiDevice();
|
||||
if (huaweiFamily && await _isHmsAvailable()) {
|
||||
final token = await _getHuaweiPushTokenNative().timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () => null,
|
||||
);
|
||||
if (token != null && token.isNotEmpty) {
|
||||
return (
|
||||
token: token,
|
||||
platform: pushPlatform(isIos: false, huaweiFamily: true, hmsAvailable: true, hasHmsToken: true),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
token: await _getFcmToken(),
|
||||
platform: pushPlatform(isIos: false, huaweiFamily: huaweiFamily, hmsAvailable: false, hasHmsToken: false),
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> _getFcmToken() {
|
||||
return _firebaseMessaging.getToken().timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () => null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _isHuaweiDevice() async {
|
||||
if (!Platform.isAndroid) return false;
|
||||
try {
|
||||
final info = await DeviceInfoPlugin().androidInfo;
|
||||
final brand = info.brand.toLowerCase();
|
||||
final manufacturer = info.manufacturer.toLowerCase();
|
||||
return brand.contains('huawei') || brand.contains('honor') || manufacturer.contains('huawei') || manufacturer.contains('honor');
|
||||
return isHuaweiFamilyBrand(brand: info.brand, manufacturer: info.manufacturer);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _isHmsAvailable() async {
|
||||
try {
|
||||
const channel = MethodChannel('com.eatvioapp/hms_push');
|
||||
final available = await channel.invokeMethod<bool>('isHmsAvailable').timeout(const Duration(seconds: 5));
|
||||
return available ?? false;
|
||||
} catch (e, st) {
|
||||
log('HMS availability check failed: $e', stackTrace: st);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> _getHuaweiPushTokenNative() async {
|
||||
try {
|
||||
const channel = MethodChannel('com.eatvioapp/hms_push');
|
||||
final String? token = await channel.invokeMethod<String>('getHuaweiToken');
|
||||
return token;
|
||||
} on PlatformException catch (e, st) {
|
||||
showErrorToast('$e $st');
|
||||
log(e.toString(), stackTrace: st);
|
||||
await _errorReporter.report(e, st, origin: 'FirebaseApi');
|
||||
if (!isHmsClientUnavailable(e)) {
|
||||
await _errorReporter.report(e, st, origin: 'FirebaseApi');
|
||||
}
|
||||
return null;
|
||||
} catch (e, st) {
|
||||
showErrorToast('$e $st');
|
||||
log(e.toString(), stackTrace: st);
|
||||
await _errorReporter.report(e, st, origin: 'FirebaseApi');
|
||||
return null;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:eatvio_app/core/app_initializer.dart';
|
||||
import 'package:eatvio_app/core/cached_base_url.dart';
|
||||
import 'package:eatvio_app/core/di/app_config_registration.dart';
|
||||
import 'package:eatvio_app/core/di/cubit_registrations.dart';
|
||||
import 'package:eatvio_app/core/di/external_registrations.dart';
|
||||
@@ -21,6 +22,8 @@ Future<void> init({required AppBuildType buildType}) async {
|
||||
|
||||
// Ожидание инициализации асинхронных зависимостей
|
||||
await sl.getAsync<Prefs>();
|
||||
// До создания Dio и любых запросов к бэкенду.
|
||||
applyCachedBaseUrl();
|
||||
|
||||
// Регистрация сервисов
|
||||
ServiceRegistrations.registerAll();
|
||||
@@ -32,5 +35,7 @@ Future<void> init({required AppBuildType buildType}) async {
|
||||
CubitRegistrations.registerAll();
|
||||
|
||||
// Регистрация WebSocket менеджера
|
||||
sl.registerLazySingleton<WsManager>(() => WsManager(wsService: sl(), errorReporter: sl()));
|
||||
sl.registerLazySingleton<WsManager>(
|
||||
() => WsManager(wsService: sl(), errorReporter: sl()),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
class NotificationPermissionRequester {
|
||||
Future<void>? _inFlight;
|
||||
|
||||
/// Coalesces overlapping calls so the OS prompt is shown at most once at a time.
|
||||
Future<void> requestOnce(Future<void> Function() requestPermission) async {
|
||||
final inFlight = _inFlight;
|
||||
if (inFlight != null) {
|
||||
await inFlight;
|
||||
return;
|
||||
}
|
||||
|
||||
final future = requestPermission();
|
||||
_inFlight = future;
|
||||
try {
|
||||
await future;
|
||||
} finally {
|
||||
if (identical(_inFlight, future)) {
|
||||
_inFlight = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,11 @@ class PushNavigationHandler {
|
||||
'PushNavigationHandler: dismiss incoming call UI, source=$source',
|
||||
name: 'PushNavigationHandler',
|
||||
);
|
||||
unawaited(VideoCallEndedPushHandler.dismissIncomingCallUi());
|
||||
unawaited(
|
||||
VideoCallEndedPushHandler.dismissIncomingCallUi(
|
||||
conversationUuid: endedPayload.conversationUuid,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1,8 @@
|
||||
enum PrefsKeys { hasAnonymousTokenBeenSent, welcomeScreenWasShowed, registrationDate, marketingBottomSheetHasBeenShown, isEmailVerificationSkipped }
|
||||
enum PrefsKeys {
|
||||
hasAnonymousTokenBeenSent,
|
||||
welcomeScreenWasShowed,
|
||||
registrationDate,
|
||||
marketingBottomSheetHasBeenShown,
|
||||
isEmailVerificationSkipped,
|
||||
customBaseUrl,
|
||||
}
|
||||
|
||||
36
lib/core/push/hms_client_error.dart
Normal file
36
lib/core/push/hms_client_error.dart
Normal file
@@ -0,0 +1,36 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
bool isHuaweiFamilyBrand({
|
||||
required String brand,
|
||||
required String manufacturer,
|
||||
}) {
|
||||
final normalizedBrand = brand.toLowerCase();
|
||||
final normalizedManufacturer = manufacturer.toLowerCase();
|
||||
return normalizedBrand.contains('huawei') ||
|
||||
normalizedBrand.contains('honor') ||
|
||||
normalizedManufacturer.contains('huawei') ||
|
||||
normalizedManufacturer.contains('honor');
|
||||
}
|
||||
|
||||
/// HMS Core is missing or rejects this app.
|
||||
///
|
||||
/// Honor devices with Google services return `907135003: client api invalid`.
|
||||
/// That is a device capability, not an app crash: the caller should use FCM.
|
||||
bool isHmsClientUnavailable(Object error) {
|
||||
if (error is! PlatformException) return false;
|
||||
final message = '${error.message ?? ''} ${error.details ?? ''}'.toLowerCase();
|
||||
return message.contains('907135003') ||
|
||||
message.contains('client api invalid');
|
||||
}
|
||||
|
||||
/// `huawei` only when an HMS token was actually issued. Otherwise Android uses FCM.
|
||||
String pushPlatform({
|
||||
required bool isIos,
|
||||
required bool huaweiFamily,
|
||||
required bool hmsAvailable,
|
||||
required bool hasHmsToken,
|
||||
}) {
|
||||
if (isIos) return 'ios';
|
||||
if (huaweiFamily && hmsAvailable && hasHmsToken) return 'huawei';
|
||||
return 'android';
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import 'package:eatvio_app/features/create_diet/presentation/diet_preview_screen
|
||||
import 'package:eatvio_app/features/create_plan/presentation/create_plan_screen.dart';
|
||||
import 'package:eatvio_app/features/create_recipe/presentation/pages/recipe_create_screen.dart';
|
||||
import 'package:eatvio_app/features/dev_tools/presentation/dev_tools_screen.dart';
|
||||
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/recipes_model.dart';
|
||||
import 'package:eatvio_app/features/glossary/presentation/pages/glossary_screen.dart';
|
||||
import 'package:eatvio_app/features/menu/presentation/pages/blocked_users_screen.dart';
|
||||
@@ -183,7 +184,14 @@ class OtherRoutes {
|
||||
return DietPreviewScreen(diet: diet, isImageInBase64: isImageInBase64);
|
||||
},
|
||||
),
|
||||
GoRoute(path: RouteConstants.createPlanScreen, name: 'createPlanScreen', builder: (context, state) => const CreatePlanScreen()),
|
||||
GoRoute(
|
||||
path: RouteConstants.createPlanScreen,
|
||||
name: 'createPlanScreen',
|
||||
builder: (context, state) {
|
||||
final plan = state.extra is PlanModel ? state.extra as PlanModel : null;
|
||||
return CreatePlanScreen(plan: plan);
|
||||
},
|
||||
),
|
||||
GoRoute(path: MyDietsScreen.path, name: MyDietsScreen.name, builder: (context, state) => const MyDietsScreen()),
|
||||
GoRoute(path: MyPlansScreen.path, name: MyPlansScreen.name, builder: (context, state) => const MyPlansScreen()),
|
||||
GoRoute(path: MyRecipesScreen.path, name: MyRecipesScreen.name, builder: (context, state) => const MyRecipesScreen()),
|
||||
|
||||
61
lib/core/utils/inline_svg_css.dart
Normal file
61
lib/core/utils/inline_svg_css.dart
Normal file
@@ -0,0 +1,61 @@
|
||||
/// flutter_svg / vector_graphics ignore `<style>` blocks.
|
||||
/// Paths then get the SVG default fill (black), so badges look like a black blob.
|
||||
/// This inlines class rules from `<style>` as attributes before parsing.
|
||||
String inlineSvgCss(String svg) {
|
||||
final styleBlockPattern = RegExp(
|
||||
r'<style[^>]*>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?</style>',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
final classProps = <String, Map<String, String>>{};
|
||||
for (final match in styleBlockPattern.allMatches(svg)) {
|
||||
final css = match.group(1) ?? '';
|
||||
final rulePattern = RegExp(r'\.([A-Za-z0-9_-]+)\s*\{([^}]*)\}');
|
||||
for (final rule in rulePattern.allMatches(css)) {
|
||||
final className = rule.group(1)!;
|
||||
final body = rule.group(2)!;
|
||||
final props = classProps.putIfAbsent(className, () => <String, String>{});
|
||||
for (final declaration in body.split(';')) {
|
||||
final colon = declaration.indexOf(':');
|
||||
if (colon <= 0) {
|
||||
continue;
|
||||
}
|
||||
final key = declaration.substring(0, colon).trim();
|
||||
final value = declaration.substring(colon + 1).trim();
|
||||
if (key.isNotEmpty && value.isNotEmpty) {
|
||||
props[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var result = svg.replaceAll(styleBlockPattern, '');
|
||||
if (classProps.isEmpty) {
|
||||
return result;
|
||||
}
|
||||
|
||||
result = result.replaceAllMapped(RegExp(r'''\sclass=(["'])([^"']+)\1'''), (
|
||||
Match match,
|
||||
) {
|
||||
final classes = match
|
||||
.group(2)!
|
||||
.split(RegExp(r'\s+'))
|
||||
.where((name) => name.isNotEmpty);
|
||||
final attrs = <String, String>{};
|
||||
for (final className in classes) {
|
||||
final props = classProps[className];
|
||||
if (props != null) {
|
||||
attrs.addAll(props);
|
||||
}
|
||||
}
|
||||
if (attrs.isEmpty) {
|
||||
return match.group(0)!;
|
||||
}
|
||||
final attrString = attrs.entries
|
||||
.map((entry) => '${entry.key}="${entry.value}"')
|
||||
.join(' ');
|
||||
return ' $attrString';
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
11
lib/core/utils/url_utils.dart
Normal file
11
lib/core/utils/url_utils.dart
Normal file
@@ -0,0 +1,11 @@
|
||||
/// Joins an API media path with [baseUrl].
|
||||
///
|
||||
/// Uploaded photos come back as relative paths (`/storage/...`).
|
||||
/// Default avatars are already absolute (`https://ui-avatars...`) and must stay as-is.
|
||||
String joinBaseUrl(String baseUrl, String path) {
|
||||
final normalized = path.toLowerCase();
|
||||
if (normalized.startsWith('https://') || normalized.startsWith('http://')) {
|
||||
return path;
|
||||
}
|
||||
return '$baseUrl$path';
|
||||
}
|
||||
@@ -38,15 +38,35 @@ class WsManager {
|
||||
StreamSubscription? _eventSubscription;
|
||||
StreamSubscription? _connectionEstablishedSubscription;
|
||||
|
||||
/// Широковещательный сигнал о восстановлении соединения после обрыва (S5).
|
||||
///
|
||||
/// Контроллер создаётся один раз и не закрывается в [clear]: подписчики
|
||||
/// (чаты, окно чата) переживают логаут/повторный вход и продолжают получать
|
||||
/// сигналы. Если контроллер всё же закрыт, он пересоздаётся в [initialize].
|
||||
StreamController<void> _reconnectController = StreamController<void>.broadcast();
|
||||
|
||||
Stream<ChannelReadEvent>? _directChatStream;
|
||||
Stream<ChannelReadEvent>? _clientTypingStream;
|
||||
|
||||
Stream<ChannelReadEvent>? get newMessagesStream => _privateMessagesChannel?.bind('App\\Events\\ChatMessageUpdated');
|
||||
|
||||
Stream<ChannelReadEvent>? get newNotificationsStream => _privateNotificationChannel?.bind('Illuminate\\Notifications\\Events\\BroadcastNotificationCreated');
|
||||
|
||||
Stream<ChannelReadEvent>? get directChatStream => _privateDirectChatChannel?.bind('App\\Events\\ChatMessageUpdated');
|
||||
Stream<ChannelReadEvent>? get directChatStream => _directChatStream;
|
||||
|
||||
Stream<ChannelReadEvent>? get clientTypingStream => _privateDirectChatChannel?.bind('client-typing');
|
||||
Stream<ChannelReadEvent>? get clientTypingStream => _clientTypingStream;
|
||||
|
||||
/// Сигнал о реконнекте сокета: эмитится после повторной подписки на каналы.
|
||||
///
|
||||
/// Подписчики используют его, чтобы догрузить пропущенные за время обрыва
|
||||
/// сообщения (S5).
|
||||
Stream<void> get reconnected => _reconnectController.stream;
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (_reconnectController.isClosed) {
|
||||
_reconnectController = StreamController<void>.broadcast();
|
||||
}
|
||||
|
||||
if (client == null) {
|
||||
try {
|
||||
if (!await TokenStorage.haveTokenInStorage()) return;
|
||||
@@ -105,8 +125,7 @@ class WsManager {
|
||||
|
||||
_lifecycleSubscription = client?.lifecycleStream.listen((data) {
|
||||
if (data == PusherChannelsClientLifeCycleState.establishedConnection) {
|
||||
_unsubscribe();
|
||||
_subscribe();
|
||||
handleConnectionEstablished();
|
||||
}
|
||||
});
|
||||
_eventSubscription = client?.eventStream.listen((event) {
|
||||
@@ -170,6 +189,7 @@ class WsManager {
|
||||
}
|
||||
|
||||
Future<void> initPrivateDirectChatChannel(String chatId) async {
|
||||
_privateDirectChatChannel?.unsubscribe();
|
||||
final token = await TokenStorage.getTokenFromStorage();
|
||||
_privateDirectChatChannel = client?.privateChannel(
|
||||
'$directChatChannelName.$chatId',
|
||||
@@ -180,6 +200,8 @@ class WsManager {
|
||||
},
|
||||
),
|
||||
);
|
||||
_directChatStream = _privateDirectChatChannel?.bind('App\\Events\\ChatMessageUpdated');
|
||||
_clientTypingStream = _privateDirectChatChannel?.bind('client-typing');
|
||||
}
|
||||
|
||||
Future<void> initPresenceDirectChatChannel(String chatId) async {
|
||||
@@ -293,12 +315,27 @@ 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;
|
||||
}
|
||||
|
||||
void unsubscribePresenceDirectChatChannel() {
|
||||
@@ -331,6 +368,8 @@ class WsManager {
|
||||
_privateDirectChatChannel = null;
|
||||
_privateMessagesChannel = null;
|
||||
_privateNotificationChannel = null;
|
||||
_directChatStream = null;
|
||||
_clientTypingStream = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,12 +6,20 @@ import 'package:eatvio_app/core/utils/toasts.dart';
|
||||
import 'package:eatvio_app/core/widgets/show_error_dialog.dart';
|
||||
import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_cubit.dart';
|
||||
import 'package:eatvio_app/features/complaining/presentation/complaining_bottom_sheet.dart';
|
||||
import 'package:eatvio_app/features/create_diet/presentation/create_diet_screen.dart';
|
||||
import 'package:eatvio_app/features/create_plan/presentation/create_plan_screen.dart';
|
||||
import 'package:eatvio_app/features/create_recipe/presentation/pages/recipe_create_screen.dart';
|
||||
import 'package:eatvio_app/features/diets/presentation/cubit/about_diet_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/diets_cubit/diets_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/plans_cubit/plans_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/recipes_cubit/recipes_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/recipes_model.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/diets_cubit/diets_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/recipes_cubit/recipes_cubit.dart';
|
||||
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_cubit.dart';
|
||||
import 'package:eatvio_app/features/recipe/presentation/cubit/about_recipe_cubit.dart';
|
||||
import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_cubit.dart';
|
||||
import 'package:eatvio_app/features/my_plans/presentation/cubit/my_plans_cubit.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
|
||||
@@ -25,6 +33,7 @@ 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;
|
||||
@@ -32,12 +41,14 @@ 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,
|
||||
@@ -45,10 +56,14 @@ class CustomPopup extends StatelessWidget {
|
||||
this.feedBlogPostModel,
|
||||
this.recipe,
|
||||
this.diet,
|
||||
this.plan,
|
||||
required this.userSlug,
|
||||
this.onPostsChanged,
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -81,7 +96,103 @@ class CustomPopup extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
List<PopupMenuEntry> _buildMenuItems(S s,BuildContext context) {
|
||||
Future<void> _openEditor(BuildContext context) async {
|
||||
final router = GoRouter.of(context);
|
||||
switch (complainedContentType) {
|
||||
case ComplainedContent.posts:
|
||||
await router.pushNamed('blogPostCreateScreen', extra: feedBlogPostModel);
|
||||
onPostsChanged?.call();
|
||||
case ComplainedContent.recipe:
|
||||
final currentRecipe = recipe;
|
||||
if (currentRecipe == null) {
|
||||
return;
|
||||
}
|
||||
final result = await router.pushNamed(RecipeCreateScreen.name, extra: currentRecipe);
|
||||
if (result != null && context.mounted) {
|
||||
_reloadEditedContent(context);
|
||||
}
|
||||
case ComplainedContent.diet:
|
||||
final currentDiet = diet;
|
||||
if (currentDiet == null) {
|
||||
return;
|
||||
}
|
||||
final result = await router.pushNamed(CreateDietScreen.name, extra: {'diet': currentDiet, 'isEditing': true});
|
||||
if (result == true && context.mounted) {
|
||||
_reloadEditedContent(context);
|
||||
}
|
||||
case ComplainedContent.plan:
|
||||
final currentPlan = plan;
|
||||
if (currentPlan == null) {
|
||||
return;
|
||||
}
|
||||
final result = await router.pushNamed(CreatePlanScreen.name, extra: currentPlan);
|
||||
if (result == true && context.mounted) {
|
||||
_reloadEditedContent(context);
|
||||
}
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void _reloadEditedContent(BuildContext context) {
|
||||
switch (complainedContentType) {
|
||||
case ComplainedContent.recipe:
|
||||
final currentRecipe = recipe;
|
||||
if (currentRecipe == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
context.read<AboutRecipeCubit>().getRecipe(currentRecipe.slug);
|
||||
} catch (_) {}
|
||||
try {
|
||||
context.read<RecipesCubit>().loadRecipesList();
|
||||
} catch (_) {}
|
||||
case ComplainedContent.diet:
|
||||
final currentDiet = diet;
|
||||
if (currentDiet == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
context.read<AboutDietCubit>().getDiet(id: currentDiet.id, slug: currentDiet.slug);
|
||||
} catch (_) {}
|
||||
try {
|
||||
context.read<DietsCubit>().loadDiets();
|
||||
} catch (_) {}
|
||||
case ComplainedContent.plan:
|
||||
final currentPlan = plan;
|
||||
if (currentPlan == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
context.read<AboutPlanCubit>().getPlan(slug: currentPlan.slug);
|
||||
} catch (_) {}
|
||||
try {
|
||||
context.read<PlansCubit>().loadPlans();
|
||||
} catch (_) {}
|
||||
try {
|
||||
context.read<MyPlansCubit>().loadPlans();
|
||||
} catch (_) {}
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
bool _showsEditAction() {
|
||||
switch (complainedContentType) {
|
||||
case ComplainedContent.posts:
|
||||
return feedBlogPostModel != null || id != null;
|
||||
case ComplainedContent.recipe:
|
||||
return recipe != null;
|
||||
case ComplainedContent.diet:
|
||||
return diet != null;
|
||||
case ComplainedContent.plan:
|
||||
return plan != null;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
List<PopupMenuEntry> _buildMenuItems(S s, BuildContext context) {
|
||||
final entries = <PopupMenuEntry>[];
|
||||
|
||||
void addItem(String icon, String text, Function() onTap) {
|
||||
@@ -105,12 +216,9 @@ class CustomPopup extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
});
|
||||
if (complainedContentType == ComplainedContent.posts) {
|
||||
addItem('assets/icons/edit_paint.svg', 'Редактировать', () async {
|
||||
if (complainedContentType == ComplainedContent.posts) {
|
||||
await GoRouter.of(context).pushNamed('blogPostCreateScreen', extra: feedBlogPostModel);
|
||||
onPostsChanged?.call();
|
||||
}
|
||||
if (_showsEditAction()) {
|
||||
addItem('assets/icons/edit_paint.svg', s.edit, () {
|
||||
_openEditor(context);
|
||||
});
|
||||
}
|
||||
addItem('assets/icons/red_trash.svg', 'Удалить', () async {
|
||||
@@ -167,19 +275,21 @@ class CustomPopup extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
});
|
||||
addItem('assets/icons/complain.svg', 'Пожаловаться', () {
|
||||
if (complainedContentType != null) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => ComplainingBottomSheet(
|
||||
complainedContentType: complainedContentType!,
|
||||
id: id,
|
||||
slug: slug,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
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',
|
||||
'Заблокировать автора',
|
||||
|
||||
57
lib/core/widgets/network_svg_picture.dart
Normal file
57
lib/core/widgets/network_svg_picture.dart
Normal file
@@ -0,0 +1,57 @@
|
||||
import 'package:eatvio_app/core/utils/inline_svg_css.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
final Map<String, String> _inlinedSvgCache = <String, String>{};
|
||||
|
||||
class NetworkSvgPicture extends StatefulWidget {
|
||||
const NetworkSvgPicture({
|
||||
super.key,
|
||||
required this.url,
|
||||
this.width,
|
||||
this.height,
|
||||
});
|
||||
|
||||
final String url;
|
||||
final double? width;
|
||||
final double? height;
|
||||
|
||||
@override
|
||||
State<NetworkSvgPicture> createState() => _NetworkSvgPictureState();
|
||||
}
|
||||
|
||||
class _NetworkSvgPictureState extends State<NetworkSvgPicture> {
|
||||
late final Future<String> _svgFuture = _load(widget.url);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<String>(
|
||||
future: _svgFuture,
|
||||
builder: (BuildContext context, AsyncSnapshot<String> snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return SizedBox(width: widget.width, height: widget.height);
|
||||
}
|
||||
return SvgPicture.string(
|
||||
snapshot.data!,
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static Future<String> _load(String url) async {
|
||||
final cached = _inlinedSvgCache[url];
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
final response = await http.get(Uri.parse(url));
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw Exception('Failed to load SVG from $url');
|
||||
}
|
||||
final processed = inlineSvgCss(response.body);
|
||||
_inlinedSvgCache[url] = processed;
|
||||
return processed;
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ class SuccessPublishedDietOrPlanBottomSheet extends StatelessWidget {
|
||||
if (isDrafted == true) {
|
||||
text = 'cохранена в черновиках';
|
||||
} else if (isUpdated == true) {
|
||||
text = 'успешно обновлена';
|
||||
text = isPlan ? 'успешно обновлён' : 'успешно обновлена';
|
||||
} else if (isPlan == true) {
|
||||
text = 'опубликован';
|
||||
} else {
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:dismissible_page/dismissible_page.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/enums/complained_content.dart';
|
||||
import 'package:eatvio_app/core/utils/date_time_utils.dart';
|
||||
import 'package:eatvio_app/core/utils/url_utils.dart';
|
||||
import 'package:eatvio_app/core/widgets/custom_popup.dart';
|
||||
import 'package:eatvio_app/core/widgets/loader.dart';
|
||||
import 'package:eatvio_app/core/widgets/markdown_html_widget.dart';
|
||||
@@ -26,7 +27,11 @@ class BlogPostScreen extends StatefulWidget {
|
||||
final int id;
|
||||
final int? highlightedCommentId;
|
||||
|
||||
const BlogPostScreen({super.key, required this.id, this.highlightedCommentId});
|
||||
const BlogPostScreen({
|
||||
super.key,
|
||||
required this.id,
|
||||
this.highlightedCommentId,
|
||||
});
|
||||
|
||||
@override
|
||||
State<BlogPostScreen> createState() => _BlogPostScreenState();
|
||||
@@ -45,7 +50,10 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
}
|
||||
|
||||
void _openGallery(List<String> imageUrls, int index, {Object? heroTag}) {
|
||||
context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: imageUrls, index: index, heroTag: heroTag));
|
||||
context.pushTransparentRoute(
|
||||
rootNavigator: true,
|
||||
PhotoViewScreen(imageUrls: imageUrls, index: index, heroTag: heroTag),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -53,7 +61,8 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
return BlocProvider(
|
||||
create: (context) => sl<BlogPostCubit>()..getBlogPost(widget.id),
|
||||
child: BlocProvider<CommentsCubit>(
|
||||
create: (BuildContext context) => commentsCubit..getComments(widget.id.toString(), CommentSourceType.blogPost, true),
|
||||
create: (BuildContext context) => commentsCubit
|
||||
..getComments(widget.id.toString(), CommentSourceType.blogPost, true),
|
||||
child: BlocBuilder<BlogPostCubit, BlogPostState>(
|
||||
builder: (context, blogPostState) {
|
||||
return BlocConsumer<CommentsCubit, CommentsState>(
|
||||
@@ -81,15 +90,23 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
body: const Loader(),
|
||||
);
|
||||
}
|
||||
final bool isLiked = blogPostState.blogPostModel?.wasLikedByMe ?? false;
|
||||
final int totalLikes = blogPostState.blogPostModel?.totalLikes ?? 0;
|
||||
final bool isLiked =
|
||||
blogPostState.blogPostModel?.wasLikedByMe ?? false;
|
||||
final int totalLikes =
|
||||
blogPostState.blogPostModel?.totalLikes ?? 0;
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: true,
|
||||
bottomSheet: Container(
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom + 8),
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).padding.bottom + 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(top: BorderSide(color: Colors.black.withValues(alpha: 0.1))),
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
@@ -107,10 +124,17 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(color: appTheme.blueA400, shape: BoxShape.circle),
|
||||
decoration: BoxDecoration(
|
||||
color: appTheme.blueA400,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
height: 32,
|
||||
width: 32,
|
||||
child: const Icon(Icons.close_rounded, size: 18, color: Colors.white),
|
||||
child: const Icon(
|
||||
Icons.close_rounded,
|
||||
size: 18,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (replyId != null) const SizedBox(width: 8),
|
||||
@@ -118,7 +142,8 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: 52),
|
||||
child: TextFormField(
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
textCapitalization:
|
||||
TextCapitalization.sentences,
|
||||
onChanged: (_) {
|
||||
setState(() {});
|
||||
},
|
||||
@@ -126,26 +151,48 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
maxLines: 5,
|
||||
minLines: 1,
|
||||
keyboardType: TextInputType.multiline,
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, color: Colors.black, height: 1.2),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: Colors.black,
|
||||
height: 1.2,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
isDense: false,
|
||||
// убираем true, чтобы padding учитывался корректно
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 16,
|
||||
),
|
||||
// вертикальный паддинг увеличен
|
||||
hintText: S.of(context).comment_hint,
|
||||
hintStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, height: 1.2, color: Color(0xFFAEAEB2)),
|
||||
hintStyle: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.2,
|
||||
color: Color(0xFFAEAEB2),
|
||||
),
|
||||
filled: true,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
||||
borderSide: const BorderSide(
|
||||
color: Color(0xFFD1D1D6),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
||||
borderSide: const BorderSide(
|
||||
color: Color(0xFFD1D1D6),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
||||
borderSide: const BorderSide(
|
||||
color: Color(0xFFD1D1D6),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -163,7 +210,11 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
|
||||
if (isSent) context.read<FeedCubit>().incrementCommentsState(widget.id);
|
||||
if (isSent) {
|
||||
context
|
||||
.read<FeedCubit>()
|
||||
.incrementCommentsState(widget.id);
|
||||
}
|
||||
|
||||
context.read<CommentsCubit>().setReplyId(null);
|
||||
setState(() {
|
||||
@@ -174,10 +225,19 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(color: textEditingController.text.length > 2 ? appTheme.blueA400 : Colors.grey, shape: BoxShape.circle),
|
||||
decoration: BoxDecoration(
|
||||
color: textEditingController.text.length > 2
|
||||
? appTheme.blueA400
|
||||
: Colors.grey,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
height: 32,
|
||||
width: 32,
|
||||
child: const Icon(Icons.send_rounded, size: 18, color: Colors.white),
|
||||
child: const Icon(
|
||||
Icons.send_rounded,
|
||||
size: 18,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -197,23 +257,36 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
),
|
||||
actions: [
|
||||
CustomPopup(
|
||||
isAuthor: blogPostState.blogPostModel?.author.authorIsMe ?? false,
|
||||
isAuthor:
|
||||
blogPostState.blogPostModel?.author.authorIsMe ??
|
||||
false,
|
||||
isAuthorAdmin:
|
||||
blogPostState.blogPostModel?.author.isAdmin ??
|
||||
false,
|
||||
complainedContentType: ComplainedContent.posts,
|
||||
path: 'blog_post/${(widget.id)}',
|
||||
id: widget.id,
|
||||
feedBlogPostModel: blogPostState.blogPostModel,
|
||||
userSlug: blogPostState.blogPostModel?.author.authorSlug ?? '',
|
||||
userSlug:
|
||||
blogPostState.blogPostModel?.author.authorSlug ??
|
||||
'',
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
],
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: EdgeInsets.only(bottom: 160 + MediaQuery.of(context).padding.bottom), // поле комментария (52-200) + padding (12+12+56) + safe area
|
||||
padding: EdgeInsets.only(
|
||||
bottom: 160 + MediaQuery.of(context).padding.bottom,
|
||||
), // поле комментария (52-200) + padding (12+12+56) + safe area
|
||||
physics: const BouncingScrollPhysics(),
|
||||
child: Column(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => GoRouter.of(context).pushNamed('profileScreen', extra: blogPostState.blogPostModel?.author.authorSlug),
|
||||
onTap: () => GoRouter.of(context).pushNamed(
|
||||
'profileScreen',
|
||||
extra:
|
||||
blogPostState.blogPostModel?.author.authorSlug,
|
||||
),
|
||||
child: Ink(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
@@ -222,21 +295,48 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 16,
|
||||
foregroundImage: NetworkImage(sl<AppConfig>().baseUrl + (blogPostState.blogPostModel?.author.authorProfileIcon ?? '')),
|
||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
||||
foregroundImage: NetworkImage(
|
||||
joinBaseUrl(
|
||||
sl<AppConfig>().baseUrl,
|
||||
blogPostState
|
||||
.blogPostModel
|
||||
?.author
|
||||
.authorProfileIcon ??
|
||||
'',
|
||||
),
|
||||
),
|
||||
backgroundImage: const AssetImage(
|
||||
'assets/images/no_avatar.png',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
blogPostState.blogPostModel?.author.authorName ?? '',
|
||||
style: const TextStyle(color: Colors.black, fontSize: 14, fontWeight: FontWeight.w400, height: 1),
|
||||
blogPostState
|
||||
.blogPostModel
|
||||
?.author
|
||||
.authorName ??
|
||||
'',
|
||||
style: const TextStyle(
|
||||
color: Colors.black,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
(blogPostState.blogPostModel?.createdAt ?? DateTime.now()).format('dd MMMM', 'ru'),
|
||||
style: TextStyle(color: Colors.grey.shade600, fontSize: 12, fontWeight: FontWeight.w400, height: 1),
|
||||
(blogPostState.blogPostModel?.createdAt ??
|
||||
DateTime.now())
|
||||
.format('dd MMMM', 'ru'),
|
||||
style: TextStyle(
|
||||
color: Colors.grey.shade600,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -244,10 +344,20 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (blogPostState.blogPostModel?.description.isNotEmpty ?? false)
|
||||
if (blogPostState
|
||||
.blogPostModel
|
||||
?.description
|
||||
.isNotEmpty ??
|
||||
false)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: MarkdownHtmlWidget(markdownText: blogPostState.blogPostModel!.description),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
child: MarkdownHtmlWidget(
|
||||
markdownText:
|
||||
blogPostState.blogPostModel!.description,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Builder(
|
||||
@@ -256,16 +366,31 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
if (post == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final imageUrl = PostFeedMapper.resolveMediaUrl(post.image);
|
||||
final imageUrl = PostFeedMapper.resolveMediaUrl(
|
||||
post.image,
|
||||
);
|
||||
final embedUrl = post.videoEmbedUrl?.trim();
|
||||
return BlogPostMediaContent(
|
||||
galleryImages: post.images,
|
||||
imageUrl: imageUrl,
|
||||
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null,
|
||||
videoUrlThumb: post.videoUrlThumb != null ? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb) : null,
|
||||
videoEmbedUrl:
|
||||
embedUrl != null && embedUrl.isNotEmpty
|
||||
? embedUrl
|
||||
: null,
|
||||
videoUrlThumb: post.videoUrlThumb != null
|
||||
? PostFeedMapper.resolveMediaUrl(
|
||||
post.videoUrlThumb,
|
||||
)
|
||||
: null,
|
||||
currentGalleryIndex: currentGalleryIndex,
|
||||
heroTag: 'blog-post-screen-${post.id}-cover',
|
||||
onOpenGallery: (urls, index) => _openGallery(urls, index, heroTag: urls.length == 1 ? 'blog-post-screen-${post.id}-cover' : null),
|
||||
onOpenGallery: (urls, index) => _openGallery(
|
||||
urls,
|
||||
index,
|
||||
heroTag: urls.length == 1
|
||||
? 'blog-post-screen-${post.id}-cover'
|
||||
: null,
|
||||
),
|
||||
onGalleryChanged: (index) {
|
||||
setState(() {
|
||||
currentGalleryIndex = index;
|
||||
@@ -275,22 +400,43 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
},
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_iconButton(
|
||||
icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
||||
icon: isLiked
|
||||
? Icons.favorite_rounded
|
||||
: Icons.favorite_border_rounded,
|
||||
text: totalLikes.toString(),
|
||||
iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
|
||||
backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null,
|
||||
textColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
|
||||
iconColor: isLiked
|
||||
? Colors.red.withValues(alpha: 0.8)
|
||||
: null,
|
||||
backgroundColor: isLiked
|
||||
? Colors.red.withValues(alpha: 0.1)
|
||||
: null,
|
||||
textColor: isLiked
|
||||
? Colors.red.withValues(alpha: 0.8)
|
||||
: null,
|
||||
onTap: () async {
|
||||
await context.read<BlogPostCubit>().toggleLike();
|
||||
await context
|
||||
.read<BlogPostCubit>()
|
||||
.toggleLike();
|
||||
// ignore: use_build_context_synchronously
|
||||
final updated = context.read<BlogPostCubit>().state.blogPostModel;
|
||||
final updated = context
|
||||
.read<BlogPostCubit>()
|
||||
.state
|
||||
.blogPostModel;
|
||||
if (updated != null) {
|
||||
// ignore: use_build_context_synchronously
|
||||
context.read<FeedCubit>().updateFeedBlogPostList(updated, updated.wasLikedByMe);
|
||||
context
|
||||
.read<FeedCubit>()
|
||||
.updateFeedBlogPostList(
|
||||
updated,
|
||||
updated.wasLikedByMe,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
@@ -298,21 +444,36 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
_iconButton(
|
||||
icon: Icons.reply_rounded,
|
||||
onTap: () {
|
||||
final box = context.findRenderObject() as RenderBox?;
|
||||
final box =
|
||||
context.findRenderObject() as RenderBox?;
|
||||
SharePlus.instance.share(
|
||||
ShareParams(
|
||||
uri: Uri.parse('${sl<AppConfig>().baseUrl}/ru/blog_post/${widget.id}'),
|
||||
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
|
||||
uri: Uri.parse(
|
||||
'${sl<AppConfig>().baseUrl}/ru/blog_post/${widget.id}',
|
||||
),
|
||||
sharePositionOrigin:
|
||||
box!.localToGlobal(Offset.zero) &
|
||||
box.size,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const Spacer(),
|
||||
Icon(Icons.visibility_rounded, size: 16, color: appTheme.gray500),
|
||||
Icon(
|
||||
Icons.visibility_rounded,
|
||||
size: 16,
|
||||
color: appTheme.gray500,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
blogPostState.blogPostModel?.totalViews.toString() ?? '',
|
||||
style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400),
|
||||
blogPostState.blogPostModel?.totalViews
|
||||
.toString() ??
|
||||
'',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: appTheme.gray500,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -320,7 +481,10 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
Container(
|
||||
width: MediaQuery.of(context).size.width - 32,
|
||||
height: 0.4,
|
||||
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(16)),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
@@ -328,7 +492,11 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
'${blogPostState.blogPostModel?.totalComments} КОММЕНТАРИЕВ',
|
||||
style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: appTheme.gray500,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
@@ -353,13 +521,23 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
InkWell _iconButton({required IconData icon, String? text, Function()? onTap, Color? iconColor, Color? backgroundColor, Color? textColor}) {
|
||||
InkWell _iconButton({
|
||||
required IconData icon,
|
||||
String? text,
|
||||
Function()? onTap,
|
||||
Color? iconColor,
|
||||
Color? backgroundColor,
|
||||
Color? textColor,
|
||||
}) {
|
||||
return InkWell(
|
||||
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,
|
||||
@@ -369,7 +547,10 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
if (text != null)
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(color: textColor ?? appTheme.gray600, fontWeight: FontWeight.w500),
|
||||
style: TextStyle(
|
||||
color: textColor ?? appTheme.gray600,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -37,6 +37,17 @@ class BlogPostCreateCubit extends Cubit<BlogPostCreateState> {
|
||||
}
|
||||
}
|
||||
|
||||
void setImagesError(String? error) {
|
||||
if (state.imagesError == error &&
|
||||
state.formError == null &&
|
||||
state.errorMessage == null) {
|
||||
return;
|
||||
}
|
||||
emit(
|
||||
state.copyWith(imagesError: error, formError: null, errorMessage: null),
|
||||
);
|
||||
}
|
||||
|
||||
void resetFormErrors() {
|
||||
if (!state.hasValidationErrors &&
|
||||
!state.isSuccess &&
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:eatvio_app/app_keys.dart';
|
||||
import 'package:eatvio_app/core/app_router.dart';
|
||||
@@ -38,11 +39,15 @@ class BlogPostCreateScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
static const _minImageWidth = 500;
|
||||
static const _minImageHeight = 500;
|
||||
|
||||
final blogPostCubit = sl<BlogPostCreateCubit>();
|
||||
bool shrinkImage = false;
|
||||
late StreamSubscription<bool> keyboardSubscription;
|
||||
final List<FeedGalleryImage> savedImages = [];
|
||||
final List<XFile> pendingImages = [];
|
||||
final Set<String> _undersizedPendingPaths = {};
|
||||
final List<int> mediaIdsToDelete = [];
|
||||
final titleController = TextEditingController();
|
||||
final descriptionController = TextEditingController();
|
||||
@@ -531,10 +536,7 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
source: ImageSource.camera,
|
||||
);
|
||||
if (image != null && _remainingSlots > 0) {
|
||||
setState(() {
|
||||
pendingImages.add(image);
|
||||
});
|
||||
blogPostCubit.clearImagesError();
|
||||
await _addPendingImages([image]);
|
||||
}
|
||||
AppRouter.navigatorKey.currentContext?.pop();
|
||||
},
|
||||
@@ -561,10 +563,9 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
limit: _remainingSlots,
|
||||
);
|
||||
if (images.isNotEmpty) {
|
||||
setState(() {
|
||||
pendingImages.addAll(images.take(_remainingSlots));
|
||||
});
|
||||
blogPostCubit.clearImagesError();
|
||||
await _addPendingImages(
|
||||
images.take(_remainingSlots).toList(),
|
||||
);
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
AppRouter.navigatorKey.currentContext?.pop();
|
||||
@@ -603,9 +604,12 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
}
|
||||
|
||||
void _removePendingImage(int index) {
|
||||
final path = pendingImages[index].path;
|
||||
setState(() {
|
||||
pendingImages.removeAt(index);
|
||||
_undersizedPendingPaths.remove(path);
|
||||
});
|
||||
_syncImagesSizeError();
|
||||
}
|
||||
|
||||
void _onReorderGallery(int oldIndex, int newIndex) {
|
||||
@@ -670,164 +674,185 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Scaffold _buildBlogPostCreate(BuildContext context) {
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: true,
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
|
||||
floatingActionButton: enableMdPanel || isPreviewMode
|
||||
? Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (!isPreviewMode) ...[
|
||||
Container(
|
||||
width: MediaQuery.of(context).size.width - 76,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0x00000000).withValues(alpha: 0.2),
|
||||
spreadRadius: 0,
|
||||
blurRadius: 32,
|
||||
offset: const Offset(0, 0),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: 8),
|
||||
_mdTextButton('H', _applyHeading),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(Icons.format_bold, _applyBold),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(Icons.format_italic, _applyItalic),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(
|
||||
Icons.format_list_numbered,
|
||||
_applyNumberedList,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(
|
||||
Icons.format_list_bulleted,
|
||||
_applyBulletedList,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(Icons.link, _applyLink),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(Icons.horizontal_rule, _applyHorizontalRule),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0x00000000).withValues(alpha: 0.2),
|
||||
spreadRadius: 0,
|
||||
blurRadius: 32,
|
||||
offset: const Offset(0, 0),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_mdButton(
|
||||
isPreviewMode ? Icons.edit : Icons.visibility,
|
||||
_togglePreview,
|
||||
),
|
||||
],
|
||||
),
|
||||
Widget _buildEditorToolbar(BuildContext context) {
|
||||
return Row(
|
||||
key: const ValueKey('blogPostCreateEditorToolbar'),
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (!isPreviewMode) ...[
|
||||
Container(
|
||||
width: MediaQuery.of(context).size.width - 76,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0x00000000).withValues(alpha: 0.2),
|
||||
spreadRadius: 0,
|
||||
blurRadius: 32,
|
||||
offset: const Offset(0, 0),
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: 8),
|
||||
_mdTextButton('H', _applyHeading),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(Icons.format_bold, _applyBold),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(Icons.format_italic, _applyItalic),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(Icons.format_list_numbered, _applyNumberedList),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(Icons.format_list_bulleted, _applyBulletedList),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(Icons.link, _applyLink),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(Icons.horizontal_rule, _applyHorizontalRule),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0x00000000).withValues(alpha: 0.2),
|
||||
spreadRadius: 0,
|
||||
blurRadius: 32,
|
||||
offset: const Offset(0, 0),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_mdButton(
|
||||
isPreviewMode ? Icons.edit : Icons.visibility,
|
||||
_togglePreview,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Scaffold _buildBlogPostCreate(BuildContext context) {
|
||||
final editorToolbarVisible = enableMdPanel || isPreviewMode;
|
||||
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: true,
|
||||
appBar: AppBar(),
|
||||
body: BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
|
||||
bloc: blogPostCubit,
|
||||
builder: (context, state) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
const Text('Заголовок'),
|
||||
const SizedBox(height: 8),
|
||||
CustomTextFormField(
|
||||
key: AppKeys.blogPostCreateScreenTitleTextField,
|
||||
controller: titleController,
|
||||
hasError: state.titleError != null,
|
||||
onChanged: (_) => blogPostCubit.clearTitleError(),
|
||||
),
|
||||
if (state.titleError != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
ValidationErrorWidget(error: state.titleError!),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
const Text('Описание'),
|
||||
const SizedBox(height: 8),
|
||||
isPreviewMode
|
||||
? _buildMarkdownPreview()
|
||||
: CustomTextFormField(
|
||||
key: AppKeys.blogPostCreateScreenContentTextField,
|
||||
controller: descriptionController,
|
||||
focusNode: descriptionFocusNode,
|
||||
maxLines: 6,
|
||||
minLines: 4,
|
||||
hasError: state.descriptionError != null,
|
||||
onChanged: (_) => blogPostCubit.clearDescriptionError(),
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Заголовок'),
|
||||
const SizedBox(height: 8),
|
||||
CustomTextFormField(
|
||||
key: AppKeys.blogPostCreateScreenTitleTextField,
|
||||
controller: titleController,
|
||||
hasError: state.titleError != null,
|
||||
onChanged: (_) => blogPostCubit.clearTitleError(),
|
||||
),
|
||||
if (state.descriptionError != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
ValidationErrorWidget(error: state.descriptionError!),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Изображения ($_galleryCount/${BlogPostRepository.maxGalleryImages})',
|
||||
if (state.titleError != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
ValidationErrorWidget(error: state.titleError!),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
const Text('Описание'),
|
||||
const SizedBox(height: 8),
|
||||
isPreviewMode
|
||||
? _buildMarkdownPreview()
|
||||
: CustomTextFormField(
|
||||
key: AppKeys.blogPostCreateScreenContentTextField,
|
||||
controller: descriptionController,
|
||||
focusNode: descriptionFocusNode,
|
||||
maxLines: 6,
|
||||
minLines: 4,
|
||||
hasError: state.descriptionError != null,
|
||||
onChanged: (_) =>
|
||||
blogPostCubit.clearDescriptionError(),
|
||||
),
|
||||
if (state.descriptionError != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
ValidationErrorWidget(error: state.descriptionError!),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Изображения ($_galleryCount/${BlogPostRepository.maxGalleryImages})',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildGallery(),
|
||||
if (state.imagesError != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
ValidationErrorWidget(error: state.imagesError!),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
const Text('Ссылка на видео'),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Rutube, VK или OK',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: appTheme.blueGray300,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
CustomTextFormField(
|
||||
key: AppKeys.blogPostCreateScreenVideoLinkTextField,
|
||||
controller: videoUrlController,
|
||||
hasError: state.videoUrlError != null,
|
||||
keyboardType: TextInputType.url,
|
||||
onChanged: (_) => blogPostCubit.clearVideoUrlError(),
|
||||
),
|
||||
if (state.videoUrlError != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
ValidationErrorWidget(error: state.videoUrlError!),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (editorToolbarVisible) ...[
|
||||
const SizedBox(height: 8),
|
||||
_buildGallery(),
|
||||
if (state.imagesError != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
ValidationErrorWidget(error: state.imagesError!),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
const Text('Ссылка на видео'),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Rutube, VK или OK',
|
||||
style: TextStyle(fontSize: 13, color: appTheme.blueGray300),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
CustomTextFormField(
|
||||
key: AppKeys.blogPostCreateScreenVideoLinkTextField,
|
||||
controller: videoUrlController,
|
||||
hasError: state.videoUrlError != null,
|
||||
keyboardType: TextInputType.url,
|
||||
onChanged: (_) => blogPostCubit.clearVideoUrlError(),
|
||||
),
|
||||
if (state.videoUrlError != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
ValidationErrorWidget(error: state.videoUrlError!),
|
||||
],
|
||||
if (state.formError != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
ValidationErrorWidget(error: state.formError!),
|
||||
],
|
||||
const SizedBox(height: 80),
|
||||
_createBlogPostButton(isSubmitting: state.isLoading),
|
||||
const SizedBox(height: 80),
|
||||
_buildEditorToolbar(context),
|
||||
],
|
||||
),
|
||||
SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (state.formError != null) ...[
|
||||
ValidationErrorWidget(error: state.formError!),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
_createBlogPostButton(isSubmitting: state.isLoading),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -839,6 +864,11 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_undersizedPendingPaths.isNotEmpty) {
|
||||
blogPostCubit.setImagesError(S.current.blog_post_image_min_size_error);
|
||||
return;
|
||||
}
|
||||
|
||||
final title = titleController.text.trim();
|
||||
final description = descriptionController.text.trim();
|
||||
final videoUrl = videoUrlController.text.trim();
|
||||
@@ -889,9 +919,8 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
onTap: isSubmitting ? null : _submitBlogPost,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: isSubmitting
|
||||
? const Center(child: Loader())
|
||||
? const SizedBox(height: 52, child: Center(child: Loader()))
|
||||
: Ink(
|
||||
width: MediaQuery.of(context).size.width - 32,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
@@ -907,6 +936,50 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _addPendingImages(List<XFile> images) async {
|
||||
if (images.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final undersizedPaths = <String>{};
|
||||
for (final image in images) {
|
||||
if (!await _isImageLargeEnough(image)) {
|
||||
undersizedPaths.add(image.path);
|
||||
}
|
||||
}
|
||||
|
||||
setState(() {
|
||||
pendingImages.addAll(images);
|
||||
_undersizedPendingPaths.addAll(undersizedPaths);
|
||||
});
|
||||
_syncImagesSizeError();
|
||||
}
|
||||
|
||||
void _syncImagesSizeError() {
|
||||
final minSizeError = S.current.blog_post_image_min_size_error;
|
||||
if (_undersizedPendingPaths.isNotEmpty) {
|
||||
blogPostCubit.setImagesError(minSizeError);
|
||||
return;
|
||||
}
|
||||
if (blogPostCubit.state.imagesError == minSizeError) {
|
||||
blogPostCubit.clearImagesError();
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _isImageLargeEnough(XFile image) async {
|
||||
try {
|
||||
final bytes = await File(image.path).readAsBytes();
|
||||
final descriptor = await ui.ImmutableBuffer.fromUint8List(bytes);
|
||||
final codec = await ui.instantiateImageCodecFromBuffer(descriptor);
|
||||
final frame = await codec.getNextFrame();
|
||||
final width = frame.image.width;
|
||||
final height = frame.image.height;
|
||||
return width >= _minImageWidth && height >= _minImageHeight;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildGallery() {
|
||||
final itemHeight = shrinkImage ? 72.0 : 112.0;
|
||||
|
||||
@@ -946,6 +1019,7 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
child: _buildGalleryTile(
|
||||
index: index,
|
||||
height: itemHeight,
|
||||
hasError: _undersizedPendingPaths.contains(pending.path),
|
||||
imageProvider: FileImage(File(pending.path)),
|
||||
onRemove: () => _removePendingImage(pendingIndex),
|
||||
),
|
||||
@@ -985,6 +1059,7 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
required double height,
|
||||
required ImageProvider imageProvider,
|
||||
required VoidCallback onRemove,
|
||||
bool hasError = false,
|
||||
}) {
|
||||
return ReorderableDelayedDragStartListener(
|
||||
index: index,
|
||||
@@ -992,12 +1067,16 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
width: height,
|
||||
height: height,
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: hasError ? Border.all(color: appTheme.red, width: 2) : null,
|
||||
),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Image(image: imageProvider, fit: BoxFit.cover),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/env_config.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/utils/image_constant.dart';
|
||||
import 'package:eatvio_app/core/utils/url_utils.dart';
|
||||
import 'package:eatvio_app/features/dev_tools/presentation/dev_tools_screen.dart';
|
||||
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
|
||||
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_state.dart';
|
||||
@@ -26,11 +27,13 @@ class BottomNavigationBarWidget extends StatefulWidget {
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
|
||||
/// Текущий путь из GoRouter (matchedLocation), чтобы подсветка таба совпадала с экраном при навигации через go() (диплинки, пуши).
|
||||
final String currentLocation;
|
||||
|
||||
@override
|
||||
State<BottomNavigationBarWidget> createState() => _BottomNavigationBarWidgetState();
|
||||
State<BottomNavigationBarWidget> createState() =>
|
||||
_BottomNavigationBarWidgetState();
|
||||
}
|
||||
|
||||
class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
@@ -40,9 +43,24 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
|
||||
List<String> _getPages(bool isConsultant) {
|
||||
if (isConsultant) {
|
||||
return ['/my-profile-screen', '/feed-screen', '/diary-screen', '/consultations-screen', '/chat-select-screen', '/notifications-screen', '/menu-screen'];
|
||||
return [
|
||||
'/my-profile-screen',
|
||||
'/feed-screen',
|
||||
'/diary-screen',
|
||||
'/consultations-screen',
|
||||
'/chat-select-screen',
|
||||
'/notifications-screen',
|
||||
'/menu-screen',
|
||||
];
|
||||
} else {
|
||||
return ['/my-profile-screen', '/feed-screen', '/diary-screen', '/chat-select-screen', '/notifications-screen', '/menu-screen'];
|
||||
return [
|
||||
'/my-profile-screen',
|
||||
'/feed-screen',
|
||||
'/diary-screen',
|
||||
'/chat-select-screen',
|
||||
'/notifications-screen',
|
||||
'/menu-screen',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +116,9 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
final logo = SvgPicture.asset(
|
||||
ImageConstant.imgLogo,
|
||||
height: 32,
|
||||
colorFilter: isProduction ? null : const ColorFilter.mode(_devAccentColor, BlendMode.srcIn),
|
||||
colorFilter: isProduction
|
||||
? null
|
||||
: const ColorFilter.mode(_devAccentColor, BlendMode.srcIn),
|
||||
);
|
||||
if (isProduction) return logo;
|
||||
return Stack(
|
||||
@@ -109,7 +129,10 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: _devAccentColor.withValues(alpha: 0.6), width: 1.5),
|
||||
border: Border.all(
|
||||
color: _devAccentColor.withValues(alpha: 0.6),
|
||||
width: 1.5,
|
||||
),
|
||||
color: _devAccentColor.withValues(alpha: 0.08),
|
||||
),
|
||||
child: logo,
|
||||
@@ -122,11 +145,23 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
decoration: BoxDecoration(
|
||||
color: _devAccentColor,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
boxShadow: [BoxShadow(color: _devAccentColor.withValues(alpha: 0.4), blurRadius: 4, offset: const Offset(0, 1))],
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: _devAccentColor.withValues(alpha: 0.4),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Text(
|
||||
'DEV',
|
||||
style: TextStyle(fontSize: 8, fontWeight: FontWeight.w700, color: Colors.white, letterSpacing: 0.3, height: 1.0),
|
||||
style: TextStyle(
|
||||
fontSize: 8,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
letterSpacing: 0.3,
|
||||
height: 1.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -155,10 +190,15 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
// Подсветка таба по текущему маршруту (совпадает при навигации через go(), в т.ч. диплинки/пуши).
|
||||
final pathWithoutQuery = widget.currentLocation.split('?').first;
|
||||
final indexFromRoute = pages!.indexOf(pathWithoutQuery);
|
||||
final effectiveIndex = indexFromRoute >= 0 ? indexFromRoute : currentBottomNavBarIndex;
|
||||
if (indexFromRoute >= 0 && indexFromRoute != currentBottomNavBarIndex) {
|
||||
final effectiveIndex = indexFromRoute >= 0
|
||||
? indexFromRoute
|
||||
: currentBottomNavBarIndex;
|
||||
if (indexFromRoute >= 0 &&
|
||||
indexFromRoute != currentBottomNavBarIndex) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) setState(() => currentBottomNavBarIndex = indexFromRoute);
|
||||
if (mounted) {
|
||||
setState(() => currentBottomNavBarIndex = indexFromRoute);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -176,9 +216,14 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
label: '',
|
||||
icon: CircleAvatar(
|
||||
foregroundImage: NetworkImage(
|
||||
profileModel.profilePhotoUrl.contains('https') ? profileModel.profilePhotoUrl : sl<AppConfig>().baseUrl + profileModel.profilePhotoUrl,
|
||||
joinBaseUrl(
|
||||
sl<AppConfig>().baseUrl,
|
||||
profileModel.profilePhotoUrl,
|
||||
),
|
||||
),
|
||||
backgroundImage: const AssetImage(
|
||||
'assets/images/no_avatar.png',
|
||||
),
|
||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
||||
radius: 16,
|
||||
),
|
||||
),
|
||||
@@ -186,22 +231,54 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
label: 'Лента',
|
||||
icon: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn), width: 20, height: 20, ImageConstant.icnFeed),
|
||||
child: SvgPicture.asset(
|
||||
colorFilter: const ColorFilter.mode(
|
||||
Colors.grey,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
width: 20,
|
||||
height: 20,
|
||||
ImageConstant.icnFeed,
|
||||
),
|
||||
),
|
||||
activeIcon: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(ImageConstant.icnFeed, width: 20, height: 20, colorFilter: ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn)),
|
||||
child: SvgPicture.asset(
|
||||
ImageConstant.icnFeed,
|
||||
width: 20,
|
||||
height: 20,
|
||||
colorFilter: ColorFilter.mode(
|
||||
appTheme.blueA400,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
label: s.diary,
|
||||
icon: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn), ImageConstant.icnDiary, width: 20, height: 20),
|
||||
child: SvgPicture.asset(
|
||||
colorFilter: const ColorFilter.mode(
|
||||
Colors.grey,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
ImageConstant.icnDiary,
|
||||
width: 20,
|
||||
height: 20,
|
||||
),
|
||||
),
|
||||
activeIcon: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(ImageConstant.icnDiary, width: 20, height: 20, colorFilter: ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn)),
|
||||
child: SvgPicture.asset(
|
||||
ImageConstant.icnDiary,
|
||||
width: 20,
|
||||
height: 20,
|
||||
colorFilter: ColorFilter.mode(
|
||||
appTheme.blueA400,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (profileModel.roles.contains('consultant'))
|
||||
@@ -210,7 +287,10 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
icon: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(
|
||||
colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn),
|
||||
colorFilter: const ColorFilter.mode(
|
||||
Colors.grey,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
'assets/icons/consultations.svg',
|
||||
width: 20,
|
||||
height: 20,
|
||||
@@ -222,7 +302,10 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
'assets/icons/consultations.svg',
|
||||
width: 20,
|
||||
height: 20,
|
||||
colorFilter: ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn),
|
||||
colorFilter: ColorFilter.mode(
|
||||
appTheme.blueA400,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -230,14 +313,26 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
label: s.chat,
|
||||
icon: BlocBuilder<ChatsCubit, ChatsState>(
|
||||
builder: (BuildContext context, ChatsState state) {
|
||||
final hasUnreadMessages = state.chatsWithUnreadMessagesCount > 0;
|
||||
final hasUnreadMessages =
|
||||
state.chatsWithUnreadMessagesCount > 0;
|
||||
return badges.Badge(
|
||||
position: badges.BadgePosition.topEnd(),
|
||||
badgeAnimation: const badges.BadgeAnimation.fade(),
|
||||
badgeStyle: badges.BadgeStyle(badgeColor: hasUnreadMessages ? Colors.red : Colors.transparent),
|
||||
badgeStyle: badges.BadgeStyle(
|
||||
badgeColor: hasUnreadMessages
|
||||
? Colors.red
|
||||
: Colors.transparent,
|
||||
),
|
||||
badgeContent: Text(
|
||||
hasUnreadMessages ? state.chatsWithUnreadMessagesCount.toString() : '',
|
||||
style: TextStyle(color: hasUnreadMessages ? Colors.white : Colors.transparent, fontSize: 8),
|
||||
hasUnreadMessages
|
||||
? state.chatsWithUnreadMessagesCount.toString()
|
||||
: '',
|
||||
style: TextStyle(
|
||||
color: hasUnreadMessages
|
||||
? Colors.white
|
||||
: Colors.transparent,
|
||||
fontSize: 8,
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
@@ -245,9 +340,16 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
ImageConstant.icnChat,
|
||||
width: 20,
|
||||
height: 20,
|
||||
colorFilter: pages![effectiveIndex] == '/chat-select-screen'
|
||||
? ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn)
|
||||
: const ColorFilter.mode(Colors.grey, BlendMode.srcIn),
|
||||
colorFilter:
|
||||
pages![effectiveIndex] == '/chat-select-screen'
|
||||
? ColorFilter.mode(
|
||||
appTheme.blueA400,
|
||||
BlendMode.srcIn,
|
||||
)
|
||||
: const ColorFilter.mode(
|
||||
Colors.grey,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -261,15 +363,28 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
return badges.Badge(
|
||||
position: badges.BadgePosition.topEnd(),
|
||||
badgeAnimation: const badges.BadgeAnimation.fade(),
|
||||
showBadge: state is NotificationsStateLoaded && state.hasUnreadNotifications,
|
||||
badgeContent: const Text('', style: TextStyle(color: Colors.white, fontSize: 8)),
|
||||
showBadge:
|
||||
state is NotificationsStateLoaded &&
|
||||
state.hasUnreadNotifications,
|
||||
badgeContent: const Text(
|
||||
'',
|
||||
style: TextStyle(color: Colors.white, fontSize: 8),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(
|
||||
ImageConstant.icnBell,
|
||||
colorFilter: pages![effectiveIndex] == '/notifications-screen'
|
||||
? ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn)
|
||||
: const ColorFilter.mode(Colors.grey, BlendMode.srcIn),
|
||||
colorFilter:
|
||||
pages![effectiveIndex] ==
|
||||
'/notifications-screen'
|
||||
? ColorFilter.mode(
|
||||
appTheme.blueA400,
|
||||
BlendMode.srcIn,
|
||||
)
|
||||
: const ColorFilter.mode(
|
||||
Colors.grey,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
width: 20,
|
||||
height: 20,
|
||||
),
|
||||
|
||||
@@ -31,6 +31,11 @@ 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,
|
||||
|
||||
@@ -31,6 +31,7 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
|
||||
StreamSubscription<ChannelReadEvent>? typingSubscription;
|
||||
StreamSubscription<ChannelReadEvent>? messagesSubscription;
|
||||
StreamSubscription<void>? _reconnectSubscription;
|
||||
int currentPage = 1;
|
||||
late int lastPage;
|
||||
bool isLoading = false;
|
||||
@@ -39,11 +40,18 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
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() {
|
||||
@@ -54,16 +62,22 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
if (currentPage < lastPage && !isLoading) {
|
||||
isLoading = true;
|
||||
|
||||
emit((state as ChatRoomStateLoaded).copyWith(isLoading: isLoading));
|
||||
_emitLoaded((current) => current.copyWith(isLoading: isLoading));
|
||||
|
||||
try {
|
||||
final chatMessages = await chatRepository.getChatMessages(uuid, currentPage + 1);
|
||||
final chatMessages = await chatRepository.getChatMessages(
|
||||
uuid,
|
||||
currentPage + 1,
|
||||
);
|
||||
|
||||
isLoading = false;
|
||||
emit(
|
||||
(state as ChatRoomStateLoaded).copyWith(
|
||||
_emitLoaded(
|
||||
(current) => current.copyWith(
|
||||
isLoading: isLoading,
|
||||
chatMessages: [...(state as ChatRoomStateLoaded).chatMessages, ...chatMessages.messagesList],
|
||||
chatMessages: [
|
||||
...current.chatMessages,
|
||||
...chatMessages.messagesList,
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -86,11 +100,8 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
}
|
||||
|
||||
void _setCard({required List<CardModel> cards}) {
|
||||
final currentState = state;
|
||||
if (currentState is ChatRoomStateLoaded) {
|
||||
if (cards.where((card) => card.isDefault).isNotEmpty) {
|
||||
emit(currentState.copyWith(hasCard: true));
|
||||
}
|
||||
if (cards.where((card) => card.isDefault).isNotEmpty) {
|
||||
_emitLoaded((current) => current.copyWith(hasCard: true));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +111,11 @@ 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());
|
||||
|
||||
@@ -108,7 +123,9 @@ 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;
|
||||
}
|
||||
@@ -117,15 +134,23 @@ 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),
|
||||
@@ -135,6 +160,7 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
hasCard: false,
|
||||
),
|
||||
);
|
||||
await _subscribeToPresenceChannel(chatId: chatRoom.id);
|
||||
|
||||
if (chosenPlan?.consultantId == chatRoom.users.sender.id) {
|
||||
await checkCards();
|
||||
@@ -145,22 +171,106 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
log(e.toString());
|
||||
typingSubscription?.cancel();
|
||||
messagesSubscription?.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 {
|
||||
try {
|
||||
final currentState = state;
|
||||
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));
|
||||
}
|
||||
if (currentState is! ChatRoomStateLoaded) {
|
||||
return;
|
||||
}
|
||||
final chosenPlanId = currentState.chatRoom.chosenPlanId;
|
||||
if (chosenPlanId == null) {
|
||||
return;
|
||||
}
|
||||
final chosenPlan = await chatRepository.getChosenPlan(id: chosenPlanId);
|
||||
_emitLoaded((latest) => latest.copyWith.chatRoom(chosenPlan: chosenPlan));
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
}
|
||||
@@ -172,9 +282,16 @@ 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);
|
||||
@@ -183,7 +300,10 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cancelChosenPlan({required String chatId, required int planId}) async {
|
||||
Future<void> cancelChosenPlan({
|
||||
required String chatId,
|
||||
required int planId,
|
||||
}) async {
|
||||
try {
|
||||
await chatRepository.cancelChosenPlan(id: planId);
|
||||
emit((state as ChatRoomStateLoaded).copyWith.chatRoom(chosenPlan: null));
|
||||
@@ -193,38 +313,129 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _subscribeToChatChannel({required String uuid, required String chatId}) async {
|
||||
bool _emitLoaded(
|
||||
ChatRoomStateLoaded Function(ChatRoomStateLoaded current) update,
|
||||
) {
|
||||
if (isClosed) {
|
||||
return false;
|
||||
}
|
||||
final currentState = state;
|
||||
if (currentState is! ChatRoomStateLoaded) {
|
||||
return false;
|
||||
}
|
||||
emit(update(currentState));
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _subscribeToChatChannel({
|
||||
required String uuid,
|
||||
required String chatId,
|
||||
}) async {
|
||||
// Активный чат запоминаем до проверки клиента: он нужен и для догрузки
|
||||
// пропущенного после реконнекта (S5), даже если сокет появится позже.
|
||||
_activeChatUuid = uuid;
|
||||
final client = wsManager.client;
|
||||
if (client == null) return;
|
||||
_activeChatUuid = uuid;
|
||||
await messagesSubscription?.cancel();
|
||||
await typingSubscription?.cancel();
|
||||
messagesSubscription = null;
|
||||
typingSubscription = null;
|
||||
await wsManager.initPrivateDirectChatChannel(chatId);
|
||||
wsManager.subscribeDirectChatChannel();
|
||||
await _subscribeToPresenceChannel(chatId: chatId);
|
||||
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());
|
||||
});
|
||||
messagesSubscription = wsManager.directChatStream?.listen(
|
||||
_onDirectChatEvent,
|
||||
onError: (Object e, StackTrace st) {
|
||||
unawaited(reportFrom(e, st, this));
|
||||
},
|
||||
);
|
||||
|
||||
typingSubscription = wsManager.clientTypingStream?.listen((event) {
|
||||
emit((state as ChatRoomStateLoaded).copyWith(isTyping: true, isLoading: isLoading));
|
||||
_emitLoaded(
|
||||
(current) => current.copyWith(isTyping: true, isLoading: isLoading),
|
||||
);
|
||||
EasyDebounce.debounce(
|
||||
'client-typing',
|
||||
const Duration(seconds: 3),
|
||||
() => emit((state as ChatRoomStateLoaded).copyWith(isTyping: false, isLoading: isLoading)),
|
||||
() => _emitLoaded(
|
||||
(current) => current.copyWith(isTyping: false, isLoading: isLoading),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _onDirectChatEvent(ChannelReadEvent event) {
|
||||
try {
|
||||
if (addIncomingMessage(
|
||||
ChatMessageModel.fromJson(
|
||||
jsonDecode(event.data) as Map<String, dynamic>,
|
||||
),
|
||||
)) {
|
||||
unawaited(_playIncomingSound());
|
||||
}
|
||||
} catch (e, st) {
|
||||
unawaited(reportFrom(e, st, this));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _playIncomingSound() async {
|
||||
try {
|
||||
await AudioPlayer().play(AssetSource('audio/recive_message.wav'));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// Добавляет входящее сообщение в текущий список, не затирая более новый state.
|
||||
///
|
||||
/// Собственное сообщение, пришедшее по сокету, — это эхо с другого устройства
|
||||
/// автора (`author_id == wsManager.userId`). Такое сообщение тоже добавляется
|
||||
/// в открытый чат, но не считается входящим: звук не играет, а список чатов
|
||||
/// обновляет S3 через `new-chat-messages.{senderId}`.
|
||||
///
|
||||
/// Возвращает `true`, только если нужно проиграть звук входящего (чужое
|
||||
/// сообщение, реально добавленное).
|
||||
bool addIncomingMessage(ChatMessageModel message) {
|
||||
final currentState = state;
|
||||
if (currentState is! ChatRoomStateLoaded) {
|
||||
return false;
|
||||
}
|
||||
final currentUserId = wsManager.userId;
|
||||
final isOwn = currentUserId != null && message.authorId == currentUserId;
|
||||
|
||||
// Дедуп: по серверному id и по uuid (если broadcast вернул наш uuid, т.е.
|
||||
// это эхо уже добавленного оптимистичного сообщения).
|
||||
final alreadyExists = currentState.chatMessages.any(
|
||||
(existing) =>
|
||||
(message.id != 0 && existing.id == message.id) ||
|
||||
(message.uuid != null && existing.uuid == message.uuid),
|
||||
);
|
||||
if (alreadyExists) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final added = _emitLoaded((current) {
|
||||
if (current.chatMessages.any(
|
||||
(existing) =>
|
||||
(message.id != 0 && existing.id == message.id) ||
|
||||
(message.uuid != null && existing.uuid == message.uuid),
|
||||
)) {
|
||||
return current;
|
||||
}
|
||||
return current.copyWith(
|
||||
chatMessages: [message, ...current.chatMessages],
|
||||
isLoading: isLoading,
|
||||
);
|
||||
});
|
||||
if (!added) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Список чатов за собственные сообщения обновляет S3 (new-chat-messages.{senderId}).
|
||||
if (!isOwn) {
|
||||
chatRepository.emitNewMessageToChats(message);
|
||||
}
|
||||
|
||||
return !isOwn;
|
||||
}
|
||||
|
||||
Future<void> _subscribeToPresenceChannel({required String chatId}) async {
|
||||
final currentState = state;
|
||||
if (currentState is! ChatRoomStateLoaded) return;
|
||||
@@ -236,12 +447,15 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
|
||||
await wsManager.initPresenceDirectChatChannel(chatId);
|
||||
_presenceOnlineListener = (isOnline) {
|
||||
unawaited(_handleCounterpartOnlineChanged(
|
||||
counterpartField: counterpart.id == currentState.chatRoom.users.receiver.id
|
||||
? _ChatCounterpartField.receiver
|
||||
: _ChatCounterpartField.sender,
|
||||
isOnline: isOnline,
|
||||
));
|
||||
unawaited(
|
||||
_handleCounterpartOnlineChanged(
|
||||
counterpartField:
|
||||
counterpart.id == currentState.chatRoom.users.receiver.id
|
||||
? _ChatCounterpartField.receiver
|
||||
: _ChatCounterpartField.sender,
|
||||
isOnline: isOnline,
|
||||
),
|
||||
);
|
||||
};
|
||||
wsManager.addPresenceDirectChatListener(
|
||||
counterpartUserId: counterpart.id,
|
||||
@@ -260,7 +474,8 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
? currentState.chatRoom.users.receiver
|
||||
: currentState.chatRoom.users.sender;
|
||||
|
||||
if (counterpart.isOnline == isOnline && (isOnline || counterpart.lastSeenText.isNotEmpty)) {
|
||||
if (counterpart.isOnline == isOnline &&
|
||||
(isOnline || counterpart.lastSeenText.isNotEmpty)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -337,63 +552,55 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> sendMessage({String? data, List<File>? files, required String userSlug}) async {
|
||||
Future<bool> sendMessage({
|
||||
String? data,
|
||||
List<File>? files,
|
||||
required String userSlug,
|
||||
}) async {
|
||||
try {
|
||||
final messageUuid = uuid.v4();
|
||||
final compressedFiles = files != null ? await ImageCompressUtility.compressFilesList(List<File>.from(files)) : null;
|
||||
AudioPlayer().play((AssetSource('audio/send_message.wav')));
|
||||
final compressedFiles = files != null
|
||||
? await ImageCompressUtility.compressFilesList(List<File>.from(files))
|
||||
: null;
|
||||
try {
|
||||
unawaited(AudioPlayer().play(AssetSource('audio/send_message.wav')));
|
||||
} catch (_) {}
|
||||
|
||||
// Создаем временное сообщение с состоянием loading
|
||||
emit(
|
||||
(state as ChatRoomStateLoaded).copyWith(
|
||||
_emitLoaded(
|
||||
(current) => current.copyWith(
|
||||
chatMessages: [
|
||||
ChatMessageModel(
|
||||
id: 1,
|
||||
id: -messageUuid.hashCode.abs(),
|
||||
data: data ?? '',
|
||||
mediaData: compressedFiles,
|
||||
authorId: 1,
|
||||
authorId: wsManager.userId ?? 0,
|
||||
updatedAt: DateTime.now(),
|
||||
isRead: true,
|
||||
isCurrentUserAuthor: true,
|
||||
roomId: '',
|
||||
roomId: current.chatRoom.id,
|
||||
messageState: MessageState.loading,
|
||||
uuid: messageUuid,
|
||||
),
|
||||
...(state as ChatRoomStateLoaded).chatMessages,
|
||||
...current.chatMessages,
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
// Отправляем сообщение и получаем ответ от сервера с правильными данными
|
||||
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(),
|
||||
),
|
||||
final serverMessage = await chatRepository.sendMessage(
|
||||
data: data,
|
||||
files: compressedFiles,
|
||||
slug: userSlug,
|
||||
);
|
||||
_replaceOptimisticMessage(
|
||||
messageUuid: messageUuid,
|
||||
serverMessage: serverMessage,
|
||||
);
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
emit(
|
||||
(state as ChatRoomStateLoaded).copyWith(
|
||||
chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) {
|
||||
_emitLoaded(
|
||||
(current) => current.copyWith(
|
||||
chatMessages: current.chatMessages.map((elem) {
|
||||
if (elem.uuid == messageUuid) {
|
||||
return elem.copyWith(messageState: MessageState.error);
|
||||
}
|
||||
@@ -411,21 +618,25 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> markMessagesAsRead(String chatId, List<({int id, int index})> messagesIds) async {
|
||||
Future<bool> markMessagesAsRead(String chatId, List<int> messageIds) async {
|
||||
try {
|
||||
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;
|
||||
if (state is! ChatRoomStateLoaded) {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
await chatRepository.markMessagesAsRead(chatId, messageIds);
|
||||
final idSet = messageIds.toSet();
|
||||
_emitLoaded(
|
||||
(current) => current.copyWith(
|
||||
chatMessages: current.chatMessages
|
||||
.map(
|
||||
(message) => idSet.contains(message.id)
|
||||
? message.copyWith(isRead: true)
|
||||
: message,
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
return true;
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString(), stackTrace: st);
|
||||
@@ -433,11 +644,52 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
}
|
||||
}
|
||||
|
||||
void _replaceOptimisticMessage({
|
||||
required String messageUuid,
|
||||
required ChatMessageModel serverMessage,
|
||||
}) {
|
||||
_emitLoaded((current) {
|
||||
final sentMessage = serverMessage.copyWith(
|
||||
uuid: messageUuid,
|
||||
messageState: MessageState.sent,
|
||||
isCurrentUserAuthor: true,
|
||||
);
|
||||
final withoutDuplicate = current.chatMessages.where(
|
||||
(message) =>
|
||||
message.id != serverMessage.id || message.uuid == messageUuid,
|
||||
);
|
||||
final hasOptimistic = withoutDuplicate.any(
|
||||
(message) => message.uuid == messageUuid,
|
||||
);
|
||||
if (!hasOptimistic) {
|
||||
return current.copyWith(
|
||||
chatMessages: [sentMessage, ...withoutDuplicate],
|
||||
);
|
||||
}
|
||||
return current.copyWith(
|
||||
chatMessages: withoutDuplicate.map((elem) {
|
||||
if (elem.uuid == messageUuid) {
|
||||
return sentMessage.copyWith(
|
||||
isRead: elem.isRead,
|
||||
roomId: elem.roomId?.isNotEmpty == true
|
||||
? elem.roomId
|
||||
: serverMessage.roomId,
|
||||
);
|
||||
}
|
||||
return elem;
|
||||
}).toList(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<String?> getPaymentLink() async {
|
||||
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);
|
||||
@@ -477,91 +729,93 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
EasyDebounce.cancel('client-typing');
|
||||
EasyDebounce.cancel('searchChatRoom');
|
||||
if (_presenceOnlineListener != null) {
|
||||
wsManager.removePresenceDirectChatListener(_presenceOnlineListener!);
|
||||
_presenceOnlineListener = null;
|
||||
}
|
||||
wsManager.unsubscribeDirectChatChannel();
|
||||
_timer?.cancel();
|
||||
messagesSubscription?.cancel();
|
||||
typingSubscription?.cancel();
|
||||
await messagesSubscription?.cancel();
|
||||
await typingSubscription?.cancel();
|
||||
await _reconnectSubscription?.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 {
|
||||
AudioPlayer().play((AssetSource('audio/send_message.wav')));
|
||||
try {
|
||||
unawaited(
|
||||
AudioPlayer().play(AssetSource('audio/send_message.wav')),
|
||||
);
|
||||
} catch (_) {}
|
||||
|
||||
// Определяем, есть ли файлы для повторной отправки
|
||||
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>();
|
||||
}
|
||||
}
|
||||
|
||||
emit(
|
||||
(state as ChatRoomStateLoaded).copyWith(
|
||||
_emitLoaded(
|
||||
(current) => current.copyWith(
|
||||
chatMessages: [
|
||||
ChatMessageModel(
|
||||
id: 1,
|
||||
id: -uuid.hashCode.abs(),
|
||||
data: message.data,
|
||||
mediaData: message.mediaData,
|
||||
authorId: 1,
|
||||
authorId: wsManager.userId ?? 0,
|
||||
updatedAt: DateTime.now(),
|
||||
isRead: true,
|
||||
isCurrentUserAuthor: true,
|
||||
roomId: '',
|
||||
roomId: current.chatRoom.id,
|
||||
messageState: MessageState.loading,
|
||||
uuid: uuid,
|
||||
),
|
||||
...(state as ChatRoomStateLoaded).chatMessages.where((message) => message.uuid != uuid),
|
||||
...current.chatMessages.where((item) => item.uuid != uuid),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
// Отправляем сообщение и получаем ответ от сервера
|
||||
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(),
|
||||
),
|
||||
final serverMessage = await chatRepository.sendMessage(
|
||||
data: message.data,
|
||||
files: filesToResend,
|
||||
slug: userSlug,
|
||||
);
|
||||
_replaceOptimisticMessage(
|
||||
messageUuid: uuid,
|
||||
serverMessage: serverMessage,
|
||||
);
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
emit(
|
||||
(state as ChatRoomStateLoaded).copyWith(
|
||||
chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) {
|
||||
_emitLoaded(
|
||||
(current) => current.copyWith(
|
||||
chatMessages: current.chatMessages.map((elem) {
|
||||
if (elem.uuid == uuid) {
|
||||
return elem.copyWith(messageState: MessageState.error);
|
||||
}
|
||||
|
||||
@@ -5,9 +5,14 @@ import 'dart:developer';
|
||||
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_ended_push_handler.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';
|
||||
|
||||
@@ -17,13 +22,23 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
final WsManager wsManager;
|
||||
StreamSubscription? newIncomingMessagesSubscription;
|
||||
StreamSubscription? newLastMessageSubscription;
|
||||
StreamSubscription<void>? reconnectedSubscription;
|
||||
final Set<int> _presentedVideoCallMessageIds = {};
|
||||
|
||||
/// Test seam. Production uses CallKit.
|
||||
Future<void> Function(VideoCallPushPayload payload)? presentIncomingVideoCall;
|
||||
Future<void> Function(String? conversationUuid)? dismissIncomingVideoCall;
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -34,9 +49,16 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
Future<void> connectToWebSocket() async {
|
||||
try {
|
||||
await newIncomingMessagesSubscription?.cancel();
|
||||
newIncomingMessagesSubscription = wsManager.newMessagesStream?.listen((event) {
|
||||
newIncomingMessagesSubscription = wsManager.newMessagesStream?.listen((
|
||||
event,
|
||||
) {
|
||||
_handleNewMessageViaSocket(event.data);
|
||||
});
|
||||
|
||||
await reconnectedSubscription?.cancel();
|
||||
reconnectedSubscription = wsManager.reconnected.listen((_) {
|
||||
unawaited(getChatRooms());
|
||||
});
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
}
|
||||
@@ -47,7 +69,9 @@ 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(
|
||||
@@ -60,9 +84,7 @@ 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +92,9 @@ 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(
|
||||
@@ -84,9 +108,7 @@ 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +116,10 @@ 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(
|
||||
@@ -134,15 +159,20 @@ 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);
|
||||
final message = ChatMessageModel.fromJson(json['message']);
|
||||
final json = jsonDecode(data) as Map<String, dynamic>;
|
||||
final message = ChatMessageModel.fromJson(json);
|
||||
|
||||
final currentUserId = wsManager.userId;
|
||||
final isOwn = currentUserId != null && message.authorId == currentUserId;
|
||||
|
||||
final currentChatRooms = [...state.chatRooms];
|
||||
|
||||
@@ -151,10 +181,21 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
final updatedChatRooms = currentChatRooms.map((chatRoom) {
|
||||
if (chatRoom.id == message.roomId) {
|
||||
wasChanged = true;
|
||||
if (chatRoom.unreadMessagesCount == 0) {
|
||||
// Идемпотентность: повторная доставка того же message.id (реконнект,
|
||||
// доставка по нескольким каналам) не начисляет непрочитанное дважды.
|
||||
final isDuplicate =
|
||||
message.id != 0 && chatRoom.lastMessage?.id == message.id;
|
||||
// Собственное сообщение (второе устройство автора) обновляет
|
||||
// lastMessage, но не создаёт непрочитанное (S3).
|
||||
if (!isOwn && !isDuplicate && chatRoom.unreadMessagesCount == 0) {
|
||||
willIncrementUnreadChats = true;
|
||||
}
|
||||
return chatRoom.copyWith(lastMessage: message, unreadMessagesCount: chatRoom.unreadMessagesCount + 1);
|
||||
return chatRoom.copyWith(
|
||||
lastMessage: message,
|
||||
unreadMessagesCount: isOwn || isDuplicate
|
||||
? chatRoom.unreadMessagesCount
|
||||
: chatRoom.unreadMessagesCount + 1,
|
||||
);
|
||||
}
|
||||
return chatRoom;
|
||||
}).toList();
|
||||
@@ -165,15 +206,92 @@ 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,
|
||||
);
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
}
|
||||
}
|
||||
|
||||
void _presentVideoCallFromMessage(
|
||||
ChatMessageModel message, {
|
||||
required bool isOwn,
|
||||
required List<ChatRoom> chatRooms,
|
||||
}) {
|
||||
final kind = VideoCallChatSignal.kindOf(message);
|
||||
if (kind == VideoCallChatSignalKind.ended) {
|
||||
unawaited(_dismissIncomingVideoCallUi(message.roomId));
|
||||
unawaited(
|
||||
VideoCallEndedPushHandler.endActiveCallIfMatches(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,
|
||||
);
|
||||
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> _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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -188,6 +306,7 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
@override
|
||||
Future<void> close() {
|
||||
newIncomingMessagesSubscription?.cancel();
|
||||
reconnectedSubscription?.cancel();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,12 @@ class ChatScreen extends StatefulWidget {
|
||||
final String? orderId;
|
||||
final bool? sendInitialMessage;
|
||||
|
||||
const ChatScreen({this.uuid, this.orderId, super.key, required this.sendInitialMessage});
|
||||
const ChatScreen({
|
||||
this.uuid,
|
||||
this.orderId,
|
||||
super.key,
|
||||
required this.sendInitialMessage,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ChatScreen> createState() => _ChatScreenState();
|
||||
@@ -56,9 +61,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
final scrollController = ScrollController();
|
||||
final TextEditingController textEditingController = TextEditingController();
|
||||
final ChatRoomCubit chatRoomCubit = sl<ChatRoomCubit>();
|
||||
final List<({int id, int index})> unreadMessagesIds = [];
|
||||
final List<({int id, int index})> unreadMessagesIdsCopy = [];
|
||||
final showableChatBadgeStates = ChosenPlanStateEnum.getShowableChatBadgeStates();
|
||||
final List<int> unreadMessageIds = [];
|
||||
final showableChatBadgeStates =
|
||||
ChosenPlanStateEnum.getShowableChatBadgeStates();
|
||||
late AppLifecycleListener _listener;
|
||||
List<File> pickedFiles = [];
|
||||
|
||||
@@ -109,13 +114,15 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
void _onScroll() {
|
||||
final uuid = conversationUuid;
|
||||
if (uuid == null) return;
|
||||
if (scrollController.offset >= scrollController.position.maxScrollExtent && !scrollController.position.outOfRange) {
|
||||
if (scrollController.offset >= scrollController.position.maxScrollExtent &&
|
||||
!scrollController.position.outOfRange) {
|
||||
chatRoomCubit.loadNextPage(uuid);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
EasyDebounce.cancel('mark_as_read');
|
||||
scrollController.dispose();
|
||||
_listener.dispose();
|
||||
chatRoomCubit.close();
|
||||
@@ -168,9 +175,13 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
}
|
||||
|
||||
Scaffold _buildChat(BuildContext context, ChatRoomStateLoaded state) {
|
||||
final chatCounterpart = resolveChatCounterpart(users: state.chatRoom.users, currentUserId: sl<WsManager>().userId);
|
||||
final chatCounterpart = resolveChatCounterpart(
|
||||
users: state.chatRoom.users,
|
||||
currentUserId: sl<WsManager>().userId,
|
||||
);
|
||||
final chosenPlan = state.chatRoom.chosenPlan;
|
||||
final amIConsultant = chosenPlan?.consultantId == state.chatRoom.users.sender.id;
|
||||
final amIConsultant =
|
||||
chosenPlan?.consultantId == state.chatRoom.users.sender.id;
|
||||
final showUserDetailsButton = [
|
||||
ChosenPlanStateEnum.stateStartDateHasBeenSet,
|
||||
ChosenPlanStateEnum.statePendingPayment,
|
||||
@@ -186,22 +197,37 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
}
|
||||
|
||||
final showAboutOrderButton =
|
||||
state.chatRoom.chosenPlan != null && state.chatRoom.chosenPlan!.chosenPlanState != ChosenPlanStateEnum.stateCanceledByTheInitiator;
|
||||
state.chatRoom.chosenPlan != null &&
|
||||
state.chatRoom.chosenPlan!.chosenPlanState !=
|
||||
ChosenPlanStateEnum.stateCanceledByTheInitiator;
|
||||
BannerType? bannerType;
|
||||
if (chosenPlan == null || chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateCanceledByTheInitiator) {
|
||||
if (chosenPlan == null ||
|
||||
chosenPlan.chosenPlanState ==
|
||||
ChosenPlanStateEnum.stateCanceledByTheInitiator) {
|
||||
bannerType = null;
|
||||
} else if (amIConsultant && !state.hasCard && chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateInCoordination) {
|
||||
} else if (amIConsultant &&
|
||||
!state.hasCard &&
|
||||
chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateInCoordination) {
|
||||
bannerType = BannerType.card;
|
||||
} else if (amIConsultant && chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateInCoordination) {
|
||||
} else if (amIConsultant &&
|
||||
chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateInCoordination) {
|
||||
bannerType = BannerType.date;
|
||||
} else if (!amIConsultant &&
|
||||
(chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateStartDateHasBeenSet || chosenPlan.chosenPlanState == ChosenPlanStateEnum.statePendingPayment)) {
|
||||
(chosenPlan.chosenPlanState ==
|
||||
ChosenPlanStateEnum.stateStartDateHasBeenSet ||
|
||||
chosenPlan.chosenPlanState ==
|
||||
ChosenPlanStateEnum.statePendingPayment)) {
|
||||
bannerType = BannerType.payment;
|
||||
} else if (chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateDone && amIConsultant) {
|
||||
} else if (chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateDone &&
|
||||
amIConsultant) {
|
||||
bannerType = BannerType.collectCharge;
|
||||
} else if (chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateLastDayFinished && !amIConsultant) {
|
||||
} else if (chosenPlan.chosenPlanState ==
|
||||
ChosenPlanStateEnum.stateLastDayFinished &&
|
||||
!amIConsultant) {
|
||||
bannerType = BannerType.finishPlan;
|
||||
} else if (chosenOption?.hasPersonalDiet == true && amIConsultant && chosenPlan.chosenPlanState != ChosenPlanStateEnum.stateDone) {
|
||||
} else if (chosenOption?.hasPersonalDiet == true &&
|
||||
amIConsultant &&
|
||||
chosenPlan.chosenPlanState != ChosenPlanStateEnum.stateDone) {
|
||||
bannerType = BannerType.setPersonalDiet;
|
||||
}
|
||||
|
||||
@@ -223,25 +249,40 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
|
||||
title: GestureDetector(
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed('profileScreen', extra: state.chatRoom.users.receiver.slug);
|
||||
GoRouter.of(context).pushNamed(
|
||||
'profileScreen',
|
||||
extra: state.chatRoom.users.receiver.slug,
|
||||
);
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
if (state.chatRoom.users.receiver.profilePhotoUrl != null)
|
||||
CircleAvatar(
|
||||
radius: 20,
|
||||
foregroundImage: NetworkImage(state.chatRoom.users.receiver.profilePhotoUrl!),
|
||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
||||
foregroundImage: NetworkImage(
|
||||
state.chatRoom.users.receiver.profilePhotoUrl!,
|
||||
),
|
||||
backgroundImage: const AssetImage(
|
||||
'assets/images/no_avatar.png',
|
||||
),
|
||||
),
|
||||
if (state.chatRoom.users.receiver.profilePhotoUrl == null)
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignment: Alignment.center,
|
||||
decoration: const BoxDecoration(color: Color(0xFFEBF4FF), shape: BoxShape.circle),
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFEBF4FF),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Text(
|
||||
state.chatRoom.users.receiver.name.split(' ').first[0] + state.chatRoom.users.receiver.name.split(' ')[1][0],
|
||||
style: const TextStyle(color: Color(0xFF7F9CF5), fontWeight: FontWeight.w400, fontSize: 20),
|
||||
state.chatRoom.users.receiver.name.split(' ').first[0] +
|
||||
state.chatRoom.users.receiver.name.split(' ')[1][0],
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF7F9CF5),
|
||||
fontWeight: FontWeight.w400,
|
||||
fontSize: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
@@ -253,10 +294,17 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
state.chatRoom.users.receiver.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: CustomTextStyles.titleSmallBold.copyWith(color: PrimaryColors().black),
|
||||
style: CustomTextStyles.titleSmallBold.copyWith(
|
||||
color: PrimaryColors().black,
|
||||
),
|
||||
),
|
||||
AnimatedCrossFade(
|
||||
firstChild: Text('Печатает...', maxLines: 1, overflow: TextOverflow.ellipsis, style: CustomTextStyles.bodySmallMulishPrimary),
|
||||
firstChild: Text(
|
||||
'Печатает...',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: CustomTextStyles.bodySmallMulishPrimary,
|
||||
),
|
||||
firstCurve: Curves.easeIn,
|
||||
secondCurve: Curves.easeOut,
|
||||
secondChild: ChatUserOnlineStatusLine(
|
||||
@@ -265,7 +313,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
style: CustomTextStyles.bodySmallMulishPrimary,
|
||||
showOnlineDot: false,
|
||||
),
|
||||
crossFadeState: state.isTyping ? CrossFadeState.showFirst : CrossFadeState.showSecond,
|
||||
crossFadeState: state.isTyping
|
||||
? CrossFadeState.showFirst
|
||||
: CrossFadeState.showSecond,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
),
|
||||
],
|
||||
@@ -277,7 +327,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsetsDirectional.only(end: 8),
|
||||
child: _ChatHeaderVideoCallButton(onPressed: () => _openVideoCall(context, state, chatCounterpart)),
|
||||
child: _ChatHeaderVideoCallButton(
|
||||
onPressed: () => _openVideoCall(context, state, chatCounterpart),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -291,44 +343,133 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
child: Container(
|
||||
color: appTheme.whiteA700,
|
||||
width: double.maxFinite,
|
||||
child: SingleChildScrollView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
controller: scrollController,
|
||||
reverse: true,
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
ListView.builder(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
reverse: true,
|
||||
shrinkWrap: true,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == state.chatMessages.length) {
|
||||
return const Padding(padding: EdgeInsets.all(8), child: Loader());
|
||||
}
|
||||
child: RefreshIndicator(
|
||||
onRefresh: chatRoomCubit.refreshChat,
|
||||
child: SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(
|
||||
parent: BouncingScrollPhysics(),
|
||||
),
|
||||
controller: scrollController,
|
||||
reverse: true,
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
ListView.builder(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
reverse: true,
|
||||
shrinkWrap: true,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == state.chatMessages.length) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
child: Loader(),
|
||||
);
|
||||
}
|
||||
|
||||
if ((index + 1 != state.chatMessages.length &&
|
||||
state.chatMessages[index].updatedAt.day != state.chatMessages[index + 1].updatedAt.day) ||
|
||||
index + 1 == state.chatMessages.length) {
|
||||
final isCurrentUserAuthor = state.chatRoom.users.receiver.id != state.chatMessages[index].authorId;
|
||||
if ((index + 1 != state.chatMessages.length &&
|
||||
state
|
||||
.chatMessages[index]
|
||||
.updatedAt
|
||||
.day !=
|
||||
state
|
||||
.chatMessages[index + 1]
|
||||
.updatedAt
|
||||
.day) ||
|
||||
index + 1 == state.chatMessages.length) {
|
||||
final isCurrentUserAuthor =
|
||||
state.chatRoom.users.receiver.id !=
|
||||
state.chatMessages[index].authorId;
|
||||
final message = state.chatMessages[index];
|
||||
final isPlanOrderMessage =
|
||||
message.type == ChatMessageType.planOrder;
|
||||
|
||||
PacketOptions? messageOption;
|
||||
if (isPlanOrderMessage &&
|
||||
chosenPlan != null) {
|
||||
if (chosenPlan.optionType == 'options1') {
|
||||
messageOption =
|
||||
chosenPlan.plan.packet1Options;
|
||||
} else if (chosenPlan.optionType ==
|
||||
'options2') {
|
||||
messageOption =
|
||||
chosenPlan.plan.packet2Options;
|
||||
} else if (chosenPlan.optionType ==
|
||||
'options3') {
|
||||
messageOption =
|
||||
chosenPlan.plan.packet3Options;
|
||||
}
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
_messageDate(message.updatedAt),
|
||||
if (isPlanOrderMessage &&
|
||||
chosenPlan != null &&
|
||||
messageOption != null)
|
||||
PlanOrderMessageWidget(
|
||||
planName: chosenPlan.plan.name,
|
||||
planSlug: chosenPlan.plan.slug,
|
||||
price: messageOption.price,
|
||||
sendTime: message.updatedAt,
|
||||
isCurrentUser: isCurrentUserAuthor,
|
||||
),
|
||||
VisibilityDetector(
|
||||
key: ValueKey(
|
||||
'msg-${message.uuid ?? message.id}',
|
||||
),
|
||||
onVisibilityChanged:
|
||||
(VisibilityInfo info) {
|
||||
_queueMarkAsRead(
|
||||
context: context,
|
||||
chatId: state.chatRoom.id,
|
||||
message: message,
|
||||
isCurrentUserAuthor:
|
||||
isCurrentUserAuthor,
|
||||
visibleFraction:
|
||||
info.visibleFraction,
|
||||
);
|
||||
},
|
||||
child: _buildMessageBubble(
|
||||
context: context,
|
||||
state: state,
|
||||
chatCounterpart: chatCounterpart,
|
||||
isCurrentUserAuthor:
|
||||
isCurrentUserAuthor,
|
||||
message: message,
|
||||
amIConsultant: amIConsultant,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
final isCurrentUserAuthor =
|
||||
state.chatRoom.users.receiver.id !=
|
||||
state.chatMessages[index].authorId;
|
||||
final message = state.chatMessages[index];
|
||||
final isPlanOrderMessage = message.type == ChatMessageType.planOrder;
|
||||
final isPlanOrderMessage =
|
||||
message.type == ChatMessageType.planOrder;
|
||||
|
||||
PacketOptions? messageOption;
|
||||
if (isPlanOrderMessage && chosenPlan != null) {
|
||||
if (chosenPlan.optionType == 'options1') {
|
||||
messageOption = chosenPlan.plan.packet1Options;
|
||||
} else if (chosenPlan.optionType == 'options2') {
|
||||
messageOption = chosenPlan.plan.packet2Options;
|
||||
} else if (chosenPlan.optionType == 'options3') {
|
||||
messageOption = chosenPlan.plan.packet3Options;
|
||||
messageOption =
|
||||
chosenPlan.plan.packet1Options;
|
||||
} else if (chosenPlan.optionType ==
|
||||
'options2') {
|
||||
messageOption =
|
||||
chosenPlan.plan.packet2Options;
|
||||
} else if (chosenPlan.optionType ==
|
||||
'options3') {
|
||||
messageOption =
|
||||
chosenPlan.plan.packet3Options;
|
||||
}
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
_messageDate(message.updatedAt),
|
||||
if (isPlanOrderMessage && chosenPlan != null && messageOption != null)
|
||||
if (isPlanOrderMessage &&
|
||||
chosenPlan != null &&
|
||||
messageOption != null)
|
||||
PlanOrderMessageWidget(
|
||||
planName: chosenPlan.plan.name,
|
||||
planSlug: chosenPlan.plan.slug,
|
||||
@@ -337,126 +478,94 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
isCurrentUser: isCurrentUserAuthor,
|
||||
),
|
||||
VisibilityDetector(
|
||||
key: Key(message.id.toString()),
|
||||
onVisibilityChanged: (VisibilityInfo info) {
|
||||
if (!message.isRead && !isCurrentUserAuthor && info.visibleFraction > 0) {
|
||||
unreadMessagesIdsCopy.add((id: message.id, index: index));
|
||||
EasyDebounce.debounce('mark_as_read', const Duration(milliseconds: 500), () async {
|
||||
final result = await context.read<ChatRoomCubit>().markMessagesAsRead(state.chatRoom.id, unreadMessagesIdsCopy);
|
||||
if (!result) return;
|
||||
if (!context.mounted) return;
|
||||
context.read<ChatsCubit>().reduceUnreadMessagesCount(
|
||||
key: ValueKey(
|
||||
'msg-${message.uuid ?? message.id}',
|
||||
),
|
||||
onVisibilityChanged:
|
||||
(VisibilityInfo info) {
|
||||
_queueMarkAsRead(
|
||||
context: context,
|
||||
chatId: state.chatRoom.id,
|
||||
count: unreadMessagesIdsCopy.length,
|
||||
message: message,
|
||||
isCurrentUserAuthor:
|
||||
isCurrentUserAuthor,
|
||||
visibleFraction:
|
||||
info.visibleFraction,
|
||||
);
|
||||
unreadMessagesIdsCopy.clear();
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
child: _buildMessageBubble(
|
||||
context: context,
|
||||
state: state,
|
||||
chatCounterpart: chatCounterpart,
|
||||
isCurrentUserAuthor: isCurrentUserAuthor,
|
||||
isCurrentUserAuthor:
|
||||
isCurrentUserAuthor,
|
||||
message: message,
|
||||
amIConsultant: amIConsultant,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
final isCurrentUserAuthor = state.chatRoom.users.receiver.id != state.chatMessages[index].authorId;
|
||||
final message = state.chatMessages[index];
|
||||
final isPlanOrderMessage = message.type == ChatMessageType.planOrder;
|
||||
|
||||
PacketOptions? messageOption;
|
||||
if (isPlanOrderMessage && chosenPlan != null) {
|
||||
if (chosenPlan.optionType == 'options1') {
|
||||
messageOption = chosenPlan.plan.packet1Options;
|
||||
} else if (chosenPlan.optionType == 'options2') {
|
||||
messageOption = chosenPlan.plan.packet2Options;
|
||||
} else if (chosenPlan.optionType == 'options3') {
|
||||
messageOption = chosenPlan.plan.packet3Options;
|
||||
}
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (isPlanOrderMessage && chosenPlan != null && messageOption != null)
|
||||
PlanOrderMessageWidget(
|
||||
planName: chosenPlan.plan.name,
|
||||
planSlug: chosenPlan.plan.slug,
|
||||
price: messageOption.price,
|
||||
sendTime: message.updatedAt,
|
||||
isCurrentUser: isCurrentUserAuthor,
|
||||
},
|
||||
itemCount:
|
||||
state.chatMessages.length +
|
||||
(state.isLoading ? 1 : 0),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (showUserDetailsButton) ...[
|
||||
// OrderCard(
|
||||
// plan: chosenPlan.plan,
|
||||
// isCustomerView: !amIConsultant,
|
||||
// optionType: chosenPlan.optionType,
|
||||
// onCancel: () async {
|
||||
// await context.read<ChatRoomCubit>().cancelChosenPlan(chatId: state.chatRoom.id, planId: chosenPlan.id);
|
||||
// },
|
||||
// ),
|
||||
if (amIConsultant)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 12,
|
||||
horizontal: 16,
|
||||
),
|
||||
child: CustomOutlinedButton(
|
||||
text: S.current.user_info,
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold
|
||||
.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(
|
||||
side: const WidgetStatePropertyAll(
|
||||
BorderSide.none,
|
||||
),
|
||||
backgroundColor: WidgetStatePropertyAll(
|
||||
appTheme.blueA400,
|
||||
),
|
||||
),
|
||||
VisibilityDetector(
|
||||
key: Key(message.id.toString()),
|
||||
onVisibilityChanged: (VisibilityInfo info) {
|
||||
if (!message.isRead && !isCurrentUserAuthor && info.visibleFraction > 0) {
|
||||
unreadMessagesIds.add((id: message.id, index: index));
|
||||
EasyDebounce.debounce('mark_as_read', const Duration(milliseconds: 500), () async {
|
||||
final result = await context.read<ChatRoomCubit>().markMessagesAsRead(state.chatRoom.id, unreadMessagesIds);
|
||||
if (!result) return;
|
||||
if (!context.mounted) return;
|
||||
context.read<ChatsCubit>().reduceUnreadMessagesCount(chatId: state.chatRoom.id, count: unreadMessagesIds.length);
|
||||
unreadMessagesIds.clear();
|
||||
});
|
||||
}
|
||||
onPressed: () async {
|
||||
GoRouter.of(context).push(
|
||||
UserDetailsScreen.path,
|
||||
extra:
|
||||
state.chatRoom.users.receiver.slug,
|
||||
);
|
||||
},
|
||||
child: _buildMessageBubble(
|
||||
context: context,
|
||||
state: state,
|
||||
chatCounterpart: chatCounterpart,
|
||||
isCurrentUserAuthor: isCurrentUserAuthor,
|
||||
message: message,
|
||||
amIConsultant: amIConsultant,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
itemCount: state.chatMessages.length + (state.isLoading ? 1 : 0),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (showUserDetailsButton) ...[
|
||||
// OrderCard(
|
||||
// plan: chosenPlan.plan,
|
||||
// isCustomerView: !amIConsultant,
|
||||
// optionType: chosenPlan.optionType,
|
||||
// onCancel: () async {
|
||||
// await context.read<ChatRoomCubit>().cancelChosenPlan(chatId: state.chatRoom.id, planId: chosenPlan.id);
|
||||
// },
|
||||
// ),
|
||||
if (amIConsultant)
|
||||
),
|
||||
],
|
||||
if (bannerType != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||
child: CustomOutlinedButton(
|
||||
text: S.current.user_info,
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
||||
),
|
||||
onPressed: () async {
|
||||
GoRouter.of(context).push(UserDetailsScreen.path, extra: state.chatRoom.users.receiver.slug);
|
||||
},
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 12,
|
||||
horizontal: 16,
|
||||
),
|
||||
child: NotificationBanner(
|
||||
bannerType: bannerType,
|
||||
chosenPlanId: chosenPlan?.id,
|
||||
planName: chosenPlan?.plan.name,
|
||||
planPrice: chosenOption?.price.toDouble(),
|
||||
hasPersonalDiet:
|
||||
chosenOption?.hasPersonalDiet ?? false,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (bannerType != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||
child: NotificationBanner(
|
||||
bannerType: bannerType,
|
||||
chosenPlanId: chosenPlan?.id,
|
||||
planName: chosenPlan?.plan.name,
|
||||
planPrice: chosenOption?.price.toDouble(),
|
||||
hasPersonalDiet: chosenOption?.hasPersonalDiet ?? false,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -481,19 +590,40 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
_isImageFile(pickedFiles[i].path)
|
||||
? GestureDetector(
|
||||
onTap: () {
|
||||
final imageFiles = pickedFiles.where((f) => _isImageFile(f.path)).toList();
|
||||
final imageIndex = _imageIndexAt(pickedFiles, i);
|
||||
final imageFiles = pickedFiles
|
||||
.where(
|
||||
(f) => _isImageFile(f.path),
|
||||
)
|
||||
.toList();
|
||||
final imageIndex = _imageIndexAt(
|
||||
pickedFiles,
|
||||
i,
|
||||
);
|
||||
|
||||
context.pushTransparentRoute(
|
||||
PhotoViewScreen(imageUrls: imageFiles.map((e) => e.path).toList(), isLocal: true, index: imageIndex),
|
||||
PhotoViewScreen(
|
||||
imageUrls: imageFiles
|
||||
.map((e) => e.path)
|
||||
.toList(),
|
||||
isLocal: true,
|
||||
index: imageIndex,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Hero(
|
||||
tag: pickedFiles[i].path,
|
||||
transitionOnUserGestures: true,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: SizedBox(height: 104, width: 104, child: Image.file(pickedFiles[i], fit: BoxFit.cover)),
|
||||
borderRadius:
|
||||
BorderRadius.circular(8),
|
||||
child: SizedBox(
|
||||
height: 104,
|
||||
width: 104,
|
||||
child: Image.file(
|
||||
pickedFiles[i],
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -510,8 +640,15 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(color: appTheme.gray50, shape: BoxShape.circle),
|
||||
child: const Icon(size: 20, Icons.close, color: Colors.black),
|
||||
decoration: BoxDecoration(
|
||||
color: appTheme.gray50,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
size: 20,
|
||||
Icons.close,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -528,14 +665,20 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
children: [
|
||||
const SizedBox(width: 8),
|
||||
InkWell(
|
||||
onTap: () => showImagePicker(state.chatRoom.users.receiver.slug),
|
||||
onTap: () => showImagePicker(
|
||||
state.chatRoom.users.receiver.slug,
|
||||
),
|
||||
customBorder: const CircleBorder(),
|
||||
child: Ink(
|
||||
height: 40,
|
||||
width: 40,
|
||||
child: Transform.rotate(
|
||||
angle: pi / 4,
|
||||
child: const Icon(Icons.attach_file_rounded, size: 30, color: Color(0xFF858E99)),
|
||||
child: const Icon(
|
||||
Icons.attach_file_rounded,
|
||||
size: 30,
|
||||
color: Color(0xFF858E99),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -547,24 +690,46 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
maxLines: 3,
|
||||
minLines: 1,
|
||||
keyboardType: TextInputType.multiline,
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, color: Colors.black, height: 1),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: Colors.black,
|
||||
height: 1,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 10,
|
||||
),
|
||||
hintText: 'Сообщение',
|
||||
hintStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w400, height: 1, color: Color(0xFFAEAEB2)),
|
||||
hintStyle: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1,
|
||||
color: Color(0xFFAEAEB2),
|
||||
),
|
||||
filled: true,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(100),
|
||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
||||
borderSide: const BorderSide(
|
||||
color: Color(0xFFD1D1D6),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(100),
|
||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
||||
borderSide: const BorderSide(
|
||||
color: Color(0xFFD1D1D6),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(100),
|
||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
||||
borderSide: const BorderSide(
|
||||
color: Color(0xFFD1D1D6),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -579,12 +744,18 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
final blockedState = blockedUsersCubit.state;
|
||||
|
||||
if (blockedState is BlockedUsersStateLoaded) {
|
||||
final isBlocked = blockedState.profilesList.any((profile) => profile.slug == state.chatRoom.users.receiver.slug);
|
||||
final isBlocked = blockedState.profilesList.any(
|
||||
(profile) =>
|
||||
profile.slug ==
|
||||
state.chatRoom.users.receiver.slug,
|
||||
);
|
||||
if (isBlocked) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Вы не можете отправить сообщение заблокированному пользователю'),
|
||||
content: Text(
|
||||
'Вы не можете отправить сообщение заблокированному пользователю',
|
||||
),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
@@ -593,10 +764,15 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
if (textEditingController.text.isNotEmpty || pickedFiles.isNotEmpty) {
|
||||
if (textEditingController.text.isNotEmpty ||
|
||||
pickedFiles.isNotEmpty) {
|
||||
chatRoomCubit.sendMessage(
|
||||
data: textEditingController.text.isEmpty ? null : textEditingController.text.trim(),
|
||||
files: pickedFiles.isEmpty ? null : pickedFiles,
|
||||
data: textEditingController.text.isEmpty
|
||||
? null
|
||||
: textEditingController.text.trim(),
|
||||
files: pickedFiles.isEmpty
|
||||
? null
|
||||
: pickedFiles,
|
||||
userSlug: state.chatRoom.users.receiver.slug,
|
||||
);
|
||||
setState(() {
|
||||
@@ -606,10 +782,17 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
}
|
||||
},
|
||||
child: Ink(
|
||||
decoration: BoxDecoration(color: appTheme.blueA400, shape: BoxShape.circle),
|
||||
decoration: BoxDecoration(
|
||||
color: appTheme.blueA400,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
height: 40,
|
||||
width: 40,
|
||||
child: const Icon(Icons.arrow_upward_rounded, size: 24, color: Colors.white),
|
||||
child: const Icon(
|
||||
Icons.arrow_upward_rounded,
|
||||
size: 24,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
@@ -628,12 +811,19 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
child: AboutOrderButton(
|
||||
onTap: () async {
|
||||
if (amIConsultant) {
|
||||
GoRouter.of(
|
||||
context,
|
||||
).push(InfoByOrderScreen.path, extra: {'planId': state.chatRoom.chosenPlan?.id, 'customerSlug': state.chatRoom.users.receiver.slug});
|
||||
GoRouter.of(context).push(
|
||||
InfoByOrderScreen.path,
|
||||
extra: {
|
||||
'planId': state.chatRoom.chosenPlan?.id,
|
||||
'customerSlug': state.chatRoom.users.receiver.slug,
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
GoRouter.of(context).push(InfoByOrderScreen.path, extra: {'planId': state.chatRoom.chosenPlan?.id});
|
||||
GoRouter.of(context).push(
|
||||
InfoByOrderScreen.path,
|
||||
extra: {'planId': state.chatRoom.chosenPlan?.id},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -658,7 +848,13 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
const Icon(Icons.attach_file, size: 32, color: Colors.black54),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Text(fileName, textAlign: TextAlign.center, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12)),
|
||||
child: Text(
|
||||
fileName,
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -706,7 +902,13 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
// await chatRoomCubit.sendMessage(files: [File(image.path)], userSlug: slug);
|
||||
// }
|
||||
},
|
||||
child: const Row(children: [Icon(Icons.photo_camera), SizedBox(width: 8), Text('Фото')]),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.photo_camera),
|
||||
SizedBox(width: 8),
|
||||
Text('Фото'),
|
||||
],
|
||||
),
|
||||
),
|
||||
CupertinoActionSheetAction(
|
||||
onPressed: () async {
|
||||
@@ -715,13 +917,21 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
List<XFile> images = await picker.pickMultiImage();
|
||||
if (images != []) {
|
||||
setState(() {
|
||||
pickedFiles.addAll(images.map((image) => File(image.path)).toList());
|
||||
pickedFiles.addAll(
|
||||
images.map((image) => File(image.path)).toList(),
|
||||
);
|
||||
});
|
||||
// List<File> files = images.map((image) => File(image.path)).toList();
|
||||
// await chatRoomCubit.sendMessage(files: files, userSlug: slug);
|
||||
}
|
||||
},
|
||||
child: const Row(children: [Icon(Icons.photo_library_rounded), SizedBox(width: 8), Text('Галерея')]),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.photo_library_rounded),
|
||||
SizedBox(width: 8),
|
||||
Text('Галерея'),
|
||||
],
|
||||
),
|
||||
),
|
||||
CupertinoActionSheetAction(
|
||||
onPressed: () async {
|
||||
@@ -753,15 +963,27 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
if (result != null) {
|
||||
// List<File> files = result.paths.map((path) => File(path!)).toList();
|
||||
setState(() {
|
||||
pickedFiles.addAll(result.paths.map((path) => File(path!)).toList());
|
||||
pickedFiles.addAll(
|
||||
result.paths.map((path) => File(path!)).toList(),
|
||||
);
|
||||
});
|
||||
// await chatRoomCubit.sendMessage(files: files, userSlug: slug);
|
||||
}
|
||||
},
|
||||
child: const Row(children: [Icon(Icons.file_present_rounded), SizedBox(width: 8), Text('Файлы')]),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.file_present_rounded),
|
||||
SizedBox(width: 8),
|
||||
Text('Файлы'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
cancelButton: CupertinoActionSheetAction(onPressed: () => Navigator.of(context).pop(), isDestructiveAction: true, child: const Text('Отмена')),
|
||||
cancelButton: CupertinoActionSheetAction(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
isDestructiveAction: true,
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -772,13 +994,21 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
padding: const EdgeInsets.only(top: 32),
|
||||
child: Text(
|
||||
DateFormat.yMMMMd('RU').format(date),
|
||||
style: const TextStyle(height: 1, color: Color(0xFF8393A1), fontWeight: FontWeight.w500),
|
||||
style: const TextStyle(
|
||||
height: 1,
|
||||
color: Color(0xFF8393A1),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openVideoCall(BuildContext context, ChatRoomStateLoaded state, ChatUserModel chatCounterpart) {
|
||||
void _openVideoCall(
|
||||
BuildContext context,
|
||||
ChatRoomStateLoaded state,
|
||||
ChatUserModel chatCounterpart,
|
||||
) {
|
||||
GoRouter.of(context).pushNamed(
|
||||
VideoCallScreen.name,
|
||||
extra: <String, dynamic>{
|
||||
@@ -793,6 +1023,43 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
void _queueMarkAsRead({
|
||||
required BuildContext context,
|
||||
required String chatId,
|
||||
required ChatMessageModel message,
|
||||
required bool isCurrentUserAuthor,
|
||||
required double visibleFraction,
|
||||
}) {
|
||||
if (message.isRead || isCurrentUserAuthor || visibleFraction <= 0) {
|
||||
return;
|
||||
}
|
||||
if (!unreadMessageIds.contains(message.id)) {
|
||||
unreadMessageIds.add(message.id);
|
||||
}
|
||||
EasyDebounce.debounce(
|
||||
'mark_as_read',
|
||||
const Duration(milliseconds: 500),
|
||||
() async {
|
||||
final ids = List<int>.from(unreadMessageIds);
|
||||
if (ids.isEmpty) {
|
||||
return;
|
||||
}
|
||||
unreadMessageIds.clear();
|
||||
final result = await context.read<ChatRoomCubit>().markMessagesAsRead(
|
||||
chatId,
|
||||
ids,
|
||||
);
|
||||
if (!result || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
context.read<ChatsCubit>().reduceUnreadMessagesCount(
|
||||
chatId: chatId,
|
||||
count: ids.length,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMessageBubble({
|
||||
required BuildContext context,
|
||||
required ChatRoomStateLoaded state,
|
||||
@@ -801,11 +1068,13 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
required ChatMessageModel message,
|
||||
required bool? amIConsultant,
|
||||
}) {
|
||||
if (message.type == ChatMessageType.videoCallStarted && !isCurrentUserAuthor) {
|
||||
if (message.type == ChatMessageType.videoCallStarted &&
|
||||
!isCurrentUserAuthor) {
|
||||
return VideoCallChatMessageWidget(
|
||||
text: message.data.trim(),
|
||||
sendTime: message.updatedAt,
|
||||
onVideoCallPressed: () => _openVideoCall(context, state, chatCounterpart),
|
||||
onVideoCallPressed: () =>
|
||||
_openVideoCall(context, state, chatCounterpart),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -844,7 +1113,13 @@ class _ChatHeaderVideoCallButton extends StatelessWidget {
|
||||
child: SizedBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Center(child: Icon(Icons.videocam_rounded, color: appTheme.blueA400, size: 18)),
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.videocam_rounded,
|
||||
color: appTheme.blueA400,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -33,9 +33,7 @@ class _ComplainingBottomSheetState extends State<ComplainingBottomSheet> {
|
||||
create: (context) => sl<ComplainingCubit>()..setContentTypeAndIdentifier(contentType: widget.complainedContentType, slug: widget.slug, id: widget.id),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 24, left: 16, right: 16),
|
||||
decoration: const BoxDecoration(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
|
||||
),
|
||||
decoration: const BoxDecoration(borderRadius: BorderRadius.vertical(top: Radius.circular(12))),
|
||||
child: BlocBuilder<ComplainingCubit, ComplainingState>(
|
||||
builder: (context, state) {
|
||||
final category = state.category;
|
||||
@@ -72,10 +70,7 @@ class _CategoryWidget extends StatelessWidget {
|
||||
children: [
|
||||
const BottomSheetHandlerBar(),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
s.complaint,
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
Text(s.complaint, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
s.complaint_question,
|
||||
@@ -114,10 +109,7 @@ class _SpamWidget extends StatelessWidget {
|
||||
children: [
|
||||
const BottomSheetHandlerBar(),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
s.spam,
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
Text(s.spam, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||
@@ -145,10 +137,7 @@ class _ExplicitImageWidget extends StatelessWidget {
|
||||
children: [
|
||||
const BottomSheetHandlerBar(),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
s.explicitImages,
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
Text(s.explicitImages, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||
@@ -202,10 +191,7 @@ class _LawViolationWidgetState extends State<_LawViolationWidget> {
|
||||
children: [
|
||||
const BottomSheetHandlerBar(),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
s.lawViolation,
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
Text(s.lawViolation, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||
@@ -235,10 +221,7 @@ class _LawViolationWidgetState extends State<_LawViolationWidget> {
|
||||
});
|
||||
},
|
||||
),
|
||||
Text(
|
||||
element.$2,
|
||||
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
Text(element.$2, style: TextStyles.textLSemibold.copyWith(color: appTheme.black)),
|
||||
],
|
||||
);
|
||||
}),
|
||||
@@ -254,12 +237,10 @@ class _LawViolationWidgetState extends State<_LawViolationWidget> {
|
||||
if ((state.complainInfo.description?.length ?? 10) < 10) ...[
|
||||
const SizedBox(height: 4),
|
||||
const ValidationErrorWidget(error: 'Минимум 10 символов'),
|
||||
]
|
||||
],
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
_ComplainButton(
|
||||
isDisabled: (state.complainInfo.description?.length ?? 0) < 10,
|
||||
),
|
||||
_ComplainButton(isDisabled: tags.firstWhere((tag) => tag.$2 == s.other).$1 && (state.complainInfo.description?.length ?? 0) < 10),
|
||||
],
|
||||
);
|
||||
},
|
||||
@@ -301,10 +282,7 @@ class _ViolenceAndHostilityState extends State<_ViolenceAndHostility> {
|
||||
children: [
|
||||
const BottomSheetHandlerBar(),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
s.lawViolation,
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
Text(s.lawViolation, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||
@@ -332,10 +310,7 @@ class _ViolenceAndHostilityState extends State<_ViolenceAndHostility> {
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
element.$2,
|
||||
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
child: Text(element.$2, style: TextStyles.textLSemibold.copyWith(color: appTheme.black)),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -363,15 +338,9 @@ class _DeceptionWidgetState extends State<_DeceptionWidget> {
|
||||
super.didChangeDependencies();
|
||||
final s = S.of(context);
|
||||
if (tags.isEmpty && descriptions.isEmpty) {
|
||||
tags.addAll([
|
||||
(false, s.misrepresentation),
|
||||
(false, s.fraud),
|
||||
]);
|
||||
tags.addAll([(false, s.misrepresentation), (false, s.fraud)]);
|
||||
|
||||
descriptions.addAll([
|
||||
s.misrepresentation_description,
|
||||
s.fraud_description,
|
||||
]);
|
||||
descriptions.addAll([s.misrepresentation_description, s.fraud_description]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,10 +352,7 @@ class _DeceptionWidgetState extends State<_DeceptionWidget> {
|
||||
children: [
|
||||
const BottomSheetHandlerBar(),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
s.lawViolation,
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
Text(s.lawViolation, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||
@@ -419,16 +385,10 @@ class _DeceptionWidgetState extends State<_DeceptionWidget> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
element.$2,
|
||||
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
Text(element.$2, style: TextStyles.textLSemibold.copyWith(color: appTheme.black)),
|
||||
SizedBox(
|
||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||
child: Text(
|
||||
descriptions[index],
|
||||
style: TextStyles.textSSemibold.copyWith(color: appTheme.lightGray600),
|
||||
),
|
||||
child: Text(descriptions[index], style: TextStyles.textSSemibold.copyWith(color: appTheme.lightGray600)),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -453,10 +413,7 @@ class _ComplaintSentWidget extends StatelessWidget {
|
||||
children: [
|
||||
const BottomSheetHandlerBar(),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
s.complaint_sent,
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
Text(s.complaint_sent, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||
@@ -471,10 +428,7 @@ class _ComplaintSentWidget extends StatelessWidget {
|
||||
text: s.ok,
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
||||
),
|
||||
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
|
||||
onPressed: () {
|
||||
AppRouter.navigatorKey.currentContext?.pop();
|
||||
},
|
||||
@@ -495,10 +449,7 @@ class _ComplaintFailedWidget extends StatelessWidget {
|
||||
children: [
|
||||
const BottomSheetHandlerBar(),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
s.could_not_send_complain,
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
Text(s.could_not_send_complain, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||
@@ -513,10 +464,7 @@ class _ComplaintFailedWidget extends StatelessWidget {
|
||||
text: s.ok,
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
||||
),
|
||||
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
|
||||
onPressed: () {
|
||||
AppRouter.navigatorKey.currentContext?.pop();
|
||||
},
|
||||
@@ -535,16 +483,13 @@ class _ComplainButton extends StatelessWidget {
|
||||
return BlocBuilder<ComplainingCubit, ComplainingState>(
|
||||
builder: (context, state) {
|
||||
if (state.isLoading) {
|
||||
return const Loader();
|
||||
return const SizedBox(height: 52, child: Loader());
|
||||
}
|
||||
return CustomOutlinedButton(
|
||||
text: s.send_complain,
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
||||
),
|
||||
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
|
||||
onPressed: () async {
|
||||
if (isDisabled) {
|
||||
context.read<ComplainingCubit>().setDescription('');
|
||||
@@ -570,10 +515,7 @@ class _CategoryButton extends StatelessWidget {
|
||||
child: Ink(
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
category.getTranslatedText(),
|
||||
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
Text(category.getTranslatedText(), style: TextStyles.textLSemibold.copyWith(color: appTheme.black)),
|
||||
const Spacer(),
|
||||
SvgPicture.asset('assets/icons/right_arrow.svg'),
|
||||
],
|
||||
@@ -591,18 +533,18 @@ enum ComplainType {
|
||||
explicitImage;
|
||||
|
||||
String getTranslatedText() => switch (this) {
|
||||
ComplainType.spam => S.current.spam,
|
||||
ComplainType.lawViolation => S.current.lawViolation,
|
||||
ComplainType.deception => S.current.deception,
|
||||
ComplainType.violenceDndHostility => S.current.violenceAndHostility,
|
||||
ComplainType.explicitImage => S.current.explicitImage,
|
||||
};
|
||||
ComplainType.spam => S.current.spam,
|
||||
ComplainType.lawViolation => S.current.lawViolation,
|
||||
ComplainType.deception => S.current.deception,
|
||||
ComplainType.violenceDndHostility => S.current.violenceAndHostility,
|
||||
ComplainType.explicitImage => S.current.explicitImage,
|
||||
};
|
||||
|
||||
String getValue() => switch (this) {
|
||||
ComplainType.spam => 'spam',
|
||||
ComplainType.lawViolation => 'legalViolation',
|
||||
ComplainType.deception => 'fraud',
|
||||
ComplainType.violenceDndHostility => 'violenceAndHatred',
|
||||
ComplainType.explicitImage => 'explicitImage',
|
||||
};
|
||||
ComplainType.spam => 'spam',
|
||||
ComplainType.lawViolation => 'legalViolation',
|
||||
ComplainType.deception => 'fraud',
|
||||
ComplainType.violenceDndHostility => 'violenceAndHatred',
|
||||
ComplainType.explicitImage => 'explicitImage',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -36,6 +36,10 @@ class ComplainingCubit extends Cubit<ComplainingState> {
|
||||
}
|
||||
|
||||
Future<void> complain() async {
|
||||
if (state.isLoading) {
|
||||
return;
|
||||
}
|
||||
emit(state.copyWith(isLoading: true));
|
||||
try {
|
||||
final category = state.category;
|
||||
final id = state.contentId;
|
||||
@@ -46,37 +50,37 @@ class ComplainingCubit extends Cubit<ComplainingState> {
|
||||
case ComplainedContent.diet:
|
||||
if (id != null) {
|
||||
await repository.sendDietComplain(id: id, category: category.getValue(), body: state.complainInfo);
|
||||
emit(state.copyWith(wasSentSuccessfully: true));
|
||||
emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
|
||||
return;
|
||||
}
|
||||
case ComplainedContent.plan:
|
||||
if (slug != null) {
|
||||
await repository.sendPlanComplain(slug: slug, category: category.getValue(), body: state.complainInfo);
|
||||
emit(state.copyWith(wasSentSuccessfully: true));
|
||||
emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
|
||||
return;
|
||||
}
|
||||
case ComplainedContent.recipe:
|
||||
if (slug != null) {
|
||||
await repository.sendRecipeComplain(slug: slug, category: category.getValue(), body: state.complainInfo);
|
||||
emit(state.copyWith(wasSentSuccessfully: true));
|
||||
emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
|
||||
return;
|
||||
}
|
||||
case ComplainedContent.profile:
|
||||
if (slug != null) {
|
||||
await repository.sendProfileComplain(slug: slug, category: category.getValue(), body: state.complainInfo);
|
||||
emit(state.copyWith(wasSentSuccessfully: true));
|
||||
emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
|
||||
return;
|
||||
}
|
||||
case ComplainedContent.posts:
|
||||
if (id != null) {
|
||||
await repository.sendBlogPostsComplain(id: id, category: category.getValue(), body: state.complainInfo);
|
||||
emit(state.copyWith(wasSentSuccessfully: true));
|
||||
emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
|
||||
return;
|
||||
}
|
||||
case ComplainedContent.comment:
|
||||
if (id != null) {
|
||||
await repository.sendCommentComplain(id: id, category: category.getValue(), body: state.complainInfo);
|
||||
emit(state.copyWith(wasSentSuccessfully: true));
|
||||
emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -85,6 +89,6 @@ class ComplainingCubit extends Cubit<ComplainingState> {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString(), stackTrace: st);
|
||||
}
|
||||
emit(state.copyWith(wasSentSuccessfully: false));
|
||||
emit(state.copyWith(wasSentSuccessfully: false, isLoading: false));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/utils/passport_data_validator.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'consultant_profile_model.freezed.dart';
|
||||
@@ -54,37 +55,7 @@ abstract class ConsultantProfileModel with _$ConsultantProfileModel {
|
||||
factory ConsultantProfileModel.fromJson(Map<String, Object?> json) =>
|
||||
_$ConsultantProfileModelFromJson(json);
|
||||
|
||||
bool get hasValidPassportData {
|
||||
final last = lastName ?? '';
|
||||
final first = firstName ?? '';
|
||||
final innValue = inn ?? '';
|
||||
final snilsValue = snils ?? '';
|
||||
final series = passportSeries ?? '';
|
||||
final number = passportNumber ?? '';
|
||||
final issueDate = passportIssueDate ?? '';
|
||||
final issuer = passportIssuer ?? '';
|
||||
final issuerCode = passportIssuerCode ?? '';
|
||||
final birth = birthDate ?? '';
|
||||
final birthPlaceValue = birthPlace ?? '';
|
||||
final registration = registrationAddress ?? '';
|
||||
final residence = residenceAddress ?? '';
|
||||
final citizenshipValue = citizenship ?? '';
|
||||
|
||||
return last.trim().isNotEmpty &&
|
||||
first.trim().isNotEmpty &&
|
||||
RegExp(r'^\d{12}$').hasMatch(innValue) &&
|
||||
RegExp(r'^\d{3}-\d{3}-\d{3} \d{2}$').hasMatch(snilsValue) &&
|
||||
RegExp(r'^\d{4}$').hasMatch(series) &&
|
||||
RegExp(r'^\d{6}$').hasMatch(number) &&
|
||||
RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(issueDate) &&
|
||||
issuer.trim().isNotEmpty &&
|
||||
RegExp(r'^\d{3}-\d{3}$').hasMatch(issuerCode) &&
|
||||
RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(birth) &&
|
||||
birthPlaceValue.trim().isNotEmpty &&
|
||||
registration.trim().isNotEmpty &&
|
||||
residence.trim().isNotEmpty &&
|
||||
RegExp(r'^[A-Za-z]{2}$').hasMatch(citizenshipValue);
|
||||
}
|
||||
bool get hasValidPassportData => PassportDataValidator.validate(this).isEmpty;
|
||||
}
|
||||
|
||||
@freezed
|
||||
|
||||
@@ -12,6 +12,7 @@ import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/utils/laravel_validation_errors.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/utils/passport_data_validator.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
||||
@@ -240,6 +241,9 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
||||
bool? acceptOffer,
|
||||
bool? acceptFrdoAgreement,
|
||||
]) {
|
||||
final nextFieldErrors = state.fieldErrors.isEmpty
|
||||
? const <String, String>{}
|
||||
: PassportDataValidator.validate(consultant);
|
||||
emit(
|
||||
state.copyWith(
|
||||
consultant: consultant,
|
||||
@@ -247,11 +251,17 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
||||
acceptFrdoAgreement: acceptFrdoAgreement ?? state.acceptFrdoAgreement,
|
||||
loading: false,
|
||||
error: null,
|
||||
fieldErrors: const <String, String>{},
|
||||
fieldErrors: nextFieldErrors,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, String> validatePassportFields() {
|
||||
final errors = PassportDataValidator.validate(state.consultant);
|
||||
emit(state.copyWith(fieldErrors: errors, error: null));
|
||||
return errors;
|
||||
}
|
||||
|
||||
void updateAgreements({bool? acceptOffer, bool? acceptFrdoAgreement}) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
@@ -322,8 +332,8 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
||||
}
|
||||
|
||||
Future<void> registerConsultant() async {
|
||||
if (!state.consultant.hasValidPassportData) {
|
||||
emit(state.copyWith(error: 'Заполните паспортные данные'));
|
||||
final passportErrors = validatePassportFields();
|
||||
if (passportErrors.isNotEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -401,6 +411,10 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
||||
if (!state.alfaScoringStatus.canRetry || state.scoringRetryInProgress) {
|
||||
return;
|
||||
}
|
||||
final passportErrors = validatePassportFields();
|
||||
if (passportErrors.isNotEmpty) {
|
||||
return;
|
||||
}
|
||||
emit(
|
||||
state.copyWith(
|
||||
scoringRetryInProgress: true,
|
||||
@@ -413,18 +427,6 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
||||
);
|
||||
_update = true;
|
||||
try {
|
||||
if (!state.consultant.hasValidPassportData) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
scoringRetryInProgress: false,
|
||||
isScoringPolling: false,
|
||||
awaitingScoringOutcome: false,
|
||||
alfaScoringStatus: AlfaScoringStatus.scoringFailed,
|
||||
error: 'Заполните паспортные данные',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await consultantRepository.registerConsultant(
|
||||
consultant: buildSubmitPayload(),
|
||||
update: true,
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:eatvio_app/features/consultant/become_consultant/presentation/bl
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/widgets/passport_field_with_error.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/widgets/passport_section_title.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_typeahead/flutter_typeahead.dart';
|
||||
import 'package:mask_text_input_formatter/mask_text_input_formatter.dart';
|
||||
@@ -68,6 +69,9 @@ class _PassportDataScreenState extends State<PassportDataScreen> {
|
||||
filter: {'#': RegExp(r'[0-9]')},
|
||||
type: MaskAutoCompletionType.lazy,
|
||||
);
|
||||
final _alfaPersonNameFormatter = FilteringTextInputFormatter.allow(
|
||||
RegExp(r'[a-zA-Zа-яА-ЯёЁ ,.-]'),
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -300,6 +304,8 @@ class _PassportDataScreenState extends State<PassportDataScreen> {
|
||||
child: CustomTextFormField(
|
||||
hintText: 'Фамилия',
|
||||
controller: lastNameController,
|
||||
maxLength: 50,
|
||||
inputFormatters: [_alfaPersonNameFormatter],
|
||||
hasError: errors.containsKey('last_name'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
@@ -310,6 +316,8 @@ class _PassportDataScreenState extends State<PassportDataScreen> {
|
||||
child: CustomTextFormField(
|
||||
hintText: 'Имя',
|
||||
controller: firstNameController,
|
||||
maxLength: 50,
|
||||
inputFormatters: [_alfaPersonNameFormatter],
|
||||
hasError: errors.containsKey('first_name'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
@@ -320,6 +328,8 @@ class _PassportDataScreenState extends State<PassportDataScreen> {
|
||||
child: CustomTextFormField(
|
||||
hintText: 'Отчество',
|
||||
controller: middleNameController,
|
||||
maxLength: 50,
|
||||
inputFormatters: [_alfaPersonNameFormatter],
|
||||
hasError: errors.containsKey('middle_name'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
@@ -332,6 +342,7 @@ class _PassportDataScreenState extends State<PassportDataScreen> {
|
||||
controller: innController,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 12,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
hasError: errors.containsKey('inn'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
@@ -446,6 +457,9 @@ class _PassportDataScreenState extends State<PassportDataScreen> {
|
||||
controller: seriesController,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 4,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
],
|
||||
hasError: errors.containsKey('passport_series'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
@@ -460,6 +474,9 @@ class _PassportDataScreenState extends State<PassportDataScreen> {
|
||||
controller: numberController,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 6,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
],
|
||||
hasError: errors.containsKey('passport_number'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
|
||||
@@ -146,212 +146,230 @@ class _StepperBecomeConsultantScreenState
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<RegisterConsultantCubit>(
|
||||
create: (context) => registrationConsultantCubit,
|
||||
child: BlocConsumer<RegisterConsultantCubit, RegisterConsultantState>(
|
||||
child: BlocListener<RegisterConsultantCubit, RegisterConsultantState>(
|
||||
listenWhen: (previous, current) =>
|
||||
previous.awaitingScoringOutcome &&
|
||||
!current.awaitingScoringOutcome &&
|
||||
current.alfaScoringStatus.isComplete,
|
||||
previous.fieldErrors.isEmpty &&
|
||||
current.fieldErrors.isNotEmpty &&
|
||||
current.error == null,
|
||||
listener: (context, state) {
|
||||
_showSuccessSheet(context);
|
||||
pageController.jumpToPage(5);
|
||||
},
|
||||
builder: (context, state) {
|
||||
if (state.loading) {
|
||||
return const Scaffold(body: Loader());
|
||||
}
|
||||
if (state.error != null &&
|
||||
state.requestModel == null &&
|
||||
widget.slug != null &&
|
||||
state.alfaScoringStatus == AlfaScoringStatus.none) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
centerTitle: true,
|
||||
leading: BackButton(
|
||||
onPressed: () {
|
||||
if (context.canPop()) {
|
||||
GoRouter.of(context).pop();
|
||||
return;
|
||||
}
|
||||
GoRouter.of(context).go('/feed-screen');
|
||||
},
|
||||
),
|
||||
title: Text(
|
||||
'',
|
||||
maxLines: 2,
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
softWrap: true,
|
||||
style: TextStyles.headingXLSemibold.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
body: DataErrorWidget(
|
||||
onReload: () => context
|
||||
.read<RegisterConsultantCubit>()
|
||||
.reloadData(widget.slug),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (progressValue == 0) {
|
||||
progressValue = 1 / totalSteps;
|
||||
}
|
||||
|
||||
final submitDisabled =
|
||||
isLoading ||
|
||||
state.isScoringPolling ||
|
||||
state.scoringRetryInProgress;
|
||||
|
||||
return Scaffold(
|
||||
floatingActionButtonLocation:
|
||||
FloatingActionButtonLocation.centerFloat,
|
||||
floatingActionButton: Row(
|
||||
children: [
|
||||
Container(
|
||||
height: 48,
|
||||
width: MediaQuery.of(context).size.width * 0.2,
|
||||
padding: const EdgeInsets.only(left: 16, right: 4),
|
||||
child: FloatingActionButton.extended(
|
||||
heroTag: 'backButtonRegistration',
|
||||
highlightElevation: 0,
|
||||
focusElevation: 0,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
backgroundColor: const Color(0xFFEAF2FD),
|
||||
onPressed: () async {
|
||||
setState(() {
|
||||
pageController.previousPage(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
});
|
||||
child: BlocConsumer<RegisterConsultantCubit, RegisterConsultantState>(
|
||||
listenWhen: (previous, current) =>
|
||||
previous.awaitingScoringOutcome &&
|
||||
!current.awaitingScoringOutcome &&
|
||||
current.alfaScoringStatus.isComplete,
|
||||
listener: (context, state) {
|
||||
_showSuccessSheet(context);
|
||||
},
|
||||
builder: (context, state) {
|
||||
if (state.loading) {
|
||||
return const Scaffold(body: Loader());
|
||||
}
|
||||
if (state.error != null &&
|
||||
state.requestModel == null &&
|
||||
widget.slug != null &&
|
||||
state.alfaScoringStatus == AlfaScoringStatus.none) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
centerTitle: true,
|
||||
leading: BackButton(
|
||||
onPressed: () {
|
||||
if (context.canPop()) {
|
||||
GoRouter.of(context).pop();
|
||||
return;
|
||||
}
|
||||
GoRouter.of(context).go('/feed-screen');
|
||||
},
|
||||
label: Icon(
|
||||
Icons.arrow_back_ios_new_rounded,
|
||||
color: appTheme.blueA400,
|
||||
),
|
||||
title: Text(
|
||||
'',
|
||||
maxLines: 2,
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
softWrap: true,
|
||||
style: TextStyles.headingXLSemibold.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.isCanceled == true && step != totalSteps)
|
||||
InkWell(
|
||||
onTap: () {
|
||||
final canceledModeration =
|
||||
state.requestModel?.canceledModeration;
|
||||
if (canceledModeration == null) return;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(canceledModeration.title),
|
||||
content: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (canceledModeration.additional != null)
|
||||
for (final item
|
||||
in canceledModeration.additional!) ...[
|
||||
Text('• $item'),
|
||||
],
|
||||
body: DataErrorWidget(
|
||||
onReload: () => context
|
||||
.read<RegisterConsultantCubit>()
|
||||
.reloadData(widget.slug),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (progressValue == 0) {
|
||||
progressValue = 1 / totalSteps;
|
||||
}
|
||||
|
||||
final submitDisabled =
|
||||
isLoading ||
|
||||
state.isScoringPolling ||
|
||||
state.scoringRetryInProgress;
|
||||
|
||||
return Scaffold(
|
||||
floatingActionButtonLocation:
|
||||
FloatingActionButtonLocation.centerFloat,
|
||||
floatingActionButton: Row(
|
||||
children: [
|
||||
Container(
|
||||
height: 48,
|
||||
width: MediaQuery.of(context).size.width * 0.2,
|
||||
padding: const EdgeInsets.only(left: 16, right: 4),
|
||||
child: FloatingActionButton.extended(
|
||||
heroTag: 'backButtonRegistration',
|
||||
highlightElevation: 0,
|
||||
focusElevation: 0,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
backgroundColor: const Color(0xFFEAF2FD),
|
||||
onPressed: () async {
|
||||
setState(() {
|
||||
pageController.previousPage(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
});
|
||||
},
|
||||
label: Icon(
|
||||
Icons.arrow_back_ios_new_rounded,
|
||||
color: appTheme.blueA400,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.isCanceled == true && step != totalSteps)
|
||||
InkWell(
|
||||
onTap: () {
|
||||
final canceledModeration =
|
||||
state.requestModel?.canceledModeration;
|
||||
if (canceledModeration == null) return;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(canceledModeration.title),
|
||||
content: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (canceledModeration.additional != null)
|
||||
for (final item
|
||||
in canceledModeration.additional!) ...[
|
||||
Text('• $item'),
|
||||
],
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Ink(
|
||||
height: 48,
|
||||
width: MediaQuery.of(context).size.width * 0.6,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
color: const Color(0x33FFCC00),
|
||||
),
|
||||
);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Ink(
|
||||
height: 48,
|
||||
width: MediaQuery.of(context).size.width * 0.6,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
color: const Color(0x33FFCC00),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'Комментарий',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF323F4B),
|
||||
height: 1,
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'Комментарий',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF323F4B),
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
height: 48,
|
||||
width: widget.isCanceled == true && step != totalSteps
|
||||
? MediaQuery.of(context).size.width * 0.2
|
||||
: MediaQuery.of(context).size.width * 0.8,
|
||||
padding: const EdgeInsets.only(left: 4, right: 16),
|
||||
child: FloatingActionButton.extended(
|
||||
heroTag: 'nextButtonRegistration',
|
||||
highlightElevation: 0,
|
||||
elevation: 0,
|
||||
focusElevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
backgroundColor: submitDisabled
|
||||
? Colors.grey
|
||||
: widget.isCanceled == true && step != totalSteps
|
||||
? const Color(0xFFEAF2FD)
|
||||
: appTheme.blueA400,
|
||||
onPressed: submitDisabled
|
||||
? null
|
||||
: () async {
|
||||
if (step == totalSteps) {
|
||||
if (!state.consultant.hasValidPassportData) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
actionsPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
),
|
||||
content: const Text(
|
||||
'Заполните паспортные данные',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: const Text('Ок'),
|
||||
onPressed: () =>
|
||||
Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (registrationConsultantCubit
|
||||
.hasAcceptedRequiredAgreements) {
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
});
|
||||
try {
|
||||
await registrationConsultantCubit
|
||||
.registerConsultant();
|
||||
if (!context.mounted) return;
|
||||
if (registrationConsultantCubit.state.error !=
|
||||
null) {
|
||||
showDialog(
|
||||
Container(
|
||||
height: 48,
|
||||
width: widget.isCanceled == true && step != totalSteps
|
||||
? MediaQuery.of(context).size.width * 0.2
|
||||
: MediaQuery.of(context).size.width * 0.8,
|
||||
padding: const EdgeInsets.only(left: 4, right: 16),
|
||||
child: FloatingActionButton.extended(
|
||||
heroTag: 'nextButtonRegistration',
|
||||
highlightElevation: 0,
|
||||
elevation: 0,
|
||||
focusElevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
backgroundColor: submitDisabled
|
||||
? Colors.grey
|
||||
: widget.isCanceled == true && step != totalSteps
|
||||
? const Color(0xFFEAF2FD)
|
||||
: appTheme.blueA400,
|
||||
onPressed: submitDisabled
|
||||
? null
|
||||
: () async {
|
||||
if (step == totalSteps) {
|
||||
final passportErrors =
|
||||
registrationConsultantCubit
|
||||
.validatePassportFields();
|
||||
if (passportErrors.isNotEmpty) {
|
||||
pageController.jumpToPage(5);
|
||||
return;
|
||||
}
|
||||
if (registrationConsultantCubit
|
||||
.hasAcceptedRequiredAgreements) {
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
});
|
||||
try {
|
||||
await registrationConsultantCubit
|
||||
.registerConsultant();
|
||||
if (!context.mounted) return;
|
||||
if (registrationConsultantCubit
|
||||
.state
|
||||
.error !=
|
||||
null) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
actionsPadding:
|
||||
const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
),
|
||||
content: Text(
|
||||
registrationConsultantCubit
|
||||
.state
|
||||
.error!,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: const Text('Ок'),
|
||||
onPressed: () =>
|
||||
Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Success sheet is shown by BlocListener when scoring completes.
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
actionsPadding:
|
||||
const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
),
|
||||
content: Text(
|
||||
registrationConsultantCubit
|
||||
.state
|
||||
.error!,
|
||||
),
|
||||
content: Text(e.toString()),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: const Text('Ок'),
|
||||
@@ -361,140 +379,120 @@ class _StepperBecomeConsultantScreenState
|
||||
],
|
||||
),
|
||||
);
|
||||
return;
|
||||
} finally {
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
}
|
||||
// Success sheet is shown by BlocListener when scoring completes.
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
showDialog<void>(
|
||||
} else {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
actionsPadding:
|
||||
const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
),
|
||||
content: Text(e.toString()),
|
||||
content: Text(
|
||||
state.isUpdating
|
||||
? 'Примите договор публичной оферты'
|
||||
: 'Примите оферту и согласие ФРДО',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: const Text('Ок'),
|
||||
onPressed: () =>
|
||||
Navigator.of(context).pop(),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
actionsPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
),
|
||||
content: Text(
|
||||
state.isUpdating
|
||||
? 'Примите договор публичной оферты'
|
||||
: 'Примите оферту и согласие ФРДО',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: const Text('Ок'),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
setState(() {
|
||||
pageController.nextPage(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
setState(() {
|
||||
pageController.nextPage(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
label: submitDisabled && step == totalSteps
|
||||
? const CircularProgressIndicator()
|
||||
: widget.isCanceled == true && step != totalSteps
|
||||
? Icon(
|
||||
Icons.arrow_forward_ios_outlined,
|
||||
color: appTheme.blueA400,
|
||||
)
|
||||
: Text(
|
||||
step == totalSteps ? 'Отправить' : 'Продолжить',
|
||||
style: theme.textTheme.titleSmall!.copyWith(
|
||||
color: Colors.white,
|
||||
},
|
||||
label: submitDisabled && step == totalSteps
|
||||
? const CircularProgressIndicator()
|
||||
: widget.isCanceled == true && step != totalSteps
|
||||
? Icon(
|
||||
Icons.arrow_forward_ios_outlined,
|
||||
color: appTheme.blueA400,
|
||||
)
|
||||
: Text(
|
||||
step == totalSteps ? 'Отправить' : 'Продолжить',
|
||||
style: theme.textTheme.titleSmall!.copyWith(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
leading: Builder(
|
||||
builder: (context) => IconButton(
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
onPressed: () => GoRouter.of(context).pop(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
leading: Builder(
|
||||
builder: (context) => IconButton(
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
onPressed: () => GoRouter.of(context).pop(),
|
||||
centerTitle: true,
|
||||
title: SvgPicture.asset(
|
||||
ImageConstant.imgLogoEatvio,
|
||||
height: 48,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
title: SvgPicture.asset(
|
||||
ImageConstant.imgLogoEatvio,
|
||||
height: 48,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
child: Text('шаг $step из $totalSteps'),
|
||||
),
|
||||
],
|
||||
bottom: PreferredSize(
|
||||
preferredSize: Size(MediaQuery.of(context).size.width, 32),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: TweenAnimationBuilder<double>(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
tween: Tween<double>(
|
||||
begin: 1 / totalSteps,
|
||||
end: progressValue,
|
||||
),
|
||||
builder: (context, value, _) => LinearProgressIndicator(
|
||||
value: value,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
minHeight: 8,
|
||||
backgroundColor: Colors.grey.shade100,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
appTheme.blueA400,
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
child: Text('шаг $step из $totalSteps'),
|
||||
),
|
||||
],
|
||||
bottom: PreferredSize(
|
||||
preferredSize: Size(MediaQuery.of(context).size.width, 32),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: TweenAnimationBuilder<double>(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
tween: Tween<double>(
|
||||
begin: 1 / totalSteps,
|
||||
end: progressValue,
|
||||
),
|
||||
builder: (context, value, _) => LinearProgressIndicator(
|
||||
value: value,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
minHeight: 8,
|
||||
backgroundColor: Colors.grey.shade100,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
appTheme.blueA400,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
body: PageView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
controller: pageController,
|
||||
onPageChanged: (index) {
|
||||
setState(() {
|
||||
step = index + 1;
|
||||
progressValue = step / totalSteps;
|
||||
});
|
||||
},
|
||||
children: _pages(),
|
||||
),
|
||||
);
|
||||
},
|
||||
body: PageView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
controller: pageController,
|
||||
onPageChanged: (index) {
|
||||
setState(() {
|
||||
step = index + 1;
|
||||
progressValue = step / totalSteps;
|
||||
});
|
||||
},
|
||||
children: _pages(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,8 @@ class LaravelValidationErrors {
|
||||
}
|
||||
|
||||
const _genericValidationSummary = 'Проверьте введённые данные';
|
||||
const _genericNetworkSummary = 'Не удалось отправить данные. Попробуйте ещё раз';
|
||||
const _genericNetworkSummary =
|
||||
'Не удалось отправить данные. Попробуйте ещё раз';
|
||||
|
||||
/// Parses Laravel `errors` map and maps them to user-facing Russian texts.
|
||||
/// Raw backend messages are never shown in the UI.
|
||||
@@ -85,8 +86,7 @@ String mapConsultantValidationMessage({
|
||||
raw.contains('required') ||
|
||||
raw.contains('обязательн') ||
|
||||
raw.contains('required_if');
|
||||
final isAccepted =
|
||||
raw.contains('accepted') || field.contains('agreement');
|
||||
final isAccepted = raw.contains('accepted') || field.contains('agreement');
|
||||
final isUrl = raw.contains('url') || field.contains('link');
|
||||
final isFormat =
|
||||
raw.contains('format') ||
|
||||
@@ -154,6 +154,9 @@ String mapConsultantValidationMessage({
|
||||
'passport_number' => 'Некорректный номер паспорта',
|
||||
'passport_issue_date' => 'Некорректная дата выдачи',
|
||||
'passport_issuer_code' => 'Некорректный код подразделения',
|
||||
'last_name' => 'Некорректная фамилия',
|
||||
'first_name' => 'Некорректное имя',
|
||||
'middle_name' => 'Некорректное отчество',
|
||||
'birth_date' => 'Некорректная дата рождения',
|
||||
'citizenship' => 'Некорректный код гражданства',
|
||||
'sbp_phone' => 'Некорректный телефон СБП',
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
|
||||
|
||||
/// Client-side validation for individual-person passport fields.
|
||||
///
|
||||
/// Messages match [mapConsultantValidationMessage] so 422 and local errors
|
||||
/// look the same in the UI. Dates also reject non-calendar values and
|
||||
/// dates after today.
|
||||
abstract final class PassportDataValidator {
|
||||
static const futureDateMessage = 'Дата не может быть в будущем';
|
||||
|
||||
/// Alfa Bank `personalInformation.{last,first,middle}Name` pattern.
|
||||
static final alfaPersonNamePattern = RegExp(r'^[a-zA-Zа-яА-ЯёЁ ,.-]{1,50}$');
|
||||
|
||||
static Map<String, String> validate(
|
||||
ConsultantProfileModel consultant, {
|
||||
DateTime? now,
|
||||
}) {
|
||||
final today = now ?? DateTime.now();
|
||||
final errors = <String, String>{};
|
||||
|
||||
_requirePattern(
|
||||
errors,
|
||||
key: 'last_name',
|
||||
value: consultant.lastName,
|
||||
pattern: alfaPersonNamePattern,
|
||||
emptyMessage: 'Укажите фамилию',
|
||||
invalidMessage: 'Некорректная фамилия',
|
||||
);
|
||||
_requirePattern(
|
||||
errors,
|
||||
key: 'first_name',
|
||||
value: consultant.firstName,
|
||||
pattern: alfaPersonNamePattern,
|
||||
emptyMessage: 'Укажите имя',
|
||||
invalidMessage: 'Некорректное имя',
|
||||
);
|
||||
_optionalPattern(
|
||||
errors,
|
||||
key: 'middle_name',
|
||||
value: consultant.middleName,
|
||||
pattern: alfaPersonNamePattern,
|
||||
invalidMessage: 'Некорректное отчество',
|
||||
);
|
||||
_requirePattern(
|
||||
errors,
|
||||
key: 'inn',
|
||||
value: consultant.inn,
|
||||
pattern: RegExp(r'^\d{12}$'),
|
||||
emptyMessage: 'Укажите ИНН',
|
||||
invalidMessage: 'Некорректный ИНН',
|
||||
);
|
||||
_requirePattern(
|
||||
errors,
|
||||
key: 'snils',
|
||||
value: consultant.snils,
|
||||
pattern: RegExp(r'^\d{3}-\d{3}-\d{3} \d{2}$'),
|
||||
emptyMessage: 'Укажите СНИЛС',
|
||||
invalidMessage: 'Некорректный СНИЛС',
|
||||
);
|
||||
_requirePattern(
|
||||
errors,
|
||||
key: 'passport_series',
|
||||
value: consultant.passportSeries,
|
||||
pattern: RegExp(r'^\d{4}$'),
|
||||
emptyMessage: 'Укажите серию паспорта',
|
||||
invalidMessage: 'Некорректная серия паспорта',
|
||||
);
|
||||
_requirePattern(
|
||||
errors,
|
||||
key: 'passport_number',
|
||||
value: consultant.passportNumber,
|
||||
pattern: RegExp(r'^\d{6}$'),
|
||||
emptyMessage: 'Укажите номер паспорта',
|
||||
invalidMessage: 'Некорректный номер паспорта',
|
||||
);
|
||||
_requireText(
|
||||
errors,
|
||||
key: 'passport_issuer',
|
||||
value: consultant.passportIssuer,
|
||||
emptyMessage: 'Укажите, кем выдан паспорт',
|
||||
);
|
||||
_requirePattern(
|
||||
errors,
|
||||
key: 'passport_issuer_code',
|
||||
value: consultant.passportIssuerCode,
|
||||
pattern: RegExp(r'^\d{3}-\d{3}$'),
|
||||
emptyMessage: 'Укажите код подразделения',
|
||||
invalidMessage: 'Некорректный код подразделения',
|
||||
);
|
||||
_requireDate(
|
||||
errors,
|
||||
key: 'passport_issue_date',
|
||||
value: consultant.passportIssueDate,
|
||||
emptyMessage: 'Укажите дату выдачи паспорта',
|
||||
invalidMessage: 'Некорректная дата выдачи',
|
||||
today: today,
|
||||
);
|
||||
_requireDate(
|
||||
errors,
|
||||
key: 'birth_date',
|
||||
value: consultant.birthDate,
|
||||
emptyMessage: 'Укажите дату рождения',
|
||||
invalidMessage: 'Некорректная дата рождения',
|
||||
today: today,
|
||||
);
|
||||
_requireText(
|
||||
errors,
|
||||
key: 'birth_place',
|
||||
value: consultant.birthPlace,
|
||||
emptyMessage: 'Укажите место рождения',
|
||||
);
|
||||
_requireText(
|
||||
errors,
|
||||
key: 'registration_address',
|
||||
value: consultant.registrationAddress,
|
||||
emptyMessage: 'Укажите адрес регистрации',
|
||||
);
|
||||
_requireText(
|
||||
errors,
|
||||
key: 'residence_address',
|
||||
value: consultant.residenceAddress,
|
||||
emptyMessage: 'Укажите адрес проживания',
|
||||
);
|
||||
_requirePattern(
|
||||
errors,
|
||||
key: 'citizenship',
|
||||
value: consultant.citizenship,
|
||||
pattern: RegExp(r'^[A-Za-z]{2}$'),
|
||||
emptyMessage: 'Укажите гражданство',
|
||||
invalidMessage: 'Некорректный код гражданства',
|
||||
);
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
static void _requireText(
|
||||
Map<String, String> errors, {
|
||||
required String key,
|
||||
required String? value,
|
||||
required String emptyMessage,
|
||||
}) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
errors[key] = emptyMessage;
|
||||
}
|
||||
}
|
||||
|
||||
static void _requirePattern(
|
||||
Map<String, String> errors, {
|
||||
required String key,
|
||||
required String? value,
|
||||
required RegExp pattern,
|
||||
required String emptyMessage,
|
||||
required String invalidMessage,
|
||||
}) {
|
||||
final raw = value?.trim() ?? '';
|
||||
if (raw.isEmpty) {
|
||||
errors[key] = emptyMessage;
|
||||
return;
|
||||
}
|
||||
if (!pattern.hasMatch(raw)) {
|
||||
errors[key] = invalidMessage;
|
||||
}
|
||||
}
|
||||
|
||||
static void _optionalPattern(
|
||||
Map<String, String> errors, {
|
||||
required String key,
|
||||
required String? value,
|
||||
required RegExp pattern,
|
||||
required String invalidMessage,
|
||||
}) {
|
||||
final raw = value?.trim() ?? '';
|
||||
if (raw.isEmpty) {
|
||||
return;
|
||||
}
|
||||
if (!pattern.hasMatch(raw)) {
|
||||
errors[key] = invalidMessage;
|
||||
}
|
||||
}
|
||||
|
||||
static void _requireDate(
|
||||
Map<String, String> errors, {
|
||||
required String key,
|
||||
required String? value,
|
||||
required String emptyMessage,
|
||||
required String invalidMessage,
|
||||
required DateTime today,
|
||||
}) {
|
||||
final raw = value?.trim() ?? '';
|
||||
if (raw.isEmpty) {
|
||||
errors[key] = emptyMessage;
|
||||
return;
|
||||
}
|
||||
|
||||
final match = RegExp(r'^(\d{4})-(\d{2})-(\d{2})$').firstMatch(raw);
|
||||
if (match == null) {
|
||||
errors[key] = invalidMessage;
|
||||
return;
|
||||
}
|
||||
|
||||
final year = int.parse(match.group(1)!);
|
||||
final month = int.parse(match.group(2)!);
|
||||
final day = int.parse(match.group(3)!);
|
||||
final parsed = DateTime(year, month, day);
|
||||
if (parsed.year != year || parsed.month != month || parsed.day != day) {
|
||||
errors[key] = invalidMessage;
|
||||
return;
|
||||
}
|
||||
|
||||
final todayDate = DateTime(today.year, today.month, today.day);
|
||||
if (parsed.isAfter(todayDate)) {
|
||||
errors[key] = futureDateMessage;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,43 +59,47 @@ class _AddMealWidgetState extends State<AddMealWidget> {
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: MediaQuery.of(context).size.width * 0.8 - 32,
|
||||
child: CustomTextFormField(
|
||||
controller: productNameController,
|
||||
autofocus: true,
|
||||
hintText: 'Введите название продукта/блюда',
|
||||
onChanged: (query) {
|
||||
EasyDebounce.debounce('glossary-search', const Duration(milliseconds: 250), () async {
|
||||
selectedGlossary = null;
|
||||
if (query.isEmpty) {
|
||||
glossariesList = [];
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: SizedBox(
|
||||
child: CustomTextFormField(
|
||||
controller: productNameController,
|
||||
autofocus: true,
|
||||
hintText: 'Введите название продукта/блюда',
|
||||
onChanged: (query) {
|
||||
EasyDebounce.debounce('glossary-search', const Duration(milliseconds: 250), () async {
|
||||
selectedGlossary = null;
|
||||
if (query.isEmpty) {
|
||||
glossariesList = [];
|
||||
selectedGlossary = null;
|
||||
setState(() {});
|
||||
return;
|
||||
}
|
||||
glossariesList = await widget.createDietCubit.getGlossaryListByQuery(query);
|
||||
setState(() {});
|
||||
return;
|
||||
}
|
||||
glossariesList = await widget.createDietCubit.getGlossaryListByQuery(query);
|
||||
setState(() {});
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
SizedBox(
|
||||
width: MediaQuery.of(context).size.width * 0.2 - 4,
|
||||
child: CustomTextFormField(
|
||||
controller: quantityController,
|
||||
keyboardType: TextInputType.number,
|
||||
hintText: selectedGlossary == null
|
||||
? '100'
|
||||
: selectedGlossary!.grInPortion == 0
|
||||
? '100'
|
||||
: '1',
|
||||
suffixText: selectedGlossary == null
|
||||
? 'гр'
|
||||
: selectedGlossary?.grInPortion == 0
|
||||
? 'гр'
|
||||
: 'шт',
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: SizedBox(
|
||||
child: CustomTextFormField(
|
||||
controller: quantityController,
|
||||
keyboardType: TextInputType.number,
|
||||
hintText: selectedGlossary == null
|
||||
? '100'
|
||||
: selectedGlossary!.grInPortion == 0
|
||||
? '100'
|
||||
: '1',
|
||||
suffixText: selectedGlossary == null
|
||||
? 'гр'
|
||||
: selectedGlossary?.grInPortion == 0
|
||||
? 'гр'
|
||||
: 'шт',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -149,17 +153,19 @@ class _AddMealWidgetState extends State<AddMealWidget> {
|
||||
),
|
||||
),
|
||||
if (widget.portionIndex != null) const SizedBox(width: 8),
|
||||
InkWell(
|
||||
onTap: addPortion,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Ink(
|
||||
width: MediaQuery.of(context).size.width - (widget.portionIndex == null ? 32 : 92),
|
||||
height: 52,
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
|
||||
child: Center(
|
||||
child: Text(
|
||||
widget.portionIndex == null ? 'Добавить' : 'Сохранить',
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white),
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
onTap: addPortion,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Ink(
|
||||
width: MediaQuery.of(context).size.width - (widget.portionIndex == null ? 32 : 92),
|
||||
height: 52,
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
|
||||
child: Center(
|
||||
child: Text(
|
||||
widget.portionIndex == null ? 'Добавить' : 'Сохранить',
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:eatvio_app/core/widgets/success_published_diet_bottom_sheet.dart
|
||||
import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart';
|
||||
import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_cubit.dart';
|
||||
import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_state.dart';
|
||||
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/create_plan/presentation/widgets/option_widget.dart';
|
||||
import 'package:eatvio_app/features/create_plan/presentation/widgets/options_count_button.dart';
|
||||
import 'package:eatvio_app/features/create_plan/presentation/widgets/plan_duration_drop_down_widget.dart';
|
||||
@@ -25,11 +26,23 @@ import 'package:go_router/go_router.dart';
|
||||
class CreatePlanScreen extends StatelessWidget {
|
||||
static const name = 'createPlanScreen';
|
||||
static const path = '/create-Plan-screen';
|
||||
const CreatePlanScreen({super.key});
|
||||
const CreatePlanScreen({super.key, this.plan});
|
||||
|
||||
final PlanModel? plan;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(create: (context) => sl<CreatePlanCubit>(), child: const CreatePlanScreenContent());
|
||||
return BlocProvider(
|
||||
create: (context) {
|
||||
final cubit = sl<CreatePlanCubit>();
|
||||
final existingPlan = plan;
|
||||
if (existingPlan != null) {
|
||||
cubit.loadExisting(existingPlan);
|
||||
}
|
||||
return cubit;
|
||||
},
|
||||
child: const CreatePlanScreenContent(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,17 +60,19 @@ class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
final isEditing = context.read<CreatePlanCubit>().sourcePlan != null;
|
||||
return BlocBuilder<CreatePlanCubit, CreatePlanState>(
|
||||
buildWhen: (previous, current) => previous.planCreated != current.planCreated || previous.isLoading != current.isLoading,
|
||||
builder: (context, state) {
|
||||
if (state.planCreated) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!context.mounted) return;
|
||||
context.pop();
|
||||
final isEditing = context.read<CreatePlanCubit>().sourcePlan != null;
|
||||
context.pop(true);
|
||||
showBottomSheetWithBlur(
|
||||
userRootNavigator: true,
|
||||
context: context,
|
||||
child: SuccessPublishedDietOrPlanBottomSheet(title: state.planData.name ?? 'План', isPlan: true),
|
||||
child: SuccessPublishedDietOrPlanBottomSheet(title: state.planData.name ?? 'План', isPlan: true, isUpdated: isEditing),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -68,7 +83,7 @@ class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
|
||||
appBar: AppBar(
|
||||
centerTitle: true,
|
||||
title: Text(
|
||||
s.adding_plan,
|
||||
isEditing ? 'Редактирование плана' : s.adding_plan,
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
softWrap: true,
|
||||
@@ -102,7 +117,9 @@ class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
|
||||
return Column(
|
||||
children: [
|
||||
CustomTextFormField(
|
||||
key: const ValueKey('plan-name'),
|
||||
hintText: s.plan_label_desc,
|
||||
initialValue: state.planData.name,
|
||||
hasError: state.validationState.titleHasError,
|
||||
onChanged: (value) {
|
||||
context.read<CreatePlanCubit>().setName(value);
|
||||
@@ -144,6 +161,7 @@ class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
|
||||
children: [
|
||||
MarkdownEditor(
|
||||
key: _mdKey,
|
||||
initialText: state.planData.details,
|
||||
hintText: s.plan_desc_example,
|
||||
hasError: state.validationState.descriptionHasError,
|
||||
minLines: 6,
|
||||
@@ -230,7 +248,7 @@ class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
|
||||
onPressed: () async {
|
||||
await context.read<CreatePlanCubit>().publishPlan();
|
||||
},
|
||||
text: s.publish,
|
||||
text: isEditing ? 'Обновить' : s.publish,
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(
|
||||
|
||||
@@ -4,16 +4,57 @@ import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/const.dart';
|
||||
import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart';
|
||||
import 'package:eatvio_app/core/utils/toasts.dart';
|
||||
import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_state.dart';
|
||||
import 'package:eatvio_app/features/create_plan/repositories/create_plan_repository.dart';
|
||||
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||
|
||||
class CreatePlanCubit extends Cubit<CreatePlanState> {
|
||||
final CreatePlanRepository createPlanRepository;
|
||||
PlanModel? sourcePlan;
|
||||
|
||||
CreatePlanCubit({required this.createPlanRepository}) : super(CreatePlanState.initial());
|
||||
|
||||
void loadExisting(PlanModel plan) {
|
||||
sourcePlan = plan;
|
||||
final durationIndex = durationValues.indexOf(plan.duration);
|
||||
emit(
|
||||
state.copyWith(
|
||||
durationIndex: durationIndex >= 0 ? durationIndex : null,
|
||||
selectedOptionIndex: plan.packetsCount == 3 ? 0 : null,
|
||||
planData: state.planData.copyWith(
|
||||
name: plan.name,
|
||||
details: plan.details,
|
||||
duration: plan.duration,
|
||||
state: plan.state,
|
||||
type: [
|
||||
plan.type.slim ?? false,
|
||||
plan.type.fit ?? false,
|
||||
plan.type.wellness ?? false,
|
||||
plan.type.behavior ?? false,
|
||||
],
|
||||
options: [
|
||||
_packetOrEmpty(plan.packet1Options),
|
||||
plan.packetsCount == 3 ? _packetOrEmpty(plan.packet2Options) : PacketOptions.empty(),
|
||||
plan.packetsCount == 3 ? _packetOrEmpty(plan.packet3Options) : PacketOptions.empty(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
PacketOptions _packetOrEmpty(PacketOptions? option) {
|
||||
if (option == null) {
|
||||
return PacketOptions.empty();
|
||||
}
|
||||
final price = option.price;
|
||||
final normalizedPrice = price is num ? price.toDouble() : double.tryParse('$price') ?? 1200;
|
||||
return option.copyWith(price: normalizedPrice);
|
||||
}
|
||||
|
||||
void setDetails(String text) {
|
||||
emit(state.copyWith(planData: state.planData.copyWith(details: text)));
|
||||
validateDetails();
|
||||
@@ -147,7 +188,7 @@ class CreatePlanCubit extends Cubit<CreatePlanState> {
|
||||
}
|
||||
|
||||
bool validateImage() {
|
||||
if (state.planData.image == null) {
|
||||
if (state.planData.image == null && sourcePlan == null) {
|
||||
emit(state.copyWith.validationState(imageHasError: true));
|
||||
return false;
|
||||
}
|
||||
@@ -413,31 +454,34 @@ class CreatePlanCubit extends Cubit<CreatePlanState> {
|
||||
'type[fit]': state.planData.type[1] ? '1' : '0',
|
||||
'type[wellness]': state.planData.type[2] ? '1' : '0',
|
||||
'type[behavior]': state.planData.type[3] ? '1' : '0',
|
||||
'image': state.planData.image != null
|
||||
? await MultipartFile.fromFile((await ImageCompressUtility.compressFile(File(state.planData.image!.path))).path,
|
||||
filename: state.planData.image!.name)
|
||||
: null
|
||||
});
|
||||
if (state.planData.image != null) {
|
||||
final image = state.planData.image!;
|
||||
planMap['image'] = await MultipartFile.fromFile(
|
||||
(await ImageCompressUtility.compressFile(File(image.path))).path,
|
||||
filename: image.name,
|
||||
);
|
||||
}
|
||||
if (state.planData.options[1].short.isNotEmpty) {
|
||||
planMap.addAll({
|
||||
'packets_count': 3,
|
||||
'packet1_options': {
|
||||
'short': state.planData.options[0].short,
|
||||
'price': int.parse((state.planData.options[0].price as double).toStringAsFixed(0)),
|
||||
'price': _optionPrice(state.planData.options[0].price),
|
||||
'description_md': state.planData.options[0].description,
|
||||
'items': Map.fromIterable(state.planData.options[0].items),
|
||||
'has_personal_diet': state.planData.options[0].hasPersonalDiet ? '1' : '0',
|
||||
},
|
||||
'packet2_options': {
|
||||
'short': state.planData.options[1].short,
|
||||
'price': int.parse((state.planData.options[1].price as double).toStringAsFixed(0)),
|
||||
'price': _optionPrice(state.planData.options[1].price),
|
||||
'description_md': state.planData.options[1].description,
|
||||
'items': Map.fromIterable(state.planData.options[1].items),
|
||||
'has_personal_diet': state.planData.options[1].hasPersonalDiet ? '1' : '0',
|
||||
},
|
||||
'packet3_options': {
|
||||
'short': state.planData.options[2].short,
|
||||
'price': int.parse((state.planData.options[2].price as double).toStringAsFixed(0)),
|
||||
'price': _optionPrice(state.planData.options[2].price),
|
||||
'description_md': state.planData.options[2].description,
|
||||
'items': Map.fromIterable(state.planData.options[2].items),
|
||||
'has_personal_diet': state.planData.options[2].hasPersonalDiet ? '1' : '0',
|
||||
@@ -448,20 +492,34 @@ class CreatePlanCubit extends Cubit<CreatePlanState> {
|
||||
'packets_count': 1,
|
||||
'packet1_options': {
|
||||
'short': state.planData.options[0].short,
|
||||
'price': int.parse((state.planData.options[0].price as double).toStringAsFixed(0)),
|
||||
'price': _optionPrice(state.planData.options[0].price),
|
||||
'description_md': state.planData.options[0].description,
|
||||
'items': Map.fromIterable(state.planData.options[0].items),
|
||||
'has_personal_diet': state.planData.options[0].hasPersonalDiet ? '1' : '0',
|
||||
},
|
||||
});
|
||||
}
|
||||
await createPlanRepository.createPlan(model: planMap);
|
||||
final editingPlan = sourcePlan;
|
||||
if (editingPlan == null) {
|
||||
await createPlanRepository.createPlan(model: planMap);
|
||||
} else {
|
||||
await createPlanRepository.updatePublishedPlan(slug: editingPlan.slug, model: planMap);
|
||||
}
|
||||
emit(state.copyWith(isLoading: false, planCreated: true));
|
||||
}
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString());
|
||||
emit(state.copyWith(isLoading: false, planCreated: true));
|
||||
emit(state.copyWith(isLoading: false, planCreated: false));
|
||||
final isForbidden = e is DioException && e.response?.statusCode == 403;
|
||||
showErrorToast(isForbidden ? 'Редактирование недоступно: клиент уже начал проходить план' : 'Не удалось сохранить план');
|
||||
}
|
||||
}
|
||||
|
||||
int _optionPrice(dynamic price) {
|
||||
if (price is num) {
|
||||
return price.round();
|
||||
}
|
||||
return int.parse(price.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,12 @@ class AddItemWidget extends StatefulWidget {
|
||||
class _AddItemWidgetState extends State<AddItemWidget> {
|
||||
final controller = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
@@ -38,14 +44,24 @@ class _AddItemWidgetState extends State<AddItemWidget> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
if (widget.items.length > widget.index && controller.text != widget.items[widget.index]) {
|
||||
if (widget.items.length > widget.index &&
|
||||
controller.text != widget.items[widget.index]) {
|
||||
controller.text = widget.items[widget.index];
|
||||
}
|
||||
return BlocBuilder<CreatePlanCubit, CreatePlanState>(
|
||||
buildWhen: (previous, current) => previous.validationState != current.validationState,
|
||||
buildWhen: (previous, current) =>
|
||||
previous.validationState != current.validationState,
|
||||
builder: (context, state) {
|
||||
final hasError = state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].isItemsEmpty ||
|
||||
(state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].itemsErrors[widget.index] ?? false);
|
||||
final hasError =
|
||||
state
|
||||
.validationState
|
||||
.optionsValidationState[state.selectedOptionIndex ?? 0]
|
||||
.isItemsEmpty ||
|
||||
(state
|
||||
.validationState
|
||||
.optionsValidationState[state.selectedOptionIndex ?? 0]
|
||||
.itemsErrors[widget.index] ??
|
||||
false);
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
@@ -54,9 +70,14 @@ class _AddItemWidgetState extends State<AddItemWidget> {
|
||||
child: CustomTextFormField(
|
||||
controller: controller,
|
||||
hasError: hasError,
|
||||
hintText: widget.enableHint ? s.option_content_hint_text : null,
|
||||
hintText: widget.enableHint
|
||||
? s.option_content_hint_text
|
||||
: null,
|
||||
onChanged: (value) {
|
||||
context.read<CreatePlanCubit>().setOptionContent(value, widget.index);
|
||||
context.read<CreatePlanCubit>().setOptionContent(
|
||||
value,
|
||||
widget.index,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -64,7 +85,9 @@ class _AddItemWidgetState extends State<AddItemWidget> {
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
context.read<CreatePlanCubit>().removeOptionContentItem(widget.index);
|
||||
context.read<CreatePlanCubit>().removeOptionContentItem(
|
||||
widget.index,
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
@@ -72,19 +95,30 @@ class _AddItemWidgetState extends State<AddItemWidget> {
|
||||
color: appTheme.lightGray0,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: SvgPicture.asset('assets/icons/trash_can.svg', width: 20, height: 24),
|
||||
child: SvgPicture.asset(
|
||||
'assets/icons/trash_can.svg',
|
||||
width: 20,
|
||||
height: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].isItemsEmpty) ...[
|
||||
if (state
|
||||
.validationState
|
||||
.optionsValidationState[state.selectedOptionIndex ?? 0]
|
||||
.isItemsEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
ValidationErrorWidget(error: s.addAtLeastOneItem)
|
||||
] else if (state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].itemsErrors[widget.index] ?? false) ...[
|
||||
ValidationErrorWidget(error: s.addAtLeastOneItem),
|
||||
] else if (state
|
||||
.validationState
|
||||
.optionsValidationState[state.selectedOptionIndex ?? 0]
|
||||
.itemsErrors[widget.index] ??
|
||||
false) ...[
|
||||
const SizedBox(height: 4),
|
||||
ValidationErrorWidget(error: s.atLeast3Symbols)
|
||||
]
|
||||
ValidationErrorWidget(error: s.atLeast3Symbols),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widge
|
||||
import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_cubit.dart';
|
||||
import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_state.dart';
|
||||
import 'package:eatvio_app/features/create_plan/presentation/widgets/add_item_widget.dart';
|
||||
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -21,47 +22,107 @@ class OptionWidget extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _OptionWidgetState extends State<OptionWidget> {
|
||||
final firstOptionCustomerPriceController = TextEditingController()..text = '1200₽';
|
||||
final secondOptionCustomerPriceController = TextEditingController()..text = '1200₽';
|
||||
final thirdOptionCustomerPriceController = TextEditingController()..text = '1200₽';
|
||||
static const _optionsCount = 3;
|
||||
|
||||
final creatorPriceController = TextEditingController()..text = '1000₽';
|
||||
|
||||
final firstOptionTitleController = TextEditingController();
|
||||
final secondOptionTitleController = TextEditingController();
|
||||
final thirdOptionTitleController = TextEditingController();
|
||||
|
||||
final firstOptionDescriptionController = TextEditingController();
|
||||
final secondOptionDescriptionController = TextEditingController();
|
||||
final thirdOptionDescriptionController = TextEditingController();
|
||||
final List<TextEditingController> _customerPriceControllers = List.generate(
|
||||
_optionsCount,
|
||||
(_) => TextEditingController(text: '1200₽'),
|
||||
);
|
||||
final List<TextEditingController> _titleControllers = List.generate(
|
||||
_optionsCount,
|
||||
(_) => TextEditingController(),
|
||||
);
|
||||
final List<TextEditingController> _descriptionControllers = List.generate(
|
||||
_optionsCount,
|
||||
(_) => TextEditingController(),
|
||||
);
|
||||
final creatorPriceController = TextEditingController(text: '1000₽');
|
||||
|
||||
int? _syncedOptionIndex;
|
||||
bool firstExplanationVisible = false;
|
||||
bool isChecked = false;
|
||||
bool secondExplanationVisible = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
firstOptionCustomerPriceController.addListener(() {
|
||||
if (!firstOptionCustomerPriceController.text.endsWith('₽')) {
|
||||
final text = firstOptionCustomerPriceController.text.replaceAll('₽', '');
|
||||
firstOptionCustomerPriceController.text = '$text₽';
|
||||
firstOptionCustomerPriceController.selection = TextSelection.collapsed(offset: text.length);
|
||||
}
|
||||
});
|
||||
secondOptionCustomerPriceController.addListener(() {
|
||||
if (!secondOptionCustomerPriceController.text.endsWith('₽')) {
|
||||
final text = secondOptionCustomerPriceController.text.replaceAll('₽', '');
|
||||
secondOptionCustomerPriceController.text = '$text₽';
|
||||
secondOptionCustomerPriceController.selection = TextSelection.collapsed(offset: text.length);
|
||||
}
|
||||
});
|
||||
thirdOptionCustomerPriceController.addListener(() {
|
||||
if (!thirdOptionCustomerPriceController.text.endsWith('₽')) {
|
||||
final text = thirdOptionCustomerPriceController.text.replaceAll('₽', '');
|
||||
thirdOptionCustomerPriceController.text = '$text₽';
|
||||
thirdOptionCustomerPriceController.selection = TextSelection.collapsed(offset: text.length);
|
||||
}
|
||||
});
|
||||
for (final controller in _customerPriceControllers) {
|
||||
controller.addListener(() => _ensureRubleSuffix(controller));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final controller in _customerPriceControllers) {
|
||||
controller.dispose();
|
||||
}
|
||||
for (final controller in _titleControllers) {
|
||||
controller.dispose();
|
||||
}
|
||||
for (final controller in _descriptionControllers) {
|
||||
controller.dispose();
|
||||
}
|
||||
creatorPriceController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _ensureRubleSuffix(TextEditingController controller) {
|
||||
if (!controller.text.endsWith('₽')) {
|
||||
final text = controller.text.replaceAll('₽', '');
|
||||
controller.text = '$text₽';
|
||||
controller.selection = TextSelection.collapsed(offset: text.length);
|
||||
}
|
||||
}
|
||||
|
||||
void _syncController(TextEditingController controller, String value) {
|
||||
if (controller.text == value) {
|
||||
return;
|
||||
}
|
||||
controller.value = TextEditingValue(
|
||||
text: value,
|
||||
selection: TextSelection.collapsed(offset: value.length),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatCustomerPrice(dynamic price) {
|
||||
if (price is num) {
|
||||
return '${price.toStringAsFixed(0)}₽';
|
||||
}
|
||||
final parsed = double.tryParse(price.toString().replaceAll('₽', ''));
|
||||
return '${(parsed ?? 0).toStringAsFixed(0)}₽';
|
||||
}
|
||||
|
||||
void _applyOptionToControllers(int index, PacketOptions option) {
|
||||
_syncController(_titleControllers[index], option.short);
|
||||
_syncController(_descriptionControllers[index], option.description ?? '');
|
||||
_syncController(
|
||||
_customerPriceControllers[index],
|
||||
_formatCustomerPrice(option.price),
|
||||
);
|
||||
}
|
||||
|
||||
void _syncSelectedOption(CreatePlanState state) {
|
||||
final selectedOptionIndex = state.selectedOptionIndex ?? 0;
|
||||
final option = state.planData.options[selectedOptionIndex];
|
||||
final price = double.tryParse(
|
||||
_customerPriceControllers[selectedOptionIndex].text.replaceAll('₽', ''),
|
||||
);
|
||||
final creatorPrice = '${((price ?? 0) * 0.8).toStringAsFixed(2)}₽';
|
||||
|
||||
if (_syncedOptionIndex != selectedOptionIndex) {
|
||||
_syncedOptionIndex = selectedOptionIndex;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
_applyOptionToControllers(selectedOptionIndex, option);
|
||||
creatorPriceController.text = creatorPrice;
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (creatorPriceController.text != creatorPrice) {
|
||||
creatorPriceController.text = creatorPrice;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -70,31 +131,21 @@ class _OptionWidgetState extends State<OptionWidget> {
|
||||
return BlocBuilder<CreatePlanCubit, CreatePlanState>(
|
||||
builder: (context, state) {
|
||||
final selectedOptionIndex = state.selectedOptionIndex ?? 0;
|
||||
_syncSelectedOption(state);
|
||||
final optionNameHintText = switch (selectedOptionIndex) {
|
||||
0 => s.economy,
|
||||
2 => s.comfort,
|
||||
_ => s.standard,
|
||||
};
|
||||
final optionTitleController = switch (selectedOptionIndex) {
|
||||
0 => firstOptionTitleController,
|
||||
2 => thirdOptionTitleController,
|
||||
_ => secondOptionTitleController,
|
||||
};
|
||||
final optionDescriptionController = switch (selectedOptionIndex) {
|
||||
0 => firstOptionDescriptionController,
|
||||
2 => thirdOptionDescriptionController,
|
||||
_ => secondOptionDescriptionController,
|
||||
};
|
||||
final customerPriceController = switch (selectedOptionIndex) {
|
||||
0 => firstOptionCustomerPriceController,
|
||||
2 => thirdOptionCustomerPriceController,
|
||||
_ => secondOptionCustomerPriceController,
|
||||
};
|
||||
final optionTitleController = _titleControllers[selectedOptionIndex];
|
||||
final optionDescriptionController =
|
||||
_descriptionControllers[selectedOptionIndex];
|
||||
final customerPriceController =
|
||||
_customerPriceControllers[selectedOptionIndex];
|
||||
final optionItems = state.planData.options[selectedOptionIndex].items;
|
||||
final optionValidation =
|
||||
state.validationState.optionsValidationState[selectedOptionIndex];
|
||||
|
||||
final price = double.tryParse(customerPriceController.text.replaceAll('₽', ''));
|
||||
creatorPriceController.text = '${((price ?? 0) * 0.8).toStringAsFixed(2)}₽';
|
||||
firstOptionTitleController.text = state.planData.options[selectedOptionIndex].short;
|
||||
firstOptionDescriptionController.text = state.planData.options[selectedOptionIndex].description ?? '';
|
||||
return MainBackgroundBouble(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
@@ -110,14 +161,14 @@ class _OptionWidgetState extends State<OptionWidget> {
|
||||
const SizedBox(height: 4),
|
||||
CustomTextFormField(
|
||||
hintText: optionNameHintText,
|
||||
hasError: state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].titleHasError,
|
||||
hasError: optionValidation.titleHasError,
|
||||
controller: optionTitleController,
|
||||
onChanged: (value) {
|
||||
context.read<CreatePlanCubit>().setOptionTitle(value);
|
||||
},
|
||||
maxLength: 150,
|
||||
),
|
||||
if (state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].titleHasError) ...[
|
||||
if (optionValidation.titleHasError) ...[
|
||||
const SizedBox(height: 4),
|
||||
ValidationErrorWidget(error: s.atLeast3Symbols),
|
||||
],
|
||||
@@ -135,7 +186,7 @@ class _OptionWidgetState extends State<OptionWidget> {
|
||||
child: CustomTextFormField(
|
||||
controller: optionDescriptionController,
|
||||
textAlignVertical: TextAlignVertical.top,
|
||||
hasError: state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].descriptionHasError,
|
||||
hasError: optionValidation.descriptionHasError,
|
||||
maxLength: 1000,
|
||||
minLines: null,
|
||||
maxLines: null,
|
||||
@@ -146,7 +197,7 @@ class _OptionWidgetState extends State<OptionWidget> {
|
||||
},
|
||||
),
|
||||
),
|
||||
if (state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].descriptionHasError) ...[
|
||||
if (optionValidation.descriptionHasError) ...[
|
||||
const SizedBox(height: 4),
|
||||
ValidationErrorWidget(error: s.atLeast3Symbols),
|
||||
],
|
||||
@@ -158,7 +209,9 @@ class _OptionWidgetState extends State<OptionWidget> {
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
softWrap: true,
|
||||
style: TextStyles.textSMedium.copyWith(color: appTheme.black),
|
||||
style: TextStyles.textSMedium.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const _PriceExplanationWidget(),
|
||||
@@ -179,7 +232,9 @@ class _OptionWidgetState extends State<OptionWidget> {
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
softWrap: true,
|
||||
style: TextStyles.textSMedium.copyWith(color: appTheme.black),
|
||||
style: TextStyles.textSMedium.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const _PriceExplanationWidget(),
|
||||
@@ -188,8 +243,10 @@ class _OptionWidgetState extends State<OptionWidget> {
|
||||
const SizedBox(height: 4),
|
||||
CustomTextFormField(
|
||||
controller: customerPriceController,
|
||||
textInputType: const TextInputType.numberWithOptions(decimal: true),
|
||||
hasError: state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].priceHasError,
|
||||
textInputType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
),
|
||||
hasError: optionValidation.priceHasError,
|
||||
onChanged: (value) {
|
||||
final price = double.tryParse(value.replaceAll('₽', ''));
|
||||
if (price != null) {
|
||||
@@ -222,34 +279,41 @@ class _OptionWidgetState extends State<OptionWidget> {
|
||||
separatorBuilder: (context, index) => const SizedBox(height: 8),
|
||||
reverse: false,
|
||||
itemBuilder: (context, index) {
|
||||
final items = state.planData.options[selectedOptionIndex].items;
|
||||
|
||||
if (items.isEmpty) {
|
||||
if (optionItems.isEmpty) {
|
||||
return AddItemWidget(
|
||||
key: ValueKey(
|
||||
'plan-option-$selectedOptionIndex-item-$index',
|
||||
),
|
||||
index: index,
|
||||
canBeDeleted: false,
|
||||
enableHint: true,
|
||||
items: items,
|
||||
items: optionItems,
|
||||
);
|
||||
} else {
|
||||
if (index < items.length) {
|
||||
if (index < optionItems.length) {
|
||||
return AddItemWidget(
|
||||
key: ValueKey(
|
||||
'plan-option-$selectedOptionIndex-item-$index',
|
||||
),
|
||||
canBeDeleted: true,
|
||||
enableHint: false,
|
||||
index: index,
|
||||
items: items,
|
||||
items: optionItems,
|
||||
);
|
||||
} else {
|
||||
return AddItemWidget(
|
||||
key: ValueKey(
|
||||
'plan-option-$selectedOptionIndex-item-$index',
|
||||
),
|
||||
canBeDeleted: false,
|
||||
enableHint: false,
|
||||
index: index,
|
||||
items: items,
|
||||
items: optionItems,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
itemCount: state.planData.options[selectedOptionIndex].items.length + 1,
|
||||
itemCount: optionItems.length + 1,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
),
|
||||
@@ -259,23 +323,30 @@ class _OptionWidgetState extends State<OptionWidget> {
|
||||
Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: state.planData.options[selectedOptionIndex].hasPersonalDiet,
|
||||
value: state
|
||||
.planData
|
||||
.options[selectedOptionIndex]
|
||||
.hasPersonalDiet,
|
||||
activeColor: appTheme.gray,
|
||||
checkColor: appTheme.gray,
|
||||
fillColor: const WidgetStatePropertyAll(Colors.transparent),
|
||||
side: WidgetStateBorderSide.resolveWith(
|
||||
(Set<WidgetState> states) {
|
||||
return BorderSide(width: 1, color: appTheme.gray);
|
||||
},
|
||||
),
|
||||
side: WidgetStateBorderSide.resolveWith((
|
||||
Set<WidgetState> states,
|
||||
) {
|
||||
return BorderSide(width: 1, color: appTheme.gray);
|
||||
}),
|
||||
onChanged: (bool? value) {
|
||||
context.read<CreatePlanCubit>().togglePersonalizedDietOptionState();
|
||||
context
|
||||
.read<CreatePlanCubit>()
|
||||
.togglePersonalizedDietOptionState();
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
s.option_personal_diet_desc,
|
||||
style: TextStyles.textMMedium.copyWith(color: appTheme.black),
|
||||
style: TextStyles.textMMedium.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -292,7 +363,8 @@ class _PriceExplanationWidget extends StatefulWidget {
|
||||
const _PriceExplanationWidget();
|
||||
|
||||
@override
|
||||
State<_PriceExplanationWidget> createState() => _PriceExplanationWidgetState();
|
||||
State<_PriceExplanationWidget> createState() =>
|
||||
_PriceExplanationWidgetState();
|
||||
}
|
||||
|
||||
class _PriceExplanationWidgetState extends State<_PriceExplanationWidget> {
|
||||
@@ -314,9 +386,7 @@ class _PriceExplanationWidgetState extends State<_PriceExplanationWidget> {
|
||||
color: visible ? appTheme.whiteA700 : Colors.transparent,
|
||||
),
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: Text(
|
||||
s.price_explanation,
|
||||
),
|
||||
child: Text(s.price_explanation),
|
||||
),
|
||||
),
|
||||
visible: visible,
|
||||
@@ -331,7 +401,11 @@ class _PriceExplanationWidgetState extends State<_PriceExplanationWidget> {
|
||||
visible = !visible;
|
||||
});
|
||||
},
|
||||
child: SvgPicture.asset('assets/icons/question_mark.svg', height: 12, width: 12),
|
||||
child: SvgPicture.asset(
|
||||
'assets/icons/question_mark.svg',
|
||||
height: 12,
|
||||
width: 12,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dotted_border/dotted_border.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/app_router.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/permission_handler/permission_handler.dart';
|
||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
@@ -91,6 +93,18 @@ class _PlanImagePickerWidgetState extends State<PlanImagePickerWidget> {
|
||||
final s = S.of(context);
|
||||
return BlocBuilder<CreatePlanCubit, CreatePlanState>(
|
||||
builder: (context, state) {
|
||||
final selectedImage = state.planData.image;
|
||||
final existingImage = context.read<CreatePlanCubit>().sourcePlan?.image;
|
||||
final hasCover = selectedImage != null || (existingImage != null && existingImage.isNotEmpty);
|
||||
final ImageProvider? coverImage;
|
||||
if (selectedImage != null) {
|
||||
coverImage = FileImage(File(selectedImage.path));
|
||||
} else if (existingImage != null && existingImage.isNotEmpty) {
|
||||
final url = existingImage.startsWith('http') ? existingImage : '${sl<AppConfig>().baseUrl}/storage/$existingImage';
|
||||
coverImage = NetworkImage(url);
|
||||
} else {
|
||||
coverImage = null;
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
GestureDetector(
|
||||
@@ -101,12 +115,12 @@ class _PlanImagePickerWidgetState extends State<PlanImagePickerWidget> {
|
||||
height: 240,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
image: state.planData.image != null ? DecorationImage(fit: BoxFit.cover, image: FileImage(File(state.planData.image?.path ?? ''))) : null,
|
||||
image: coverImage != null ? DecorationImage(fit: BoxFit.cover, image: coverImage) : null,
|
||||
color: appTheme.gray50,
|
||||
),
|
||||
width: MediaQuery.sizeOf(context).width,
|
||||
padding: state.planData.image != null ? null : const EdgeInsets.all(8),
|
||||
child: state.planData.image != null
|
||||
padding: hasCover ? null : const EdgeInsets.all(8),
|
||||
child: selectedImage != null
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(top: 6, right: 6),
|
||||
child: GestureDetector(
|
||||
@@ -131,6 +145,8 @@ class _PlanImagePickerWidgetState extends State<PlanImagePickerWidget> {
|
||||
),
|
||||
),
|
||||
)
|
||||
: hasCover
|
||||
? const SizedBox.shrink()
|
||||
: Stack(
|
||||
children: [
|
||||
DottedBorder(
|
||||
|
||||
@@ -11,7 +11,13 @@ class CreatePlanRepository {
|
||||
}) async {
|
||||
final formData = FormData.fromMap(model);
|
||||
return await createPlanService.createPlan(formData: formData);
|
||||
|
||||
}
|
||||
|
||||
|
||||
Future<void> updatePublishedPlan({
|
||||
required String slug,
|
||||
required Map<String, dynamic> model,
|
||||
}) async {
|
||||
final formData = FormData.fromMap(model);
|
||||
return await createPlanService.updatePublishedPlan(slug: slug, formData: formData);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,5 +12,11 @@ abstract class CreatePlanService {
|
||||
Future<void> createPlan({
|
||||
@Body() required FormData formData,
|
||||
});
|
||||
|
||||
|
||||
@POST('/api/plans/update/published/{slug}')
|
||||
@MultiPart()
|
||||
Future<void> updatePublishedPlan({
|
||||
@Path() required String slug,
|
||||
@Body() required FormData formData,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:eatvio_app/features/diary/entities/diary_day_entity.dart';
|
||||
import 'package:eatvio_app/features/diary/entities/diary_future_diet_day_entity.dart';
|
||||
import 'package:eatvio_app/features/diary/models/consultant_review_model.dart';
|
||||
import 'package:eatvio_app/features/diary/models/nutrition_model.dart';
|
||||
import 'package:eatvio_app/features/diary/presentation/diary_edit_access.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'diary_state.freezed.dart';
|
||||
@@ -12,7 +13,8 @@ abstract class DiaryState with _$DiaryState {
|
||||
|
||||
const factory DiaryState.loading() = DiaryStateLoading;
|
||||
|
||||
const factory DiaryState.error({required String errorMessage}) = DiaryStateError;
|
||||
const factory DiaryState.error({required String errorMessage}) =
|
||||
DiaryStateError;
|
||||
|
||||
const factory DiaryState.loaded({
|
||||
required DiaryDayEntity diary,
|
||||
@@ -31,6 +33,19 @@ abstract class DiaryState with _$DiaryState {
|
||||
@Default(false) bool isLoadingAddingProductInMeal,
|
||||
}) = DiaryStateLoaded;
|
||||
|
||||
const factory DiaryState.loadedTomorrowWithDiet({required DiaryFutureDietDayEntity diary, required DateTime selectedDay, required int selectedMeal}) =
|
||||
DiaryStateLoadedTomorrowWithDiet;
|
||||
const factory DiaryState.loadedTomorrowWithDiet({
|
||||
required DiaryFutureDietDayEntity diary,
|
||||
required DateTime selectedDay,
|
||||
required int selectedMeal,
|
||||
}) = DiaryStateLoadedTomorrowWithDiet;
|
||||
}
|
||||
|
||||
extension DiaryStateLoadedEditAccess on DiaryStateLoaded {
|
||||
bool get canEditDiary => canEditDiaryDay(
|
||||
isConsultantView: isConsultantView,
|
||||
isDiaryCommented: isDiaryCommented,
|
||||
isDiaryFinished: isDiaryFinished,
|
||||
closed: diary.closed,
|
||||
diaryDate: diary.date,
|
||||
);
|
||||
}
|
||||
|
||||
38
lib/features/diary/presentation/diary_edit_access.dart
Normal file
38
lib/features/diary/presentation/diary_edit_access.dart
Normal file
@@ -0,0 +1,38 @@
|
||||
/// Finished diaries stay editable for this many calendar days back from today,
|
||||
/// including today. Older finished days stay read-only.
|
||||
const int diaryFinishedEditWindowInDays = 7;
|
||||
|
||||
bool canEditDiaryDay({
|
||||
required bool isConsultantView,
|
||||
required bool isDiaryCommented,
|
||||
required bool isDiaryFinished,
|
||||
required int closed,
|
||||
required String diaryDate,
|
||||
DateTime? now,
|
||||
}) {
|
||||
if (isConsultantView || isDiaryCommented || closed != 0) {
|
||||
return false;
|
||||
}
|
||||
if (!isDiaryFinished) {
|
||||
return true;
|
||||
}
|
||||
final day = _calendarDate(diaryDate);
|
||||
if (day == null) {
|
||||
return false;
|
||||
}
|
||||
final clock = now ?? DateTime.now();
|
||||
final today = DateTime(clock.year, clock.month, clock.day);
|
||||
final oldestEditableDay = today.subtract(
|
||||
const Duration(days: diaryFinishedEditWindowInDays),
|
||||
);
|
||||
return !day.isBefore(oldestEditableDay) && !day.isAfter(today);
|
||||
}
|
||||
|
||||
DateTime? _calendarDate(String raw) {
|
||||
final parsed = DateTime.tryParse(raw);
|
||||
if (parsed == null) {
|
||||
return null;
|
||||
}
|
||||
final local = parsed.toLocal();
|
||||
return DateTime(local.year, local.month, local.day);
|
||||
}
|
||||
@@ -74,7 +74,12 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
|
||||
}
|
||||
if (state is DiaryStateError) {
|
||||
return Scaffold(
|
||||
body: DataErrorWidget(onReload: () => context.read<DiaryCubit>().fetchDiaryV2(needLoading: false, diaryId: widget.diaryId)),
|
||||
body: DataErrorWidget(
|
||||
onReload: () => context.read<DiaryCubit>().fetchDiaryV2(
|
||||
needLoading: false,
|
||||
diaryId: widget.diaryId,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -84,8 +89,14 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
|
||||
? AppBar(toolbarHeight: 0)
|
||||
: AppBar(
|
||||
title: Text(
|
||||
state is DiaryStateLoaded ? DateTime.parse(state.diary.date).toLocal().format('d MMMM') : '',
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
state is DiaryStateLoaded
|
||||
? DateTime.parse(
|
||||
state.diary.date,
|
||||
).toLocal().format('d MMMM')
|
||||
: '',
|
||||
style: TextStyles.headingXLSemibold.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
),
|
||||
@@ -95,26 +106,39 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
|
||||
child: Column(
|
||||
children: [
|
||||
if (widget.diaryId == null) CalendarWidget(date: widget.date),
|
||||
if (state is DiaryStateLoading) ...[const SizedBox(height: 60), const Loader()],
|
||||
if (state is DiaryStateLoading) ...[
|
||||
const SizedBox(height: 60),
|
||||
const Loader(),
|
||||
],
|
||||
if (state is DiaryStateLoaded)
|
||||
WakeupAndRetireWidget(
|
||||
isEnabled: state.diary.typeOfDiary.hasChosenPlan || state.isConsultantView || state.isWakeUpAndRetireEnabled,
|
||||
disable: state.isConsultantView || state.isDiaryFinished || state.isDiaryCommented,
|
||||
isEnabled:
|
||||
state.diary.typeOfDiary.hasChosenPlan ||
|
||||
state.isConsultantView ||
|
||||
state.isWakeUpAndRetireEnabled,
|
||||
disable: !state.canEditDiary,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (state is DiaryStateLoaded)
|
||||
MealWidget(
|
||||
showMood: state.diary.typeOfDiary.hasChosenPlan || state.isConsultantView || state.isMoodEnabled,
|
||||
showAddButton: !state.isConsultantView && !state.isDiaryFinished && !state.isDiaryCommented,
|
||||
showMood:
|
||||
state.diary.typeOfDiary.hasChosenPlan ||
|
||||
state.isConsultantView ||
|
||||
state.isMoodEnabled,
|
||||
showAddButton: state.canEditDiary,
|
||||
),
|
||||
if (state is DiaryStateLoadedTomorrowWithDiet) const MealWidget(showMood: false, showAddButton: false),
|
||||
if (state is DiaryStateLoadedTomorrowWithDiet)
|
||||
const MealWidget(showMood: false, showAddButton: false),
|
||||
if (state is DiaryStateLoaded) const CaloriesChartWidget(),
|
||||
const SizedBox(height: 12),
|
||||
Builder(
|
||||
builder: (context) {
|
||||
if (state is DiaryStateLoaded && state.diary.typeOfDiary.hasChosenPlan) {
|
||||
if (state is DiaryStateLoaded &&
|
||||
state.diary.typeOfDiary.hasChosenPlan) {
|
||||
if (state.diary.comment.isNotEmpty) {
|
||||
return CommentForConsultantWidget(comment: state.diary.comment);
|
||||
return CommentForConsultantWidget(
|
||||
comment: state.diary.comment,
|
||||
);
|
||||
}
|
||||
if (state.isDiaryCommented || state.isDiaryFinished) {
|
||||
return const SizedBox.shrink();
|
||||
@@ -124,7 +148,10 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
if (state is DiaryStateLoaded && !state.isConsultantView && !state.isDiaryFinished && !state.isDiaryCommented)
|
||||
if (state is DiaryStateLoaded &&
|
||||
!state.isConsultantView &&
|
||||
!state.isDiaryFinished &&
|
||||
!state.isDiaryCommented)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: _isDiaryFinishing
|
||||
@@ -135,11 +162,13 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
|
||||
setState(() {
|
||||
_isDiaryFinishing = true;
|
||||
});
|
||||
final isFinished = await context.read<DiaryCubit>().finishDiaryDay(
|
||||
diaryId: state.diary.id,
|
||||
wakeUp: state.diary.wakeup.toString(),
|
||||
retire: state.diary.retire.toString(),
|
||||
);
|
||||
final isFinished = await context
|
||||
.read<DiaryCubit>()
|
||||
.finishDiaryDay(
|
||||
diaryId: state.diary.id,
|
||||
wakeUp: state.diary.wakeup.toString(),
|
||||
retire: state.diary.retire.toString(),
|
||||
);
|
||||
if (!isFinished && context.mounted) {
|
||||
showErrorDialog(context);
|
||||
}
|
||||
@@ -149,33 +178,64 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
|
||||
},
|
||||
),
|
||||
),
|
||||
if (state is DiaryStateLoaded && state.isConsultantView && !state.isDiaryCommented) ...[
|
||||
if (state is DiaryStateLoaded &&
|
||||
state.isConsultantView &&
|
||||
!state.isDiaryCommented) ...[
|
||||
const SizedBox(height: 16),
|
||||
const MainBackgroundBouble(padding: EdgeInsets.all(16), child: DayCommentWidget()),
|
||||
const MainBackgroundBouble(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: DayCommentWidget(),
|
||||
),
|
||||
],
|
||||
if (state is DiaryStateLoaded && state.consultantReview?.consultantComments.dayComment != null) ...[
|
||||
if (state is DiaryStateLoaded &&
|
||||
state.consultantReview?.consultantComments.dayComment !=
|
||||
null) ...[
|
||||
const SizedBox(height: 12),
|
||||
MainBackgroundBouble(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(s.dayComment, style: TextStyles.textLSemibold.copyWith(color: appTheme.black)),
|
||||
Text(
|
||||
s.dayComment,
|
||||
style: TextStyles.textLSemibold.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
CommentContainer(comment: state.consultantReview?.consultantComments.dayComment ?? ''),
|
||||
CommentContainer(
|
||||
comment:
|
||||
state
|
||||
.consultantReview
|
||||
?.consultantComments
|
||||
.dayComment ??
|
||||
'',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
],
|
||||
if (state is DiaryStateLoaded && state.isConsultantView && state.isDiaryFinished && !state.isDiaryCommented) ...[
|
||||
if (state is DiaryStateLoaded &&
|
||||
state.isConsultantView &&
|
||||
state.isDiaryFinished &&
|
||||
!state.isDiaryCommented) ...[
|
||||
const SizedBox(height: 16),
|
||||
CustomOutlinedButton(
|
||||
text: s.send,
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(
|
||||
color: appTheme.whiteA700,
|
||||
),
|
||||
buttonStyle: ButtonStyle(
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
backgroundColor: WidgetStatePropertyAll(
|
||||
appTheme.blueA400,
|
||||
),
|
||||
),
|
||||
onPressed: () async {
|
||||
await context.read<ConsultantCommentsCubit>().submitComments(state.diary.id);
|
||||
await context
|
||||
.read<ConsultantCommentsCubit>()
|
||||
.submitComments(state.diary.id);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
@@ -70,42 +70,46 @@ class _AddProductModalState extends State<AddProductModal> {
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: MediaQuery.of(context).size.width * 0.76 - 32,
|
||||
child: CustomTextFormField(
|
||||
controller: productNameController,
|
||||
autofocus: true,
|
||||
hintText: 'Введите название продукта/блюда',
|
||||
onChanged: (query) {
|
||||
EasyDebounce.debounce('glossary-search', const Duration(milliseconds: 250), () async {
|
||||
selectedGlossary = null;
|
||||
if (query.isEmpty) {
|
||||
glossariesList = [];
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: SizedBox(
|
||||
child: CustomTextFormField(
|
||||
controller: productNameController,
|
||||
autofocus: true,
|
||||
hintText: 'Введите название продукта/блюда',
|
||||
onChanged: (query) {
|
||||
EasyDebounce.debounce('glossary-search', const Duration(milliseconds: 250), () async {
|
||||
selectedGlossary = null;
|
||||
if (query.isEmpty) {
|
||||
glossariesList = [];
|
||||
setState(() {});
|
||||
return;
|
||||
}
|
||||
glossariesList = await widget.diaryCubit.getGlossaryListByQuery(query);
|
||||
setState(() {});
|
||||
return;
|
||||
}
|
||||
glossariesList = await widget.diaryCubit.getGlossaryListByQuery(query);
|
||||
setState(() {});
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
SizedBox(
|
||||
width: MediaQuery.of(context).size.width * 0.24 - 4,
|
||||
child: CustomTextFormField(
|
||||
controller: quantityController,
|
||||
keyboardType: TextInputType.number,
|
||||
hintText: selectedGlossary == null
|
||||
? '100'
|
||||
: selectedGlossary!.grInPortion == 0
|
||||
? '100'
|
||||
: '1',
|
||||
suffixText: selectedGlossary == null
|
||||
? 'гр'
|
||||
: selectedGlossary?.grInPortion == 0
|
||||
? 'гр'
|
||||
: 'шт',
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: SizedBox(
|
||||
child: CustomTextFormField(
|
||||
controller: quantityController,
|
||||
keyboardType: TextInputType.number,
|
||||
hintText: selectedGlossary == null
|
||||
? '100'
|
||||
: selectedGlossary!.grInPortion == 0
|
||||
? '100'
|
||||
: '1',
|
||||
suffixText: selectedGlossary == null
|
||||
? 'гр'
|
||||
: selectedGlossary?.grInPortion == 0
|
||||
? 'гр'
|
||||
: 'шт',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -168,66 +172,68 @@ class _AddProductModalState extends State<AddProductModal> {
|
||||
),
|
||||
),
|
||||
if (widget.portion != null) const SizedBox(width: 8),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
// Автоматически выбираем первый элемент, если текст точно совпадает
|
||||
if (selectedGlossary == null &&
|
||||
glossariesList.isNotEmpty &&
|
||||
productNameController.text.trim().toLowerCase() == glossariesList[0].name.toLowerCase()) {
|
||||
selectedGlossary = glossariesList[0];
|
||||
}
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
// Автоматически выбираем первый элемент, если текст точно совпадает
|
||||
if (selectedGlossary == null &&
|
||||
glossariesList.isNotEmpty &&
|
||||
productNameController.text.trim().toLowerCase() == glossariesList[0].name.toLowerCase()) {
|
||||
selectedGlossary = glossariesList[0];
|
||||
}
|
||||
|
||||
if (widget.portion == null) {
|
||||
widget.diaryCubit.addProductToMeal(
|
||||
diaryId: widget.state.diary.id,
|
||||
mealId: widget.state.selectedMeal,
|
||||
productName: productNameController.text,
|
||||
portion: selectedGlossary == null
|
||||
? 'gr'
|
||||
: selectedGlossary!.grInPortion == 0
|
||||
? 'gr'
|
||||
: 'pcs',
|
||||
quantity: int.parse(quantityController.text),
|
||||
productNutritionalValueId: selectedGlossary?.id,
|
||||
);
|
||||
} else {
|
||||
widget.diaryCubit.updateProductInMeal(
|
||||
diaryId: widget.state.diary.id,
|
||||
mealId: widget.state.selectedMeal,
|
||||
portionId: widget.portion!.id,
|
||||
productName: productNameController.text,
|
||||
portion: selectedGlossary == null
|
||||
? 'gr'
|
||||
: selectedGlossary!.grInPortion == 0
|
||||
? 'gr'
|
||||
: 'pcs',
|
||||
quantity: int.parse(quantityController.text),
|
||||
productNutritionalValueId: selectedGlossary?.id,
|
||||
portionState: widget.portion!.portionState,
|
||||
);
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
if (selectedGlossary == null && _previousProductName != productNameController.text) {
|
||||
showModalBottomSheet(
|
||||
isScrollControlled: true,
|
||||
useRootNavigator: true,
|
||||
context: context,
|
||||
builder: (context) => Padding(
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||
child: GlossaryModerationRequestModalWidget(productName: productNameController.text, diaryCubit: widget.diaryCubit),
|
||||
if (widget.portion == null) {
|
||||
widget.diaryCubit.addProductToMeal(
|
||||
diaryId: widget.state.diary.id,
|
||||
mealId: widget.state.selectedMeal,
|
||||
productName: productNameController.text,
|
||||
portion: selectedGlossary == null
|
||||
? 'gr'
|
||||
: selectedGlossary!.grInPortion == 0
|
||||
? 'gr'
|
||||
: 'pcs',
|
||||
quantity: int.parse(quantityController.text),
|
||||
productNutritionalValueId: selectedGlossary?.id,
|
||||
);
|
||||
} else {
|
||||
widget.diaryCubit.updateProductInMeal(
|
||||
diaryId: widget.state.diary.id,
|
||||
mealId: widget.state.selectedMeal,
|
||||
portionId: widget.portion!.id,
|
||||
productName: productNameController.text,
|
||||
portion: selectedGlossary == null
|
||||
? 'gr'
|
||||
: selectedGlossary!.grInPortion == 0
|
||||
? 'gr'
|
||||
: 'pcs',
|
||||
quantity: int.parse(quantityController.text),
|
||||
productNutritionalValueId: selectedGlossary?.id,
|
||||
portionState: widget.portion!.portionState,
|
||||
);
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
if (selectedGlossary == null && _previousProductName != productNameController.text) {
|
||||
showModalBottomSheet(
|
||||
isScrollControlled: true,
|
||||
useRootNavigator: true,
|
||||
context: context,
|
||||
builder: (context) => Padding(
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||
child: GlossaryModerationRequestModalWidget(productName: productNameController.text, diaryCubit: widget.diaryCubit),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Ink(
|
||||
width: MediaQuery.of(context).size.width - (widget.portion == null ? 32 : 92),
|
||||
height: 52,
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
|
||||
child: Center(
|
||||
child: Text(
|
||||
widget.portion == null ? 'Добавить' : 'Сохранить',
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Ink(
|
||||
width: MediaQuery.of(context).size.width - (widget.portion == null ? 32 : 92),
|
||||
height: 52,
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
|
||||
child: Center(
|
||||
child: Text(
|
||||
widget.portion == null ? 'Добавить' : 'Сохранить',
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -16,6 +16,9 @@ class CalendarWidget extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _CalendarWidgetState extends State<CalendarWidget> {
|
||||
static const _kDaysOfWeekHeight = 16.0;
|
||||
static const _kRowHeight = 52.0;
|
||||
|
||||
CalendarFormat _calendarFormat = CalendarFormat.week;
|
||||
DateTime _focusedDay = DateTime.now();
|
||||
DateTime _selectedDay = DateTime.now();
|
||||
@@ -40,6 +43,8 @@ class _CalendarWidgetState extends State<CalendarWidget> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
final textScaler = MediaQuery.textScalerOf(context);
|
||||
final daysOfWeekHeight = _scaledDaysOfWeekHeight(context);
|
||||
return BlocConsumer<DiaryCubit, DiaryState>(
|
||||
listener: (BuildContext context, DiaryState state) {
|
||||
if (state is DiaryStateLoaded) {
|
||||
@@ -61,6 +66,8 @@ class _CalendarWidgetState extends State<CalendarWidget> {
|
||||
locale: 'ru_RU',
|
||||
startingDayOfWeek: StartingDayOfWeek.monday,
|
||||
calendarFormat: _calendarFormat,
|
||||
daysOfWeekHeight: daysOfWeekHeight,
|
||||
rowHeight: textScaler.scale(_kRowHeight),
|
||||
headerStyle: const HeaderStyle(titleCentered: true, formatButtonVisible: false, leftChevronVisible: false, rightChevronVisible: false),
|
||||
onDaySelected: (selectedDay, focusedDay) async {
|
||||
if (!isSameDay(_selectedDay, selectedDay)) {
|
||||
@@ -116,6 +123,18 @@ class _CalendarWidgetState extends State<CalendarWidget> {
|
||||
);
|
||||
}
|
||||
|
||||
double _scaledDaysOfWeekHeight(BuildContext context) {
|
||||
final painter = TextPainter(
|
||||
text: TextSpan(text: 'Щy', style: DefaultTextStyle.of(context).style),
|
||||
textDirection: Directionality.of(context),
|
||||
textScaler: MediaQuery.textScalerOf(context),
|
||||
maxLines: 1,
|
||||
)..layout();
|
||||
final height = painter.height;
|
||||
painter.dispose();
|
||||
return height.clamp(_kDaysOfWeekHeight, double.infinity);
|
||||
}
|
||||
|
||||
void _nextCalendarFormat() {
|
||||
setState(() {
|
||||
_calendarFormat = CalendarFormat.values[(_calendarFormat.index - 1) % CalendarFormat.values.length];
|
||||
|
||||
@@ -26,7 +26,8 @@ class _MoodRatingWidgetState extends State<MoodRatingWidget> {
|
||||
final meals = state.diary.meals;
|
||||
if (selectedIndex >= 0 && selectedIndex < meals.length) {
|
||||
if (isFirstFetch[selectedIndex]) {
|
||||
currentSelectedRating[selectedIndex] = meals[selectedIndex].mood.toDouble() + 1;
|
||||
currentSelectedRating[selectedIndex] =
|
||||
meals[selectedIndex].mood.toDouble() + 1;
|
||||
isFirstFetch[selectedIndex] = false;
|
||||
}
|
||||
}
|
||||
@@ -36,21 +37,30 @@ class _MoodRatingWidgetState extends State<MoodRatingWidget> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
state.isConsultantView ? 'Настроение пользователя после приема пищи' : 'Ваше настроение после приёма пищи',
|
||||
state.isConsultantView
|
||||
? 'Настроение пользователя после приема пищи'
|
||||
: 'Ваше настроение после приёма пищи',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
SvgPicture.asset('assets/icons/mood_1.svg', height: 32, width: 32),
|
||||
SvgPicture.asset(
|
||||
'assets/icons/mood_1.svg',
|
||||
height: 32,
|
||||
width: 32,
|
||||
),
|
||||
RatingBar.builder(
|
||||
ignoreGestures: state.isConsultantView || state.isDiaryFinished || state.isDiaryCommented,
|
||||
initialRating: (selectedIndex >= 0 && selectedIndex < state.diary.meals.length)
|
||||
ignoreGestures: !state.canEditDiary,
|
||||
initialRating:
|
||||
(selectedIndex >= 0 &&
|
||||
selectedIndex < state.diary.meals.length)
|
||||
? state.diary.meals[selectedIndex].mood.toDouble() + 1
|
||||
: 3,
|
||||
itemCount: 6,
|
||||
itemSize: MediaQuery.of(context).size.width / 6 - (124 / 6),
|
||||
itemSize:
|
||||
MediaQuery.of(context).size.width / 6 - (124 / 6),
|
||||
updateOnDrag: true,
|
||||
itemBuilder: (context, index) {
|
||||
switch (index) {
|
||||
@@ -58,82 +68,154 @@ class _MoodRatingWidgetState extends State<MoodRatingWidget> {
|
||||
return Container(
|
||||
width: MediaQuery.of(context).size.width / 6,
|
||||
height: 24,
|
||||
margin: currentSelectedRating[selectedIndex] == 1 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
||||
margin: currentSelectedRating[selectedIndex] == 1
|
||||
? const EdgeInsets.symmetric(horizontal: 2)
|
||||
: null,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFC82222),
|
||||
border: currentSelectedRating[selectedIndex] == 1
|
||||
? Border.all(width: 2, color: const Color(0xFFAA3333), strokeAlign: BorderSide.strokeAlignOutside)
|
||||
border:
|
||||
currentSelectedRating[selectedIndex] == 1
|
||||
? Border.all(
|
||||
width: 2,
|
||||
color: const Color(0xFFAA3333),
|
||||
strokeAlign:
|
||||
BorderSide.strokeAlignOutside,
|
||||
)
|
||||
: null,
|
||||
borderRadius: currentSelectedRating[selectedIndex] == 1
|
||||
? const BorderRadius.horizontal(left: Radius.circular(8), right: Radius.circular(2))
|
||||
: const BorderRadius.horizontal(left: Radius.circular(8)),
|
||||
borderRadius:
|
||||
currentSelectedRating[selectedIndex] == 1
|
||||
? const BorderRadius.horizontal(
|
||||
left: Radius.circular(8),
|
||||
right: Radius.circular(2),
|
||||
)
|
||||
: const BorderRadius.horizontal(
|
||||
left: Radius.circular(8),
|
||||
),
|
||||
),
|
||||
);
|
||||
case 1:
|
||||
return Container(
|
||||
width: MediaQuery.of(context).size.width / 6,
|
||||
height: 24,
|
||||
margin: currentSelectedRating[selectedIndex] == 2 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
||||
margin: currentSelectedRating[selectedIndex] == 2
|
||||
? const EdgeInsets.symmetric(horizontal: 2)
|
||||
: null,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFF3E3E),
|
||||
border: currentSelectedRating[selectedIndex] == 2
|
||||
? Border.all(width: 2, color: const Color(0xFF550000), strokeAlign: BorderSide.strokeAlignOutside)
|
||||
border:
|
||||
currentSelectedRating[selectedIndex] == 2
|
||||
? Border.all(
|
||||
width: 2,
|
||||
color: const Color(0xFF550000),
|
||||
strokeAlign:
|
||||
BorderSide.strokeAlignOutside,
|
||||
)
|
||||
: null,
|
||||
borderRadius:
|
||||
currentSelectedRating[selectedIndex] == 2
|
||||
? BorderRadius.circular(2)
|
||||
: null,
|
||||
borderRadius: currentSelectedRating[selectedIndex] == 2 ? BorderRadius.circular(2) : null,
|
||||
),
|
||||
);
|
||||
case 2:
|
||||
return Container(
|
||||
width: MediaQuery.of(context).size.width / 6,
|
||||
height: 24,
|
||||
margin: currentSelectedRating[selectedIndex] == 3 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
||||
margin: currentSelectedRating[selectedIndex] == 3
|
||||
? const EdgeInsets.symmetric(horizontal: 2)
|
||||
: null,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8C926),
|
||||
border: currentSelectedRating[selectedIndex] == 3
|
||||
? Border.all(width: 2, color: const Color(0xFF8C5D00), strokeAlign: BorderSide.strokeAlignOutside)
|
||||
border:
|
||||
currentSelectedRating[selectedIndex] == 3
|
||||
? Border.all(
|
||||
width: 2,
|
||||
color: const Color(0xFF8C5D00),
|
||||
strokeAlign:
|
||||
BorderSide.strokeAlignOutside,
|
||||
)
|
||||
: null,
|
||||
borderRadius:
|
||||
currentSelectedRating[selectedIndex] == 3
|
||||
? BorderRadius.circular(2)
|
||||
: null,
|
||||
borderRadius: currentSelectedRating[selectedIndex] == 3 ? BorderRadius.circular(2) : null,
|
||||
),
|
||||
);
|
||||
case 3:
|
||||
return Container(
|
||||
width: MediaQuery.of(context).size.width / 6,
|
||||
height: 24,
|
||||
margin: currentSelectedRating[selectedIndex] == 4 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
||||
margin: currentSelectedRating[selectedIndex] == 4
|
||||
? const EdgeInsets.symmetric(horizontal: 2)
|
||||
: null,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF3BF),
|
||||
border: currentSelectedRating[selectedIndex] == 4
|
||||
? Border.all(width: 2, color: const Color(0xFFB5911B), strokeAlign: BorderSide.strokeAlignOutside)
|
||||
border:
|
||||
currentSelectedRating[selectedIndex] == 4
|
||||
? Border.all(
|
||||
width: 2,
|
||||
color: const Color(0xFFB5911B),
|
||||
strokeAlign:
|
||||
BorderSide.strokeAlignOutside,
|
||||
)
|
||||
: null,
|
||||
borderRadius:
|
||||
currentSelectedRating[selectedIndex] == 4
|
||||
? BorderRadius.circular(2)
|
||||
: null,
|
||||
borderRadius: currentSelectedRating[selectedIndex] == 4 ? BorderRadius.circular(2) : null,
|
||||
),
|
||||
);
|
||||
case 4:
|
||||
return Container(
|
||||
width: MediaQuery.of(context).size.width / 6,
|
||||
height: 24,
|
||||
margin: currentSelectedRating[selectedIndex] == 5 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
||||
margin: currentSelectedRating[selectedIndex] == 5
|
||||
? const EdgeInsets.symmetric(horizontal: 2)
|
||||
: null,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF62E833),
|
||||
border: currentSelectedRating[selectedIndex] == 5
|
||||
? Border.all(width: 2, color: const Color(0xFF276918), strokeAlign: BorderSide.strokeAlignOutside)
|
||||
border:
|
||||
currentSelectedRating[selectedIndex] == 5
|
||||
? Border.all(
|
||||
width: 2,
|
||||
color: const Color(0xFF276918),
|
||||
strokeAlign:
|
||||
BorderSide.strokeAlignOutside,
|
||||
)
|
||||
: null,
|
||||
borderRadius:
|
||||
currentSelectedRating[selectedIndex] == 5
|
||||
? BorderRadius.circular(2)
|
||||
: null,
|
||||
borderRadius: currentSelectedRating[selectedIndex] == 5 ? BorderRadius.circular(2) : null,
|
||||
),
|
||||
);
|
||||
default:
|
||||
return Container(
|
||||
width: MediaQuery.of(context).size.width / 6,
|
||||
height: 24,
|
||||
margin: currentSelectedRating[selectedIndex] == 6 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
||||
margin: currentSelectedRating[selectedIndex] == 6
|
||||
? const EdgeInsets.symmetric(horizontal: 2)
|
||||
: null,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF4DCE20),
|
||||
border: currentSelectedRating[selectedIndex] == 6
|
||||
? Border.all(width: 2, color: const Color(0xFF184D12), strokeAlign: BorderSide.strokeAlignOutside)
|
||||
border:
|
||||
currentSelectedRating[selectedIndex] == 6
|
||||
? Border.all(
|
||||
width: 2,
|
||||
color: const Color(0xFF184D12),
|
||||
strokeAlign:
|
||||
BorderSide.strokeAlignOutside,
|
||||
)
|
||||
: null,
|
||||
borderRadius: currentSelectedRating[selectedIndex] == 6
|
||||
? const BorderRadius.horizontal(left: Radius.circular(2), right: Radius.circular(8))
|
||||
: const BorderRadius.horizontal(right: Radius.circular(8)),
|
||||
borderRadius:
|
||||
currentSelectedRating[selectedIndex] == 6
|
||||
? const BorderRadius.horizontal(
|
||||
left: Radius.circular(2),
|
||||
right: Radius.circular(8),
|
||||
)
|
||||
: const BorderRadius.horizontal(
|
||||
right: Radius.circular(8),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -142,12 +224,25 @@ class _MoodRatingWidgetState extends State<MoodRatingWidget> {
|
||||
setState(() {
|
||||
currentSelectedRating[selectedIndex] = value;
|
||||
});
|
||||
EasyDebounce.debounce('change-satiety', const Duration(milliseconds: 250), () {
|
||||
context.read<DiaryCubit>().fillMood(diaryId: state.diary.id, mood: currentSelectedRating[selectedIndex].toInt() - 1);
|
||||
});
|
||||
EasyDebounce.debounce(
|
||||
'change-satiety',
|
||||
const Duration(milliseconds: 250),
|
||||
() {
|
||||
context.read<DiaryCubit>().fillMood(
|
||||
diaryId: state.diary.id,
|
||||
mood:
|
||||
currentSelectedRating[selectedIndex].toInt() -
|
||||
1,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
SvgPicture.asset('assets/icons/mood_2.svg', height: 32, width: 32),
|
||||
SvgPicture.asset(
|
||||
'assets/icons/mood_2.svg',
|
||||
height: 32,
|
||||
width: 32,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -52,7 +52,9 @@ class _PhotoGalleryWidgetState extends State<PhotoGalleryWidget> {
|
||||
AppRouter.navigatorKey.currentContext?.pop();
|
||||
|
||||
final picker = ImagePicker();
|
||||
XFile? image = await picker.pickImage(source: ImageSource.camera);
|
||||
XFile? image = await picker.pickImage(
|
||||
source: ImageSource.camera,
|
||||
);
|
||||
if (image != null) {
|
||||
diaryCubit.uploadPhotos(
|
||||
files: [File(image.path)],
|
||||
@@ -116,7 +118,8 @@ class _PhotoGalleryWidgetState extends State<PhotoGalleryWidget> {
|
||||
builder: (context, state) {
|
||||
if (state is DiaryStateLoaded) {
|
||||
int mealIndex = state.selectedMeal - 1;
|
||||
if (state.diary.meals[mealIndex].photos.isEmpty && !state.isConsultantView && !state.isDiaryFinished && !state.isDiaryCommented) {
|
||||
if (state.diary.meals[mealIndex].photos.isEmpty &&
|
||||
state.canEditDiary) {
|
||||
return GestureDetector(
|
||||
onTap: () async {
|
||||
showImagePicker(state.diary);
|
||||
@@ -140,10 +143,11 @@ class _PhotoGalleryWidgetState extends State<PhotoGalleryWidget> {
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: '${sl<AppConfig>().baseUrl}/storage/${state.diary.meals[mealIndex].photos[currentSelectedPhotoIndex].path}',
|
||||
imageUrl:
|
||||
'${sl<AppConfig>().baseUrl}/storage/${state.diary.meals[mealIndex].photos[currentSelectedPhotoIndex].path}',
|
||||
),
|
||||
),
|
||||
if (!state.isConsultantView && !state.isDiaryFinished && !state.isDiaryCommented)
|
||||
if (state.canEditDiary)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: IconButton.filled(
|
||||
@@ -153,12 +157,24 @@ class _PhotoGalleryWidgetState extends State<PhotoGalleryWidget> {
|
||||
),
|
||||
onPressed: () async {
|
||||
await diaryCubit.deletePhoto(
|
||||
photo: state.diary.meals[mealIndex].photos[currentSelectedPhotoIndex],
|
||||
photo: state
|
||||
.diary
|
||||
.meals[mealIndex]
|
||||
.photos[currentSelectedPhotoIndex],
|
||||
diaryId: state.diary.id,
|
||||
);
|
||||
setState(() {
|
||||
if (currentSelectedPhotoIndex == state.diary.meals[mealIndex].photos.length - 1 && currentSelectedPhotoIndex != 0) {
|
||||
currentSelectedPhotoIndex = state.diary.meals[mealIndex].photos.length - 2;
|
||||
if (currentSelectedPhotoIndex ==
|
||||
state
|
||||
.diary
|
||||
.meals[mealIndex]
|
||||
.photos
|
||||
.length -
|
||||
1 &&
|
||||
currentSelectedPhotoIndex != 0) {
|
||||
currentSelectedPhotoIndex =
|
||||
state.diary.meals[mealIndex].photos.length -
|
||||
2;
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -178,8 +194,13 @@ class _PhotoGalleryWidgetState extends State<PhotoGalleryWidget> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
for (int i = 0; i < state.diary.meals[mealIndex].photos.length + 1; i++) ...[
|
||||
if (i == state.diary.meals[mealIndex].photos.length && !state.isConsultantView && !state.isDiaryFinished && !state.isDiaryCommented)
|
||||
for (
|
||||
int i = 0;
|
||||
i < state.diary.meals[mealIndex].photos.length + 1;
|
||||
i++
|
||||
) ...[
|
||||
if (i == state.diary.meals[mealIndex].photos.length &&
|
||||
state.canEditDiary)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: InkWell(
|
||||
@@ -212,13 +233,15 @@ class _PhotoGalleryWidgetState extends State<PhotoGalleryWidget> {
|
||||
height: 52,
|
||||
width: 52,
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: '${sl<AppConfig>().baseUrl}/storage/${state.diary.meals[mealIndex].photos[i].path}',
|
||||
imageUrl:
|
||||
'${sl<AppConfig>().baseUrl}/storage/${state.diary.meals[mealIndex].photos[i].path}',
|
||||
fit: BoxFit.fitWidth,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (i < state.diary.meals[mealIndex].photos.length - 1) const SizedBox(width: 8),
|
||||
if (i < state.diary.meals[mealIndex].photos.length - 1)
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:dotted_border/dotted_border.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/features/consultations/models/consultation_model.dart';
|
||||
import 'package:eatvio_app/features/diary/models/consultant_review_model.dart';
|
||||
import 'package:eatvio_app/features/diary/models/diary_day_model.dart';
|
||||
import 'package:eatvio_app/features/diary/presentation/bloc/diary_cubit.dart';
|
||||
@@ -32,17 +30,26 @@ class _ProductWidgetState extends State<ProductWidget> {
|
||||
super.initState();
|
||||
diaryCubit = context.read<DiaryCubit>();
|
||||
portionName = widget.portion.portion == 'gr' ? 'гр' : 'шт';
|
||||
isEaten = widget.portion.portionState == PortionState.eatenFromDiet ? true : false;
|
||||
isEaten = widget.portion.portionState == PortionState.eatenFromDiet
|
||||
? true
|
||||
: false;
|
||||
}
|
||||
|
||||
Widget _buildNutritionalInfo() {
|
||||
if (widget.portion.productNutritionalValueId == null) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Text(
|
||||
'Продукт отправлен на модерацию',
|
||||
style: TextStyle(color: Color(0xFF2F80ED), fontSize: 12, fontWeight: FontWeight.w500),
|
||||
style: TextStyle(
|
||||
color: Color(0xFF2F80ED),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -57,7 +64,8 @@ class _ProductWidgetState extends State<ProductWidget> {
|
||||
calories = (caloriesInfo.calories * widget.portion.quantity) / 100;
|
||||
proteins = (caloriesInfo.proteins * widget.portion.quantity) / 100;
|
||||
fats = (caloriesInfo.fats * widget.portion.quantity) / 100;
|
||||
carbohydrates = (caloriesInfo.carbohydrates * widget.portion.quantity) / 100;
|
||||
carbohydrates =
|
||||
(caloriesInfo.carbohydrates * widget.portion.quantity) / 100;
|
||||
} else {
|
||||
calories = caloriesInfo.energyOnPortion;
|
||||
proteins = caloriesInfo.proteinsOnPortion;
|
||||
@@ -81,10 +89,17 @@ class _ProductWidgetState extends State<ProductWidget> {
|
||||
Widget _buildNutritionChip(String label, int value) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(8)),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'$label $value',
|
||||
style: const TextStyle(color: Color(0xFF6D6F71), fontSize: 12, fontWeight: FontWeight.w500),
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF6D6F71),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -93,16 +108,24 @@ class _ProductWidgetState extends State<ProductWidget> {
|
||||
void didUpdateWidget(ProductWidget oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
portionName = widget.portion.portion == 'gr' ? 'гр' : 'шт';
|
||||
isEaten = widget.portion.portionState == PortionState.eatenFromDiet ? true : false;
|
||||
isEaten = widget.portion.portionState == PortionState.eatenFromDiet
|
||||
? true
|
||||
: false;
|
||||
}
|
||||
|
||||
Future updateProductInMeal(DiaryStateLoaded state, PortionModel portion) {
|
||||
return showModalBottomSheet(
|
||||
useRootNavigator: true,
|
||||
context: context,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(12))),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
|
||||
),
|
||||
isScrollControlled: true,
|
||||
builder: (context) => AddProductModal(state: state, portion: portion, diaryCubit: diaryCubit),
|
||||
builder: (context) => AddProductModal(
|
||||
state: state,
|
||||
portion: portion,
|
||||
diaryCubit: diaryCubit,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -113,33 +136,36 @@ class _ProductWidgetState extends State<ProductWidget> {
|
||||
if (state is DiaryStateLoadedTomorrowWithDiet) {
|
||||
return ListTile(
|
||||
horizontalTitleGap: 0,
|
||||
contentPadding: widget.portion.fromDiet ? const EdgeInsets.only(right: 24, left: 12) : null,
|
||||
contentPadding: widget.portion.fromDiet
|
||||
? const EdgeInsets.only(right: 24, left: 12)
|
||||
: null,
|
||||
title: Text(widget.portion.productName),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('${widget.portion.quantity} ${widget.portion.portion == 'gr' ? 'гр' : 'шт'}'),
|
||||
Text(
|
||||
'${widget.portion.quantity} ${widget.portion.portion == 'gr' ? 'гр' : 'шт'}',
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
_buildNutritionalInfo(),
|
||||
],
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
tileColor: const Color(0xFFF6FAFF),
|
||||
);
|
||||
}
|
||||
if (state is DiaryStateLoaded) {
|
||||
final isDiaryCommented =
|
||||
state.diary.typeOfDiary.diaryState == DiaryStateEnum.commented ||
|
||||
state.diary.typeOfDiary.diaryState == DiaryStateEnum.lastDayCommented ||
|
||||
state.diary.typeOfDiary.diaryState == DiaryStateEnum.reviewedByUser;
|
||||
final isDiaryFinished =
|
||||
state.diary.typeOfDiary.diaryState == DiaryStateEnum.finished || state.diary.typeOfDiary.diaryState == DiaryStateEnum.lastDayFinished;
|
||||
final canEditPortion = !state.isConsultantView && !isDiaryCommented && !isDiaryFinished && !widget.portion.fromDiet;
|
||||
final canEditPortion = state.canEditDiary && !widget.portion.fromDiet;
|
||||
final consultantComments = state.consultantReview?.consultantComments;
|
||||
final canMarkAsEaten = !state.isConsultantView && !isDiaryCommented && !isDiaryFinished;
|
||||
final canMarkAsEaten = state.canEditDiary;
|
||||
PortionComment? portionComment;
|
||||
if (consultantComments != null) {
|
||||
portionComment = consultantComments.portionsComments.firstWhereOrNull((elem) => elem.portionId == widget.portion.id);
|
||||
portionComment = consultantComments.portionsComments
|
||||
.firstWhereOrNull(
|
||||
(elem) => elem.portionId == widget.portion.id,
|
||||
);
|
||||
}
|
||||
if (widget.portion.portionState == PortionState.eatenDeleted) {
|
||||
return DottedBorder(
|
||||
@@ -151,27 +177,41 @@ class _ProductWidgetState extends State<ProductWidget> {
|
||||
color: Color(0xFF6D6F71),
|
||||
),
|
||||
child: ListTile(
|
||||
onTap: () => diaryCubit.restore(diaryId: state.diary.id, mealId: state.selectedMeal, portionId: widget.portion.id),
|
||||
trailing: SvgPicture.asset('assets/icons/revert_product.svg'),
|
||||
onTap: state.canEditDiary
|
||||
? () => diaryCubit.restore(
|
||||
diaryId: state.diary.id,
|
||||
mealId: state.selectedMeal,
|
||||
portionId: widget.portion.id,
|
||||
)
|
||||
: null,
|
||||
trailing: state.canEditDiary
|
||||
? SvgPicture.asset('assets/icons/revert_product.svg')
|
||||
: null,
|
||||
horizontalTitleGap: 0,
|
||||
contentPadding: widget.portion.fromDiet ? const EdgeInsets.only(right: 24, left: 4) : null,
|
||||
contentPadding: widget.portion.fromDiet
|
||||
? const EdgeInsets.only(right: 24, left: 4)
|
||||
: null,
|
||||
title: Text(widget.portion.productName),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('${widget.portion.quantity} ${widget.portion.portion == 'gr' ? 'гр' : 'шт'}'),
|
||||
Text(
|
||||
'${widget.portion.quantity} ${widget.portion.portion == 'gr' ? 'гр' : 'шт'}',
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
_buildNutritionalInfo(),
|
||||
],
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
// ignore: use_full_hex_values_for_flutter_colors
|
||||
tileColor: const Color(0xfff6faff03).withValues(alpha: 0.01),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
Widget? trailing;
|
||||
if (state.isConsultantView && !isDiaryCommented) {
|
||||
if (state.isConsultantView && !state.isDiaryCommented) {
|
||||
trailing = GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
@@ -180,13 +220,15 @@ class _ProductWidgetState extends State<ProductWidget> {
|
||||
},
|
||||
child: SvgPicture.asset('assets/icons/edit.svg'),
|
||||
);
|
||||
} else if (!isDiaryCommented && !widget.portion.fromDiet) {
|
||||
} else if (canEditPortion) {
|
||||
trailing = const Icon(Icons.more_vert_rounded);
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
ListTile(
|
||||
onTap: canEditPortion ? () => updateProductInMeal(state, widget.portion) : null,
|
||||
onTap: canEditPortion
|
||||
? () => updateProductInMeal(state, widget.portion)
|
||||
: null,
|
||||
trailing: trailing,
|
||||
leading: widget.portion.fromDiet
|
||||
? Transform.scale(
|
||||
@@ -199,35 +241,55 @@ class _ProductWidgetState extends State<ProductWidget> {
|
||||
isEaten = value!;
|
||||
});
|
||||
if (isEaten) {
|
||||
diaryCubit.markEaten(diaryId: state.diary.id, mealId: state.selectedMeal, portionId: widget.portion.id);
|
||||
diaryCubit.markEaten(
|
||||
diaryId: state.diary.id,
|
||||
mealId: state.selectedMeal,
|
||||
portionId: widget.portion.id,
|
||||
);
|
||||
} else {
|
||||
diaryCubit.unmarkEaten(diaryId: state.diary.id, mealId: state.selectedMeal, portionId: widget.portion.id);
|
||||
diaryCubit.unmarkEaten(
|
||||
diaryId: state.diary.id,
|
||||
mealId: state.selectedMeal,
|
||||
portionId: widget.portion.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
: null,
|
||||
activeColor: appTheme.blueA400,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
side: const BorderSide(color: Color(0xFF809CB9)),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
horizontalTitleGap: 0,
|
||||
contentPadding: widget.portion.fromDiet ? const EdgeInsets.only(right: 24, left: 4) : null,
|
||||
contentPadding: widget.portion.fromDiet
|
||||
? const EdgeInsets.only(right: 24, left: 4)
|
||||
: null,
|
||||
title: Text(widget.portion.productName),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('${widget.portion.quantity} ${widget.portion.portion == 'gr' ? 'гр' : 'шт'}'),
|
||||
Text(
|
||||
'${widget.portion.quantity} ${widget.portion.portion == 'gr' ? 'гр' : 'шт'}',
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
_buildNutritionalInfo(),
|
||||
],
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
tileColor: const Color(0xFFF6FAFF),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: PortionCommentWidget(enabled: showCommentWidget, portionId: widget.portion.id, comment: portionComment?.comment),
|
||||
child: PortionCommentWidget(
|
||||
enabled: showCommentWidget,
|
||||
portionId: widget.portion.id,
|
||||
comment: portionComment?.comment,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -26,7 +26,8 @@ class _SatietyRatingWidgetState extends State<SatietyRatingWidget> {
|
||||
final meals = state.diary.meals;
|
||||
if (selectedIndex >= 0 && selectedIndex < meals.length) {
|
||||
if (isFirstFetch[selectedIndex]) {
|
||||
currentSelectedRating[selectedIndex] = meals[selectedIndex].satiety.toDouble() + 1;
|
||||
currentSelectedRating[selectedIndex] =
|
||||
meals[selectedIndex].satiety.toDouble() + 1;
|
||||
isFirstFetch[selectedIndex] = false;
|
||||
}
|
||||
}
|
||||
@@ -36,21 +37,32 @@ class _SatietyRatingWidgetState extends State<SatietyRatingWidget> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
state.isConsultantView ? 'Сытость пользователя после приема пищи' : 'Ваша сытость после приёма пищи',
|
||||
state.isConsultantView
|
||||
? 'Сытость пользователя после приема пищи'
|
||||
: 'Ваша сытость после приёма пищи',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
SvgPicture.asset('assets/icons/satiety_emoji_left.svg', height: 32, width: 32),
|
||||
SvgPicture.asset(
|
||||
'assets/icons/satiety_emoji_left.svg',
|
||||
height: 32,
|
||||
width: 32,
|
||||
),
|
||||
RatingBar.builder(
|
||||
ignoreGestures: state.isConsultantView || state.isDiaryFinished || state.isDiaryCommented,
|
||||
initialRating: (selectedIndex >= 0 && selectedIndex < state.diary.meals.length)
|
||||
? state.diary.meals[selectedIndex].satiety.toDouble() + 1
|
||||
ignoreGestures: !state.canEditDiary,
|
||||
initialRating:
|
||||
(selectedIndex >= 0 &&
|
||||
selectedIndex < state.diary.meals.length)
|
||||
? state.diary.meals[selectedIndex].satiety
|
||||
.toDouble() +
|
||||
1
|
||||
: 3,
|
||||
itemCount: 6,
|
||||
itemSize: MediaQuery.of(context).size.width / 6 - (124 / 6),
|
||||
itemSize:
|
||||
MediaQuery.of(context).size.width / 6 - (124 / 6),
|
||||
updateOnDrag: true,
|
||||
itemBuilder: (context, index) {
|
||||
switch (index) {
|
||||
@@ -58,82 +70,154 @@ class _SatietyRatingWidgetState extends State<SatietyRatingWidget> {
|
||||
return Container(
|
||||
width: MediaQuery.of(context).size.width / 6,
|
||||
height: 24,
|
||||
margin: currentSelectedRating[selectedIndex] == 1 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
||||
margin: currentSelectedRating[selectedIndex] == 1
|
||||
? const EdgeInsets.symmetric(horizontal: 2)
|
||||
: null,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFC82222),
|
||||
border: currentSelectedRating[selectedIndex] == 1
|
||||
? Border.all(width: 2, color: const Color(0xFFAA3333), strokeAlign: BorderSide.strokeAlignOutside)
|
||||
border:
|
||||
currentSelectedRating[selectedIndex] == 1
|
||||
? Border.all(
|
||||
width: 2,
|
||||
color: const Color(0xFFAA3333),
|
||||
strokeAlign:
|
||||
BorderSide.strokeAlignOutside,
|
||||
)
|
||||
: null,
|
||||
borderRadius: currentSelectedRating[selectedIndex] == 1
|
||||
? const BorderRadius.horizontal(left: Radius.circular(8), right: Radius.circular(2))
|
||||
: const BorderRadius.horizontal(left: Radius.circular(8)),
|
||||
borderRadius:
|
||||
currentSelectedRating[selectedIndex] == 1
|
||||
? const BorderRadius.horizontal(
|
||||
left: Radius.circular(8),
|
||||
right: Radius.circular(2),
|
||||
)
|
||||
: const BorderRadius.horizontal(
|
||||
left: Radius.circular(8),
|
||||
),
|
||||
),
|
||||
);
|
||||
case 1:
|
||||
return Container(
|
||||
width: MediaQuery.of(context).size.width / 6,
|
||||
height: 24,
|
||||
margin: currentSelectedRating[selectedIndex] == 2 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
||||
margin: currentSelectedRating[selectedIndex] == 2
|
||||
? const EdgeInsets.symmetric(horizontal: 2)
|
||||
: null,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFF3E3E),
|
||||
border: currentSelectedRating[selectedIndex] == 2
|
||||
? Border.all(width: 2, color: const Color(0xFF550000), strokeAlign: BorderSide.strokeAlignOutside)
|
||||
border:
|
||||
currentSelectedRating[selectedIndex] == 2
|
||||
? Border.all(
|
||||
width: 2,
|
||||
color: const Color(0xFF550000),
|
||||
strokeAlign:
|
||||
BorderSide.strokeAlignOutside,
|
||||
)
|
||||
: null,
|
||||
borderRadius:
|
||||
currentSelectedRating[selectedIndex] == 2
|
||||
? BorderRadius.circular(2)
|
||||
: null,
|
||||
borderRadius: currentSelectedRating[selectedIndex] == 2 ? BorderRadius.circular(2) : null,
|
||||
),
|
||||
);
|
||||
case 2:
|
||||
return Container(
|
||||
width: MediaQuery.of(context).size.width / 6,
|
||||
height: 24,
|
||||
margin: currentSelectedRating[selectedIndex] == 3 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
||||
margin: currentSelectedRating[selectedIndex] == 3
|
||||
? const EdgeInsets.symmetric(horizontal: 2)
|
||||
: null,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8C926),
|
||||
border: currentSelectedRating[selectedIndex] == 3
|
||||
? Border.all(width: 2, color: const Color(0xFF8C5D00), strokeAlign: BorderSide.strokeAlignOutside)
|
||||
border:
|
||||
currentSelectedRating[selectedIndex] == 3
|
||||
? Border.all(
|
||||
width: 2,
|
||||
color: const Color(0xFF8C5D00),
|
||||
strokeAlign:
|
||||
BorderSide.strokeAlignOutside,
|
||||
)
|
||||
: null,
|
||||
borderRadius:
|
||||
currentSelectedRating[selectedIndex] == 3
|
||||
? BorderRadius.circular(2)
|
||||
: null,
|
||||
borderRadius: currentSelectedRating[selectedIndex] == 3 ? BorderRadius.circular(2) : null,
|
||||
),
|
||||
);
|
||||
case 3:
|
||||
return Container(
|
||||
width: MediaQuery.of(context).size.width / 6,
|
||||
height: 24,
|
||||
margin: currentSelectedRating[selectedIndex] == 4 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
||||
margin: currentSelectedRating[selectedIndex] == 4
|
||||
? const EdgeInsets.symmetric(horizontal: 2)
|
||||
: null,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF3BF),
|
||||
border: currentSelectedRating[selectedIndex] == 4
|
||||
? Border.all(width: 2, color: const Color(0xFFB5911B), strokeAlign: BorderSide.strokeAlignOutside)
|
||||
border:
|
||||
currentSelectedRating[selectedIndex] == 4
|
||||
? Border.all(
|
||||
width: 2,
|
||||
color: const Color(0xFFB5911B),
|
||||
strokeAlign:
|
||||
BorderSide.strokeAlignOutside,
|
||||
)
|
||||
: null,
|
||||
borderRadius:
|
||||
currentSelectedRating[selectedIndex] == 4
|
||||
? BorderRadius.circular(2)
|
||||
: null,
|
||||
borderRadius: currentSelectedRating[selectedIndex] == 4 ? BorderRadius.circular(2) : null,
|
||||
),
|
||||
);
|
||||
case 4:
|
||||
return Container(
|
||||
width: MediaQuery.of(context).size.width / 6,
|
||||
height: 24,
|
||||
margin: currentSelectedRating[selectedIndex] == 5 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
||||
margin: currentSelectedRating[selectedIndex] == 5
|
||||
? const EdgeInsets.symmetric(horizontal: 2)
|
||||
: null,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF62E833),
|
||||
border: currentSelectedRating[selectedIndex] == 5
|
||||
? Border.all(width: 2, color: const Color(0xFF276918), strokeAlign: BorderSide.strokeAlignOutside)
|
||||
border:
|
||||
currentSelectedRating[selectedIndex] == 5
|
||||
? Border.all(
|
||||
width: 2,
|
||||
color: const Color(0xFF276918),
|
||||
strokeAlign:
|
||||
BorderSide.strokeAlignOutside,
|
||||
)
|
||||
: null,
|
||||
borderRadius:
|
||||
currentSelectedRating[selectedIndex] == 5
|
||||
? BorderRadius.circular(2)
|
||||
: null,
|
||||
borderRadius: currentSelectedRating[selectedIndex] == 5 ? BorderRadius.circular(2) : null,
|
||||
),
|
||||
);
|
||||
default:
|
||||
return Container(
|
||||
width: MediaQuery.of(context).size.width / 6,
|
||||
height: 24,
|
||||
margin: currentSelectedRating[selectedIndex] == 6 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
||||
margin: currentSelectedRating[selectedIndex] == 6
|
||||
? const EdgeInsets.symmetric(horizontal: 2)
|
||||
: null,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF4DCE20),
|
||||
border: currentSelectedRating[selectedIndex] == 6
|
||||
? Border.all(width: 2, color: const Color(0xFF184D12), strokeAlign: BorderSide.strokeAlignOutside)
|
||||
border:
|
||||
currentSelectedRating[selectedIndex] == 6
|
||||
? Border.all(
|
||||
width: 2,
|
||||
color: const Color(0xFF184D12),
|
||||
strokeAlign:
|
||||
BorderSide.strokeAlignOutside,
|
||||
)
|
||||
: null,
|
||||
borderRadius: currentSelectedRating[selectedIndex] == 6
|
||||
? const BorderRadius.horizontal(left: Radius.circular(2), right: Radius.circular(8))
|
||||
: const BorderRadius.horizontal(right: Radius.circular(8)),
|
||||
borderRadius:
|
||||
currentSelectedRating[selectedIndex] == 6
|
||||
? const BorderRadius.horizontal(
|
||||
left: Radius.circular(2),
|
||||
right: Radius.circular(8),
|
||||
)
|
||||
: const BorderRadius.horizontal(
|
||||
right: Radius.circular(8),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -143,12 +227,25 @@ class _SatietyRatingWidgetState extends State<SatietyRatingWidget> {
|
||||
setState(() {
|
||||
currentSelectedRating[selectedIndex] = value;
|
||||
});
|
||||
EasyDebounce.debounce('change-satiety', const Duration(milliseconds: 250), () {
|
||||
context.read<DiaryCubit>().fillSatiety(diaryId: state.diary.id, satiety: currentSelectedRating[selectedIndex].toInt() - 1);
|
||||
});
|
||||
EasyDebounce.debounce(
|
||||
'change-satiety',
|
||||
const Duration(milliseconds: 250),
|
||||
() {
|
||||
context.read<DiaryCubit>().fillSatiety(
|
||||
diaryId: state.diary.id,
|
||||
satiety:
|
||||
currentSelectedRating[selectedIndex].toInt() -
|
||||
1,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
SvgPicture.asset('assets/icons/satiety_emoji_right.svg', height: 32, width: 32),
|
||||
SvgPicture.asset(
|
||||
'assets/icons/satiety_emoji_right.svg',
|
||||
height: 32,
|
||||
width: 32,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -46,7 +46,8 @@ class _TimeOfMealPickerWidgetState extends State<TimeOfMealPickerWidget> {
|
||||
Navigator.of(context).pop();
|
||||
diaryCubit.fillTime(
|
||||
diaryId: diaryId,
|
||||
time: '${value.hour.toString().padLeft(2, '0')}:${value.minute.toString().padLeft(2, '0')}:00',
|
||||
time:
|
||||
'${value.hour.toString().padLeft(2, '0')}:${value.minute.toString().padLeft(2, '0')}:00',
|
||||
);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
@@ -69,7 +70,7 @@ class _TimeOfMealPickerWidgetState extends State<TimeOfMealPickerWidget> {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 30)
|
||||
const SizedBox(height: 30),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -98,7 +99,9 @@ class _TimeOfMealPickerWidgetState extends State<TimeOfMealPickerWidget> {
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
onTap: state.isConsultantView || state.isDiaryFinished || state.isDiaryCommented ? null : () => showTimePicker(state.diary.id),
|
||||
onTap: state.canEditDiary
|
||||
? () => showTimePicker(state.diary.id)
|
||||
: null,
|
||||
borderRadius: BorderRadius.circular(100),
|
||||
child: Ink(
|
||||
height: 32,
|
||||
@@ -110,7 +113,16 @@ class _TimeOfMealPickerWidgetState extends State<TimeOfMealPickerWidget> {
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
state.diary.meals[state.selectedMeal - 1].time.substring(0, state.diary.meals[state.selectedMeal - 1].time.length - 3),
|
||||
state.diary.meals[state.selectedMeal - 1].time
|
||||
.substring(
|
||||
0,
|
||||
state
|
||||
.diary
|
||||
.meals[state.selectedMeal - 1]
|
||||
.time
|
||||
.length -
|
||||
3,
|
||||
),
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF6D6F71),
|
||||
fontSize: 17,
|
||||
|
||||
@@ -296,6 +296,7 @@ class _AboutDietScreenState extends State<AboutDietScreen> {
|
||||
actions: [
|
||||
CustomPopup(
|
||||
isAuthor: dietState.diet.author.authorIsMe,
|
||||
isAuthorAdmin: dietState.diet.author.isAdmin,
|
||||
path: 'diet/${dietState.diet.slug}',
|
||||
complainedContentType: ComplainedContent.diet,
|
||||
id: dietState.diet.id,
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/features/diets/presentation/widgets/info_bouble.dart';
|
||||
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||
import 'package:eatvio_app/core/utils/url_utils.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/features/profile/models/profile_model.dart';
|
||||
import 'package:eatvio_app/features/user_details/presentation/user_details_screen.dart';
|
||||
@@ -11,7 +12,14 @@ import 'package:flutter_svg/svg.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class AuthorCard extends StatelessWidget {
|
||||
const AuthorCard({super.key, required this.author, required this.title, this.backgroundColor, this.titleStyle, this.customerProfile});
|
||||
const AuthorCard({
|
||||
super.key,
|
||||
required this.author,
|
||||
required this.title,
|
||||
this.backgroundColor,
|
||||
this.titleStyle,
|
||||
this.customerProfile,
|
||||
});
|
||||
final Author author;
|
||||
final String title;
|
||||
final Color? backgroundColor;
|
||||
@@ -21,15 +29,28 @@ class AuthorCard extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed('profileScreen', extra: customerProfile != null ? customerProfile!.slug : author.authorSlug);
|
||||
GoRouter.of(context).pushNamed(
|
||||
'profileScreen',
|
||||
extra: customerProfile != null
|
||||
? customerProfile!.slug
|
||||
: author.authorSlug,
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
width: MediaQuery.sizeOf(context).width,
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(12), color: backgroundColor ?? appTheme.cardBackground),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: backgroundColor ?? appTheme.cardBackground,
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16, top: 16, bottom: 24, right: 16),
|
||||
padding: const EdgeInsets.only(
|
||||
left: 16,
|
||||
top: 16,
|
||||
bottom: 24,
|
||||
right: 16,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
ListTile(
|
||||
@@ -41,28 +62,43 @@ class AuthorCard extends StatelessWidget {
|
||||
shape: BoxShape.circle,
|
||||
image: DecorationImage(
|
||||
image: NetworkImage(
|
||||
customerProfile != null
|
||||
? customerProfile!.profilePhotoUrl.contains('https')
|
||||
? customerProfile!.profilePhotoUrl
|
||||
: sl<AppConfig>().baseUrl + customerProfile!.profilePhotoUrl
|
||||
: author.authorProfileIcon.contains('https')
|
||||
? author.authorProfileIcon
|
||||
: sl<AppConfig>().baseUrl + author.authorProfileIcon,
|
||||
joinBaseUrl(
|
||||
sl<AppConfig>().baseUrl,
|
||||
customerProfile != null
|
||||
? customerProfile!.profilePhotoUrl
|
||||
: author.authorProfileIcon,
|
||||
),
|
||||
),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
customerProfile != null ? customerProfile!.name : author.authorName,
|
||||
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
|
||||
customerProfile != null
|
||||
? customerProfile!.name
|
||||
: author.authorName,
|
||||
style: TextStyles.textLSemibold.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
title,
|
||||
style:
|
||||
titleStyle ??
|
||||
TextStyles.textSSemibold.copyWith(
|
||||
color: appTheme.gray,
|
||||
),
|
||||
),
|
||||
title: Text(title, style: titleStyle ?? TextStyles.textSSemibold.copyWith(color: appTheme.gray)),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
if (customerProfile == null) ...[
|
||||
Text('Консультант', style: TextStyles.textMMedium.copyWith(color: appTheme.black)),
|
||||
Text(
|
||||
'Консультант',
|
||||
style: TextStyles.textMMedium.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
|
||||
if (author.authorPlaceInRating != null)
|
||||
@@ -70,20 +106,35 @@ class AuthorCard extends StatelessWidget {
|
||||
radius: 12,
|
||||
backgroundColor: appTheme.cardBackground,
|
||||
children: [
|
||||
Text('${author.authorPlaceInRating}-й в рейтинге', style: TextStyles.textMMediumAuto.copyWith(color: appTheme.blueA400)),
|
||||
Text(
|
||||
'${author.authorPlaceInRating}-й в рейтинге',
|
||||
style: TextStyles.textMMediumAuto.copyWith(
|
||||
color: appTheme.blueA400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
] else
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed(UserDetailsScreen.name, extra: customerProfile!.slug);
|
||||
GoRouter.of(context).pushNamed(
|
||||
UserDetailsScreen.name,
|
||||
extra: customerProfile!.slug,
|
||||
);
|
||||
},
|
||||
child: InfoBouble(
|
||||
radius: 12,
|
||||
backgroundColor: appTheme.cardBackground,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [Text('Анкета пользователя', style: TextStyles.textMMediumAuto.copyWith(color: appTheme.blueA400))],
|
||||
children: [
|
||||
Text(
|
||||
'Анкета пользователя',
|
||||
style: TextStyles.textMMediumAuto.copyWith(
|
||||
color: appTheme.blueA400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -98,8 +149,18 @@ class AuthorCard extends StatelessWidget {
|
||||
child: Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(color: appTheme.whiteA700, borderRadius: BorderRadius.circular(8)),
|
||||
child: Center(child: SvgPicture.asset('assets/icons/arrow.svg', width: 12, height: 12, fit: BoxFit.none)),
|
||||
decoration: BoxDecoration(
|
||||
color: appTheme.whiteA700,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Center(
|
||||
child: SvgPicture.asset(
|
||||
'assets/icons/arrow.svg',
|
||||
width: 12,
|
||||
height: 12,
|
||||
fit: BoxFit.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -14,12 +14,21 @@ import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
class MoreOptions extends StatelessWidget {
|
||||
const MoreOptions({super.key, this.disableActions, required this.path, this.complainedContentType, this.slug, this.id});
|
||||
const MoreOptions({
|
||||
super.key,
|
||||
this.disableActions,
|
||||
required this.path,
|
||||
this.complainedContentType,
|
||||
this.slug,
|
||||
this.id,
|
||||
this.isAuthorAdmin = false,
|
||||
});
|
||||
final ComplainedContent? complainedContentType;
|
||||
final int? id;
|
||||
final String? slug;
|
||||
final String path;
|
||||
final bool? disableActions;
|
||||
final bool isAuthorAdmin;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -83,40 +92,42 @@ class MoreOptions extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12.0),
|
||||
child: CustomDivider(),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: disableActions == true
|
||||
? null
|
||||
: () {
|
||||
if (complainedContentType != null) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => ComplainingBottomSheet(
|
||||
complainedContentType: complainedContentType!,
|
||||
id: id,
|
||||
slug: slug,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
s.complain,
|
||||
style: TextStyles.textMMedium.copyWith(
|
||||
color: appTheme.black,
|
||||
if (!isAuthorAdmin) ...[
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12.0),
|
||||
child: CustomDivider(),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: disableActions == true
|
||||
? null
|
||||
: () {
|
||||
if (complainedContentType != null) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => ComplainingBottomSheet(
|
||||
complainedContentType: complainedContentType!,
|
||||
id: id,
|
||||
slug: slug,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
s.complain,
|
||||
style: TextStyles.textMMedium.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SvgPicture.asset('assets/icons/complain.svg', width: 16, height: 20),
|
||||
],
|
||||
SvgPicture.asset('assets/icons/complain.svg', width: 16, height: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -86,6 +86,8 @@ int parseDuration(dynamic duration) {
|
||||
|
||||
@freezed
|
||||
abstract class Author with _$Author {
|
||||
const Author._();
|
||||
|
||||
const factory Author({
|
||||
@JsonKey(name: "author_slug") required String authorSlug,
|
||||
@JsonKey(name: "author_name") required String authorName,
|
||||
@@ -93,9 +95,12 @@ abstract class Author with _$Author {
|
||||
@JsonKey(name: "author_is_me") required bool authorIsMe,
|
||||
@JsonKey(name: "is_entity_subscribed_by_me") bool? isEntitySubscribedByMe,
|
||||
@JsonKey(name: "author_place_in_rating") int? authorPlaceInRating,
|
||||
@JsonKey(name: "author_roles") @Default([]) List<String> authorRoles,
|
||||
}) = _Author;
|
||||
|
||||
factory Author.fromJson(Map<String, dynamic> json) => _$AuthorFromJson(json);
|
||||
|
||||
bool get isAdmin => authorRoles.contains('admin');
|
||||
}
|
||||
|
||||
@freezed
|
||||
|
||||
@@ -26,16 +26,47 @@ class PostFeedMapper {
|
||||
totalViews: item.postViewsCount,
|
||||
totalComments: item.postCommentsCount,
|
||||
wasLikedByMe: item.postIsLikedByUser,
|
||||
images: item.images,
|
||||
images: item.images.where((image) => hasRenderableImage(image.url)).toList(),
|
||||
videoEmbedUrl: item.videoEmbedUrl,
|
||||
videoUrlThumb: item.videoUrlThumb,
|
||||
);
|
||||
}
|
||||
|
||||
static String resolveMediaUrl(String? url) {
|
||||
if (url == null || url.isEmpty) return '';
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) return url;
|
||||
if (url.startsWith('/')) return '${sl<AppConfig>().baseUrl}$url';
|
||||
if (url == null || url.isEmpty || isPlaceholderMediaUrl(url)) {
|
||||
return '';
|
||||
}
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return url;
|
||||
}
|
||||
if (url.startsWith('/')) {
|
||||
return '${sl<AppConfig>().baseUrl}$url';
|
||||
}
|
||||
return '${sl<AppConfig>().baseUrl}/storage/$url';
|
||||
}
|
||||
|
||||
static bool isPlaceholderMediaUrl(String url) {
|
||||
return url.toLowerCase().contains('element-image-placeholder');
|
||||
}
|
||||
|
||||
static bool hasRenderableImage(String? url) {
|
||||
if (url == null || url.trim().isEmpty) {
|
||||
return false;
|
||||
}
|
||||
return !isPlaceholderMediaUrl(url);
|
||||
}
|
||||
|
||||
static bool hasVisibleMedia({
|
||||
required String image,
|
||||
required List<FeedGalleryImage> images,
|
||||
String? videoEmbedUrl,
|
||||
}) {
|
||||
if (videoEmbedUrl != null && videoEmbedUrl.trim().isNotEmpty) {
|
||||
return true;
|
||||
}
|
||||
if (hasRenderableImage(image)) {
|
||||
return true;
|
||||
}
|
||||
return images.any((image) => hasRenderableImage(image.url));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,7 @@ class DietsCubit extends Cubit<DietsState> {
|
||||
}) async {
|
||||
try {
|
||||
_query = query;
|
||||
currentPage = 1;
|
||||
final type = _types[state.filterState.typeIndex];
|
||||
final duration = _durationValues[state.filterState.durationIndex];
|
||||
final sorting = _sortingValues[state.filterState.sortingIndex];
|
||||
|
||||
@@ -149,6 +149,7 @@ class PlansCubit extends Cubit<PlansState> {
|
||||
}) async {
|
||||
try {
|
||||
_query = query;
|
||||
currentPage = 1;
|
||||
final type = _types[state.filterState.typeIndex];
|
||||
final duration = _durationValues[state.filterState.durationIndex];
|
||||
final minPrice = state.filterState.priceFilter.$1;
|
||||
|
||||
@@ -46,6 +46,7 @@ class RecipesCubit extends Cubit<RecipesState> {
|
||||
}) async {
|
||||
try {
|
||||
_query = query;
|
||||
currentPage = 1;
|
||||
final recipesList = await feedRepository.getRecipes(
|
||||
tags: _selectedTags.toList(),
|
||||
query: query,
|
||||
|
||||
@@ -123,6 +123,19 @@ class _FeedScreenContentState extends State<FeedScreenContent> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _openCreateAndReloadFeed({
|
||||
required Future<Object?> Function() openCreate,
|
||||
required Future<void> Function() reload,
|
||||
}) async {
|
||||
final result = await openCreate();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
if (shouldReloadFeedAfterCreate(result)) {
|
||||
await reload();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
FlutterNativeSplash.remove();
|
||||
@@ -254,26 +267,43 @@ class _FeedScreenContentState extends State<FeedScreenContent> {
|
||||
const SizedBox(width: 12),
|
||||
switch (_selectedChipIndex) {
|
||||
1 when !hidePlans => AddItemButton(
|
||||
onTap: state.profile?.roles.contains('consultant') ?? false ? () => GoRouter.of(context).pushNamed(CreatePlanScreen.name) : null,
|
||||
onTap: state.profile?.roles.contains('consultant') ?? false
|
||||
? () => _openCreateAndReloadFeed(
|
||||
openCreate: () => GoRouter.of(context).pushNamed(CreatePlanScreen.name),
|
||||
reload: () => context.read<PlansCubit>().loadPlans(),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
2 when hidePlans => AddItemButton(
|
||||
onTap: state.profile?.roles.contains('consultant') ?? false ? () => GoRouter.of(context).pushNamed(RecipeCreateScreen.name) : null,
|
||||
onTap: state.profile?.roles.contains('consultant') ?? false
|
||||
? () => _openCreateAndReloadFeed(
|
||||
openCreate: () => GoRouter.of(context).pushNamed(RecipeCreateScreen.name),
|
||||
reload: () => context.read<RecipesCubit>().loadRecipesList(),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
3 when !hidePlans => AddItemButton(
|
||||
onTap: state.profile?.roles.contains('consultant') ?? false ? () => GoRouter.of(context).pushNamed(RecipeCreateScreen.name) : null,
|
||||
onTap: state.profile?.roles.contains('consultant') ?? false
|
||||
? () => _openCreateAndReloadFeed(
|
||||
openCreate: () => GoRouter.of(context).pushNamed(RecipeCreateScreen.name),
|
||||
reload: () => context.read<RecipesCubit>().loadRecipesList(),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
3 when hidePlans => AddItemButton(
|
||||
onTap: state.profile?.roles.contains('consultant') ?? false
|
||||
? () async {
|
||||
await GoRouter.of(context).pushNamed(CreateDietScreen.name, extra: {'isEditing': false});
|
||||
}
|
||||
? () => _openCreateAndReloadFeed(
|
||||
openCreate: () => GoRouter.of(context).pushNamed(CreateDietScreen.name, extra: {'isEditing': false}),
|
||||
reload: () => context.read<DietsCubit>().loadDiets(),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
4 when !hidePlans => AddItemButton(
|
||||
onTap: state.profile?.roles.contains('consultant') ?? false
|
||||
? () async {
|
||||
await GoRouter.of(context).pushNamed(CreateDietScreen.name);
|
||||
}
|
||||
? () => _openCreateAndReloadFeed(
|
||||
openCreate: () => GoRouter.of(context).pushNamed(CreateDietScreen.name),
|
||||
reload: () => context.read<DietsCubit>().loadDiets(),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
_ => const AddItemButton(key: AppKeys.feedScreenBlogPostCreationButton),
|
||||
@@ -301,6 +331,9 @@ class _FeedScreenContentState extends State<FeedScreenContent> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Create screens pop `true` (diet/plan) or `'active'` (recipe) after a successful publish.
|
||||
bool shouldReloadFeedAfterCreate(Object? result) => result == true || result == 'active';
|
||||
|
||||
enum ViewableContentType {
|
||||
plans,
|
||||
posts,
|
||||
|
||||
@@ -39,15 +39,17 @@ class BlogPostMediaContent extends StatefulWidget {
|
||||
class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
|
||||
static const _counterDisplayDuration = Duration(seconds: 1);
|
||||
|
||||
/// Landscape / video slide aspect (width / height).
|
||||
/// Fallback while the real photo size is unknown, and for video-only.
|
||||
static const _horizontalAspectRatio = 16 / 9;
|
||||
|
||||
/// Portrait slide aspect (width / height).
|
||||
static const _verticalAspectRatio = 3 / 4;
|
||||
|
||||
bool _isCounterVisible = false;
|
||||
Timer? _counterHideTimer;
|
||||
|
||||
final Map<String, double> _aspectByUrl = {};
|
||||
final Map<String, ImageStream> _aspectStreams = {};
|
||||
final Map<String, ImageStreamListener> _aspectListeners = {};
|
||||
|
||||
bool get _hasVideo =>
|
||||
widget.videoEmbedUrl != null && widget.videoEmbedUrl!.trim().isNotEmpty;
|
||||
|
||||
@@ -82,7 +84,6 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
|
||||
|
||||
bool get _shouldShowCounter => !_isCurrentSlideVideo || _isCounterVisible;
|
||||
|
||||
/// Height is fixed for the whole post from the first gallery image (web-like).
|
||||
String get _postOrientation {
|
||||
if (_gallery.isNotEmpty) {
|
||||
return _gallery.first.orientation;
|
||||
@@ -90,23 +91,89 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
|
||||
return 'horizontal';
|
||||
}
|
||||
|
||||
double _heightForAspect(double aspectRatio, double width) {
|
||||
if (aspectRatio <= 0) {
|
||||
return width / _horizontalAspectRatio;
|
||||
}
|
||||
return width / aspectRatio;
|
||||
}
|
||||
|
||||
double _heightForOrientation(String orientation, double width) {
|
||||
final aspectRatio = orientation == 'vertical'
|
||||
? _verticalAspectRatio
|
||||
: _horizontalAspectRatio;
|
||||
final maxHeight = MediaQuery.sizeOf(context).height * 0.7;
|
||||
return (width / aspectRatio).clamp(180.0, maxHeight);
|
||||
return _heightForAspect(aspectRatio, width);
|
||||
}
|
||||
|
||||
/// Window height follows the current photo's real size, not a fake 16:9 / 3:4 box.
|
||||
double _mediaHeight(double width) {
|
||||
final gallery = _gallery;
|
||||
if (gallery.isEmpty) {
|
||||
return _heightForOrientation(_postOrientation, width);
|
||||
}
|
||||
|
||||
final index = widget.currentGalleryIndex;
|
||||
final image = index < gallery.length ? gallery[index] : gallery.first;
|
||||
final aspect = _aspectByUrl[image.url];
|
||||
if (aspect != null && aspect > 0) {
|
||||
return _heightForAspect(aspect, width);
|
||||
}
|
||||
return _heightForOrientation(image.orientation, width);
|
||||
}
|
||||
|
||||
void _syncAspectListeners() {
|
||||
final urls = _gallery.map((image) => image.url).toSet();
|
||||
for (final url in _aspectStreams.keys.toList()) {
|
||||
if (urls.contains(url)) {
|
||||
continue;
|
||||
}
|
||||
final stream = _aspectStreams.remove(url);
|
||||
final listener = _aspectListeners.remove(url);
|
||||
if (stream != null && listener != null) {
|
||||
stream.removeListener(listener);
|
||||
}
|
||||
_aspectByUrl.remove(url);
|
||||
}
|
||||
|
||||
for (final url in urls) {
|
||||
if (_aspectStreams.containsKey(url)) {
|
||||
continue;
|
||||
}
|
||||
final stream = CachedNetworkImageProvider(
|
||||
url,
|
||||
).resolve(ImageConfiguration.empty);
|
||||
final listener = ImageStreamListener((info, _) {
|
||||
final aspect = info.image.width / info.image.height;
|
||||
if (!mounted || aspect <= 0 || _aspectByUrl[url] == aspect) {
|
||||
return;
|
||||
}
|
||||
setState(() => _aspectByUrl[url] = aspect);
|
||||
}, onError: (_, _) {});
|
||||
stream.addListener(listener);
|
||||
_aspectStreams[url] = stream;
|
||||
_aspectListeners[url] = listener;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_syncAspectListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_counterHideTimer?.cancel();
|
||||
for (final url in _aspectStreams.keys) {
|
||||
_aspectStreams[url]!.removeListener(_aspectListeners[url]!);
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant BlogPostMediaContent oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
_syncAspectListeners();
|
||||
if (oldWidget.currentGalleryIndex != widget.currentGalleryIndex) {
|
||||
_scheduleCounterHideOnVideo();
|
||||
}
|
||||
@@ -138,16 +205,26 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
|
||||
return false;
|
||||
}
|
||||
|
||||
Widget _imageSlide(String itemUrl, int itemIndex, double height) {
|
||||
Widget _photo(String url, {required double width, double? height}) {
|
||||
return CachedNetworkImage(
|
||||
fit: BoxFit.fitWidth,
|
||||
alignment: Alignment.center,
|
||||
width: width,
|
||||
height: height,
|
||||
imageUrl: url,
|
||||
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _imageSlide(
|
||||
String itemUrl,
|
||||
int itemIndex, {
|
||||
required double width,
|
||||
required double height,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
onTap: () => widget.onOpenGallery(_galleryUrls, itemIndex),
|
||||
child: CachedNetworkImage(
|
||||
fit: BoxFit.cover,
|
||||
width: MediaQuery.sizeOf(context).width,
|
||||
height: height,
|
||||
imageUrl: itemUrl,
|
||||
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
||||
),
|
||||
child: _photo(itemUrl, width: width, height: height),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -175,6 +252,8 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
|
||||
viewportFraction: 1,
|
||||
height: height,
|
||||
enableInfiniteScroll: false,
|
||||
disableCenter: true,
|
||||
padEnds: false,
|
||||
onPageChanged: (index, _) => widget.onGalleryChanged(index),
|
||||
),
|
||||
),
|
||||
@@ -200,42 +279,59 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth.isFinite
|
||||
? constraints.maxWidth
|
||||
: MediaQuery.sizeOf(context).width;
|
||||
return _buildMedia(width);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMedia(double width) {
|
||||
final gallery = _gallery;
|
||||
final width = MediaQuery.sizeOf(context).width;
|
||||
final mediaHeight = _heightForOrientation(_postOrientation, width);
|
||||
final mediaHeight = _mediaHeight(width);
|
||||
|
||||
if (gallery.length > 1 || (gallery.length == 1 && _hasVideo)) {
|
||||
final itemCount = _slideCount;
|
||||
return _carousel(
|
||||
itemCount: itemCount,
|
||||
height: mediaHeight,
|
||||
itemBuilder: (context, itemIndex) {
|
||||
if (itemIndex < gallery.length) {
|
||||
return _imageSlide(gallery[itemIndex].url, itemIndex, mediaHeight);
|
||||
}
|
||||
if (_hasVideo) {
|
||||
return _videoSlide();
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
return AnimatedSize(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeInOut,
|
||||
child: _carousel(
|
||||
itemCount: itemCount,
|
||||
height: mediaHeight,
|
||||
itemBuilder: (context, itemIndex) {
|
||||
if (itemIndex < gallery.length) {
|
||||
return _imageSlide(
|
||||
gallery[itemIndex].url,
|
||||
itemIndex,
|
||||
width: width,
|
||||
height: mediaHeight,
|
||||
);
|
||||
}
|
||||
if (_hasVideo) {
|
||||
return _videoSlide();
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (gallery.length == 1) {
|
||||
return _imageSlide(gallery.first.url, 0, mediaHeight);
|
||||
return GestureDetector(
|
||||
onTap: () => widget.onOpenGallery(_galleryUrls, 0),
|
||||
child: _photo(gallery.first.url, width: width),
|
||||
);
|
||||
}
|
||||
|
||||
if (_hasVideo) {
|
||||
return SizedBox(height: mediaHeight, width: width, child: _videoSlide());
|
||||
}
|
||||
|
||||
if (widget.imageUrl.isNotEmpty) {
|
||||
final image = CachedNetworkImage(
|
||||
fit: BoxFit.fitWidth,
|
||||
width: width,
|
||||
imageUrl: widget.imageUrl,
|
||||
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
||||
);
|
||||
if (PostFeedMapper.hasRenderableImage(widget.imageUrl)) {
|
||||
final image = _photo(widget.imageUrl, width: width);
|
||||
return GestureDetector(
|
||||
onTap: () => widget.onOpenGallery([widget.imageUrl], 0),
|
||||
child: widget.heroTag == null
|
||||
|
||||
@@ -83,6 +83,11 @@ class _BlogPostWidgetState extends State<BlogPostWidget> {
|
||||
Widget build(BuildContext context) {
|
||||
final imageUrl = PostFeedMapper.resolveMediaUrl(post.image);
|
||||
final embedUrl = post.videoEmbedUrl?.trim();
|
||||
final hasMedia = PostFeedMapper.hasVisibleMedia(
|
||||
image: imageUrl,
|
||||
images: post.images,
|
||||
videoEmbedUrl: embedUrl,
|
||||
);
|
||||
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
@@ -97,30 +102,27 @@ class _BlogPostWidgetState extends State<BlogPostWidget> {
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
_BlogPostHeader(post: post),
|
||||
_BlogPostTextContent(post: post),
|
||||
BlogPostMediaContent(
|
||||
galleryImages: post.images,
|
||||
imageUrl: imageUrl,
|
||||
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty
|
||||
? embedUrl
|
||||
: null,
|
||||
videoUrlThumb: post.videoUrlThumb,
|
||||
currentGalleryIndex: currentGalleryIndex,
|
||||
heroTag: 'feed-blog-post-${post.id}-cover',
|
||||
onOpenGallery: (urls, index) => _openGallery(
|
||||
urls,
|
||||
index,
|
||||
heroTag: urls.length == 1
|
||||
? 'feed-blog-post-${post.id}-cover'
|
||||
: null,
|
||||
_BlogPostTextContent(post: post, includeBottomSpacing: hasMedia),
|
||||
if (hasMedia)
|
||||
BlogPostMediaContent(
|
||||
galleryImages: post.images,
|
||||
imageUrl: imageUrl,
|
||||
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null,
|
||||
videoUrlThumb: post.videoUrlThumb,
|
||||
currentGalleryIndex: currentGalleryIndex,
|
||||
heroTag: 'feed-blog-post-${post.id}-cover',
|
||||
onOpenGallery: (urls, index) => _openGallery(
|
||||
urls,
|
||||
index,
|
||||
heroTag: urls.length == 1 ? 'feed-blog-post-${post.id}-cover' : null,
|
||||
),
|
||||
onGalleryChanged: (index) {
|
||||
setState(() {
|
||||
currentGalleryIndex = index;
|
||||
});
|
||||
},
|
||||
),
|
||||
onGalleryChanged: (index) {
|
||||
setState(() {
|
||||
currentGalleryIndex = index;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(height: hasMedia ? 12 : 8),
|
||||
_BlogPostActionsRow(
|
||||
post: post,
|
||||
isLiked: isLiked,
|
||||
@@ -199,6 +201,7 @@ class _BlogPostHeader extends StatelessWidget {
|
||||
),
|
||||
CustomPopup(
|
||||
isAuthor: post.author.authorIsMe,
|
||||
isAuthorAdmin: post.author.isAdmin,
|
||||
complainedContentType: ComplainedContent.posts,
|
||||
path: 'blog_post/${post.id}',
|
||||
id: post.id,
|
||||
@@ -212,9 +215,10 @@ class _BlogPostHeader extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _BlogPostTextContent extends StatelessWidget {
|
||||
const _BlogPostTextContent({required this.post});
|
||||
const _BlogPostTextContent({required this.post, required this.includeBottomSpacing});
|
||||
|
||||
final FeedBlogPostModel post;
|
||||
final bool includeBottomSpacing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -258,7 +262,7 @@ class _BlogPostTextContent extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (post.title.isNotEmpty || post.description.isNotEmpty)
|
||||
if (includeBottomSpacing && (post.title.isNotEmpty || post.description.isNotEmpty))
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:eatvio_app/core/comments/presentation/comments_actions_bottom_sh
|
||||
import 'package:eatvio_app/core/enums/complained_content.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/utils/date_time_utils.dart';
|
||||
import 'package:eatvio_app/core/utils/url_utils.dart';
|
||||
import 'package:eatvio_app/core/utils/toasts.dart';
|
||||
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
|
||||
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_state.dart';
|
||||
@@ -54,13 +55,15 @@ class _CommentsWidgetState extends State<CommentsWidget> {
|
||||
@override
|
||||
void didUpdateWidget(CommentsWidget oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.highlightedCommentId != null && widget.highlightedCommentId != oldWidget.highlightedCommentId) {
|
||||
if (widget.highlightedCommentId != null &&
|
||||
widget.highlightedCommentId != oldWidget.highlightedCommentId) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_scrollToHighlightedComment();
|
||||
});
|
||||
} else if (widget.comment != oldWidget.comment && widget.highlightedCommentId != null) {
|
||||
// Comments might have just loaded
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
} else if (widget.comment != oldWidget.comment &&
|
||||
widget.highlightedCommentId != null) {
|
||||
// Comments might have just loaded
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_scrollToHighlightedComment();
|
||||
});
|
||||
}
|
||||
@@ -84,9 +87,22 @@ class _CommentsWidgetState extends State<CommentsWidget> {
|
||||
children: [
|
||||
for (int i = 0; i < widget.comment.data.length; i++) ...[
|
||||
_comment(s, widget.comment.data[i]),
|
||||
if (widget.comment.data[i].childrenComments.isNotEmpty) const SizedBox(height: 8),
|
||||
for (int j = 0; j < widget.comment.data[i].childrenComments.length; j++)
|
||||
Padding(padding: const EdgeInsets.only(left: 40), child: _comment(s, widget.comment.data[i].childrenComments[j], 16, true)),
|
||||
if (widget.comment.data[i].childrenComments.isNotEmpty)
|
||||
const SizedBox(height: 8),
|
||||
for (
|
||||
int j = 0;
|
||||
j < widget.comment.data[i].childrenComments.length;
|
||||
j++
|
||||
)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 40),
|
||||
child: _comment(
|
||||
s,
|
||||
widget.comment.data[i].childrenComments[j],
|
||||
16,
|
||||
true,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
const SizedBox(height: 48),
|
||||
@@ -94,9 +110,18 @@ class _CommentsWidgetState extends State<CommentsWidget> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _comment(S s, Comment comment, [double radius = 18, bool isChild = false]) {
|
||||
Widget _comment(
|
||||
S s,
|
||||
Comment comment, [
|
||||
double radius = 18,
|
||||
bool isChild = false,
|
||||
]) {
|
||||
final key = commentKeys.putIfAbsent(comment.id, () => GlobalKey());
|
||||
final isMe = (sl<ProfileAvatarCubit>().state as ProfileAvatarStateLoaded).profile.slug == comment.authorSlug;
|
||||
final isMe =
|
||||
(sl<ProfileAvatarCubit>().state as ProfileAvatarStateLoaded)
|
||||
.profile
|
||||
.slug ==
|
||||
comment.authorSlug;
|
||||
return GestureDetector(
|
||||
key: key,
|
||||
onTap: () {
|
||||
@@ -113,7 +138,10 @@ class _CommentsWidgetState extends State<CommentsWidget> {
|
||||
showInfoToast(s.text_was_copied);
|
||||
},
|
||||
edit: (String text) {
|
||||
context.read<CommentsCubit>().updateComment(id: comment.id, body: text);
|
||||
context.read<CommentsCubit>().updateComment(
|
||||
id: comment.id,
|
||||
body: text,
|
||||
);
|
||||
},
|
||||
delete: () {
|
||||
context.read<CommentsCubit>().deleteComment(id: comment.id);
|
||||
@@ -122,7 +150,10 @@ class _CommentsWidgetState extends State<CommentsWidget> {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => ComplainingBottomSheet(complainedContentType: ComplainedContent.comment, id: comment.id),
|
||||
builder: (context) => ComplainingBottomSheet(
|
||||
complainedContentType: ComplainedContent.comment,
|
||||
id: comment.id,
|
||||
),
|
||||
);
|
||||
},
|
||||
isMyComment: isMe,
|
||||
@@ -131,7 +162,11 @@ class _CommentsWidgetState extends State<CommentsWidget> {
|
||||
child: Container(
|
||||
width: MediaQuery.of(context).size.width,
|
||||
decoration: BoxDecoration(
|
||||
color: (widget.replyId == comment.id || widget.highlightedCommentId == comment.id) ? appTheme.blueA400.withValues(alpha: 0.1) : Colors.white,
|
||||
color:
|
||||
(widget.replyId == comment.id ||
|
||||
widget.highlightedCommentId == comment.id)
|
||||
? appTheme.blueA400.withValues(alpha: 0.1)
|
||||
: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Padding(
|
||||
@@ -141,14 +176,20 @@ class _CommentsWidgetState extends State<CommentsWidget> {
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed(
|
||||
'profileScreen',
|
||||
extra: comment.authorSlug,
|
||||
);
|
||||
GoRouter.of(
|
||||
context,
|
||||
).pushNamed('profileScreen', extra: comment.authorSlug);
|
||||
},
|
||||
child: CircleAvatar(
|
||||
foregroundImage: NetworkImage(comment.authorProfileIcon.contains('https') ? comment.authorProfileIcon : sl<AppConfig>().baseUrl + comment.authorProfileIcon),
|
||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
||||
foregroundImage: NetworkImage(
|
||||
joinBaseUrl(
|
||||
sl<AppConfig>().baseUrl,
|
||||
comment.authorProfileIcon,
|
||||
),
|
||||
),
|
||||
backgroundImage: const AssetImage(
|
||||
'assets/images/no_avatar.png',
|
||||
),
|
||||
radius: radius,
|
||||
),
|
||||
),
|
||||
@@ -159,14 +200,26 @@ class _CommentsWidgetState extends State<CommentsWidget> {
|
||||
children: [
|
||||
Text(
|
||||
comment.authorName,
|
||||
style: TextStyle(height: 1, color: appTheme.gray, fontSize: 14, fontWeight: FontWeight.w500),
|
||||
style: TextStyle(
|
||||
height: 1,
|
||||
color: appTheme.gray,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
SizedBox(
|
||||
width: isChild ? MediaQuery.of(context).size.width * 0.72 : MediaQuery.of(context).size.width * 0.8,
|
||||
width: isChild
|
||||
? MediaQuery.of(context).size.width * 0.72
|
||||
: MediaQuery.of(context).size.width * 0.8,
|
||||
child: Text(
|
||||
comment.body,
|
||||
style: TextStyle(height: 1, color: appTheme.black, fontSize: 16, fontWeight: FontWeight.w500),
|
||||
style: TextStyle(
|
||||
height: 1,
|
||||
color: appTheme.black,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
@@ -174,17 +227,29 @@ class _CommentsWidgetState extends State<CommentsWidget> {
|
||||
children: [
|
||||
Text(
|
||||
comment.createdAt.format('dd MMMM', 'ru'),
|
||||
style: TextStyle(height: 1, color: appTheme.gray, fontSize: 14, fontWeight: FontWeight.w500),
|
||||
style: TextStyle(
|
||||
height: 1,
|
||||
color: appTheme.gray,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
if (!isChild)
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
context.read<CommentsCubit>().setReplyId(comment.id);
|
||||
context.read<CommentsCubit>().setReplyId(
|
||||
comment.id,
|
||||
);
|
||||
},
|
||||
child: Text(
|
||||
s.answer,
|
||||
style: TextStyle(height: 1, color: appTheme.gray, fontSize: 14, fontWeight: FontWeight.w600),
|
||||
style: TextStyle(
|
||||
height: 1,
|
||||
color: appTheme.gray,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -2,6 +2,8 @@ import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/core/utils/url_utils.dart';
|
||||
import 'package:eatvio_app/core/widgets/network_svg_picture.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_consultant_model.dart';
|
||||
import 'package:eatvio_app/features/profile/models/badge_model.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -17,15 +19,15 @@ class ConsultantWidget extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed(
|
||||
'profileScreen',
|
||||
extra: consultant.slug,
|
||||
);
|
||||
GoRouter.of(context).pushNamed('profileScreen', extra: consultant.slug);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Ink(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: const Color(0xFFF6F6F6)),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
color: const Color(0xFFF6F6F6),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -36,33 +38,54 @@ class ConsultantWidget extends StatelessWidget {
|
||||
Row(
|
||||
children: [
|
||||
Ink(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(8)),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 4,
|
||||
horizontal: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'${consultant.ratingPlace}',
|
||||
style: TextStyle(fontSize: 20, color: appTheme.blueA400, fontWeight: FontWeight.w600),
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
color: appTheme.blueA400,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (consultant.photo.contains('https'))
|
||||
CircleAvatar(foregroundImage: NetworkImage(consultant.photo), backgroundImage: const AssetImage('assets/images/no_avatar.png'))
|
||||
else
|
||||
CircleAvatar(
|
||||
foregroundImage: NetworkImage(sl<AppConfig>().baseUrl + consultant.photo),
|
||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
||||
CircleAvatar(
|
||||
foregroundImage: NetworkImage(
|
||||
joinBaseUrl(sl<AppConfig>().baseUrl, consultant.photo),
|
||||
),
|
||||
backgroundImage: const AssetImage(
|
||||
'assets/images/no_avatar.png',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
consultant.name,
|
||||
style: const TextStyle(height: 1, fontWeight: FontWeight.w500, fontSize: 15, color: Color(0xFF323F4B)),
|
||||
style: const TextStyle(
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 15,
|
||||
color: Color(0xFF323F4B),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Рейтинг: ${consultant.totalScores}',
|
||||
style: TextStyle(color: appTheme.blueA400, height: 1, fontWeight: FontWeight.w500, fontSize: 12),
|
||||
style: TextStyle(
|
||||
color: appTheme.blueA400,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -70,7 +93,10 @@ class ConsultantWidget extends StatelessWidget {
|
||||
),
|
||||
Ink(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(4), color: Colors.white),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
color: Colors.white,
|
||||
),
|
||||
child: SvgPicture.asset('assets/icons/go_to_arrow.svg'),
|
||||
),
|
||||
],
|
||||
@@ -92,9 +118,21 @@ class ConsultantWidget extends StatelessWidget {
|
||||
children: [
|
||||
for (int i = 0; i < badges.length; i++) ...[
|
||||
badges[i].image.contains('svg')
|
||||
? SvgPicture.network('${sl<AppConfig>().baseUrl}/storage/${badges[i].image}', width: 28, height: 28)
|
||||
: CachedNetworkImage(imageUrl: '${sl<AppConfig>().baseUrl}/storage/${badges[i].image}', width: 28, height: 28),
|
||||
i < badges.length - 1 ? const SizedBox(width: 8) : const SizedBox.shrink(),
|
||||
? NetworkSvgPicture(
|
||||
url:
|
||||
'${sl<AppConfig>().baseUrl}/storage/${badges[i].image}',
|
||||
width: 28,
|
||||
height: 28,
|
||||
)
|
||||
: CachedNetworkImage(
|
||||
imageUrl:
|
||||
'${sl<AppConfig>().baseUrl}/storage/${badges[i].image}',
|
||||
width: 28,
|
||||
height: 28,
|
||||
),
|
||||
i < badges.length - 1
|
||||
? const SizedBox(width: 8)
|
||||
: const SizedBox.shrink(),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
@@ -80,6 +80,7 @@ class _DietsWidgetState extends State<DietsWidget> {
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: CustomPopup(
|
||||
isAuthor: widget.diet.author.authorIsMe,
|
||||
isAuthorAdmin: widget.diet.author.isAdmin,
|
||||
path: 'diet/${widget.diet.slug}',
|
||||
id: widget.diet.id,
|
||||
complainedContentType: ComplainedContent.diet,
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:eatvio_app/core/widgets/markdown_html_widget.dart';
|
||||
import 'package:eatvio_app/features/diets/presentation/widgets/info_bouble.dart';
|
||||
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||
import 'package:eatvio_app/core/utils/url_utils.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/plans_cubit/plans_cubit.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
@@ -37,7 +38,14 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
return InkWell(
|
||||
onTap: () => GoRouter.of(context).pushNamed('aboutPlanScreen', extra: {'slug': widget.plan.slug, 'index': widget.index, 'navigateToComments': false}),
|
||||
onTap: () => GoRouter.of(context).pushNamed(
|
||||
'aboutPlanScreen',
|
||||
extra: {
|
||||
'slug': widget.plan.slug,
|
||||
'index': widget.index,
|
||||
'navigateToComments': false,
|
||||
},
|
||||
),
|
||||
child: Material(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: Colors.white,
|
||||
@@ -48,9 +56,12 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(12),
|
||||
),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: '${sl<AppConfig>().baseUrl}/storage/${widget.plan.image}',
|
||||
imageUrl:
|
||||
'${sl<AppConfig>().baseUrl}/storage/${widget.plan.image}',
|
||||
fit: BoxFit.cover,
|
||||
width: MediaQuery.of(context).size.width,
|
||||
height: 426,
|
||||
@@ -61,14 +72,22 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
child: BookmarkButton(
|
||||
onTap: () async {
|
||||
if (widget.plan.isFavorite) {
|
||||
await context.read<PlansCubit>().removePlanFromFavorite(widget.plan.slug, widget.index);
|
||||
await context.read<PlansCubit>().removePlanFromFavorite(
|
||||
widget.plan.slug,
|
||||
widget.index,
|
||||
);
|
||||
} else {
|
||||
await context.read<PlansCubit>().addPlanToFavorite(widget.plan.slug, widget.index);
|
||||
await context.read<PlansCubit>().addPlanToFavorite(
|
||||
widget.plan.slug,
|
||||
widget.index,
|
||||
);
|
||||
}
|
||||
},
|
||||
color: appTheme.whiteA700,
|
||||
isFavorite: widget.plan.isFavorite,
|
||||
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(12)),
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
bottom: Radius.circular(12),
|
||||
),
|
||||
width: 40,
|
||||
height: 44,
|
||||
size: 19,
|
||||
@@ -85,9 +104,11 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: CustomPopup(
|
||||
isAuthor: widget.plan.author.authorIsMe,
|
||||
isAuthorAdmin: widget.plan.author.isAdmin,
|
||||
path: 'plan/${widget.plan.slug}',
|
||||
id: widget.plan.id,
|
||||
slug: widget.plan.slug,
|
||||
plan: widget.plan,
|
||||
complainedContentType: ComplainedContent.plan,
|
||||
userSlug: widget.plan.author.authorSlug,
|
||||
),
|
||||
@@ -98,7 +119,12 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
bottom: 16,
|
||||
child: SizedBox(
|
||||
width: MediaQuery.sizeOf(context).width,
|
||||
child: InfoRow(plan: widget.plan, itemsBackgroundColor: appTheme.whiteA700, disableDescriptionOfTypes: true, type: ProductType.plan),
|
||||
child: InfoRow(
|
||||
plan: widget.plan,
|
||||
itemsBackgroundColor: appTheme.whiteA700,
|
||||
disableDescriptionOfTypes: true,
|
||||
type: ProductType.plan,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -109,14 +135,22 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(widget.plan.name, style: TextStyles.textMSemibold.copyWith(color: appTheme.black, fontSize: 18)),
|
||||
child: Text(
|
||||
widget.plan.name,
|
||||
style: TextStyles.textMSemibold.copyWith(
|
||||
color: appTheme.black,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.plan.details.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: MarkdownHtmlWidget(
|
||||
markdownText: widget.plan.details.length > 200 ? '${widget.plan.details.substring(0, 200)}...' : widget.plan.details,
|
||||
markdownText: widget.plan.details.length > 200
|
||||
? '${widget.plan.details.substring(0, 200)}...'
|
||||
: widget.plan.details,
|
||||
textColor: appTheme.black,
|
||||
),
|
||||
),
|
||||
@@ -125,7 +159,12 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
padding: EdgeInsets.only(left: 16, top: 8),
|
||||
child: Text(
|
||||
'Показать ещё',
|
||||
style: TextStyle(height: 1, color: Color(0xFF8B8B8B), fontSize: 14, fontWeight: FontWeight.w600),
|
||||
style: TextStyle(
|
||||
height: 1,
|
||||
color: Color(0xFF8B8B8B),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -134,7 +173,10 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
const SizedBox(height: 16),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed('profileScreen', extra: widget.plan.author.authorSlug);
|
||||
GoRouter.of(context).pushNamed(
|
||||
'profileScreen',
|
||||
extra: widget.plan.author.authorSlug,
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0),
|
||||
@@ -145,7 +187,15 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
height: 46,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
image: DecorationImage(image: NetworkImage(sl<AppConfig>().baseUrl + widget.plan.author.authorProfileIcon), fit: BoxFit.cover),
|
||||
image: DecorationImage(
|
||||
image: NetworkImage(
|
||||
joinBaseUrl(
|
||||
sl<AppConfig>().baseUrl,
|
||||
widget.plan.author.authorProfileIcon,
|
||||
),
|
||||
),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
subtitle: Row(
|
||||
@@ -153,22 +203,37 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
InfoBouble(
|
||||
radius: 12,
|
||||
backgroundColor: appTheme.gray50,
|
||||
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 10),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 4,
|
||||
horizontal: 10,
|
||||
),
|
||||
children: [
|
||||
Text('${widget.plan.author.authorPlaceInRating}-й в рейтинге', style: TextStyles.textXSSemibold.copyWith(color: appTheme.blueA400)),
|
||||
Text(
|
||||
'${widget.plan.author.authorPlaceInRating}-й в рейтинге',
|
||||
style: TextStyles.textXSSemibold.copyWith(
|
||||
color: appTheme.blueA400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'${widget.plan.packetsCount == 1 ? widget.plan.packet1Options?.price : '${s.from} ${widget.plan.minPriceOfOptions.toStringAsFixed(0)}'}₽',
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.greenA700),
|
||||
style: TextStyles.headingXLSemibold.copyWith(
|
||||
color: appTheme.greenA700,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
],
|
||||
),
|
||||
title: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4.0),
|
||||
child: Text(widget.plan.author.authorName, style: TextStyles.textSSemibold.copyWith(color: appTheme.black)),
|
||||
child: Text(
|
||||
widget.plan.author.authorName,
|
||||
style: TextStyles.textSSemibold.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -179,12 +244,21 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
child: Row(
|
||||
children: [
|
||||
FeedIconButton(
|
||||
icon: widget.plan.wasLikedByMe ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
||||
icon: widget.plan.wasLikedByMe
|
||||
? Icons.favorite_rounded
|
||||
: Icons.favorite_border_rounded,
|
||||
text: widget.plan.totalLikes.toString(),
|
||||
iconColor: widget.plan.wasLikedByMe ? appTheme.blueA400 : null,
|
||||
backgroundColor: widget.plan.wasLikedByMe ? appTheme.transparentBlue : null,
|
||||
iconColor: widget.plan.wasLikedByMe
|
||||
? appTheme.blueA400
|
||||
: null,
|
||||
backgroundColor: widget.plan.wasLikedByMe
|
||||
? appTheme.transparentBlue
|
||||
: null,
|
||||
onTap: () async {
|
||||
await context.read<PlansCubit>().likePlan(widget.plan.slug, widget.index);
|
||||
await context.read<PlansCubit>().likePlan(
|
||||
widget.plan.slug,
|
||||
widget.index,
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
@@ -192,9 +266,14 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
icon: Icons.chat_bubble_outline,
|
||||
text: widget.plan.totalComments.toString(),
|
||||
onTap: () {
|
||||
return GoRouter.of(
|
||||
context,
|
||||
).pushNamed('aboutPlanScreen', extra: {'slug': widget.plan.slug, 'index': widget.index, 'navigateToComments': true});
|
||||
return GoRouter.of(context).pushNamed(
|
||||
'aboutPlanScreen',
|
||||
extra: {
|
||||
'slug': widget.plan.slug,
|
||||
'index': widget.index,
|
||||
'navigateToComments': true,
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
@@ -204,8 +283,11 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
final box = context.findRenderObject() as RenderBox?;
|
||||
final result = await SharePlus.instance.share(
|
||||
ShareParams(
|
||||
uri: Uri.parse('${sl<AppConfig>().baseUrl}/ru/diet/${widget.plan.id}'),
|
||||
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
|
||||
uri: Uri.parse(
|
||||
'${sl<AppConfig>().baseUrl}/ru/diet/${widget.plan.id}',
|
||||
),
|
||||
sharePositionOrigin:
|
||||
box!.localToGlobal(Offset.zero) & box.size,
|
||||
),
|
||||
);
|
||||
switch (result.status) {
|
||||
|
||||
@@ -115,6 +115,7 @@ class _RecipesWidgetState extends State<RecipesWidget> {
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: CustomPopup(
|
||||
isAuthor: widget.recipe.author.authorIsMe,
|
||||
isAuthorAdmin: widget.recipe.author.isAdmin,
|
||||
path: 'recipe/${widget.recipe.slug}',
|
||||
slug: widget.recipe.slug,
|
||||
complainedContentType: ComplainedContent.recipe,
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/widgets/loader.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
|
||||
const _passKeyLoginPath = 'login_with_pass_key';
|
||||
const _contentReadyProgress = 40;
|
||||
|
||||
@visibleForTesting
|
||||
bool shouldHideGlossaryLoader({required String url, required int progress}) {
|
||||
if (url.isEmpty || url.contains(_passKeyLoginPath)) {
|
||||
return false;
|
||||
}
|
||||
return progress >= _contentReadyProgress;
|
||||
}
|
||||
|
||||
class GlossaryScreen extends StatefulWidget {
|
||||
const GlossaryScreen({super.key, required this.passKey});
|
||||
final String passKey;
|
||||
@@ -12,6 +24,8 @@ class GlossaryScreen extends StatefulWidget {
|
||||
|
||||
class _GlossaryScreenState extends State<GlossaryScreen> {
|
||||
late final WebViewController _controller;
|
||||
bool _isLoading = true;
|
||||
String _currentUrl = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -19,14 +33,47 @@ class _GlossaryScreenState extends State<GlossaryScreen> {
|
||||
|
||||
_controller = WebViewController()
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..setNavigationDelegate(
|
||||
NavigationDelegate(
|
||||
onPageStarted: (url) {
|
||||
_currentUrl = url;
|
||||
},
|
||||
onProgress: (progress) {
|
||||
if (shouldHideGlossaryLoader(url: _currentUrl, progress: progress)) {
|
||||
_hideLoader();
|
||||
}
|
||||
},
|
||||
onPageFinished: (url) {
|
||||
if (shouldHideGlossaryLoader(url: url, progress: 100)) {
|
||||
_hideLoader();
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
..loadRequest(Uri.parse('${sl<AppConfig>().baseUrl}/login_with_pass_key?id=${widget.passKey}'));
|
||||
}
|
||||
|
||||
void _hideLoader() {
|
||||
if (!_isLoading || !mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Глоссарий')),
|
||||
body: WebViewWidget(controller: _controller),
|
||||
body: Stack(
|
||||
children: [
|
||||
WebViewWidget(controller: _controller),
|
||||
if (_isLoading)
|
||||
ColoredBox(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
child: const SizedBox.expand(child: Loader()),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ import 'dart:developer';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||
import 'package:eatvio_app/features/menu/presentation/bloc/notification_settings_cubit/notification_settings_state.dart';
|
||||
import 'package:eatvio_app/features/menu/presentation/notification_settings_user_message.dart';
|
||||
import 'package:eatvio_app/features/menu/repositories/notification_settings_repository.dart';
|
||||
|
||||
class NotificationSettingsCubit extends Cubit<NotificationSettingsState> {
|
||||
final NotificationSettingsRepository _notificationSettingsRepository;
|
||||
int _alertSeq = 0;
|
||||
|
||||
NotificationSettingsCubit({required NotificationSettingsRepository notificationSettingsRepository})
|
||||
: _notificationSettingsRepository = notificationSettingsRepository,
|
||||
@@ -30,6 +32,17 @@ class NotificationSettingsCubit extends Cubit<NotificationSettingsState> {
|
||||
} catch (e, stackTrace) {
|
||||
await reportFrom(e, stackTrace, this);
|
||||
log('Error updating notification settings: $e', stackTrace: stackTrace);
|
||||
final current = state;
|
||||
if (current is NotificationSettingsStateLoaded) {
|
||||
emit(
|
||||
NotificationSettingsStateLoaded(
|
||||
notificationSettings: current.notificationSettings,
|
||||
alertMessage: notificationSettingsUpdateAlertMessage(e),
|
||||
alertId: ++_alertSeq,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
emit(NotificationSettingsStateError(errorMessage: e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,11 +14,17 @@ class NotificationSettingsStateLoading extends NotificationSettingsState {
|
||||
|
||||
class NotificationSettingsStateLoaded extends NotificationSettingsState {
|
||||
final NotificationSettingsModel notificationSettings;
|
||||
final String? alertMessage;
|
||||
final int? alertId;
|
||||
|
||||
const NotificationSettingsStateLoaded({required this.notificationSettings});
|
||||
const NotificationSettingsStateLoaded({
|
||||
required this.notificationSettings,
|
||||
this.alertMessage,
|
||||
this.alertId,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [notificationSettings];
|
||||
List<Object?> get props => [notificationSettings, alertMessage, alertId];
|
||||
}
|
||||
|
||||
class NotificationSettingsStateError extends NotificationSettingsState {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
const notificationSettingsSaveFailedMessage =
|
||||
'Не удалось сохранить настройки уведомлений';
|
||||
|
||||
|
||||
String notificationSettingsUpdateAlertMessage(Object error) {
|
||||
final validationMessage = _validationMessage(error);
|
||||
if (validationMessage != null) return validationMessage;
|
||||
return notificationSettingsSaveFailedMessage;
|
||||
}
|
||||
|
||||
String? _validationMessage(Object error) {
|
||||
if (error is! DioException || error.response?.statusCode != 422) return null;
|
||||
|
||||
final data = error.response?.data;
|
||||
if (data is! Map) return null;
|
||||
|
||||
final errors = data['errors'];
|
||||
if (errors is Map) {
|
||||
for (final value in errors.values) {
|
||||
final text = _firstErrorText(value);
|
||||
if (text != null) return text;
|
||||
}
|
||||
}
|
||||
|
||||
final message = data['message'];
|
||||
if (message is String && message.trim().isNotEmpty) return message.trim();
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _firstErrorText(Object? value) {
|
||||
if (value is List) {
|
||||
for (final item in value) {
|
||||
final text = item.toString().trim();
|
||||
if (text.isNotEmpty) return text;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (value is String && value.trim().isNotEmpty) return value.trim();
|
||||
return null;
|
||||
}
|
||||
@@ -27,7 +27,37 @@ class NotificationsSettingsContent extends StatelessWidget {
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(s.notifications)),
|
||||
body: BlocBuilder<NotificationSettingsCubit, NotificationSettingsState>(
|
||||
body: BlocConsumer<NotificationSettingsCubit, NotificationSettingsState>(
|
||||
listenWhen: (previous, current) {
|
||||
if (current is! NotificationSettingsStateLoaded || current.alertId == null) {
|
||||
return false;
|
||||
}
|
||||
final previousId = previous is NotificationSettingsStateLoaded ? previous.alertId : null;
|
||||
return current.alertId != previousId;
|
||||
},
|
||||
listener: (context, state) {
|
||||
if (state is! NotificationSettingsStateLoaded) {
|
||||
return;
|
||||
}
|
||||
final message = state.alertMessage;
|
||||
if (message == null || message.isEmpty) {
|
||||
return;
|
||||
}
|
||||
showCupertinoDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return CupertinoAlertDialog(
|
||||
content: Text(message),
|
||||
actions: [
|
||||
CupertinoDialogAction(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
builder: (context, state) {
|
||||
if (state is NotificationSettingsStateLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/utils/url_utils.dart';
|
||||
import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_cubit.dart';
|
||||
import 'package:eatvio_app/features/profile/models/profile_model.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -18,16 +19,20 @@ class _BlockedUserWidgetState extends State<BlockedUserWidget> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: () => GoRouter.of(context).pushNamed(
|
||||
'profileScreen',
|
||||
extra: widget.profile.slug,
|
||||
),
|
||||
onTap: () => GoRouter.of(
|
||||
context,
|
||||
).pushNamed('profileScreen', extra: widget.profile.slug),
|
||||
child: Ink(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
foregroundImage: NetworkImage(sl<AppConfig>().baseUrl + widget.profile.profilePhotoUrl),
|
||||
foregroundImage: NetworkImage(
|
||||
joinBaseUrl(
|
||||
sl<AppConfig>().baseUrl,
|
||||
widget.profile.profilePhotoUrl,
|
||||
),
|
||||
),
|
||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
||||
radius: 28,
|
||||
),
|
||||
@@ -50,11 +55,16 @@ class _BlockedUserWidgetState extends State<BlockedUserWidget> {
|
||||
const Spacer(),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
context.read<BlockedUsersCubit>().unblockUser(widget.profile.slug);
|
||||
context.read<BlockedUsersCubit>().unblockUser(
|
||||
widget.profile.slug,
|
||||
);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Ink(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF1F2F6),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
|
||||
@@ -369,7 +369,7 @@ class _ChosenPlanFinishedToConsultantWidget extends StatelessWidget {
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.green)),
|
||||
onPressed: () {
|
||||
GoRouter.of(context).push(CardSettingsScreen.path, extra: notification.planId);
|
||||
GoRouter.of(context).push(CardSettingsScreen.path, extra: notification.chosenPlanId);
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
@@ -34,16 +34,28 @@ class InfoByOrderScreen extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => sl<OrderInfoCubit>()..getChosenPlan(id: planId, customerSlug: customerSlug, isConsultantView: isConsultantView),
|
||||
child: InfoByOrderScreenContent(successfulPayment: successfulPayment, planId: planId, customerSlug: customerSlug),
|
||||
child: InfoByOrderScreenContent(
|
||||
successfulPayment: successfulPayment,
|
||||
planId: planId,
|
||||
customerSlug: customerSlug,
|
||||
isConsultantView: isConsultantView,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class InfoByOrderScreenContent extends StatefulWidget {
|
||||
const InfoByOrderScreenContent({super.key, required this.planId, required this.successfulPayment, this.customerSlug});
|
||||
const InfoByOrderScreenContent({
|
||||
super.key,
|
||||
required this.planId,
|
||||
required this.successfulPayment,
|
||||
this.customerSlug,
|
||||
this.isConsultantView = false,
|
||||
});
|
||||
final int planId;
|
||||
final bool? successfulPayment;
|
||||
final String? customerSlug;
|
||||
final bool isConsultantView;
|
||||
@override
|
||||
State<InfoByOrderScreenContent> createState() => _InfoByOrderScreenContentState();
|
||||
}
|
||||
@@ -131,7 +143,9 @@ class _InfoByOrderScreenContentState extends State<InfoByOrderScreenContent> {
|
||||
body: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
if (plan.chosenPlanState == ChosenPlanStateEnum.stateDone) ...[CollectChargeBanner(chosenPlan: plan, price: chosenOption?.price ?? 0)],
|
||||
if (plan.chosenPlanState == ChosenPlanStateEnum.stateDone && (widget.customerSlug != null || widget.isConsultantView)) ...[
|
||||
CollectChargeBanner(chosenPlan: plan, price: chosenOption?.price ?? 0),
|
||||
],
|
||||
if (widget.successfulPayment != null) ...[const SizedBox(height: 8), PaymentBanner(successfulPayment: widget.successfulPayment ?? false)],
|
||||
const SizedBox(height: 8),
|
||||
AuthorCard(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/utils/url_utils.dart';
|
||||
import 'package:eatvio_app/core/app_router.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||
@@ -14,7 +15,11 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class ConsultationWidget extends StatelessWidget {
|
||||
const ConsultationWidget({super.key, required this.consultationModel, required this.dietName});
|
||||
const ConsultationWidget({
|
||||
super.key,
|
||||
required this.consultationModel,
|
||||
required this.dietName,
|
||||
});
|
||||
final ConsultationModel consultationModel;
|
||||
final String dietName;
|
||||
@override
|
||||
@@ -33,7 +38,12 @@ class ConsultationWidget extends StatelessWidget {
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 30,
|
||||
backgroundImage: NetworkImage(sl<AppConfig>().baseUrl + consultationModel.userProfilePhotoUrl),
|
||||
backgroundImage: NetworkImage(
|
||||
joinBaseUrl(
|
||||
sl<AppConfig>().baseUrl,
|
||||
consultationModel.userProfilePhotoUrl,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
@@ -43,18 +53,24 @@ class ConsultationWidget extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
consultationModel.userName,
|
||||
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
|
||||
style: TextStyles.textLSemibold.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
consultationModel.planTitle,
|
||||
style: TextStyles.textMMedium.copyWith(color: const Color(0xFF88949F)),
|
||||
style: TextStyles.textMMedium.copyWith(
|
||||
color: const Color(0xFF88949F),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Старт: ${consultationModel.userName}',
|
||||
style: TextStyles.textMMedium.copyWith(color: const Color(0xFF88949F)),
|
||||
style: TextStyles.textMMedium.copyWith(
|
||||
color: const Color(0xFF88949F),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
@@ -62,7 +78,12 @@ class ConsultationWidget extends StatelessWidget {
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 30,
|
||||
backgroundImage: NetworkImage(sl<AppConfig>().baseUrl + consultationModel.userProfilePhotoUrl),
|
||||
backgroundImage: NetworkImage(
|
||||
joinBaseUrl(
|
||||
sl<AppConfig>().baseUrl,
|
||||
consultationModel.userProfilePhotoUrl,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
@@ -72,25 +93,32 @@ class ConsultationWidget extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
consultationModel.userName,
|
||||
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
|
||||
style: TextStyles.textLSemibold.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
consultationModel.planTitle,
|
||||
style: TextStyles.textMMedium.copyWith(color: const Color(0xFF88949F)),
|
||||
style: TextStyles.textMMedium.copyWith(
|
||||
color: const Color(0xFF88949F),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(
|
||||
height: 12,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (consultationModel.personalDietId != null)
|
||||
CustomOutlinedButton(
|
||||
text: s.changeDiet,
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.gray),
|
||||
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.dividerColor)),
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(
|
||||
color: appTheme.gray,
|
||||
),
|
||||
buttonStyle: ButtonStyle(
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
backgroundColor: WidgetStatePropertyAll(appTheme.dividerColor),
|
||||
),
|
||||
onPressed: () async {
|
||||
showBottomSheetWithBlur(
|
||||
userRootNavigator: true,
|
||||
@@ -100,7 +128,11 @@ class ConsultationWidget extends StatelessWidget {
|
||||
userName: consultationModel.userName,
|
||||
dietName: dietName,
|
||||
onTap: () async {
|
||||
final result = await context.read<PersonalDietSuggestionCubit>().attachDietToConsultation(consultationId: consultationModel.id);
|
||||
final result = await context
|
||||
.read<PersonalDietSuggestionCubit>()
|
||||
.attachDietToConsultation(
|
||||
consultationId: consultationModel.id,
|
||||
);
|
||||
AppRouter.navigatorKey.currentContext?.pop();
|
||||
if (result) {
|
||||
if (!context.mounted) return;
|
||||
@@ -134,8 +166,13 @@ class ConsultationWidget extends StatelessWidget {
|
||||
CustomOutlinedButton(
|
||||
text: s.suggestDiet,
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(
|
||||
color: appTheme.whiteA700,
|
||||
),
|
||||
buttonStyle: ButtonStyle(
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
||||
),
|
||||
onPressed: () async {
|
||||
showBottomSheetWithBlur(
|
||||
userRootNavigator: true,
|
||||
@@ -146,7 +183,11 @@ class ConsultationWidget extends StatelessWidget {
|
||||
dietName: dietName,
|
||||
onTap: () async {
|
||||
AppRouter.navigatorKey.currentContext?.pop();
|
||||
final result = await context.read<PersonalDietSuggestionCubit>().attachDietToConsultation(consultationId: consultationModel.id);
|
||||
final result = await context
|
||||
.read<PersonalDietSuggestionCubit>()
|
||||
.attachDietToConsultation(
|
||||
consultationId: consultationModel.id,
|
||||
);
|
||||
if (result) {
|
||||
if (!context.mounted) return;
|
||||
showBottomSheetWithBlur(
|
||||
@@ -174,7 +215,7 @@ class ConsultationWidget extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -204,35 +245,33 @@ class _SuggestDietBottomSheet extends State<SuggestDietBottomSheet> {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const BottomSheetHandlerBar(),
|
||||
const SizedBox(
|
||||
height: 20,
|
||||
),
|
||||
Text('Предложение диеты', style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'Предложение диеты',
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Предложить пользователю ${widget.userName} «${widget.dietName}»',
|
||||
style: TextStyles.textSMedium.copyWith(color: appTheme.black),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
CustomOutlinedButton(
|
||||
text: s.toOffer,
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(
|
||||
color: appTheme.whiteA700,
|
||||
),
|
||||
buttonStyle: ButtonStyle(
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
||||
),
|
||||
onPressed: widget.onTap,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 40,
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -249,10 +288,12 @@ class SuggestionSuccessBottomSheet extends StatefulWidget {
|
||||
final String dietName;
|
||||
|
||||
@override
|
||||
State<SuggestionSuccessBottomSheet> createState() => _SuggestionSuccessBottomSheet();
|
||||
State<SuggestionSuccessBottomSheet> createState() =>
|
||||
_SuggestionSuccessBottomSheet();
|
||||
}
|
||||
|
||||
class _SuggestionSuccessBottomSheet extends State<SuggestionSuccessBottomSheet> {
|
||||
class _SuggestionSuccessBottomSheet
|
||||
extends State<SuggestionSuccessBottomSheet> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
@@ -261,37 +302,35 @@ class _SuggestionSuccessBottomSheet extends State<SuggestionSuccessBottomSheet>
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const BottomSheetHandlerBar(),
|
||||
const SizedBox(
|
||||
height: 20,
|
||||
),
|
||||
Text('Предложение диеты', style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'Предложение диеты',
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'«${widget.dietName}» предложена пользователю ${widget.userName}',
|
||||
style: TextStyles.textSMedium.copyWith(color: appTheme.black),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
CustomOutlinedButton(
|
||||
text: s.ok,
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(
|
||||
color: appTheme.whiteA700,
|
||||
),
|
||||
buttonStyle: ButtonStyle(
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
||||
),
|
||||
onPressed: () async {
|
||||
AppRouter.navigatorKey.currentContext?.pop();
|
||||
},
|
||||
),
|
||||
const SizedBox(
|
||||
height: 40,
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -308,10 +347,12 @@ class SuggestionFailureBottomSheet extends StatefulWidget {
|
||||
final String dietName;
|
||||
|
||||
@override
|
||||
State<SuggestionFailureBottomSheet> createState() => _SuggestionFailureBottomSheet();
|
||||
State<SuggestionFailureBottomSheet> createState() =>
|
||||
_SuggestionFailureBottomSheet();
|
||||
}
|
||||
|
||||
class _SuggestionFailureBottomSheet extends State<SuggestionFailureBottomSheet> {
|
||||
class _SuggestionFailureBottomSheet
|
||||
extends State<SuggestionFailureBottomSheet> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
@@ -320,37 +361,35 @@ class _SuggestionFailureBottomSheet extends State<SuggestionFailureBottomSheet>
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const BottomSheetHandlerBar(),
|
||||
const SizedBox(
|
||||
height: 20,
|
||||
),
|
||||
Text('Предложение диеты', style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'Предложение диеты',
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Не удалось предложить диету ${widget.dietName} пользователю ${widget.userName}',
|
||||
style: TextStyles.textSMedium.copyWith(color: appTheme.black),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
CustomOutlinedButton(
|
||||
text: s.ok,
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(
|
||||
color: appTheme.whiteA700,
|
||||
),
|
||||
buttonStyle: ButtonStyle(
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
||||
),
|
||||
onPressed: () async {
|
||||
AppRouter.navigatorKey.currentContext?.pop();
|
||||
},
|
||||
),
|
||||
const SizedBox(
|
||||
height: 40,
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -7,12 +7,21 @@ import 'package:eatvio_app/features/diets/repositories/diet_repository.dart';
|
||||
import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart';
|
||||
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_state.dart';
|
||||
import 'package:eatvio_app/features/plans/repositories/plan_repository.dart';
|
||||
import 'package:eatvio_app/features/profile/models/chosen_diets_pagination_model.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';
|
||||
|
||||
class AboutPlanCubit extends Cubit<AboutPlanState> {
|
||||
final PlanRepository planRepository;
|
||||
AboutPlanCubit({required this.planRepository}) : super(AboutPlanState.initial());
|
||||
final ProfileRepository profileRepository;
|
||||
final DietRepository dietRepository;
|
||||
|
||||
AboutPlanCubit({
|
||||
required this.planRepository,
|
||||
required this.profileRepository,
|
||||
required this.dietRepository,
|
||||
}) : super(AboutPlanState.initial());
|
||||
|
||||
Future<void> getPlan({required String slug, int? index}) async {
|
||||
try {
|
||||
@@ -141,6 +150,38 @@ class AboutPlanCubit extends Cubit<AboutPlanState> {
|
||||
return (null, '');
|
||||
}
|
||||
|
||||
/// Активная публичная диета (без привязки к плану) в статусах inited/started.
|
||||
Future<ChosenDietModel?> getActivePublicDiet() async {
|
||||
try {
|
||||
final chosenDiets = await profileRepository.getChosenDiets();
|
||||
final lastChosenDiet = chosenDiets.firstOrNull;
|
||||
if (lastChosenDiet == null) {
|
||||
return null;
|
||||
}
|
||||
final isActive = ['inited', 'started'].contains(lastChosenDiet.state);
|
||||
final isPublic = lastChosenDiet.chosenPlanId == 0;
|
||||
if (isActive && isPublic && lastChosenDiet.dietId != null) {
|
||||
return lastChosenDiet;
|
||||
}
|
||||
return null;
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString(), stackTrace: st);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> cancelPublicDiet(int dietId) async {
|
||||
try {
|
||||
await dietRepository.cancelDiet(dietId);
|
||||
return true;
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString(), stackTrace: st);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
String _getOptionNameFromIndex(int index) => switch (index) {
|
||||
1 => 'options2',
|
||||
2 => 'options3',
|
||||
|
||||
@@ -17,8 +17,10 @@ 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/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/widgets/comments_widget.dart';
|
||||
import 'package:eatvio_app/core/utils/toasts.dart';
|
||||
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_cubit.dart';
|
||||
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_state.dart';
|
||||
import 'package:eatvio_app/features/plans/presentation/widgets/confirm_start_plan_bottom_sheet.dart';
|
||||
import 'package:eatvio_app/features/plans/presentation/widgets/options_tab.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
@@ -73,6 +75,51 @@ class _AboutPlanScreenState extends State<AboutPlanScreen> {
|
||||
scrollController.jumpTo(scrollController.position.maxScrollExtent);
|
||||
}
|
||||
|
||||
Future<void> _onOrderPressed(BuildContext context) async {
|
||||
final cubit = context.read<AboutPlanCubit>();
|
||||
final publicDiet = await cubit.getActivePublicDiet();
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (publicDiet != null) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (_) => ConfirmStartPlanBottomSheet(
|
||||
onConfirm: () => _submitOrder(context, cubit, publicDietId: publicDiet.dietId),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await _submitOrder(context, cubit);
|
||||
}
|
||||
|
||||
Future<void> _submitOrder(BuildContext context, AboutPlanCubit cubit, {int? publicDietId}) async {
|
||||
final s = S.of(context);
|
||||
if (publicDietId != null) {
|
||||
final cancelled = await cubit.cancelPublicDiet(publicDietId);
|
||||
if (!cancelled) {
|
||||
showErrorToast('Не удалось отменить диету');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
final (chosenPlan, error) = await cubit.chosePlan();
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
if (error == 'hasPlan') {
|
||||
showErrorDialog(context, title: s.end_previous_plan, description: s.you_can_not_start_plan_until_end_of_prev_one);
|
||||
return;
|
||||
}
|
||||
if (chosenPlan == null) {
|
||||
showErrorDialog(context, title: 'Не удалось заказать план');
|
||||
return;
|
||||
}
|
||||
GoRouter.of(context).pushReplacementNamed('chatScreen', extra: {'uuid': chosenPlan.chatId, 'sendInitialMessage': true});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
@@ -147,7 +194,11 @@ class _AboutPlanScreenState extends State<AboutPlanScreen> {
|
||||
actions: [
|
||||
CustomPopup(
|
||||
isAuthor: state.planModel?.author.authorIsMe ?? false,
|
||||
isAuthorAdmin: state.planModel?.author.isAdmin ?? false,
|
||||
path: 'plan/${widget.slug}',
|
||||
slug: widget.slug,
|
||||
id: state.planModel?.id,
|
||||
plan: state.planModel,
|
||||
complainedContentType: ComplainedContent.plan,
|
||||
userSlug: state.planModel?.author.authorSlug ?? '',
|
||||
),
|
||||
@@ -182,20 +233,7 @@ class _AboutPlanScreenState extends State<AboutPlanScreen> {
|
||||
focusElevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
backgroundColor: appTheme.greenA700,
|
||||
onPressed: () async {
|
||||
final (chosenPlan, error) = await context.read<AboutPlanCubit>().chosePlan();
|
||||
if (error == 'hasPlan') {
|
||||
showErrorDialog(context, title: s.end_previous_plan, description: s.you_can_not_start_plan_until_end_of_prev_one);
|
||||
return;
|
||||
}
|
||||
if (chosenPlan == null) {
|
||||
if (!context.mounted) return;
|
||||
showErrorDialog(context, title: 'Не удалось заказать план');
|
||||
return;
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
GoRouter.of(context).pushReplacementNamed('chatScreen', extra: {'uuid': chosenPlan.chatId, 'sendInitialMessage': true});
|
||||
},
|
||||
onPressed: () => _onOrderPressed(context),
|
||||
label: Text(s.to_order, style: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700)),
|
||||
),
|
||||
),
|
||||
@@ -339,7 +377,11 @@ class _AboutPlanScreenState extends State<AboutPlanScreen> {
|
||||
actions: [
|
||||
CustomPopup(
|
||||
isAuthor: state.planModel?.author.authorIsMe ?? false,
|
||||
isAuthorAdmin: state.planModel?.author.isAdmin ?? false,
|
||||
path: 'plan/${widget.slug}',
|
||||
slug: widget.slug,
|
||||
id: state.planModel?.id,
|
||||
plan: state.planModel,
|
||||
complainedContentType: ComplainedContent.plan,
|
||||
userSlug: state.planModel?.author.authorSlug ?? '',
|
||||
),
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/core/widgets/bottom_sheet_handler_bar.dart';
|
||||
import 'package:eatvio_app/core/widgets/custom_outlined_button.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ConfirmStartPlanBottomSheet extends StatelessWidget {
|
||||
const ConfirmStartPlanBottomSheet({super.key, required this.onConfirm});
|
||||
|
||||
final Future<void> Function() onConfirm;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
const BottomSheetHandlerBar(),
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 25.0),
|
||||
child: Text(
|
||||
'Вы уверены, что хотите начать прохождение плана?',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16, right: 16, top: 16, bottom: 28),
|
||||
child: CustomOutlinedButton(
|
||||
onPressed: () async {
|
||||
await Navigator.of(context).maybePop();
|
||||
await onConfirm();
|
||||
},
|
||||
height: 60,
|
||||
text: s.confirm,
|
||||
buttonTextStyle: TextStyles.textLSemibold.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(
|
||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ abstract class ChosenDietsPaginationModel with _$ChosenDietsPaginationModel {
|
||||
abstract class ChosenDietModel with _$ChosenDietModel {
|
||||
factory ChosenDietModel({
|
||||
@JsonKey(name: 'id') required int chosenDietID,
|
||||
@JsonKey(name: 'chosen_plan_id') required int chosenPlanId,
|
||||
@JsonKey(name: 'chosen_plan_id', fromJson: _parseChosenPlanId) required int chosenPlanId,
|
||||
@JsonKey() required String state,
|
||||
@JsonKey(name: 'start_date', fromJson: parseDate) required DateTime startDate,
|
||||
@JsonKey(name: 'diet', fromJson: _parseDietId) required int? dietId,
|
||||
@@ -30,3 +30,9 @@ int? _parseDietId(dynamic json) {
|
||||
if (json == null) return null;
|
||||
return json['id'] as int?;
|
||||
}
|
||||
|
||||
int _parseChosenPlanId(dynamic json) {
|
||||
if (json == null) return 0;
|
||||
if (json is num) return json.toInt();
|
||||
return int.tryParse(json.toString()) ?? 0;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user