diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index a5327c7..ddbfa98 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -67,7 +67,7 @@
+ android:windowSoftInputMode="adjustResize" />
@@ -79,7 +79,7 @@
+ android:windowSoftInputMode="adjustResize" />
- 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
- ): 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
+ ): 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()
- val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
- imm.showSoftInput(binding.editTextMessage, InputMethodManager.SHOW_IMPLICIT)
+ 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) {
diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatAdapter.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatAdapter.kt
index 484803f..05cfbfc 100644
--- a/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatAdapter.kt
+++ b/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatAdapter.kt
@@ -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(ChatMessageDiffCallback()) {
+class ChatAdapter(
+ private val onProductClick: ((ProductInfo) -> Unit)? = null
+) : ListAdapter(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
- )
- SentMessageViewHolder(binding)
- } else {
- val binding = ItemMessageReceivedBinding.inflate(
- LayoutInflater.from(parent.context),
- parent,
- false
- )
- ReceivedMessageViewHolder(binding)
+ val inflater = LayoutInflater.from(parent.context)
+
+ return when (viewType) {
+ VIEW_TYPE_MESSAGE_SENT -> {
+ val binding = ItemMessageSentBinding.inflate(inflater, parent, false)
+ SentMessageViewHolder(binding)
+ }
+ 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(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(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)
+ }
+ }
+ }
+ }
}
/**
diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatViewModel.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatViewModel.kt
index 73a57b2..2b8383f 100644
--- a/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatViewModel.kt
+++ b/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatViewModel.kt
@@ -24,12 +24,12 @@ import javax.inject.Inject
class ChatViewModel @Inject constructor(
private val chatRepository: ChatRepository,
private val socketService: SocketIOService,
-
-
private val sessionManager: SessionManager
) : ViewModel() {
private val TAG = "ChatViewModel"
+ // Product attachment flag
+ private var shouldAttachProduct = false
// UI state using LiveData
private val _state = MutableLiveData(ChatUiState())
@@ -45,9 +45,9 @@ class ChatViewModel @Inject constructor(
val chatListStore: LiveData>> = _chatListStore
private val _storeDetail = MutableLiveData>()
- val storeDetail : LiveData> get() = _storeDetail
+ val storeDetail: LiveData> get() = _storeDetail
- // Store and product parameters
+ // Chat parameters
private var storeId: Int = 0
private var productId: Int = 0
private var currentUserId: Int? = null
@@ -64,36 +64,39 @@ class ChatViewModel @Inject constructor(
private var selectedImageFile: File? = null
init {
- // Try to get current user ID from the repository
+ Log.d(TAG, "ChatViewModel initialized")
+ initializeUser()
+ }
+
+ private fun initializeUser() {
viewModelScope.launch {
+ Log.d(TAG, "Initializing user session...")
+
when (val result = chatRepository.fetchUserProfile()) {
is Result.Success -> {
currentUserId = result.data?.userId
- Log.e(TAG, "User ID: $currentUserId")
+ Log.d(TAG, "User session initialized - User ID: $currentUserId")
- // Move the validation and subsequent logic inside the coroutine
if (currentUserId == null || currentUserId == 0) {
- Log.e(TAG, "Error: User ID is not set or invalid")
+ Log.e(TAG, "Invalid user ID detected")
updateState { it.copy(error = "User authentication error. Please login again.") }
} else {
- // Set up socket listeners
+ Log.d(TAG, "Setting up socket listeners...")
setupSocketListeners()
}
}
is Result.Error -> {
- Log.e(TAG, "Error fetching user profile: ${result.exception.message}")
+ Log.e(TAG, "Failed to fetch user profile: ${result.exception.message}")
updateState { it.copy(error = "User authentication error. Please login again.") }
}
is Result.Loading -> {
- // Handle loading state if needed
+ Log.d(TAG, "Loading user profile...")
}
}
}
}
- /**
- * Set chat parameters received from activity
- */
+ // set chat parameter for buyer
fun setChatParameters(
storeId: Int,
productId: Int? = 0,
@@ -103,8 +106,9 @@ class ChatViewModel @Inject constructor(
productRating: Float? = 0f,
storeName: String
) {
- this.productId = if (productId != null && productId > 0) productId else 0
+ Log.d(TAG, "Setting chat parameters - StoreID: $storeId, ProductID: $productId")
+ this.productId = if (productId != null && productId > 0) productId else 0
this.storeId = storeId
this.productName = productName.toString()
this.productPrice = productPrice.toString()
@@ -112,7 +116,6 @@ class ChatViewModel @Inject constructor(
this.productRating = productRating!!
this.storeName = storeName
- // Update state with product info
updateState {
it.copy(
productName = productName.toString(),
@@ -123,17 +126,15 @@ class ChatViewModel @Inject constructor(
)
}
- // Connect to socket and load chat history
val existingChatRoomId = _chatRoomId.value ?: 0
if (existingChatRoomId > 0) {
- // If we already have a chat room ID, we can load the chat history
+ Log.d(TAG, "Loading existing chat room: $existingChatRoomId")
loadChatHistory(existingChatRoomId)
-
- // And join the Socket.IO room
joinSocketRoom(existingChatRoomId)
}
}
+ // set chat parameter for store
fun setChatParametersStore(
storeId: Int,
userId: Int,
@@ -144,16 +145,17 @@ class ChatViewModel @Inject constructor(
productRating: Float? = 0f,
storeName: String
) {
- this.productId = if (productId != null && productId > 0) productId else 0
+ Log.d(TAG, "Setting store chat parameters - StoreID: $storeId, UserID: $userId, ProductID: $productId")
+ this.productId = if (productId != null && productId > 0) productId else 0
this.storeId = storeId
- this.defaultUserId = userId // Store the user_id for store-side chat
+ this.defaultUserId = userId
this.productName = productName.toString()
this.productPrice = productPrice.toString()
this.productImage = productImage.toString()
this.productRating = productRating!!
this.storeName = storeName
- // Update state with product info
+
updateState {
it.copy(
productName = productName.toString(),
@@ -164,95 +166,111 @@ class ChatViewModel @Inject constructor(
)
}
- // Connect to socket and load chat history
val existingChatRoomId = _chatRoomId.value ?: 0
if (existingChatRoomId > 0) {
- // If we already have a chat room ID, we can load the chat history
+ Log.d(TAG, "Loading existing store chat room: $existingChatRoomId")
loadChatHistory(existingChatRoomId)
-
- // And join the Socket.IO room
joinSocketRoom(existingChatRoomId)
}
}
+ //enable product attach from detailproductactivity
+ fun enableProductAttachment() {
+ Log.d(TAG, "Product attachment enabled - ProductID: $productId, ProductName: $productName")
+ shouldAttachProduct = true
+ updateState { it.copy(hasProductAttachment = true) }
+ }
+
+ // disable product attach
+ fun disableProductAttachment() {
+ Log.d(TAG, "Product attachment disabled")
+ shouldAttachProduct = false
+ updateState { it.copy(hasProductAttachment = false) }
+ }
+
+ private fun setupSocketListeners() {
+ Log.d(TAG, "Setting up socket listeners...")
+
+ viewModelScope.launch {
+ socketService.connectionState.collect { connectionState ->
+ Log.d(TAG, "Socket connection state changed: $connectionState")
+ updateState { it.copy(connectionState = connectionState) }
+
+ if (connectionState is ConnectionState.Connected) {
+ Log.d(TAG, "Socket connected, joining room...")
+ socketService.joinRoom()
+ }
+ }
+ }
+
+ viewModelScope.launch {
+ socketService.newMessages.collect { chatLine ->
+ chatLine?.let {
+ Log.d(TAG, "New message received via socket - ID: ${it.id}, SenderID: ${it.senderId}")
+ val currentMessages = _state.value?.messages ?: listOf()
+ val updatedMessages = currentMessages.toMutableList().apply {
+ add(convertChatLineToUiMessage(it))
+ }
+ updateState { it.copy(messages = updatedMessages) }
+
+ if (it.senderId != currentUserId) {
+ Log.d(TAG, "Marking message as read: ${it.id}")
+ updateMessageStatus(it.id, Constants.STATUS_READ)
+ }
+ }
+ }
+ }
+
+ viewModelScope.launch {
+ socketService.typingStatus.collect { typingStatus ->
+ typingStatus?.let {
+ val currentRoomId = _chatRoomId.value ?: 0
+ if (typingStatus.roomId == currentRoomId && typingStatus.userId != currentUserId) {
+ Log.d(TAG, "Typing status updated: ${typingStatus.isTyping}")
+ updateState { it.copy(isOtherUserTyping = typingStatus.isTyping) }
+ }
+ }
+ }
+ }
+ }
+
fun joinSocketRoom(roomId: Int) {
if (roomId <= 0) {
Log.e(TAG, "Cannot join room: Invalid room ID")
return
}
+ Log.d(TAG, "Joining socket room: $roomId")
socketService.joinRoom()
}
- /**
- * Sets up listeners for Socket.IO events
- */
- private fun setupSocketListeners() {
- viewModelScope.launch {
- // Listen for connection state changes
- socketService.connectionState.collect { connectionState ->
- updateState { it.copy(connectionState = connectionState) }
-
- // Join room when connected
- if (connectionState is ConnectionState.Connected) {
- socketService.joinRoom()
- }
- }
- }
-
- viewModelScope.launch {
- // Listen for new messages
- socketService.newMessages.collect { chatLine ->
- chatLine?.let {
- val currentMessages = _state.value?.messages ?: listOf()
- val updatedMessages = currentMessages.toMutableList().apply {
- add(convertChatLineToUiMessage(it))
- }
- updateState { it.copy(messages = updatedMessages) }
-
- // Update message status if received from others
- if (it.senderId != currentUserId) {
- updateMessageStatus(it.id, Constants.STATUS_READ)
- }
- }
- }
- }
-
- viewModelScope.launch {
- // Listen for typing status updates
- socketService.typingStatus.collect { typingStatus ->
- typingStatus?.let {
- if (typingStatus.roomId == (_chatRoomId.value ?: 0) && typingStatus.userId != currentUserId) {
- updateState { it.copy(isOtherUserTyping = typingStatus.isTyping) }
- }
- }
- }
- }
- }
-
- /**
- * Helper function to update LiveData state
- */
- private fun updateState(update: (ChatUiState) -> ChatUiState) {
- _state.value?.let {
- _state.value = update(it)
- }
- }
-
- /**
- * Loads chat history
- */
- fun loadChatHistory(chatRoomId: Int) {
- if (chatRoomId <= 0) {
- Log.e(TAG, "Cannot load chat history: Chat room ID is 0")
+ fun sendTypingStatus(isTyping: Boolean) {
+ val roomId = _chatRoomId.value ?: 0
+ if (roomId <= 0) {
+ Log.w(TAG, "Cannot send typing status: No active room")
return
}
+ Log.d(TAG, "Sending typing status: $isTyping for room: $roomId")
+ socketService.sendTypingStatus(roomId, isTyping)
+ }
+
+ // load chat history
+ fun loadChatHistory(chatRoomId: Int) {
+ if (chatRoomId <= 0) {
+ Log.e(TAG, "Cannot load chat history: Invalid chat room ID")
+ return
+ }
+
+ Log.d(TAG, "Loading chat history for room: $chatRoomId")
+
viewModelScope.launch {
updateState { it.copy(isLoading = true) }
when (val result = chatRepository.getChatHistory(chatRoomId)) {
is Result.Success -> {
+ Log.d(TAG, "Chat history loaded successfully - ${result.data.chat.size} messages")
+
val messages = result.data.chat.map { chatLine ->
convertChatLineToUiMessageHistory(chatLine)
}
@@ -265,21 +283,24 @@ class ChatViewModel @Inject constructor(
)
}
- Log.d(TAG, "Loaded ${messages.size} messages for chat room $chatRoomId")
-
// Update status of unread messages
- result.data.chat
- .filter { it.senderId != currentUserId && it.status != Constants.STATUS_READ }
- .forEach { updateMessageStatus(it.id, Constants.STATUS_READ) }
+ val unreadMessages = result.data.chat.filter {
+ it.senderId != currentUserId && it.status != Constants.STATUS_READ
+ }
+
+ if (unreadMessages.isNotEmpty()) {
+ Log.d(TAG, "Marking ${unreadMessages.size} messages as read")
+ unreadMessages.forEach { updateMessageStatus(it.id, Constants.STATUS_READ) }
+ }
}
is Result.Error -> {
+ Log.e(TAG, "Error loading chat history: ${result.exception.message}")
updateState {
it.copy(
isLoading = false,
error = result.exception.message
)
}
- Log.e(TAG, "Error loading chat history: ${result.exception.message}")
}
is Result.Loading -> {
updateState { it.copy(isLoading = true) }
@@ -288,52 +309,329 @@ class ChatViewModel @Inject constructor(
}
}
- /**
- * Sends a chat message
- */
+ fun getChatList() {
+ Log.d(TAG, "Getting chat list...")
+ viewModelScope.launch {
+ _chatList.value = Result.Loading
+ _chatList.value = chatRepository.getListChat()
+ }
+ }
+
+ fun getChatListStore() {
+ Log.d(TAG, "Getting store chat list...")
+ _chatListStore.value = Result.Loading
+
+ viewModelScope.launch {
+ val result = chatRepository.getListChatStore()
+ Log.d(TAG, "Store chat list result: $result")
+ _chatListStore.value = result
+ }
+ }
+
+ // handle regular message and product message
fun sendMessage(message: String) {
Log.d(TAG, "=== SEND MESSAGE ===")
Log.d(TAG, "Message: '$message'")
- Log.d(TAG, "Has attachment: ${selectedImageFile != null}")
- Log.d(TAG, "Selected image file: ${selectedImageFile?.absolutePath}")
- Log.d(TAG, "File exists: ${selectedImageFile?.exists()}")
+ Log.d(TAG, "Should attach product: $shouldAttachProduct")
+ Log.d(TAG, "Has image attachment: ${selectedImageFile != null}")
+ Log.d(TAG, "Product ID: $productId")
+
if (message.isBlank() && selectedImageFile == null) {
- Log.e(TAG, "Cannot send message: Both message and image are empty")
+ Log.w(TAG, "Cannot send message: Both message and image are empty")
return
}
- // Check if we have the necessary parameters
if (storeId <= 0) {
- Log.e(TAG, "Cannot send message: Store ID is invalid")
+ Log.e(TAG, "Cannot send message: Invalid store ID")
updateState { it.copy(error = "Cannot send message. Invalid store ID.") }
return
}
- // Get the existing chatRoomId (not used in API but may be needed for Socket.IO)
- val existingChatRoomId = _chatRoomId.value ?: 0
+ // Check for product attachment
+ if (shouldAttachProduct && productId > 0) {
+ Log.d(TAG, "Sending message with product attachment")
+ sendMessageWithProduct(message)
+ shouldAttachProduct = false
+ updateState { it.copy(hasProductAttachment = false) }
+ return
+ }
- // Log debug information
- Log.d(TAG, "Sending message with params: storeId=$storeId, productId=$productId")
- Log.d(TAG, "Current user ID: $currentUserId")
+ Log.d(TAG, "Sending regular message")
+ sendRegularMessage(message)
+ }
+
+ //send message for store
+ fun sendMessageStore(message: String) {
+ Log.d(TAG, "=== SEND MESSAGE STORE ===")
+ Log.d(TAG, "Message: '$message'")
Log.d(TAG, "Has attachment: ${selectedImageFile != null}")
+ Log.d(TAG, "Default User ID: $defaultUserId")
+
+ if (message.isBlank() && selectedImageFile == null) {
+ Log.w(TAG, "Cannot send store message: Both message and image are empty")
+ return
+ }
+
+ if (storeId <= 0) {
+ Log.e(TAG, "Cannot send store message: Invalid store ID")
+ updateState { it.copy(error = "Cannot send message. Invalid store ID.") }
+ return
+ }
+
+ if (defaultUserId <= 0) {
+ Log.e(TAG, "Cannot send store message: Invalid user ID")
+ updateState { it.copy(error = "Cannot send message. Invalid user ID.") }
+ return
+ }
- // Check image file size if present
selectedImageFile?.let { file ->
- if (file.exists() && file.length() > 5 * 1024 * 1024) { // 5MB limit
+ if (file.exists() && file.length() > 5 * 1024 * 1024) {
+ Log.e(TAG, "Image file too large: ${file.length()} bytes")
updateState { it.copy(error = "Image file is too large. Please select a smaller image.") }
return
}
}
+ val existingChatRoomId = _chatRoomId.value ?: 0
+ Log.d(TAG, "Sending store message - StoreID: $storeId, UserID: $defaultUserId, RoomID: $existingChatRoomId")
+
viewModelScope.launch {
updateState { it.copy(isSending = true) }
try {
- // Send the message using the repository
- // Note: We keep the chatRoomId parameter for compatibility with the repository method signature,
- // but it's not actually used in the API call
val safeProductId = if (productId == 0) null else productId
+ val result = chatRepository.sendChatMessageStore(
+ userId = defaultUserId,
+ message = message,
+ productId = safeProductId,
+ imageFile = selectedImageFile
+ )
+
+ when (result) {
+ is Result.Success -> {
+ val chatLine = result.data.chatLine
+ Log.d(TAG, "Store message sent successfully - ID: ${chatLine.id}")
+
+ val newMessage = convertChatLineToUiMessage(chatLine)
+ val currentMessages = _state.value?.messages ?: listOf()
+ val updatedMessages = currentMessages.toMutableList().apply {
+ add(newMessage)
+ }
+
+ updateState {
+ it.copy(
+ messages = updatedMessages,
+ isSending = false,
+ hasAttachment = false,
+ error = null
+ )
+ }
+
+ handleChatRoomCreation(existingChatRoomId, chatLine.chatRoomId)
+ socketService.sendMessage(chatLine)
+ selectedImageFile = null
+ }
+ is Result.Error -> {
+ val errorMsg = result.exception.message?.takeIf { it.isNotBlank() && it != "{}" }
+ ?: "Failed to send message. Please try again."
+
+ Log.e(TAG, "Error sending store message: $errorMsg")
+ updateState {
+ it.copy(
+ isSending = false,
+ error = errorMsg
+ )
+ }
+ }
+ is Result.Loading -> {
+ updateState { it.copy(isSending = true) }
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Exception in sendMessageStore", e)
+ updateState {
+ it.copy(
+ isSending = false,
+ error = "An unexpected error occurred: ${e.message}"
+ )
+ }
+ }
+ }
+ }
+
+ // send message with product info
+ private fun sendMessageWithProduct(userMessage: String) {
+ Log.d(TAG, "Sending message with product attachment")
+ Log.d(TAG, "User message: '$userMessage'")
+ Log.d(TAG, "Product: $productName")
+
+ // Send product bubble FIRST
+ sendProductBubble()
+
+ // Then send user's text message after a small delay
+ viewModelScope.launch {
+ kotlinx.coroutines.delay(100)
+ sendTextMessage(userMessage)
+ }
+ }
+
+ // send only text message w/o product info
+ private fun sendTextMessage(message: String) {
+ if (message.isBlank()) {
+ Log.w(TAG, "Cannot send text message: Message is blank")
+ return
+ }
+
+ Log.d(TAG, "Sending text message: '$message'")
+
+ viewModelScope.launch {
+ updateState { it.copy(isSending = true) }
+
+ try {
+ val result = chatRepository.sendChatMessage(
+ storeId = storeId,
+ message = message,
+ productId = null,
+ imageFile = selectedImageFile
+ )
+
+ when (result) {
+ is Result.Success -> {
+ val chatLine = result.data.chatLine
+ Log.d(TAG, "Text message sent successfully - ID: ${chatLine.id}")
+
+ val newMessage = convertChatLineToUiMessage(chatLine)
+ val currentMessages = _state.value?.messages ?: listOf()
+ val updatedMessages = currentMessages.toMutableList().apply {
+ add(newMessage)
+ }
+
+ updateState {
+ it.copy(
+ messages = updatedMessages,
+ isSending = false,
+ hasAttachment = false,
+ error = null
+ )
+ }
+
+ val existingChatRoomId = _chatRoomId.value ?: 0
+ handleChatRoomCreation(existingChatRoomId, chatLine.chatRoomId)
+ socketService.sendMessage(chatLine)
+ selectedImageFile = null
+ }
+ is Result.Error -> {
+ Log.e(TAG, "Error sending text message: ${result.exception.message}")
+ updateState {
+ it.copy(
+ isSending = false,
+ error = result.exception.message ?: "Failed to send message"
+ )
+ }
+ }
+ is Result.Loading -> {
+ updateState { it.copy(isSending = true) }
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Exception in sendTextMessage", e)
+ updateState {
+ it.copy(
+ isSending = false,
+ error = "An unexpected error occurred: ${e.message}"
+ )
+ }
+ }
+ }
+ }
+
+ // send product bubble message
+ private fun sendProductBubble() {
+ Log.d(TAG, "Sending product bubble - ProductID: $productId, ProductName: $productName")
+
+ viewModelScope.launch {
+ try {
+ val result = chatRepository.sendChatMessage(
+ storeId = storeId,
+ message = "",
+ productId = productId,
+ imageFile = null
+ )
+
+ when (result) {
+ is Result.Success -> {
+ val chatLine = result.data.chatLine
+ Log.d(TAG, "Product bubble sent successfully - ID: ${chatLine.id}")
+
+ val newMessage = convertChatLineToUiMessage(chatLine).copy(
+ messageType = MessageType.PRODUCT,
+ productInfo = ProductInfo(
+ productId = productId,
+ productName = productName,
+ productPrice = productPrice,
+ productImage = productImage,
+ productRating = productRating,
+ storeName = storeName
+ )
+ )
+
+ val currentMessages = _state.value?.messages ?: listOf()
+ val updatedMessages = currentMessages.toMutableList().apply {
+ add(newMessage)
+ }
+
+ updateState {
+ it.copy(
+ messages = updatedMessages,
+ error = null
+ )
+ }
+
+ socketService.sendMessage(chatLine)
+ }
+ is Result.Error -> {
+ Log.e(TAG, "Error sending product bubble: ${result.exception.message}")
+ updateState {
+ it.copy(
+ error = "Failed to send product info: ${result.exception.message}"
+ )
+ }
+ }
+ is Result.Loading -> {
+ // Handle loading if needed
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Exception in sendProductBubble", e)
+ updateState {
+ it.copy(
+ error = "An unexpected error occurred: ${e.message}"
+ )
+ }
+ }
+ }
+ }
+
+ // send regular message for normal chat
+ private fun sendRegularMessage(message: String) {
+ Log.d(TAG, "Sending regular message: '$message'")
+
+ selectedImageFile?.let { file ->
+ if (file.exists() && file.length() > 5 * 1024 * 1024) {
+ Log.e(TAG, "Image file too large: ${file.length()} bytes")
+ updateState { it.copy(error = "Image file is too large. Please select a smaller image.") }
+ return
+ }
+ }
+
+ val existingChatRoomId = _chatRoomId.value ?: 0
+
+ viewModelScope.launch {
+ updateState { it.copy(isSending = true) }
+
+ try {
+ val safeProductId = if (productId == 0) null else productId
val result = chatRepository.sendChatMessage(
storeId = storeId,
@@ -344,10 +642,10 @@ class ChatViewModel @Inject constructor(
when (result) {
is Result.Success -> {
- // Add new message to the list
val chatLine = result.data.chatLine
- val newMessage = convertChatLineToUiMessage(chatLine)
+ Log.d(TAG, "Regular message sent successfully - ID: ${chatLine.id}")
+ val newMessage = convertChatLineToUiMessage(chatLine)
val currentMessages = _state.value?.messages ?: listOf()
val updatedMessages = currentMessages.toMutableList().apply {
add(newMessage)
@@ -362,45 +660,28 @@ class ChatViewModel @Inject constructor(
)
}
- Log.d(TAG, "Message sent successfully: ${chatLine.id}")
-
- // Update the chat room ID if it's the first message
- val newChatRoomId = chatLine.chatRoomId
- if (existingChatRoomId == 0 && newChatRoomId > 0) {
- Log.d(TAG, "Chat room created: $newChatRoomId")
- _chatRoomId.value = newChatRoomId
-
- // Now that we have a chat room ID, we can join the Socket.IO room
- joinSocketRoom(newChatRoomId)
- }
-
- // Emit the message via Socket.IO for real-time updates
+ handleChatRoomCreation(existingChatRoomId, chatLine.chatRoomId)
socketService.sendMessage(chatLine)
-
- // Clear the image attachment
selectedImageFile = null
}
is Result.Error -> {
- val errorMsg = if (result.exception.message.isNullOrEmpty() || result.exception.message == "{}") {
- "Failed to send message. Please try again."
- } else {
- result.exception.message
- }
+ val errorMsg = result.exception.message?.takeIf { it.isNotBlank() && it != "{}" }
+ ?: "Failed to send message. Please try again."
+ Log.e(TAG, "Error sending regular message: $errorMsg")
updateState {
it.copy(
isSending = false,
error = errorMsg
)
}
- Log.e(TAG, "Error sending message: ${result.exception.message}")
}
is Result.Loading -> {
updateState { it.copy(isSending = true) }
}
}
} catch (e: Exception) {
- Log.e(TAG, "Exception in sendMessage", e)
+ Log.e(TAG, "Exception in sendRegularMessage", e)
updateState {
it.copy(
isSending = false,
@@ -411,138 +692,15 @@ class ChatViewModel @Inject constructor(
}
}
- fun sendMessageStore(message: String) {
- Log.d(TAG, "=== SEND MESSAGE ===")
- Log.d(TAG, "Message: '$message'")
- Log.d(TAG, "Has attachment: ${selectedImageFile != null}")
- Log.d(TAG, "Selected image file: ${selectedImageFile?.absolutePath}")
- Log.d(TAG, "File exists: ${selectedImageFile?.exists()}")
- if (message.isBlank() && selectedImageFile == null) {
- Log.e(TAG, "Cannot send message: Both message and image are empty")
- return
- }
-
- // Check if we have the necessary parameters
- if (storeId <= 0) {
- Log.e(TAG, "Cannot send message: Store ID is invalid")
- updateState { it.copy(error = "Cannot send message. Invalid store ID.") }
- return
- }
-
- // Get the existing chatRoomId (not used in API but may be needed for Socket.IO)
- val existingChatRoomId = _chatRoomId.value ?: 0
-
- // Log debug information
- Log.d(TAG, "Sending message with params: storeId=$storeId, productId=$productId")
- Log.d(TAG, "Current user ID: $currentUserId")
- Log.d(TAG, "Has attachment: ${selectedImageFile != null}")
-
- // Check image file size if present
- selectedImageFile?.let { file ->
- if (file.exists() && file.length() > 5 * 1024 * 1024) { // 5MB limit
- updateState { it.copy(error = "Image file is too large. Please select a smaller image.") }
- return
- }
- }
-
- if (defaultUserId <= 0) { // Check userId instead of storeId
- Log.e(TAG, "Cannot send message: User ID is invalid")
- updateState { it.copy(error = "Cannot send message. Invalid user ID.") }
- return
- }
-
- viewModelScope.launch {
- updateState { it.copy(isSending = true) }
-
- try {
- val safeProductId = if (productId == 0) null else productId
-
- val result = chatRepository.sendChatMessageStore(
- userId = defaultUserId, // Pass userId instead of storeId
- message = message,
- productId = safeProductId,
- imageFile = selectedImageFile
- )
-
- when (result) {
- is Result.Success -> {
- // Add new message to the list
- val chatLine = result.data.chatLine
- val newMessage = convertChatLineToUiMessage(chatLine)
-
- val currentMessages = _state.value?.messages ?: listOf()
- val updatedMessages = currentMessages.toMutableList().apply {
- add(newMessage)
- }
-
- updateState {
- it.copy(
- messages = updatedMessages,
- isSending = false,
- hasAttachment = false,
- error = null
- )
- }
-
- Log.d(TAG, "Message sent successfully: ${chatLine.id}")
-
- // Update the chat room ID if it's the first message
- val newChatRoomId = chatLine.chatRoomId
- if (existingChatRoomId == 0 && newChatRoomId > 0) {
- Log.d(TAG, "Chat room created: $newChatRoomId")
- _chatRoomId.value = newChatRoomId
-
- // Now that we have a chat room ID, we can join the Socket.IO room
- joinSocketRoom(newChatRoomId)
- }
-
- // Emit the message via Socket.IO for real-time updates
- socketService.sendMessage(chatLine)
-
- // Clear the image attachment
- selectedImageFile = null
- }
- is Result.Error -> {
- val errorMsg = if (result.exception.message.isNullOrEmpty() || result.exception.message == "{}") {
- "Failed to send message. Please try again."
- } else {
- result.exception.message
- }
-
- updateState {
- it.copy(
- isSending = false,
- error = errorMsg
- )
- }
- Log.e(TAG, "Error sending message: ${result.exception.message}")
- }
- is Result.Loading -> {
- updateState { it.copy(isSending = true) }
- }
- }
- } catch (e: Exception) {
- Log.e(TAG, "Exception in sendMessage", e)
- updateState {
- it.copy(
- isSending = false,
- error = "An unexpected error occurred: ${e.message}"
- )
- }
- }
- }
- }
-
- /**
- * Updates a message status (delivered, read)
- */
+ //update message status
fun updateMessageStatus(messageId: Int, status: String) {
+ Log.d(TAG, "Updating message status - ID: $messageId, Status: $status")
+
viewModelScope.launch {
try {
val result = chatRepository.updateMessageStatus(messageId, status)
if (result is Result.Success) {
- // Update local message status
val currentMessages = _state.value?.messages ?: listOf()
val updatedMessages = currentMessages.map { message ->
if (message.id == messageId) {
@@ -552,8 +710,7 @@ class ChatViewModel @Inject constructor(
}
}
updateState { it.copy(messages = updatedMessages) }
-
- Log.d(TAG, "Message status updated: $messageId -> $status")
+ Log.d(TAG, "Message status updated successfully")
} else if (result is Result.Error) {
Log.e(TAG, "Error updating message status: ${result.exception.message}")
}
@@ -563,118 +720,245 @@ class ChatViewModel @Inject constructor(
}
}
- /**
- * Sets the selected image file for attachment
- */
+ //set image attachment
fun setSelectedImageFile(file: File?) {
selectedImageFile = file
updateState { it.copy(hasAttachment = file != null) }
-
- Log.d(TAG, "Image attachment ${if (file != null) "selected" else "cleared"}")
+ Log.d(TAG, "Image attachment ${if (file != null) "selected: ${file.name}" else "cleared"}")
}
- /**
- * Sends typing status to the other user
- */
- fun sendTypingStatus(isTyping: Boolean) {
- val roomId = _chatRoomId.value ?: 0
- if (roomId <= 0) return
-
- socketService.sendTypingStatus(roomId, isTyping)
- }
-
- /**
- * Clears any error message in the state
- */
- fun clearError() {
- updateState { it.copy(error = null) }
- }
-
- /**
- * Converts a ChatLine from API to a UI message model
- */
+ // convert form chatLine api to UI chat messages
private fun convertChatLineToUiMessage(chatLine: ChatLine): ChatUiMessage {
- // Format the timestamp for display
- val formattedTime = try {
- val inputFormat = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault())
- inputFormat.timeZone = TimeZone.getTimeZone("UTC")
- val outputFormat = java.text.SimpleDateFormat("HH:mm", Locale.getDefault())
-
- val date = inputFormat.parse(chatLine.createdAt)
- date?.let { outputFormat.format(it) } ?: ""
- } catch (e: Exception) {
- Log.e(TAG, "Error formatting date: ${chatLine.createdAt}", e)
- ""
- }
+ val formattedTime = formatTimestamp(chatLine.createdAt)
return ChatUiMessage(
id = chatLine.id,
message = chatLine.message,
- attachment = chatLine.attachment ?: "", // Handle null attachment
+ attachment = chatLine.attachment ?: "",
status = chatLine.status,
time = formattedTime,
isSentByMe = chatLine.senderId == currentUserId
)
}
+ // convert chat history item to ui
private fun convertChatLineToUiMessageHistory(chatItem: ChatItem): ChatUiMessage {
- // Format the timestamp for display
- val formattedTime = try {
+ val formattedTime = formatTimestamp(chatItem.createdAt)
+
+ val messageType = when {
+ chatItem.productId > 0 -> MessageType.PRODUCT
+ !chatItem.attachment.isNullOrEmpty() -> MessageType.IMAGE
+ else -> MessageType.TEXT
+ }
+
+ val productInfo = if (messageType == MessageType.PRODUCT) {
+ if (chatItem.senderId == currentUserId && chatItem.productId == productId) {
+ ProductInfo(
+ productId = productId,
+ productName = productName,
+ productPrice = productPrice,
+ productImage = productImage,
+ productRating = productRating,
+ storeName = storeName
+ )
+ } else {
+ ProductInfo(
+ productId = chatItem.productId,
+ productName = "Loading...",
+ productPrice = "Loading...",
+ productImage = "",
+ productRating = 0f,
+ storeName = "Loading..."
+ )
+ }
+ } else null
+
+ val message = ChatUiMessage(
+ id = chatItem.id,
+ message = chatItem.message,
+ attachment = chatItem.attachment,
+ status = chatItem.status,
+ time = formattedTime,
+ isSentByMe = chatItem.senderId == currentUserId,
+ messageType = messageType,
+ productInfo = productInfo
+ )
+
+ // Fetch product info for non-current-user products
+ if (messageType == MessageType.PRODUCT &&
+ (chatItem.senderId != currentUserId || chatItem.productId != productId)) {
+ fetchProductInfoForHistoryMessage(message, chatItem.productId)
+ }
+
+ return message
+ }
+
+ // fetch produc =t info in chat history
+ private fun fetchProductInfoForHistoryMessage(message: ChatUiMessage, productId: Int) {
+ Log.d(TAG, "Fetching product info for message ${message.id}, productId: $productId")
+
+ viewModelScope.launch {
+ try {
+ val productResult = chatRepository.fetchProductDetail(productId)
+
+ if (productResult != null) {
+ val product = productResult.product
+ Log.d(TAG, "Product fetched successfully: ${product.productName}")
+
+ val productInfo = ProductInfo(
+ productId = product.productId,
+ productName = product.productName,
+ productPrice = formatPrice(product.price),
+ productImage = product.image,
+ productRating = parseRating(product.rating),
+ storeName = getStoreName(product.storeId)
+ )
+
+ updateMessageWithProductInfo(message.id, productInfo)
+ } else {
+ Log.e(TAG, "Failed to fetch product info for productId: $productId")
+ val errorProductInfo = ProductInfo(
+ productId = productId,
+ productName = "Product not available",
+ productPrice = "N/A",
+ productImage = "",
+ productRating = 0f,
+ storeName = "Unknown Store"
+ )
+ updateMessageWithProductInfo(message.id, errorProductInfo)
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Exception fetching product info for message: ${message.id}", e)
+ val errorProductInfo = ProductInfo(
+ productId = productId,
+ productName = "Error loading product",
+ productPrice = "N/A",
+ productImage = "",
+ productRating = 0f,
+ storeName = "Unknown Store"
+ )
+ updateMessageWithProductInfo(message.id, errorProductInfo)
+ }
+ }
+ }
+
+ //update specific message with product info
+ private fun updateMessageWithProductInfo(messageId: Int, productInfo: ProductInfo) {
+ Log.d(TAG, "Updating message $messageId with product info: ${productInfo.productName}")
+
+ val currentMessages = _state.value?.messages ?: listOf()
+ val updatedMessages = currentMessages.map { message ->
+ if (message.id == messageId) {
+ message.copy(productInfo = productInfo)
+ } else {
+ message
+ }
+ }
+ updateState { it.copy(messages = updatedMessages) }
+ }
+
+ //handle chat room when initiate chat
+ private fun handleChatRoomCreation(existingChatRoomId: Int, newChatRoomId: Int) {
+ if (existingChatRoomId == 0 && newChatRoomId > 0) {
+ Log.d(TAG, "Chat room created: $newChatRoomId")
+ _chatRoomId.value = newChatRoomId
+ joinSocketRoom(newChatRoomId)
+ }
+ }
+
+ //format timestamp
+ private fun formatTimestamp(timestamp: String): String {
+ return try {
val inputFormat = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault())
inputFormat.timeZone = TimeZone.getTimeZone("UTC")
val outputFormat = java.text.SimpleDateFormat("HH:mm", Locale.getDefault())
- val date = inputFormat.parse(chatItem.createdAt)
+ val date = inputFormat.parse(timestamp)
date?.let { outputFormat.format(it) } ?: ""
} catch (e: Exception) {
- Log.e(TAG, "Error formatting date: ${chatItem.createdAt}", e)
+ Log.e(TAG, "Error formatting date: $timestamp", e)
""
}
+ }
- return ChatUiMessage(
- attachment = chatItem.attachment, // Handle null attachment
- id = chatItem.id,
- message = chatItem.message,
- status = chatItem.status,
- time = formattedTime,
- isSentByMe = chatItem.senderId == currentUserId,
- )
+ //format price
+ private fun formatPrice(price: String): String {
+ return if (price.startsWith("Rp")) price else "Rp$price"
+ }
+
+ // parse string to float
+ private fun parseRating(rating: String): Float {
+ return try {
+ rating.toFloat()
+ } catch (e: Exception) {
+ Log.w(TAG, "Error parsing rating: $rating", e)
+ 0f
+ }
+ }
+
+ //get store name by Id
+ private fun getStoreName(storeId: Int): String {
+ return if (storeId == this.storeId) {
+ storeName
+ } else {
+ "Store #$storeId"
+ }
+ }
+
+ // helper function to update live data
+ private fun updateState(update: (ChatUiState) -> ChatUiState) {
+ _state.value?.let {
+ _state.value = update(it)
+ }
+ }
+
+ //clear any error messages
+ fun clearError() {
+ Log.d(TAG, "Clearing error state")
+ updateState { it.copy(error = null) }
}
override fun onCleared() {
super.onCleared()
- // Disconnect Socket.IO when ViewModel is cleared
+ Log.d(TAG, "ChatViewModel cleared - Disconnecting socket")
socketService.disconnect()
- Log.d(TAG, "ViewModel cleared, Socket.IO disconnected")
- }
-
- fun getChatList() {
- viewModelScope.launch {
- _chatList.value = com.alya.ecommerce_serang.data.repository.Result.Loading
- _chatList.value = chatRepository.getListChat()
- }
- }
-
- fun getChatListStore() {
- Log.d("ChatViewModel", "getChatListStore() called")
- _chatListStore.value = Result.Loading
-
- viewModelScope.launch {
- val result = chatRepository.getListChatStore()
- Log.d("ChatViewModel", "getChatListStore() result: $result")
- _chatListStore.value = result
- }
}
}
-/**
- * Data class representing the UI state for the chat screen
- */
+enum class MessageType {
+ TEXT, // Regular text message
+ IMAGE, // Image message
+ PRODUCT // Product share message
+}
+
+data class ProductInfo(
+ val productId: Int,
+ val productName: String,
+ val productPrice: String,
+ val productImage: String,
+ val productRating: Float,
+ val storeName: String
+)
+
+// representing chat messages to UI
+data class ChatUiMessage(
+ val id: Int,
+ val message: String,
+ val attachment: String?,
+ val status: String,
+ val time: String,
+ val isSentByMe: Boolean,
+ val messageType: MessageType = MessageType.TEXT,
+ val productInfo: ProductInfo? = null
+)
+
+// representing UI state to screen
data class ChatUiState(
val messages: List = emptyList(),
val isLoading: Boolean = false,
val isSending: Boolean = false,
val hasAttachment: Boolean = false,
+ val hasProductAttachment: Boolean = false,
val isOtherUserTyping: Boolean = false,
val error: String? = null,
val connectionState: ConnectionState = ConnectionState.Disconnected(),
@@ -685,16 +969,4 @@ data class ChatUiState(
val productImageUrl: String = "",
val productRating: Float = 0f,
val storeName: String = ""
-)
-
-/**
- * Data class representing a chat message in the UI
- */
-data class ChatUiMessage(
- val id: Int,
- val message: String,
- val attachment: String?,
- val status: String,
- val time: String,
- val isSentByMe: Boolean
)
\ No newline at end of file
diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/product/DetailProductActivity.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/product/DetailProductActivity.kt
index 83d156c..3e62dea 100644
--- a/app/src/main/java/com/alya/ecommerce_serang/ui/product/DetailProductActivity.kt
+++ b/app/src/main/java/com/alya/ecommerce_serang/ui/product/DetailProductActivity.kt
@@ -483,8 +483,10 @@ 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!
+
+ )
}
diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatListStoreActivity.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatListStoreActivity.kt
index 9620907..c68e3de 100644
--- a/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatListStoreActivity.kt
+++ b/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatListStoreActivity.kt
@@ -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)
}
diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatStoreActivity.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatStoreActivity.kt
index c872f50..8dfabfc 100644
--- a/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatStoreActivity.kt
+++ b/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatStoreActivity.kt
@@ -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
- ): 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
+ ): 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()
- val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
- imm.showSoftInput(binding.editTextMessage, InputMethodManager.SHOW_IMPLICIT)
-
+ 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) {
diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatStoreAdapter.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatStoreAdapter.kt
new file mode 100644
index 0000000..8fcc2b9
--- /dev/null
+++ b/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatStoreAdapter.kt
@@ -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(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() {
+ override fun areItemsTheSame(oldItem: ChatUiMessage, newItem: ChatUiMessage): Boolean {
+ return oldItem.id == newItem.id
+ }
+
+ override fun areContentsTheSame(oldItem: ChatUiMessage, newItem: ChatUiMessage): Boolean {
+ return oldItem == newItem
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatListAdapter.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatStoreListAdapter.kt
similarity index 92%
rename from app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatListAdapter.kt
rename to app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatStoreListAdapter.kt
index dd42766..8d5f471 100644
--- a/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatListAdapter.kt
+++ b/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatStoreListAdapter.kt
@@ -13,20 +13,20 @@ import java.util.Date
import java.util.Locale
import java.util.TimeZone
-class ChatListAdapter(
+class ChatStoreListAdapter(
private val chatList: List,
private val onClick: (ChatItemList) -> Unit
-) : RecyclerView.Adapter() {
+) : RecyclerView.Adapter() {
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
}
diff --git a/app/src/main/java/com/alya/ecommerce_serang/utils/Constants.kt b/app/src/main/java/com/alya/ecommerce_serang/utils/Constants.kt
index 4ec6689..13f2c58 100644
--- a/app/src/main/java/com/alya/ecommerce_serang/utils/Constants.kt
+++ b/app/src/main/java/com/alya/ecommerce_serang/utils/Constants.kt
@@ -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
diff --git a/app/src/main/res/drawable/bg_product_bubble.xml b/app/src/main/res/drawable/bg_product_bubble.xml
new file mode 100644
index 0000000..024858f
--- /dev/null
+++ b/app/src/main/res/drawable/bg_product_bubble.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/bg_product_normal.xml b/app/src/main/res/drawable/bg_product_normal.xml
new file mode 100644
index 0000000..ce46e64
--- /dev/null
+++ b/app/src/main/res/drawable/bg_product_normal.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/bg_product_selected.xml b/app/src/main/res/drawable/bg_product_selected.xml
new file mode 100644
index 0000000..907f113
--- /dev/null
+++ b/app/src/main/res/drawable/bg_product_selected.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/item_message_product_received.xml b/app/src/main/res/layout/item_message_product_received.xml
new file mode 100644
index 0000000..8b6e8a8
--- /dev/null
+++ b/app/src/main/res/layout/item_message_product_received.xml
@@ -0,0 +1,87 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/item_message_product_sent.xml b/app/src/main/res/layout/item_message_product_sent.xml
new file mode 100644
index 0000000..d6ad233
--- /dev/null
+++ b/app/src/main/res/layout/item_message_product_sent.xml
@@ -0,0 +1,95 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file