mirror of
https://github.com/shaulascr/ecommerce_serang.git
synced 2025-08-10 09:22:21 +00:00
update chat customer and store
This commit is contained in:
@ -67,7 +67,7 @@
|
||||
<activity
|
||||
android:name=".ui.chat.ChatActivity"
|
||||
android:exported="false"
|
||||
android:windowSoftInputMode="adjustResize|stateHidden" /> <!-- <provider -->
|
||||
android:windowSoftInputMode="adjustResize" /> <!-- <provider -->
|
||||
<!-- android:name="androidx.startup.InitializationProvider" -->
|
||||
<!-- android:authorities="${applicationId}.androidx-startup" -->
|
||||
<!-- tools:node="remove" /> -->
|
||||
@ -79,7 +79,7 @@
|
||||
<activity
|
||||
android:name=".ui.profile.mystore.chat.ChatStoreActivity"
|
||||
android:exported="false"
|
||||
android:windowSoftInputMode="adjustResize|stateHidden" />
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.profile.mystore.profile.shipping_service.ShippingServiceActivity"
|
||||
|
@ -7,6 +7,7 @@ import com.alya.ecommerce_serang.data.api.response.chat.ChatHistoryResponse
|
||||
import com.alya.ecommerce_serang.data.api.response.chat.ChatItemList
|
||||
import com.alya.ecommerce_serang.data.api.response.chat.SendChatResponse
|
||||
import com.alya.ecommerce_serang.data.api.response.chat.UpdateChatResponse
|
||||
import com.alya.ecommerce_serang.data.api.response.customer.product.ProductResponse
|
||||
import com.alya.ecommerce_serang.data.api.retrofit.ApiService
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
@ -293,4 +294,22 @@ class ChatRepository @Inject constructor(
|
||||
Result.Error(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchProductDetail(productId: Int): ProductResponse? {
|
||||
return try {
|
||||
val response = apiService.getDetailProduct(productId)
|
||||
if (response.isSuccessful) {
|
||||
val productResponse = response.body()
|
||||
Log.d("Order Repository", "Product detail fetched successfully: ${productResponse?.product?.productName}")
|
||||
productResponse
|
||||
} else {
|
||||
val errorBody = response.errorBody()?.string() ?: "Unknown error"
|
||||
Log.e("Order Repository", "Error fetching product detail. Code: ${response.code()}, Error: $errorBody")
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("Order Repository", "Exception fetching product", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
@ -34,6 +34,7 @@ import com.alya.ecommerce_serang.data.api.retrofit.ApiConfig
|
||||
import com.alya.ecommerce_serang.data.api.retrofit.ApiService
|
||||
import com.alya.ecommerce_serang.databinding.ActivityChatBinding
|
||||
import com.alya.ecommerce_serang.ui.auth.LoginActivity
|
||||
import com.alya.ecommerce_serang.ui.product.DetailProductActivity
|
||||
import com.alya.ecommerce_serang.utils.Constants
|
||||
import com.alya.ecommerce_serang.utils.SessionManager
|
||||
import com.bumptech.glide.Glide
|
||||
@ -43,7 +44,6 @@ import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.max
|
||||
|
||||
@AndroidEntryPoint
|
||||
class ChatActivity : AppCompatActivity() {
|
||||
@ -103,9 +103,6 @@ class ChatActivity : AppCompatActivity() {
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
enableEdgeToEdge()
|
||||
|
||||
// Apply insets to your root layout
|
||||
|
||||
|
||||
// Get parameters from intent
|
||||
val storeId = intent.getIntExtra(Constants.EXTRA_STORE_ID, 0)
|
||||
val productId = intent.getIntExtra(Constants.EXTRA_PRODUCT_ID, 0)
|
||||
@ -117,6 +114,8 @@ class ChatActivity : AppCompatActivity() {
|
||||
val chatRoomId = intent.getIntExtra(Constants.EXTRA_CHAT_ROOM_ID, 0)
|
||||
val storeImg = intent.getStringExtra(Constants.EXTRA_STORE_IMAGE) ?: ""
|
||||
|
||||
val shouldAttachProduct = intent.getBooleanExtra(Constants.EXTRA_ATTACH_PRODUCT, false)
|
||||
|
||||
// Check if user is logged in
|
||||
val token = sessionManager.getToken()
|
||||
|
||||
@ -141,84 +140,6 @@ class ChatActivity : AppCompatActivity() {
|
||||
.placeholder(R.drawable.placeholder_image)
|
||||
.into(binding.imgProfile)
|
||||
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.layoutChatInput) { view, insets ->
|
||||
val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime())
|
||||
val navBarInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars())
|
||||
|
||||
val bottomPadding = max(imeInsets.bottom, navBarInsets.bottom)
|
||||
view.setPadding(view.paddingLeft, view.paddingTop, view.paddingRight, bottomPadding)
|
||||
insets
|
||||
}
|
||||
|
||||
// Handle top inset on toolbar (status bar height)
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.chatToolbar) { view, insets ->
|
||||
val statusBarHeight = insets.getInsets(WindowInsetsCompat.Type.statusBars()).top
|
||||
view.setPadding(view.paddingLeft, statusBarHeight, view.paddingRight, view.paddingBottom)
|
||||
insets
|
||||
}
|
||||
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerChat) { view, insets ->
|
||||
val navBarInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars())
|
||||
val bottomPadding = binding.layoutChatInput.height + navBarInsets.bottom
|
||||
|
||||
view.setPadding(
|
||||
view.paddingLeft,
|
||||
view.paddingTop,
|
||||
view.paddingRight,
|
||||
bottomPadding
|
||||
)
|
||||
insets
|
||||
}
|
||||
|
||||
// For RecyclerView, add bottom padding = chat input height + nav bar height (to avoid last message hidden)
|
||||
|
||||
ViewCompat.setWindowInsetsAnimationCallback(binding.root,
|
||||
object : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_STOP) {
|
||||
|
||||
private var startPaddingBottom = 0
|
||||
private var endPaddingBottom = 0
|
||||
|
||||
override fun onPrepare(animation: WindowInsetsAnimationCompat) {
|
||||
startPaddingBottom = binding.layoutChatInput.paddingBottom
|
||||
}
|
||||
|
||||
override fun onStart(
|
||||
animation: WindowInsetsAnimationCompat,
|
||||
bounds: WindowInsetsAnimationCompat.BoundsCompat
|
||||
): WindowInsetsAnimationCompat.BoundsCompat {
|
||||
endPaddingBottom = binding.layoutChatInput.paddingBottom
|
||||
return bounds
|
||||
}
|
||||
|
||||
override fun onProgress(
|
||||
insets: WindowInsetsCompat,
|
||||
runningAnimations: MutableList<WindowInsetsAnimationCompat>
|
||||
): WindowInsetsCompat {
|
||||
val imeAnimation = runningAnimations.find {
|
||||
it.typeMask and WindowInsetsCompat.Type.ime() != 0
|
||||
} ?: return insets
|
||||
|
||||
val animatedBottomPadding = startPaddingBottom +
|
||||
(endPaddingBottom - startPaddingBottom) * imeAnimation.interpolatedFraction
|
||||
|
||||
binding.layoutChatInput.setPadding(
|
||||
binding.layoutChatInput.paddingLeft,
|
||||
binding.layoutChatInput.paddingTop,
|
||||
binding.layoutChatInput.paddingRight,
|
||||
animatedBottomPadding.toInt()
|
||||
)
|
||||
|
||||
binding.recyclerChat.setPadding(
|
||||
binding.recyclerChat.paddingLeft,
|
||||
binding.recyclerChat.paddingTop,
|
||||
binding.recyclerChat.paddingRight,
|
||||
animatedBottomPadding.toInt() + binding.layoutChatInput.height
|
||||
)
|
||||
|
||||
return insets
|
||||
}
|
||||
})
|
||||
|
||||
// Set chat parameters to ViewModel
|
||||
viewModel.setChatParameters(
|
||||
storeId = storeId,
|
||||
@ -230,8 +151,14 @@ class ChatActivity : AppCompatActivity() {
|
||||
storeName = storeName
|
||||
)
|
||||
|
||||
if (shouldAttachProduct && productId > 0) {
|
||||
viewModel.enableProductAttachment()
|
||||
showProductAttachmentToast()
|
||||
}
|
||||
|
||||
// Setup UI components
|
||||
setupRecyclerView()
|
||||
setupWindowInsets()
|
||||
setupListeners()
|
||||
setupTypingIndicator()
|
||||
observeViewModel()
|
||||
@ -243,20 +170,95 @@ class ChatActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun showProductAttachmentToast() {
|
||||
Toast.makeText(
|
||||
this,
|
||||
"Product will be attached to your message",
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
|
||||
private fun setupRecyclerView() {
|
||||
chatAdapter = ChatAdapter()
|
||||
chatAdapter = ChatAdapter { productInfo ->
|
||||
// This lambda will be called when user taps on a product bubble
|
||||
handleProductClick(productInfo)
|
||||
}
|
||||
binding.recyclerChat.apply {
|
||||
adapter = chatAdapter
|
||||
layoutManager = LinearLayoutManager(this@ChatActivity).apply {
|
||||
stackFromEnd = true
|
||||
layoutManager = LinearLayoutManager(this@ChatActivity)
|
||||
// Use clipToPadding to allow content to scroll under padding
|
||||
clipToPadding = false
|
||||
// Set minimal padding - we'll handle spacing differently
|
||||
setPadding(paddingLeft, paddingTop, paddingRight, 16)
|
||||
}
|
||||
}
|
||||
// binding.recyclerChat.setPadding(
|
||||
// binding.recyclerChat.paddingLeft,
|
||||
// binding.recyclerChat.paddingTop,
|
||||
// binding.recyclerChat.paddingRight,
|
||||
// binding.layoutChatInput.height + binding.root.rootWindowInsets?.getInsets(WindowInsetsCompat.Type.navigationBars())?.bottom ?: 0
|
||||
// )
|
||||
|
||||
private fun setupWindowInsets() {
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.chatToolbar) { view, insets ->
|
||||
val statusBarInsets = insets.getInsets(WindowInsetsCompat.Type.statusBars())
|
||||
view.updatePadding(top = statusBarInsets.top)
|
||||
insets
|
||||
}
|
||||
|
||||
// Handle IME (keyboard) and navigation bar insets for the input layout only
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.layoutChatInput) { view, insets ->
|
||||
val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime())
|
||||
val navBarInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars())
|
||||
|
||||
Log.d(TAG, "Insets - IME: ${imeInsets.bottom}, NavBar: ${navBarInsets.bottom}")
|
||||
|
||||
val bottomInset = if (imeInsets.bottom > 0) {
|
||||
imeInsets.bottom
|
||||
} else {
|
||||
navBarInsets.bottom
|
||||
}
|
||||
|
||||
// Only apply padding to the input layout
|
||||
view.updatePadding(bottom = bottomInset)
|
||||
|
||||
// When keyboard appears, scroll to bottom to keep last message visible
|
||||
if (imeInsets.bottom > 0) {
|
||||
// Keyboard is visible - scroll to bottom with delay to ensure layout is complete
|
||||
binding.recyclerChat.postDelayed({
|
||||
scrollToBottomSmooth()
|
||||
}, 100)
|
||||
}
|
||||
|
||||
insets
|
||||
}
|
||||
|
||||
// Smooth animation for keyboard transitions
|
||||
ViewCompat.setWindowInsetsAnimationCallback(
|
||||
binding.layoutChatInput,
|
||||
object : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_STOP) {
|
||||
|
||||
override fun onProgress(
|
||||
insets: WindowInsetsCompat,
|
||||
runningAnimations: MutableList<WindowInsetsAnimationCompat>
|
||||
): WindowInsetsCompat {
|
||||
val imeAnimation = runningAnimations.find {
|
||||
it.typeMask and WindowInsetsCompat.Type.ime() != 0
|
||||
}
|
||||
|
||||
if (imeAnimation != null) {
|
||||
val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime())
|
||||
val navBarInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars())
|
||||
val targetBottomInset = if (imeInsets.bottom > 0) imeInsets.bottom else navBarInsets.bottom
|
||||
|
||||
// Only animate input layout padding
|
||||
binding.layoutChatInput.updatePadding(bottom = targetBottomInset)
|
||||
}
|
||||
|
||||
return insets
|
||||
}
|
||||
|
||||
override fun onEnd(animation: WindowInsetsAnimationCompat) {
|
||||
super.onEnd(animation)
|
||||
// Smooth scroll to bottom after animation
|
||||
scrollToBottomSmooth()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -276,8 +278,14 @@ class ChatActivity : AppCompatActivity() {
|
||||
val message = binding.editTextMessage.text.toString().trim()
|
||||
val currentState = viewModel.state.value
|
||||
if (message.isNotEmpty() || (currentState != null && currentState.hasAttachment)) {
|
||||
// This will automatically handle product attachment if enabled
|
||||
viewModel.sendMessage(message)
|
||||
binding.editTextMessage.text.clear()
|
||||
|
||||
// Instantly scroll to show new message
|
||||
binding.recyclerChat.postDelayed({
|
||||
scrollToBottomInstant()
|
||||
}, 50)
|
||||
}
|
||||
}
|
||||
|
||||
@ -285,6 +293,38 @@ class ChatActivity : AppCompatActivity() {
|
||||
binding.btnAttachment.setOnClickListener {
|
||||
checkPermissionsAndShowImagePicker()
|
||||
}
|
||||
|
||||
// Product card click to enable/disable product attachment
|
||||
binding.productContainer.setOnClickListener {
|
||||
toggleProductAttachment()
|
||||
}
|
||||
}
|
||||
|
||||
private fun toggleProductAttachment() {
|
||||
val currentState = viewModel.state.value
|
||||
if (currentState?.hasProductAttachment == true) {
|
||||
// Disable product attachment
|
||||
viewModel.disableProductAttachment()
|
||||
updateProductAttachmentUI(false)
|
||||
Toast.makeText(this, "Product attachment disabled", Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
// Enable product attachment
|
||||
viewModel.enableProductAttachment()
|
||||
updateProductAttachmentUI(true)
|
||||
Toast.makeText(this, "Product will be attached to your next message", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateProductAttachmentUI(isEnabled: Boolean) {
|
||||
if (isEnabled) {
|
||||
// Show visual indicator that product will be attached
|
||||
binding.productContainer.setBackgroundResource(R.drawable.bg_product_selected)
|
||||
binding.editTextMessage.hint = "Type your message (product will be attached)"
|
||||
} else {
|
||||
// Reset to normal state
|
||||
binding.productContainer.setBackgroundResource(R.drawable.bg_product_normal)
|
||||
binding.editTextMessage.hint = getString(R.string.write_message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupTypingIndicator() {
|
||||
@ -302,33 +342,64 @@ class ChatActivity : AppCompatActivity() {
|
||||
override fun afterTextChanged(s: Editable?) {}
|
||||
})
|
||||
|
||||
// Focus and show keyboard
|
||||
binding.editTextMessage.requestFocus()
|
||||
binding.editTextMessage.post {
|
||||
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.showSoftInput(binding.editTextMessage, InputMethodManager.SHOW_IMPLICIT)
|
||||
}
|
||||
}
|
||||
|
||||
private fun scrollToBottomSmooth() {
|
||||
val messageCount = chatAdapter.itemCount
|
||||
if (messageCount > 0) {
|
||||
binding.recyclerChat.post {
|
||||
// Use smooth scroll to bottom
|
||||
binding.recyclerChat.smoothScrollToPosition(messageCount - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun scrollToBottomInstant() {
|
||||
val messageCount = chatAdapter.itemCount
|
||||
if (messageCount > 0) {
|
||||
binding.recyclerChat.post {
|
||||
// Instant scroll for new messages
|
||||
binding.recyclerChat.scrollToPosition(messageCount - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extension function to make padding updates cleaner
|
||||
private fun View.updatePadding(
|
||||
left: Int = paddingLeft,
|
||||
top: Int = paddingTop,
|
||||
right: Int = paddingRight,
|
||||
bottom: Int = paddingBottom
|
||||
) {
|
||||
setPadding(left, top, right, bottom)
|
||||
}
|
||||
|
||||
private fun observeViewModel() {
|
||||
viewModel.chatRoomId.observe(this, Observer { chatRoomId ->
|
||||
if (chatRoomId > 0) {
|
||||
// Chat room has been created, now we can join the Socket.IO room
|
||||
viewModel.joinSocketRoom(chatRoomId)
|
||||
|
||||
// Now we can also load chat history
|
||||
viewModel.loadChatHistory(chatRoomId)
|
||||
Log.d(TAG, "Chat Activity started - Chat Room: $chatRoomId")
|
||||
|
||||
}
|
||||
})
|
||||
|
||||
// Observe state changes using LiveData
|
||||
viewModel.state.observe(this, Observer { state ->
|
||||
// Update messages
|
||||
chatAdapter.submitList(state.messages)
|
||||
Log.d(TAG, "State updated - Messages: ${state.messages.size}")
|
||||
|
||||
// Scroll to bottom if new message
|
||||
if (state.messages.isNotEmpty()) {
|
||||
binding.recyclerChat.scrollToPosition(state.messages.size - 1)
|
||||
// Update messages
|
||||
val previousCount = chatAdapter.itemCount
|
||||
chatAdapter.submitList(state.messages) {
|
||||
Log.d(TAG, "Messages submitted to adapter")
|
||||
// Only auto-scroll for new messages or initial load
|
||||
if (previousCount == 0 || state.messages.size > previousCount) {
|
||||
scrollToBottomInstant()
|
||||
}
|
||||
}
|
||||
|
||||
// Update product info
|
||||
@ -338,7 +409,7 @@ class ChatActivity : AppCompatActivity() {
|
||||
binding.ratingBar.rating = state.productRating
|
||||
binding.tvRating.text = state.productRating.toString()
|
||||
binding.tvSellerName.text = state.storeName
|
||||
binding.tvStoreName.text=state.storeName
|
||||
binding.tvStoreName.text = state.storeName
|
||||
|
||||
val fullImageUrl = when (val img = state.productImageUrl) {
|
||||
is String -> {
|
||||
@ -347,7 +418,6 @@ class ChatActivity : AppCompatActivity() {
|
||||
else -> R.drawable.placeholder_image
|
||||
}
|
||||
|
||||
// Load product image
|
||||
if (!state.productImageUrl.isNullOrEmpty()) {
|
||||
Glide.with(this@ChatActivity)
|
||||
.load(fullImageUrl)
|
||||
@ -357,13 +427,15 @@ class ChatActivity : AppCompatActivity() {
|
||||
.into(binding.imgProduct)
|
||||
}
|
||||
|
||||
// Make sure the product section is visible
|
||||
binding.productContainer.visibility = View.VISIBLE
|
||||
} else {
|
||||
// Hide the product section if info is missing
|
||||
binding.productContainer.visibility = View.GONE
|
||||
}
|
||||
|
||||
updateInputHint(state)
|
||||
|
||||
// Update product card visual feedback
|
||||
updateProductCardUI(state.hasProductAttachment)
|
||||
|
||||
// Update attachment hint
|
||||
if (state.hasAttachment) {
|
||||
@ -372,7 +444,6 @@ class ChatActivity : AppCompatActivity() {
|
||||
binding.editTextMessage.hint = getString(R.string.write_message)
|
||||
}
|
||||
|
||||
|
||||
// Show typing indicator
|
||||
binding.tvTypingIndicator.visibility =
|
||||
if (state.isOtherUserTyping) View.VISIBLE else View.GONE
|
||||
@ -385,6 +456,45 @@ class ChatActivity : AppCompatActivity() {
|
||||
})
|
||||
}
|
||||
|
||||
private fun updateInputHint(state: ChatUiState) {
|
||||
binding.editTextMessage.hint = when {
|
||||
state.hasAttachment -> getString(R.string.image_attached)
|
||||
state.hasProductAttachment -> "Type your message (product will be attached)"
|
||||
else -> getString(R.string.write_message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateProductCardUI(hasProductAttachment: Boolean) {
|
||||
if (hasProductAttachment) {
|
||||
// Show visual indicator that product will be attached
|
||||
binding.productContainer.setBackgroundResource(R.drawable.bg_product_selected)
|
||||
} else {
|
||||
// Reset to normal state
|
||||
binding.productContainer.setBackgroundResource(R.drawable.bg_product_normal)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleProductClick(productInfo: ProductInfo) {
|
||||
// Navigate to product detail
|
||||
Toast.makeText(this, "Opening: ${productInfo.productName}", Toast.LENGTH_SHORT).show()
|
||||
|
||||
// You can navigate to product detail here
|
||||
navigateToProductDetail(productInfo.productId)
|
||||
}
|
||||
|
||||
private fun navigateToProductDetail(productId: Int) {
|
||||
try {
|
||||
val intent = Intent(this, DetailProductActivity::class.java).apply {
|
||||
putExtra("PRODUCT_ID", productId)
|
||||
// Add other necessary extras
|
||||
}
|
||||
startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(this, "Cannot open product details", Toast.LENGTH_SHORT).show()
|
||||
Log.e(TAG, "Error navigating to product detail", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showOptionsMenu() {
|
||||
val options = arrayOf(
|
||||
getString(R.string.block_user),
|
||||
@ -538,7 +648,8 @@ class ChatActivity : AppCompatActivity() {
|
||||
productRating: String? = null,
|
||||
storeName: String? = null,
|
||||
chatRoomId: Int = 0,
|
||||
storeImage: String? = null
|
||||
storeImage: String? = null,
|
||||
attachProduct: Boolean = false // NEW: Flag to auto-attach product
|
||||
) {
|
||||
val intent = Intent(context, ChatActivity::class.java).apply {
|
||||
putExtra(Constants.EXTRA_STORE_ID, storeId)
|
||||
@ -547,6 +658,7 @@ class ChatActivity : AppCompatActivity() {
|
||||
putExtra(Constants.EXTRA_PRODUCT_PRICE, productPrice)
|
||||
putExtra(Constants.EXTRA_PRODUCT_IMAGE, productImage)
|
||||
putExtra(Constants.EXTRA_STORE_IMAGE, storeImage)
|
||||
putExtra(Constants.EXTRA_ATTACH_PRODUCT, attachProduct) // NEW
|
||||
|
||||
// Convert productRating string to float if provided
|
||||
if (productRating != null) {
|
||||
|
@ -8,56 +8,71 @@ import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.alya.ecommerce_serang.BuildConfig.BASE_URL
|
||||
import com.alya.ecommerce_serang.R
|
||||
import com.alya.ecommerce_serang.databinding.ItemMessageProductReceivedBinding
|
||||
import com.alya.ecommerce_serang.databinding.ItemMessageProductSentBinding
|
||||
import com.alya.ecommerce_serang.databinding.ItemMessageReceivedBinding
|
||||
import com.alya.ecommerce_serang.databinding.ItemMessageSentBinding
|
||||
import com.alya.ecommerce_serang.utils.Constants
|
||||
import com.bumptech.glide.Glide
|
||||
|
||||
class ChatAdapter : ListAdapter<ChatUiMessage, RecyclerView.ViewHolder>(ChatMessageDiffCallback()) {
|
||||
class ChatAdapter(
|
||||
private val onProductClick: ((ProductInfo) -> Unit)? = null
|
||||
) : ListAdapter<ChatUiMessage, RecyclerView.ViewHolder>(ChatMessageDiffCallback()) {
|
||||
|
||||
companion object {
|
||||
private const val VIEW_TYPE_MESSAGE_SENT = 1
|
||||
private const val VIEW_TYPE_MESSAGE_RECEIVED = 2
|
||||
private const val VIEW_TYPE_PRODUCT_SENT = 3
|
||||
private const val VIEW_TYPE_PRODUCT_RECEIVED = 4
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int): Int {
|
||||
val message = getItem(position)
|
||||
return when {
|
||||
message.messageType == MessageType.PRODUCT && message.isSentByMe -> VIEW_TYPE_PRODUCT_SENT
|
||||
message.messageType == MessageType.PRODUCT && !message.isSentByMe -> VIEW_TYPE_PRODUCT_RECEIVED
|
||||
message.isSentByMe -> VIEW_TYPE_MESSAGE_SENT
|
||||
else -> VIEW_TYPE_MESSAGE_RECEIVED
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
return if (viewType == VIEW_TYPE_MESSAGE_SENT) {
|
||||
val binding = ItemMessageSentBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
val inflater = LayoutInflater.from(parent.context)
|
||||
|
||||
return when (viewType) {
|
||||
VIEW_TYPE_MESSAGE_SENT -> {
|
||||
val binding = ItemMessageSentBinding.inflate(inflater, parent, false)
|
||||
SentMessageViewHolder(binding)
|
||||
} else {
|
||||
val binding = ItemMessageReceivedBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
}
|
||||
VIEW_TYPE_MESSAGE_RECEIVED -> {
|
||||
val binding = ItemMessageReceivedBinding.inflate(inflater, parent, false)
|
||||
ReceivedMessageViewHolder(binding)
|
||||
}
|
||||
VIEW_TYPE_PRODUCT_SENT -> {
|
||||
val binding = ItemMessageProductSentBinding.inflate(inflater, parent, false)
|
||||
SentProductViewHolder(binding)
|
||||
}
|
||||
VIEW_TYPE_PRODUCT_RECEIVED -> {
|
||||
val binding = ItemMessageProductReceivedBinding.inflate(inflater, parent, false)
|
||||
ReceivedProductViewHolder(binding)
|
||||
}
|
||||
else -> throw IllegalArgumentException("Unknown view type: $viewType")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
val message = getItem(position)
|
||||
|
||||
when (holder.itemViewType) {
|
||||
VIEW_TYPE_MESSAGE_SENT -> (holder as SentMessageViewHolder).bind(message)
|
||||
VIEW_TYPE_MESSAGE_RECEIVED -> (holder as ReceivedMessageViewHolder).bind(message)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int): Int {
|
||||
val message = getItem(position)
|
||||
return if (message.isSentByMe) {
|
||||
VIEW_TYPE_MESSAGE_SENT
|
||||
} else {
|
||||
VIEW_TYPE_MESSAGE_RECEIVED
|
||||
when (holder) {
|
||||
is SentMessageViewHolder -> holder.bind(message)
|
||||
is ReceivedMessageViewHolder -> holder.bind(message)
|
||||
is SentProductViewHolder -> holder.bind(message)
|
||||
is ReceivedProductViewHolder -> holder.bind(message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ViewHolder for messages sent by the current user
|
||||
* ViewHolder for regular messages sent by the current user
|
||||
*/
|
||||
inner class SentMessageViewHolder(private val binding: ItemMessageSentBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
@ -99,7 +114,7 @@ class ChatAdapter : ListAdapter<ChatUiMessage, RecyclerView.ViewHolder>(ChatMess
|
||||
}
|
||||
|
||||
/**
|
||||
* ViewHolder for messages received from other users
|
||||
* ViewHolder for regular messages received from other users
|
||||
*/
|
||||
inner class ReceivedMessageViewHolder(private val binding: ItemMessageReceivedBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
@ -135,6 +150,89 @@ class ChatAdapter : ListAdapter<ChatUiMessage, RecyclerView.ViewHolder>(ChatMess
|
||||
.into(binding.imgAvatar)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ViewHolder for product messages sent by the current user
|
||||
*/
|
||||
inner class SentProductViewHolder(private val binding: ItemMessageProductSentBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
fun bind(message: ChatUiMessage) {
|
||||
// For product bubble, we don't show the text message here
|
||||
binding.tvTimestamp.text = message.time
|
||||
|
||||
// Show message status
|
||||
val statusIcon = when (message.status) {
|
||||
Constants.STATUS_SENT -> R.drawable.check_single_24
|
||||
Constants.STATUS_DELIVERED -> R.drawable.check_double_24
|
||||
Constants.STATUS_READ -> R.drawable.check_double_read_24
|
||||
else -> R.drawable.check_single_24
|
||||
}
|
||||
binding.imgStatus.setImageResource(statusIcon)
|
||||
|
||||
// Bind product info
|
||||
message.productInfo?.let { product ->
|
||||
binding.tvProductName.text = product.productName
|
||||
binding.tvProductPrice.text = product.productPrice
|
||||
|
||||
// Load product image
|
||||
val fullImageUrl = if (product.productImage.startsWith("/")) {
|
||||
BASE_URL + product.productImage.substring(1)
|
||||
} else {
|
||||
product.productImage
|
||||
}
|
||||
|
||||
Glide.with(binding.root.context)
|
||||
.load(fullImageUrl)
|
||||
.centerCrop()
|
||||
.placeholder(R.drawable.placeholder_image)
|
||||
.error(R.drawable.placeholder_image)
|
||||
.into(binding.imgProduct)
|
||||
|
||||
// Handle product click
|
||||
binding.layoutProduct.setOnClickListener {
|
||||
onProductClick?.invoke(product)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ViewHolder for product messages received from other users
|
||||
*/
|
||||
inner class ReceivedProductViewHolder(private val binding: ItemMessageProductReceivedBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
fun bind(message: ChatUiMessage) {
|
||||
// For product bubble, we don't show the text message here
|
||||
binding.tvTimestamp.text = message.time
|
||||
|
||||
// Bind product info
|
||||
message.productInfo?.let { product ->
|
||||
binding.tvProductName.text = product.productName
|
||||
binding.tvProductPrice.text = product.productPrice
|
||||
|
||||
// Load product image
|
||||
val fullImageUrl = if (product.productImage.startsWith("/")) {
|
||||
BASE_URL + product.productImage.substring(1)
|
||||
} else {
|
||||
product.productImage
|
||||
}
|
||||
|
||||
Glide.with(binding.root.context)
|
||||
.load(fullImageUrl)
|
||||
.centerCrop()
|
||||
.placeholder(R.drawable.placeholder_image)
|
||||
.error(R.drawable.placeholder_image)
|
||||
.into(binding.imgProduct)
|
||||
|
||||
// Handle product click
|
||||
binding.layoutProduct.setOnClickListener {
|
||||
onProductClick?.invoke(product)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -483,7 +483,9 @@ class DetailProductActivity : AppCompatActivity() {
|
||||
productRating = productDetail.rating,
|
||||
storeName = storeDetail.data.storeName,
|
||||
chatRoomId = 0,
|
||||
storeImage = storeDetail.data.storeImage
|
||||
storeImage = storeDetail.data.storeImage,
|
||||
attachProduct = true // This will auto-attach the product!
|
||||
|
||||
)
|
||||
|
||||
}
|
||||
|
@ -80,7 +80,7 @@ class ChatListStoreActivity : AppCompatActivity() {
|
||||
when (result) {
|
||||
is Result.Success -> {
|
||||
Log.d(TAG, "Chat list fetch success. Data size: ${result.data.size}")
|
||||
val adapter = ChatListAdapter(result.data) { chatItem ->
|
||||
val adapter = ChatStoreListAdapter(result.data) { chatItem ->
|
||||
Log.d(TAG, "Chat item clicked: storeId=${chatItem.storeId}, chatRoomId=${chatItem.chatRoomId}")
|
||||
val intent = ChatStoreActivity.createIntent(
|
||||
context = this,
|
||||
@ -93,7 +93,10 @@ class ChatListStoreActivity : AppCompatActivity() {
|
||||
storeName = chatItem.storeName,
|
||||
chatRoomId = chatItem.chatRoomId,
|
||||
storeImage = chatItem.storeImage,
|
||||
userId = chatItem.userId
|
||||
userId = chatItem.userId,
|
||||
userName = chatItem.userName,
|
||||
userImg = chatItem.userImage
|
||||
|
||||
)
|
||||
startActivity(intent)
|
||||
}
|
||||
|
@ -15,7 +15,6 @@ import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.Toast
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
@ -23,7 +22,6 @@ import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsAnimationCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.lifecycle.Observer
|
||||
@ -45,7 +43,6 @@ import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.max
|
||||
|
||||
@AndroidEntryPoint
|
||||
class ChatStoreActivity : AppCompatActivity() {
|
||||
@ -102,10 +99,10 @@ class ChatStoreActivity : AppCompatActivity() {
|
||||
|
||||
Log.d("ChatActivity", "Token in storage: '${sessionManager.getToken()}'")
|
||||
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
enableEdgeToEdge()
|
||||
|
||||
// Apply insets to your root layout
|
||||
// WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
// enableEdgeToEdge()
|
||||
//
|
||||
// // Apply insets to your root layout
|
||||
|
||||
|
||||
// Get parameters from intent
|
||||
@ -119,6 +116,8 @@ class ChatStoreActivity : AppCompatActivity() {
|
||||
val storeName = intent.getStringExtra(Constants.EXTRA_STORE_NAME) ?: ""
|
||||
val chatRoomId = intent.getIntExtra(Constants.EXTRA_CHAT_ROOM_ID, 0)
|
||||
val storeImg = intent.getStringExtra(Constants.EXTRA_STORE_IMAGE) ?: ""
|
||||
val userName = intent.getStringExtra(Constants.EXTRA_USER_NAME) ?: ""
|
||||
val userImg = intent.getStringExtra(Constants.EXTRA_USER_IMAGE) ?: ""
|
||||
|
||||
// Check if user is logged in
|
||||
val token = sessionManager.getToken()
|
||||
@ -131,8 +130,8 @@ class ChatStoreActivity : AppCompatActivity() {
|
||||
return
|
||||
}
|
||||
|
||||
binding.tvStoreName.text = storeName
|
||||
val fullImageUrl = when (val img = storeImg) {
|
||||
binding.tvStoreName.text = userName
|
||||
val fullImageUrl = when (val img = userImg) {
|
||||
is String -> {
|
||||
if (img.startsWith("/")) BASE_URL + img.substring(1) else img
|
||||
}
|
||||
@ -144,84 +143,6 @@ class ChatStoreActivity : AppCompatActivity() {
|
||||
.placeholder(R.drawable.placeholder_image)
|
||||
.into(binding.imgProfile)
|
||||
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.layoutChatInput) { view, insets ->
|
||||
val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime())
|
||||
val navBarInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars())
|
||||
|
||||
val bottomPadding = max(imeInsets.bottom, navBarInsets.bottom)
|
||||
view.setPadding(view.paddingLeft, view.paddingTop, view.paddingRight, bottomPadding)
|
||||
insets
|
||||
}
|
||||
|
||||
// Handle top inset on toolbar (status bar height)
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.chatToolbar) { view, insets ->
|
||||
val statusBarHeight = insets.getInsets(WindowInsetsCompat.Type.statusBars()).top
|
||||
view.setPadding(view.paddingLeft, statusBarHeight, view.paddingRight, view.paddingBottom)
|
||||
insets
|
||||
}
|
||||
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerChat) { view, insets ->
|
||||
val navBarInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars())
|
||||
val bottomPadding = binding.layoutChatInput.height + navBarInsets.bottom
|
||||
|
||||
view.setPadding(
|
||||
view.paddingLeft,
|
||||
view.paddingTop,
|
||||
view.paddingRight,
|
||||
bottomPadding
|
||||
)
|
||||
insets
|
||||
}
|
||||
|
||||
// For RecyclerView, add bottom padding = chat input height + nav bar height (to avoid last message hidden)
|
||||
|
||||
ViewCompat.setWindowInsetsAnimationCallback(binding.root,
|
||||
object : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_STOP) {
|
||||
|
||||
private var startPaddingBottom = 0
|
||||
private var endPaddingBottom = 0
|
||||
|
||||
override fun onPrepare(animation: WindowInsetsAnimationCompat) {
|
||||
startPaddingBottom = binding.layoutChatInput.paddingBottom
|
||||
}
|
||||
|
||||
override fun onStart(
|
||||
animation: WindowInsetsAnimationCompat,
|
||||
bounds: WindowInsetsAnimationCompat.BoundsCompat
|
||||
): WindowInsetsAnimationCompat.BoundsCompat {
|
||||
endPaddingBottom = binding.layoutChatInput.paddingBottom
|
||||
return bounds
|
||||
}
|
||||
|
||||
override fun onProgress(
|
||||
insets: WindowInsetsCompat,
|
||||
runningAnimations: MutableList<WindowInsetsAnimationCompat>
|
||||
): WindowInsetsCompat {
|
||||
val imeAnimation = runningAnimations.find {
|
||||
it.typeMask and WindowInsetsCompat.Type.ime() != 0
|
||||
} ?: return insets
|
||||
|
||||
val animatedBottomPadding = startPaddingBottom +
|
||||
(endPaddingBottom - startPaddingBottom) * imeAnimation.interpolatedFraction
|
||||
|
||||
binding.layoutChatInput.setPadding(
|
||||
binding.layoutChatInput.paddingLeft,
|
||||
binding.layoutChatInput.paddingTop,
|
||||
binding.layoutChatInput.paddingRight,
|
||||
animatedBottomPadding.toInt()
|
||||
)
|
||||
|
||||
binding.recyclerChat.setPadding(
|
||||
binding.recyclerChat.paddingLeft,
|
||||
binding.recyclerChat.paddingTop,
|
||||
binding.recyclerChat.paddingRight,
|
||||
animatedBottomPadding.toInt() + binding.layoutChatInput.height
|
||||
)
|
||||
|
||||
return insets
|
||||
}
|
||||
})
|
||||
|
||||
// Set chat parameters to ViewModel
|
||||
viewModel.setChatParametersStore(
|
||||
storeId = storeId,
|
||||
@ -234,8 +155,10 @@ class ChatStoreActivity : AppCompatActivity() {
|
||||
storeName = storeName
|
||||
)
|
||||
|
||||
// Setup UI components
|
||||
|
||||
// Then setup other components
|
||||
setupRecyclerView()
|
||||
setupWindowInsets()
|
||||
setupListeners()
|
||||
setupTypingIndicator()
|
||||
observeViewModel()
|
||||
@ -251,18 +174,140 @@ class ChatStoreActivity : AppCompatActivity() {
|
||||
chatAdapter = ChatAdapter()
|
||||
binding.recyclerChat.apply {
|
||||
adapter = chatAdapter
|
||||
layoutManager = LinearLayoutManager(this@ChatStoreActivity).apply {
|
||||
stackFromEnd = true
|
||||
layoutManager = LinearLayoutManager(this@ChatStoreActivity)
|
||||
// Use clipToPadding to allow content to scroll under padding
|
||||
clipToPadding = false
|
||||
// Set minimal padding - we'll handle spacing differently
|
||||
setPadding(paddingLeft, paddingTop, paddingRight, 16)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupWindowInsets() {
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.chatToolbar) { view, insets ->
|
||||
val statusBarInsets = insets.getInsets(WindowInsetsCompat.Type.statusBars())
|
||||
view.updatePadding(top = statusBarInsets.top)
|
||||
insets
|
||||
}
|
||||
|
||||
// Handle IME (keyboard) and navigation bar insets for the input layout only
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.layoutChatInput) { view, insets ->
|
||||
val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime())
|
||||
val navBarInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars())
|
||||
|
||||
Log.d(TAG, "Insets - IME: ${imeInsets.bottom}, NavBar: ${navBarInsets.bottom}")
|
||||
|
||||
val bottomInset = if (imeInsets.bottom > 0) {
|
||||
imeInsets.bottom
|
||||
} else {
|
||||
navBarInsets.bottom
|
||||
}
|
||||
|
||||
// Only apply padding to the input layout
|
||||
view.updatePadding(bottom = bottomInset)
|
||||
|
||||
// When keyboard appears, scroll to bottom to keep last message visible
|
||||
if (imeInsets.bottom > 0) {
|
||||
// Keyboard is visible - scroll to bottom with delay to ensure layout is complete
|
||||
binding.recyclerChat.postDelayed({
|
||||
scrollToBottomSmooth()
|
||||
}, 100)
|
||||
}
|
||||
|
||||
insets
|
||||
}
|
||||
|
||||
// Smooth animation for keyboard transitions
|
||||
ViewCompat.setWindowInsetsAnimationCallback(
|
||||
binding.layoutChatInput,
|
||||
object : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_STOP) {
|
||||
|
||||
override fun onProgress(
|
||||
insets: WindowInsetsCompat,
|
||||
runningAnimations: MutableList<WindowInsetsAnimationCompat>
|
||||
): WindowInsetsCompat {
|
||||
val imeAnimation = runningAnimations.find {
|
||||
it.typeMask and WindowInsetsCompat.Type.ime() != 0
|
||||
}
|
||||
|
||||
if (imeAnimation != null) {
|
||||
val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime())
|
||||
val navBarInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars())
|
||||
val targetBottomInset = if (imeInsets.bottom > 0) imeInsets.bottom else navBarInsets.bottom
|
||||
|
||||
// Only animate input layout padding
|
||||
binding.layoutChatInput.updatePadding(bottom = targetBottomInset)
|
||||
}
|
||||
|
||||
return insets
|
||||
}
|
||||
|
||||
override fun onEnd(animation: WindowInsetsAnimationCompat) {
|
||||
super.onEnd(animation)
|
||||
// Smooth scroll to bottom after animation
|
||||
scrollToBottomSmooth()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// private fun updateRecyclerViewPadding(inputLayoutBottomPadding: Int) {
|
||||
// // Calculate total bottom padding needed for RecyclerView
|
||||
// // This ensures the last message is visible above the input layout
|
||||
// val inputLayoutHeight = binding.layoutChatInput.height
|
||||
// val totalBottomPadding = inputLayoutHeight + inputLayoutBottomPadding
|
||||
//
|
||||
// binding.recyclerChat.setPadding(
|
||||
// binding.recyclerChat.paddingLeft,
|
||||
// binding.recyclerChat.paddingTop,
|
||||
// binding.recyclerChat.paddingRight,
|
||||
// totalBottomPadding
|
||||
// )
|
||||
//
|
||||
// // Scroll to bottom if there are messages
|
||||
// val messageCount = chatAdapter.itemCount
|
||||
// if (messageCount > 0) {
|
||||
// binding.recyclerChat.post {
|
||||
// binding.recyclerChat.scrollToPosition(messageCount - 1)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// binding.recyclerChat.setPadding(
|
||||
// binding.recyclerChat.paddingLeft,
|
||||
// binding.recyclerChat.paddingTop,
|
||||
// binding.recyclerChat.paddingRight,
|
||||
// binding.layoutChatInput.height + binding.root.rootWindowInsets?.getInsets(WindowInsetsCompat.Type.navigationBars())?.bottom ?: 0
|
||||
// )
|
||||
|
||||
private fun scrollToBottomSmooth() {
|
||||
val messageCount = chatAdapter.itemCount
|
||||
if (messageCount > 0) {
|
||||
binding.recyclerChat.post {
|
||||
// Use smooth scroll to bottom
|
||||
binding.recyclerChat.smoothScrollToPosition(messageCount - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun scrollToBottomInstant() {
|
||||
val messageCount = chatAdapter.itemCount
|
||||
if (messageCount > 0) {
|
||||
binding.recyclerChat.post {
|
||||
// Instant scroll for new messages
|
||||
binding.recyclerChat.scrollToPosition(messageCount - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extension function to make padding updates cleaner
|
||||
private fun View.updatePadding(
|
||||
left: Int = paddingLeft,
|
||||
top: Int = paddingTop,
|
||||
right: Int = paddingRight,
|
||||
bottom: Int = paddingBottom
|
||||
) {
|
||||
setPadding(left, top, right, bottom)
|
||||
}
|
||||
|
||||
private fun setupListeners() {
|
||||
// Back button
|
||||
@ -282,6 +327,11 @@ class ChatStoreActivity : AppCompatActivity() {
|
||||
if (message.isNotEmpty() || (currentState != null && currentState.hasAttachment)) {
|
||||
viewModel.sendMessageStore(message)
|
||||
binding.editTextMessage.text.clear()
|
||||
|
||||
// Instantly scroll to show new message
|
||||
binding.recyclerChat.postDelayed({
|
||||
scrollToBottomInstant()
|
||||
}, 50)
|
||||
}
|
||||
}
|
||||
|
||||
@ -306,33 +356,34 @@ class ChatStoreActivity : AppCompatActivity() {
|
||||
override fun afterTextChanged(s: Editable?) {}
|
||||
})
|
||||
|
||||
// Focus and show keyboard
|
||||
binding.editTextMessage.requestFocus()
|
||||
binding.editTextMessage.post {
|
||||
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.showSoftInput(binding.editTextMessage, InputMethodManager.SHOW_IMPLICIT)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeViewModel() {
|
||||
viewModel.chatRoomId.observe(this, Observer { chatRoomId ->
|
||||
if (chatRoomId > 0) {
|
||||
// Chat room has been created, now we can join the Socket.IO room
|
||||
viewModel.joinSocketRoom(chatRoomId)
|
||||
|
||||
// Now we can also load chat history
|
||||
viewModel.loadChatHistory(chatRoomId)
|
||||
Log.d(TAG, "Chat Activity started - Chat Room: $chatRoomId")
|
||||
|
||||
}
|
||||
})
|
||||
|
||||
// Observe state changes using LiveData
|
||||
viewModel.state.observe(this, Observer { state ->
|
||||
// Update messages
|
||||
chatAdapter.submitList(state.messages)
|
||||
Log.d(TAG, "State updated - Messages: ${state.messages.size}")
|
||||
|
||||
// Scroll to bottom if new message
|
||||
if (state.messages.isNotEmpty()) {
|
||||
binding.recyclerChat.scrollToPosition(state.messages.size - 1)
|
||||
// Update messages
|
||||
val previousCount = chatAdapter.itemCount
|
||||
chatAdapter.submitList(state.messages) {
|
||||
Log.d(TAG, "Messages submitted to adapter")
|
||||
// Only auto-scroll for new messages or initial load
|
||||
if (previousCount == 0 || state.messages.size > previousCount) {
|
||||
scrollToBottomInstant()
|
||||
}
|
||||
}
|
||||
|
||||
// Update product info
|
||||
@ -342,7 +393,7 @@ class ChatStoreActivity : AppCompatActivity() {
|
||||
binding.ratingBar.rating = state.productRating
|
||||
binding.tvRating.text = state.productRating.toString()
|
||||
binding.tvSellerName.text = state.storeName
|
||||
binding.tvStoreName.text=state.storeName
|
||||
// binding.tvStoreName.text = state.storeName
|
||||
|
||||
val fullImageUrl = when (val img = state.productImageUrl) {
|
||||
is String -> {
|
||||
@ -351,7 +402,6 @@ class ChatStoreActivity : AppCompatActivity() {
|
||||
else -> R.drawable.placeholder_image
|
||||
}
|
||||
|
||||
// Load product image
|
||||
if (!state.productImageUrl.isNullOrEmpty()) {
|
||||
Glide.with(this@ChatStoreActivity)
|
||||
.load(fullImageUrl)
|
||||
@ -361,14 +411,11 @@ class ChatStoreActivity : AppCompatActivity() {
|
||||
.into(binding.imgProduct)
|
||||
}
|
||||
|
||||
// Make sure the product section is visible
|
||||
binding.productContainer.visibility = View.VISIBLE
|
||||
} else {
|
||||
// Hide the product section if info is missing
|
||||
binding.productContainer.visibility = View.GONE
|
||||
}
|
||||
|
||||
|
||||
// Update attachment hint
|
||||
if (state.hasAttachment) {
|
||||
binding.editTextMessage.hint = getString(R.string.image_attached)
|
||||
@ -376,7 +423,6 @@ class ChatStoreActivity : AppCompatActivity() {
|
||||
binding.editTextMessage.hint = getString(R.string.write_message)
|
||||
}
|
||||
|
||||
|
||||
// Show typing indicator
|
||||
binding.tvTypingIndicator.visibility =
|
||||
if (state.isOtherUserTyping) View.VISIBLE else View.GONE
|
||||
@ -543,7 +589,9 @@ class ChatStoreActivity : AppCompatActivity() {
|
||||
storeName: String? = null,
|
||||
chatRoomId: Int = 0,
|
||||
storeImage: String? = null,
|
||||
userId: Int
|
||||
userId: Int,
|
||||
userName: String,
|
||||
userImg: String? = null
|
||||
): Intent {
|
||||
return Intent(context, ChatStoreActivity::class.java).apply {
|
||||
putExtra(Constants.EXTRA_STORE_ID, storeId)
|
||||
@ -553,6 +601,8 @@ class ChatStoreActivity : AppCompatActivity() {
|
||||
putExtra(Constants.EXTRA_PRODUCT_IMAGE, productImage)
|
||||
putExtra(Constants.EXTRA_STORE_IMAGE, storeImage)
|
||||
putExtra(Constants.EXTRA_USER_ID, userId)
|
||||
putExtra(Constants.EXTRA_USER_NAME,userName)
|
||||
putExtra(Constants.EXTRA_USER_IMAGE, userImg)
|
||||
|
||||
// Convert productRating string to float if provided
|
||||
if (productRating != null) {
|
||||
|
@ -0,0 +1,152 @@
|
||||
package com.alya.ecommerce_serang.ui.profile.mystore.chat
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.alya.ecommerce_serang.BuildConfig.BASE_URL
|
||||
import com.alya.ecommerce_serang.R
|
||||
import com.alya.ecommerce_serang.databinding.ItemMessageReceivedBinding
|
||||
import com.alya.ecommerce_serang.databinding.ItemMessageSentBinding
|
||||
import com.alya.ecommerce_serang.ui.chat.ChatUiMessage
|
||||
import com.alya.ecommerce_serang.utils.Constants
|
||||
import com.bumptech.glide.Glide
|
||||
|
||||
class ChatStoreAdapter : ListAdapter<ChatUiMessage, RecyclerView.ViewHolder>(ChatMessageDiffCallback()) {
|
||||
|
||||
companion object {
|
||||
private const val VIEW_TYPE_MESSAGE_SENT = 1
|
||||
private const val VIEW_TYPE_MESSAGE_RECEIVED = 2
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
return if (viewType == VIEW_TYPE_MESSAGE_SENT) {
|
||||
val binding = ItemMessageSentBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
SentMessageViewHolder(binding)
|
||||
} else {
|
||||
val binding = ItemMessageReceivedBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
ReceivedMessageViewHolder(binding)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
val message = getItem(position)
|
||||
|
||||
when (holder.itemViewType) {
|
||||
VIEW_TYPE_MESSAGE_SENT -> (holder as SentMessageViewHolder).bind(message)
|
||||
VIEW_TYPE_MESSAGE_RECEIVED -> (holder as ReceivedMessageViewHolder).bind(message)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int): Int {
|
||||
val message = getItem(position)
|
||||
return if (message.isSentByMe) {
|
||||
VIEW_TYPE_MESSAGE_SENT
|
||||
} else {
|
||||
VIEW_TYPE_MESSAGE_RECEIVED
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ViewHolder for messages sent by the current user
|
||||
*/
|
||||
inner class SentMessageViewHolder(private val binding: ItemMessageSentBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
fun bind(message: ChatUiMessage) {
|
||||
binding.tvMessage.text = message.message
|
||||
binding.tvTimestamp.text = message.time
|
||||
|
||||
// Show message status
|
||||
val statusIcon = when (message.status) {
|
||||
Constants.STATUS_SENT -> R.drawable.check_single_24
|
||||
Constants.STATUS_DELIVERED -> R.drawable.check_double_24
|
||||
Constants.STATUS_READ -> R.drawable.check_double_read_24
|
||||
else -> R.drawable.check_single_24
|
||||
}
|
||||
binding.imgStatus.setImageResource(statusIcon)
|
||||
|
||||
// Handle attachment if exists
|
||||
if (message.attachment?.isNotEmpty() == true) {
|
||||
binding.imgAttachment.visibility = View.VISIBLE
|
||||
|
||||
val fullImageUrl = when (val img = message.attachment) {
|
||||
is String -> {
|
||||
if (img.startsWith("/")) BASE_URL + img.substring(1) else img
|
||||
}
|
||||
else -> R.drawable.placeholder_image
|
||||
}
|
||||
|
||||
Glide.with(binding.root.context)
|
||||
.load(fullImageUrl)
|
||||
.centerCrop()
|
||||
.placeholder(R.drawable.placeholder_image)
|
||||
.error(R.drawable.placeholder_image)
|
||||
.into(binding.imgAttachment)
|
||||
} else {
|
||||
binding.imgAttachment.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ViewHolder for messages received from other users
|
||||
*/
|
||||
inner class ReceivedMessageViewHolder(private val binding: ItemMessageReceivedBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
fun bind(message: ChatUiMessage) {
|
||||
binding.tvMessage.text = message.message
|
||||
binding.tvTimestamp.text = message.time
|
||||
|
||||
// Handle attachment if exists
|
||||
val fullImageUrl = when (val img = message.attachment) {
|
||||
is String -> {
|
||||
if (img.startsWith("/")) BASE_URL + img.substring(1) else img
|
||||
}
|
||||
else -> R.drawable.placeholder_image
|
||||
}
|
||||
|
||||
if (message.attachment?.isNotEmpty() == true) {
|
||||
binding.imgAttachment.visibility = View.VISIBLE
|
||||
Glide.with(binding.root.context)
|
||||
.load(fullImageUrl)
|
||||
.centerCrop()
|
||||
.placeholder(R.drawable.placeholder_image)
|
||||
.error(R.drawable.placeholder_image)
|
||||
.into(binding.imgAttachment)
|
||||
} else {
|
||||
binding.imgAttachment.visibility = View.GONE
|
||||
}
|
||||
|
||||
// Load avatar image
|
||||
Glide.with(binding.root.context)
|
||||
.load(R.drawable.placeholder_image) // Replace with actual avatar URL if available
|
||||
.circleCrop()
|
||||
.into(binding.imgAvatar)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DiffUtil callback for optimizing RecyclerView updates
|
||||
*/
|
||||
class ChatMessageDiffCallback : DiffUtil.ItemCallback<ChatUiMessage>() {
|
||||
override fun areItemsTheSame(oldItem: ChatUiMessage, newItem: ChatUiMessage): Boolean {
|
||||
return oldItem.id == newItem.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: ChatUiMessage, newItem: ChatUiMessage): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
}
|
@ -13,20 +13,20 @@ import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
class ChatListAdapter(
|
||||
class ChatStoreListAdapter(
|
||||
private val chatList: List<ChatItemList>,
|
||||
private val onClick: (ChatItemList) -> Unit
|
||||
) : RecyclerView.Adapter<ChatListAdapter.ChatViewHolder>() {
|
||||
) : RecyclerView.Adapter<ChatStoreListAdapter.ChatViewHolder>() {
|
||||
|
||||
inner class ChatViewHolder(private val binding: ItemChatBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
fun bind(chat: ChatItemList) {
|
||||
binding.txtStoreName.text = chat.storeName
|
||||
binding.txtStoreName.text = chat.userName
|
||||
binding.txtMessage.text = chat.message
|
||||
binding.txtTime.text = formatTime(chat.latestMessageTime)
|
||||
|
||||
// Process image URL properly
|
||||
val imageUrl = chat.storeImage?.let {
|
||||
val imageUrl = chat.userImage?.let {
|
||||
if (it.startsWith("/")) BASE_URL + it else it
|
||||
}
|
||||
|
@ -22,6 +22,11 @@ object Constants {
|
||||
const val EXTRA_PRODUCT_RATING = "product_rating"
|
||||
const val EXTRA_STORE_IMAGE = "store_image"
|
||||
const val EXTRA_USER_ID = "user_id"
|
||||
const val EXTRA_USER_NAME = "user_name"
|
||||
const val EXTRA_USER_IMAGE = "user_image"
|
||||
const val EXTRA_ATTACH_PRODUCT = "extra_attach_product"
|
||||
|
||||
|
||||
|
||||
// Request codes
|
||||
const val REQUEST_IMAGE_PICK = 1001
|
||||
|
9
app/src/main/res/drawable/bg_product_bubble.xml
Normal file
9
app/src/main/res/drawable/bg_product_bubble.xml
Normal file
@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#F5F5F5" />
|
||||
<corners android:radius="8dp" />
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="#E0E0E0" />
|
||||
</shape>
|
9
app/src/main/res/drawable/bg_product_normal.xml
Normal file
9
app/src/main/res/drawable/bg_product_normal.xml
Normal file
@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#FFFFFF" />
|
||||
<corners android:radius="8dp" />
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="#E0E0E0" />
|
||||
</shape>
|
9
app/src/main/res/drawable/bg_product_selected.xml
Normal file
9
app/src/main/res/drawable/bg_product_selected.xml
Normal file
@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#E3F2FD" />
|
||||
<corners android:radius="8dp" />
|
||||
<stroke
|
||||
android:width="2dp"
|
||||
android:color="#2196F3" />
|
||||
</shape>
|
87
app/src/main/res/layout/item_message_product_received.xml
Normal file
87
app/src/main/res/layout/item_message_product_received.xml
Normal file
@ -0,0 +1,87 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingStart="8dp"
|
||||
android:paddingTop="4dp"
|
||||
android:paddingEnd="60dp"
|
||||
android:paddingBottom="4dp">
|
||||
|
||||
<!-- Product bubble only - no text message -->
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutMessage"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:background="@drawable/bg_message_received"
|
||||
android:orientation="vertical"
|
||||
android:maxWidth="280dp"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<!-- Product card with horizontal layout -->
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutProduct"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:padding="12dp"
|
||||
android:background="@android:color/transparent">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/imgProduct"
|
||||
android:layout_width="60dp"
|
||||
android:layout_height="60dp"
|
||||
android:scaleType="centerCrop"
|
||||
android:background="@drawable/bg_product_inactive"
|
||||
android:src="@drawable/placeholder_image" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="12dp"
|
||||
android:orientation="vertical"
|
||||
android:layout_gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvProductName"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Claude AI Pro - Solusi AI Canggih 1 Bulan"
|
||||
android:textColor="@android:color/black"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold"
|
||||
android:maxLines="2"
|
||||
android:ellipsize="end" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvProductPrice"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Rp25.000"
|
||||
android:textColor="#E91E63"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginTop="4dp" />
|
||||
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvTimestamp"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="4dp"
|
||||
android:layout_marginTop="2dp"
|
||||
android:textColor="#888888"
|
||||
android:textSize="10sp"
|
||||
app:layout_constraintStart_toStartOf="@+id/layoutMessage"
|
||||
app:layout_constraintTop_toBottomOf="@+id/layoutMessage"
|
||||
tools:text="15:36" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
95
app/src/main/res/layout/item_message_product_sent.xml
Normal file
95
app/src/main/res/layout/item_message_product_sent.xml
Normal file
@ -0,0 +1,95 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingStart="60dp"
|
||||
android:paddingTop="4dp"
|
||||
android:paddingEnd="8dp"
|
||||
android:paddingBottom="4dp">
|
||||
|
||||
<!-- Product bubble only - no text message -->
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutMessage"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/bg_message_sent"
|
||||
android:orientation="vertical"
|
||||
android:maxWidth="280dp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<!-- Product card with horizontal layout -->
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutProduct"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:padding="12dp"
|
||||
android:background="@android:color/transparent">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/imgProduct"
|
||||
android:layout_width="60dp"
|
||||
android:layout_height="60dp"
|
||||
android:scaleType="centerCrop"
|
||||
android:background="@drawable/bg_product_inactive"
|
||||
android:src="@drawable/placeholder_image" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="12dp"
|
||||
android:orientation="vertical"
|
||||
android:layout_gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvProductName"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Claude AI Pro - Solusi AI Canggih 1 Bulan"
|
||||
android:textColor="@android:color/black"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold"
|
||||
android:maxLines="2"
|
||||
android:ellipsize="end" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvProductPrice"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Rp25.000"
|
||||
android:textColor="#E91E63"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginTop="4dp" />
|
||||
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvTimestamp"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:textColor="#888888"
|
||||
android:textSize="10sp"
|
||||
app:layout_constraintEnd_toStartOf="@+id/imgStatus"
|
||||
app:layout_constraintTop_toBottomOf="@+id/layoutMessage"
|
||||
tools:text="15:35" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/imgStatus"
|
||||
android:layout_width="16dp"
|
||||
android:layout_height="16dp"
|
||||
android:src="@drawable/check_double_24"
|
||||
app:layout_constraintBottom_toBottomOf="@+id/tvTimestamp"
|
||||
app:layout_constraintEnd_toEndOf="@+id/layoutMessage"
|
||||
app:layout_constraintTop_toTopOf="@+id/tvTimestamp" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
Reference in New Issue
Block a user