forked from eatvio/eatvio_app
Compare commits
46 Commits
pasport_da
...
issue-1062
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bbb08f2a2 | ||
|
|
ef4df92f58 | ||
|
|
dcd343e765 | ||
|
|
98c175df44 | ||
|
|
cb312037b2 | ||
|
|
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 |
@@ -8,8 +8,16 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
runs-on: [macos-arm64]
|
runs-on: [macos-arm64]
|
||||||
steps:
|
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
|
- name: Fastlane version
|
||||||
run: fastlane --version
|
run: |
|
||||||
|
git --version
|
||||||
|
fastlane --version
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: https://github.com/actions/checkout@v2
|
uses: https://github.com/actions/checkout@v2
|
||||||
|
|||||||
@@ -78,12 +78,7 @@
|
|||||||
<data android:host="eatvio.com" />
|
<data android:host="eatvio.com" />
|
||||||
<data android:host="www.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>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
|
|||||||
@@ -24,11 +24,9 @@ class EatvioFcmMessagingService : FirebaseMessagingService() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (MainActivity.isInForeground) {
|
// Data-only call pushes are delivered only to this service.
|
||||||
Log.i(TAG, "App in foreground, deferring incoming call to Flutter handler")
|
// FlutterFirebaseMessagingService is removed, so the Dart listener does
|
||||||
return
|
// not see them. Show the incoming UI here for both foreground and background.
|
||||||
}
|
|
||||||
|
|
||||||
VideoCallPushBridge.showIncomingCall(this, message.data)
|
VideoCallPushBridge.showIncomingCall(this, message.data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import io.flutter.plugin.common.MethodChannel
|
|||||||
import com.hiennv.flutter_callkit_incoming.FlutterCallkitIncomingPlugin
|
import com.hiennv.flutter_callkit_incoming.FlutterCallkitIncomingPlugin
|
||||||
import com.huawei.agconnect.config.AGConnectServicesConfig
|
import com.huawei.agconnect.config.AGConnectServicesConfig
|
||||||
import com.huawei.hms.aaid.HmsInstanceId
|
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.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -82,6 +85,20 @@ class MainActivity: FlutterActivity() {
|
|||||||
}
|
}
|
||||||
methodChannel?.setMethodCallHandler { call, result ->
|
methodChannel?.setMethodCallHandler { call, result ->
|
||||||
when (call.method) {
|
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" -> {
|
"getHuaweiToken" -> {
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
try {
|
try {
|
||||||
@@ -121,10 +138,30 @@ class MainActivity: FlutterActivity() {
|
|||||||
runOnUiThread {
|
runOnUiThread {
|
||||||
result.success(token)
|
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) {
|
} catch (e: Exception) {
|
||||||
android.util.Log.e("HMS", "Error getting HMS token: ${e.message}", e)
|
val message = e.message.orEmpty()
|
||||||
runOnUiThread {
|
if (message.contains("907135003") || message.contains("client api invalid", ignoreCase = true)) {
|
||||||
result.error("HMS_TOKEN_ERROR", e.message, null)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -264,7 +264,7 @@ object VideoCallPushBridge {
|
|||||||
putString(CallkitConstants.EXTRA_CALLKIT_APP_NAME, "Итвио")
|
putString(CallkitConstants.EXTRA_CALLKIT_APP_NAME, "Итвио")
|
||||||
putString(CallkitConstants.EXTRA_CALLKIT_HANDLE, conversationUuid)
|
putString(CallkitConstants.EXTRA_CALLKIT_HANDLE, conversationUuid)
|
||||||
putString(CallkitConstants.EXTRA_CALLKIT_AVATAR, avatarUrl)
|
putString(CallkitConstants.EXTRA_CALLKIT_AVATAR, avatarUrl)
|
||||||
putInt(CallkitConstants.EXTRA_CALLKIT_TYPE, 0)
|
putInt(CallkitConstants.EXTRA_CALLKIT_TYPE, 1)
|
||||||
putLong(CallkitConstants.EXTRA_CALLKIT_DURATION, 45000L)
|
putLong(CallkitConstants.EXTRA_CALLKIT_DURATION, 45000L)
|
||||||
putString(CallkitConstants.EXTRA_CALLKIT_TEXT_ACCEPT, "Answer")
|
putString(CallkitConstants.EXTRA_CALLKIT_TEXT_ACCEPT, "Answer")
|
||||||
putString(CallkitConstants.EXTRA_CALLKIT_TEXT_DECLINE, "Decline")
|
putString(CallkitConstants.EXTRA_CALLKIT_TEXT_DECLINE, "Decline")
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ post_install do |installer|
|
|||||||
'$(inherited)',
|
'$(inherited)',
|
||||||
## dart: PermissionGroup.camera
|
## dart: PermissionGroup.camera
|
||||||
'PERMISSION_CAMERA=1',
|
'PERMISSION_CAMERA=1',
|
||||||
|
## dart: PermissionGroup.microphone
|
||||||
|
'PERMISSION_MICROPHONE=1',
|
||||||
## dart: PermissionGroup.photos
|
## dart: PermissionGroup.photos
|
||||||
'PERMISSION_PHOTOS=1',
|
'PERMISSION_PHOTOS=1',
|
||||||
'PERMISSION_NOTIFICATIONS=1',
|
'PERMISSION_NOTIFICATIONS=1',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Flutter
|
|||||||
import PushKit
|
import PushKit
|
||||||
import UIKit
|
import UIKit
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
import WebRTC
|
||||||
import YandexLoginSDK
|
import YandexLoginSDK
|
||||||
import flutter_callkit_incoming
|
import flutter_callkit_incoming
|
||||||
import flutter_foreground_task
|
import flutter_foreground_task
|
||||||
@@ -15,7 +16,11 @@ import flutter_foreground_task
|
|||||||
private let voipDiagnosticsStorageKey = "EatvioVoipDiagnostics"
|
private let voipDiagnosticsStorageKey = "EatvioVoipDiagnostics"
|
||||||
private let pendingAcceptedVoipCallKey = "EatvioPendingAcceptedVoipCall"
|
private let pendingAcceptedVoipCallKey = "EatvioPendingAcceptedVoipCall"
|
||||||
private let voipDiagnosticsChannelName = "com.eatvioapp/voip_diagnostics"
|
private let voipDiagnosticsChannelName = "com.eatvioapp/voip_diagnostics"
|
||||||
|
private let callAudioSessionChannelName = "com.eatvioapp/call_audio_session"
|
||||||
private let appUrlScheme = "eatvio"
|
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
|
||||||
|
|
||||||
override func application(
|
override func application(
|
||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
@@ -57,6 +62,27 @@ import flutter_foreground_task
|
|||||||
result(FlutterMethodNotImplemented)
|
result(FlutterMethodNotImplemented)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let callAudioChannel = FlutterMethodChannel(
|
||||||
|
name: callAudioSessionChannelName,
|
||||||
|
binaryMessenger: controller.binaryMessenger
|
||||||
|
)
|
||||||
|
callAudioChannel.setMethodCallHandler { [weak self] call, result in
|
||||||
|
guard let self else {
|
||||||
|
result(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch call.method {
|
||||||
|
case "activate":
|
||||||
|
self.activateInAppCallAudioSession()
|
||||||
|
result(nil)
|
||||||
|
case "deactivate":
|
||||||
|
self.deactivateInAppCallAudioSession()
|
||||||
|
result(nil)
|
||||||
|
default:
|
||||||
|
result(FlutterMethodNotImplemented)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return launched
|
return launched
|
||||||
@@ -120,6 +146,35 @@ import flutter_foreground_task
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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? {
|
private func conversationUuid(fromActionUrl actionUrl: String?) -> String? {
|
||||||
guard
|
guard
|
||||||
let actionUrl = actionUrl,
|
let actionUrl = actionUrl,
|
||||||
@@ -343,13 +398,16 @@ import flutter_foreground_task
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let nameCaller = (dict["nameCaller"] as? String)
|
let rawNameCaller = (dict["nameCaller"] as? String)
|
||||||
?? (dict["counterpartName"] as? String)
|
?? (dict["counterpartName"] as? String)
|
||||||
?? (dict["counterpart_name"] as? String)
|
?? (dict["counterpart_name"] as? String)
|
||||||
?? (dict["caller_name"] as? String)
|
?? (dict["caller_name"] as? String)
|
||||||
?? (dict["callerName"] as? String)
|
?? (dict["callerName"] as? String)
|
||||||
?? (dict["caller"] as? String)
|
?? (dict["caller"] as? String)
|
||||||
?? ""
|
?? ""
|
||||||
|
let nameCaller = rawNameCaller.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
? "Итвио"
|
||||||
|
: rawNameCaller.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let conversationUuid = (dict["conversation_uuid"] as? String)
|
let conversationUuid = (dict["conversation_uuid"] as? String)
|
||||||
?? (dict["conversationUuid"] as? String)
|
?? (dict["conversationUuid"] as? String)
|
||||||
?? (dict["conversation_id"] as? String)
|
?? (dict["conversation_id"] as? String)
|
||||||
@@ -358,26 +416,27 @@ import flutter_foreground_task
|
|||||||
?? conversationUuid(fromActionUrl: dict["action_url"] as? String)
|
?? conversationUuid(fromActionUrl: dict["action_url"] as? String)
|
||||||
?? (dict["handle"] as? String)
|
?? (dict["handle"] as? String)
|
||||||
?? ""
|
?? ""
|
||||||
let callKitIdSource = [
|
let trimmedConversationUuid = conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
(key: "call_id", value: dict["call_id"] as? String),
|
let parsedMessageId = voipMessageId(dict["message_id"])
|
||||||
(key: "id", value: dict["id"] as? String),
|
let id: String
|
||||||
(key: "message_id", value: dict["message_id"] as? String),
|
let callKitIdSourceName: String
|
||||||
]
|
if let derived = derivedCallKitId(
|
||||||
.first { item in
|
conversationUuid: trimmedConversationUuid,
|
||||||
guard let trimmed = item.value?.trimmingCharacters(in: .whitespacesAndNewlines) else {
|
messageId: parsedMessageId
|
||||||
return false
|
) {
|
||||||
}
|
id = derived
|
||||||
return !trimmed.isEmpty && UUID(uuidString: trimmed) != nil
|
callKitIdSourceName = "conversation_and_message"
|
||||||
}
|
} else {
|
||||||
let id = callKitIdSource?.value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? UUID().uuidString
|
id = UUID().uuidString
|
||||||
let callKitIdSourceName = callKitIdSource?.key ?? "generated"
|
callKitIdSourceName = "generated"
|
||||||
|
}
|
||||||
let isVideo = (dict["isVideo"] as? Bool)
|
let isVideo = (dict["isVideo"] as? Bool)
|
||||||
?? (dict["is_video"] as? Bool)
|
?? (dict["is_video"] as? Bool)
|
||||||
?? true
|
?? true
|
||||||
|
|
||||||
var extra: [String: Any] = [:]
|
var extra: [String: Any] = [:]
|
||||||
if !conversationUuid.isEmpty {
|
if !trimmedConversationUuid.isEmpty {
|
||||||
extra["conversationUuid"] = conversationUuid
|
extra["conversationUuid"] = trimmedConversationUuid
|
||||||
}
|
}
|
||||||
if let avatar = dict["caller_avatar"] as? String ?? dict["avatar"] as? String
|
if let avatar = dict["caller_avatar"] as? String ?? dict["avatar"] as? String
|
||||||
?? dict["avatarUrl"] as? String
|
?? dict["avatarUrl"] as? String
|
||||||
@@ -409,9 +468,10 @@ import flutter_foreground_task
|
|||||||
let data = flutter_callkit_incoming.Data(
|
let data = flutter_callkit_incoming.Data(
|
||||||
id: id,
|
id: id,
|
||||||
nameCaller: nameCaller,
|
nameCaller: nameCaller,
|
||||||
handle: conversationUuid.isEmpty ? nameCaller : conversationUuid,
|
handle: trimmedConversationUuid.isEmpty ? nameCaller : trimmedConversationUuid,
|
||||||
type: isVideo ? 1 : 0
|
type: isVideo ? 1 : 0
|
||||||
)
|
)
|
||||||
|
data.duration = 45000
|
||||||
data.extra = extra as NSDictionary
|
data.extra = extra as NSDictionary
|
||||||
|
|
||||||
NSLog(
|
NSLog(
|
||||||
@@ -482,6 +542,8 @@ import flutter_foreground_task
|
|||||||
|
|
||||||
|
|
||||||
func onAccept(_ call: Call, _ action: CXAnswerCallAction) {
|
func onAccept(_ call: Call, _ action: CXAnswerCallAction) {
|
||||||
|
resumeAudioAfterCallKitEnds = true
|
||||||
|
prepareCallAudioCategory()
|
||||||
storePendingAcceptedCall(call)
|
storePendingAcceptedCall(call)
|
||||||
recordVoipDiagnostic(
|
recordVoipDiagnostic(
|
||||||
"callkit_accept_native",
|
"callkit_accept_native",
|
||||||
@@ -500,6 +562,7 @@ import flutter_foreground_task
|
|||||||
}
|
}
|
||||||
|
|
||||||
func onDecline(_ call: Call, _ action: CXEndCallAction) {
|
func onDecline(_ call: Call, _ action: CXEndCallAction) {
|
||||||
|
resumeAudioAfterCallKitEnds = false
|
||||||
action.fulfill()
|
action.fulfill()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -509,9 +572,71 @@ import flutter_foreground_task
|
|||||||
|
|
||||||
func onTimeOut(_ call: Call) {}
|
func onTimeOut(_ call: Call) {}
|
||||||
|
|
||||||
func didActivateAudioSession(_ audioSession: AVAudioSession) {}
|
func didActivateAudioSession(_ audioSession: AVAudioSession) {
|
||||||
|
let rtcSession = RTCAudioSession.sharedInstance()
|
||||||
|
rtcSession.audioSessionDidActivate(audioSession)
|
||||||
|
rtcSession.isAudioEnabled = true
|
||||||
|
}
|
||||||
|
|
||||||
func didDeactivateAudioSession(_ audioSession: AVAudioSession) {}
|
func didDeactivateAudioSession(_ audioSession: AVAudioSession) {
|
||||||
|
let rtcSession = RTCAudioSession.sharedInstance()
|
||||||
|
rtcSession.audioSessionDidDeactivate(audioSession)
|
||||||
|
guard resumeAudioAfterCallKitEnds else { return }
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self, self.resumeAudioAfterCallKitEnds else { return }
|
||||||
|
self.activateInAppCallAudioSession()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func providerDidReset() {}
|
func providerDidReset() {
|
||||||
|
resumeAudioAfterCallKitEnds = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func prepareCallAudioCategory() {
|
||||||
|
let rtcSession = RTCAudioSession.sharedInstance()
|
||||||
|
rtcSession.lockForConfiguration()
|
||||||
|
defer { rtcSession.unlockForConfiguration() }
|
||||||
|
let configuration = callAudioConfiguration()
|
||||||
|
do {
|
||||||
|
try rtcSession.setConfiguration(configuration)
|
||||||
|
} catch {
|
||||||
|
NSLog("[EatvioVoIP] prepare call audio category failed: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func activateInAppCallAudioSession() {
|
||||||
|
let rtcSession = RTCAudioSession.sharedInstance()
|
||||||
|
rtcSession.lockForConfiguration()
|
||||||
|
defer { rtcSession.unlockForConfiguration() }
|
||||||
|
do {
|
||||||
|
try rtcSession.setConfiguration(callAudioConfiguration(), active: true)
|
||||||
|
rtcSession.isAudioEnabled = true
|
||||||
|
} catch {
|
||||||
|
NSLog("[EatvioVoIP] activate call audio session failed: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deactivateInAppCallAudioSession() {
|
||||||
|
resumeAudioAfterCallKitEnds = false
|
||||||
|
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,
|
||||||
|
]
|
||||||
|
configuration.mode = AVAudioSession.Mode.voiceChat.rawValue
|
||||||
|
return configuration
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
<array>
|
<array>
|
||||||
<string>applinks:eatvio.ru</string>
|
<string>applinks:eatvio.ru</string>
|
||||||
<string>applinks:www.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>
|
<string>applinks:5399b03636104c6d920b87c1ad329254.oauth.yandex.ru</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:developer';
|
|
||||||
import 'dart:io' as io;
|
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/env_config.dart';
|
||||||
import 'package:eatvio_app/core/error_reporting/error_report_policy.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' 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/features/video_calls/services/video_call_fcm_handler.dart';
|
||||||
import 'package:eatvio_app/firebase_options.dart';
|
import 'package:eatvio_app/firebase_options.dart';
|
||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_native_splash/flutter_native_splash.dart';
|
import 'package:flutter_native_splash/flutter_native_splash.dart';
|
||||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
@@ -18,7 +14,6 @@ import 'package:sentry_flutter/sentry_flutter.dart';
|
|||||||
|
|
||||||
/// Класс для инициализации приложения
|
/// Класс для инициализации приложения
|
||||||
class AppInitializer {
|
class AppInitializer {
|
||||||
|
|
||||||
/// Инициализация всех сервисов приложения
|
/// Инициализация всех сервисов приложения
|
||||||
static Future<void> initialize({required AppBuildType buildType}) async {
|
static Future<void> initialize({required AppBuildType buildType}) async {
|
||||||
await _initializeEnvironment();
|
await _initializeEnvironment();
|
||||||
@@ -42,25 +37,21 @@ class AppInitializer {
|
|||||||
|
|
||||||
/// Инициализация Firebase
|
/// Инициализация Firebase
|
||||||
static Future<void> _initializeFirebase() async {
|
static Future<void> _initializeFirebase() async {
|
||||||
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform).timeout(
|
await Firebase.initializeApp(
|
||||||
|
options: DefaultFirebaseOptions.currentPlatform,
|
||||||
|
).timeout(
|
||||||
const Duration(seconds: 10),
|
const Duration(seconds: 10),
|
||||||
onTimeout: () {
|
onTimeout: () {
|
||||||
throw TimeoutException('Firebase initialization timeout');
|
throw TimeoutException('Firebase initialization timeout');
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
VideoCallFcmHandler.registerBackgroundHandler();
|
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);
|
await di.init(buildType: buildType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ class CubitRegistrations {
|
|||||||
|
|
||||||
// Content viewing cubits
|
// Content viewing cubits
|
||||||
sl.registerFactory<AboutDietCubit>(() => AboutDietCubit(dietsRepository: sl(), profileRepository: sl()));
|
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<AboutRecipeCubit>(() => AboutRecipeCubit(recipeRepository: sl(), profileRepository: sl()));
|
||||||
sl.registerFactory<BlogPostCubit>(() => BlogPostCubit(sl()));
|
sl.registerFactory<BlogPostCubit>(() => BlogPostCubit(sl()));
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:eatvio_app/core/push/hms_client_error.dart';
|
||||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||||
|
|
||||||
/// Rules for which errors are sent to crash reporting.
|
/// Rules for which errors are sent to crash reporting.
|
||||||
@@ -15,6 +16,7 @@ class ErrorReportPolicy {
|
|||||||
if (error == null) return false;
|
if (error == null) return false;
|
||||||
if (error is String) return false;
|
if (error is String) return false;
|
||||||
if (error is SocketException) return false;
|
if (error is SocketException) return false;
|
||||||
|
if (isHmsClientUnavailable(error)) return false;
|
||||||
|
|
||||||
if (error is DioException) {
|
if (error is DioException) {
|
||||||
return _shouldReportDio(error);
|
return _shouldReportDio(error);
|
||||||
|
|||||||
@@ -3,59 +3,57 @@ import 'dart:developer';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:eatvio_app/core/app_config.dart';
|
import 'package:eatvio_app/core/app_config.dart';
|
||||||
import 'package:eatvio_app/core/app_initializer.dart';
|
import 'package:eatvio_app/core/app_initializer.dart';
|
||||||
import 'package:eatvio_app/core/utils/toasts.dart';
|
import 'package:eatvio_app/core/notifications/notification_permission_requester.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:eatvio_app/core/push/hms_client_error.dart';
|
||||||
import 'package:device_info_plus/device_info_plus.dart';
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:eatvio_app/core/locator_service.dart';
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
import 'package:eatvio_app/core/token_storage.dart';
|
import 'package:eatvio_app/core/token_storage.dart';
|
||||||
import 'package:firebase_messaging/firebase_messaging.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:eatvio_app/core/error_reporting/error_reporter.dart';
|
||||||
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
|
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
|
||||||
|
|
||||||
class FirebaseApi {
|
class FirebaseApi {
|
||||||
final ErrorReporter _errorReporter;
|
final ErrorReporter _errorReporter;
|
||||||
|
final NotificationPermissionRequester _notificationPermissionRequester;
|
||||||
final _firebaseMessaging = FirebaseMessaging.instance;
|
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 {
|
Future<String?> getToken() async {
|
||||||
String? token;
|
final credentials = await _resolvePushCredentials();
|
||||||
|
return credentials.token;
|
||||||
|
}
|
||||||
|
|
||||||
if (await _isHuaweiDevice()) {
|
Future<void> _requestNotificationPermission() async {
|
||||||
token = await _getHuaweiPushTokenNative().timeout(
|
if (sl<AppConfig>().buildType != AppBuildType.prod) return;
|
||||||
const Duration(seconds: 5),
|
try {
|
||||||
onTimeout: () => null,
|
await _notificationPermissionRequester.requestOnce(
|
||||||
);
|
() => _firebaseMessaging.requestPermission(),
|
||||||
} else {
|
|
||||||
if ((sl<AppConfig>().buildType == AppBuildType.prod)) {
|
|
||||||
try {
|
|
||||||
await _firebaseMessaging.requestPermission().timeout(
|
|
||||||
const Duration(seconds: 5),
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
// Разрешение не критично, продолжаем
|
|
||||||
log('Failed to request Firebase messaging permission: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
token = await _firebaseMessaging.getToken().timeout(
|
|
||||||
const Duration(seconds: 5),
|
|
||||||
onTimeout: () => null,
|
|
||||||
);
|
);
|
||||||
|
} catch (e) {
|
||||||
|
log('Failed to request Firebase messaging permission: $e');
|
||||||
}
|
}
|
||||||
return token;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> registerAnonymousPushToken() async {
|
Future<void> registerAnonymousPushToken() async {
|
||||||
try {
|
try {
|
||||||
|
await _requestNotificationPermission();
|
||||||
final deviceInfo = DeviceInfoPlugin();
|
final deviceInfo = DeviceInfoPlugin();
|
||||||
|
final credentials = await _resolvePushCredentials();
|
||||||
|
final token = credentials.token;
|
||||||
|
if (token == null || token.isEmpty) return;
|
||||||
await sl<Dio>().post(
|
await sl<Dio>().post(
|
||||||
'/api/anonymous_token',
|
'/api/anonymous_token',
|
||||||
data: {
|
data: {
|
||||||
'platform': Platform.isIOS ? 'ios' : (await _isHuaweiDevice()) ? 'huawei' : 'android',
|
'platform': credentials.platform,
|
||||||
'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id,
|
'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id,
|
||||||
'token': await getToken(),
|
'token': token,
|
||||||
},
|
},
|
||||||
).timeout(
|
).timeout(
|
||||||
const Duration(seconds: 10),
|
const Duration(seconds: 10),
|
||||||
@@ -73,14 +71,17 @@ class FirebaseApi {
|
|||||||
Future<void> registerPushToken() async {
|
Future<void> registerPushToken() async {
|
||||||
if (await TokenStorage.haveTokenInStorage()) {
|
if (await TokenStorage.haveTokenInStorage()) {
|
||||||
try {
|
try {
|
||||||
|
await _requestNotificationPermission();
|
||||||
final deviceInfo = DeviceInfoPlugin();
|
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(
|
await sl<Dio>().post(
|
||||||
'/api/profile/notification/token/register',
|
'/api/profile/notification/token/register',
|
||||||
data: {
|
data: {
|
||||||
'platform': Platform.isIOS ? 'ios' : (isHuawei ? 'huawei' : 'android'),
|
'platform': credentials.platform,
|
||||||
'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id,
|
'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id,
|
||||||
'token': await getToken(),
|
'token': token,
|
||||||
},
|
},
|
||||||
).timeout(
|
).timeout(
|
||||||
const Duration(seconds: 10),
|
const Duration(seconds: 10),
|
||||||
@@ -115,13 +116,13 @@ class FirebaseApi {
|
|||||||
Future<void> unregisterPushToken() async {
|
Future<void> unregisterPushToken() async {
|
||||||
if (!await TokenStorage.haveTokenInStorage()) return;
|
if (!await TokenStorage.haveTokenInStorage()) return;
|
||||||
try {
|
try {
|
||||||
final isHuawei = Platform.isAndroid ? await _isHuaweiDevice() : false;
|
final credentials = await _resolvePushCredentials();
|
||||||
final platform = Platform.isIOS ? 'ios' : (isHuawei ? 'huawei' : 'android');
|
final token = credentials.token ?? '';
|
||||||
final token = (await getToken()) ?? '';
|
if (token.isEmpty) return;
|
||||||
await sl<Dio>().post(
|
await sl<Dio>().post(
|
||||||
'/api/profile/notification/token/unregister',
|
'/api/profile/notification/token/unregister',
|
||||||
data: {
|
data: {
|
||||||
'platform': platform,
|
'platform': credentials.platform,
|
||||||
'token': token,
|
'token': token,
|
||||||
},
|
},
|
||||||
).timeout(
|
).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 {
|
Future<bool> _isHuaweiDevice() async {
|
||||||
if (!Platform.isAndroid) return false;
|
if (!Platform.isAndroid) return false;
|
||||||
try {
|
try {
|
||||||
final info = await DeviceInfoPlugin().androidInfo;
|
final info = await DeviceInfoPlugin().androidInfo;
|
||||||
final brand = info.brand.toLowerCase();
|
return isHuaweiFamilyBrand(brand: info.brand, manufacturer: info.manufacturer);
|
||||||
final manufacturer = info.manufacturer.toLowerCase();
|
|
||||||
return brand.contains('huawei') || brand.contains('honor') || manufacturer.contains('huawei') || manufacturer.contains('honor');
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return false;
|
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 {
|
Future<String?> _getHuaweiPushTokenNative() async {
|
||||||
try {
|
try {
|
||||||
const channel = MethodChannel('com.eatvioapp/hms_push');
|
const channel = MethodChannel('com.eatvioapp/hms_push');
|
||||||
final String? token = await channel.invokeMethod<String>('getHuaweiToken');
|
final String? token = await channel.invokeMethod<String>('getHuaweiToken');
|
||||||
return token;
|
return token;
|
||||||
} on PlatformException catch (e, st) {
|
} on PlatformException catch (e, st) {
|
||||||
showErrorToast('$e $st');
|
|
||||||
log(e.toString(), stackTrace: 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;
|
return null;
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
showErrorToast('$e $st');
|
|
||||||
log(e.toString(), stackTrace: st);
|
log(e.toString(), stackTrace: st);
|
||||||
await _errorReporter.report(e, st, origin: 'FirebaseApi');
|
await _errorReporter.report(e, st, origin: 'FirebaseApi');
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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_plan/presentation/create_plan_screen.dart';
|
||||||
import 'package:eatvio_app/features/create_recipe/presentation/pages/recipe_create_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/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/feed/models/recipes_model.dart';
|
||||||
import 'package:eatvio_app/features/glossary/presentation/pages/glossary_screen.dart';
|
import 'package:eatvio_app/features/glossary/presentation/pages/glossary_screen.dart';
|
||||||
import 'package:eatvio_app/features/menu/presentation/pages/blocked_users_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);
|
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: MyDietsScreen.path, name: MyDietsScreen.name, builder: (context, state) => const MyDietsScreen()),
|
||||||
GoRoute(path: MyPlansScreen.path, name: MyPlansScreen.name, builder: (context, state) => const MyPlansScreen()),
|
GoRoute(path: MyPlansScreen.path, name: MyPlansScreen.name, builder: (context, state) => const MyPlansScreen()),
|
||||||
GoRoute(path: MyRecipesScreen.path, name: MyRecipesScreen.name, builder: (context, state) => const MyRecipesScreen()),
|
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? _eventSubscription;
|
||||||
StreamSubscription? _connectionEstablishedSubscription;
|
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 newMessagesStream => _privateMessagesChannel?.bind('App\\Events\\ChatMessageUpdated');
|
||||||
|
|
||||||
Stream<ChannelReadEvent>? get newNotificationsStream => _privateNotificationChannel?.bind('Illuminate\\Notifications\\Events\\BroadcastNotificationCreated');
|
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 {
|
Future<void> initialize() async {
|
||||||
|
if (_reconnectController.isClosed) {
|
||||||
|
_reconnectController = StreamController<void>.broadcast();
|
||||||
|
}
|
||||||
|
|
||||||
if (client == null) {
|
if (client == null) {
|
||||||
try {
|
try {
|
||||||
if (!await TokenStorage.haveTokenInStorage()) return;
|
if (!await TokenStorage.haveTokenInStorage()) return;
|
||||||
@@ -105,8 +125,7 @@ class WsManager {
|
|||||||
|
|
||||||
_lifecycleSubscription = client?.lifecycleStream.listen((data) {
|
_lifecycleSubscription = client?.lifecycleStream.listen((data) {
|
||||||
if (data == PusherChannelsClientLifeCycleState.establishedConnection) {
|
if (data == PusherChannelsClientLifeCycleState.establishedConnection) {
|
||||||
_unsubscribe();
|
handleConnectionEstablished();
|
||||||
_subscribe();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
_eventSubscription = client?.eventStream.listen((event) {
|
_eventSubscription = client?.eventStream.listen((event) {
|
||||||
@@ -170,6 +189,7 @@ class WsManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> initPrivateDirectChatChannel(String chatId) async {
|
Future<void> initPrivateDirectChatChannel(String chatId) async {
|
||||||
|
_privateDirectChatChannel?.unsubscribe();
|
||||||
final token = await TokenStorage.getTokenFromStorage();
|
final token = await TokenStorage.getTokenFromStorage();
|
||||||
_privateDirectChatChannel = client?.privateChannel(
|
_privateDirectChatChannel = client?.privateChannel(
|
||||||
'$directChatChannelName.$chatId',
|
'$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 {
|
Future<void> initPresenceDirectChatChannel(String chatId) async {
|
||||||
@@ -293,12 +315,27 @@ class WsManager {
|
|||||||
_privateNotificationChannel?.unsubscribe();
|
_privateNotificationChannel?.unsubscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Повторно подписывается на каналы и сигналит подписчикам о реконнекте.
|
||||||
|
///
|
||||||
|
/// Вызывается при `establishedConnection` — в том числе после обрыва сети.
|
||||||
|
/// Публичный, чтобы сигнал [reconnected] покрывался тестами без реального
|
||||||
|
/// Pusher-клиента.
|
||||||
|
void handleConnectionEstablished() {
|
||||||
|
_unsubscribe();
|
||||||
|
_subscribe();
|
||||||
|
_privateDirectChatChannel?.subscribe();
|
||||||
|
_presenceDirectChatChannel?.subscribe();
|
||||||
|
_reconnectController.add(null);
|
||||||
|
}
|
||||||
|
|
||||||
void subscribeDirectChatChannel() {
|
void subscribeDirectChatChannel() {
|
||||||
_privateDirectChatChannel?.subscribe();
|
_privateDirectChatChannel?.subscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
void unsubscribeDirectChatChannel() {
|
void unsubscribeDirectChatChannel() {
|
||||||
_privateDirectChatChannel?.unsubscribe();
|
_privateDirectChatChannel?.unsubscribe();
|
||||||
|
_directChatStream = null;
|
||||||
|
_clientTypingStream = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
void unsubscribePresenceDirectChatChannel() {
|
void unsubscribePresenceDirectChatChannel() {
|
||||||
@@ -331,6 +368,8 @@ class WsManager {
|
|||||||
_privateDirectChatChannel = null;
|
_privateDirectChatChannel = null;
|
||||||
_privateMessagesChannel = null;
|
_privateMessagesChannel = null;
|
||||||
_privateNotificationChannel = 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/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/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/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/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/diets_pagination_model.dart';
|
||||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_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/models/recipes_model.dart';
|
||||||
import 'package:eatvio_app/features/feed/presentation/bloc/diets_cubit/diets_cubit.dart';
|
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_cubit.dart';
|
||||||
import 'package:eatvio_app/features/feed/presentation/bloc/recipes_cubit/recipes_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/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/my_plans/presentation/cubit/my_plans_cubit.dart';
|
||||||
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
|
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
|
||||||
@@ -25,6 +33,7 @@ import 'package:share_plus/share_plus.dart';
|
|||||||
|
|
||||||
class CustomPopup extends StatelessWidget {
|
class CustomPopup extends StatelessWidget {
|
||||||
final bool isAuthor;
|
final bool isAuthor;
|
||||||
|
final bool isAuthorAdmin;
|
||||||
final ComplainedContent? complainedContentType;
|
final ComplainedContent? complainedContentType;
|
||||||
final String path;
|
final String path;
|
||||||
final String? slug;
|
final String? slug;
|
||||||
@@ -32,12 +41,14 @@ class CustomPopup extends StatelessWidget {
|
|||||||
final FeedBlogPostModel? feedBlogPostModel;
|
final FeedBlogPostModel? feedBlogPostModel;
|
||||||
final Recipe? recipe;
|
final Recipe? recipe;
|
||||||
final Diet? diet;
|
final Diet? diet;
|
||||||
|
final PlanModel? plan;
|
||||||
final String userSlug;
|
final String userSlug;
|
||||||
final VoidCallback? onPostsChanged;
|
final VoidCallback? onPostsChanged;
|
||||||
|
|
||||||
const CustomPopup({
|
const CustomPopup({
|
||||||
super.key,
|
super.key,
|
||||||
required this.isAuthor,
|
required this.isAuthor,
|
||||||
|
this.isAuthorAdmin = false,
|
||||||
this.complainedContentType,
|
this.complainedContentType,
|
||||||
required this.path,
|
required this.path,
|
||||||
this.id,
|
this.id,
|
||||||
@@ -45,10 +56,14 @@ class CustomPopup extends StatelessWidget {
|
|||||||
this.feedBlogPostModel,
|
this.feedBlogPostModel,
|
||||||
this.recipe,
|
this.recipe,
|
||||||
this.diet,
|
this.diet,
|
||||||
|
this.plan,
|
||||||
required this.userSlug,
|
required this.userSlug,
|
||||||
this.onPostsChanged,
|
this.onPostsChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
bool get _isAdminAuthor =>
|
||||||
|
isAuthorAdmin || (diet?.author.isAdmin ?? false) || (recipe?.author.isAdmin ?? false) || (feedBlogPostModel?.author.isAdmin ?? false);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final s = S.of(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>[];
|
final entries = <PopupMenuEntry>[];
|
||||||
|
|
||||||
void addItem(String icon, String text, Function() onTap) {
|
void addItem(String icon, String text, Function() onTap) {
|
||||||
@@ -105,12 +216,9 @@ class CustomPopup extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
if (complainedContentType == ComplainedContent.posts) {
|
if (_showsEditAction()) {
|
||||||
addItem('assets/icons/edit_paint.svg', 'Редактировать', () async {
|
addItem('assets/icons/edit_paint.svg', s.edit, () {
|
||||||
if (complainedContentType == ComplainedContent.posts) {
|
_openEditor(context);
|
||||||
await GoRouter.of(context).pushNamed('blogPostCreateScreen', extra: feedBlogPostModel);
|
|
||||||
onPostsChanged?.call();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
addItem('assets/icons/red_trash.svg', 'Удалить', () async {
|
addItem('assets/icons/red_trash.svg', 'Удалить', () async {
|
||||||
@@ -167,19 +275,21 @@ class CustomPopup extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
addItem('assets/icons/complain.svg', 'Пожаловаться', () {
|
if (!_isAdminAuthor) {
|
||||||
if (complainedContentType != null) {
|
addItem('assets/icons/complain.svg', 'Пожаловаться', () {
|
||||||
showModalBottomSheet(
|
if (complainedContentType != null) {
|
||||||
context: context,
|
showModalBottomSheet(
|
||||||
isScrollControlled: true,
|
context: context,
|
||||||
builder: (context) => ComplainingBottomSheet(
|
isScrollControlled: true,
|
||||||
complainedContentType: complainedContentType!,
|
builder: (context) => ComplainingBottomSheet(
|
||||||
id: id,
|
complainedContentType: complainedContentType!,
|
||||||
slug: slug,
|
id: id,
|
||||||
),
|
slug: slug,
|
||||||
);
|
),
|
||||||
}
|
);
|
||||||
});
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
addItem(
|
addItem(
|
||||||
'assets/icons/complain.svg',
|
'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) {
|
if (isDrafted == true) {
|
||||||
text = 'cохранена в черновиках';
|
text = 'cохранена в черновиках';
|
||||||
} else if (isUpdated == true) {
|
} else if (isUpdated == true) {
|
||||||
text = 'успешно обновлена';
|
text = isPlan ? 'успешно обновлён' : 'успешно обновлена';
|
||||||
} else if (isPlan == true) {
|
} else if (isPlan == true) {
|
||||||
text = 'опубликован';
|
text = 'опубликован';
|
||||||
} else {
|
} 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/app_config.dart';
|
||||||
import 'package:eatvio_app/core/enums/complained_content.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/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/custom_popup.dart';
|
||||||
import 'package:eatvio_app/core/widgets/loader.dart';
|
import 'package:eatvio_app/core/widgets/loader.dart';
|
||||||
import 'package:eatvio_app/core/widgets/markdown_html_widget.dart';
|
import 'package:eatvio_app/core/widgets/markdown_html_widget.dart';
|
||||||
@@ -26,7 +27,11 @@ class BlogPostScreen extends StatefulWidget {
|
|||||||
final int id;
|
final int id;
|
||||||
final int? highlightedCommentId;
|
final int? highlightedCommentId;
|
||||||
|
|
||||||
const BlogPostScreen({super.key, required this.id, this.highlightedCommentId});
|
const BlogPostScreen({
|
||||||
|
super.key,
|
||||||
|
required this.id,
|
||||||
|
this.highlightedCommentId,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<BlogPostScreen> createState() => _BlogPostScreenState();
|
State<BlogPostScreen> createState() => _BlogPostScreenState();
|
||||||
@@ -45,7 +50,10 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _openGallery(List<String> imageUrls, int index, {Object? heroTag}) {
|
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
|
@override
|
||||||
@@ -53,7 +61,8 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
|||||||
return BlocProvider(
|
return BlocProvider(
|
||||||
create: (context) => sl<BlogPostCubit>()..getBlogPost(widget.id),
|
create: (context) => sl<BlogPostCubit>()..getBlogPost(widget.id),
|
||||||
child: BlocProvider<CommentsCubit>(
|
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>(
|
child: BlocBuilder<BlogPostCubit, BlogPostState>(
|
||||||
builder: (context, blogPostState) {
|
builder: (context, blogPostState) {
|
||||||
return BlocConsumer<CommentsCubit, CommentsState>(
|
return BlocConsumer<CommentsCubit, CommentsState>(
|
||||||
@@ -81,15 +90,23 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
|||||||
body: const Loader(),
|
body: const Loader(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final bool isLiked = blogPostState.blogPostModel?.wasLikedByMe ?? false;
|
final bool isLiked =
|
||||||
final int totalLikes = blogPostState.blogPostModel?.totalLikes ?? 0;
|
blogPostState.blogPostModel?.wasLikedByMe ?? false;
|
||||||
|
final int totalLikes =
|
||||||
|
blogPostState.blogPostModel?.totalLikes ?? 0;
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
resizeToAvoidBottomInset: true,
|
resizeToAvoidBottomInset: true,
|
||||||
bottomSheet: Container(
|
bottomSheet: Container(
|
||||||
padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom + 8),
|
padding: EdgeInsets.only(
|
||||||
|
bottom: MediaQuery.of(context).padding.bottom + 8,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
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(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||||
@@ -107,10 +124,17 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
|||||||
FocusManager.instance.primaryFocus?.unfocus();
|
FocusManager.instance.primaryFocus?.unfocus();
|
||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(color: appTheme.blueA400, shape: BoxShape.circle),
|
decoration: BoxDecoration(
|
||||||
|
color: appTheme.blueA400,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
height: 32,
|
height: 32,
|
||||||
width: 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),
|
if (replyId != null) const SizedBox(width: 8),
|
||||||
@@ -118,7 +142,8 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
|||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(minHeight: 52),
|
constraints: const BoxConstraints(minHeight: 52),
|
||||||
child: TextFormField(
|
child: TextFormField(
|
||||||
textCapitalization: TextCapitalization.sentences,
|
textCapitalization:
|
||||||
|
TextCapitalization.sentences,
|
||||||
onChanged: (_) {
|
onChanged: (_) {
|
||||||
setState(() {});
|
setState(() {});
|
||||||
},
|
},
|
||||||
@@ -126,26 +151,48 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
|||||||
maxLines: 5,
|
maxLines: 5,
|
||||||
minLines: 1,
|
minLines: 1,
|
||||||
keyboardType: TextInputType.multiline,
|
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(
|
decoration: InputDecoration(
|
||||||
isDense: false,
|
isDense: false,
|
||||||
// убираем true, чтобы padding учитывался корректно
|
// убираем true, чтобы padding учитывался корректно
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 16,
|
||||||
|
),
|
||||||
// вертикальный паддинг увеличен
|
// вертикальный паддинг увеличен
|
||||||
hintText: S.of(context).comment_hint,
|
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,
|
filled: true,
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
borderSide: const BorderSide(
|
||||||
|
color: Color(0xFFD1D1D6),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
borderSide: const BorderSide(
|
||||||
|
color: Color(0xFFD1D1D6),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
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 (!context.mounted) return;
|
||||||
|
|
||||||
if (isSent) context.read<FeedCubit>().incrementCommentsState(widget.id);
|
if (isSent) {
|
||||||
|
context
|
||||||
|
.read<FeedCubit>()
|
||||||
|
.incrementCommentsState(widget.id);
|
||||||
|
}
|
||||||
|
|
||||||
context.read<CommentsCubit>().setReplyId(null);
|
context.read<CommentsCubit>().setReplyId(null);
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -174,10 +225,19 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Container(
|
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,
|
height: 32,
|
||||||
width: 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: [
|
actions: [
|
||||||
CustomPopup(
|
CustomPopup(
|
||||||
isAuthor: blogPostState.blogPostModel?.author.authorIsMe ?? false,
|
isAuthor:
|
||||||
|
blogPostState.blogPostModel?.author.authorIsMe ??
|
||||||
|
false,
|
||||||
|
isAuthorAdmin:
|
||||||
|
blogPostState.blogPostModel?.author.isAdmin ??
|
||||||
|
false,
|
||||||
complainedContentType: ComplainedContent.posts,
|
complainedContentType: ComplainedContent.posts,
|
||||||
path: 'blog_post/${(widget.id)}',
|
path: 'blog_post/${(widget.id)}',
|
||||||
id: widget.id,
|
id: widget.id,
|
||||||
feedBlogPostModel: blogPostState.blogPostModel,
|
feedBlogPostModel: blogPostState.blogPostModel,
|
||||||
userSlug: blogPostState.blogPostModel?.author.authorSlug ?? '',
|
userSlug:
|
||||||
|
blogPostState.blogPostModel?.author.authorSlug ??
|
||||||
|
'',
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: SingleChildScrollView(
|
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(),
|
physics: const BouncingScrollPhysics(),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
GestureDetector(
|
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(
|
child: Ink(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -222,21 +295,48 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
|||||||
children: [
|
children: [
|
||||||
CircleAvatar(
|
CircleAvatar(
|
||||||
radius: 16,
|
radius: 16,
|
||||||
foregroundImage: NetworkImage(sl<AppConfig>().baseUrl + (blogPostState.blogPostModel?.author.authorProfileIcon ?? '')),
|
foregroundImage: NetworkImage(
|
||||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
joinBaseUrl(
|
||||||
|
sl<AppConfig>().baseUrl,
|
||||||
|
blogPostState
|
||||||
|
.blogPostModel
|
||||||
|
?.author
|
||||||
|
.authorProfileIcon ??
|
||||||
|
'',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
backgroundImage: const AssetImage(
|
||||||
|
'assets/images/no_avatar.png',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Column(
|
Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
blogPostState.blogPostModel?.author.authorName ?? '',
|
blogPostState
|
||||||
style: const TextStyle(color: Colors.black, fontSize: 14, fontWeight: FontWeight.w400, height: 1),
|
.blogPostModel
|
||||||
|
?.author
|
||||||
|
.authorName ??
|
||||||
|
'',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.black,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
height: 1,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
(blogPostState.blogPostModel?.createdAt ?? DateTime.now()).format('dd MMMM', 'ru'),
|
(blogPostState.blogPostModel?.createdAt ??
|
||||||
style: TextStyle(color: Colors.grey.shade600, fontSize: 12, fontWeight: FontWeight.w400, height: 1),
|
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(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(
|
||||||
child: MarkdownHtmlWidget(markdownText: blogPostState.blogPostModel!.description),
|
horizontal: 16,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
child: MarkdownHtmlWidget(
|
||||||
|
markdownText:
|
||||||
|
blogPostState.blogPostModel!.description,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Builder(
|
Builder(
|
||||||
@@ -256,16 +366,31 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
|||||||
if (post == null) {
|
if (post == null) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
final imageUrl = PostFeedMapper.resolveMediaUrl(post.image);
|
final imageUrl = PostFeedMapper.resolveMediaUrl(
|
||||||
|
post.image,
|
||||||
|
);
|
||||||
final embedUrl = post.videoEmbedUrl?.trim();
|
final embedUrl = post.videoEmbedUrl?.trim();
|
||||||
return BlogPostMediaContent(
|
return BlogPostMediaContent(
|
||||||
galleryImages: post.images,
|
galleryImages: post.images,
|
||||||
imageUrl: imageUrl,
|
imageUrl: imageUrl,
|
||||||
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null,
|
videoEmbedUrl:
|
||||||
videoUrlThumb: post.videoUrlThumb != null ? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb) : null,
|
embedUrl != null && embedUrl.isNotEmpty
|
||||||
|
? embedUrl
|
||||||
|
: null,
|
||||||
|
videoUrlThumb: post.videoUrlThumb != null
|
||||||
|
? PostFeedMapper.resolveMediaUrl(
|
||||||
|
post.videoUrlThumb,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
currentGalleryIndex: currentGalleryIndex,
|
currentGalleryIndex: currentGalleryIndex,
|
||||||
heroTag: 'blog-post-screen-${post.id}-cover',
|
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) {
|
onGalleryChanged: (index) {
|
||||||
setState(() {
|
setState(() {
|
||||||
currentGalleryIndex = index;
|
currentGalleryIndex = index;
|
||||||
@@ -275,22 +400,43 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
_iconButton(
|
_iconButton(
|
||||||
icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
icon: isLiked
|
||||||
|
? Icons.favorite_rounded
|
||||||
|
: Icons.favorite_border_rounded,
|
||||||
text: totalLikes.toString(),
|
text: totalLikes.toString(),
|
||||||
iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
|
iconColor: isLiked
|
||||||
backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null,
|
? Colors.red.withValues(alpha: 0.8)
|
||||||
textColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
|
: null,
|
||||||
|
backgroundColor: isLiked
|
||||||
|
? Colors.red.withValues(alpha: 0.1)
|
||||||
|
: null,
|
||||||
|
textColor: isLiked
|
||||||
|
? Colors.red.withValues(alpha: 0.8)
|
||||||
|
: null,
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
await context.read<BlogPostCubit>().toggleLike();
|
await context
|
||||||
|
.read<BlogPostCubit>()
|
||||||
|
.toggleLike();
|
||||||
// ignore: use_build_context_synchronously
|
// ignore: use_build_context_synchronously
|
||||||
final updated = context.read<BlogPostCubit>().state.blogPostModel;
|
final updated = context
|
||||||
|
.read<BlogPostCubit>()
|
||||||
|
.state
|
||||||
|
.blogPostModel;
|
||||||
if (updated != null) {
|
if (updated != null) {
|
||||||
// ignore: use_build_context_synchronously
|
// 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(
|
_iconButton(
|
||||||
icon: Icons.reply_rounded,
|
icon: Icons.reply_rounded,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
final box = context.findRenderObject() as RenderBox?;
|
final box =
|
||||||
|
context.findRenderObject() as RenderBox?;
|
||||||
SharePlus.instance.share(
|
SharePlus.instance.share(
|
||||||
ShareParams(
|
ShareParams(
|
||||||
uri: Uri.parse('${sl<AppConfig>().baseUrl}/ru/blog_post/${widget.id}'),
|
uri: Uri.parse(
|
||||||
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
|
'${sl<AppConfig>().baseUrl}/ru/blog_post/${widget.id}',
|
||||||
|
),
|
||||||
|
sharePositionOrigin:
|
||||||
|
box!.localToGlobal(Offset.zero) &
|
||||||
|
box.size,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
Icon(Icons.visibility_rounded, size: 16, color: appTheme.gray500),
|
Icon(
|
||||||
|
Icons.visibility_rounded,
|
||||||
|
size: 16,
|
||||||
|
color: appTheme.gray500,
|
||||||
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
blogPostState.blogPostModel?.totalViews.toString() ?? '',
|
blogPostState.blogPostModel?.totalViews
|
||||||
style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400),
|
.toString() ??
|
||||||
|
'',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: appTheme.gray500,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -320,7 +481,10 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
|||||||
Container(
|
Container(
|
||||||
width: MediaQuery.of(context).size.width - 32,
|
width: MediaQuery.of(context).size.width - 32,
|
||||||
height: 0.4,
|
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),
|
const SizedBox(height: 8),
|
||||||
Container(
|
Container(
|
||||||
@@ -328,7 +492,11 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
|||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: Text(
|
child: Text(
|
||||||
'${blogPostState.blogPostModel?.totalComments} КОММЕНТАРИЕВ',
|
'${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),
|
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(
|
return InkWell(
|
||||||
onTap: onTap ?? () {},
|
onTap: onTap ?? () {},
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
child: Ink(
|
child: Ink(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
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,
|
height: 32,
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -369,7 +547,10 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
|||||||
if (text != null)
|
if (text != null)
|
||||||
Text(
|
Text(
|
||||||
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() {
|
void resetFormErrors() {
|
||||||
if (!state.hasValidationErrors &&
|
if (!state.hasValidationErrors &&
|
||||||
!state.isSuccess &&
|
!state.isSuccess &&
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
import 'package:eatvio_app/app_keys.dart';
|
import 'package:eatvio_app/app_keys.dart';
|
||||||
import 'package:eatvio_app/core/app_router.dart';
|
import 'package:eatvio_app/core/app_router.dart';
|
||||||
@@ -38,11 +39,15 @@ class BlogPostCreateScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||||
|
static const _minImageWidth = 500;
|
||||||
|
static const _minImageHeight = 500;
|
||||||
|
|
||||||
final blogPostCubit = sl<BlogPostCreateCubit>();
|
final blogPostCubit = sl<BlogPostCreateCubit>();
|
||||||
bool shrinkImage = false;
|
bool shrinkImage = false;
|
||||||
late StreamSubscription<bool> keyboardSubscription;
|
late StreamSubscription<bool> keyboardSubscription;
|
||||||
final List<FeedGalleryImage> savedImages = [];
|
final List<FeedGalleryImage> savedImages = [];
|
||||||
final List<XFile> pendingImages = [];
|
final List<XFile> pendingImages = [];
|
||||||
|
final Set<String> _undersizedPendingPaths = {};
|
||||||
final List<int> mediaIdsToDelete = [];
|
final List<int> mediaIdsToDelete = [];
|
||||||
final titleController = TextEditingController();
|
final titleController = TextEditingController();
|
||||||
final descriptionController = TextEditingController();
|
final descriptionController = TextEditingController();
|
||||||
@@ -531,10 +536,7 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
|||||||
source: ImageSource.camera,
|
source: ImageSource.camera,
|
||||||
);
|
);
|
||||||
if (image != null && _remainingSlots > 0) {
|
if (image != null && _remainingSlots > 0) {
|
||||||
setState(() {
|
await _addPendingImages([image]);
|
||||||
pendingImages.add(image);
|
|
||||||
});
|
|
||||||
blogPostCubit.clearImagesError();
|
|
||||||
}
|
}
|
||||||
AppRouter.navigatorKey.currentContext?.pop();
|
AppRouter.navigatorKey.currentContext?.pop();
|
||||||
},
|
},
|
||||||
@@ -561,10 +563,9 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
|||||||
limit: _remainingSlots,
|
limit: _remainingSlots,
|
||||||
);
|
);
|
||||||
if (images.isNotEmpty) {
|
if (images.isNotEmpty) {
|
||||||
setState(() {
|
await _addPendingImages(
|
||||||
pendingImages.addAll(images.take(_remainingSlots));
|
images.take(_remainingSlots).toList(),
|
||||||
});
|
);
|
||||||
blogPostCubit.clearImagesError();
|
|
||||||
}
|
}
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
AppRouter.navigatorKey.currentContext?.pop();
|
AppRouter.navigatorKey.currentContext?.pop();
|
||||||
@@ -603,9 +604,12 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _removePendingImage(int index) {
|
void _removePendingImage(int index) {
|
||||||
|
final path = pendingImages[index].path;
|
||||||
setState(() {
|
setState(() {
|
||||||
pendingImages.removeAt(index);
|
pendingImages.removeAt(index);
|
||||||
|
_undersizedPendingPaths.remove(path);
|
||||||
});
|
});
|
||||||
|
_syncImagesSizeError();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onReorderGallery(int oldIndex, int newIndex) {
|
void _onReorderGallery(int oldIndex, int newIndex) {
|
||||||
@@ -670,164 +674,185 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold _buildBlogPostCreate(BuildContext context) {
|
Widget _buildEditorToolbar(BuildContext context) {
|
||||||
return Scaffold(
|
return Row(
|
||||||
resizeToAvoidBottomInset: true,
|
key: const ValueKey('blogPostCreateEditorToolbar'),
|
||||||
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
floatingActionButton: enableMdPanel || isPreviewMode
|
children: [
|
||||||
? Row(
|
if (!isPreviewMode) ...[
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
Container(
|
||||||
children: [
|
width: MediaQuery.of(context).size.width - 76,
|
||||||
if (!isPreviewMode) ...[
|
height: 52,
|
||||||
Container(
|
decoration: BoxDecoration(
|
||||||
width: MediaQuery.of(context).size.width - 76,
|
color: Colors.white,
|
||||||
height: 52,
|
borderRadius: BorderRadius.circular(20),
|
||||||
decoration: BoxDecoration(
|
boxShadow: [
|
||||||
color: Colors.white,
|
BoxShadow(
|
||||||
borderRadius: BorderRadius.circular(20),
|
color: const Color(0x00000000).withValues(alpha: 0.2),
|
||||||
boxShadow: [
|
spreadRadius: 0,
|
||||||
BoxShadow(
|
blurRadius: 32,
|
||||||
color: const Color(0x00000000).withValues(alpha: 0.2),
|
offset: const Offset(0, 0),
|
||||||
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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
),
|
||||||
: 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(),
|
appBar: AppBar(),
|
||||||
body: BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
|
body: BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
|
||||||
bloc: blogPostCubit,
|
bloc: blogPostCubit,
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
return SingleChildScrollView(
|
return Column(
|
||||||
padding: const EdgeInsets.all(16),
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
child: Column(
|
children: [
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
Expanded(
|
||||||
children: [
|
child: SingleChildScrollView(
|
||||||
const SizedBox(height: 16),
|
padding: const EdgeInsets.all(16),
|
||||||
const Text('Заголовок'),
|
child: Column(
|
||||||
const SizedBox(height: 8),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
CustomTextFormField(
|
children: [
|
||||||
key: AppKeys.blogPostCreateScreenTitleTextField,
|
const Text('Заголовок'),
|
||||||
controller: titleController,
|
const SizedBox(height: 8),
|
||||||
hasError: state.titleError != null,
|
CustomTextFormField(
|
||||||
onChanged: (_) => blogPostCubit.clearTitleError(),
|
key: AppKeys.blogPostCreateScreenTitleTextField,
|
||||||
),
|
controller: titleController,
|
||||||
if (state.titleError != null) ...[
|
hasError: state.titleError != null,
|
||||||
const SizedBox(height: 4),
|
onChanged: (_) => blogPostCubit.clearTitleError(),
|
||||||
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) ...[
|
if (state.titleError != null) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
ValidationErrorWidget(error: state.descriptionError!),
|
ValidationErrorWidget(error: state.titleError!),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
const Text('Описание'),
|
||||||
'Изображения ($_galleryCount/${BlogPostRepository.maxGalleryImages})',
|
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),
|
const SizedBox(height: 8),
|
||||||
_buildGallery(),
|
_buildEditorToolbar(context),
|
||||||
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),
|
|
||||||
],
|
],
|
||||||
),
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_undersizedPendingPaths.isNotEmpty) {
|
||||||
|
blogPostCubit.setImagesError(S.current.blog_post_image_min_size_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final title = titleController.text.trim();
|
final title = titleController.text.trim();
|
||||||
final description = descriptionController.text.trim();
|
final description = descriptionController.text.trim();
|
||||||
final videoUrl = videoUrlController.text.trim();
|
final videoUrl = videoUrlController.text.trim();
|
||||||
@@ -889,9 +919,8 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
|||||||
onTap: isSubmitting ? null : _submitBlogPost,
|
onTap: isSubmitting ? null : _submitBlogPost,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
child: isSubmitting
|
child: isSubmitting
|
||||||
? const Center(child: Loader())
|
? const SizedBox(height: 52, child: Center(child: Loader()))
|
||||||
: Ink(
|
: Ink(
|
||||||
width: MediaQuery.of(context).size.width - 32,
|
|
||||||
height: 52,
|
height: 52,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(16),
|
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() {
|
Widget _buildGallery() {
|
||||||
final itemHeight = shrinkImage ? 72.0 : 112.0;
|
final itemHeight = shrinkImage ? 72.0 : 112.0;
|
||||||
|
|
||||||
@@ -946,6 +1019,7 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
|||||||
child: _buildGalleryTile(
|
child: _buildGalleryTile(
|
||||||
index: index,
|
index: index,
|
||||||
height: itemHeight,
|
height: itemHeight,
|
||||||
|
hasError: _undersizedPendingPaths.contains(pending.path),
|
||||||
imageProvider: FileImage(File(pending.path)),
|
imageProvider: FileImage(File(pending.path)),
|
||||||
onRemove: () => _removePendingImage(pendingIndex),
|
onRemove: () => _removePendingImage(pendingIndex),
|
||||||
),
|
),
|
||||||
@@ -985,6 +1059,7 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
|||||||
required double height,
|
required double height,
|
||||||
required ImageProvider imageProvider,
|
required ImageProvider imageProvider,
|
||||||
required VoidCallback onRemove,
|
required VoidCallback onRemove,
|
||||||
|
bool hasError = false,
|
||||||
}) {
|
}) {
|
||||||
return ReorderableDelayedDragStartListener(
|
return ReorderableDelayedDragStartListener(
|
||||||
index: index,
|
index: index,
|
||||||
@@ -992,12 +1067,16 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
|||||||
width: height,
|
width: height,
|
||||||
height: height,
|
height: height,
|
||||||
margin: const EdgeInsets.only(right: 8),
|
margin: const EdgeInsets.only(right: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: hasError ? Border.all(color: appTheme.red, width: 2) : null,
|
||||||
|
),
|
||||||
child: Stack(
|
child: Stack(
|
||||||
clipBehavior: Clip.none,
|
clipBehavior: Clip.none,
|
||||||
children: [
|
children: [
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(14),
|
||||||
child: Image(image: imageProvider, fit: BoxFit.cover),
|
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/env_config.dart';
|
||||||
import 'package:eatvio_app/core/locator_service.dart';
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
import 'package:eatvio_app/core/utils/image_constant.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/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_cubit.dart';
|
||||||
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_state.dart';
|
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_state.dart';
|
||||||
@@ -26,11 +27,13 @@ class BottomNavigationBarWidget extends StatefulWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
final Widget child;
|
final Widget child;
|
||||||
|
|
||||||
/// Текущий путь из GoRouter (matchedLocation), чтобы подсветка таба совпадала с экраном при навигации через go() (диплинки, пуши).
|
/// Текущий путь из GoRouter (matchedLocation), чтобы подсветка таба совпадала с экраном при навигации через go() (диплинки, пуши).
|
||||||
final String currentLocation;
|
final String currentLocation;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<BottomNavigationBarWidget> createState() => _BottomNavigationBarWidgetState();
|
State<BottomNavigationBarWidget> createState() =>
|
||||||
|
_BottomNavigationBarWidgetState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||||
@@ -40,9 +43,24 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
|||||||
|
|
||||||
List<String> _getPages(bool isConsultant) {
|
List<String> _getPages(bool isConsultant) {
|
||||||
if (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 {
|
} 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(
|
final logo = SvgPicture.asset(
|
||||||
ImageConstant.imgLogo,
|
ImageConstant.imgLogo,
|
||||||
height: 32,
|
height: 32,
|
||||||
colorFilter: isProduction ? null : const ColorFilter.mode(_devAccentColor, BlendMode.srcIn),
|
colorFilter: isProduction
|
||||||
|
? null
|
||||||
|
: const ColorFilter.mode(_devAccentColor, BlendMode.srcIn),
|
||||||
);
|
);
|
||||||
if (isProduction) return logo;
|
if (isProduction) return logo;
|
||||||
return Stack(
|
return Stack(
|
||||||
@@ -109,7 +129,10 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(8),
|
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),
|
color: _devAccentColor.withValues(alpha: 0.08),
|
||||||
),
|
),
|
||||||
child: logo,
|
child: logo,
|
||||||
@@ -122,11 +145,23 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _devAccentColor,
|
color: _devAccentColor,
|
||||||
borderRadius: BorderRadius.circular(4),
|
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(
|
child: const Text(
|
||||||
'DEV',
|
'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(), в т.ч. диплинки/пуши).
|
// Подсветка таба по текущему маршруту (совпадает при навигации через go(), в т.ч. диплинки/пуши).
|
||||||
final pathWithoutQuery = widget.currentLocation.split('?').first;
|
final pathWithoutQuery = widget.currentLocation.split('?').first;
|
||||||
final indexFromRoute = pages!.indexOf(pathWithoutQuery);
|
final indexFromRoute = pages!.indexOf(pathWithoutQuery);
|
||||||
final effectiveIndex = indexFromRoute >= 0 ? indexFromRoute : currentBottomNavBarIndex;
|
final effectiveIndex = indexFromRoute >= 0
|
||||||
if (indexFromRoute >= 0 && indexFromRoute != currentBottomNavBarIndex) {
|
? indexFromRoute
|
||||||
|
: currentBottomNavBarIndex;
|
||||||
|
if (indexFromRoute >= 0 &&
|
||||||
|
indexFromRoute != currentBottomNavBarIndex) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (mounted) setState(() => currentBottomNavBarIndex = indexFromRoute);
|
if (mounted) {
|
||||||
|
setState(() => currentBottomNavBarIndex = indexFromRoute);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,9 +216,14 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
|||||||
label: '',
|
label: '',
|
||||||
icon: CircleAvatar(
|
icon: CircleAvatar(
|
||||||
foregroundImage: NetworkImage(
|
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,
|
radius: 16,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -186,22 +231,54 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
|||||||
label: 'Лента',
|
label: 'Лента',
|
||||||
icon: Padding(
|
icon: Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 2),
|
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(
|
activeIcon: Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 2),
|
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(
|
BottomNavigationBarItem(
|
||||||
label: s.diary,
|
label: s.diary,
|
||||||
icon: Padding(
|
icon: Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 2),
|
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(
|
activeIcon: Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 2),
|
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'))
|
if (profileModel.roles.contains('consultant'))
|
||||||
@@ -210,7 +287,10 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
|||||||
icon: Padding(
|
icon: Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 2),
|
padding: const EdgeInsets.only(bottom: 2),
|
||||||
child: SvgPicture.asset(
|
child: SvgPicture.asset(
|
||||||
colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn),
|
colorFilter: const ColorFilter.mode(
|
||||||
|
Colors.grey,
|
||||||
|
BlendMode.srcIn,
|
||||||
|
),
|
||||||
'assets/icons/consultations.svg',
|
'assets/icons/consultations.svg',
|
||||||
width: 20,
|
width: 20,
|
||||||
height: 20,
|
height: 20,
|
||||||
@@ -222,7 +302,10 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
|||||||
'assets/icons/consultations.svg',
|
'assets/icons/consultations.svg',
|
||||||
width: 20,
|
width: 20,
|
||||||
height: 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,
|
label: s.chat,
|
||||||
icon: BlocBuilder<ChatsCubit, ChatsState>(
|
icon: BlocBuilder<ChatsCubit, ChatsState>(
|
||||||
builder: (BuildContext context, ChatsState state) {
|
builder: (BuildContext context, ChatsState state) {
|
||||||
final hasUnreadMessages = state.chatsWithUnreadMessagesCount > 0;
|
final hasUnreadMessages =
|
||||||
|
state.chatsWithUnreadMessagesCount > 0;
|
||||||
return badges.Badge(
|
return badges.Badge(
|
||||||
position: badges.BadgePosition.topEnd(),
|
position: badges.BadgePosition.topEnd(),
|
||||||
badgeAnimation: const badges.BadgeAnimation.fade(),
|
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(
|
badgeContent: Text(
|
||||||
hasUnreadMessages ? state.chatsWithUnreadMessagesCount.toString() : '',
|
hasUnreadMessages
|
||||||
style: TextStyle(color: hasUnreadMessages ? Colors.white : Colors.transparent, fontSize: 8),
|
? state.chatsWithUnreadMessagesCount.toString()
|
||||||
|
: '',
|
||||||
|
style: TextStyle(
|
||||||
|
color: hasUnreadMessages
|
||||||
|
? Colors.white
|
||||||
|
: Colors.transparent,
|
||||||
|
fontSize: 8,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 2),
|
padding: const EdgeInsets.only(bottom: 2),
|
||||||
@@ -245,9 +340,16 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
|||||||
ImageConstant.icnChat,
|
ImageConstant.icnChat,
|
||||||
width: 20,
|
width: 20,
|
||||||
height: 20,
|
height: 20,
|
||||||
colorFilter: pages![effectiveIndex] == '/chat-select-screen'
|
colorFilter:
|
||||||
? ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn)
|
pages![effectiveIndex] == '/chat-select-screen'
|
||||||
: const ColorFilter.mode(Colors.grey, BlendMode.srcIn),
|
? 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(
|
return badges.Badge(
|
||||||
position: badges.BadgePosition.topEnd(),
|
position: badges.BadgePosition.topEnd(),
|
||||||
badgeAnimation: const badges.BadgeAnimation.fade(),
|
badgeAnimation: const badges.BadgeAnimation.fade(),
|
||||||
showBadge: state is NotificationsStateLoaded && state.hasUnreadNotifications,
|
showBadge:
|
||||||
badgeContent: const Text('', style: TextStyle(color: Colors.white, fontSize: 8)),
|
state is NotificationsStateLoaded &&
|
||||||
|
state.hasUnreadNotifications,
|
||||||
|
badgeContent: const Text(
|
||||||
|
'',
|
||||||
|
style: TextStyle(color: Colors.white, fontSize: 8),
|
||||||
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 2),
|
padding: const EdgeInsets.only(bottom: 2),
|
||||||
child: SvgPicture.asset(
|
child: SvgPicture.asset(
|
||||||
ImageConstant.icnBell,
|
ImageConstant.icnBell,
|
||||||
colorFilter: pages![effectiveIndex] == '/notifications-screen'
|
colorFilter:
|
||||||
? ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn)
|
pages![effectiveIndex] ==
|
||||||
: const ColorFilter.mode(Colors.grey, BlendMode.srcIn),
|
'/notifications-screen'
|
||||||
|
? ColorFilter.mode(
|
||||||
|
appTheme.blueA400,
|
||||||
|
BlendMode.srcIn,
|
||||||
|
)
|
||||||
|
: const ColorFilter.mode(
|
||||||
|
Colors.grey,
|
||||||
|
BlendMode.srcIn,
|
||||||
|
),
|
||||||
width: 20,
|
width: 20,
|
||||||
height: 20,
|
height: 20,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ abstract class ChatMessageModel with _$ChatMessageModel {
|
|||||||
@JsonKey(name: 'author_id', fromJson: _parseAuthorId) required int authorId,
|
@JsonKey(name: 'author_id', fromJson: _parseAuthorId) required int authorId,
|
||||||
@JsonKey(name: 'updated_at', fromJson: _parseUpdatedAt) required DateTime updatedAt,
|
@JsonKey(name: 'updated_at', fromJson: _parseUpdatedAt) required DateTime updatedAt,
|
||||||
@JsonKey(name: 'read_by_to_user', defaultValue: false) required bool isRead,
|
@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: 'is_author_message_auth_user') required bool? isCurrentUserAuthor,
|
||||||
@JsonKey(name: 'room_id') required String? roomId,
|
@JsonKey(name: 'room_id') required String? roomId,
|
||||||
@JsonKey(name: 'type', unknownEnumValue: ChatMessageType.unknown) ChatMessageType? type,
|
@JsonKey(name: 'type', unknownEnumValue: ChatMessageType.unknown) ChatMessageType? type,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
|
|
||||||
StreamSubscription<ChannelReadEvent>? typingSubscription;
|
StreamSubscription<ChannelReadEvent>? typingSubscription;
|
||||||
StreamSubscription<ChannelReadEvent>? messagesSubscription;
|
StreamSubscription<ChannelReadEvent>? messagesSubscription;
|
||||||
|
StreamSubscription<void>? _reconnectSubscription;
|
||||||
int currentPage = 1;
|
int currentPage = 1;
|
||||||
late int lastPage;
|
late int lastPage;
|
||||||
bool isLoading = false;
|
bool isLoading = false;
|
||||||
@@ -39,11 +40,18 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
void Function(bool isOnline)? _presenceOnlineListener;
|
void Function(bool isOnline)? _presenceOnlineListener;
|
||||||
var uuid = const Uuid();
|
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() {
|
void setTimer() {
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
_timer = Timer.periodic(const Duration(seconds: 5), (_) => updateChosenPlanState());
|
_timer = Timer.periodic(
|
||||||
|
const Duration(seconds: 5),
|
||||||
|
(_) => updateChosenPlanState(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void cancelTimer() {
|
void cancelTimer() {
|
||||||
@@ -54,16 +62,22 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
if (currentPage < lastPage && !isLoading) {
|
if (currentPage < lastPage && !isLoading) {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
|
|
||||||
emit((state as ChatRoomStateLoaded).copyWith(isLoading: isLoading));
|
_emitLoaded((current) => current.copyWith(isLoading: isLoading));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final chatMessages = await chatRepository.getChatMessages(uuid, currentPage + 1);
|
final chatMessages = await chatRepository.getChatMessages(
|
||||||
|
uuid,
|
||||||
|
currentPage + 1,
|
||||||
|
);
|
||||||
|
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
emit(
|
_emitLoaded(
|
||||||
(state as ChatRoomStateLoaded).copyWith(
|
(current) => current.copyWith(
|
||||||
isLoading: isLoading,
|
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}) {
|
void _setCard({required List<CardModel> cards}) {
|
||||||
final currentState = state;
|
if (cards.where((card) => card.isDefault).isNotEmpty) {
|
||||||
if (currentState is ChatRoomStateLoaded) {
|
_emitLoaded((current) => current.copyWith(hasCard: true));
|
||||||
if (cards.where((card) => card.isDefault).isNotEmpty) {
|
|
||||||
emit(currentState.copyWith(hasCard: true));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +111,11 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
/// `GET /api/consultation/resolve/order_id_to_conversation/{orderId}`.
|
/// `GET /api/consultation/resolve/order_id_to_conversation/{orderId}`.
|
||||||
///
|
///
|
||||||
/// Возвращает conversation uuid, которым инициализировали чат (для reload без повторного resolve).
|
/// Возвращает 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 {
|
try {
|
||||||
emit(const ChatRoomState.loading());
|
emit(const ChatRoomState.loading());
|
||||||
|
|
||||||
@@ -108,7 +123,9 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
int? resolvedChosenPlanId;
|
int? resolvedChosenPlanId;
|
||||||
|
|
||||||
if (orderId != null && orderId.isNotEmpty) {
|
if (orderId != null && orderId.isNotEmpty) {
|
||||||
final resolved = await chatRepository.resolveOrderIdToConversation(orderId: orderId);
|
final resolved = await chatRepository.resolveOrderIdToConversation(
|
||||||
|
orderId: orderId,
|
||||||
|
);
|
||||||
resolvedUuid = resolved.conversationUuid;
|
resolvedUuid = resolved.conversationUuid;
|
||||||
resolvedChosenPlanId = resolved.chosenPlanId;
|
resolvedChosenPlanId = resolved.chosenPlanId;
|
||||||
}
|
}
|
||||||
@@ -117,15 +134,23 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
throw ArgumentError('initChat requires uuid or orderId');
|
throw ArgumentError('initChat requires uuid or orderId');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
currentPage = 1;
|
||||||
|
isLoading = false;
|
||||||
|
_activeChatUuid = resolvedUuid;
|
||||||
|
|
||||||
final chatRoom = await chatRepository.getChatRoom(resolvedUuid);
|
final chatRoom = await chatRepository.getChatRoom(resolvedUuid);
|
||||||
ChosenPlanModel? chosenPlan;
|
ChosenPlanModel? chosenPlan;
|
||||||
final planId = resolvedChosenPlanId ?? chatRoom.chosenPlanId;
|
final planId = resolvedChosenPlanId ?? chatRoom.chosenPlanId;
|
||||||
if (planId != null) {
|
if (planId != null) {
|
||||||
chosenPlan = await chatRepository.getChosenPlan(id: planId);
|
chosenPlan = await chatRepository.getChosenPlan(id: planId);
|
||||||
}
|
}
|
||||||
final chatMessages = await chatRepository.getChatMessages(resolvedUuid, currentPage);
|
final chatMessages = await chatRepository.getChatMessages(
|
||||||
|
resolvedUuid,
|
||||||
|
currentPage,
|
||||||
|
);
|
||||||
lastPage = chatMessages.meta.lastPage;
|
lastPage = chatMessages.meta.lastPage;
|
||||||
await _subscribeToChatChannel(uuid: resolvedUuid, chatId: chatRoom.id);
|
await _subscribeToChatChannel(uuid: resolvedUuid, chatId: chatRoom.id);
|
||||||
|
_listenToReconnect();
|
||||||
emit(
|
emit(
|
||||||
ChatRoomState.loaded(
|
ChatRoomState.loaded(
|
||||||
chatRoom: chatRoom.copyWith(chosenPlan: chosenPlan),
|
chatRoom: chatRoom.copyWith(chosenPlan: chosenPlan),
|
||||||
@@ -135,6 +160,7 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
hasCard: false,
|
hasCard: false,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
await _subscribeToPresenceChannel(chatId: chatRoom.id);
|
||||||
|
|
||||||
if (chosenPlan?.consultantId == chatRoom.users.sender.id) {
|
if (chosenPlan?.consultantId == chatRoom.users.sender.id) {
|
||||||
await checkCards();
|
await checkCards();
|
||||||
@@ -145,22 +171,106 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
log(e.toString());
|
log(e.toString());
|
||||||
typingSubscription?.cancel();
|
typingSubscription?.cancel();
|
||||||
messagesSubscription?.cancel();
|
messagesSubscription?.cancel();
|
||||||
|
_reconnectSubscription?.cancel();
|
||||||
|
_reconnectSubscription = null;
|
||||||
wsManager.unsubscribeDirectChatChannel();
|
wsManager.unsubscribeDirectChatChannel();
|
||||||
emit(const ChatRoomState.error(errorMessage: 'Ошибка получеия данных'));
|
emit(const ChatRoomState.error(errorMessage: 'Ошибка получеия данных'));
|
||||||
return null;
|
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 {
|
Future<void> updateChosenPlanState() async {
|
||||||
try {
|
try {
|
||||||
final currentState = state;
|
final currentState = state;
|
||||||
if (currentState is ChatRoomStateLoaded) {
|
if (currentState is! ChatRoomStateLoaded) {
|
||||||
final chosenPlanId = currentState.chatRoom.chosenPlanId;
|
return;
|
||||||
if (chosenPlanId != null) {
|
|
||||||
final chosenPlan = await chatRepository.getChosenPlan(id: chosenPlanId);
|
|
||||||
emit(currentState.copyWith.chatRoom(chosenPlan: chosenPlan));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
}
|
}
|
||||||
@@ -172,9 +282,16 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
final planId = (state as ChatRoomStateLoaded).chatRoom.chosenPlanId;
|
final planId = (state as ChatRoomStateLoaded).chatRoom.chosenPlanId;
|
||||||
if (planId == null) return false;
|
if (planId == null) return false;
|
||||||
final stringDate = date.format('yyyy-MM-dd');
|
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);
|
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;
|
return true;
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
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 {
|
try {
|
||||||
await chatRepository.cancelChosenPlan(id: planId);
|
await chatRepository.cancelChosenPlan(id: planId);
|
||||||
emit((state as ChatRoomStateLoaded).copyWith.chatRoom(chosenPlan: null));
|
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;
|
final client = wsManager.client;
|
||||||
if (client == null) return;
|
if (client == null) return;
|
||||||
_activeChatUuid = uuid;
|
await messagesSubscription?.cancel();
|
||||||
|
await typingSubscription?.cancel();
|
||||||
|
messagesSubscription = null;
|
||||||
|
typingSubscription = null;
|
||||||
await wsManager.initPrivateDirectChatChannel(chatId);
|
await wsManager.initPrivateDirectChatChannel(chatId);
|
||||||
wsManager.subscribeDirectChatChannel();
|
wsManager.subscribeDirectChatChannel();
|
||||||
await _subscribeToPresenceChannel(chatId: chatId);
|
messagesSubscription = wsManager.directChatStream?.listen(
|
||||||
messagesSubscription = wsManager.directChatStream?.listen((event) {
|
_onDirectChatEvent,
|
||||||
AudioPlayer().play((AssetSource('audio/recive_message.wav')));
|
onError: (Object e, StackTrace st) {
|
||||||
final message = ChatMessageModel.fromJson(jsonDecode(event.data));
|
unawaited(reportFrom(e, st, this));
|
||||||
final currentState = state as ChatRoomStateLoaded;
|
},
|
||||||
|
);
|
||||||
// Сообщения от текущего пользователя не приходят через WebSocket,
|
|
||||||
// они обрабатываются через ответ API в sendMessage
|
|
||||||
// Здесь обрабатываем только входящие сообщения от других пользователей
|
|
||||||
List<ChatMessageModel> messagesList = [message, ...currentState.chatMessages];
|
|
||||||
|
|
||||||
chatRepository.emitNewMessageToChats(message);
|
|
||||||
emit(currentState.copyWith(chatMessages: messagesList, isLoading: isLoading));
|
|
||||||
EasyDebounce.debounce('searchChatRoom', const Duration(milliseconds: 300), () => sl<ChatsCubit>().getChatRooms());
|
|
||||||
});
|
|
||||||
|
|
||||||
typingSubscription = wsManager.clientTypingStream?.listen((event) {
|
typingSubscription = wsManager.clientTypingStream?.listen((event) {
|
||||||
emit((state as ChatRoomStateLoaded).copyWith(isTyping: true, isLoading: isLoading));
|
_emitLoaded(
|
||||||
|
(current) => current.copyWith(isTyping: true, isLoading: isLoading),
|
||||||
|
);
|
||||||
EasyDebounce.debounce(
|
EasyDebounce.debounce(
|
||||||
'client-typing',
|
'client-typing',
|
||||||
const Duration(seconds: 3),
|
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 {
|
Future<void> _subscribeToPresenceChannel({required String chatId}) async {
|
||||||
final currentState = state;
|
final currentState = state;
|
||||||
if (currentState is! ChatRoomStateLoaded) return;
|
if (currentState is! ChatRoomStateLoaded) return;
|
||||||
@@ -236,12 +447,15 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
|
|
||||||
await wsManager.initPresenceDirectChatChannel(chatId);
|
await wsManager.initPresenceDirectChatChannel(chatId);
|
||||||
_presenceOnlineListener = (isOnline) {
|
_presenceOnlineListener = (isOnline) {
|
||||||
unawaited(_handleCounterpartOnlineChanged(
|
unawaited(
|
||||||
counterpartField: counterpart.id == currentState.chatRoom.users.receiver.id
|
_handleCounterpartOnlineChanged(
|
||||||
? _ChatCounterpartField.receiver
|
counterpartField:
|
||||||
: _ChatCounterpartField.sender,
|
counterpart.id == currentState.chatRoom.users.receiver.id
|
||||||
isOnline: isOnline,
|
? _ChatCounterpartField.receiver
|
||||||
));
|
: _ChatCounterpartField.sender,
|
||||||
|
isOnline: isOnline,
|
||||||
|
),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
wsManager.addPresenceDirectChatListener(
|
wsManager.addPresenceDirectChatListener(
|
||||||
counterpartUserId: counterpart.id,
|
counterpartUserId: counterpart.id,
|
||||||
@@ -260,7 +474,8 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
? currentState.chatRoom.users.receiver
|
? currentState.chatRoom.users.receiver
|
||||||
: currentState.chatRoom.users.sender;
|
: currentState.chatRoom.users.sender;
|
||||||
|
|
||||||
if (counterpart.isOnline == isOnline && (isOnline || counterpart.lastSeenText.isNotEmpty)) {
|
if (counterpart.isOnline == isOnline &&
|
||||||
|
(isOnline || counterpart.lastSeenText.isNotEmpty)) {
|
||||||
return;
|
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 {
|
try {
|
||||||
final messageUuid = uuid.v4();
|
final messageUuid = uuid.v4();
|
||||||
final compressedFiles = files != null ? await ImageCompressUtility.compressFilesList(List<File>.from(files)) : null;
|
final compressedFiles = files != null
|
||||||
AudioPlayer().play((AssetSource('audio/send_message.wav')));
|
? await ImageCompressUtility.compressFilesList(List<File>.from(files))
|
||||||
|
: null;
|
||||||
|
try {
|
||||||
|
unawaited(AudioPlayer().play(AssetSource('audio/send_message.wav')));
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
// Создаем временное сообщение с состоянием loading
|
_emitLoaded(
|
||||||
emit(
|
(current) => current.copyWith(
|
||||||
(state as ChatRoomStateLoaded).copyWith(
|
|
||||||
chatMessages: [
|
chatMessages: [
|
||||||
ChatMessageModel(
|
ChatMessageModel(
|
||||||
id: 1,
|
id: -messageUuid.hashCode.abs(),
|
||||||
data: data ?? '',
|
data: data ?? '',
|
||||||
mediaData: compressedFiles,
|
mediaData: compressedFiles,
|
||||||
authorId: 1,
|
authorId: wsManager.userId ?? 0,
|
||||||
updatedAt: DateTime.now(),
|
updatedAt: DateTime.now(),
|
||||||
isRead: true,
|
isRead: true,
|
||||||
isCurrentUserAuthor: true,
|
isCurrentUserAuthor: true,
|
||||||
roomId: '',
|
roomId: current.chatRoom.id,
|
||||||
messageState: MessageState.loading,
|
messageState: MessageState.loading,
|
||||||
uuid: messageUuid,
|
uuid: messageUuid,
|
||||||
),
|
),
|
||||||
...(state as ChatRoomStateLoaded).chatMessages,
|
...current.chatMessages,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Отправляем сообщение и получаем ответ от сервера с правильными данными
|
final serverMessage = await chatRepository.sendMessage(
|
||||||
final serverMessage = await chatRepository.sendMessage(data: data, files: compressedFiles, slug: userSlug);
|
data: data,
|
||||||
|
files: compressedFiles,
|
||||||
// Заменяем локальное сообщение данными с сервера (с правильными URL файлов)
|
slug: userSlug,
|
||||||
emit(
|
);
|
||||||
(state as ChatRoomStateLoaded).copyWith(
|
_replaceOptimisticMessage(
|
||||||
chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) {
|
messageUuid: messageUuid,
|
||||||
if (elem.uuid == messageUuid) {
|
serverMessage: serverMessage,
|
||||||
// Сохраняем UUID, состояние и обновляем данные из ответа сервера
|
|
||||||
// Используем значения из локального сообщения для полей, которые могут отсутствовать в ответе
|
|
||||||
return serverMessage.copyWith(
|
|
||||||
uuid: messageUuid,
|
|
||||||
messageState: MessageState.sent,
|
|
||||||
isCurrentUserAuthor: true,
|
|
||||||
isRead: elem.isRead,
|
|
||||||
roomId: elem.roomId ?? serverMessage.roomId,
|
|
||||||
authorId: serverMessage.authorId,
|
|
||||||
updatedAt: serverMessage.updatedAt,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return elem;
|
|
||||||
}).toList(),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
emit(
|
_emitLoaded(
|
||||||
(state as ChatRoomStateLoaded).copyWith(
|
(current) => current.copyWith(
|
||||||
chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) {
|
chatMessages: current.chatMessages.map((elem) {
|
||||||
if (elem.uuid == messageUuid) {
|
if (elem.uuid == messageUuid) {
|
||||||
return elem.copyWith(messageState: MessageState.error);
|
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 {
|
try {
|
||||||
if (state is ChatRoomStateLoaded) {
|
if (state is! ChatRoomStateLoaded) {
|
||||||
final ids = messagesIds.map((e) => e.id).toList();
|
return false;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
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) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
log(e.toString(), stackTrace: st);
|
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 {
|
Future<String?> getPaymentLink() async {
|
||||||
try {
|
try {
|
||||||
final planId = (state as ChatRoomStateLoaded).chatRoom.chosenPlan?.id;
|
final planId = (state as ChatRoomStateLoaded).chatRoom.chosenPlan?.id;
|
||||||
if (planId == null) return null;
|
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;
|
return paymentModel.paymentUrl;
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
@@ -477,91 +729,93 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> close() async {
|
Future<void> close() async {
|
||||||
|
EasyDebounce.cancel('client-typing');
|
||||||
|
EasyDebounce.cancel('searchChatRoom');
|
||||||
if (_presenceOnlineListener != null) {
|
if (_presenceOnlineListener != null) {
|
||||||
wsManager.removePresenceDirectChatListener(_presenceOnlineListener!);
|
wsManager.removePresenceDirectChatListener(_presenceOnlineListener!);
|
||||||
_presenceOnlineListener = null;
|
_presenceOnlineListener = null;
|
||||||
}
|
}
|
||||||
wsManager.unsubscribeDirectChatChannel();
|
wsManager.unsubscribeDirectChatChannel();
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
messagesSubscription?.cancel();
|
await messagesSubscription?.cancel();
|
||||||
typingSubscription?.cancel();
|
await typingSubscription?.cancel();
|
||||||
|
await _reconnectSubscription?.cancel();
|
||||||
super.close();
|
super.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
void deleteMessage(String uuid) {
|
void deleteMessage(String uuid) {
|
||||||
final currentState = state;
|
final currentState = state;
|
||||||
if (currentState is ChatRoomStateLoaded) {
|
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;
|
final currentState = state;
|
||||||
if (currentState is ChatRoomStateLoaded) {
|
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) {
|
if (message != null) {
|
||||||
try {
|
try {
|
||||||
AudioPlayer().play((AssetSource('audio/send_message.wav')));
|
try {
|
||||||
|
unawaited(
|
||||||
|
AudioPlayer().play(AssetSource('audio/send_message.wav')),
|
||||||
|
);
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
// Определяем, есть ли файлы для повторной отправки
|
|
||||||
List<File>? filesToResend;
|
List<File>? filesToResend;
|
||||||
if (message.mediaData != null && message.mediaData!.isNotEmpty) {
|
if (message.mediaData != null && message.mediaData!.isNotEmpty) {
|
||||||
// Если mediaData содержит File объекты, используем их
|
|
||||||
// Если это строки (URL), то файлы уже на сервере и повторная отправка не нужна
|
|
||||||
final firstItem = message.mediaData!.first;
|
final firstItem = message.mediaData!.first;
|
||||||
if (firstItem is File) {
|
if (firstItem is File) {
|
||||||
filesToResend = message.mediaData!.cast<File>();
|
filesToResend = message.mediaData!.cast<File>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
emit(
|
_emitLoaded(
|
||||||
(state as ChatRoomStateLoaded).copyWith(
|
(current) => current.copyWith(
|
||||||
chatMessages: [
|
chatMessages: [
|
||||||
ChatMessageModel(
|
ChatMessageModel(
|
||||||
id: 1,
|
id: -uuid.hashCode.abs(),
|
||||||
data: message.data,
|
data: message.data,
|
||||||
mediaData: message.mediaData,
|
mediaData: message.mediaData,
|
||||||
authorId: 1,
|
authorId: wsManager.userId ?? 0,
|
||||||
updatedAt: DateTime.now(),
|
updatedAt: DateTime.now(),
|
||||||
isRead: true,
|
isRead: true,
|
||||||
isCurrentUserAuthor: true,
|
isCurrentUserAuthor: true,
|
||||||
roomId: '',
|
roomId: current.chatRoom.id,
|
||||||
messageState: MessageState.loading,
|
messageState: MessageState.loading,
|
||||||
uuid: uuid,
|
uuid: uuid,
|
||||||
),
|
),
|
||||||
...(state as ChatRoomStateLoaded).chatMessages.where((message) => message.uuid != uuid),
|
...current.chatMessages.where((item) => item.uuid != uuid),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Отправляем сообщение и получаем ответ от сервера
|
final serverMessage = await chatRepository.sendMessage(
|
||||||
final serverMessage = await chatRepository.sendMessage(data: message.data, files: filesToResend, slug: userSlug);
|
data: message.data,
|
||||||
|
files: filesToResend,
|
||||||
// Заменяем локальное сообщение данными с сервера
|
slug: userSlug,
|
||||||
emit(
|
);
|
||||||
(state as ChatRoomStateLoaded).copyWith(
|
_replaceOptimisticMessage(
|
||||||
chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) {
|
messageUuid: uuid,
|
||||||
if (elem.uuid == uuid) {
|
serverMessage: serverMessage,
|
||||||
return serverMessage.copyWith(
|
|
||||||
uuid: uuid,
|
|
||||||
messageState: MessageState.sent,
|
|
||||||
isCurrentUserAuthor: true,
|
|
||||||
isRead: elem.isRead,
|
|
||||||
roomId: elem.roomId ?? serverMessage.roomId,
|
|
||||||
authorId: serverMessage.authorId,
|
|
||||||
updatedAt: serverMessage.updatedAt,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return elem;
|
|
||||||
}).toList(),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
emit(
|
_emitLoaded(
|
||||||
(state as ChatRoomStateLoaded).copyWith(
|
(current) => current.copyWith(
|
||||||
chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) {
|
chatMessages: current.chatMessages.map((elem) {
|
||||||
if (elem.uuid == uuid) {
|
if (elem.uuid == uuid) {
|
||||||
return elem.copyWith(messageState: MessageState.error);
|
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/enums/base_state.dart';
|
||||||
import 'package:eatvio_app/core/websockets/ws_manager.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_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/presentation/bloc/chats_cubit/chats_state.dart';
|
||||||
import 'package:eatvio_app/features/chat/repositories/chat_repository.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/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:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||||
|
|
||||||
@@ -17,13 +22,23 @@ class ChatsCubit extends Cubit<ChatsState> {
|
|||||||
final WsManager wsManager;
|
final WsManager wsManager;
|
||||||
StreamSubscription? newIncomingMessagesSubscription;
|
StreamSubscription? newIncomingMessagesSubscription;
|
||||||
StreamSubscription? newLastMessageSubscription;
|
StreamSubscription? newLastMessageSubscription;
|
||||||
|
StreamSubscription<void>? reconnectedSubscription;
|
||||||
|
final Set<int> _presentedVideoCallMessageIds = {};
|
||||||
|
|
||||||
|
/// Test seam. Production uses CallKit.
|
||||||
|
Future<void> Function(VideoCallPushPayload payload)? presentIncomingVideoCall;
|
||||||
|
Future<void> Function()? dismissIncomingVideoCall;
|
||||||
|
|
||||||
ChatsCubit({
|
ChatsCubit({
|
||||||
required this.chatRepository,
|
required this.chatRepository,
|
||||||
required this.wsManager,
|
required this.wsManager,
|
||||||
required this.profileRepository,
|
required this.profileRepository,
|
||||||
|
this.presentIncomingVideoCall,
|
||||||
|
this.dismissIncomingVideoCall,
|
||||||
}) : super(ChatsState()) {
|
}) : super(ChatsState()) {
|
||||||
newLastMessageSubscription = chatRepository.newUserMessagesStream.listen((message) {
|
newLastMessageSubscription = chatRepository.newUserMessagesStream.listen((
|
||||||
|
message,
|
||||||
|
) {
|
||||||
_setNewLastMessage(message);
|
_setNewLastMessage(message);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -34,9 +49,16 @@ class ChatsCubit extends Cubit<ChatsState> {
|
|||||||
Future<void> connectToWebSocket() async {
|
Future<void> connectToWebSocket() async {
|
||||||
try {
|
try {
|
||||||
await newIncomingMessagesSubscription?.cancel();
|
await newIncomingMessagesSubscription?.cancel();
|
||||||
newIncomingMessagesSubscription = wsManager.newMessagesStream?.listen((event) {
|
newIncomingMessagesSubscription = wsManager.newMessagesStream?.listen((
|
||||||
|
event,
|
||||||
|
) {
|
||||||
_handleNewMessageViaSocket(event.data);
|
_handleNewMessageViaSocket(event.data);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await reconnectedSubscription?.cancel();
|
||||||
|
reconnectedSubscription = wsManager.reconnected.listen((_) {
|
||||||
|
unawaited(getChatRooms());
|
||||||
|
});
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
}
|
}
|
||||||
@@ -47,7 +69,9 @@ class ChatsCubit extends Cubit<ChatsState> {
|
|||||||
_query = query;
|
_query = query;
|
||||||
emit(state.copyWith(state: BaseState.loading));
|
emit(state.copyWith(state: BaseState.loading));
|
||||||
try {
|
try {
|
||||||
final chatRoomsPaginationModel = await chatRepository.getChatRooms(query: query);
|
final chatRoomsPaginationModel = await chatRepository.getChatRooms(
|
||||||
|
query: query,
|
||||||
|
);
|
||||||
final unreadChats = chatRoomsPaginationModel.unreadChatsCount;
|
final unreadChats = chatRoomsPaginationModel.unreadChatsCount;
|
||||||
lastPage = chatRoomsPaginationModel.meta.lastPage + 1;
|
lastPage = chatRoomsPaginationModel.meta.lastPage + 1;
|
||||||
emit(
|
emit(
|
||||||
@@ -60,9 +84,7 @@ class ChatsCubit extends Cubit<ChatsState> {
|
|||||||
page++;
|
page++;
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
emit(
|
emit(state.copyWith(errorMessage: e.toString(), state: BaseState.error));
|
||||||
state.copyWith(errorMessage: e.toString(), state: BaseState.error),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +92,9 @@ class ChatsCubit extends Cubit<ChatsState> {
|
|||||||
page = 1;
|
page = 1;
|
||||||
_query = query;
|
_query = query;
|
||||||
try {
|
try {
|
||||||
final chatRoomsPaginationModel = await chatRepository.getChatRooms(query: query);
|
final chatRoomsPaginationModel = await chatRepository.getChatRooms(
|
||||||
|
query: query,
|
||||||
|
);
|
||||||
final unreadChats = chatRoomsPaginationModel.unreadChatsCount;
|
final unreadChats = chatRoomsPaginationModel.unreadChatsCount;
|
||||||
lastPage = chatRoomsPaginationModel.meta.lastPage + 1;
|
lastPage = chatRoomsPaginationModel.meta.lastPage + 1;
|
||||||
emit(
|
emit(
|
||||||
@@ -84,9 +108,7 @@ class ChatsCubit extends Cubit<ChatsState> {
|
|||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
log(e.toString(), stackTrace: st);
|
log(e.toString(), stackTrace: st);
|
||||||
emit(
|
emit(state.copyWith(errorMessage: e.toString(), state: BaseState.error));
|
||||||
state.copyWith(errorMessage: e.toString(), state: BaseState.error),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +116,10 @@ class ChatsCubit extends Cubit<ChatsState> {
|
|||||||
try {
|
try {
|
||||||
if (state.isLoadingMoreChats || lastPage == page) return;
|
if (state.isLoadingMoreChats || lastPage == page) return;
|
||||||
emit(state.copyWith(isLoadingMoreChats: true));
|
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;
|
final unreadChats = chatRoomsPaginationModel.unreadChatsCount;
|
||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
@@ -134,15 +159,20 @@ class ChatsCubit extends Cubit<ChatsState> {
|
|||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
chatRooms: updatedChatRooms,
|
chatRooms: updatedChatRooms,
|
||||||
chatsWithUnreadMessagesCount: isChatRead ? state.chatsWithUnreadMessagesCount - 1 : state.chatsWithUnreadMessagesCount,
|
chatsWithUnreadMessagesCount: isChatRead
|
||||||
|
? state.chatsWithUnreadMessagesCount - 1
|
||||||
|
: state.chatsWithUnreadMessagesCount,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleNewMessageViaSocket(dynamic data) async {
|
Future<void> _handleNewMessageViaSocket(dynamic data) async {
|
||||||
try {
|
try {
|
||||||
final json = jsonDecode(data);
|
final json = jsonDecode(data) as Map<String, dynamic>;
|
||||||
final message = ChatMessageModel.fromJson(json['message']);
|
final message = ChatMessageModel.fromJson(json);
|
||||||
|
|
||||||
|
final currentUserId = wsManager.userId;
|
||||||
|
final isOwn = currentUserId != null && message.authorId == currentUserId;
|
||||||
|
|
||||||
final currentChatRooms = [...state.chatRooms];
|
final currentChatRooms = [...state.chatRooms];
|
||||||
|
|
||||||
@@ -151,10 +181,21 @@ class ChatsCubit extends Cubit<ChatsState> {
|
|||||||
final updatedChatRooms = currentChatRooms.map((chatRoom) {
|
final updatedChatRooms = currentChatRooms.map((chatRoom) {
|
||||||
if (chatRoom.id == message.roomId) {
|
if (chatRoom.id == message.roomId) {
|
||||||
wasChanged = true;
|
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;
|
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;
|
return chatRoom;
|
||||||
}).toList();
|
}).toList();
|
||||||
@@ -165,15 +206,83 @@ class ChatsCubit extends Cubit<ChatsState> {
|
|||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
chatRooms: updatedChatRooms,
|
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) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
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());
|
||||||
|
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() async {
|
||||||
|
try {
|
||||||
|
await (dismissIncomingVideoCall ??
|
||||||
|
VideoCallEndedPushHandler.dismissIncomingCallUi)();
|
||||||
|
} 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) {
|
void _setNewLastMessage(ChatMessageModel message) {
|
||||||
final chats = state.chatRooms.map((chat) {
|
final chats = state.chatRooms.map((chat) {
|
||||||
if (chat.id == message.roomId) {
|
if (chat.id == message.roomId) {
|
||||||
@@ -188,6 +297,7 @@ class ChatsCubit extends Cubit<ChatsState> {
|
|||||||
@override
|
@override
|
||||||
Future<void> close() {
|
Future<void> close() {
|
||||||
newIncomingMessagesSubscription?.cancel();
|
newIncomingMessagesSubscription?.cancel();
|
||||||
|
reconnectedSubscription?.cancel();
|
||||||
return super.close();
|
return super.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,12 @@ class ChatScreen extends StatefulWidget {
|
|||||||
final String? orderId;
|
final String? orderId;
|
||||||
final bool? sendInitialMessage;
|
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
|
@override
|
||||||
State<ChatScreen> createState() => _ChatScreenState();
|
State<ChatScreen> createState() => _ChatScreenState();
|
||||||
@@ -56,9 +61,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
final scrollController = ScrollController();
|
final scrollController = ScrollController();
|
||||||
final TextEditingController textEditingController = TextEditingController();
|
final TextEditingController textEditingController = TextEditingController();
|
||||||
final ChatRoomCubit chatRoomCubit = sl<ChatRoomCubit>();
|
final ChatRoomCubit chatRoomCubit = sl<ChatRoomCubit>();
|
||||||
final List<({int id, int index})> unreadMessagesIds = [];
|
final List<int> unreadMessageIds = [];
|
||||||
final List<({int id, int index})> unreadMessagesIdsCopy = [];
|
final showableChatBadgeStates =
|
||||||
final showableChatBadgeStates = ChosenPlanStateEnum.getShowableChatBadgeStates();
|
ChosenPlanStateEnum.getShowableChatBadgeStates();
|
||||||
late AppLifecycleListener _listener;
|
late AppLifecycleListener _listener;
|
||||||
List<File> pickedFiles = [];
|
List<File> pickedFiles = [];
|
||||||
|
|
||||||
@@ -109,13 +114,15 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
void _onScroll() {
|
void _onScroll() {
|
||||||
final uuid = conversationUuid;
|
final uuid = conversationUuid;
|
||||||
if (uuid == null) return;
|
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);
|
chatRoomCubit.loadNextPage(uuid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
EasyDebounce.cancel('mark_as_read');
|
||||||
scrollController.dispose();
|
scrollController.dispose();
|
||||||
_listener.dispose();
|
_listener.dispose();
|
||||||
chatRoomCubit.close();
|
chatRoomCubit.close();
|
||||||
@@ -168,9 +175,13 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Scaffold _buildChat(BuildContext context, ChatRoomStateLoaded state) {
|
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 chosenPlan = state.chatRoom.chosenPlan;
|
||||||
final amIConsultant = chosenPlan?.consultantId == state.chatRoom.users.sender.id;
|
final amIConsultant =
|
||||||
|
chosenPlan?.consultantId == state.chatRoom.users.sender.id;
|
||||||
final showUserDetailsButton = [
|
final showUserDetailsButton = [
|
||||||
ChosenPlanStateEnum.stateStartDateHasBeenSet,
|
ChosenPlanStateEnum.stateStartDateHasBeenSet,
|
||||||
ChosenPlanStateEnum.statePendingPayment,
|
ChosenPlanStateEnum.statePendingPayment,
|
||||||
@@ -186,22 +197,37 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final showAboutOrderButton =
|
final showAboutOrderButton =
|
||||||
state.chatRoom.chosenPlan != null && state.chatRoom.chosenPlan!.chosenPlanState != ChosenPlanStateEnum.stateCanceledByTheInitiator;
|
state.chatRoom.chosenPlan != null &&
|
||||||
|
state.chatRoom.chosenPlan!.chosenPlanState !=
|
||||||
|
ChosenPlanStateEnum.stateCanceledByTheInitiator;
|
||||||
BannerType? bannerType;
|
BannerType? bannerType;
|
||||||
if (chosenPlan == null || chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateCanceledByTheInitiator) {
|
if (chosenPlan == null ||
|
||||||
|
chosenPlan.chosenPlanState ==
|
||||||
|
ChosenPlanStateEnum.stateCanceledByTheInitiator) {
|
||||||
bannerType = null;
|
bannerType = null;
|
||||||
} else if (amIConsultant && !state.hasCard && chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateInCoordination) {
|
} else if (amIConsultant &&
|
||||||
|
!state.hasCard &&
|
||||||
|
chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateInCoordination) {
|
||||||
bannerType = BannerType.card;
|
bannerType = BannerType.card;
|
||||||
} else if (amIConsultant && chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateInCoordination) {
|
} else if (amIConsultant &&
|
||||||
|
chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateInCoordination) {
|
||||||
bannerType = BannerType.date;
|
bannerType = BannerType.date;
|
||||||
} else if (!amIConsultant &&
|
} else if (!amIConsultant &&
|
||||||
(chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateStartDateHasBeenSet || chosenPlan.chosenPlanState == ChosenPlanStateEnum.statePendingPayment)) {
|
(chosenPlan.chosenPlanState ==
|
||||||
|
ChosenPlanStateEnum.stateStartDateHasBeenSet ||
|
||||||
|
chosenPlan.chosenPlanState ==
|
||||||
|
ChosenPlanStateEnum.statePendingPayment)) {
|
||||||
bannerType = BannerType.payment;
|
bannerType = BannerType.payment;
|
||||||
} else if (chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateDone && amIConsultant) {
|
} else if (chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateDone &&
|
||||||
|
amIConsultant) {
|
||||||
bannerType = BannerType.collectCharge;
|
bannerType = BannerType.collectCharge;
|
||||||
} else if (chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateLastDayFinished && !amIConsultant) {
|
} else if (chosenPlan.chosenPlanState ==
|
||||||
|
ChosenPlanStateEnum.stateLastDayFinished &&
|
||||||
|
!amIConsultant) {
|
||||||
bannerType = BannerType.finishPlan;
|
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;
|
bannerType = BannerType.setPersonalDiet;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,25 +249,40 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
|
|
||||||
title: GestureDetector(
|
title: GestureDetector(
|
||||||
onTap: () {
|
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(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
if (state.chatRoom.users.receiver.profilePhotoUrl != null)
|
if (state.chatRoom.users.receiver.profilePhotoUrl != null)
|
||||||
CircleAvatar(
|
CircleAvatar(
|
||||||
radius: 20,
|
radius: 20,
|
||||||
foregroundImage: NetworkImage(state.chatRoom.users.receiver.profilePhotoUrl!),
|
foregroundImage: NetworkImage(
|
||||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
state.chatRoom.users.receiver.profilePhotoUrl!,
|
||||||
|
),
|
||||||
|
backgroundImage: const AssetImage(
|
||||||
|
'assets/images/no_avatar.png',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (state.chatRoom.users.receiver.profilePhotoUrl == null)
|
if (state.chatRoom.users.receiver.profilePhotoUrl == null)
|
||||||
Container(
|
Container(
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
decoration: const BoxDecoration(color: Color(0xFFEBF4FF), shape: BoxShape.circle),
|
decoration: const BoxDecoration(
|
||||||
|
color: Color(0xFFEBF4FF),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
state.chatRoom.users.receiver.name.split(' ').first[0] + state.chatRoom.users.receiver.name.split(' ')[1][0],
|
state.chatRoom.users.receiver.name.split(' ').first[0] +
|
||||||
style: const TextStyle(color: Color(0xFF7F9CF5), fontWeight: FontWeight.w400, fontSize: 20),
|
state.chatRoom.users.receiver.name.split(' ')[1][0],
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Color(0xFF7F9CF5),
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
fontSize: 20,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
@@ -253,10 +294,17 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
state.chatRoom.users.receiver.name,
|
state.chatRoom.users.receiver.name,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: CustomTextStyles.titleSmallBold.copyWith(color: PrimaryColors().black),
|
style: CustomTextStyles.titleSmallBold.copyWith(
|
||||||
|
color: PrimaryColors().black,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
AnimatedCrossFade(
|
AnimatedCrossFade(
|
||||||
firstChild: Text('Печатает...', maxLines: 1, overflow: TextOverflow.ellipsis, style: CustomTextStyles.bodySmallMulishPrimary),
|
firstChild: Text(
|
||||||
|
'Печатает...',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: CustomTextStyles.bodySmallMulishPrimary,
|
||||||
|
),
|
||||||
firstCurve: Curves.easeIn,
|
firstCurve: Curves.easeIn,
|
||||||
secondCurve: Curves.easeOut,
|
secondCurve: Curves.easeOut,
|
||||||
secondChild: ChatUserOnlineStatusLine(
|
secondChild: ChatUserOnlineStatusLine(
|
||||||
@@ -265,7 +313,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
style: CustomTextStyles.bodySmallMulishPrimary,
|
style: CustomTextStyles.bodySmallMulishPrimary,
|
||||||
showOnlineDot: false,
|
showOnlineDot: false,
|
||||||
),
|
),
|
||||||
crossFadeState: state.isTyping ? CrossFadeState.showFirst : CrossFadeState.showSecond,
|
crossFadeState: state.isTyping
|
||||||
|
? CrossFadeState.showFirst
|
||||||
|
: CrossFadeState.showSecond,
|
||||||
duration: const Duration(milliseconds: 150),
|
duration: const Duration(milliseconds: 150),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -277,7 +327,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
actions: [
|
actions: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsetsDirectional.only(end: 8),
|
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(
|
child: Container(
|
||||||
color: appTheme.whiteA700,
|
color: appTheme.whiteA700,
|
||||||
width: double.maxFinite,
|
width: double.maxFinite,
|
||||||
child: SingleChildScrollView(
|
child: RefreshIndicator(
|
||||||
physics: const BouncingScrollPhysics(),
|
onRefresh: chatRoomCubit.refreshChat,
|
||||||
controller: scrollController,
|
child: SingleChildScrollView(
|
||||||
reverse: true,
|
physics: const AlwaysScrollableScrollPhysics(
|
||||||
child: Column(
|
parent: BouncingScrollPhysics(),
|
||||||
children: [
|
),
|
||||||
const SizedBox(height: 16),
|
controller: scrollController,
|
||||||
ListView.builder(
|
reverse: true,
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
child: Column(
|
||||||
reverse: true,
|
children: [
|
||||||
shrinkWrap: true,
|
const SizedBox(height: 16),
|
||||||
itemBuilder: (context, index) {
|
ListView.builder(
|
||||||
if (index == state.chatMessages.length) {
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
return const Padding(padding: EdgeInsets.all(8), child: Loader());
|
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 &&
|
if ((index + 1 != state.chatMessages.length &&
|
||||||
state.chatMessages[index].updatedAt.day != state.chatMessages[index + 1].updatedAt.day) ||
|
state
|
||||||
index + 1 == state.chatMessages.length) {
|
.chatMessages[index]
|
||||||
final isCurrentUserAuthor = state.chatRoom.users.receiver.id != state.chatMessages[index].authorId;
|
.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 message = state.chatMessages[index];
|
||||||
final isPlanOrderMessage = message.type == ChatMessageType.planOrder;
|
final isPlanOrderMessage =
|
||||||
|
message.type == ChatMessageType.planOrder;
|
||||||
|
|
||||||
PacketOptions? messageOption;
|
PacketOptions? messageOption;
|
||||||
if (isPlanOrderMessage && chosenPlan != null) {
|
if (isPlanOrderMessage && chosenPlan != null) {
|
||||||
if (chosenPlan.optionType == 'options1') {
|
if (chosenPlan.optionType == 'options1') {
|
||||||
messageOption = chosenPlan.plan.packet1Options;
|
messageOption =
|
||||||
} else if (chosenPlan.optionType == 'options2') {
|
chosenPlan.plan.packet1Options;
|
||||||
messageOption = chosenPlan.plan.packet2Options;
|
} else if (chosenPlan.optionType ==
|
||||||
} else if (chosenPlan.optionType == 'options3') {
|
'options2') {
|
||||||
messageOption = chosenPlan.plan.packet3Options;
|
messageOption =
|
||||||
|
chosenPlan.plan.packet2Options;
|
||||||
|
} else if (chosenPlan.optionType ==
|
||||||
|
'options3') {
|
||||||
|
messageOption =
|
||||||
|
chosenPlan.plan.packet3Options;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
_messageDate(message.updatedAt),
|
if (isPlanOrderMessage &&
|
||||||
if (isPlanOrderMessage && chosenPlan != null && messageOption != null)
|
chosenPlan != null &&
|
||||||
|
messageOption != null)
|
||||||
PlanOrderMessageWidget(
|
PlanOrderMessageWidget(
|
||||||
planName: chosenPlan.plan.name,
|
planName: chosenPlan.plan.name,
|
||||||
planSlug: chosenPlan.plan.slug,
|
planSlug: chosenPlan.plan.slug,
|
||||||
@@ -337,126 +478,94 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
isCurrentUser: isCurrentUserAuthor,
|
isCurrentUser: isCurrentUserAuthor,
|
||||||
),
|
),
|
||||||
VisibilityDetector(
|
VisibilityDetector(
|
||||||
key: Key(message.id.toString()),
|
key: ValueKey(
|
||||||
onVisibilityChanged: (VisibilityInfo info) {
|
'msg-${message.uuid ?? message.id}',
|
||||||
if (!message.isRead && !isCurrentUserAuthor && info.visibleFraction > 0) {
|
),
|
||||||
unreadMessagesIdsCopy.add((id: message.id, index: index));
|
onVisibilityChanged:
|
||||||
EasyDebounce.debounce('mark_as_read', const Duration(milliseconds: 500), () async {
|
(VisibilityInfo info) {
|
||||||
final result = await context.read<ChatRoomCubit>().markMessagesAsRead(state.chatRoom.id, unreadMessagesIdsCopy);
|
_queueMarkAsRead(
|
||||||
if (!result) return;
|
context: context,
|
||||||
if (!context.mounted) return;
|
|
||||||
context.read<ChatsCubit>().reduceUnreadMessagesCount(
|
|
||||||
chatId: state.chatRoom.id,
|
chatId: state.chatRoom.id,
|
||||||
count: unreadMessagesIdsCopy.length,
|
message: message,
|
||||||
|
isCurrentUserAuthor:
|
||||||
|
isCurrentUserAuthor,
|
||||||
|
visibleFraction:
|
||||||
|
info.visibleFraction,
|
||||||
);
|
);
|
||||||
unreadMessagesIdsCopy.clear();
|
},
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: _buildMessageBubble(
|
child: _buildMessageBubble(
|
||||||
context: context,
|
context: context,
|
||||||
state: state,
|
state: state,
|
||||||
chatCounterpart: chatCounterpart,
|
chatCounterpart: chatCounterpart,
|
||||||
isCurrentUserAuthor: isCurrentUserAuthor,
|
isCurrentUserAuthor:
|
||||||
|
isCurrentUserAuthor,
|
||||||
message: message,
|
message: message,
|
||||||
amIConsultant: amIConsultant,
|
amIConsultant: amIConsultant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
final isCurrentUserAuthor = state.chatRoom.users.receiver.id != state.chatMessages[index].authorId;
|
itemCount:
|
||||||
final message = state.chatMessages[index];
|
state.chatMessages.length +
|
||||||
final isPlanOrderMessage = message.type == ChatMessageType.planOrder;
|
(state.isLoading ? 1 : 0),
|
||||||
|
),
|
||||||
PacketOptions? messageOption;
|
const SizedBox(height: 16),
|
||||||
if (isPlanOrderMessage && chosenPlan != null) {
|
if (showUserDetailsButton) ...[
|
||||||
if (chosenPlan.optionType == 'options1') {
|
// OrderCard(
|
||||||
messageOption = chosenPlan.plan.packet1Options;
|
// plan: chosenPlan.plan,
|
||||||
} else if (chosenPlan.optionType == 'options2') {
|
// isCustomerView: !amIConsultant,
|
||||||
messageOption = chosenPlan.plan.packet2Options;
|
// optionType: chosenPlan.optionType,
|
||||||
} else if (chosenPlan.optionType == 'options3') {
|
// onCancel: () async {
|
||||||
messageOption = chosenPlan.plan.packet3Options;
|
// await context.read<ChatRoomCubit>().cancelChosenPlan(chatId: state.chatRoom.id, planId: chosenPlan.id);
|
||||||
}
|
// },
|
||||||
}
|
// ),
|
||||||
|
if (amIConsultant)
|
||||||
return Column(
|
Padding(
|
||||||
children: [
|
padding: const EdgeInsets.symmetric(
|
||||||
if (isPlanOrderMessage && chosenPlan != null && messageOption != null)
|
vertical: 12,
|
||||||
PlanOrderMessageWidget(
|
horizontal: 16,
|
||||||
planName: chosenPlan.plan.name,
|
),
|
||||||
planSlug: chosenPlan.plan.slug,
|
child: CustomOutlinedButton(
|
||||||
price: messageOption.price,
|
text: S.current.user_info,
|
||||||
sendTime: message.updatedAt,
|
height: 52,
|
||||||
isCurrentUser: isCurrentUserAuthor,
|
buttonTextStyle: TextStyles.textMSemibold
|
||||||
|
.copyWith(color: appTheme.whiteA700),
|
||||||
|
buttonStyle: ButtonStyle(
|
||||||
|
side: const WidgetStatePropertyAll(
|
||||||
|
BorderSide.none,
|
||||||
|
),
|
||||||
|
backgroundColor: WidgetStatePropertyAll(
|
||||||
|
appTheme.blueA400,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
VisibilityDetector(
|
onPressed: () async {
|
||||||
key: Key(message.id.toString()),
|
GoRouter.of(context).push(
|
||||||
onVisibilityChanged: (VisibilityInfo info) {
|
UserDetailsScreen.path,
|
||||||
if (!message.isRead && !isCurrentUserAuthor && info.visibleFraction > 0) {
|
extra:
|
||||||
unreadMessagesIds.add((id: message.id, index: index));
|
state.chatRoom.users.receiver.slug,
|
||||||
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();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
child: _buildMessageBubble(
|
|
||||||
context: context,
|
|
||||||
state: state,
|
|
||||||
chatCounterpart: chatCounterpart,
|
|
||||||
isCurrentUserAuthor: isCurrentUserAuthor,
|
|
||||||
message: message,
|
|
||||||
amIConsultant: amIConsultant,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
);
|
],
|
||||||
},
|
if (bannerType != null)
|
||||||
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(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
padding: const EdgeInsets.symmetric(
|
||||||
child: CustomOutlinedButton(
|
vertical: 12,
|
||||||
text: S.current.user_info,
|
horizontal: 16,
|
||||||
height: 52,
|
),
|
||||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
child: NotificationBanner(
|
||||||
buttonStyle: ButtonStyle(
|
bannerType: bannerType,
|
||||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
chosenPlanId: chosenPlan?.id,
|
||||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
planName: chosenPlan?.plan.name,
|
||||||
),
|
planPrice: chosenOption?.price.toDouble(),
|
||||||
onPressed: () async {
|
hasPersonalDiet:
|
||||||
GoRouter.of(context).push(UserDetailsScreen.path, extra: state.chatRoom.users.receiver.slug);
|
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)
|
_isImageFile(pickedFiles[i].path)
|
||||||
? GestureDetector(
|
? GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
final imageFiles = pickedFiles.where((f) => _isImageFile(f.path)).toList();
|
final imageFiles = pickedFiles
|
||||||
final imageIndex = _imageIndexAt(pickedFiles, i);
|
.where(
|
||||||
|
(f) => _isImageFile(f.path),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
final imageIndex = _imageIndexAt(
|
||||||
|
pickedFiles,
|
||||||
|
i,
|
||||||
|
);
|
||||||
|
|
||||||
context.pushTransparentRoute(
|
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(
|
child: Hero(
|
||||||
tag: pickedFiles[i].path,
|
tag: pickedFiles[i].path,
|
||||||
transitionOnUserGestures: true,
|
transitionOnUserGestures: true,
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius:
|
||||||
child: SizedBox(height: 104, width: 104, child: Image.file(pickedFiles[i], fit: BoxFit.cover)),
|
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(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(4),
|
padding: const EdgeInsets.all(4),
|
||||||
decoration: BoxDecoration(color: appTheme.gray50, shape: BoxShape.circle),
|
decoration: BoxDecoration(
|
||||||
child: const Icon(size: 20, Icons.close, color: Colors.black),
|
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: [
|
children: [
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
InkWell(
|
InkWell(
|
||||||
onTap: () => showImagePicker(state.chatRoom.users.receiver.slug),
|
onTap: () => showImagePicker(
|
||||||
|
state.chatRoom.users.receiver.slug,
|
||||||
|
),
|
||||||
customBorder: const CircleBorder(),
|
customBorder: const CircleBorder(),
|
||||||
child: Ink(
|
child: Ink(
|
||||||
height: 40,
|
height: 40,
|
||||||
width: 40,
|
width: 40,
|
||||||
child: Transform.rotate(
|
child: Transform.rotate(
|
||||||
angle: pi / 4,
|
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,
|
maxLines: 3,
|
||||||
minLines: 1,
|
minLines: 1,
|
||||||
keyboardType: TextInputType.multiline,
|
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(
|
decoration: InputDecoration(
|
||||||
isDense: true,
|
isDense: true,
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 10,
|
||||||
|
),
|
||||||
hintText: 'Сообщение',
|
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,
|
filled: true,
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(100),
|
borderRadius: BorderRadius.circular(100),
|
||||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
borderSide: const BorderSide(
|
||||||
|
color: Color(0xFFD1D1D6),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(100),
|
borderRadius: BorderRadius.circular(100),
|
||||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
borderSide: const BorderSide(
|
||||||
|
color: Color(0xFFD1D1D6),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(100),
|
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;
|
final blockedState = blockedUsersCubit.state;
|
||||||
|
|
||||||
if (blockedState is BlockedUsersStateLoaded) {
|
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 (isBlocked) {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
content: Text('Вы не можете отправить сообщение заблокированному пользователю'),
|
content: Text(
|
||||||
|
'Вы не можете отправить сообщение заблокированному пользователю',
|
||||||
|
),
|
||||||
backgroundColor: Colors.red,
|
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(
|
chatRoomCubit.sendMessage(
|
||||||
data: textEditingController.text.isEmpty ? null : textEditingController.text.trim(),
|
data: textEditingController.text.isEmpty
|
||||||
files: pickedFiles.isEmpty ? null : pickedFiles,
|
? null
|
||||||
|
: textEditingController.text.trim(),
|
||||||
|
files: pickedFiles.isEmpty
|
||||||
|
? null
|
||||||
|
: pickedFiles,
|
||||||
userSlug: state.chatRoom.users.receiver.slug,
|
userSlug: state.chatRoom.users.receiver.slug,
|
||||||
);
|
);
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -606,10 +782,17 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Ink(
|
child: Ink(
|
||||||
decoration: BoxDecoration(color: appTheme.blueA400, shape: BoxShape.circle),
|
decoration: BoxDecoration(
|
||||||
|
color: appTheme.blueA400,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
height: 40,
|
height: 40,
|
||||||
width: 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),
|
const SizedBox(width: 8),
|
||||||
@@ -628,12 +811,19 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
child: AboutOrderButton(
|
child: AboutOrderButton(
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
if (amIConsultant) {
|
if (amIConsultant) {
|
||||||
GoRouter.of(
|
GoRouter.of(context).push(
|
||||||
context,
|
InfoByOrderScreen.path,
|
||||||
).push(InfoByOrderScreen.path, extra: {'planId': state.chatRoom.chosenPlan?.id, 'customerSlug': state.chatRoom.users.receiver.slug});
|
extra: {
|
||||||
|
'planId': state.chatRoom.chosenPlan?.id,
|
||||||
|
'customerSlug': state.chatRoom.users.receiver.slug,
|
||||||
|
},
|
||||||
|
);
|
||||||
return;
|
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),
|
const Icon(Icons.attach_file, size: 32, color: Colors.black54),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
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);
|
// 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(
|
CupertinoActionSheetAction(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
@@ -715,13 +917,21 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
List<XFile> images = await picker.pickMultiImage();
|
List<XFile> images = await picker.pickMultiImage();
|
||||||
if (images != []) {
|
if (images != []) {
|
||||||
setState(() {
|
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();
|
// List<File> files = images.map((image) => File(image.path)).toList();
|
||||||
// await chatRoomCubit.sendMessage(files: files, userSlug: slug);
|
// 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(
|
CupertinoActionSheetAction(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
@@ -753,15 +963,27 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
if (result != null) {
|
if (result != null) {
|
||||||
// List<File> files = result.paths.map((path) => File(path!)).toList();
|
// List<File> files = result.paths.map((path) => File(path!)).toList();
|
||||||
setState(() {
|
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);
|
// 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),
|
padding: const EdgeInsets.only(top: 32),
|
||||||
child: Text(
|
child: Text(
|
||||||
DateFormat.yMMMMd('RU').format(date),
|
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(
|
GoRouter.of(context).pushNamed(
|
||||||
VideoCallScreen.name,
|
VideoCallScreen.name,
|
||||||
extra: <String, dynamic>{
|
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({
|
Widget _buildMessageBubble({
|
||||||
required BuildContext context,
|
required BuildContext context,
|
||||||
required ChatRoomStateLoaded state,
|
required ChatRoomStateLoaded state,
|
||||||
@@ -801,11 +1068,13 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
required ChatMessageModel message,
|
required ChatMessageModel message,
|
||||||
required bool? amIConsultant,
|
required bool? amIConsultant,
|
||||||
}) {
|
}) {
|
||||||
if (message.type == ChatMessageType.videoCallStarted && !isCurrentUserAuthor) {
|
if (message.type == ChatMessageType.videoCallStarted &&
|
||||||
|
!isCurrentUserAuthor) {
|
||||||
return VideoCallChatMessageWidget(
|
return VideoCallChatMessageWidget(
|
||||||
text: message.data.trim(),
|
text: message.data.trim(),
|
||||||
sendTime: message.updatedAt,
|
sendTime: message.updatedAt,
|
||||||
onVideoCallPressed: () => _openVideoCall(context, state, chatCounterpart),
|
onVideoCallPressed: () =>
|
||||||
|
_openVideoCall(context, state, chatCounterpart),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -844,7 +1113,13 @@ class _ChatHeaderVideoCallButton extends StatelessWidget {
|
|||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 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),
|
create: (context) => sl<ComplainingCubit>()..setContentTypeAndIdentifier(contentType: widget.complainedContentType, slug: widget.slug, id: widget.id),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.only(top: 8, bottom: 24, left: 16, right: 16),
|
padding: const EdgeInsets.only(top: 8, bottom: 24, left: 16, right: 16),
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(borderRadius: BorderRadius.vertical(top: Radius.circular(12))),
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
|
|
||||||
),
|
|
||||||
child: BlocBuilder<ComplainingCubit, ComplainingState>(
|
child: BlocBuilder<ComplainingCubit, ComplainingState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final category = state.category;
|
final category = state.category;
|
||||||
@@ -72,10 +70,7 @@ class _CategoryWidget extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
const BottomSheetHandlerBar(),
|
const BottomSheetHandlerBar(),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Text(
|
Text(s.complaint, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||||
s.complaint,
|
|
||||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
s.complaint_question,
|
s.complaint_question,
|
||||||
@@ -114,10 +109,7 @@ class _SpamWidget extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
const BottomSheetHandlerBar(),
|
const BottomSheetHandlerBar(),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Text(
|
Text(s.spam, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||||
s.spam,
|
|
||||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||||
@@ -145,10 +137,7 @@ class _ExplicitImageWidget extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
const BottomSheetHandlerBar(),
|
const BottomSheetHandlerBar(),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Text(
|
Text(s.explicitImages, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||||
s.explicitImages,
|
|
||||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||||
@@ -202,10 +191,7 @@ class _LawViolationWidgetState extends State<_LawViolationWidget> {
|
|||||||
children: [
|
children: [
|
||||||
const BottomSheetHandlerBar(),
|
const BottomSheetHandlerBar(),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Text(
|
Text(s.lawViolation, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||||
s.lawViolation,
|
|
||||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||||
@@ -235,10 +221,7 @@ class _LawViolationWidgetState extends State<_LawViolationWidget> {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
Text(
|
Text(element.$2, style: TextStyles.textLSemibold.copyWith(color: appTheme.black)),
|
||||||
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) ...[
|
if ((state.complainInfo.description?.length ?? 10) < 10) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
const ValidationErrorWidget(error: 'Минимум 10 символов'),
|
const ValidationErrorWidget(error: 'Минимум 10 символов'),
|
||||||
]
|
],
|
||||||
],
|
],
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
_ComplainButton(
|
_ComplainButton(isDisabled: tags.firstWhere((tag) => tag.$2 == s.other).$1 && (state.complainInfo.description?.length ?? 0) < 10),
|
||||||
isDisabled: (state.complainInfo.description?.length ?? 0) < 10,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -301,10 +282,7 @@ class _ViolenceAndHostilityState extends State<_ViolenceAndHostility> {
|
|||||||
children: [
|
children: [
|
||||||
const BottomSheetHandlerBar(),
|
const BottomSheetHandlerBar(),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Text(
|
Text(s.lawViolation, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||||
s.lawViolation,
|
|
||||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||||
@@ -332,10 +310,7 @@ class _ViolenceAndHostilityState extends State<_ViolenceAndHostility> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(element.$2, style: TextStyles.textLSemibold.copyWith(color: appTheme.black)),
|
||||||
element.$2,
|
|
||||||
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -363,15 +338,9 @@ class _DeceptionWidgetState extends State<_DeceptionWidget> {
|
|||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
final s = S.of(context);
|
final s = S.of(context);
|
||||||
if (tags.isEmpty && descriptions.isEmpty) {
|
if (tags.isEmpty && descriptions.isEmpty) {
|
||||||
tags.addAll([
|
tags.addAll([(false, s.misrepresentation), (false, s.fraud)]);
|
||||||
(false, s.misrepresentation),
|
|
||||||
(false, s.fraud),
|
|
||||||
]);
|
|
||||||
|
|
||||||
descriptions.addAll([
|
descriptions.addAll([s.misrepresentation_description, s.fraud_description]);
|
||||||
s.misrepresentation_description,
|
|
||||||
s.fraud_description,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,10 +352,7 @@ class _DeceptionWidgetState extends State<_DeceptionWidget> {
|
|||||||
children: [
|
children: [
|
||||||
const BottomSheetHandlerBar(),
|
const BottomSheetHandlerBar(),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Text(
|
Text(s.lawViolation, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||||
s.lawViolation,
|
|
||||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||||
@@ -419,16 +385,10 @@ class _DeceptionWidgetState extends State<_DeceptionWidget> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(element.$2, style: TextStyles.textLSemibold.copyWith(color: appTheme.black)),
|
||||||
element.$2,
|
|
||||||
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
|
|
||||||
),
|
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||||
child: Text(
|
child: Text(descriptions[index], style: TextStyles.textSSemibold.copyWith(color: appTheme.lightGray600)),
|
||||||
descriptions[index],
|
|
||||||
style: TextStyles.textSSemibold.copyWith(color: appTheme.lightGray600),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -453,10 +413,7 @@ class _ComplaintSentWidget extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
const BottomSheetHandlerBar(),
|
const BottomSheetHandlerBar(),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Text(
|
Text(s.complaint_sent, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||||
s.complaint_sent,
|
|
||||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||||
@@ -471,10 +428,7 @@ class _ComplaintSentWidget extends StatelessWidget {
|
|||||||
text: s.ok,
|
text: s.ok,
|
||||||
height: 52,
|
height: 52,
|
||||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||||
buttonStyle: ButtonStyle(
|
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
|
||||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
|
||||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
|
||||||
),
|
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
AppRouter.navigatorKey.currentContext?.pop();
|
AppRouter.navigatorKey.currentContext?.pop();
|
||||||
},
|
},
|
||||||
@@ -495,10 +449,7 @@ class _ComplaintFailedWidget extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
const BottomSheetHandlerBar(),
|
const BottomSheetHandlerBar(),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Text(
|
Text(s.could_not_send_complain, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||||
s.could_not_send_complain,
|
|
||||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||||
@@ -513,10 +464,7 @@ class _ComplaintFailedWidget extends StatelessWidget {
|
|||||||
text: s.ok,
|
text: s.ok,
|
||||||
height: 52,
|
height: 52,
|
||||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||||
buttonStyle: ButtonStyle(
|
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
|
||||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
|
||||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
|
||||||
),
|
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
AppRouter.navigatorKey.currentContext?.pop();
|
AppRouter.navigatorKey.currentContext?.pop();
|
||||||
},
|
},
|
||||||
@@ -535,16 +483,13 @@ class _ComplainButton extends StatelessWidget {
|
|||||||
return BlocBuilder<ComplainingCubit, ComplainingState>(
|
return BlocBuilder<ComplainingCubit, ComplainingState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
if (state.isLoading) {
|
if (state.isLoading) {
|
||||||
return const Loader();
|
return const SizedBox(height: 52, child: Loader());
|
||||||
}
|
}
|
||||||
return CustomOutlinedButton(
|
return CustomOutlinedButton(
|
||||||
text: s.send_complain,
|
text: s.send_complain,
|
||||||
height: 52,
|
height: 52,
|
||||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||||
buttonStyle: ButtonStyle(
|
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
|
||||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
|
||||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
|
||||||
),
|
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
if (isDisabled) {
|
if (isDisabled) {
|
||||||
context.read<ComplainingCubit>().setDescription('');
|
context.read<ComplainingCubit>().setDescription('');
|
||||||
@@ -570,10 +515,7 @@ class _CategoryButton extends StatelessWidget {
|
|||||||
child: Ink(
|
child: Ink(
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(category.getTranslatedText(), style: TextStyles.textLSemibold.copyWith(color: appTheme.black)),
|
||||||
category.getTranslatedText(),
|
|
||||||
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
SvgPicture.asset('assets/icons/right_arrow.svg'),
|
SvgPicture.asset('assets/icons/right_arrow.svg'),
|
||||||
],
|
],
|
||||||
@@ -591,18 +533,18 @@ enum ComplainType {
|
|||||||
explicitImage;
|
explicitImage;
|
||||||
|
|
||||||
String getTranslatedText() => switch (this) {
|
String getTranslatedText() => switch (this) {
|
||||||
ComplainType.spam => S.current.spam,
|
ComplainType.spam => S.current.spam,
|
||||||
ComplainType.lawViolation => S.current.lawViolation,
|
ComplainType.lawViolation => S.current.lawViolation,
|
||||||
ComplainType.deception => S.current.deception,
|
ComplainType.deception => S.current.deception,
|
||||||
ComplainType.violenceDndHostility => S.current.violenceAndHostility,
|
ComplainType.violenceDndHostility => S.current.violenceAndHostility,
|
||||||
ComplainType.explicitImage => S.current.explicitImage,
|
ComplainType.explicitImage => S.current.explicitImage,
|
||||||
};
|
};
|
||||||
|
|
||||||
String getValue() => switch (this) {
|
String getValue() => switch (this) {
|
||||||
ComplainType.spam => 'spam',
|
ComplainType.spam => 'spam',
|
||||||
ComplainType.lawViolation => 'legalViolation',
|
ComplainType.lawViolation => 'legalViolation',
|
||||||
ComplainType.deception => 'fraud',
|
ComplainType.deception => 'fraud',
|
||||||
ComplainType.violenceDndHostility => 'violenceAndHatred',
|
ComplainType.violenceDndHostility => 'violenceAndHatred',
|
||||||
ComplainType.explicitImage => 'explicitImage',
|
ComplainType.explicitImage => 'explicitImage',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ class ComplainingCubit extends Cubit<ComplainingState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> complain() async {
|
Future<void> complain() async {
|
||||||
|
if (state.isLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit(state.copyWith(isLoading: true));
|
||||||
try {
|
try {
|
||||||
final category = state.category;
|
final category = state.category;
|
||||||
final id = state.contentId;
|
final id = state.contentId;
|
||||||
@@ -46,37 +50,37 @@ class ComplainingCubit extends Cubit<ComplainingState> {
|
|||||||
case ComplainedContent.diet:
|
case ComplainedContent.diet:
|
||||||
if (id != null) {
|
if (id != null) {
|
||||||
await repository.sendDietComplain(id: id, category: category.getValue(), body: state.complainInfo);
|
await repository.sendDietComplain(id: id, category: category.getValue(), body: state.complainInfo);
|
||||||
emit(state.copyWith(wasSentSuccessfully: true));
|
emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case ComplainedContent.plan:
|
case ComplainedContent.plan:
|
||||||
if (slug != null) {
|
if (slug != null) {
|
||||||
await repository.sendPlanComplain(slug: slug, category: category.getValue(), body: state.complainInfo);
|
await repository.sendPlanComplain(slug: slug, category: category.getValue(), body: state.complainInfo);
|
||||||
emit(state.copyWith(wasSentSuccessfully: true));
|
emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case ComplainedContent.recipe:
|
case ComplainedContent.recipe:
|
||||||
if (slug != null) {
|
if (slug != null) {
|
||||||
await repository.sendRecipeComplain(slug: slug, category: category.getValue(), body: state.complainInfo);
|
await repository.sendRecipeComplain(slug: slug, category: category.getValue(), body: state.complainInfo);
|
||||||
emit(state.copyWith(wasSentSuccessfully: true));
|
emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case ComplainedContent.profile:
|
case ComplainedContent.profile:
|
||||||
if (slug != null) {
|
if (slug != null) {
|
||||||
await repository.sendProfileComplain(slug: slug, category: category.getValue(), body: state.complainInfo);
|
await repository.sendProfileComplain(slug: slug, category: category.getValue(), body: state.complainInfo);
|
||||||
emit(state.copyWith(wasSentSuccessfully: true));
|
emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case ComplainedContent.posts:
|
case ComplainedContent.posts:
|
||||||
if (id != null) {
|
if (id != null) {
|
||||||
await repository.sendBlogPostsComplain(id: id, category: category.getValue(), body: state.complainInfo);
|
await repository.sendBlogPostsComplain(id: id, category: category.getValue(), body: state.complainInfo);
|
||||||
emit(state.copyWith(wasSentSuccessfully: true));
|
emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case ComplainedContent.comment:
|
case ComplainedContent.comment:
|
||||||
if (id != null) {
|
if (id != null) {
|
||||||
await repository.sendCommentComplain(id: id, category: category.getValue(), body: state.complainInfo);
|
await repository.sendCommentComplain(id: id, category: category.getValue(), body: state.complainInfo);
|
||||||
emit(state.copyWith(wasSentSuccessfully: true));
|
emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,6 +89,6 @@ class ComplainingCubit extends Cubit<ComplainingState> {
|
|||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
log(e.toString(), stackTrace: st);
|
log(e.toString(), stackTrace: st);
|
||||||
}
|
}
|
||||||
emit(state.copyWith(wasSentSuccessfully: false));
|
emit(state.copyWith(wasSentSuccessfully: false, isLoading: false));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ class _PassportDataScreenState extends State<PassportDataScreen> {
|
|||||||
filter: {'#': RegExp(r'[0-9]')},
|
filter: {'#': RegExp(r'[0-9]')},
|
||||||
type: MaskAutoCompletionType.lazy,
|
type: MaskAutoCompletionType.lazy,
|
||||||
);
|
);
|
||||||
|
final _alfaPersonNameFormatter = FilteringTextInputFormatter.allow(
|
||||||
|
RegExp(r'[a-zA-Zа-яА-ЯёЁ ,.-]'),
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -301,6 +304,8 @@ class _PassportDataScreenState extends State<PassportDataScreen> {
|
|||||||
child: CustomTextFormField(
|
child: CustomTextFormField(
|
||||||
hintText: 'Фамилия',
|
hintText: 'Фамилия',
|
||||||
controller: lastNameController,
|
controller: lastNameController,
|
||||||
|
maxLength: 50,
|
||||||
|
inputFormatters: [_alfaPersonNameFormatter],
|
||||||
hasError: errors.containsKey('last_name'),
|
hasError: errors.containsKey('last_name'),
|
||||||
onChanged: (_) => _syncToCubit(),
|
onChanged: (_) => _syncToCubit(),
|
||||||
),
|
),
|
||||||
@@ -311,6 +316,8 @@ class _PassportDataScreenState extends State<PassportDataScreen> {
|
|||||||
child: CustomTextFormField(
|
child: CustomTextFormField(
|
||||||
hintText: 'Имя',
|
hintText: 'Имя',
|
||||||
controller: firstNameController,
|
controller: firstNameController,
|
||||||
|
maxLength: 50,
|
||||||
|
inputFormatters: [_alfaPersonNameFormatter],
|
||||||
hasError: errors.containsKey('first_name'),
|
hasError: errors.containsKey('first_name'),
|
||||||
onChanged: (_) => _syncToCubit(),
|
onChanged: (_) => _syncToCubit(),
|
||||||
),
|
),
|
||||||
@@ -321,6 +328,8 @@ class _PassportDataScreenState extends State<PassportDataScreen> {
|
|||||||
child: CustomTextFormField(
|
child: CustomTextFormField(
|
||||||
hintText: 'Отчество',
|
hintText: 'Отчество',
|
||||||
controller: middleNameController,
|
controller: middleNameController,
|
||||||
|
maxLength: 50,
|
||||||
|
inputFormatters: [_alfaPersonNameFormatter],
|
||||||
hasError: errors.containsKey('middle_name'),
|
hasError: errors.containsKey('middle_name'),
|
||||||
onChanged: (_) => _syncToCubit(),
|
onChanged: (_) => _syncToCubit(),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ class LaravelValidationErrors {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const _genericValidationSummary = 'Проверьте введённые данные';
|
const _genericValidationSummary = 'Проверьте введённые данные';
|
||||||
const _genericNetworkSummary = 'Не удалось отправить данные. Попробуйте ещё раз';
|
const _genericNetworkSummary =
|
||||||
|
'Не удалось отправить данные. Попробуйте ещё раз';
|
||||||
|
|
||||||
/// Parses Laravel `errors` map and maps them to user-facing Russian texts.
|
/// Parses Laravel `errors` map and maps them to user-facing Russian texts.
|
||||||
/// Raw backend messages are never shown in the UI.
|
/// Raw backend messages are never shown in the UI.
|
||||||
@@ -85,8 +86,7 @@ String mapConsultantValidationMessage({
|
|||||||
raw.contains('required') ||
|
raw.contains('required') ||
|
||||||
raw.contains('обязательн') ||
|
raw.contains('обязательн') ||
|
||||||
raw.contains('required_if');
|
raw.contains('required_if');
|
||||||
final isAccepted =
|
final isAccepted = raw.contains('accepted') || field.contains('agreement');
|
||||||
raw.contains('accepted') || field.contains('agreement');
|
|
||||||
final isUrl = raw.contains('url') || field.contains('link');
|
final isUrl = raw.contains('url') || field.contains('link');
|
||||||
final isFormat =
|
final isFormat =
|
||||||
raw.contains('format') ||
|
raw.contains('format') ||
|
||||||
@@ -154,6 +154,9 @@ String mapConsultantValidationMessage({
|
|||||||
'passport_number' => 'Некорректный номер паспорта',
|
'passport_number' => 'Некорректный номер паспорта',
|
||||||
'passport_issue_date' => 'Некорректная дата выдачи',
|
'passport_issue_date' => 'Некорректная дата выдачи',
|
||||||
'passport_issuer_code' => 'Некорректный код подразделения',
|
'passport_issuer_code' => 'Некорректный код подразделения',
|
||||||
|
'last_name' => 'Некорректная фамилия',
|
||||||
|
'first_name' => 'Некорректное имя',
|
||||||
|
'middle_name' => 'Некорректное отчество',
|
||||||
'birth_date' => 'Некорректная дата рождения',
|
'birth_date' => 'Некорректная дата рождения',
|
||||||
'citizenship' => 'Некорректный код гражданства',
|
'citizenship' => 'Некорректный код гражданства',
|
||||||
'sbp_phone' => 'Некорректный телефон СБП',
|
'sbp_phone' => 'Некорректный телефон СБП',
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import 'package:eatvio_app/features/consultant/become_consultant/models/consulta
|
|||||||
abstract final class PassportDataValidator {
|
abstract final class PassportDataValidator {
|
||||||
static const futureDateMessage = 'Дата не может быть в будущем';
|
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(
|
static Map<String, String> validate(
|
||||||
ConsultantProfileModel consultant, {
|
ConsultantProfileModel consultant, {
|
||||||
DateTime? now,
|
DateTime? now,
|
||||||
@@ -15,17 +18,28 @@ abstract final class PassportDataValidator {
|
|||||||
final today = now ?? DateTime.now();
|
final today = now ?? DateTime.now();
|
||||||
final errors = <String, String>{};
|
final errors = <String, String>{};
|
||||||
|
|
||||||
_requireText(
|
_requirePattern(
|
||||||
errors,
|
errors,
|
||||||
key: 'last_name',
|
key: 'last_name',
|
||||||
value: consultant.lastName,
|
value: consultant.lastName,
|
||||||
|
pattern: alfaPersonNamePattern,
|
||||||
emptyMessage: 'Укажите фамилию',
|
emptyMessage: 'Укажите фамилию',
|
||||||
|
invalidMessage: 'Некорректная фамилия',
|
||||||
);
|
);
|
||||||
_requireText(
|
_requirePattern(
|
||||||
errors,
|
errors,
|
||||||
key: 'first_name',
|
key: 'first_name',
|
||||||
value: consultant.firstName,
|
value: consultant.firstName,
|
||||||
|
pattern: alfaPersonNamePattern,
|
||||||
emptyMessage: 'Укажите имя',
|
emptyMessage: 'Укажите имя',
|
||||||
|
invalidMessage: 'Некорректное имя',
|
||||||
|
);
|
||||||
|
_optionalPattern(
|
||||||
|
errors,
|
||||||
|
key: 'middle_name',
|
||||||
|
value: consultant.middleName,
|
||||||
|
pattern: alfaPersonNamePattern,
|
||||||
|
invalidMessage: 'Некорректное отчество',
|
||||||
);
|
);
|
||||||
_requirePattern(
|
_requirePattern(
|
||||||
errors,
|
errors,
|
||||||
@@ -148,6 +162,22 @@ abstract final class PassportDataValidator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
static void _requireDate(
|
||||||
Map<String, String> errors, {
|
Map<String, String> errors, {
|
||||||
required String key,
|
required String key,
|
||||||
|
|||||||
@@ -59,43 +59,47 @@ class _AddMealWidgetState extends State<AddMealWidget> {
|
|||||||
Row(
|
Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
Expanded(
|
||||||
width: MediaQuery.of(context).size.width * 0.8 - 32,
|
flex: 2,
|
||||||
child: CustomTextFormField(
|
child: SizedBox(
|
||||||
controller: productNameController,
|
child: CustomTextFormField(
|
||||||
autofocus: true,
|
controller: productNameController,
|
||||||
hintText: 'Введите название продукта/блюда',
|
autofocus: true,
|
||||||
onChanged: (query) {
|
hintText: 'Введите название продукта/блюда',
|
||||||
EasyDebounce.debounce('glossary-search', const Duration(milliseconds: 250), () async {
|
onChanged: (query) {
|
||||||
selectedGlossary = null;
|
EasyDebounce.debounce('glossary-search', const Duration(milliseconds: 250), () async {
|
||||||
if (query.isEmpty) {
|
|
||||||
glossariesList = [];
|
|
||||||
selectedGlossary = null;
|
selectedGlossary = null;
|
||||||
|
if (query.isEmpty) {
|
||||||
|
glossariesList = [];
|
||||||
|
selectedGlossary = null;
|
||||||
|
setState(() {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
glossariesList = await widget.createDietCubit.getGlossaryListByQuery(query);
|
||||||
setState(() {});
|
setState(() {});
|
||||||
return;
|
});
|
||||||
}
|
},
|
||||||
glossariesList = await widget.createDietCubit.getGlossaryListByQuery(query);
|
),
|
||||||
setState(() {});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
SizedBox(
|
Expanded(
|
||||||
width: MediaQuery.of(context).size.width * 0.2 - 4,
|
flex: 1,
|
||||||
child: CustomTextFormField(
|
child: SizedBox(
|
||||||
controller: quantityController,
|
child: CustomTextFormField(
|
||||||
keyboardType: TextInputType.number,
|
controller: quantityController,
|
||||||
hintText: selectedGlossary == null
|
keyboardType: TextInputType.number,
|
||||||
? '100'
|
hintText: selectedGlossary == null
|
||||||
: selectedGlossary!.grInPortion == 0
|
? '100'
|
||||||
? '100'
|
: selectedGlossary!.grInPortion == 0
|
||||||
: '1',
|
? '100'
|
||||||
suffixText: selectedGlossary == null
|
: '1',
|
||||||
? 'гр'
|
suffixText: selectedGlossary == null
|
||||||
: selectedGlossary?.grInPortion == 0
|
? 'гр'
|
||||||
? 'гр'
|
: selectedGlossary?.grInPortion == 0
|
||||||
: 'шт',
|
? 'гр'
|
||||||
|
: 'шт',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -149,17 +153,19 @@ class _AddMealWidgetState extends State<AddMealWidget> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (widget.portionIndex != null) const SizedBox(width: 8),
|
if (widget.portionIndex != null) const SizedBox(width: 8),
|
||||||
InkWell(
|
Expanded(
|
||||||
onTap: addPortion,
|
child: InkWell(
|
||||||
borderRadius: BorderRadius.circular(20),
|
onTap: addPortion,
|
||||||
child: Ink(
|
borderRadius: BorderRadius.circular(20),
|
||||||
width: MediaQuery.of(context).size.width - (widget.portionIndex == null ? 32 : 92),
|
child: Ink(
|
||||||
height: 52,
|
width: MediaQuery.of(context).size.width - (widget.portionIndex == null ? 32 : 92),
|
||||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
|
height: 52,
|
||||||
child: Center(
|
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
|
||||||
child: Text(
|
child: Center(
|
||||||
widget.portionIndex == null ? 'Добавить' : 'Сохранить',
|
child: Text(
|
||||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white),
|
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_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_cubit.dart';
|
||||||
import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_state.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/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/options_count_button.dart';
|
||||||
import 'package:eatvio_app/features/create_plan/presentation/widgets/plan_duration_drop_down_widget.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 {
|
class CreatePlanScreen extends StatelessWidget {
|
||||||
static const name = 'createPlanScreen';
|
static const name = 'createPlanScreen';
|
||||||
static const path = '/create-Plan-screen';
|
static const path = '/create-Plan-screen';
|
||||||
const CreatePlanScreen({super.key});
|
const CreatePlanScreen({super.key, this.plan});
|
||||||
|
|
||||||
|
final PlanModel? plan;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final s = S.of(context);
|
final s = S.of(context);
|
||||||
|
final isEditing = context.read<CreatePlanCubit>().sourcePlan != null;
|
||||||
return BlocBuilder<CreatePlanCubit, CreatePlanState>(
|
return BlocBuilder<CreatePlanCubit, CreatePlanState>(
|
||||||
buildWhen: (previous, current) => previous.planCreated != current.planCreated || previous.isLoading != current.isLoading,
|
buildWhen: (previous, current) => previous.planCreated != current.planCreated || previous.isLoading != current.isLoading,
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
if (state.planCreated) {
|
if (state.planCreated) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
context.pop();
|
final isEditing = context.read<CreatePlanCubit>().sourcePlan != null;
|
||||||
|
context.pop(true);
|
||||||
showBottomSheetWithBlur(
|
showBottomSheetWithBlur(
|
||||||
userRootNavigator: true,
|
userRootNavigator: true,
|
||||||
context: context,
|
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(
|
appBar: AppBar(
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
title: Text(
|
title: Text(
|
||||||
s.adding_plan,
|
isEditing ? 'Редактирование плана' : s.adding_plan,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
softWrap: true,
|
softWrap: true,
|
||||||
@@ -102,7 +117,9 @@ class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
|
|||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
CustomTextFormField(
|
CustomTextFormField(
|
||||||
|
key: const ValueKey('plan-name'),
|
||||||
hintText: s.plan_label_desc,
|
hintText: s.plan_label_desc,
|
||||||
|
initialValue: state.planData.name,
|
||||||
hasError: state.validationState.titleHasError,
|
hasError: state.validationState.titleHasError,
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
context.read<CreatePlanCubit>().setName(value);
|
context.read<CreatePlanCubit>().setName(value);
|
||||||
@@ -144,6 +161,7 @@ class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
|
|||||||
children: [
|
children: [
|
||||||
MarkdownEditor(
|
MarkdownEditor(
|
||||||
key: _mdKey,
|
key: _mdKey,
|
||||||
|
initialText: state.planData.details,
|
||||||
hintText: s.plan_desc_example,
|
hintText: s.plan_desc_example,
|
||||||
hasError: state.validationState.descriptionHasError,
|
hasError: state.validationState.descriptionHasError,
|
||||||
minLines: 6,
|
minLines: 6,
|
||||||
@@ -230,7 +248,7 @@ class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
|
|||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
await context.read<CreatePlanCubit>().publishPlan();
|
await context.read<CreatePlanCubit>().publishPlan();
|
||||||
},
|
},
|
||||||
text: s.publish,
|
text: isEditing ? 'Обновить' : s.publish,
|
||||||
height: 52,
|
height: 52,
|
||||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||||
buttonStyle: ButtonStyle(
|
buttonStyle: ButtonStyle(
|
||||||
|
|||||||
@@ -4,16 +4,57 @@ import 'dart:io';
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:eatvio_app/core/const.dart';
|
import 'package:eatvio_app/core/const.dart';
|
||||||
import 'package:eatvio_app/core/image_compressing/image_compress_utility.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/presentation/cubit/create_plan_state.dart';
|
||||||
import 'package:eatvio_app/features/create_plan/repositories/create_plan_repository.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:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||||
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||||
|
|
||||||
class CreatePlanCubit extends Cubit<CreatePlanState> {
|
class CreatePlanCubit extends Cubit<CreatePlanState> {
|
||||||
final CreatePlanRepository createPlanRepository;
|
final CreatePlanRepository createPlanRepository;
|
||||||
|
PlanModel? sourcePlan;
|
||||||
|
|
||||||
CreatePlanCubit({required this.createPlanRepository}) : super(CreatePlanState.initial());
|
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) {
|
void setDetails(String text) {
|
||||||
emit(state.copyWith(planData: state.planData.copyWith(details: text)));
|
emit(state.copyWith(planData: state.planData.copyWith(details: text)));
|
||||||
validateDetails();
|
validateDetails();
|
||||||
@@ -147,7 +188,7 @@ class CreatePlanCubit extends Cubit<CreatePlanState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool validateImage() {
|
bool validateImage() {
|
||||||
if (state.planData.image == null) {
|
if (state.planData.image == null && sourcePlan == null) {
|
||||||
emit(state.copyWith.validationState(imageHasError: true));
|
emit(state.copyWith.validationState(imageHasError: true));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -413,31 +454,34 @@ class CreatePlanCubit extends Cubit<CreatePlanState> {
|
|||||||
'type[fit]': state.planData.type[1] ? '1' : '0',
|
'type[fit]': state.planData.type[1] ? '1' : '0',
|
||||||
'type[wellness]': state.planData.type[2] ? '1' : '0',
|
'type[wellness]': state.planData.type[2] ? '1' : '0',
|
||||||
'type[behavior]': state.planData.type[3] ? '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) {
|
if (state.planData.options[1].short.isNotEmpty) {
|
||||||
planMap.addAll({
|
planMap.addAll({
|
||||||
'packets_count': 3,
|
'packets_count': 3,
|
||||||
'packet1_options': {
|
'packet1_options': {
|
||||||
'short': state.planData.options[0].short,
|
'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,
|
'description_md': state.planData.options[0].description,
|
||||||
'items': Map.fromIterable(state.planData.options[0].items),
|
'items': Map.fromIterable(state.planData.options[0].items),
|
||||||
'has_personal_diet': state.planData.options[0].hasPersonalDiet ? '1' : '0',
|
'has_personal_diet': state.planData.options[0].hasPersonalDiet ? '1' : '0',
|
||||||
},
|
},
|
||||||
'packet2_options': {
|
'packet2_options': {
|
||||||
'short': state.planData.options[1].short,
|
'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,
|
'description_md': state.planData.options[1].description,
|
||||||
'items': Map.fromIterable(state.planData.options[1].items),
|
'items': Map.fromIterable(state.planData.options[1].items),
|
||||||
'has_personal_diet': state.planData.options[1].hasPersonalDiet ? '1' : '0',
|
'has_personal_diet': state.planData.options[1].hasPersonalDiet ? '1' : '0',
|
||||||
},
|
},
|
||||||
'packet3_options': {
|
'packet3_options': {
|
||||||
'short': state.planData.options[2].short,
|
'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,
|
'description_md': state.planData.options[2].description,
|
||||||
'items': Map.fromIterable(state.planData.options[2].items),
|
'items': Map.fromIterable(state.planData.options[2].items),
|
||||||
'has_personal_diet': state.planData.options[2].hasPersonalDiet ? '1' : '0',
|
'has_personal_diet': state.planData.options[2].hasPersonalDiet ? '1' : '0',
|
||||||
@@ -448,20 +492,34 @@ class CreatePlanCubit extends Cubit<CreatePlanState> {
|
|||||||
'packets_count': 1,
|
'packets_count': 1,
|
||||||
'packet1_options': {
|
'packet1_options': {
|
||||||
'short': state.planData.options[0].short,
|
'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,
|
'description_md': state.planData.options[0].description,
|
||||||
'items': Map.fromIterable(state.planData.options[0].items),
|
'items': Map.fromIterable(state.planData.options[0].items),
|
||||||
'has_personal_diet': state.planData.options[0].hasPersonalDiet ? '1' : '0',
|
'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));
|
emit(state.copyWith(isLoading: false, planCreated: true));
|
||||||
}
|
}
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
log(e.toString());
|
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> {
|
class _AddItemWidgetState extends State<AddItemWidget> {
|
||||||
final controller = TextEditingController();
|
final controller = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(oldWidget) {
|
void didUpdateWidget(oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
@@ -38,14 +44,24 @@ class _AddItemWidgetState extends State<AddItemWidget> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final s = S.of(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];
|
controller.text = widget.items[widget.index];
|
||||||
}
|
}
|
||||||
return BlocBuilder<CreatePlanCubit, CreatePlanState>(
|
return BlocBuilder<CreatePlanCubit, CreatePlanState>(
|
||||||
buildWhen: (previous, current) => previous.validationState != current.validationState,
|
buildWhen: (previous, current) =>
|
||||||
|
previous.validationState != current.validationState,
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final hasError = state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].isItemsEmpty ||
|
final hasError =
|
||||||
(state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].itemsErrors[widget.index] ?? false);
|
state
|
||||||
|
.validationState
|
||||||
|
.optionsValidationState[state.selectedOptionIndex ?? 0]
|
||||||
|
.isItemsEmpty ||
|
||||||
|
(state
|
||||||
|
.validationState
|
||||||
|
.optionsValidationState[state.selectedOptionIndex ?? 0]
|
||||||
|
.itemsErrors[widget.index] ??
|
||||||
|
false);
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
@@ -54,9 +70,14 @@ class _AddItemWidgetState extends State<AddItemWidget> {
|
|||||||
child: CustomTextFormField(
|
child: CustomTextFormField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
hasError: hasError,
|
hasError: hasError,
|
||||||
hintText: widget.enableHint ? s.option_content_hint_text : null,
|
hintText: widget.enableHint
|
||||||
|
? s.option_content_hint_text
|
||||||
|
: null,
|
||||||
onChanged: (value) {
|
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),
|
const SizedBox(width: 8),
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
context.read<CreatePlanCubit>().removeOptionContentItem(widget.index);
|
context.read<CreatePlanCubit>().removeOptionContentItem(
|
||||||
|
widget.index,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(14),
|
padding: const EdgeInsets.all(14),
|
||||||
@@ -72,19 +95,30 @@ class _AddItemWidgetState extends State<AddItemWidget> {
|
|||||||
color: appTheme.lightGray0,
|
color: appTheme.lightGray0,
|
||||||
borderRadius: BorderRadius.circular(20),
|
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),
|
const SizedBox(height: 4),
|
||||||
ValidationErrorWidget(error: s.addAtLeastOneItem)
|
ValidationErrorWidget(error: s.addAtLeastOneItem),
|
||||||
] else if (state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].itemsErrors[widget.index] ?? false) ...[
|
] else if (state
|
||||||
|
.validationState
|
||||||
|
.optionsValidationState[state.selectedOptionIndex ?? 0]
|
||||||
|
.itemsErrors[widget.index] ??
|
||||||
|
false) ...[
|
||||||
const SizedBox(height: 4),
|
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_cubit.dart';
|
||||||
import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_state.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/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:eatvio_app/generated/l10n.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
@@ -21,47 +22,107 @@ class OptionWidget extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _OptionWidgetState extends State<OptionWidget> {
|
class _OptionWidgetState extends State<OptionWidget> {
|
||||||
final firstOptionCustomerPriceController = TextEditingController()..text = '1200₽';
|
static const _optionsCount = 3;
|
||||||
final secondOptionCustomerPriceController = TextEditingController()..text = '1200₽';
|
|
||||||
final thirdOptionCustomerPriceController = TextEditingController()..text = '1200₽';
|
|
||||||
|
|
||||||
final creatorPriceController = TextEditingController()..text = '1000₽';
|
final List<TextEditingController> _customerPriceControllers = List.generate(
|
||||||
|
_optionsCount,
|
||||||
final firstOptionTitleController = TextEditingController();
|
(_) => TextEditingController(text: '1200₽'),
|
||||||
final secondOptionTitleController = TextEditingController();
|
);
|
||||||
final thirdOptionTitleController = TextEditingController();
|
final List<TextEditingController> _titleControllers = List.generate(
|
||||||
|
_optionsCount,
|
||||||
final firstOptionDescriptionController = TextEditingController();
|
(_) => TextEditingController(),
|
||||||
final secondOptionDescriptionController = TextEditingController();
|
);
|
||||||
final thirdOptionDescriptionController = TextEditingController();
|
final List<TextEditingController> _descriptionControllers = List.generate(
|
||||||
|
_optionsCount,
|
||||||
|
(_) => TextEditingController(),
|
||||||
|
);
|
||||||
|
final creatorPriceController = TextEditingController(text: '1000₽');
|
||||||
|
|
||||||
|
int? _syncedOptionIndex;
|
||||||
bool firstExplanationVisible = false;
|
bool firstExplanationVisible = false;
|
||||||
bool isChecked = false;
|
bool isChecked = false;
|
||||||
bool secondExplanationVisible = false;
|
bool secondExplanationVisible = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
firstOptionCustomerPriceController.addListener(() {
|
for (final controller in _customerPriceControllers) {
|
||||||
if (!firstOptionCustomerPriceController.text.endsWith('₽')) {
|
controller.addListener(() => _ensureRubleSuffix(controller));
|
||||||
final text = firstOptionCustomerPriceController.text.replaceAll('₽', '');
|
}
|
||||||
firstOptionCustomerPriceController.text = '$text₽';
|
}
|
||||||
firstOptionCustomerPriceController.selection = TextSelection.collapsed(offset: text.length);
|
|
||||||
}
|
@override
|
||||||
});
|
void dispose() {
|
||||||
secondOptionCustomerPriceController.addListener(() {
|
for (final controller in _customerPriceControllers) {
|
||||||
if (!secondOptionCustomerPriceController.text.endsWith('₽')) {
|
controller.dispose();
|
||||||
final text = secondOptionCustomerPriceController.text.replaceAll('₽', '');
|
}
|
||||||
secondOptionCustomerPriceController.text = '$text₽';
|
for (final controller in _titleControllers) {
|
||||||
secondOptionCustomerPriceController.selection = TextSelection.collapsed(offset: text.length);
|
controller.dispose();
|
||||||
}
|
}
|
||||||
});
|
for (final controller in _descriptionControllers) {
|
||||||
thirdOptionCustomerPriceController.addListener(() {
|
controller.dispose();
|
||||||
if (!thirdOptionCustomerPriceController.text.endsWith('₽')) {
|
}
|
||||||
final text = thirdOptionCustomerPriceController.text.replaceAll('₽', '');
|
creatorPriceController.dispose();
|
||||||
thirdOptionCustomerPriceController.text = '$text₽';
|
super.dispose();
|
||||||
thirdOptionCustomerPriceController.selection = TextSelection.collapsed(offset: text.length);
|
}
|
||||||
}
|
|
||||||
});
|
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
|
@override
|
||||||
@@ -70,31 +131,21 @@ class _OptionWidgetState extends State<OptionWidget> {
|
|||||||
return BlocBuilder<CreatePlanCubit, CreatePlanState>(
|
return BlocBuilder<CreatePlanCubit, CreatePlanState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final selectedOptionIndex = state.selectedOptionIndex ?? 0;
|
final selectedOptionIndex = state.selectedOptionIndex ?? 0;
|
||||||
|
_syncSelectedOption(state);
|
||||||
final optionNameHintText = switch (selectedOptionIndex) {
|
final optionNameHintText = switch (selectedOptionIndex) {
|
||||||
0 => s.economy,
|
0 => s.economy,
|
||||||
2 => s.comfort,
|
2 => s.comfort,
|
||||||
_ => s.standard,
|
_ => s.standard,
|
||||||
};
|
};
|
||||||
final optionTitleController = switch (selectedOptionIndex) {
|
final optionTitleController = _titleControllers[selectedOptionIndex];
|
||||||
0 => firstOptionTitleController,
|
final optionDescriptionController =
|
||||||
2 => thirdOptionTitleController,
|
_descriptionControllers[selectedOptionIndex];
|
||||||
_ => secondOptionTitleController,
|
final customerPriceController =
|
||||||
};
|
_customerPriceControllers[selectedOptionIndex];
|
||||||
final optionDescriptionController = switch (selectedOptionIndex) {
|
final optionItems = state.planData.options[selectedOptionIndex].items;
|
||||||
0 => firstOptionDescriptionController,
|
final optionValidation =
|
||||||
2 => thirdOptionDescriptionController,
|
state.validationState.optionsValidationState[selectedOptionIndex];
|
||||||
_ => secondOptionDescriptionController,
|
|
||||||
};
|
|
||||||
final customerPriceController = switch (selectedOptionIndex) {
|
|
||||||
0 => firstOptionCustomerPriceController,
|
|
||||||
2 => thirdOptionCustomerPriceController,
|
|
||||||
_ => secondOptionCustomerPriceController,
|
|
||||||
};
|
|
||||||
|
|
||||||
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(
|
return MainBackgroundBouble(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -110,14 +161,14 @@ class _OptionWidgetState extends State<OptionWidget> {
|
|||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
CustomTextFormField(
|
CustomTextFormField(
|
||||||
hintText: optionNameHintText,
|
hintText: optionNameHintText,
|
||||||
hasError: state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].titleHasError,
|
hasError: optionValidation.titleHasError,
|
||||||
controller: optionTitleController,
|
controller: optionTitleController,
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
context.read<CreatePlanCubit>().setOptionTitle(value);
|
context.read<CreatePlanCubit>().setOptionTitle(value);
|
||||||
},
|
},
|
||||||
maxLength: 150,
|
maxLength: 150,
|
||||||
),
|
),
|
||||||
if (state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].titleHasError) ...[
|
if (optionValidation.titleHasError) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
ValidationErrorWidget(error: s.atLeast3Symbols),
|
ValidationErrorWidget(error: s.atLeast3Symbols),
|
||||||
],
|
],
|
||||||
@@ -135,7 +186,7 @@ class _OptionWidgetState extends State<OptionWidget> {
|
|||||||
child: CustomTextFormField(
|
child: CustomTextFormField(
|
||||||
controller: optionDescriptionController,
|
controller: optionDescriptionController,
|
||||||
textAlignVertical: TextAlignVertical.top,
|
textAlignVertical: TextAlignVertical.top,
|
||||||
hasError: state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].descriptionHasError,
|
hasError: optionValidation.descriptionHasError,
|
||||||
maxLength: 1000,
|
maxLength: 1000,
|
||||||
minLines: null,
|
minLines: null,
|
||||||
maxLines: 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),
|
const SizedBox(height: 4),
|
||||||
ValidationErrorWidget(error: s.atLeast3Symbols),
|
ValidationErrorWidget(error: s.atLeast3Symbols),
|
||||||
],
|
],
|
||||||
@@ -158,7 +209,9 @@ class _OptionWidgetState extends State<OptionWidget> {
|
|||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
softWrap: true,
|
softWrap: true,
|
||||||
style: TextStyles.textSMedium.copyWith(color: appTheme.black),
|
style: TextStyles.textSMedium.copyWith(
|
||||||
|
color: appTheme.black,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
const _PriceExplanationWidget(),
|
const _PriceExplanationWidget(),
|
||||||
@@ -179,7 +232,9 @@ class _OptionWidgetState extends State<OptionWidget> {
|
|||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
softWrap: true,
|
softWrap: true,
|
||||||
style: TextStyles.textSMedium.copyWith(color: appTheme.black),
|
style: TextStyles.textSMedium.copyWith(
|
||||||
|
color: appTheme.black,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
const _PriceExplanationWidget(),
|
const _PriceExplanationWidget(),
|
||||||
@@ -188,8 +243,10 @@ class _OptionWidgetState extends State<OptionWidget> {
|
|||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
CustomTextFormField(
|
CustomTextFormField(
|
||||||
controller: customerPriceController,
|
controller: customerPriceController,
|
||||||
textInputType: const TextInputType.numberWithOptions(decimal: true),
|
textInputType: const TextInputType.numberWithOptions(
|
||||||
hasError: state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].priceHasError,
|
decimal: true,
|
||||||
|
),
|
||||||
|
hasError: optionValidation.priceHasError,
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
final price = double.tryParse(value.replaceAll('₽', ''));
|
final price = double.tryParse(value.replaceAll('₽', ''));
|
||||||
if (price != null) {
|
if (price != null) {
|
||||||
@@ -222,34 +279,41 @@ class _OptionWidgetState extends State<OptionWidget> {
|
|||||||
separatorBuilder: (context, index) => const SizedBox(height: 8),
|
separatorBuilder: (context, index) => const SizedBox(height: 8),
|
||||||
reverse: false,
|
reverse: false,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final items = state.planData.options[selectedOptionIndex].items;
|
if (optionItems.isEmpty) {
|
||||||
|
|
||||||
if (items.isEmpty) {
|
|
||||||
return AddItemWidget(
|
return AddItemWidget(
|
||||||
|
key: ValueKey(
|
||||||
|
'plan-option-$selectedOptionIndex-item-$index',
|
||||||
|
),
|
||||||
index: index,
|
index: index,
|
||||||
canBeDeleted: false,
|
canBeDeleted: false,
|
||||||
enableHint: true,
|
enableHint: true,
|
||||||
items: items,
|
items: optionItems,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
if (index < items.length) {
|
if (index < optionItems.length) {
|
||||||
return AddItemWidget(
|
return AddItemWidget(
|
||||||
|
key: ValueKey(
|
||||||
|
'plan-option-$selectedOptionIndex-item-$index',
|
||||||
|
),
|
||||||
canBeDeleted: true,
|
canBeDeleted: true,
|
||||||
enableHint: false,
|
enableHint: false,
|
||||||
index: index,
|
index: index,
|
||||||
items: items,
|
items: optionItems,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return AddItemWidget(
|
return AddItemWidget(
|
||||||
|
key: ValueKey(
|
||||||
|
'plan-option-$selectedOptionIndex-item-$index',
|
||||||
|
),
|
||||||
canBeDeleted: false,
|
canBeDeleted: false,
|
||||||
enableHint: false,
|
enableHint: false,
|
||||||
index: index,
|
index: index,
|
||||||
items: items,
|
items: optionItems,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
itemCount: state.planData.options[selectedOptionIndex].items.length + 1,
|
itemCount: optionItems.length + 1,
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
),
|
),
|
||||||
@@ -259,23 +323,30 @@ class _OptionWidgetState extends State<OptionWidget> {
|
|||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Checkbox(
|
Checkbox(
|
||||||
value: state.planData.options[selectedOptionIndex].hasPersonalDiet,
|
value: state
|
||||||
|
.planData
|
||||||
|
.options[selectedOptionIndex]
|
||||||
|
.hasPersonalDiet,
|
||||||
activeColor: appTheme.gray,
|
activeColor: appTheme.gray,
|
||||||
checkColor: appTheme.gray,
|
checkColor: appTheme.gray,
|
||||||
fillColor: const WidgetStatePropertyAll(Colors.transparent),
|
fillColor: const WidgetStatePropertyAll(Colors.transparent),
|
||||||
side: WidgetStateBorderSide.resolveWith(
|
side: WidgetStateBorderSide.resolveWith((
|
||||||
(Set<WidgetState> states) {
|
Set<WidgetState> states,
|
||||||
return BorderSide(width: 1, color: appTheme.gray);
|
) {
|
||||||
},
|
return BorderSide(width: 1, color: appTheme.gray);
|
||||||
),
|
}),
|
||||||
onChanged: (bool? value) {
|
onChanged: (bool? value) {
|
||||||
context.read<CreatePlanCubit>().togglePersonalizedDietOptionState();
|
context
|
||||||
|
.read<CreatePlanCubit>()
|
||||||
|
.togglePersonalizedDietOptionState();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
s.option_personal_diet_desc,
|
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();
|
const _PriceExplanationWidget();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_PriceExplanationWidget> createState() => _PriceExplanationWidgetState();
|
State<_PriceExplanationWidget> createState() =>
|
||||||
|
_PriceExplanationWidgetState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PriceExplanationWidgetState extends State<_PriceExplanationWidget> {
|
class _PriceExplanationWidgetState extends State<_PriceExplanationWidget> {
|
||||||
@@ -314,9 +386,7 @@ class _PriceExplanationWidgetState extends State<_PriceExplanationWidget> {
|
|||||||
color: visible ? appTheme.whiteA700 : Colors.transparent,
|
color: visible ? appTheme.whiteA700 : Colors.transparent,
|
||||||
),
|
),
|
||||||
duration: const Duration(milliseconds: 300),
|
duration: const Duration(milliseconds: 300),
|
||||||
child: Text(
|
child: Text(s.price_explanation),
|
||||||
s.price_explanation,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
visible: visible,
|
visible: visible,
|
||||||
@@ -331,7 +401,11 @@ class _PriceExplanationWidgetState extends State<_PriceExplanationWidget> {
|
|||||||
visible = !visible;
|
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 'dart:io';
|
||||||
|
|
||||||
import 'package:dotted_border/dotted_border.dart';
|
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/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/permission_handler/permission_handler.dart';
|
||||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||||
@@ -91,6 +93,18 @@ class _PlanImagePickerWidgetState extends State<PlanImagePickerWidget> {
|
|||||||
final s = S.of(context);
|
final s = S.of(context);
|
||||||
return BlocBuilder<CreatePlanCubit, CreatePlanState>(
|
return BlocBuilder<CreatePlanCubit, CreatePlanState>(
|
||||||
builder: (context, state) {
|
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(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
@@ -101,12 +115,12 @@ class _PlanImagePickerWidgetState extends State<PlanImagePickerWidget> {
|
|||||||
height: 240,
|
height: 240,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(8),
|
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,
|
color: appTheme.gray50,
|
||||||
),
|
),
|
||||||
width: MediaQuery.sizeOf(context).width,
|
width: MediaQuery.sizeOf(context).width,
|
||||||
padding: state.planData.image != null ? null : const EdgeInsets.all(8),
|
padding: hasCover ? null : const EdgeInsets.all(8),
|
||||||
child: state.planData.image != null
|
child: selectedImage != null
|
||||||
? Padding(
|
? Padding(
|
||||||
padding: const EdgeInsets.only(top: 6, right: 6),
|
padding: const EdgeInsets.only(top: 6, right: 6),
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
@@ -131,6 +145,8 @@ class _PlanImagePickerWidgetState extends State<PlanImagePickerWidget> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
: hasCover
|
||||||
|
? const SizedBox.shrink()
|
||||||
: Stack(
|
: Stack(
|
||||||
children: [
|
children: [
|
||||||
DottedBorder(
|
DottedBorder(
|
||||||
|
|||||||
@@ -11,7 +11,13 @@ class CreatePlanRepository {
|
|||||||
}) async {
|
}) async {
|
||||||
final formData = FormData.fromMap(model);
|
final formData = FormData.fromMap(model);
|
||||||
return await createPlanService.createPlan(formData: formData);
|
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({
|
Future<void> createPlan({
|
||||||
@Body() required FormData formData,
|
@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/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/consultant_review_model.dart';
|
||||||
import 'package:eatvio_app/features/diary/models/nutrition_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';
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
part 'diary_state.freezed.dart';
|
part 'diary_state.freezed.dart';
|
||||||
@@ -12,7 +13,8 @@ abstract class DiaryState with _$DiaryState {
|
|||||||
|
|
||||||
const factory DiaryState.loading() = DiaryStateLoading;
|
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({
|
const factory DiaryState.loaded({
|
||||||
required DiaryDayEntity diary,
|
required DiaryDayEntity diary,
|
||||||
@@ -31,6 +33,19 @@ abstract class DiaryState with _$DiaryState {
|
|||||||
@Default(false) bool isLoadingAddingProductInMeal,
|
@Default(false) bool isLoadingAddingProductInMeal,
|
||||||
}) = DiaryStateLoaded;
|
}) = DiaryStateLoaded;
|
||||||
|
|
||||||
const factory DiaryState.loadedTomorrowWithDiet({required DiaryFutureDietDayEntity diary, required DateTime selectedDay, required int selectedMeal}) =
|
const factory DiaryState.loadedTomorrowWithDiet({
|
||||||
DiaryStateLoadedTomorrowWithDiet;
|
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) {
|
if (state is DiaryStateError) {
|
||||||
return Scaffold(
|
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(toolbarHeight: 0)
|
||||||
: AppBar(
|
: AppBar(
|
||||||
title: Text(
|
title: Text(
|
||||||
state is DiaryStateLoaded ? DateTime.parse(state.diary.date).toLocal().format('d MMMM') : '',
|
state is DiaryStateLoaded
|
||||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
? DateTime.parse(
|
||||||
|
state.diary.date,
|
||||||
|
).toLocal().format('d MMMM')
|
||||||
|
: '',
|
||||||
|
style: TextStyles.headingXLSemibold.copyWith(
|
||||||
|
color: appTheme.black,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
),
|
),
|
||||||
@@ -95,26 +106,39 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
if (widget.diaryId == null) CalendarWidget(date: widget.date),
|
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)
|
if (state is DiaryStateLoaded)
|
||||||
WakeupAndRetireWidget(
|
WakeupAndRetireWidget(
|
||||||
isEnabled: state.diary.typeOfDiary.hasChosenPlan || state.isConsultantView || state.isWakeUpAndRetireEnabled,
|
isEnabled:
|
||||||
disable: state.isConsultantView || state.isDiaryFinished || state.isDiaryCommented,
|
state.diary.typeOfDiary.hasChosenPlan ||
|
||||||
|
state.isConsultantView ||
|
||||||
|
state.isWakeUpAndRetireEnabled,
|
||||||
|
disable: !state.canEditDiary,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
if (state is DiaryStateLoaded)
|
if (state is DiaryStateLoaded)
|
||||||
MealWidget(
|
MealWidget(
|
||||||
showMood: state.diary.typeOfDiary.hasChosenPlan || state.isConsultantView || state.isMoodEnabled,
|
showMood:
|
||||||
showAddButton: !state.isConsultantView && !state.isDiaryFinished && !state.isDiaryCommented,
|
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(),
|
if (state is DiaryStateLoaded) const CaloriesChartWidget(),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Builder(
|
Builder(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
if (state is DiaryStateLoaded && state.diary.typeOfDiary.hasChosenPlan) {
|
if (state is DiaryStateLoaded &&
|
||||||
|
state.diary.typeOfDiary.hasChosenPlan) {
|
||||||
if (state.diary.comment.isNotEmpty) {
|
if (state.diary.comment.isNotEmpty) {
|
||||||
return CommentForConsultantWidget(comment: state.diary.comment);
|
return CommentForConsultantWidget(
|
||||||
|
comment: state.diary.comment,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (state.isDiaryCommented || state.isDiaryFinished) {
|
if (state.isDiaryCommented || state.isDiaryFinished) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
@@ -124,7 +148,10 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
|
|||||||
return const SizedBox.shrink();
|
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(
|
||||||
padding: const EdgeInsets.only(top: 12),
|
padding: const EdgeInsets.only(top: 12),
|
||||||
child: _isDiaryFinishing
|
child: _isDiaryFinishing
|
||||||
@@ -135,11 +162,13 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_isDiaryFinishing = true;
|
_isDiaryFinishing = true;
|
||||||
});
|
});
|
||||||
final isFinished = await context.read<DiaryCubit>().finishDiaryDay(
|
final isFinished = await context
|
||||||
diaryId: state.diary.id,
|
.read<DiaryCubit>()
|
||||||
wakeUp: state.diary.wakeup.toString(),
|
.finishDiaryDay(
|
||||||
retire: state.diary.retire.toString(),
|
diaryId: state.diary.id,
|
||||||
);
|
wakeUp: state.diary.wakeup.toString(),
|
||||||
|
retire: state.diary.retire.toString(),
|
||||||
|
);
|
||||||
if (!isFinished && context.mounted) {
|
if (!isFinished && context.mounted) {
|
||||||
showErrorDialog(context);
|
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 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),
|
const SizedBox(height: 12),
|
||||||
MainBackgroundBouble(
|
MainBackgroundBouble(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
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),
|
const SizedBox(height: 12),
|
||||||
CommentContainer(comment: state.consultantReview?.consultantComments.dayComment ?? ''),
|
CommentContainer(
|
||||||
|
comment:
|
||||||
|
state
|
||||||
|
.consultantReview
|
||||||
|
?.consultantComments
|
||||||
|
.dayComment ??
|
||||||
|
'',
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 28),
|
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),
|
const SizedBox(height: 16),
|
||||||
CustomOutlinedButton(
|
CustomOutlinedButton(
|
||||||
text: s.send,
|
text: s.send,
|
||||||
height: 52,
|
height: 52,
|
||||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
buttonTextStyle: TextStyles.textMSemibold.copyWith(
|
||||||
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
|
color: appTheme.whiteA700,
|
||||||
|
),
|
||||||
|
buttonStyle: ButtonStyle(
|
||||||
|
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||||
|
backgroundColor: WidgetStatePropertyAll(
|
||||||
|
appTheme.blueA400,
|
||||||
|
),
|
||||||
|
),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
await context.read<ConsultantCommentsCubit>().submitComments(state.diary.id);
|
await context
|
||||||
|
.read<ConsultantCommentsCubit>()
|
||||||
|
.submitComments(state.diary.id);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|||||||
@@ -70,42 +70,46 @@ class _AddProductModalState extends State<AddProductModal> {
|
|||||||
Row(
|
Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
Expanded(
|
||||||
width: MediaQuery.of(context).size.width * 0.76 - 32,
|
flex: 2,
|
||||||
child: CustomTextFormField(
|
child: SizedBox(
|
||||||
controller: productNameController,
|
child: CustomTextFormField(
|
||||||
autofocus: true,
|
controller: productNameController,
|
||||||
hintText: 'Введите название продукта/блюда',
|
autofocus: true,
|
||||||
onChanged: (query) {
|
hintText: 'Введите название продукта/блюда',
|
||||||
EasyDebounce.debounce('glossary-search', const Duration(milliseconds: 250), () async {
|
onChanged: (query) {
|
||||||
selectedGlossary = null;
|
EasyDebounce.debounce('glossary-search', const Duration(milliseconds: 250), () async {
|
||||||
if (query.isEmpty) {
|
selectedGlossary = null;
|
||||||
glossariesList = [];
|
if (query.isEmpty) {
|
||||||
|
glossariesList = [];
|
||||||
|
setState(() {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
glossariesList = await widget.diaryCubit.getGlossaryListByQuery(query);
|
||||||
setState(() {});
|
setState(() {});
|
||||||
return;
|
});
|
||||||
}
|
},
|
||||||
glossariesList = await widget.diaryCubit.getGlossaryListByQuery(query);
|
),
|
||||||
setState(() {});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
SizedBox(
|
Expanded(
|
||||||
width: MediaQuery.of(context).size.width * 0.24 - 4,
|
flex: 1,
|
||||||
child: CustomTextFormField(
|
child: SizedBox(
|
||||||
controller: quantityController,
|
child: CustomTextFormField(
|
||||||
keyboardType: TextInputType.number,
|
controller: quantityController,
|
||||||
hintText: selectedGlossary == null
|
keyboardType: TextInputType.number,
|
||||||
? '100'
|
hintText: selectedGlossary == null
|
||||||
: selectedGlossary!.grInPortion == 0
|
? '100'
|
||||||
? '100'
|
: selectedGlossary!.grInPortion == 0
|
||||||
: '1',
|
? '100'
|
||||||
suffixText: selectedGlossary == null
|
: '1',
|
||||||
? 'гр'
|
suffixText: selectedGlossary == null
|
||||||
: selectedGlossary?.grInPortion == 0
|
? 'гр'
|
||||||
? 'гр'
|
: selectedGlossary?.grInPortion == 0
|
||||||
: 'шт',
|
? 'гр'
|
||||||
|
: 'шт',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -168,66 +172,68 @@ class _AddProductModalState extends State<AddProductModal> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (widget.portion != null) const SizedBox(width: 8),
|
if (widget.portion != null) const SizedBox(width: 8),
|
||||||
InkWell(
|
Expanded(
|
||||||
onTap: () {
|
child: InkWell(
|
||||||
// Автоматически выбираем первый элемент, если текст точно совпадает
|
onTap: () {
|
||||||
if (selectedGlossary == null &&
|
// Автоматически выбираем первый элемент, если текст точно совпадает
|
||||||
glossariesList.isNotEmpty &&
|
if (selectedGlossary == null &&
|
||||||
productNameController.text.trim().toLowerCase() == glossariesList[0].name.toLowerCase()) {
|
glossariesList.isNotEmpty &&
|
||||||
selectedGlossary = glossariesList[0];
|
productNameController.text.trim().toLowerCase() == glossariesList[0].name.toLowerCase()) {
|
||||||
}
|
selectedGlossary = glossariesList[0];
|
||||||
|
}
|
||||||
|
|
||||||
if (widget.portion == null) {
|
if (widget.portion == null) {
|
||||||
widget.diaryCubit.addProductToMeal(
|
widget.diaryCubit.addProductToMeal(
|
||||||
diaryId: widget.state.diary.id,
|
diaryId: widget.state.diary.id,
|
||||||
mealId: widget.state.selectedMeal,
|
mealId: widget.state.selectedMeal,
|
||||||
productName: productNameController.text,
|
productName: productNameController.text,
|
||||||
portion: selectedGlossary == null
|
portion: selectedGlossary == null
|
||||||
? 'gr'
|
? 'gr'
|
||||||
: selectedGlossary!.grInPortion == 0
|
: selectedGlossary!.grInPortion == 0
|
||||||
? 'gr'
|
? 'gr'
|
||||||
: 'pcs',
|
: 'pcs',
|
||||||
quantity: int.parse(quantityController.text),
|
quantity: int.parse(quantityController.text),
|
||||||
productNutritionalValueId: selectedGlossary?.id,
|
productNutritionalValueId: selectedGlossary?.id,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
widget.diaryCubit.updateProductInMeal(
|
widget.diaryCubit.updateProductInMeal(
|
||||||
diaryId: widget.state.diary.id,
|
diaryId: widget.state.diary.id,
|
||||||
mealId: widget.state.selectedMeal,
|
mealId: widget.state.selectedMeal,
|
||||||
portionId: widget.portion!.id,
|
portionId: widget.portion!.id,
|
||||||
productName: productNameController.text,
|
productName: productNameController.text,
|
||||||
portion: selectedGlossary == null
|
portion: selectedGlossary == null
|
||||||
? 'gr'
|
? 'gr'
|
||||||
: selectedGlossary!.grInPortion == 0
|
: selectedGlossary!.grInPortion == 0
|
||||||
? 'gr'
|
? 'gr'
|
||||||
: 'pcs',
|
: 'pcs',
|
||||||
quantity: int.parse(quantityController.text),
|
quantity: int.parse(quantityController.text),
|
||||||
productNutritionalValueId: selectedGlossary?.id,
|
productNutritionalValueId: selectedGlossary?.id,
|
||||||
portionState: widget.portion!.portionState,
|
portionState: widget.portion!.portionState,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
if (selectedGlossary == null && _previousProductName != productNameController.text) {
|
if (selectedGlossary == null && _previousProductName != productNameController.text) {
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
useRootNavigator: true,
|
useRootNavigator: true,
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => Padding(
|
builder: (context) => Padding(
|
||||||
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||||
child: GlossaryModerationRequestModalWidget(productName: productNameController.text, diaryCubit: widget.diaryCubit),
|
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> {
|
class _CalendarWidgetState extends State<CalendarWidget> {
|
||||||
|
static const _kDaysOfWeekHeight = 16.0;
|
||||||
|
static const _kRowHeight = 52.0;
|
||||||
|
|
||||||
CalendarFormat _calendarFormat = CalendarFormat.week;
|
CalendarFormat _calendarFormat = CalendarFormat.week;
|
||||||
DateTime _focusedDay = DateTime.now();
|
DateTime _focusedDay = DateTime.now();
|
||||||
DateTime _selectedDay = DateTime.now();
|
DateTime _selectedDay = DateTime.now();
|
||||||
@@ -40,6 +43,8 @@ class _CalendarWidgetState extends State<CalendarWidget> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final s = S.of(context);
|
final s = S.of(context);
|
||||||
|
final textScaler = MediaQuery.textScalerOf(context);
|
||||||
|
final daysOfWeekHeight = _scaledDaysOfWeekHeight(context);
|
||||||
return BlocConsumer<DiaryCubit, DiaryState>(
|
return BlocConsumer<DiaryCubit, DiaryState>(
|
||||||
listener: (BuildContext context, DiaryState state) {
|
listener: (BuildContext context, DiaryState state) {
|
||||||
if (state is DiaryStateLoaded) {
|
if (state is DiaryStateLoaded) {
|
||||||
@@ -61,6 +66,8 @@ class _CalendarWidgetState extends State<CalendarWidget> {
|
|||||||
locale: 'ru_RU',
|
locale: 'ru_RU',
|
||||||
startingDayOfWeek: StartingDayOfWeek.monday,
|
startingDayOfWeek: StartingDayOfWeek.monday,
|
||||||
calendarFormat: _calendarFormat,
|
calendarFormat: _calendarFormat,
|
||||||
|
daysOfWeekHeight: daysOfWeekHeight,
|
||||||
|
rowHeight: textScaler.scale(_kRowHeight),
|
||||||
headerStyle: const HeaderStyle(titleCentered: true, formatButtonVisible: false, leftChevronVisible: false, rightChevronVisible: false),
|
headerStyle: const HeaderStyle(titleCentered: true, formatButtonVisible: false, leftChevronVisible: false, rightChevronVisible: false),
|
||||||
onDaySelected: (selectedDay, focusedDay) async {
|
onDaySelected: (selectedDay, focusedDay) async {
|
||||||
if (!isSameDay(_selectedDay, selectedDay)) {
|
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() {
|
void _nextCalendarFormat() {
|
||||||
setState(() {
|
setState(() {
|
||||||
_calendarFormat = CalendarFormat.values[(_calendarFormat.index - 1) % CalendarFormat.values.length];
|
_calendarFormat = CalendarFormat.values[(_calendarFormat.index - 1) % CalendarFormat.values.length];
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ class _MoodRatingWidgetState extends State<MoodRatingWidget> {
|
|||||||
final meals = state.diary.meals;
|
final meals = state.diary.meals;
|
||||||
if (selectedIndex >= 0 && selectedIndex < meals.length) {
|
if (selectedIndex >= 0 && selectedIndex < meals.length) {
|
||||||
if (isFirstFetch[selectedIndex]) {
|
if (isFirstFetch[selectedIndex]) {
|
||||||
currentSelectedRating[selectedIndex] = meals[selectedIndex].mood.toDouble() + 1;
|
currentSelectedRating[selectedIndex] =
|
||||||
|
meals[selectedIndex].mood.toDouble() + 1;
|
||||||
isFirstFetch[selectedIndex] = false;
|
isFirstFetch[selectedIndex] = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -36,21 +37,30 @@ class _MoodRatingWidgetState extends State<MoodRatingWidget> {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
state.isConsultantView ? 'Настроение пользователя после приема пищи' : 'Ваше настроение после приёма пищи',
|
state.isConsultantView
|
||||||
|
? 'Настроение пользователя после приема пищи'
|
||||||
|
: 'Ваше настроение после приёма пищи',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: const TextStyle(fontSize: 16),
|
style: const TextStyle(fontSize: 16),
|
||||||
),
|
),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
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(
|
RatingBar.builder(
|
||||||
ignoreGestures: state.isConsultantView || state.isDiaryFinished || state.isDiaryCommented,
|
ignoreGestures: !state.canEditDiary,
|
||||||
initialRating: (selectedIndex >= 0 && selectedIndex < state.diary.meals.length)
|
initialRating:
|
||||||
|
(selectedIndex >= 0 &&
|
||||||
|
selectedIndex < state.diary.meals.length)
|
||||||
? state.diary.meals[selectedIndex].mood.toDouble() + 1
|
? state.diary.meals[selectedIndex].mood.toDouble() + 1
|
||||||
: 3,
|
: 3,
|
||||||
itemCount: 6,
|
itemCount: 6,
|
||||||
itemSize: MediaQuery.of(context).size.width / 6 - (124 / 6),
|
itemSize:
|
||||||
|
MediaQuery.of(context).size.width / 6 - (124 / 6),
|
||||||
updateOnDrag: true,
|
updateOnDrag: true,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
switch (index) {
|
switch (index) {
|
||||||
@@ -58,82 +68,154 @@ class _MoodRatingWidgetState extends State<MoodRatingWidget> {
|
|||||||
return Container(
|
return Container(
|
||||||
width: MediaQuery.of(context).size.width / 6,
|
width: MediaQuery.of(context).size.width / 6,
|
||||||
height: 24,
|
height: 24,
|
||||||
margin: currentSelectedRating[selectedIndex] == 1 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
margin: currentSelectedRating[selectedIndex] == 1
|
||||||
|
? const EdgeInsets.symmetric(horizontal: 2)
|
||||||
|
: null,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFC82222),
|
color: const Color(0xFFC82222),
|
||||||
border: currentSelectedRating[selectedIndex] == 1
|
border:
|
||||||
? Border.all(width: 2, color: const Color(0xFFAA3333), strokeAlign: BorderSide.strokeAlignOutside)
|
currentSelectedRating[selectedIndex] == 1
|
||||||
|
? Border.all(
|
||||||
|
width: 2,
|
||||||
|
color: const Color(0xFFAA3333),
|
||||||
|
strokeAlign:
|
||||||
|
BorderSide.strokeAlignOutside,
|
||||||
|
)
|
||||||
: null,
|
: null,
|
||||||
borderRadius: currentSelectedRating[selectedIndex] == 1
|
borderRadius:
|
||||||
? const BorderRadius.horizontal(left: Radius.circular(8), right: Radius.circular(2))
|
currentSelectedRating[selectedIndex] == 1
|
||||||
: const BorderRadius.horizontal(left: Radius.circular(8)),
|
? const BorderRadius.horizontal(
|
||||||
|
left: Radius.circular(8),
|
||||||
|
right: Radius.circular(2),
|
||||||
|
)
|
||||||
|
: const BorderRadius.horizontal(
|
||||||
|
left: Radius.circular(8),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
case 1:
|
case 1:
|
||||||
return Container(
|
return Container(
|
||||||
width: MediaQuery.of(context).size.width / 6,
|
width: MediaQuery.of(context).size.width / 6,
|
||||||
height: 24,
|
height: 24,
|
||||||
margin: currentSelectedRating[selectedIndex] == 2 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
margin: currentSelectedRating[selectedIndex] == 2
|
||||||
|
? const EdgeInsets.symmetric(horizontal: 2)
|
||||||
|
: null,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFFF3E3E),
|
color: const Color(0xFFFF3E3E),
|
||||||
border: currentSelectedRating[selectedIndex] == 2
|
border:
|
||||||
? Border.all(width: 2, color: const Color(0xFF550000), strokeAlign: BorderSide.strokeAlignOutside)
|
currentSelectedRating[selectedIndex] == 2
|
||||||
|
? Border.all(
|
||||||
|
width: 2,
|
||||||
|
color: const Color(0xFF550000),
|
||||||
|
strokeAlign:
|
||||||
|
BorderSide.strokeAlignOutside,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
borderRadius:
|
||||||
|
currentSelectedRating[selectedIndex] == 2
|
||||||
|
? BorderRadius.circular(2)
|
||||||
: null,
|
: null,
|
||||||
borderRadius: currentSelectedRating[selectedIndex] == 2 ? BorderRadius.circular(2) : null,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
case 2:
|
case 2:
|
||||||
return Container(
|
return Container(
|
||||||
width: MediaQuery.of(context).size.width / 6,
|
width: MediaQuery.of(context).size.width / 6,
|
||||||
height: 24,
|
height: 24,
|
||||||
margin: currentSelectedRating[selectedIndex] == 3 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
margin: currentSelectedRating[selectedIndex] == 3
|
||||||
|
? const EdgeInsets.symmetric(horizontal: 2)
|
||||||
|
: null,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFF8C926),
|
color: const Color(0xFFF8C926),
|
||||||
border: currentSelectedRating[selectedIndex] == 3
|
border:
|
||||||
? Border.all(width: 2, color: const Color(0xFF8C5D00), strokeAlign: BorderSide.strokeAlignOutside)
|
currentSelectedRating[selectedIndex] == 3
|
||||||
|
? Border.all(
|
||||||
|
width: 2,
|
||||||
|
color: const Color(0xFF8C5D00),
|
||||||
|
strokeAlign:
|
||||||
|
BorderSide.strokeAlignOutside,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
borderRadius:
|
||||||
|
currentSelectedRating[selectedIndex] == 3
|
||||||
|
? BorderRadius.circular(2)
|
||||||
: null,
|
: null,
|
||||||
borderRadius: currentSelectedRating[selectedIndex] == 3 ? BorderRadius.circular(2) : null,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
case 3:
|
case 3:
|
||||||
return Container(
|
return Container(
|
||||||
width: MediaQuery.of(context).size.width / 6,
|
width: MediaQuery.of(context).size.width / 6,
|
||||||
height: 24,
|
height: 24,
|
||||||
margin: currentSelectedRating[selectedIndex] == 4 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
margin: currentSelectedRating[selectedIndex] == 4
|
||||||
|
? const EdgeInsets.symmetric(horizontal: 2)
|
||||||
|
: null,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFFFF3BF),
|
color: const Color(0xFFFFF3BF),
|
||||||
border: currentSelectedRating[selectedIndex] == 4
|
border:
|
||||||
? Border.all(width: 2, color: const Color(0xFFB5911B), strokeAlign: BorderSide.strokeAlignOutside)
|
currentSelectedRating[selectedIndex] == 4
|
||||||
|
? Border.all(
|
||||||
|
width: 2,
|
||||||
|
color: const Color(0xFFB5911B),
|
||||||
|
strokeAlign:
|
||||||
|
BorderSide.strokeAlignOutside,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
borderRadius:
|
||||||
|
currentSelectedRating[selectedIndex] == 4
|
||||||
|
? BorderRadius.circular(2)
|
||||||
: null,
|
: null,
|
||||||
borderRadius: currentSelectedRating[selectedIndex] == 4 ? BorderRadius.circular(2) : null,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
case 4:
|
case 4:
|
||||||
return Container(
|
return Container(
|
||||||
width: MediaQuery.of(context).size.width / 6,
|
width: MediaQuery.of(context).size.width / 6,
|
||||||
height: 24,
|
height: 24,
|
||||||
margin: currentSelectedRating[selectedIndex] == 5 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
margin: currentSelectedRating[selectedIndex] == 5
|
||||||
|
? const EdgeInsets.symmetric(horizontal: 2)
|
||||||
|
: null,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFF62E833),
|
color: const Color(0xFF62E833),
|
||||||
border: currentSelectedRating[selectedIndex] == 5
|
border:
|
||||||
? Border.all(width: 2, color: const Color(0xFF276918), strokeAlign: BorderSide.strokeAlignOutside)
|
currentSelectedRating[selectedIndex] == 5
|
||||||
|
? Border.all(
|
||||||
|
width: 2,
|
||||||
|
color: const Color(0xFF276918),
|
||||||
|
strokeAlign:
|
||||||
|
BorderSide.strokeAlignOutside,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
borderRadius:
|
||||||
|
currentSelectedRating[selectedIndex] == 5
|
||||||
|
? BorderRadius.circular(2)
|
||||||
: null,
|
: null,
|
||||||
borderRadius: currentSelectedRating[selectedIndex] == 5 ? BorderRadius.circular(2) : null,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
return Container(
|
return Container(
|
||||||
width: MediaQuery.of(context).size.width / 6,
|
width: MediaQuery.of(context).size.width / 6,
|
||||||
height: 24,
|
height: 24,
|
||||||
margin: currentSelectedRating[selectedIndex] == 6 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
margin: currentSelectedRating[selectedIndex] == 6
|
||||||
|
? const EdgeInsets.symmetric(horizontal: 2)
|
||||||
|
: null,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFF4DCE20),
|
color: const Color(0xFF4DCE20),
|
||||||
border: currentSelectedRating[selectedIndex] == 6
|
border:
|
||||||
? Border.all(width: 2, color: const Color(0xFF184D12), strokeAlign: BorderSide.strokeAlignOutside)
|
currentSelectedRating[selectedIndex] == 6
|
||||||
|
? Border.all(
|
||||||
|
width: 2,
|
||||||
|
color: const Color(0xFF184D12),
|
||||||
|
strokeAlign:
|
||||||
|
BorderSide.strokeAlignOutside,
|
||||||
|
)
|
||||||
: null,
|
: null,
|
||||||
borderRadius: currentSelectedRating[selectedIndex] == 6
|
borderRadius:
|
||||||
? const BorderRadius.horizontal(left: Radius.circular(2), right: Radius.circular(8))
|
currentSelectedRating[selectedIndex] == 6
|
||||||
: const BorderRadius.horizontal(right: Radius.circular(8)),
|
? 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(() {
|
setState(() {
|
||||||
currentSelectedRating[selectedIndex] = value;
|
currentSelectedRating[selectedIndex] = value;
|
||||||
});
|
});
|
||||||
EasyDebounce.debounce('change-satiety', const Duration(milliseconds: 250), () {
|
EasyDebounce.debounce(
|
||||||
context.read<DiaryCubit>().fillMood(diaryId: state.diary.id, mood: currentSelectedRating[selectedIndex].toInt() - 1);
|
'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();
|
AppRouter.navigatorKey.currentContext?.pop();
|
||||||
|
|
||||||
final picker = ImagePicker();
|
final picker = ImagePicker();
|
||||||
XFile? image = await picker.pickImage(source: ImageSource.camera);
|
XFile? image = await picker.pickImage(
|
||||||
|
source: ImageSource.camera,
|
||||||
|
);
|
||||||
if (image != null) {
|
if (image != null) {
|
||||||
diaryCubit.uploadPhotos(
|
diaryCubit.uploadPhotos(
|
||||||
files: [File(image.path)],
|
files: [File(image.path)],
|
||||||
@@ -116,7 +118,8 @@ class _PhotoGalleryWidgetState extends State<PhotoGalleryWidget> {
|
|||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
if (state is DiaryStateLoaded) {
|
if (state is DiaryStateLoaded) {
|
||||||
int mealIndex = state.selectedMeal - 1;
|
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(
|
return GestureDetector(
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
showImagePicker(state.diary);
|
showImagePicker(state.diary);
|
||||||
@@ -140,10 +143,11 @@ class _PhotoGalleryWidgetState extends State<PhotoGalleryWidget> {
|
|||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: CachedNetworkImage(
|
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(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
child: IconButton.filled(
|
child: IconButton.filled(
|
||||||
@@ -153,12 +157,24 @@ class _PhotoGalleryWidgetState extends State<PhotoGalleryWidget> {
|
|||||||
),
|
),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
await diaryCubit.deletePhoto(
|
await diaryCubit.deletePhoto(
|
||||||
photo: state.diary.meals[mealIndex].photos[currentSelectedPhotoIndex],
|
photo: state
|
||||||
|
.diary
|
||||||
|
.meals[mealIndex]
|
||||||
|
.photos[currentSelectedPhotoIndex],
|
||||||
diaryId: state.diary.id,
|
diaryId: state.diary.id,
|
||||||
);
|
);
|
||||||
setState(() {
|
setState(() {
|
||||||
if (currentSelectedPhotoIndex == state.diary.meals[mealIndex].photos.length - 1 && currentSelectedPhotoIndex != 0) {
|
if (currentSelectedPhotoIndex ==
|
||||||
currentSelectedPhotoIndex = state.diary.meals[mealIndex].photos.length - 2;
|
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,
|
mainAxisSize: MainAxisSize.min,
|
||||||
mainAxisAlignment: MainAxisAlignment.start,
|
mainAxisAlignment: MainAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
for (int i = 0; i < state.diary.meals[mealIndex].photos.length + 1; i++) ...[
|
for (
|
||||||
if (i == state.diary.meals[mealIndex].photos.length && !state.isConsultantView && !state.isDiaryFinished && !state.isDiaryCommented)
|
int i = 0;
|
||||||
|
i < state.diary.meals[mealIndex].photos.length + 1;
|
||||||
|
i++
|
||||||
|
) ...[
|
||||||
|
if (i == state.diary.meals[mealIndex].photos.length &&
|
||||||
|
state.canEditDiary)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(left: 8),
|
padding: const EdgeInsets.only(left: 8),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
@@ -212,13 +233,15 @@ class _PhotoGalleryWidgetState extends State<PhotoGalleryWidget> {
|
|||||||
height: 52,
|
height: 52,
|
||||||
width: 52,
|
width: 52,
|
||||||
child: CachedNetworkImage(
|
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,
|
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:collection/collection.dart';
|
||||||
import 'package:dotted_border/dotted_border.dart';
|
import 'package:dotted_border/dotted_border.dart';
|
||||||
import 'package:eatvio_app/core/theme/theme_helper.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/consultant_review_model.dart';
|
||||||
import 'package:eatvio_app/features/diary/models/diary_day_model.dart';
|
import 'package:eatvio_app/features/diary/models/diary_day_model.dart';
|
||||||
import 'package:eatvio_app/features/diary/presentation/bloc/diary_cubit.dart';
|
import 'package:eatvio_app/features/diary/presentation/bloc/diary_cubit.dart';
|
||||||
@@ -32,17 +30,26 @@ class _ProductWidgetState extends State<ProductWidget> {
|
|||||||
super.initState();
|
super.initState();
|
||||||
diaryCubit = context.read<DiaryCubit>();
|
diaryCubit = context.read<DiaryCubit>();
|
||||||
portionName = widget.portion.portion == 'gr' ? 'гр' : 'шт';
|
portionName = widget.portion.portion == 'gr' ? 'гр' : 'шт';
|
||||||
isEaten = widget.portion.portionState == PortionState.eatenFromDiet ? true : false;
|
isEaten = widget.portion.portionState == PortionState.eatenFromDiet
|
||||||
|
? true
|
||||||
|
: false;
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildNutritionalInfo() {
|
Widget _buildNutritionalInfo() {
|
||||||
if (widget.portion.productNutritionalValueId == null) {
|
if (widget.portion.productNutritionalValueId == null) {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
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(
|
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;
|
calories = (caloriesInfo.calories * widget.portion.quantity) / 100;
|
||||||
proteins = (caloriesInfo.proteins * widget.portion.quantity) / 100;
|
proteins = (caloriesInfo.proteins * widget.portion.quantity) / 100;
|
||||||
fats = (caloriesInfo.fats * 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 {
|
} else {
|
||||||
calories = caloriesInfo.energyOnPortion;
|
calories = caloriesInfo.energyOnPortion;
|
||||||
proteins = caloriesInfo.proteinsOnPortion;
|
proteins = caloriesInfo.proteinsOnPortion;
|
||||||
@@ -81,10 +89,17 @@ class _ProductWidgetState extends State<ProductWidget> {
|
|||||||
Widget _buildNutritionChip(String label, int value) {
|
Widget _buildNutritionChip(String label, int value) {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
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(
|
child: Text(
|
||||||
'$label $value',
|
'$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) {
|
void didUpdateWidget(ProductWidget oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
portionName = widget.portion.portion == 'gr' ? 'гр' : 'шт';
|
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) {
|
Future updateProductInMeal(DiaryStateLoaded state, PortionModel portion) {
|
||||||
return showModalBottomSheet(
|
return showModalBottomSheet(
|
||||||
useRootNavigator: true,
|
useRootNavigator: true,
|
||||||
context: context,
|
context: context,
|
||||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(12))),
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
|
||||||
|
),
|
||||||
isScrollControlled: true,
|
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) {
|
if (state is DiaryStateLoadedTomorrowWithDiet) {
|
||||||
return ListTile(
|
return ListTile(
|
||||||
horizontalTitleGap: 0,
|
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),
|
title: Text(widget.portion.productName),
|
||||||
subtitle: Column(
|
subtitle: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text('${widget.portion.quantity} ${widget.portion.portion == 'gr' ? 'гр' : 'шт'}'),
|
Text(
|
||||||
|
'${widget.portion.quantity} ${widget.portion.portion == 'gr' ? 'гр' : 'шт'}',
|
||||||
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
_buildNutritionalInfo(),
|
_buildNutritionalInfo(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
tileColor: const Color(0xFFF6FAFF),
|
tileColor: const Color(0xFFF6FAFF),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (state is DiaryStateLoaded) {
|
if (state is DiaryStateLoaded) {
|
||||||
final isDiaryCommented =
|
final canEditPortion = state.canEditDiary && !widget.portion.fromDiet;
|
||||||
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 consultantComments = state.consultantReview?.consultantComments;
|
final consultantComments = state.consultantReview?.consultantComments;
|
||||||
final canMarkAsEaten = !state.isConsultantView && !isDiaryCommented && !isDiaryFinished;
|
final canMarkAsEaten = state.canEditDiary;
|
||||||
PortionComment? portionComment;
|
PortionComment? portionComment;
|
||||||
if (consultantComments != null) {
|
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) {
|
if (widget.portion.portionState == PortionState.eatenDeleted) {
|
||||||
return DottedBorder(
|
return DottedBorder(
|
||||||
@@ -151,27 +177,41 @@ class _ProductWidgetState extends State<ProductWidget> {
|
|||||||
color: Color(0xFF6D6F71),
|
color: Color(0xFF6D6F71),
|
||||||
),
|
),
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
onTap: () => diaryCubit.restore(diaryId: state.diary.id, mealId: state.selectedMeal, portionId: widget.portion.id),
|
onTap: state.canEditDiary
|
||||||
trailing: SvgPicture.asset('assets/icons/revert_product.svg'),
|
? () => 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,
|
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),
|
title: Text(widget.portion.productName),
|
||||||
subtitle: Column(
|
subtitle: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text('${widget.portion.quantity} ${widget.portion.portion == 'gr' ? 'гр' : 'шт'}'),
|
Text(
|
||||||
|
'${widget.portion.quantity} ${widget.portion.portion == 'gr' ? 'гр' : 'шт'}',
|
||||||
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
_buildNutritionalInfo(),
|
_buildNutritionalInfo(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
// ignore: use_full_hex_values_for_flutter_colors
|
// ignore: use_full_hex_values_for_flutter_colors
|
||||||
tileColor: const Color(0xfff6faff03).withValues(alpha: 0.01),
|
tileColor: const Color(0xfff6faff03).withValues(alpha: 0.01),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
Widget? trailing;
|
Widget? trailing;
|
||||||
if (state.isConsultantView && !isDiaryCommented) {
|
if (state.isConsultantView && !state.isDiaryCommented) {
|
||||||
trailing = GestureDetector(
|
trailing = GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -180,13 +220,15 @@ class _ProductWidgetState extends State<ProductWidget> {
|
|||||||
},
|
},
|
||||||
child: SvgPicture.asset('assets/icons/edit.svg'),
|
child: SvgPicture.asset('assets/icons/edit.svg'),
|
||||||
);
|
);
|
||||||
} else if (!isDiaryCommented && !widget.portion.fromDiet) {
|
} else if (canEditPortion) {
|
||||||
trailing = const Icon(Icons.more_vert_rounded);
|
trailing = const Icon(Icons.more_vert_rounded);
|
||||||
}
|
}
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
ListTile(
|
ListTile(
|
||||||
onTap: canEditPortion ? () => updateProductInMeal(state, widget.portion) : null,
|
onTap: canEditPortion
|
||||||
|
? () => updateProductInMeal(state, widget.portion)
|
||||||
|
: null,
|
||||||
trailing: trailing,
|
trailing: trailing,
|
||||||
leading: widget.portion.fromDiet
|
leading: widget.portion.fromDiet
|
||||||
? Transform.scale(
|
? Transform.scale(
|
||||||
@@ -199,35 +241,55 @@ class _ProductWidgetState extends State<ProductWidget> {
|
|||||||
isEaten = value!;
|
isEaten = value!;
|
||||||
});
|
});
|
||||||
if (isEaten) {
|
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 {
|
} 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,
|
: null,
|
||||||
activeColor: appTheme.blueA400,
|
activeColor: appTheme.blueA400,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
side: const BorderSide(color: Color(0xFF809CB9)),
|
side: const BorderSide(color: Color(0xFF809CB9)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
horizontalTitleGap: 0,
|
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),
|
title: Text(widget.portion.productName),
|
||||||
subtitle: Column(
|
subtitle: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text('${widget.portion.quantity} ${widget.portion.portion == 'gr' ? 'гр' : 'шт'}'),
|
Text(
|
||||||
|
'${widget.portion.quantity} ${widget.portion.portion == 'gr' ? 'гр' : 'шт'}',
|
||||||
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
_buildNutritionalInfo(),
|
_buildNutritionalInfo(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
tileColor: const Color(0xFFF6FAFF),
|
tileColor: const Color(0xFFF6FAFF),
|
||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 8.0),
|
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;
|
final meals = state.diary.meals;
|
||||||
if (selectedIndex >= 0 && selectedIndex < meals.length) {
|
if (selectedIndex >= 0 && selectedIndex < meals.length) {
|
||||||
if (isFirstFetch[selectedIndex]) {
|
if (isFirstFetch[selectedIndex]) {
|
||||||
currentSelectedRating[selectedIndex] = meals[selectedIndex].satiety.toDouble() + 1;
|
currentSelectedRating[selectedIndex] =
|
||||||
|
meals[selectedIndex].satiety.toDouble() + 1;
|
||||||
isFirstFetch[selectedIndex] = false;
|
isFirstFetch[selectedIndex] = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -36,21 +37,32 @@ class _SatietyRatingWidgetState extends State<SatietyRatingWidget> {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
state.isConsultantView ? 'Сытость пользователя после приема пищи' : 'Ваша сытость после приёма пищи',
|
state.isConsultantView
|
||||||
|
? 'Сытость пользователя после приема пищи'
|
||||||
|
: 'Ваша сытость после приёма пищи',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: const TextStyle(fontSize: 16),
|
style: const TextStyle(fontSize: 16),
|
||||||
),
|
),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
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(
|
RatingBar.builder(
|
||||||
ignoreGestures: state.isConsultantView || state.isDiaryFinished || state.isDiaryCommented,
|
ignoreGestures: !state.canEditDiary,
|
||||||
initialRating: (selectedIndex >= 0 && selectedIndex < state.diary.meals.length)
|
initialRating:
|
||||||
? state.diary.meals[selectedIndex].satiety.toDouble() + 1
|
(selectedIndex >= 0 &&
|
||||||
|
selectedIndex < state.diary.meals.length)
|
||||||
|
? state.diary.meals[selectedIndex].satiety
|
||||||
|
.toDouble() +
|
||||||
|
1
|
||||||
: 3,
|
: 3,
|
||||||
itemCount: 6,
|
itemCount: 6,
|
||||||
itemSize: MediaQuery.of(context).size.width / 6 - (124 / 6),
|
itemSize:
|
||||||
|
MediaQuery.of(context).size.width / 6 - (124 / 6),
|
||||||
updateOnDrag: true,
|
updateOnDrag: true,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
switch (index) {
|
switch (index) {
|
||||||
@@ -58,82 +70,154 @@ class _SatietyRatingWidgetState extends State<SatietyRatingWidget> {
|
|||||||
return Container(
|
return Container(
|
||||||
width: MediaQuery.of(context).size.width / 6,
|
width: MediaQuery.of(context).size.width / 6,
|
||||||
height: 24,
|
height: 24,
|
||||||
margin: currentSelectedRating[selectedIndex] == 1 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
margin: currentSelectedRating[selectedIndex] == 1
|
||||||
|
? const EdgeInsets.symmetric(horizontal: 2)
|
||||||
|
: null,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFC82222),
|
color: const Color(0xFFC82222),
|
||||||
border: currentSelectedRating[selectedIndex] == 1
|
border:
|
||||||
? Border.all(width: 2, color: const Color(0xFFAA3333), strokeAlign: BorderSide.strokeAlignOutside)
|
currentSelectedRating[selectedIndex] == 1
|
||||||
|
? Border.all(
|
||||||
|
width: 2,
|
||||||
|
color: const Color(0xFFAA3333),
|
||||||
|
strokeAlign:
|
||||||
|
BorderSide.strokeAlignOutside,
|
||||||
|
)
|
||||||
: null,
|
: null,
|
||||||
borderRadius: currentSelectedRating[selectedIndex] == 1
|
borderRadius:
|
||||||
? const BorderRadius.horizontal(left: Radius.circular(8), right: Radius.circular(2))
|
currentSelectedRating[selectedIndex] == 1
|
||||||
: const BorderRadius.horizontal(left: Radius.circular(8)),
|
? const BorderRadius.horizontal(
|
||||||
|
left: Radius.circular(8),
|
||||||
|
right: Radius.circular(2),
|
||||||
|
)
|
||||||
|
: const BorderRadius.horizontal(
|
||||||
|
left: Radius.circular(8),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
case 1:
|
case 1:
|
||||||
return Container(
|
return Container(
|
||||||
width: MediaQuery.of(context).size.width / 6,
|
width: MediaQuery.of(context).size.width / 6,
|
||||||
height: 24,
|
height: 24,
|
||||||
margin: currentSelectedRating[selectedIndex] == 2 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
margin: currentSelectedRating[selectedIndex] == 2
|
||||||
|
? const EdgeInsets.symmetric(horizontal: 2)
|
||||||
|
: null,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFFF3E3E),
|
color: const Color(0xFFFF3E3E),
|
||||||
border: currentSelectedRating[selectedIndex] == 2
|
border:
|
||||||
? Border.all(width: 2, color: const Color(0xFF550000), strokeAlign: BorderSide.strokeAlignOutside)
|
currentSelectedRating[selectedIndex] == 2
|
||||||
|
? Border.all(
|
||||||
|
width: 2,
|
||||||
|
color: const Color(0xFF550000),
|
||||||
|
strokeAlign:
|
||||||
|
BorderSide.strokeAlignOutside,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
borderRadius:
|
||||||
|
currentSelectedRating[selectedIndex] == 2
|
||||||
|
? BorderRadius.circular(2)
|
||||||
: null,
|
: null,
|
||||||
borderRadius: currentSelectedRating[selectedIndex] == 2 ? BorderRadius.circular(2) : null,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
case 2:
|
case 2:
|
||||||
return Container(
|
return Container(
|
||||||
width: MediaQuery.of(context).size.width / 6,
|
width: MediaQuery.of(context).size.width / 6,
|
||||||
height: 24,
|
height: 24,
|
||||||
margin: currentSelectedRating[selectedIndex] == 3 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
margin: currentSelectedRating[selectedIndex] == 3
|
||||||
|
? const EdgeInsets.symmetric(horizontal: 2)
|
||||||
|
: null,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFF8C926),
|
color: const Color(0xFFF8C926),
|
||||||
border: currentSelectedRating[selectedIndex] == 3
|
border:
|
||||||
? Border.all(width: 2, color: const Color(0xFF8C5D00), strokeAlign: BorderSide.strokeAlignOutside)
|
currentSelectedRating[selectedIndex] == 3
|
||||||
|
? Border.all(
|
||||||
|
width: 2,
|
||||||
|
color: const Color(0xFF8C5D00),
|
||||||
|
strokeAlign:
|
||||||
|
BorderSide.strokeAlignOutside,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
borderRadius:
|
||||||
|
currentSelectedRating[selectedIndex] == 3
|
||||||
|
? BorderRadius.circular(2)
|
||||||
: null,
|
: null,
|
||||||
borderRadius: currentSelectedRating[selectedIndex] == 3 ? BorderRadius.circular(2) : null,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
case 3:
|
case 3:
|
||||||
return Container(
|
return Container(
|
||||||
width: MediaQuery.of(context).size.width / 6,
|
width: MediaQuery.of(context).size.width / 6,
|
||||||
height: 24,
|
height: 24,
|
||||||
margin: currentSelectedRating[selectedIndex] == 4 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
margin: currentSelectedRating[selectedIndex] == 4
|
||||||
|
? const EdgeInsets.symmetric(horizontal: 2)
|
||||||
|
: null,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFFFF3BF),
|
color: const Color(0xFFFFF3BF),
|
||||||
border: currentSelectedRating[selectedIndex] == 4
|
border:
|
||||||
? Border.all(width: 2, color: const Color(0xFFB5911B), strokeAlign: BorderSide.strokeAlignOutside)
|
currentSelectedRating[selectedIndex] == 4
|
||||||
|
? Border.all(
|
||||||
|
width: 2,
|
||||||
|
color: const Color(0xFFB5911B),
|
||||||
|
strokeAlign:
|
||||||
|
BorderSide.strokeAlignOutside,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
borderRadius:
|
||||||
|
currentSelectedRating[selectedIndex] == 4
|
||||||
|
? BorderRadius.circular(2)
|
||||||
: null,
|
: null,
|
||||||
borderRadius: currentSelectedRating[selectedIndex] == 4 ? BorderRadius.circular(2) : null,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
case 4:
|
case 4:
|
||||||
return Container(
|
return Container(
|
||||||
width: MediaQuery.of(context).size.width / 6,
|
width: MediaQuery.of(context).size.width / 6,
|
||||||
height: 24,
|
height: 24,
|
||||||
margin: currentSelectedRating[selectedIndex] == 5 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
margin: currentSelectedRating[selectedIndex] == 5
|
||||||
|
? const EdgeInsets.symmetric(horizontal: 2)
|
||||||
|
: null,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFF62E833),
|
color: const Color(0xFF62E833),
|
||||||
border: currentSelectedRating[selectedIndex] == 5
|
border:
|
||||||
? Border.all(width: 2, color: const Color(0xFF276918), strokeAlign: BorderSide.strokeAlignOutside)
|
currentSelectedRating[selectedIndex] == 5
|
||||||
|
? Border.all(
|
||||||
|
width: 2,
|
||||||
|
color: const Color(0xFF276918),
|
||||||
|
strokeAlign:
|
||||||
|
BorderSide.strokeAlignOutside,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
borderRadius:
|
||||||
|
currentSelectedRating[selectedIndex] == 5
|
||||||
|
? BorderRadius.circular(2)
|
||||||
: null,
|
: null,
|
||||||
borderRadius: currentSelectedRating[selectedIndex] == 5 ? BorderRadius.circular(2) : null,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
return Container(
|
return Container(
|
||||||
width: MediaQuery.of(context).size.width / 6,
|
width: MediaQuery.of(context).size.width / 6,
|
||||||
height: 24,
|
height: 24,
|
||||||
margin: currentSelectedRating[selectedIndex] == 6 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
margin: currentSelectedRating[selectedIndex] == 6
|
||||||
|
? const EdgeInsets.symmetric(horizontal: 2)
|
||||||
|
: null,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFF4DCE20),
|
color: const Color(0xFF4DCE20),
|
||||||
border: currentSelectedRating[selectedIndex] == 6
|
border:
|
||||||
? Border.all(width: 2, color: const Color(0xFF184D12), strokeAlign: BorderSide.strokeAlignOutside)
|
currentSelectedRating[selectedIndex] == 6
|
||||||
|
? Border.all(
|
||||||
|
width: 2,
|
||||||
|
color: const Color(0xFF184D12),
|
||||||
|
strokeAlign:
|
||||||
|
BorderSide.strokeAlignOutside,
|
||||||
|
)
|
||||||
: null,
|
: null,
|
||||||
borderRadius: currentSelectedRating[selectedIndex] == 6
|
borderRadius:
|
||||||
? const BorderRadius.horizontal(left: Radius.circular(2), right: Radius.circular(8))
|
currentSelectedRating[selectedIndex] == 6
|
||||||
: const BorderRadius.horizontal(right: Radius.circular(8)),
|
? 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(() {
|
setState(() {
|
||||||
currentSelectedRating[selectedIndex] = value;
|
currentSelectedRating[selectedIndex] = value;
|
||||||
});
|
});
|
||||||
EasyDebounce.debounce('change-satiety', const Duration(milliseconds: 250), () {
|
EasyDebounce.debounce(
|
||||||
context.read<DiaryCubit>().fillSatiety(diaryId: state.diary.id, satiety: currentSelectedRating[selectedIndex].toInt() - 1);
|
'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();
|
Navigator.of(context).pop();
|
||||||
diaryCubit.fillTime(
|
diaryCubit.fillTime(
|
||||||
diaryId: diaryId,
|
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),
|
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(
|
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),
|
borderRadius: BorderRadius.circular(100),
|
||||||
child: Ink(
|
child: Ink(
|
||||||
height: 32,
|
height: 32,
|
||||||
@@ -110,7 +113,16 @@ class _TimeOfMealPickerWidgetState extends State<TimeOfMealPickerWidget> {
|
|||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Text(
|
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(
|
style: const TextStyle(
|
||||||
color: Color(0xFF6D6F71),
|
color: Color(0xFF6D6F71),
|
||||||
fontSize: 17,
|
fontSize: 17,
|
||||||
|
|||||||
@@ -296,6 +296,7 @@ class _AboutDietScreenState extends State<AboutDietScreen> {
|
|||||||
actions: [
|
actions: [
|
||||||
CustomPopup(
|
CustomPopup(
|
||||||
isAuthor: dietState.diet.author.authorIsMe,
|
isAuthor: dietState.diet.author.authorIsMe,
|
||||||
|
isAuthorAdmin: dietState.diet.author.isAdmin,
|
||||||
path: 'diet/${dietState.diet.slug}',
|
path: 'diet/${dietState.diet.slug}',
|
||||||
complainedContentType: ComplainedContent.diet,
|
complainedContentType: ComplainedContent.diet,
|
||||||
id: dietState.diet.id,
|
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/diets/presentation/widgets/info_bouble.dart';
|
||||||
import 'package:eatvio_app/features/feed/models/diets_pagination_model.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/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/core/theme/theme_helper.dart';
|
||||||
import 'package:eatvio_app/features/profile/models/profile_model.dart';
|
import 'package:eatvio_app/features/profile/models/profile_model.dart';
|
||||||
import 'package:eatvio_app/features/user_details/presentation/user_details_screen.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';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
class AuthorCard extends StatelessWidget {
|
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 Author author;
|
||||||
final String title;
|
final String title;
|
||||||
final Color? backgroundColor;
|
final Color? backgroundColor;
|
||||||
@@ -21,15 +29,28 @@ class AuthorCard extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () {
|
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(
|
child: Container(
|
||||||
width: MediaQuery.sizeOf(context).width,
|
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(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
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(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
ListTile(
|
ListTile(
|
||||||
@@ -41,28 +62,43 @@ class AuthorCard extends StatelessWidget {
|
|||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
image: DecorationImage(
|
image: DecorationImage(
|
||||||
image: NetworkImage(
|
image: NetworkImage(
|
||||||
customerProfile != null
|
joinBaseUrl(
|
||||||
? customerProfile!.profilePhotoUrl.contains('https')
|
sl<AppConfig>().baseUrl,
|
||||||
? customerProfile!.profilePhotoUrl
|
customerProfile != null
|
||||||
: sl<AppConfig>().baseUrl + customerProfile!.profilePhotoUrl
|
? customerProfile!.profilePhotoUrl
|
||||||
: author.authorProfileIcon.contains('https')
|
: author.authorProfileIcon,
|
||||||
? author.authorProfileIcon
|
),
|
||||||
: sl<AppConfig>().baseUrl + author.authorProfileIcon,
|
|
||||||
),
|
),
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
customerProfile != null ? customerProfile!.name : author.authorName,
|
customerProfile != null
|
||||||
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
|
? 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(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
if (customerProfile == null) ...[
|
if (customerProfile == null) ...[
|
||||||
Text('Консультант', style: TextStyles.textMMedium.copyWith(color: appTheme.black)),
|
Text(
|
||||||
|
'Консультант',
|
||||||
|
style: TextStyles.textMMedium.copyWith(
|
||||||
|
color: appTheme.black,
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
|
|
||||||
if (author.authorPlaceInRating != null)
|
if (author.authorPlaceInRating != null)
|
||||||
@@ -70,20 +106,35 @@ class AuthorCard extends StatelessWidget {
|
|||||||
radius: 12,
|
radius: 12,
|
||||||
backgroundColor: appTheme.cardBackground,
|
backgroundColor: appTheme.cardBackground,
|
||||||
children: [
|
children: [
|
||||||
Text('${author.authorPlaceInRating}-й в рейтинге', style: TextStyles.textMMediumAuto.copyWith(color: appTheme.blueA400)),
|
Text(
|
||||||
|
'${author.authorPlaceInRating}-й в рейтинге',
|
||||||
|
style: TextStyles.textMMediumAuto.copyWith(
|
||||||
|
color: appTheme.blueA400,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
] else
|
] else
|
||||||
Expanded(
|
Expanded(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
GoRouter.of(context).pushNamed(UserDetailsScreen.name, extra: customerProfile!.slug);
|
GoRouter.of(context).pushNamed(
|
||||||
|
UserDetailsScreen.name,
|
||||||
|
extra: customerProfile!.slug,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
child: InfoBouble(
|
child: InfoBouble(
|
||||||
radius: 12,
|
radius: 12,
|
||||||
backgroundColor: appTheme.cardBackground,
|
backgroundColor: appTheme.cardBackground,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
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(
|
child: Container(
|
||||||
width: 28,
|
width: 28,
|
||||||
height: 28,
|
height: 28,
|
||||||
decoration: BoxDecoration(color: appTheme.whiteA700, borderRadius: BorderRadius.circular(8)),
|
decoration: BoxDecoration(
|
||||||
child: Center(child: SvgPicture.asset('assets/icons/arrow.svg', width: 12, height: 12, fit: BoxFit.none)),
|
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';
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
|
||||||
class MoreOptions extends StatelessWidget {
|
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 ComplainedContent? complainedContentType;
|
||||||
final int? id;
|
final int? id;
|
||||||
final String? slug;
|
final String? slug;
|
||||||
final String path;
|
final String path;
|
||||||
final bool? disableActions;
|
final bool? disableActions;
|
||||||
|
final bool isAuthorAdmin;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -83,40 +92,42 @@ class MoreOptions extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Padding(
|
if (!isAuthorAdmin) ...[
|
||||||
padding: EdgeInsets.symmetric(vertical: 12.0),
|
const Padding(
|
||||||
child: CustomDivider(),
|
padding: EdgeInsets.symmetric(vertical: 12.0),
|
||||||
),
|
child: CustomDivider(),
|
||||||
GestureDetector(
|
),
|
||||||
onTap: disableActions == true
|
GestureDetector(
|
||||||
? null
|
onTap: disableActions == true
|
||||||
: () {
|
? null
|
||||||
if (complainedContentType != null) {
|
: () {
|
||||||
showModalBottomSheet(
|
if (complainedContentType != null) {
|
||||||
context: context,
|
showModalBottomSheet(
|
||||||
isScrollControlled: true,
|
context: context,
|
||||||
builder: (context) => ComplainingBottomSheet(
|
isScrollControlled: true,
|
||||||
complainedContentType: complainedContentType!,
|
builder: (context) => ComplainingBottomSheet(
|
||||||
id: id,
|
complainedContentType: complainedContentType!,
|
||||||
slug: slug,
|
id: id,
|
||||||
),
|
slug: slug,
|
||||||
);
|
),
|
||||||
}
|
);
|
||||||
},
|
}
|
||||||
child: Row(
|
},
|
||||||
children: [
|
child: Row(
|
||||||
Expanded(
|
children: [
|
||||||
child: Text(
|
Expanded(
|
||||||
s.complain,
|
child: Text(
|
||||||
style: TextStyles.textMMedium.copyWith(
|
s.complain,
|
||||||
color: appTheme.black,
|
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
|
@freezed
|
||||||
abstract class Author with _$Author {
|
abstract class Author with _$Author {
|
||||||
|
const Author._();
|
||||||
|
|
||||||
const factory Author({
|
const factory Author({
|
||||||
@JsonKey(name: "author_slug") required String authorSlug,
|
@JsonKey(name: "author_slug") required String authorSlug,
|
||||||
@JsonKey(name: "author_name") required String authorName,
|
@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: "author_is_me") required bool authorIsMe,
|
||||||
@JsonKey(name: "is_entity_subscribed_by_me") bool? isEntitySubscribedByMe,
|
@JsonKey(name: "is_entity_subscribed_by_me") bool? isEntitySubscribedByMe,
|
||||||
@JsonKey(name: "author_place_in_rating") int? authorPlaceInRating,
|
@JsonKey(name: "author_place_in_rating") int? authorPlaceInRating,
|
||||||
|
@JsonKey(name: "author_roles") @Default([]) List<String> authorRoles,
|
||||||
}) = _Author;
|
}) = _Author;
|
||||||
|
|
||||||
factory Author.fromJson(Map<String, dynamic> json) => _$AuthorFromJson(json);
|
factory Author.fromJson(Map<String, dynamic> json) => _$AuthorFromJson(json);
|
||||||
|
|
||||||
|
bool get isAdmin => authorRoles.contains('admin');
|
||||||
}
|
}
|
||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
|
|||||||
@@ -26,16 +26,47 @@ class PostFeedMapper {
|
|||||||
totalViews: item.postViewsCount,
|
totalViews: item.postViewsCount,
|
||||||
totalComments: item.postCommentsCount,
|
totalComments: item.postCommentsCount,
|
||||||
wasLikedByMe: item.postIsLikedByUser,
|
wasLikedByMe: item.postIsLikedByUser,
|
||||||
images: item.images,
|
images: item.images.where((image) => hasRenderableImage(image.url)).toList(),
|
||||||
videoEmbedUrl: item.videoEmbedUrl,
|
videoEmbedUrl: item.videoEmbedUrl,
|
||||||
videoUrlThumb: item.videoUrlThumb,
|
videoUrlThumb: item.videoUrlThumb,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static String resolveMediaUrl(String? url) {
|
static String resolveMediaUrl(String? url) {
|
||||||
if (url == null || url.isEmpty) return '';
|
if (url == null || url.isEmpty || isPlaceholderMediaUrl(url)) {
|
||||||
if (url.startsWith('http://') || url.startsWith('https://')) return url;
|
return '';
|
||||||
if (url.startsWith('/')) return '${sl<AppConfig>().baseUrl}$url';
|
}
|
||||||
|
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
if (url.startsWith('/')) {
|
||||||
|
return '${sl<AppConfig>().baseUrl}$url';
|
||||||
|
}
|
||||||
return '${sl<AppConfig>().baseUrl}/storage/$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 {
|
}) async {
|
||||||
try {
|
try {
|
||||||
_query = query;
|
_query = query;
|
||||||
|
currentPage = 1;
|
||||||
final type = _types[state.filterState.typeIndex];
|
final type = _types[state.filterState.typeIndex];
|
||||||
final duration = _durationValues[state.filterState.durationIndex];
|
final duration = _durationValues[state.filterState.durationIndex];
|
||||||
final sorting = _sortingValues[state.filterState.sortingIndex];
|
final sorting = _sortingValues[state.filterState.sortingIndex];
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ class PlansCubit extends Cubit<PlansState> {
|
|||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
_query = query;
|
_query = query;
|
||||||
|
currentPage = 1;
|
||||||
final type = _types[state.filterState.typeIndex];
|
final type = _types[state.filterState.typeIndex];
|
||||||
final duration = _durationValues[state.filterState.durationIndex];
|
final duration = _durationValues[state.filterState.durationIndex];
|
||||||
final minPrice = state.filterState.priceFilter.$1;
|
final minPrice = state.filterState.priceFilter.$1;
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ class RecipesCubit extends Cubit<RecipesState> {
|
|||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
_query = query;
|
_query = query;
|
||||||
|
currentPage = 1;
|
||||||
final recipesList = await feedRepository.getRecipes(
|
final recipesList = await feedRepository.getRecipes(
|
||||||
tags: _selectedTags.toList(),
|
tags: _selectedTags.toList(),
|
||||||
query: query,
|
query: query,
|
||||||
|
|||||||
@@ -123,6 +123,19 @@ class _FeedScreenContentState extends State<FeedScreenContent> {
|
|||||||
super.dispose();
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
FlutterNativeSplash.remove();
|
FlutterNativeSplash.remove();
|
||||||
@@ -254,26 +267,43 @@ class _FeedScreenContentState extends State<FeedScreenContent> {
|
|||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
switch (_selectedChipIndex) {
|
switch (_selectedChipIndex) {
|
||||||
1 when !hidePlans => AddItemButton(
|
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(
|
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(
|
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(
|
3 when hidePlans => AddItemButton(
|
||||||
onTap: state.profile?.roles.contains('consultant') ?? false
|
onTap: state.profile?.roles.contains('consultant') ?? false
|
||||||
? () async {
|
? () => _openCreateAndReloadFeed(
|
||||||
await GoRouter.of(context).pushNamed(CreateDietScreen.name, extra: {'isEditing': false});
|
openCreate: () => GoRouter.of(context).pushNamed(CreateDietScreen.name, extra: {'isEditing': false}),
|
||||||
}
|
reload: () => context.read<DietsCubit>().loadDiets(),
|
||||||
|
)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
4 when !hidePlans => AddItemButton(
|
4 when !hidePlans => AddItemButton(
|
||||||
onTap: state.profile?.roles.contains('consultant') ?? false
|
onTap: state.profile?.roles.contains('consultant') ?? false
|
||||||
? () async {
|
? () => _openCreateAndReloadFeed(
|
||||||
await GoRouter.of(context).pushNamed(CreateDietScreen.name);
|
openCreate: () => GoRouter.of(context).pushNamed(CreateDietScreen.name),
|
||||||
}
|
reload: () => context.read<DietsCubit>().loadDiets(),
|
||||||
|
)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
_ => const AddItemButton(key: AppKeys.feedScreenBlogPostCreationButton),
|
_ => 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 {
|
enum ViewableContentType {
|
||||||
plans,
|
plans,
|
||||||
posts,
|
posts,
|
||||||
|
|||||||
@@ -39,15 +39,17 @@ class BlogPostMediaContent extends StatefulWidget {
|
|||||||
class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
|
class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
|
||||||
static const _counterDisplayDuration = Duration(seconds: 1);
|
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;
|
static const _horizontalAspectRatio = 16 / 9;
|
||||||
|
|
||||||
/// Portrait slide aspect (width / height).
|
|
||||||
static const _verticalAspectRatio = 3 / 4;
|
static const _verticalAspectRatio = 3 / 4;
|
||||||
|
|
||||||
bool _isCounterVisible = false;
|
bool _isCounterVisible = false;
|
||||||
Timer? _counterHideTimer;
|
Timer? _counterHideTimer;
|
||||||
|
|
||||||
|
final Map<String, double> _aspectByUrl = {};
|
||||||
|
final Map<String, ImageStream> _aspectStreams = {};
|
||||||
|
final Map<String, ImageStreamListener> _aspectListeners = {};
|
||||||
|
|
||||||
bool get _hasVideo =>
|
bool get _hasVideo =>
|
||||||
widget.videoEmbedUrl != null && widget.videoEmbedUrl!.trim().isNotEmpty;
|
widget.videoEmbedUrl != null && widget.videoEmbedUrl!.trim().isNotEmpty;
|
||||||
|
|
||||||
@@ -82,7 +84,6 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
|
|||||||
|
|
||||||
bool get _shouldShowCounter => !_isCurrentSlideVideo || _isCounterVisible;
|
bool get _shouldShowCounter => !_isCurrentSlideVideo || _isCounterVisible;
|
||||||
|
|
||||||
/// Height is fixed for the whole post from the first gallery image (web-like).
|
|
||||||
String get _postOrientation {
|
String get _postOrientation {
|
||||||
if (_gallery.isNotEmpty) {
|
if (_gallery.isNotEmpty) {
|
||||||
return _gallery.first.orientation;
|
return _gallery.first.orientation;
|
||||||
@@ -90,23 +91,89 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
|
|||||||
return 'horizontal';
|
return 'horizontal';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
double _heightForAspect(double aspectRatio, double width) {
|
||||||
|
if (aspectRatio <= 0) {
|
||||||
|
return width / _horizontalAspectRatio;
|
||||||
|
}
|
||||||
|
return width / aspectRatio;
|
||||||
|
}
|
||||||
|
|
||||||
double _heightForOrientation(String orientation, double width) {
|
double _heightForOrientation(String orientation, double width) {
|
||||||
final aspectRatio = orientation == 'vertical'
|
final aspectRatio = orientation == 'vertical'
|
||||||
? _verticalAspectRatio
|
? _verticalAspectRatio
|
||||||
: _horizontalAspectRatio;
|
: _horizontalAspectRatio;
|
||||||
final maxHeight = MediaQuery.sizeOf(context).height * 0.7;
|
return _heightForAspect(aspectRatio, width);
|
||||||
return (width / aspectRatio).clamp(180.0, maxHeight);
|
}
|
||||||
|
|
||||||
|
/// 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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_counterHideTimer?.cancel();
|
_counterHideTimer?.cancel();
|
||||||
|
for (final url in _aspectStreams.keys) {
|
||||||
|
_aspectStreams[url]!.removeListener(_aspectListeners[url]!);
|
||||||
|
}
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(covariant BlogPostMediaContent oldWidget) {
|
void didUpdateWidget(covariant BlogPostMediaContent oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
|
_syncAspectListeners();
|
||||||
if (oldWidget.currentGalleryIndex != widget.currentGalleryIndex) {
|
if (oldWidget.currentGalleryIndex != widget.currentGalleryIndex) {
|
||||||
_scheduleCounterHideOnVideo();
|
_scheduleCounterHideOnVideo();
|
||||||
}
|
}
|
||||||
@@ -138,16 +205,26 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
|
|||||||
return false;
|
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(
|
return GestureDetector(
|
||||||
onTap: () => widget.onOpenGallery(_galleryUrls, itemIndex),
|
onTap: () => widget.onOpenGallery(_galleryUrls, itemIndex),
|
||||||
child: CachedNetworkImage(
|
child: _photo(itemUrl, width: width, height: height),
|
||||||
fit: BoxFit.cover,
|
|
||||||
width: MediaQuery.sizeOf(context).width,
|
|
||||||
height: height,
|
|
||||||
imageUrl: itemUrl,
|
|
||||||
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,6 +252,8 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
|
|||||||
viewportFraction: 1,
|
viewportFraction: 1,
|
||||||
height: height,
|
height: height,
|
||||||
enableInfiniteScroll: false,
|
enableInfiniteScroll: false,
|
||||||
|
disableCenter: true,
|
||||||
|
padEnds: false,
|
||||||
onPageChanged: (index, _) => widget.onGalleryChanged(index),
|
onPageChanged: (index, _) => widget.onGalleryChanged(index),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -200,42 +279,59 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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 gallery = _gallery;
|
||||||
final width = MediaQuery.sizeOf(context).width;
|
final mediaHeight = _mediaHeight(width);
|
||||||
final mediaHeight = _heightForOrientation(_postOrientation, width);
|
|
||||||
|
|
||||||
if (gallery.length > 1 || (gallery.length == 1 && _hasVideo)) {
|
if (gallery.length > 1 || (gallery.length == 1 && _hasVideo)) {
|
||||||
final itemCount = _slideCount;
|
final itemCount = _slideCount;
|
||||||
return _carousel(
|
return AnimatedSize(
|
||||||
itemCount: itemCount,
|
duration: const Duration(milliseconds: 200),
|
||||||
height: mediaHeight,
|
curve: Curves.easeInOut,
|
||||||
itemBuilder: (context, itemIndex) {
|
child: _carousel(
|
||||||
if (itemIndex < gallery.length) {
|
itemCount: itemCount,
|
||||||
return _imageSlide(gallery[itemIndex].url, itemIndex, mediaHeight);
|
height: mediaHeight,
|
||||||
}
|
itemBuilder: (context, itemIndex) {
|
||||||
if (_hasVideo) {
|
if (itemIndex < gallery.length) {
|
||||||
return _videoSlide();
|
return _imageSlide(
|
||||||
}
|
gallery[itemIndex].url,
|
||||||
return const SizedBox.shrink();
|
itemIndex,
|
||||||
},
|
width: width,
|
||||||
|
height: mediaHeight,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (_hasVideo) {
|
||||||
|
return _videoSlide();
|
||||||
|
}
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (gallery.length == 1) {
|
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) {
|
if (_hasVideo) {
|
||||||
return SizedBox(height: mediaHeight, width: width, child: _videoSlide());
|
return SizedBox(height: mediaHeight, width: width, child: _videoSlide());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (widget.imageUrl.isNotEmpty) {
|
if (PostFeedMapper.hasRenderableImage(widget.imageUrl)) {
|
||||||
final image = CachedNetworkImage(
|
final image = _photo(widget.imageUrl, width: width);
|
||||||
fit: BoxFit.fitWidth,
|
|
||||||
width: width,
|
|
||||||
imageUrl: widget.imageUrl,
|
|
||||||
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
|
||||||
);
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => widget.onOpenGallery([widget.imageUrl], 0),
|
onTap: () => widget.onOpenGallery([widget.imageUrl], 0),
|
||||||
child: widget.heroTag == null
|
child: widget.heroTag == null
|
||||||
|
|||||||
@@ -83,6 +83,11 @@ class _BlogPostWidgetState extends State<BlogPostWidget> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final imageUrl = PostFeedMapper.resolveMediaUrl(post.image);
|
final imageUrl = PostFeedMapper.resolveMediaUrl(post.image);
|
||||||
final embedUrl = post.videoEmbedUrl?.trim();
|
final embedUrl = post.videoEmbedUrl?.trim();
|
||||||
|
final hasMedia = PostFeedMapper.hasVisibleMedia(
|
||||||
|
image: imageUrl,
|
||||||
|
images: post.images,
|
||||||
|
videoEmbedUrl: embedUrl,
|
||||||
|
);
|
||||||
|
|
||||||
return InkWell(
|
return InkWell(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
@@ -97,30 +102,27 @@ class _BlogPostWidgetState extends State<BlogPostWidget> {
|
|||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_BlogPostHeader(post: post),
|
_BlogPostHeader(post: post),
|
||||||
_BlogPostTextContent(post: post),
|
_BlogPostTextContent(post: post, includeBottomSpacing: hasMedia),
|
||||||
BlogPostMediaContent(
|
if (hasMedia)
|
||||||
galleryImages: post.images,
|
BlogPostMediaContent(
|
||||||
imageUrl: imageUrl,
|
galleryImages: post.images,
|
||||||
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty
|
imageUrl: imageUrl,
|
||||||
? embedUrl
|
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null,
|
||||||
: null,
|
videoUrlThumb: post.videoUrlThumb,
|
||||||
videoUrlThumb: post.videoUrlThumb,
|
currentGalleryIndex: currentGalleryIndex,
|
||||||
currentGalleryIndex: currentGalleryIndex,
|
heroTag: 'feed-blog-post-${post.id}-cover',
|
||||||
heroTag: 'feed-blog-post-${post.id}-cover',
|
onOpenGallery: (urls, index) => _openGallery(
|
||||||
onOpenGallery: (urls, index) => _openGallery(
|
urls,
|
||||||
urls,
|
index,
|
||||||
index,
|
heroTag: urls.length == 1 ? 'feed-blog-post-${post.id}-cover' : null,
|
||||||
heroTag: urls.length == 1
|
),
|
||||||
? 'feed-blog-post-${post.id}-cover'
|
onGalleryChanged: (index) {
|
||||||
: null,
|
setState(() {
|
||||||
|
currentGalleryIndex = index;
|
||||||
|
});
|
||||||
|
},
|
||||||
),
|
),
|
||||||
onGalleryChanged: (index) {
|
SizedBox(height: hasMedia ? 12 : 8),
|
||||||
setState(() {
|
|
||||||
currentGalleryIndex = index;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_BlogPostActionsRow(
|
_BlogPostActionsRow(
|
||||||
post: post,
|
post: post,
|
||||||
isLiked: isLiked,
|
isLiked: isLiked,
|
||||||
@@ -199,6 +201,7 @@ class _BlogPostHeader extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
CustomPopup(
|
CustomPopup(
|
||||||
isAuthor: post.author.authorIsMe,
|
isAuthor: post.author.authorIsMe,
|
||||||
|
isAuthorAdmin: post.author.isAdmin,
|
||||||
complainedContentType: ComplainedContent.posts,
|
complainedContentType: ComplainedContent.posts,
|
||||||
path: 'blog_post/${post.id}',
|
path: 'blog_post/${post.id}',
|
||||||
id: post.id,
|
id: post.id,
|
||||||
@@ -212,9 +215,10 @@ class _BlogPostHeader extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _BlogPostTextContent extends StatelessWidget {
|
class _BlogPostTextContent extends StatelessWidget {
|
||||||
const _BlogPostTextContent({required this.post});
|
const _BlogPostTextContent({required this.post, required this.includeBottomSpacing});
|
||||||
|
|
||||||
final FeedBlogPostModel post;
|
final FeedBlogPostModel post;
|
||||||
|
final bool includeBottomSpacing;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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),
|
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/enums/complained_content.dart';
|
||||||
import 'package:eatvio_app/core/locator_service.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/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/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_cubit.dart';
|
||||||
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_state.dart';
|
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_state.dart';
|
||||||
@@ -54,13 +55,15 @@ class _CommentsWidgetState extends State<CommentsWidget> {
|
|||||||
@override
|
@override
|
||||||
void didUpdateWidget(CommentsWidget oldWidget) {
|
void didUpdateWidget(CommentsWidget oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
if (widget.highlightedCommentId != null && widget.highlightedCommentId != oldWidget.highlightedCommentId) {
|
if (widget.highlightedCommentId != null &&
|
||||||
|
widget.highlightedCommentId != oldWidget.highlightedCommentId) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
_scrollToHighlightedComment();
|
_scrollToHighlightedComment();
|
||||||
});
|
});
|
||||||
} else if (widget.comment != oldWidget.comment && widget.highlightedCommentId != null) {
|
} else if (widget.comment != oldWidget.comment &&
|
||||||
// Comments might have just loaded
|
widget.highlightedCommentId != null) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
// Comments might have just loaded
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
_scrollToHighlightedComment();
|
_scrollToHighlightedComment();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -84,9 +87,22 @@ class _CommentsWidgetState extends State<CommentsWidget> {
|
|||||||
children: [
|
children: [
|
||||||
for (int i = 0; i < widget.comment.data.length; i++) ...[
|
for (int i = 0; i < widget.comment.data.length; i++) ...[
|
||||||
_comment(s, widget.comment.data[i]),
|
_comment(s, widget.comment.data[i]),
|
||||||
if (widget.comment.data[i].childrenComments.isNotEmpty) const SizedBox(height: 8),
|
if (widget.comment.data[i].childrenComments.isNotEmpty)
|
||||||
for (int j = 0; j < widget.comment.data[i].childrenComments.length; j++)
|
const SizedBox(height: 8),
|
||||||
Padding(padding: const EdgeInsets.only(left: 40), child: _comment(s, widget.comment.data[i].childrenComments[j], 16, true)),
|
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: 8),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 48),
|
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 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(
|
return GestureDetector(
|
||||||
key: key,
|
key: key,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
@@ -113,7 +138,10 @@ class _CommentsWidgetState extends State<CommentsWidget> {
|
|||||||
showInfoToast(s.text_was_copied);
|
showInfoToast(s.text_was_copied);
|
||||||
},
|
},
|
||||||
edit: (String text) {
|
edit: (String text) {
|
||||||
context.read<CommentsCubit>().updateComment(id: comment.id, body: text);
|
context.read<CommentsCubit>().updateComment(
|
||||||
|
id: comment.id,
|
||||||
|
body: text,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
delete: () {
|
delete: () {
|
||||||
context.read<CommentsCubit>().deleteComment(id: comment.id);
|
context.read<CommentsCubit>().deleteComment(id: comment.id);
|
||||||
@@ -122,7 +150,10 @@ class _CommentsWidgetState extends State<CommentsWidget> {
|
|||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
builder: (context) => ComplainingBottomSheet(complainedContentType: ComplainedContent.comment, id: comment.id),
|
builder: (context) => ComplainingBottomSheet(
|
||||||
|
complainedContentType: ComplainedContent.comment,
|
||||||
|
id: comment.id,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
isMyComment: isMe,
|
isMyComment: isMe,
|
||||||
@@ -131,7 +162,11 @@ class _CommentsWidgetState extends State<CommentsWidget> {
|
|||||||
child: Container(
|
child: Container(
|
||||||
width: MediaQuery.of(context).size.width,
|
width: MediaQuery.of(context).size.width,
|
||||||
decoration: BoxDecoration(
|
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),
|
borderRadius: BorderRadius.circular(16),
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -141,14 +176,20 @@ class _CommentsWidgetState extends State<CommentsWidget> {
|
|||||||
children: [
|
children: [
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
GoRouter.of(context).pushNamed(
|
GoRouter.of(
|
||||||
'profileScreen',
|
context,
|
||||||
extra: comment.authorSlug,
|
).pushNamed('profileScreen', extra: comment.authorSlug);
|
||||||
);
|
|
||||||
},
|
},
|
||||||
child: CircleAvatar(
|
child: CircleAvatar(
|
||||||
foregroundImage: NetworkImage(comment.authorProfileIcon.contains('https') ? comment.authorProfileIcon : sl<AppConfig>().baseUrl + comment.authorProfileIcon),
|
foregroundImage: NetworkImage(
|
||||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
joinBaseUrl(
|
||||||
|
sl<AppConfig>().baseUrl,
|
||||||
|
comment.authorProfileIcon,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
backgroundImage: const AssetImage(
|
||||||
|
'assets/images/no_avatar.png',
|
||||||
|
),
|
||||||
radius: radius,
|
radius: radius,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -159,14 +200,26 @@ class _CommentsWidgetState extends State<CommentsWidget> {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
comment.authorName,
|
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),
|
const SizedBox(height: 4),
|
||||||
SizedBox(
|
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(
|
child: Text(
|
||||||
comment.body,
|
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),
|
const SizedBox(height: 4),
|
||||||
@@ -174,17 +227,29 @@ class _CommentsWidgetState extends State<CommentsWidget> {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
comment.createdAt.format('dd MMMM', 'ru'),
|
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),
|
const SizedBox(width: 16),
|
||||||
if (!isChild)
|
if (!isChild)
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
context.read<CommentsCubit>().setReplyId(comment.id);
|
context.read<CommentsCubit>().setReplyId(
|
||||||
|
comment.id,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
child: Text(
|
child: Text(
|
||||||
s.answer,
|
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/app_config.dart';
|
||||||
import 'package:eatvio_app/core/locator_service.dart';
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
import 'package:eatvio_app/core/theme/theme_helper.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/feed/models/feed_consultant_model.dart';
|
||||||
import 'package:eatvio_app/features/profile/models/badge_model.dart';
|
import 'package:eatvio_app/features/profile/models/badge_model.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -17,15 +19,15 @@ class ConsultantWidget extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
GoRouter.of(context).pushNamed(
|
GoRouter.of(context).pushNamed('profileScreen', extra: consultant.slug);
|
||||||
'profileScreen',
|
|
||||||
extra: consultant.slug,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
child: Ink(
|
child: Ink(
|
||||||
padding: const EdgeInsets.all(12),
|
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(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -36,33 +38,54 @@ class ConsultantWidget extends StatelessWidget {
|
|||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Ink(
|
Ink(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
|
padding: const EdgeInsets.symmetric(
|
||||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(8)),
|
vertical: 4,
|
||||||
|
horizontal: 8,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
'${consultant.ratingPlace}',
|
'${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),
|
const SizedBox(width: 8),
|
||||||
if (consultant.photo.contains('https'))
|
CircleAvatar(
|
||||||
CircleAvatar(foregroundImage: NetworkImage(consultant.photo), backgroundImage: const AssetImage('assets/images/no_avatar.png'))
|
foregroundImage: NetworkImage(
|
||||||
else
|
joinBaseUrl(sl<AppConfig>().baseUrl, consultant.photo),
|
||||||
CircleAvatar(
|
|
||||||
foregroundImage: NetworkImage(sl<AppConfig>().baseUrl + consultant.photo),
|
|
||||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
|
||||||
),
|
),
|
||||||
|
backgroundImage: const AssetImage(
|
||||||
|
'assets/images/no_avatar.png',
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Column(
|
Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
consultant.name,
|
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),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'Рейтинг: ${consultant.totalScores}',
|
'Рейтинг: ${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(
|
Ink(
|
||||||
padding: const EdgeInsets.all(6),
|
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'),
|
child: SvgPicture.asset('assets/icons/go_to_arrow.svg'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -92,9 +118,21 @@ class ConsultantWidget extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
for (int i = 0; i < badges.length; i++) ...[
|
for (int i = 0; i < badges.length; i++) ...[
|
||||||
badges[i].image.contains('svg')
|
badges[i].image.contains('svg')
|
||||||
? SvgPicture.network('${sl<AppConfig>().baseUrl}/storage/${badges[i].image}', width: 28, height: 28)
|
? NetworkSvgPicture(
|
||||||
: CachedNetworkImage(imageUrl: '${sl<AppConfig>().baseUrl}/storage/${badges[i].image}', width: 28, height: 28),
|
url:
|
||||||
i < badges.length - 1 ? const SizedBox(width: 8) : const SizedBox.shrink(),
|
'${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),
|
borderRadius: BorderRadius.circular(4),
|
||||||
child: CustomPopup(
|
child: CustomPopup(
|
||||||
isAuthor: widget.diet.author.authorIsMe,
|
isAuthor: widget.diet.author.authorIsMe,
|
||||||
|
isAuthorAdmin: widget.diet.author.isAdmin,
|
||||||
path: 'diet/${widget.diet.slug}',
|
path: 'diet/${widget.diet.slug}',
|
||||||
id: widget.diet.id,
|
id: widget.diet.id,
|
||||||
complainedContentType: ComplainedContent.diet,
|
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/diets/presentation/widgets/info_bouble.dart';
|
||||||
import 'package:eatvio_app/features/feed/models/plan_pagination_model.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/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/core/theme/theme_helper.dart';
|
||||||
import 'package:eatvio_app/features/feed/presentation/bloc/plans_cubit/plans_cubit.dart';
|
import 'package:eatvio_app/features/feed/presentation/bloc/plans_cubit/plans_cubit.dart';
|
||||||
import 'package:eatvio_app/generated/l10n.dart';
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
@@ -37,7 +38,14 @@ class _PlanWidgetState extends State<PlanWidget> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final s = S.of(context);
|
final s = S.of(context);
|
||||||
return InkWell(
|
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(
|
child: Material(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
@@ -48,9 +56,12 @@ class _PlanWidgetState extends State<PlanWidget> {
|
|||||||
Stack(
|
Stack(
|
||||||
children: [
|
children: [
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
|
borderRadius: const BorderRadius.vertical(
|
||||||
|
top: Radius.circular(12),
|
||||||
|
),
|
||||||
child: CachedNetworkImage(
|
child: CachedNetworkImage(
|
||||||
imageUrl: '${sl<AppConfig>().baseUrl}/storage/${widget.plan.image}',
|
imageUrl:
|
||||||
|
'${sl<AppConfig>().baseUrl}/storage/${widget.plan.image}',
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
width: MediaQuery.of(context).size.width,
|
width: MediaQuery.of(context).size.width,
|
||||||
height: 426,
|
height: 426,
|
||||||
@@ -61,14 +72,22 @@ class _PlanWidgetState extends State<PlanWidget> {
|
|||||||
child: BookmarkButton(
|
child: BookmarkButton(
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
if (widget.plan.isFavorite) {
|
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 {
|
} else {
|
||||||
await context.read<PlansCubit>().addPlanToFavorite(widget.plan.slug, widget.index);
|
await context.read<PlansCubit>().addPlanToFavorite(
|
||||||
|
widget.plan.slug,
|
||||||
|
widget.index,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
color: appTheme.whiteA700,
|
color: appTheme.whiteA700,
|
||||||
isFavorite: widget.plan.isFavorite,
|
isFavorite: widget.plan.isFavorite,
|
||||||
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(12)),
|
borderRadius: const BorderRadius.vertical(
|
||||||
|
bottom: Radius.circular(12),
|
||||||
|
),
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 44,
|
height: 44,
|
||||||
size: 19,
|
size: 19,
|
||||||
@@ -85,9 +104,11 @@ class _PlanWidgetState extends State<PlanWidget> {
|
|||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
child: CustomPopup(
|
child: CustomPopup(
|
||||||
isAuthor: widget.plan.author.authorIsMe,
|
isAuthor: widget.plan.author.authorIsMe,
|
||||||
|
isAuthorAdmin: widget.plan.author.isAdmin,
|
||||||
path: 'plan/${widget.plan.slug}',
|
path: 'plan/${widget.plan.slug}',
|
||||||
id: widget.plan.id,
|
id: widget.plan.id,
|
||||||
slug: widget.plan.slug,
|
slug: widget.plan.slug,
|
||||||
|
plan: widget.plan,
|
||||||
complainedContentType: ComplainedContent.plan,
|
complainedContentType: ComplainedContent.plan,
|
||||||
userSlug: widget.plan.author.authorSlug,
|
userSlug: widget.plan.author.authorSlug,
|
||||||
),
|
),
|
||||||
@@ -98,7 +119,12 @@ class _PlanWidgetState extends State<PlanWidget> {
|
|||||||
bottom: 16,
|
bottom: 16,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: MediaQuery.sizeOf(context).width,
|
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: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
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) ...[
|
if (widget.plan.details.isNotEmpty) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: MarkdownHtmlWidget(
|
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,
|
textColor: appTheme.black,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -125,7 +159,12 @@ class _PlanWidgetState extends State<PlanWidget> {
|
|||||||
padding: EdgeInsets.only(left: 16, top: 8),
|
padding: EdgeInsets.only(left: 16, top: 8),
|
||||||
child: Text(
|
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),
|
const SizedBox(height: 16),
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
GoRouter.of(context).pushNamed('profileScreen', extra: widget.plan.author.authorSlug);
|
GoRouter.of(context).pushNamed(
|
||||||
|
'profileScreen',
|
||||||
|
extra: widget.plan.author.authorSlug,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.only(left: 16.0),
|
padding: const EdgeInsets.only(left: 16.0),
|
||||||
@@ -145,7 +187,15 @@ class _PlanWidgetState extends State<PlanWidget> {
|
|||||||
height: 46,
|
height: 46,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
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(
|
subtitle: Row(
|
||||||
@@ -153,22 +203,37 @@ class _PlanWidgetState extends State<PlanWidget> {
|
|||||||
InfoBouble(
|
InfoBouble(
|
||||||
radius: 12,
|
radius: 12,
|
||||||
backgroundColor: appTheme.gray50,
|
backgroundColor: appTheme.gray50,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 10),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: 4,
|
||||||
|
horizontal: 10,
|
||||||
|
),
|
||||||
children: [
|
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(),
|
const Spacer(),
|
||||||
Text(
|
Text(
|
||||||
'${widget.plan.packetsCount == 1 ? widget.plan.packet1Options?.price : '${s.from} ${widget.plan.minPriceOfOptions.toStringAsFixed(0)}'}₽',
|
'${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),
|
const SizedBox(width: 16),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
title: Padding(
|
title: Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 4.0),
|
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(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
FeedIconButton(
|
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(),
|
text: widget.plan.totalLikes.toString(),
|
||||||
iconColor: widget.plan.wasLikedByMe ? appTheme.blueA400 : null,
|
iconColor: widget.plan.wasLikedByMe
|
||||||
backgroundColor: widget.plan.wasLikedByMe ? appTheme.transparentBlue : null,
|
? appTheme.blueA400
|
||||||
|
: null,
|
||||||
|
backgroundColor: widget.plan.wasLikedByMe
|
||||||
|
? appTheme.transparentBlue
|
||||||
|
: null,
|
||||||
onTap: () async {
|
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),
|
const SizedBox(width: 12),
|
||||||
@@ -192,9 +266,14 @@ class _PlanWidgetState extends State<PlanWidget> {
|
|||||||
icon: Icons.chat_bubble_outline,
|
icon: Icons.chat_bubble_outline,
|
||||||
text: widget.plan.totalComments.toString(),
|
text: widget.plan.totalComments.toString(),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
return GoRouter.of(
|
return GoRouter.of(context).pushNamed(
|
||||||
context,
|
'aboutPlanScreen',
|
||||||
).pushNamed('aboutPlanScreen', extra: {'slug': widget.plan.slug, 'index': widget.index, 'navigateToComments': true});
|
extra: {
|
||||||
|
'slug': widget.plan.slug,
|
||||||
|
'index': widget.index,
|
||||||
|
'navigateToComments': true,
|
||||||
|
},
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
@@ -204,8 +283,11 @@ class _PlanWidgetState extends State<PlanWidget> {
|
|||||||
final box = context.findRenderObject() as RenderBox?;
|
final box = context.findRenderObject() as RenderBox?;
|
||||||
final result = await SharePlus.instance.share(
|
final result = await SharePlus.instance.share(
|
||||||
ShareParams(
|
ShareParams(
|
||||||
uri: Uri.parse('${sl<AppConfig>().baseUrl}/ru/diet/${widget.plan.id}'),
|
uri: Uri.parse(
|
||||||
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
|
'${sl<AppConfig>().baseUrl}/ru/diet/${widget.plan.id}',
|
||||||
|
),
|
||||||
|
sharePositionOrigin:
|
||||||
|
box!.localToGlobal(Offset.zero) & box.size,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
switch (result.status) {
|
switch (result.status) {
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ class _RecipesWidgetState extends State<RecipesWidget> {
|
|||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
child: CustomPopup(
|
child: CustomPopup(
|
||||||
isAuthor: widget.recipe.author.authorIsMe,
|
isAuthor: widget.recipe.author.authorIsMe,
|
||||||
|
isAuthorAdmin: widget.recipe.author.isAdmin,
|
||||||
path: 'recipe/${widget.recipe.slug}',
|
path: 'recipe/${widget.recipe.slug}',
|
||||||
slug: widget.recipe.slug,
|
slug: widget.recipe.slug,
|
||||||
complainedContentType: ComplainedContent.recipe,
|
complainedContentType: ComplainedContent.recipe,
|
||||||
|
|||||||
@@ -1,8 +1,20 @@
|
|||||||
import 'package:eatvio_app/core/app_config.dart';
|
import 'package:eatvio_app/core/app_config.dart';
|
||||||
import 'package:eatvio_app/core/locator_service.dart';
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
|
import 'package:eatvio_app/core/widgets/loader.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:webview_flutter/webview_flutter.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 {
|
class GlossaryScreen extends StatefulWidget {
|
||||||
const GlossaryScreen({super.key, required this.passKey});
|
const GlossaryScreen({super.key, required this.passKey});
|
||||||
final String passKey;
|
final String passKey;
|
||||||
@@ -12,6 +24,8 @@ class GlossaryScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _GlossaryScreenState extends State<GlossaryScreen> {
|
class _GlossaryScreenState extends State<GlossaryScreen> {
|
||||||
late final WebViewController _controller;
|
late final WebViewController _controller;
|
||||||
|
bool _isLoading = true;
|
||||||
|
String _currentUrl = '';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -19,14 +33,47 @@ class _GlossaryScreenState extends State<GlossaryScreen> {
|
|||||||
|
|
||||||
_controller = WebViewController()
|
_controller = WebViewController()
|
||||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
..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}'));
|
..loadRequest(Uri.parse('${sl<AppConfig>().baseUrl}/login_with_pass_key?id=${widget.passKey}'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _hideLoader() {
|
||||||
|
if (!_isLoading || !mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Глоссарий')),
|
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:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:eatvio_app/core/error_reporting/report_from.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/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';
|
import 'package:eatvio_app/features/menu/repositories/notification_settings_repository.dart';
|
||||||
|
|
||||||
class NotificationSettingsCubit extends Cubit<NotificationSettingsState> {
|
class NotificationSettingsCubit extends Cubit<NotificationSettingsState> {
|
||||||
final NotificationSettingsRepository _notificationSettingsRepository;
|
final NotificationSettingsRepository _notificationSettingsRepository;
|
||||||
|
int _alertSeq = 0;
|
||||||
|
|
||||||
NotificationSettingsCubit({required NotificationSettingsRepository notificationSettingsRepository})
|
NotificationSettingsCubit({required NotificationSettingsRepository notificationSettingsRepository})
|
||||||
: _notificationSettingsRepository = notificationSettingsRepository,
|
: _notificationSettingsRepository = notificationSettingsRepository,
|
||||||
@@ -30,6 +32,17 @@ class NotificationSettingsCubit extends Cubit<NotificationSettingsState> {
|
|||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
await reportFrom(e, stackTrace, this);
|
await reportFrom(e, stackTrace, this);
|
||||||
log('Error updating notification settings: $e', stackTrace: stackTrace);
|
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()));
|
emit(NotificationSettingsStateError(errorMessage: e.toString()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,11 +14,17 @@ class NotificationSettingsStateLoading extends NotificationSettingsState {
|
|||||||
|
|
||||||
class NotificationSettingsStateLoaded extends NotificationSettingsState {
|
class NotificationSettingsStateLoaded extends NotificationSettingsState {
|
||||||
final NotificationSettingsModel notificationSettings;
|
final NotificationSettingsModel notificationSettings;
|
||||||
|
final String? alertMessage;
|
||||||
|
final int? alertId;
|
||||||
|
|
||||||
const NotificationSettingsStateLoaded({required this.notificationSettings});
|
const NotificationSettingsStateLoaded({
|
||||||
|
required this.notificationSettings,
|
||||||
|
this.alertMessage,
|
||||||
|
this.alertId,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [notificationSettings];
|
List<Object?> get props => [notificationSettings, alertMessage, alertId];
|
||||||
}
|
}
|
||||||
|
|
||||||
class NotificationSettingsStateError extends NotificationSettingsState {
|
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(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text(s.notifications)),
|
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) {
|
builder: (context, state) {
|
||||||
if (state is NotificationSettingsStateLoading) {
|
if (state is NotificationSettingsStateLoading) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:eatvio_app/core/app_config.dart';
|
import 'package:eatvio_app/core/app_config.dart';
|
||||||
import 'package:eatvio_app/core/locator_service.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/menu/presentation/blocked_user_cubit/blocked_users_cubit.dart';
|
||||||
import 'package:eatvio_app/features/profile/models/profile_model.dart';
|
import 'package:eatvio_app/features/profile/models/profile_model.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -18,16 +19,20 @@ class _BlockedUserWidgetState extends State<BlockedUserWidget> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: () => GoRouter.of(context).pushNamed(
|
onTap: () => GoRouter.of(
|
||||||
'profileScreen',
|
context,
|
||||||
extra: widget.profile.slug,
|
).pushNamed('profileScreen', extra: widget.profile.slug),
|
||||||
),
|
|
||||||
child: Ink(
|
child: Ink(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
CircleAvatar(
|
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'),
|
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
||||||
radius: 28,
|
radius: 28,
|
||||||
),
|
),
|
||||||
@@ -50,11 +55,16 @@ class _BlockedUserWidgetState extends State<BlockedUserWidget> {
|
|||||||
const Spacer(),
|
const Spacer(),
|
||||||
InkWell(
|
InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
context.read<BlockedUsersCubit>().unblockUser(widget.profile.slug);
|
context.read<BlockedUsersCubit>().unblockUser(
|
||||||
|
widget.profile.slug,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
child: Ink(
|
child: Ink(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFF1F2F6),
|
color: const Color(0xFFF1F2F6),
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
|||||||
@@ -369,7 +369,7 @@ class _ChosenPlanFinishedToConsultantWidget extends StatelessWidget {
|
|||||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||||
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.green)),
|
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.green)),
|
||||||
onPressed: () {
|
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) {
|
Widget build(BuildContext context) {
|
||||||
return BlocProvider(
|
return BlocProvider(
|
||||||
create: (context) => sl<OrderInfoCubit>()..getChosenPlan(id: planId, customerSlug: customerSlug, isConsultantView: isConsultantView),
|
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 {
|
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 int planId;
|
||||||
final bool? successfulPayment;
|
final bool? successfulPayment;
|
||||||
final String? customerSlug;
|
final String? customerSlug;
|
||||||
|
final bool isConsultantView;
|
||||||
@override
|
@override
|
||||||
State<InfoByOrderScreenContent> createState() => _InfoByOrderScreenContentState();
|
State<InfoByOrderScreenContent> createState() => _InfoByOrderScreenContentState();
|
||||||
}
|
}
|
||||||
@@ -131,7 +143,9 @@ class _InfoByOrderScreenContentState extends State<InfoByOrderScreenContent> {
|
|||||||
body: SingleChildScrollView(
|
body: SingleChildScrollView(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
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)],
|
if (widget.successfulPayment != null) ...[const SizedBox(height: 8), PaymentBanner(successfulPayment: widget.successfulPayment ?? false)],
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
AuthorCard(
|
AuthorCard(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:eatvio_app/core/app_config.dart';
|
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/app_router.dart';
|
||||||
import 'package:eatvio_app/core/locator_service.dart';
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||||
@@ -14,7 +15,11 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
class ConsultationWidget extends StatelessWidget {
|
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 ConsultationModel consultationModel;
|
||||||
final String dietName;
|
final String dietName;
|
||||||
@override
|
@override
|
||||||
@@ -33,7 +38,12 @@ class ConsultationWidget extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
CircleAvatar(
|
CircleAvatar(
|
||||||
radius: 30,
|
radius: 30,
|
||||||
backgroundImage: NetworkImage(sl<AppConfig>().baseUrl + consultationModel.userProfilePhotoUrl),
|
backgroundImage: NetworkImage(
|
||||||
|
joinBaseUrl(
|
||||||
|
sl<AppConfig>().baseUrl,
|
||||||
|
consultationModel.userProfilePhotoUrl,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Column(
|
Column(
|
||||||
@@ -43,18 +53,24 @@ class ConsultationWidget extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
consultationModel.userName,
|
consultationModel.userName,
|
||||||
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
|
style: TextStyles.textLSemibold.copyWith(
|
||||||
|
color: appTheme.black,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
consultationModel.planTitle,
|
consultationModel.planTitle,
|
||||||
style: TextStyles.textMMedium.copyWith(color: const Color(0xFF88949F)),
|
style: TextStyles.textMMedium.copyWith(
|
||||||
|
color: const Color(0xFF88949F),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'Старт: ${consultationModel.userName}',
|
'Старт: ${consultationModel.userName}',
|
||||||
style: TextStyles.textMMedium.copyWith(color: const Color(0xFF88949F)),
|
style: TextStyles.textMMedium.copyWith(
|
||||||
|
color: const Color(0xFF88949F),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
@@ -62,7 +78,12 @@ class ConsultationWidget extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
CircleAvatar(
|
CircleAvatar(
|
||||||
radius: 30,
|
radius: 30,
|
||||||
backgroundImage: NetworkImage(sl<AppConfig>().baseUrl + consultationModel.userProfilePhotoUrl),
|
backgroundImage: NetworkImage(
|
||||||
|
joinBaseUrl(
|
||||||
|
sl<AppConfig>().baseUrl,
|
||||||
|
consultationModel.userProfilePhotoUrl,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Column(
|
Column(
|
||||||
@@ -72,25 +93,32 @@ class ConsultationWidget extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
consultationModel.userName,
|
consultationModel.userName,
|
||||||
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
|
style: TextStyles.textLSemibold.copyWith(
|
||||||
|
color: appTheme.black,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
consultationModel.planTitle,
|
consultationModel.planTitle,
|
||||||
style: TextStyles.textMMedium.copyWith(color: const Color(0xFF88949F)),
|
style: TextStyles.textMMedium.copyWith(
|
||||||
|
color: const Color(0xFF88949F),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(
|
const SizedBox(height: 12),
|
||||||
height: 12,
|
|
||||||
),
|
|
||||||
if (consultationModel.personalDietId != null)
|
if (consultationModel.personalDietId != null)
|
||||||
CustomOutlinedButton(
|
CustomOutlinedButton(
|
||||||
text: s.changeDiet,
|
text: s.changeDiet,
|
||||||
height: 52,
|
height: 52,
|
||||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.gray),
|
buttonTextStyle: TextStyles.textMSemibold.copyWith(
|
||||||
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.dividerColor)),
|
color: appTheme.gray,
|
||||||
|
),
|
||||||
|
buttonStyle: ButtonStyle(
|
||||||
|
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||||
|
backgroundColor: WidgetStatePropertyAll(appTheme.dividerColor),
|
||||||
|
),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
showBottomSheetWithBlur(
|
showBottomSheetWithBlur(
|
||||||
userRootNavigator: true,
|
userRootNavigator: true,
|
||||||
@@ -100,7 +128,11 @@ class ConsultationWidget extends StatelessWidget {
|
|||||||
userName: consultationModel.userName,
|
userName: consultationModel.userName,
|
||||||
dietName: dietName,
|
dietName: dietName,
|
||||||
onTap: () async {
|
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();
|
AppRouter.navigatorKey.currentContext?.pop();
|
||||||
if (result) {
|
if (result) {
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
@@ -134,8 +166,13 @@ class ConsultationWidget extends StatelessWidget {
|
|||||||
CustomOutlinedButton(
|
CustomOutlinedButton(
|
||||||
text: s.suggestDiet,
|
text: s.suggestDiet,
|
||||||
height: 52,
|
height: 52,
|
||||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
buttonTextStyle: TextStyles.textMSemibold.copyWith(
|
||||||
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
|
color: appTheme.whiteA700,
|
||||||
|
),
|
||||||
|
buttonStyle: ButtonStyle(
|
||||||
|
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||||
|
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
||||||
|
),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
showBottomSheetWithBlur(
|
showBottomSheetWithBlur(
|
||||||
userRootNavigator: true,
|
userRootNavigator: true,
|
||||||
@@ -146,7 +183,11 @@ class ConsultationWidget extends StatelessWidget {
|
|||||||
dietName: dietName,
|
dietName: dietName,
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
AppRouter.navigatorKey.currentContext?.pop();
|
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 (result) {
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
showBottomSheetWithBlur(
|
showBottomSheetWithBlur(
|
||||||
@@ -174,7 +215,7 @@ class ConsultationWidget extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
)
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -204,35 +245,33 @@ class _SuggestDietBottomSheet extends State<SuggestDietBottomSheet> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(
|
const SizedBox(height: 8),
|
||||||
height: 8,
|
|
||||||
),
|
|
||||||
const BottomSheetHandlerBar(),
|
const BottomSheetHandlerBar(),
|
||||||
const SizedBox(
|
const SizedBox(height: 20),
|
||||||
height: 20,
|
Text(
|
||||||
),
|
'Предложение диеты',
|
||||||
Text('Предложение диеты', style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||||
const SizedBox(
|
|
||||||
height: 16,
|
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'Предложить пользователю ${widget.userName} «${widget.dietName}»',
|
'Предложить пользователю ${widget.userName} «${widget.dietName}»',
|
||||||
style: TextStyles.textSMedium.copyWith(color: appTheme.black),
|
style: TextStyles.textSMedium.copyWith(color: appTheme.black),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(
|
const SizedBox(height: 8),
|
||||||
height: 8,
|
|
||||||
),
|
|
||||||
CustomOutlinedButton(
|
CustomOutlinedButton(
|
||||||
text: s.toOffer,
|
text: s.toOffer,
|
||||||
height: 52,
|
height: 52,
|
||||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
buttonTextStyle: TextStyles.textMSemibold.copyWith(
|
||||||
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
|
color: appTheme.whiteA700,
|
||||||
|
),
|
||||||
|
buttonStyle: ButtonStyle(
|
||||||
|
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||||
|
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
||||||
|
),
|
||||||
onPressed: widget.onTap,
|
onPressed: widget.onTap,
|
||||||
),
|
),
|
||||||
const SizedBox(
|
const SizedBox(height: 40),
|
||||||
height: 40,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -249,10 +288,12 @@ class SuggestionSuccessBottomSheet extends StatefulWidget {
|
|||||||
final String dietName;
|
final String dietName;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<SuggestionSuccessBottomSheet> createState() => _SuggestionSuccessBottomSheet();
|
State<SuggestionSuccessBottomSheet> createState() =>
|
||||||
|
_SuggestionSuccessBottomSheet();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SuggestionSuccessBottomSheet extends State<SuggestionSuccessBottomSheet> {
|
class _SuggestionSuccessBottomSheet
|
||||||
|
extends State<SuggestionSuccessBottomSheet> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final s = S.of(context);
|
final s = S.of(context);
|
||||||
@@ -261,37 +302,35 @@ class _SuggestionSuccessBottomSheet extends State<SuggestionSuccessBottomSheet>
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(
|
const SizedBox(height: 8),
|
||||||
height: 8,
|
|
||||||
),
|
|
||||||
const BottomSheetHandlerBar(),
|
const BottomSheetHandlerBar(),
|
||||||
const SizedBox(
|
const SizedBox(height: 20),
|
||||||
height: 20,
|
Text(
|
||||||
),
|
'Предложение диеты',
|
||||||
Text('Предложение диеты', style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||||
const SizedBox(
|
|
||||||
height: 16,
|
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'«${widget.dietName}» предложена пользователю ${widget.userName}',
|
'«${widget.dietName}» предложена пользователю ${widget.userName}',
|
||||||
style: TextStyles.textSMedium.copyWith(color: appTheme.black),
|
style: TextStyles.textSMedium.copyWith(color: appTheme.black),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(
|
const SizedBox(height: 8),
|
||||||
height: 8,
|
|
||||||
),
|
|
||||||
CustomOutlinedButton(
|
CustomOutlinedButton(
|
||||||
text: s.ok,
|
text: s.ok,
|
||||||
height: 52,
|
height: 52,
|
||||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
buttonTextStyle: TextStyles.textMSemibold.copyWith(
|
||||||
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
|
color: appTheme.whiteA700,
|
||||||
|
),
|
||||||
|
buttonStyle: ButtonStyle(
|
||||||
|
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||||
|
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
||||||
|
),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
AppRouter.navigatorKey.currentContext?.pop();
|
AppRouter.navigatorKey.currentContext?.pop();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(
|
const SizedBox(height: 40),
|
||||||
height: 40,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -308,10 +347,12 @@ class SuggestionFailureBottomSheet extends StatefulWidget {
|
|||||||
final String dietName;
|
final String dietName;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<SuggestionFailureBottomSheet> createState() => _SuggestionFailureBottomSheet();
|
State<SuggestionFailureBottomSheet> createState() =>
|
||||||
|
_SuggestionFailureBottomSheet();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SuggestionFailureBottomSheet extends State<SuggestionFailureBottomSheet> {
|
class _SuggestionFailureBottomSheet
|
||||||
|
extends State<SuggestionFailureBottomSheet> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final s = S.of(context);
|
final s = S.of(context);
|
||||||
@@ -320,37 +361,35 @@ class _SuggestionFailureBottomSheet extends State<SuggestionFailureBottomSheet>
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(
|
const SizedBox(height: 8),
|
||||||
height: 8,
|
|
||||||
),
|
|
||||||
const BottomSheetHandlerBar(),
|
const BottomSheetHandlerBar(),
|
||||||
const SizedBox(
|
const SizedBox(height: 20),
|
||||||
height: 20,
|
Text(
|
||||||
),
|
'Предложение диеты',
|
||||||
Text('Предложение диеты', style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||||
const SizedBox(
|
|
||||||
height: 16,
|
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'Не удалось предложить диету ${widget.dietName} пользователю ${widget.userName}',
|
'Не удалось предложить диету ${widget.dietName} пользователю ${widget.userName}',
|
||||||
style: TextStyles.textSMedium.copyWith(color: appTheme.black),
|
style: TextStyles.textSMedium.copyWith(color: appTheme.black),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(
|
const SizedBox(height: 8),
|
||||||
height: 8,
|
|
||||||
),
|
|
||||||
CustomOutlinedButton(
|
CustomOutlinedButton(
|
||||||
text: s.ok,
|
text: s.ok,
|
||||||
height: 52,
|
height: 52,
|
||||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
buttonTextStyle: TextStyles.textMSemibold.copyWith(
|
||||||
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
|
color: appTheme.whiteA700,
|
||||||
|
),
|
||||||
|
buttonStyle: ButtonStyle(
|
||||||
|
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||||
|
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
||||||
|
),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
AppRouter.navigatorKey.currentContext?.pop();
|
AppRouter.navigatorKey.currentContext?.pop();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(
|
const SizedBox(height: 40),
|
||||||
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/models/chosen_plan_model.dart';
|
||||||
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_state.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/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:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||||
|
|
||||||
class AboutPlanCubit extends Cubit<AboutPlanState> {
|
class AboutPlanCubit extends Cubit<AboutPlanState> {
|
||||||
final PlanRepository planRepository;
|
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 {
|
Future<void> getPlan({required String slug, int? index}) async {
|
||||||
try {
|
try {
|
||||||
@@ -141,6 +150,38 @@ class AboutPlanCubit extends Cubit<AboutPlanState> {
|
|||||||
return (null, '');
|
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) {
|
String _getOptionNameFromIndex(int index) => switch (index) {
|
||||||
1 => 'options2',
|
1 => 'options2',
|
||||||
2 => 'options3',
|
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/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/bloc/feed_cubit/feed_cubit.dart';
|
||||||
import 'package:eatvio_app/features/feed/presentation/widgets/comments_widget.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_cubit.dart';
|
||||||
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_state.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/features/plans/presentation/widgets/options_tab.dart';
|
||||||
import 'package:eatvio_app/generated/l10n.dart';
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
import 'package:eatvio_app/core/locator_service.dart';
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
@@ -73,6 +75,51 @@ class _AboutPlanScreenState extends State<AboutPlanScreen> {
|
|||||||
scrollController.jumpTo(scrollController.position.maxScrollExtent);
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final s = S.of(context);
|
final s = S.of(context);
|
||||||
@@ -147,7 +194,11 @@ class _AboutPlanScreenState extends State<AboutPlanScreen> {
|
|||||||
actions: [
|
actions: [
|
||||||
CustomPopup(
|
CustomPopup(
|
||||||
isAuthor: state.planModel?.author.authorIsMe ?? false,
|
isAuthor: state.planModel?.author.authorIsMe ?? false,
|
||||||
|
isAuthorAdmin: state.planModel?.author.isAdmin ?? false,
|
||||||
path: 'plan/${widget.slug}',
|
path: 'plan/${widget.slug}',
|
||||||
|
slug: widget.slug,
|
||||||
|
id: state.planModel?.id,
|
||||||
|
plan: state.planModel,
|
||||||
complainedContentType: ComplainedContent.plan,
|
complainedContentType: ComplainedContent.plan,
|
||||||
userSlug: state.planModel?.author.authorSlug ?? '',
|
userSlug: state.planModel?.author.authorSlug ?? '',
|
||||||
),
|
),
|
||||||
@@ -182,20 +233,7 @@ class _AboutPlanScreenState extends State<AboutPlanScreen> {
|
|||||||
focusElevation: 0,
|
focusElevation: 0,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
backgroundColor: appTheme.greenA700,
|
backgroundColor: appTheme.greenA700,
|
||||||
onPressed: () async {
|
onPressed: () => _onOrderPressed(context),
|
||||||
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});
|
|
||||||
},
|
|
||||||
label: Text(s.to_order, style: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700)),
|
label: Text(s.to_order, style: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -339,7 +377,11 @@ class _AboutPlanScreenState extends State<AboutPlanScreen> {
|
|||||||
actions: [
|
actions: [
|
||||||
CustomPopup(
|
CustomPopup(
|
||||||
isAuthor: state.planModel?.author.authorIsMe ?? false,
|
isAuthor: state.planModel?.author.authorIsMe ?? false,
|
||||||
|
isAuthorAdmin: state.planModel?.author.isAdmin ?? false,
|
||||||
path: 'plan/${widget.slug}',
|
path: 'plan/${widget.slug}',
|
||||||
|
slug: widget.slug,
|
||||||
|
id: state.planModel?.id,
|
||||||
|
plan: state.planModel,
|
||||||
complainedContentType: ComplainedContent.plan,
|
complainedContentType: ComplainedContent.plan,
|
||||||
userSlug: state.planModel?.author.authorSlug ?? '',
|
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 {
|
abstract class ChosenDietModel with _$ChosenDietModel {
|
||||||
factory ChosenDietModel({
|
factory ChosenDietModel({
|
||||||
@JsonKey(name: 'id') required int chosenDietID,
|
@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() required String state,
|
||||||
@JsonKey(name: 'start_date', fromJson: parseDate) required DateTime startDate,
|
@JsonKey(name: 'start_date', fromJson: parseDate) required DateTime startDate,
|
||||||
@JsonKey(name: 'diet', fromJson: _parseDietId) required int? dietId,
|
@JsonKey(name: 'diet', fromJson: _parseDietId) required int? dietId,
|
||||||
@@ -30,3 +30,9 @@ int? _parseDietId(dynamic json) {
|
|||||||
if (json == null) return null;
|
if (json == null) return null;
|
||||||
return json['id'] as int?;
|
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
@@ -0,0 +1,115 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class ExpandablePageView extends StatefulWidget {
|
||||||
|
const ExpandablePageView({
|
||||||
|
super.key,
|
||||||
|
required this.itemCount,
|
||||||
|
required this.itemBuilder,
|
||||||
|
this.onPageChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int itemCount;
|
||||||
|
final IndexedWidgetBuilder itemBuilder;
|
||||||
|
final ValueChanged<int>? onPageChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ExpandablePageView> createState() => _ExpandablePageViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ExpandablePageViewState extends State<ExpandablePageView> {
|
||||||
|
late List<double> _heights;
|
||||||
|
int _currentPage = 0;
|
||||||
|
|
||||||
|
double get _currentHeight {
|
||||||
|
if (_heights.isEmpty) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
final page = _currentPage.clamp(0, _heights.length - 1);
|
||||||
|
return _heights[page];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_heights = List<double>.filled(widget.itemCount, 0, growable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant ExpandablePageView oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (widget.itemCount != oldWidget.itemCount) {
|
||||||
|
_heights = List<double>.filled(widget.itemCount, 0, growable: true);
|
||||||
|
if (widget.itemCount == 0) {
|
||||||
|
_currentPage = 0;
|
||||||
|
} else {
|
||||||
|
_currentPage = _currentPage.clamp(0, widget.itemCount - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SizedBox(
|
||||||
|
height: _currentHeight,
|
||||||
|
child: PageView.builder(
|
||||||
|
itemCount: widget.itemCount,
|
||||||
|
onPageChanged: (int index) {
|
||||||
|
setState(() {
|
||||||
|
_currentPage = index;
|
||||||
|
});
|
||||||
|
widget.onPageChanged?.call(index);
|
||||||
|
},
|
||||||
|
itemBuilder: (BuildContext context, int index) {
|
||||||
|
return OverflowBox(
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
minHeight: 0,
|
||||||
|
maxHeight: double.infinity,
|
||||||
|
child: _SizeReportingWidget(
|
||||||
|
onSizeChange: (Size size) {
|
||||||
|
if (index >= _heights.length ||
|
||||||
|
_heights[index] == size.height) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_heights[index] = size.height;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: widget.itemBuilder(context, index),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SizeReportingWidget extends StatefulWidget {
|
||||||
|
const _SizeReportingWidget({required this.child, required this.onSizeChange});
|
||||||
|
|
||||||
|
final Widget child;
|
||||||
|
final ValueChanged<Size> onSizeChange;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_SizeReportingWidget> createState() => _SizeReportingWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SizeReportingWidgetState extends State<_SizeReportingWidget> {
|
||||||
|
Size? _oldSize;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _notifySize());
|
||||||
|
return widget.child;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _notifySize() {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final size = context.size;
|
||||||
|
if (size != null && _oldSize != size) {
|
||||||
|
_oldSize = size;
|
||||||
|
widget.onSizeChange(size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,6 +60,11 @@ class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
|
|||||||
totalLikes = post.totalLikes;
|
totalLikes = post.totalLikes;
|
||||||
final imageUrl = PostFeedMapper.resolveMediaUrl(post.image);
|
final imageUrl = PostFeedMapper.resolveMediaUrl(post.image);
|
||||||
final embedUrl = post.videoEmbedUrl?.trim();
|
final embedUrl = post.videoEmbedUrl?.trim();
|
||||||
|
final hasMedia = PostFeedMapper.hasVisibleMedia(
|
||||||
|
image: imageUrl,
|
||||||
|
images: post.images,
|
||||||
|
videoEmbedUrl: embedUrl,
|
||||||
|
);
|
||||||
|
|
||||||
return InkWell(
|
return InkWell(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
@@ -79,44 +84,17 @@ class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
CircleAvatar(
|
Text(
|
||||||
radius: 20,
|
'${calculateDifference(post.createdAt)} в ${post.createdAt.hour.toString().padLeft(2, '0')}:${post.createdAt.minute.toString().padLeft(2, '0')}',
|
||||||
backgroundColor: Colors.black12,
|
style: const TextStyle(
|
||||||
foregroundImage: NetworkImage(
|
height: 1,
|
||||||
post.author.authorProfileIcon.contains('https')
|
color: Colors.black45,
|
||||||
? post.author.authorProfileIcon
|
|
||||||
: sl<AppConfig>().baseUrl +
|
|
||||||
post.author.authorProfileIcon,
|
|
||||||
),
|
),
|
||||||
backgroundImage: const AssetImage(
|
|
||||||
'assets/images/no_avatar.png',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
post.author.authorName,
|
|
||||||
style: const TextStyle(
|
|
||||||
height: 1,
|
|
||||||
color: Colors.black87,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
'${calculateDifference(post.createdAt)} в ${post.createdAt.hour.toString().padLeft(2, '0')}:${post.createdAt.minute.toString().padLeft(2, '0')}',
|
|
||||||
style: const TextStyle(
|
|
||||||
height: 1,
|
|
||||||
color: Colors.black45,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
CustomPopup(
|
CustomPopup(
|
||||||
isAuthor: post.author.authorIsMe,
|
isAuthor: post.author.authorIsMe,
|
||||||
|
isAuthorAdmin: post.author.isAdmin || state.profile.roles.contains('admin'),
|
||||||
complainedContentType: ComplainedContent.posts,
|
complainedContentType: ComplainedContent.posts,
|
||||||
path: 'blog_post/${post.id}',
|
path: 'blog_post/${post.id}',
|
||||||
id: post.id,
|
id: post.id,
|
||||||
@@ -166,33 +144,34 @@ class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (post.title.isNotEmpty || post.description.isNotEmpty)
|
if (hasMedia && (post.title.isNotEmpty || post.description.isNotEmpty))
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
BlogPostMediaContent(
|
if (hasMedia)
|
||||||
galleryImages: post.images,
|
BlogPostMediaContent(
|
||||||
imageUrl: imageUrl,
|
galleryImages: post.images,
|
||||||
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty
|
imageUrl: imageUrl,
|
||||||
? embedUrl
|
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty
|
||||||
: null,
|
? embedUrl
|
||||||
videoUrlThumb: post.videoUrlThumb != null
|
|
||||||
? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb)
|
|
||||||
: null,
|
|
||||||
currentGalleryIndex: currentGalleryIndex,
|
|
||||||
heroTag: 'profile-blog-post-${post.id}-cover',
|
|
||||||
onOpenGallery: (urls, index) => _openGallery(
|
|
||||||
urls,
|
|
||||||
index,
|
|
||||||
heroTag: urls.length == 1
|
|
||||||
? 'profile-blog-post-${post.id}-cover'
|
|
||||||
: null,
|
: null,
|
||||||
|
videoUrlThumb: post.videoUrlThumb != null
|
||||||
|
? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb)
|
||||||
|
: null,
|
||||||
|
currentGalleryIndex: currentGalleryIndex,
|
||||||
|
heroTag: 'profile-blog-post-${post.id}-cover',
|
||||||
|
onOpenGallery: (urls, index) => _openGallery(
|
||||||
|
urls,
|
||||||
|
index,
|
||||||
|
heroTag: urls.length == 1
|
||||||
|
? 'profile-blog-post-${post.id}-cover'
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
onGalleryChanged: (index) {
|
||||||
|
setState(() {
|
||||||
|
currentGalleryIndex = index;
|
||||||
|
});
|
||||||
|
},
|
||||||
),
|
),
|
||||||
onGalleryChanged: (index) {
|
SizedBox(height: hasMedia ? 12 : 8),
|
||||||
setState(() {
|
|
||||||
currentGalleryIndex = index;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:eatvio_app/core/app_config.dart';
|
import 'package:eatvio_app/core/app_config.dart';
|
||||||
import 'package:eatvio_app/core/locator_service.dart';
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
|
import 'package:eatvio_app/core/utils/url_utils.dart';
|
||||||
import 'package:eatvio_app/features/profile/models/sub_model.dart';
|
import 'package:eatvio_app/features/profile/models/sub_model.dart';
|
||||||
import 'package:eatvio_app/features/profile/presentation/widgets/sub_action_button_widget.dart';
|
import 'package:eatvio_app/features/profile/presentation/widgets/sub_action_button_widget.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -8,10 +9,7 @@ import 'package:go_router/go_router.dart';
|
|||||||
class SubWidget extends StatefulWidget {
|
class SubWidget extends StatefulWidget {
|
||||||
final SubModel subModel;
|
final SubModel subModel;
|
||||||
|
|
||||||
const SubWidget({
|
const SubWidget({super.key, required this.subModel});
|
||||||
super.key,
|
|
||||||
required this.subModel,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<SubWidget> createState() => _SubWidgetState();
|
State<SubWidget> createState() => _SubWidgetState();
|
||||||
@@ -21,16 +19,20 @@ class _SubWidgetState extends State<SubWidget> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: () => GoRouter.of(context).pushNamed(
|
onTap: () => GoRouter.of(
|
||||||
'profileScreen',
|
context,
|
||||||
extra: widget.subModel.slug,
|
).pushNamed('profileScreen', extra: widget.subModel.slug),
|
||||||
),
|
|
||||||
child: Ink(
|
child: Ink(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
CircleAvatar(
|
CircleAvatar(
|
||||||
foregroundImage: NetworkImage(sl<AppConfig>().baseUrl + widget.subModel.profilePhotoUrl),
|
foregroundImage: NetworkImage(
|
||||||
|
joinBaseUrl(
|
||||||
|
sl<AppConfig>().baseUrl,
|
||||||
|
widget.subModel.profilePhotoUrl,
|
||||||
|
),
|
||||||
|
),
|
||||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
||||||
radius: 28,
|
radius: 28,
|
||||||
),
|
),
|
||||||
@@ -62,7 +64,9 @@ class _SubWidgetState extends State<SubWidget> {
|
|||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
SubActionButtonWidget(
|
SubActionButtonWidget(
|
||||||
subActionType: widget.subModel.isEntitySubscribedByMe ? SubActionType.sendMessage : SubActionType.subscribe,
|
subActionType: widget.subModel.isEntitySubscribedByMe
|
||||||
|
? SubActionType.sendMessage
|
||||||
|
: SubActionType.subscribe,
|
||||||
slug: widget.subModel.slug,
|
slug: widget.subModel.slug,
|
||||||
name: widget.subModel.name,
|
name: widget.subModel.name,
|
||||||
porfileIcon: widget.subModel.profilePhotoUrl,
|
porfileIcon: widget.subModel.profilePhotoUrl,
|
||||||
|
|||||||
@@ -223,6 +223,7 @@ class _AboutRecipeScreenState extends State<AboutRecipeScreen> {
|
|||||||
actions: [
|
actions: [
|
||||||
CustomPopup(
|
CustomPopup(
|
||||||
isAuthor: recipeState.recipe.author.authorIsMe,
|
isAuthor: recipeState.recipe.author.authorIsMe,
|
||||||
|
isAuthorAdmin: recipeState.recipe.author.isAdmin,
|
||||||
path: 'recipe/${widget.recipeSlug}',
|
path: 'recipe/${widget.recipeSlug}',
|
||||||
complainedContentType: ComplainedContent.recipe,
|
complainedContentType: ComplainedContent.recipe,
|
||||||
slug: widget.recipeSlug,
|
slug: widget.recipeSlug,
|
||||||
|
|||||||
@@ -221,49 +221,51 @@ class _GeneralInfoScreenState extends State<GeneralInfoScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _saveButton() {
|
Widget _saveButton() {
|
||||||
return isLoading
|
if (isLoading) {
|
||||||
? const Center(child: Loader())
|
return const SizedBox(height: 52, child: Loader());
|
||||||
: InkWell(
|
}
|
||||||
onTap: () async {
|
return InkWell(
|
||||||
final hasError = _checkFields();
|
onTap: () async {
|
||||||
if (hasError) {
|
final hasError = _checkFields();
|
||||||
return;
|
if (hasError) {
|
||||||
}
|
return;
|
||||||
setState(() {
|
}
|
||||||
isLoading = true;
|
setState(() {
|
||||||
});
|
isLoading = true;
|
||||||
await widget.settingsEntity.settingsCubit.updateProfileDetails(
|
});
|
||||||
profileDetailsModel: (widget.settingsEntity.settingsCubit.state as SettingsStateLoaded).profileDetails.copyWith(
|
await widget.settingsEntity.settingsCubit.updateProfileDetails(
|
||||||
firstName: firstNameController.text,
|
profileDetailsModel: (widget.settingsEntity.settingsCubit.state as SettingsStateLoaded).profileDetails.copyWith(
|
||||||
lastName: lastNameController.text,
|
firstName: firstNameController.text,
|
||||||
birthdate: selectedDate.toString(),
|
lastName: lastNameController.text,
|
||||||
gender: gender.value,
|
birthdate: selectedDate.toString(),
|
||||||
country: countryController.text,
|
gender: gender.value,
|
||||||
city: cityController.text,
|
country: countryController.text,
|
||||||
lang: languageController.text,
|
city: cityController.text,
|
||||||
),
|
lang: languageController.text,
|
||||||
);
|
),
|
||||||
await widget.settingsEntity.settingsCubit.updateProfileSettings(
|
);
|
||||||
profileSettingsModel: (widget.settingsEntity.settingsCubit.state as SettingsStateLoaded).profileSettings.copyWith(
|
await widget.settingsEntity.settingsCubit.updateProfileSettings(
|
||||||
isPublicProfile: isPublicProfile.value,
|
profileSettingsModel: (widget.settingsEntity.settingsCubit.state as SettingsStateLoaded).profileSettings.copyWith(
|
||||||
),
|
isPublicProfile: isPublicProfile.value,
|
||||||
);
|
),
|
||||||
setState(() {
|
);
|
||||||
isLoading = false;
|
if (!mounted) return;
|
||||||
});
|
setState(() {
|
||||||
},
|
isLoading = false;
|
||||||
borderRadius: BorderRadius.circular(20),
|
});
|
||||||
child: Ink(
|
},
|
||||||
height: 52,
|
borderRadius: BorderRadius.circular(20),
|
||||||
width: MediaQuery.of(context).size.width - 32,
|
child: Ink(
|
||||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
|
height: 52,
|
||||||
child: const Center(
|
width: MediaQuery.of(context).size.width - 32,
|
||||||
child: Text(
|
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
|
||||||
'Сохранить',
|
child: const Center(
|
||||||
style: TextStyle(color: Colors.white, height: 1, fontWeight: FontWeight.w600, fontSize: 18),
|
child: Text(
|
||||||
),
|
'Сохранить',
|
||||||
),
|
style: TextStyle(color: Colors.white, height: 1, fontWeight: FontWeight.w600, fontSize: 18),
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ import 'package:flutter_foreground_task/flutter_foreground_task.dart';
|
|||||||
import 'package:livekit_client/livekit_client.dart' as lk;
|
import 'package:livekit_client/livekit_client.dart' as lk;
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
|
||||||
typedef VideoCallManagerListener = void Function(VideoCallManagerSnapshot snapshot);
|
typedef VideoCallManagerListener =
|
||||||
|
void Function(VideoCallManagerSnapshot snapshot);
|
||||||
|
|
||||||
class VideoCallManager {
|
class VideoCallManager {
|
||||||
VideoCallManager({
|
VideoCallManager({
|
||||||
@@ -158,9 +159,7 @@ class VideoCallManager {
|
|||||||
return lk.CameraCaptureOptions(
|
return lk.CameraCaptureOptions(
|
||||||
cameraPosition:
|
cameraPosition:
|
||||||
cameraPosition ??
|
cameraPosition ??
|
||||||
(_useFrontCamera
|
(_useFrontCamera ? lk.CameraPosition.front : lk.CameraPosition.back),
|
||||||
? lk.CameraPosition.front
|
|
||||||
: lk.CameraPosition.back),
|
|
||||||
params: lk.VideoParametersPresets.h1080_169,
|
params: lk.VideoParametersPresets.h1080_169,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -286,10 +285,11 @@ class VideoCallManager {
|
|||||||
if (_liveKitDisposed) return false;
|
if (_liveKitDisposed) return false;
|
||||||
|
|
||||||
_callStageEntered = true;
|
_callStageEntered = true;
|
||||||
|
await _startCallAudioSession();
|
||||||
|
if (_liveKitDisposed || !_callStageEntered) return false;
|
||||||
await _publishLocalMedia();
|
await _publishLocalMedia();
|
||||||
if (_liveKitDisposed || !_callStageEntered) return false;
|
if (_liveKitDisposed || !_callStageEntered) return false;
|
||||||
await _startForegroundIfPossible();
|
await _startForegroundIfPossible();
|
||||||
await _startCallAudioSession();
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_emitSnapshot(
|
_emitSnapshot(
|
||||||
@@ -352,8 +352,7 @@ class VideoCallManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final room = _room;
|
final room = _room;
|
||||||
if (room == null ||
|
if (room == null || room.connectionState != lk.ConnectionState.connected) {
|
||||||
room.connectionState != lk.ConnectionState.connected) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (room.remoteParticipants.isEmpty) return;
|
if (room.remoteParticipants.isEmpty) return;
|
||||||
@@ -469,13 +468,20 @@ class VideoCallManager {
|
|||||||
if (next) {
|
if (next) {
|
||||||
final granted = await _ensureMicrophonePermission();
|
final granted = await _ensureMicrophonePermission();
|
||||||
if (!granted) return;
|
if (!granted) return;
|
||||||
|
await _startCallAudioSession();
|
||||||
|
}
|
||||||
|
final participant = _room!.localParticipant;
|
||||||
|
if (participant == null) return;
|
||||||
|
try {
|
||||||
|
await participant.setMicrophoneEnabled(
|
||||||
|
next,
|
||||||
|
audioCaptureOptions: _micCaptureOptions,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
_emitSnapshot(_snapshot.copyWith(micEnabled: next));
|
_emitSnapshot(_snapshot.copyWith(micEnabled: next));
|
||||||
_lastMutedMicTotalEnergy = null;
|
_lastMutedMicTotalEnergy = null;
|
||||||
await _room!.localParticipant?.setMicrophoneEnabled(
|
|
||||||
next,
|
|
||||||
audioCaptureOptions: _micCaptureOptions,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> toggleVideo(bool currentEnabled) async {
|
Future<void> toggleVideo(bool currentEnabled) async {
|
||||||
@@ -536,9 +542,7 @@ class VideoCallManager {
|
|||||||
try {
|
try {
|
||||||
await lp.setCameraEnabled(
|
await lp.setCameraEnabled(
|
||||||
true,
|
true,
|
||||||
cameraCaptureOptions: cameraCaptureOptions(
|
cameraCaptureOptions: cameraCaptureOptions(cameraPosition: newPosition),
|
||||||
cameraPosition: newPosition,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
@@ -663,6 +667,15 @@ class VideoCallManager {
|
|||||||
|
|
||||||
Future<void> _onAppResumed() async {
|
Future<void> _onAppResumed() async {
|
||||||
if (!isActiveCall) return;
|
if (!isActiveCall) return;
|
||||||
|
await _startCallAudioSession();
|
||||||
|
if (_snapshot.micEnabled) {
|
||||||
|
try {
|
||||||
|
await _room?.localParticipant?.setMicrophoneEnabled(
|
||||||
|
true,
|
||||||
|
audioCaptureOptions: _micCaptureOptions,
|
||||||
|
);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
await _resumeVideoAfterForeground();
|
await _resumeVideoAfterForeground();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -670,12 +683,10 @@ class VideoCallManager {
|
|||||||
if (!isActiveCall) return;
|
if (!isActiveCall) return;
|
||||||
final ctx = _navigatorContext;
|
final ctx = _navigatorContext;
|
||||||
final s = ctx != null ? S.of(ctx) : null;
|
final s = ctx != null ? S.of(ctx) : null;
|
||||||
final title =
|
final title = resolvedRemoteDisplayName(
|
||||||
resolvedRemoteDisplayName(
|
s?.video_call_counterpart_default ?? 'Call',
|
||||||
s?.video_call_counterpart_default ?? 'Call',
|
);
|
||||||
);
|
final body = s?.video_call_notification_in_progress ?? 'Call in progress';
|
||||||
final body =
|
|
||||||
s?.video_call_notification_in_progress ?? 'Call in progress';
|
|
||||||
final endLabel = s?.video_call_notification_end ?? 'End call';
|
final endLabel = s?.video_call_notification_end ?? 'End call';
|
||||||
await _foreground.startOngoingCall(
|
await _foreground.startOngoingCall(
|
||||||
conversationUuid: _snapshot.conversationUuid,
|
conversationUuid: _snapshot.conversationUuid,
|
||||||
@@ -739,11 +750,7 @@ class VideoCallManager {
|
|||||||
_roomConnectFuture = null;
|
_roomConnectFuture = null;
|
||||||
_clearPreparedConnectionCredentials();
|
_clearPreparedConnectionCredentials();
|
||||||
_callAcceptNotifiedConversationUuids.clear();
|
_callAcceptNotifiedConversationUuids.clear();
|
||||||
_emitSnapshot(
|
_emitSnapshot(_snapshot.copyWith(counterpartPresentInLobby: false));
|
||||||
_snapshot.copyWith(
|
|
||||||
counterpartPresentInLobby: false,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _clearPreparedConnectionCredentials() {
|
void _clearPreparedConnectionCredentials() {
|
||||||
@@ -925,10 +932,13 @@ class VideoCallManager {
|
|||||||
_preparedWsUrl = tokenResponse.wsUrl;
|
_preparedWsUrl = tokenResponse.wsUrl;
|
||||||
_preparedToken = tokenResponse.token;
|
_preparedToken = tokenResponse.token;
|
||||||
|
|
||||||
await room.prepareConnection(
|
// Warmup must not keep the enter button disabled. Joining the room
|
||||||
tokenResponse.wsUrl,
|
// connects with the token even if prepareConnection hangs or fails.
|
||||||
tokenResponse.token,
|
try {
|
||||||
);
|
await room
|
||||||
|
.prepareConnection(tokenResponse.wsUrl, tokenResponse.token)
|
||||||
|
.timeout(const Duration(seconds: 5));
|
||||||
|
} catch (_) {}
|
||||||
if (_liveKitDisposed) return;
|
if (_liveKitDisposed) return;
|
||||||
|
|
||||||
_emitSnapshot(
|
_emitSnapshot(
|
||||||
@@ -977,8 +987,7 @@ class VideoCallManager {
|
|||||||
_syncRemoteParticipantNameFromRoom();
|
_syncRemoteParticipantNameFromRoom();
|
||||||
|
|
||||||
_syncPoorConnectionOverlay(
|
_syncPoorConnectionOverlay(
|
||||||
room.localParticipant?.connectionQuality ??
|
room.localParticipant?.connectionQuality ?? lk.ConnectionQuality.unknown,
|
||||||
lk.ConnectionQuality.unknown,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1002,8 +1011,6 @@ class VideoCallManager {
|
|||||||
status.isPermanentlyDenied || status.isRestricted;
|
status.isPermanentlyDenied || status.isRestricted;
|
||||||
|
|
||||||
Future<bool> _ensureCameraPermission() async {
|
Future<bool> _ensureCameraPermission() async {
|
||||||
final ctx = _navigatorContext;
|
|
||||||
if (ctx == null) return false;
|
|
||||||
var status = await Permission.camera.status;
|
var status = await Permission.camera.status;
|
||||||
if (status.isGranted) return true;
|
if (status.isGranted) return true;
|
||||||
status = await Permission.camera.request();
|
status = await Permission.camera.request();
|
||||||
@@ -1023,8 +1030,6 @@ class VideoCallManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> _ensureMicrophonePermission() async {
|
Future<bool> _ensureMicrophonePermission() async {
|
||||||
final ctx = _navigatorContext;
|
|
||||||
if (ctx == null) return false;
|
|
||||||
var status = await Permission.microphone.status;
|
var status = await Permission.microphone.status;
|
||||||
if (status.isGranted) return true;
|
if (status.isGranted) return true;
|
||||||
status = await Permission.microphone.request();
|
status = await Permission.microphone.request();
|
||||||
@@ -1136,4 +1141,4 @@ class VideoCallManager {
|
|||||||
await _audioSession.stop();
|
await _audioSession.stop();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,10 +66,9 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
|||||||
connectPhase: snapshot.connectPhase,
|
connectPhase: snapshot.connectPhase,
|
||||||
linkIssue: snapshot.linkIssue,
|
linkIssue: snapshot.linkIssue,
|
||||||
callStageEntered: resetCallStage ? false : state.callStageEntered,
|
callStageEntered: resetCallStage ? false : state.callStageEntered,
|
||||||
uiMode:
|
uiMode: _isCallSessionInactive(snapshot)
|
||||||
_isCallSessionInactive(snapshot)
|
? VideoCallUiMode.hidden
|
||||||
? VideoCallUiMode.hidden
|
: state.uiMode,
|
||||||
: state.uiMode,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,8 +363,6 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
|||||||
if (!granted || isClosed) return;
|
if (!granted || isClosed) return;
|
||||||
|
|
||||||
await _videoCallManager.toggleMic(state.isMicActiveForUi);
|
await _videoCallManager.toggleMic(state.isMicActiveForUi);
|
||||||
if (isClosed) return;
|
|
||||||
emit(state.copyWith(micEnabled: true));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
|
|||||||
@@ -1,43 +1,65 @@
|
|||||||
import 'dart:async';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:audio_session/audio_session.dart';
|
import 'package:audio_session/audio_session.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
typedef VideoCallAudioSessionFactory = Future<AudioSession> Function();
|
typedef VideoCallAudioSessionFactory = Future<AudioSession> Function();
|
||||||
|
typedef IosCallAudioSessionHandler = Future<void> Function(String method);
|
||||||
|
|
||||||
/// Configures and owns the platform audio session for an active VoIP call.
|
/// Configures and owns the platform audio session for an active VoIP call.
|
||||||
class VideoCallAudioSessionCoordinator {
|
class VideoCallAudioSessionCoordinator {
|
||||||
VideoCallAudioSessionCoordinator({
|
VideoCallAudioSessionCoordinator({
|
||||||
VideoCallAudioSessionFactory? audioSessionFactory,
|
VideoCallAudioSessionFactory? audioSessionFactory,
|
||||||
|
bool? isIos,
|
||||||
|
IosCallAudioSessionHandler? iosCallAudioSession,
|
||||||
}) : _audioSessionFactory =
|
}) : _audioSessionFactory =
|
||||||
audioSessionFactory ?? (() => AudioSession.instance);
|
audioSessionFactory ?? (() => AudioSession.instance),
|
||||||
|
_isIos = isIos ?? Platform.isIOS,
|
||||||
|
_iosCallAudioSession = iosCallAudioSession ?? _invokeIosCallAudioSession;
|
||||||
|
|
||||||
|
static const MethodChannel _iosChannel = MethodChannel(
|
||||||
|
'com.eatvioapp/call_audio_session',
|
||||||
|
);
|
||||||
|
|
||||||
final VideoCallAudioSessionFactory _audioSessionFactory;
|
final VideoCallAudioSessionFactory _audioSessionFactory;
|
||||||
|
final bool _isIos;
|
||||||
|
final IosCallAudioSessionHandler _iosCallAudioSession;
|
||||||
|
|
||||||
static final AudioSessionConfiguration callConfiguration =
|
static final AudioSessionConfiguration callConfiguration =
|
||||||
AudioSessionConfiguration(
|
AudioSessionConfiguration(
|
||||||
avAudioSessionCategory: AVAudioSessionCategory.playAndRecord,
|
avAudioSessionCategory: AVAudioSessionCategory.playAndRecord,
|
||||||
avAudioSessionCategoryOptions:
|
avAudioSessionCategoryOptions:
|
||||||
AVAudioSessionCategoryOptions.allowBluetooth |
|
AVAudioSessionCategoryOptions.allowBluetooth |
|
||||||
AVAudioSessionCategoryOptions.defaultToSpeaker,
|
AVAudioSessionCategoryOptions.defaultToSpeaker,
|
||||||
avAudioSessionMode: AVAudioSessionMode.voiceChat,
|
avAudioSessionMode: AVAudioSessionMode.voiceChat,
|
||||||
avAudioSessionRouteSharingPolicy:
|
avAudioSessionRouteSharingPolicy:
|
||||||
AVAudioSessionRouteSharingPolicy.defaultPolicy,
|
AVAudioSessionRouteSharingPolicy.defaultPolicy,
|
||||||
avAudioSessionSetActiveOptions: AVAudioSessionSetActiveOptions.none,
|
avAudioSessionSetActiveOptions: AVAudioSessionSetActiveOptions.none,
|
||||||
androidAudioAttributes: const AndroidAudioAttributes(
|
androidAudioAttributes: const AndroidAudioAttributes(
|
||||||
contentType: AndroidAudioContentType.speech,
|
contentType: AndroidAudioContentType.speech,
|
||||||
flags: AndroidAudioFlags.none,
|
flags: AndroidAudioFlags.none,
|
||||||
usage: AndroidAudioUsage.voiceCommunication,
|
usage: AndroidAudioUsage.voiceCommunication,
|
||||||
),
|
),
|
||||||
androidAudioFocusGainType: AndroidAudioFocusGainType.gain,
|
androidAudioFocusGainType: AndroidAudioFocusGainType.gain,
|
||||||
androidWillPauseWhenDucked: true,
|
androidWillPauseWhenDucked: true,
|
||||||
);
|
);
|
||||||
|
|
||||||
AudioSession? _session;
|
AudioSession? _session;
|
||||||
bool _configured = false;
|
bool _configured = false;
|
||||||
|
bool _iosActive = false;
|
||||||
|
|
||||||
bool get isActive => _session != null;
|
bool get isActive => _isIos ? _iosActive : _session != null;
|
||||||
|
|
||||||
|
static Future<void> _invokeIosCallAudioSession(String method) {
|
||||||
|
return _iosChannel.invokeMethod<void>(method);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> start() async {
|
Future<void> start() async {
|
||||||
|
if (_isIos) {
|
||||||
|
await _iosCallAudioSession('activate');
|
||||||
|
_iosActive = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
final session = await _audioSessionFactory();
|
final session = await _audioSessionFactory();
|
||||||
_session = session;
|
_session = session;
|
||||||
if (!_configured) {
|
if (!_configured) {
|
||||||
@@ -48,6 +70,15 @@ class VideoCallAudioSessionCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> reactivate() async {
|
Future<bool> reactivate() async {
|
||||||
|
if (_isIos) {
|
||||||
|
try {
|
||||||
|
await _iosCallAudioSession('activate');
|
||||||
|
_iosActive = true;
|
||||||
|
return true;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
final session = _session;
|
final session = _session;
|
||||||
if (session == null) return false;
|
if (session == null) return false;
|
||||||
try {
|
try {
|
||||||
@@ -58,6 +89,13 @@ class VideoCallAudioSessionCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> stop() async {
|
Future<void> stop() async {
|
||||||
|
if (_isIos) {
|
||||||
|
_iosActive = false;
|
||||||
|
try {
|
||||||
|
await _iosCallAudioSession('deactivate');
|
||||||
|
} catch (_) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
final session = _session;
|
final session = _session;
|
||||||
_session = null;
|
_session = null;
|
||||||
if (session == null) return;
|
if (session == null) return;
|
||||||
|
|||||||
@@ -92,17 +92,9 @@ class VideoCallCallKitCoordinator {
|
|||||||
onResumed: _onAndroidAppResumed,
|
onResumed: _onAndroidAppResumed,
|
||||||
);
|
);
|
||||||
WidgetsBinding.instance.addObserver(_androidResumeObserver!);
|
WidgetsBinding.instance.addObserver(_androidResumeObserver!);
|
||||||
_androidCallPushChannel.setMethodCallHandler(_onAndroidCallPushMethodCall);
|
_androidCallPushChannel.setMethodCallHandler(
|
||||||
await FlutterCallkitIncoming.requestNotificationPermission(<
|
_onAndroidCallPushMethodCall,
|
||||||
String,
|
);
|
||||||
dynamic
|
|
||||||
>{
|
|
||||||
'title': 'Уведомления',
|
|
||||||
'rationaleMessagePermission':
|
|
||||||
'Разрешите уведомления, чтобы получать входящие видеозвонки.',
|
|
||||||
'postNotificationMessageRequired':
|
|
||||||
'Разрешите уведомления в настройках, чтобы получать входящие видеозвонки.',
|
|
||||||
});
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
unawaited(_pollNativePendingAcceptAfterResume());
|
unawaited(_pollNativePendingAcceptAfterResume());
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import 'package:eatvio_app/features/chat/models/chat_message_model.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart';
|
||||||
|
|
||||||
|
enum VideoCallChatSignalKind { none, started, ended }
|
||||||
|
|
||||||
|
/// Detects a video-call system message.
|
||||||
|
///
|
||||||
|
/// The realtime `ChatMessageUpdated` payload does not include `type`, so an
|
||||||
|
/// open app has to recognize the system text. Messages that already have
|
||||||
|
/// another type are ignored.
|
||||||
|
abstract final class VideoCallChatSignal {
|
||||||
|
static final RegExp _startedRu = RegExp(
|
||||||
|
r'начал\(а\) видеозвонок\.?\s*$',
|
||||||
|
caseSensitive: false,
|
||||||
|
);
|
||||||
|
static final RegExp _startedEn = RegExp(
|
||||||
|
r'started a video call\.?\s*$',
|
||||||
|
caseSensitive: false,
|
||||||
|
);
|
||||||
|
static final RegExp _endedRu = RegExp(
|
||||||
|
r'завершил\(а\) видеозвонок\.?\s*$',
|
||||||
|
caseSensitive: false,
|
||||||
|
);
|
||||||
|
static final RegExp _endedEn = RegExp(
|
||||||
|
r'ended a video call\.?\s*$',
|
||||||
|
caseSensitive: false,
|
||||||
|
);
|
||||||
|
static final RegExp _callerRu = RegExp(
|
||||||
|
r'^(.*) начал\(а\) видеозвонок\.?\s*$',
|
||||||
|
caseSensitive: false,
|
||||||
|
);
|
||||||
|
static final RegExp _callerEn = RegExp(
|
||||||
|
r'^(.*) started a video call\.?\s*$',
|
||||||
|
caseSensitive: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
static VideoCallChatSignalKind kindOf(ChatMessageModel message) {
|
||||||
|
if (message.type == ChatMessageType.videoCallEnded) {
|
||||||
|
return VideoCallChatSignalKind.ended;
|
||||||
|
}
|
||||||
|
if (message.type == ChatMessageType.videoCallStarted) {
|
||||||
|
return VideoCallChatSignalKind.started;
|
||||||
|
}
|
||||||
|
if (message.type != null) return VideoCallChatSignalKind.none;
|
||||||
|
|
||||||
|
final text = message.data.trim();
|
||||||
|
if (_endedRu.hasMatch(text) || _endedEn.hasMatch(text)) {
|
||||||
|
return VideoCallChatSignalKind.ended;
|
||||||
|
}
|
||||||
|
if (_startedRu.hasMatch(text) || _startedEn.hasMatch(text)) {
|
||||||
|
return VideoCallChatSignalKind.started;
|
||||||
|
}
|
||||||
|
return VideoCallChatSignalKind.none;
|
||||||
|
}
|
||||||
|
|
||||||
|
static VideoCallPushPayload? incomingPayload({
|
||||||
|
required ChatMessageModel message,
|
||||||
|
String? callerName,
|
||||||
|
String? callerAvatarUrl,
|
||||||
|
}) {
|
||||||
|
if (kindOf(message) != VideoCallChatSignalKind.started) return null;
|
||||||
|
final conversationUuid = message.roomId?.trim();
|
||||||
|
if (conversationUuid == null || conversationUuid.isEmpty) return null;
|
||||||
|
|
||||||
|
final resolvedName = callerName?.trim();
|
||||||
|
final avatar = callerAvatarUrl?.trim();
|
||||||
|
return VideoCallPushPayload(
|
||||||
|
conversationUuid: conversationUuid,
|
||||||
|
counterpartName: resolvedName != null && resolvedName.isNotEmpty
|
||||||
|
? resolvedName
|
||||||
|
: callerNameFromBody(message.data),
|
||||||
|
counterpartAvatarUrl: avatar != null && avatar.isNotEmpty ? avatar : null,
|
||||||
|
messageId: message.id > 0 ? message.id : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? callerNameFromBody(String body) {
|
||||||
|
final text = body.trim();
|
||||||
|
for (final pattern in [_callerRu, _callerEn]) {
|
||||||
|
final name = pattern.firstMatch(text)?.group(1)?.trim();
|
||||||
|
if (name != null && name.isNotEmpty) return name;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:eatvio_app/features/video_calls/services/video_call_kit_id.dart';
|
||||||
import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart';
|
import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart';
|
||||||
import 'package:flutter_callkit_incoming/entities/entities.dart';
|
import 'package:flutter_callkit_incoming/entities/entities.dart';
|
||||||
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
|
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
|
||||||
@@ -6,18 +9,25 @@ abstract final class VideoCallIncomingPushPresenter {
|
|||||||
static const String _androidAccentColor = '#2787F5';
|
static const String _androidAccentColor = '#2787F5';
|
||||||
|
|
||||||
static Future<void> showIncomingCall(VideoCallPushPayload payload) async {
|
static Future<void> showIncomingCall(VideoCallPushPayload payload) async {
|
||||||
final callerName =
|
final callerName = payload.counterpartName?.trim().isNotEmpty == true
|
||||||
payload.counterpartName?.trim().isNotEmpty == true
|
? payload.counterpartName!.trim()
|
||||||
? payload.counterpartName!.trim()
|
: 'Итвио';
|
||||||
: 'Итвио';
|
|
||||||
|
final callId = Platform.isIOS
|
||||||
|
? VideoCallKitId.iosUuid(
|
||||||
|
conversationUuid: payload.conversationUuid,
|
||||||
|
messageId: payload.messageId,
|
||||||
|
) ??
|
||||||
|
payload.conversationUuid
|
||||||
|
: payload.conversationUuid;
|
||||||
|
|
||||||
final params = CallKitParams(
|
final params = CallKitParams(
|
||||||
id: payload.conversationUuid,
|
id: callId,
|
||||||
nameCaller: callerName,
|
nameCaller: callerName,
|
||||||
appName: 'Итвио',
|
appName: 'Итвио',
|
||||||
avatar: payload.counterpartAvatarUrl,
|
avatar: payload.counterpartAvatarUrl,
|
||||||
handle: payload.conversationUuid,
|
handle: payload.conversationUuid,
|
||||||
type: 0,
|
type: 1,
|
||||||
duration: 45000,
|
duration: 45000,
|
||||||
extra: <String, dynamic>{
|
extra: <String, dynamic>{
|
||||||
'conversationUuid': payload.conversationUuid,
|
'conversationUuid': payload.conversationUuid,
|
||||||
|
|||||||
23
lib/features/video_calls/services/video_call_kit_id.dart
Normal file
23
lib/features/video_calls/services/video_call_kit_id.dart
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/// CallKit on iOS rejects a second incoming call that reuses a UUID.
|
||||||
|
///
|
||||||
|
/// The conversation uuid stays in the call payload for navigation. The CallKit
|
||||||
|
/// id is derived from that uuid plus the chat message id, so one ring is one
|
||||||
|
/// call and the next ring in the same chat still appears. Android notifications
|
||||||
|
/// keep the conversation uuid: the same id updates one incoming notification.
|
||||||
|
abstract final class VideoCallKitId {
|
||||||
|
static final RegExp _uuidPrefix = RegExp(
|
||||||
|
r'^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-)[0-9a-fA-F]{12}$',
|
||||||
|
);
|
||||||
|
|
||||||
|
static String? iosUuid({
|
||||||
|
required String conversationUuid,
|
||||||
|
required int? messageId,
|
||||||
|
}) {
|
||||||
|
if (messageId == null || messageId <= 0) return null;
|
||||||
|
final prefix = _uuidPrefix.firstMatch(conversationUuid.trim())?.group(1);
|
||||||
|
if (prefix == null) return null;
|
||||||
|
final suffix = messageId.toRadixString(16).padLeft(12, '0');
|
||||||
|
if (suffix.length != 12) return null;
|
||||||
|
return '$prefix$suffix'.toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ class VideoCallPushPayload {
|
|||||||
this.counterpartAvatarUrl,
|
this.counterpartAvatarUrl,
|
||||||
this.localParticipantName,
|
this.localParticipantName,
|
||||||
this.counterpartIsConsultant,
|
this.counterpartIsConsultant,
|
||||||
|
this.messageId,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String conversationUuid;
|
final String conversationUuid;
|
||||||
@@ -12,6 +13,7 @@ class VideoCallPushPayload {
|
|||||||
final String? counterpartAvatarUrl;
|
final String? counterpartAvatarUrl;
|
||||||
final String? localParticipantName;
|
final String? localParticipantName;
|
||||||
final bool? counterpartIsConsultant;
|
final bool? counterpartIsConsultant;
|
||||||
|
final int? messageId;
|
||||||
|
|
||||||
static VideoCallPushPayload? tryParse(Map<String, dynamic> data) {
|
static VideoCallPushPayload? tryParse(Map<String, dynamic> data) {
|
||||||
if (!isIncomingVideoCall(data)) return null;
|
if (!isIncomingVideoCall(data)) return null;
|
||||||
@@ -49,6 +51,7 @@ class VideoCallPushPayload {
|
|||||||
'is_consultant',
|
'is_consultant',
|
||||||
'isConsultant',
|
'isConsultant',
|
||||||
]),
|
]),
|
||||||
|
messageId: _messageId(data),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +135,8 @@ class VideoCallPushPayload {
|
|||||||
final uri = Uri.tryParse(actionUrl);
|
final uri = Uri.tryParse(actionUrl);
|
||||||
if (uri == null) return null;
|
if (uri == null) return null;
|
||||||
|
|
||||||
final fromQuery = uri.queryParameters['uuid'] ??
|
final fromQuery =
|
||||||
|
uri.queryParameters['uuid'] ??
|
||||||
uri.queryParameters['conversationUuid'] ??
|
uri.queryParameters['conversationUuid'] ??
|
||||||
uri.queryParameters['conversation_uuid'];
|
uri.queryParameters['conversation_uuid'];
|
||||||
if (fromQuery != null && fromQuery.trim().isNotEmpty) {
|
if (fromQuery != null && fromQuery.trim().isNotEmpty) {
|
||||||
@@ -167,14 +171,22 @@ class VideoCallPushPayload {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static int? _messageId(Map<String, dynamic> data) {
|
||||||
|
final value = data['message_id'] ?? data['messageId'];
|
||||||
|
if (value is int) return value > 0 ? value : null;
|
||||||
|
if (value is String) {
|
||||||
|
final parsed = int.tryParse(value.trim());
|
||||||
|
if (parsed != null && parsed > 0) return parsed;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
static bool _isTruthy(Object? value) {
|
static bool _isTruthy(Object? value) {
|
||||||
if (value == null) return false;
|
if (value == null) return false;
|
||||||
if (value is bool) return value;
|
if (value is bool) return value;
|
||||||
if (value is num) return value != 0;
|
if (value is num) return value != 0;
|
||||||
final normalized = value.toString().trim().toLowerCase();
|
final normalized = value.toString().trim().toLowerCase();
|
||||||
return normalized == '1' ||
|
return normalized == '1' || normalized == 'true' || normalized == 'yes';
|
||||||
normalized == 'true' ||
|
|
||||||
normalized == 'yes';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static String? _resolveAvatarUrl(String? raw) {
|
static String? _resolveAvatarUrl(String? raw) {
|
||||||
|
|||||||
@@ -44,82 +44,112 @@ class _WelcomeScreenState extends State<WelcomeScreen> {
|
|||||||
),
|
),
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
child: Padding(
|
child: LayoutBuilder(
|
||||||
padding: const EdgeInsets.only(bottom: 16),
|
builder: (context, constraints) {
|
||||||
child: Column(
|
final textScale = MediaQuery.textScalerOf(context).scale(1);
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
final topGap = textScale > 1.2 ? 24.0 : 80.0;
|
||||||
children: [
|
return SingleChildScrollView(
|
||||||
const SizedBox(height: 80),
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
SvgPicture.asset('assets/images/logo_welcome.svg'),
|
child: ConstrainedBox(
|
||||||
const Spacer(),
|
constraints: BoxConstraints(
|
||||||
Padding(
|
minHeight: constraints.maxHeight - 16,
|
||||||
padding: const EdgeInsets.all(16),
|
),
|
||||||
child: Material(
|
child: IntrinsicHeight(
|
||||||
borderRadius: BorderRadius.circular(14),
|
child: Column(
|
||||||
color: Colors.white,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
child: Padding(
|
children: [
|
||||||
padding: const EdgeInsets.all(16),
|
SizedBox(height: topGap),
|
||||||
child: Column(
|
SvgPicture.asset('assets/images/logo_welcome.svg'),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
const Spacer(),
|
||||||
children: [
|
Padding(
|
||||||
const SizedBox(height: 16),
|
padding: const EdgeInsets.all(16),
|
||||||
const Text(
|
child: Material(
|
||||||
'Добро пожаловать!',
|
borderRadius: BorderRadius.circular(14),
|
||||||
style: TextStyle(
|
color: Colors.white,
|
||||||
fontSize: 28,
|
child: Padding(
|
||||||
fontWeight: FontWeight.w800,
|
padding: const EdgeInsets.all(16),
|
||||||
height: 1,
|
child: Column(
|
||||||
),
|
crossAxisAlignment:
|
||||||
),
|
CrossAxisAlignment.start,
|
||||||
const SizedBox(height: 20),
|
children: [
|
||||||
const Text(
|
const SizedBox(height: 16),
|
||||||
'Это первая версия приложения,\nи мы активно работаем над его\nулучшением. Если вы заметите ошибку\nили у вас есть идея — пожалуйста,\nсообщите нам!\n\nВаша помощь бесценна',
|
const Text(
|
||||||
style: TextStyle(
|
'Добро пожаловать!',
|
||||||
fontSize: 16,
|
style: TextStyle(
|
||||||
fontWeight: FontWeight.w500,
|
fontSize: 28,
|
||||||
height: 1.15,
|
fontWeight: FontWeight.w800,
|
||||||
),
|
height: 1,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
|
||||||
InkWell(
|
|
||||||
key: AppKeys.welcomeScreenButton,
|
|
||||||
onTap: () async {
|
|
||||||
final sharedPreferences = sl<Prefs>();
|
|
||||||
await sharedPreferences.setBool(PrefsKeys.welcomeScreenWasShowed, true);
|
|
||||||
if (await TokenStorage.haveTokenInStorage()) {
|
|
||||||
goRouter.go('/feed-screen');
|
|
||||||
} else {
|
|
||||||
goRouter.go('/auth-screen');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
child: Ink(
|
|
||||||
height: 52,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: appTheme.blueA400,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: const Center(
|
|
||||||
child: Text(
|
|
||||||
'Начать пользоваться',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
height: 1,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 20),
|
||||||
|
const Text(
|
||||||
|
'Это первая версия приложения,\nи мы активно работаем над его\nулучшением. Если вы заметите ошибку\nили у вас есть идея — пожалуйста,\nсообщите нам!\n\nВаша помощь бесценна',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
height: 1.15,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
InkWell(
|
||||||
|
key: AppKeys.welcomeScreenButton,
|
||||||
|
onTap: () async {
|
||||||
|
final sharedPreferences = sl<Prefs>();
|
||||||
|
await sharedPreferences.setBool(
|
||||||
|
PrefsKeys.welcomeScreenWasShowed,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
if (await TokenStorage.haveTokenInStorage()) {
|
||||||
|
goRouter.go('/feed-screen');
|
||||||
|
} else {
|
||||||
|
goRouter.go('/auth-screen');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Ink(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: appTheme.blueA400,
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(
|
||||||
|
minHeight: 52,
|
||||||
|
),
|
||||||
|
child: const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 14,
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
'Начать пользоваться',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
height: 1,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
);
|
||||||
),
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -137,6 +137,9 @@ class MessageLookup extends MessageLookupByLibrary {
|
|||||||
"Средние показатели давления:",
|
"Средние показатели давления:",
|
||||||
),
|
),
|
||||||
"back_to_chat": MessageLookupByLibrary.simpleMessage("Вернуться в чат"),
|
"back_to_chat": MessageLookupByLibrary.simpleMessage("Вернуться в чат"),
|
||||||
|
"blog_post_image_min_size_error": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"Минимальный размер изображения — 500×500 px",
|
||||||
|
),
|
||||||
"bodymass_chart": MessageLookupByLibrary.simpleMessage("График ИМТ"),
|
"bodymass_chart": MessageLookupByLibrary.simpleMessage("График ИМТ"),
|
||||||
"by_default": MessageLookupByLibrary.simpleMessage("По умолчанию"),
|
"by_default": MessageLookupByLibrary.simpleMessage("По умолчанию"),
|
||||||
"calls_for_bullying": MessageLookupByLibrary.simpleMessage(
|
"calls_for_bullying": MessageLookupByLibrary.simpleMessage(
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ class MessageLookup extends MessageLookupByLibrary {
|
|||||||
"average_pressure": MessageLookupByLibrary.simpleMessage(
|
"average_pressure": MessageLookupByLibrary.simpleMessage(
|
||||||
"Средние показатели давления:",
|
"Средние показатели давления:",
|
||||||
),
|
),
|
||||||
|
"blog_post_image_min_size_error": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"Минимальный размер изображения — 500×500 px",
|
||||||
|
),
|
||||||
"bodymass_chart": MessageLookupByLibrary.simpleMessage("График ИМТ"),
|
"bodymass_chart": MessageLookupByLibrary.simpleMessage("График ИМТ"),
|
||||||
"by_default": MessageLookupByLibrary.simpleMessage("По умолчанию"),
|
"by_default": MessageLookupByLibrary.simpleMessage("По умолчанию"),
|
||||||
"calories": MessageLookupByLibrary.simpleMessage("Калории"),
|
"calories": MessageLookupByLibrary.simpleMessage("Калории"),
|
||||||
|
|||||||
@@ -2569,6 +2569,16 @@ class S {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `Минимальный размер изображения — 500×500 px`
|
||||||
|
String get blog_post_image_min_size_error {
|
||||||
|
return Intl.message(
|
||||||
|
'Минимальный размер изображения — 500×500 px',
|
||||||
|
name: 'blog_post_image_min_size_error',
|
||||||
|
desc: '',
|
||||||
|
args: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// `Информация о заказе`
|
/// `Информация о заказе`
|
||||||
String get infoAboutOrder {
|
String get infoAboutOrder {
|
||||||
return Intl.message(
|
return Intl.message(
|
||||||
|
|||||||
@@ -317,6 +317,7 @@
|
|||||||
"could_not_publish_blog_post": "Не удалось опубликовать пост",
|
"could_not_publish_blog_post": "Не удалось опубликовать пост",
|
||||||
"could_not_update_blog_post": "Не удалось обновить пост",
|
"could_not_update_blog_post": "Не удалось обновить пост",
|
||||||
"could_not_delete_blog_post": "Не удалось удалить пост",
|
"could_not_delete_blog_post": "Не удалось удалить пост",
|
||||||
|
"blog_post_image_min_size_error": "Минимальный размер изображения — 500×500 px",
|
||||||
"infoAboutOrder": "Информация о заказе",
|
"infoAboutOrder": "Информация о заказе",
|
||||||
"to_order": "Заказать",
|
"to_order": "Заказать",
|
||||||
"can_not_choose_start_date": "Дату старта плана невозможно выбрать, пока Вы не привяжите карту",
|
"can_not_choose_start_date": "Дату старта плана невозможно выбрать, пока Вы не привяжите карту",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user