From 1f47ca6d74fc68bed02514b1d1f84ba1adc3d643 Mon Sep 17 00:00:00 2001 From: shaulascr Date: Thu, 29 May 2025 02:46:31 +0700 Subject: [PATCH] update chat customer and store --- app/src/main/AndroidManifest.xml | 4 +- .../data/repository/ChatRepository.kt | 19 + .../ecommerce_serang/ui/chat/ChatActivity.kt | 332 ++++-- .../ecommerce_serang/ui/chat/ChatAdapter.kt | 156 ++- .../ecommerce_serang/ui/chat/ChatViewModel.kt | 972 +++++++++++------- .../ui/product/DetailProductActivity.kt | 6 +- .../mystore/chat/ChatListStoreActivity.kt | 7 +- .../profile/mystore/chat/ChatStoreActivity.kt | 272 +++-- .../profile/mystore/chat/ChatStoreAdapter.kt | 152 +++ ...ListAdapter.kt => ChatStoreListAdapter.kt} | 8 +- .../alya/ecommerce_serang/utils/Constants.kt | 5 + .../main/res/drawable/bg_product_bubble.xml | 9 + .../main/res/drawable/bg_product_normal.xml | 9 + .../main/res/drawable/bg_product_selected.xml | 9 + .../layout/item_message_product_received.xml | 87 ++ .../res/layout/item_message_product_sent.xml | 95 ++ 16 files changed, 1532 insertions(+), 610 deletions(-) create mode 100644 app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatStoreAdapter.kt rename app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/{ChatListAdapter.kt => ChatStoreListAdapter.kt} (92%) create mode 100644 app/src/main/res/drawable/bg_product_bubble.xml create mode 100644 app/src/main/res/drawable/bg_product_normal.xml create mode 100644 app/src/main/res/drawable/bg_product_selected.xml create mode 100644 app/src/main/res/layout/item_message_product_received.xml create mode 100644 app/src/main/res/layout/item_message_product_sent.xml 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