update chat customer and store

This commit is contained in:
shaulascr
2025-05-29 02:46:31 +07:00
committed by Gracia Hotmauli
parent 1e76b08be6
commit 1f47ca6d74
16 changed files with 1532 additions and 610 deletions

View File

@ -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"

View File

@ -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
}
}
}

View File

@ -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) {

View File

@ -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)
}
}
}
}
}
/**

View File

@ -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!
)
}

View File

@ -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)
}

View File

@ -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) {

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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

View 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>

View 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>

View 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>

View 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>

View 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>