diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 4edca1b..a5327c7 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -29,13 +29,16 @@
android:theme="@style/Theme.Ecommerce_serang"
android:usesCleartextTraffic="true"
tools:targetApi="31">
+
+ android:exported="false"
+ android:windowSoftInputMode="adjustResize" />
@@ -50,8 +53,8 @@
android:exported="false" />
+ android:exported="false"
+ android:windowSoftInputMode="adjustResize" />
@@ -73,6 +76,10 @@
android:enabled="true"
android:exported="false"
android:foregroundServiceType="dataSync" />
+
+ android:exported="false"
+ android:windowSoftInputMode="adjustResize" />
@@ -146,8 +153,8 @@
android:exported="false" />
+ android:exported="true"
+ android:windowSoftInputMode="adjustResize">
diff --git a/app/src/main/java/com/alya/ecommerce_serang/data/api/retrofit/ApiService.kt b/app/src/main/java/com/alya/ecommerce_serang/data/api/retrofit/ApiService.kt
index 6bfc8da..8fe84d8 100644
--- a/app/src/main/java/com/alya/ecommerce_serang/data/api/retrofit/ApiService.kt
+++ b/app/src/main/java/com/alya/ecommerce_serang/data/api/retrofit/ApiService.kt
@@ -443,6 +443,17 @@ interface ApiService {
@Part chatimg: MultipartBody.Part?
): Response
+ @Multipart
+ @POST("store/sendchat")
+ suspend fun sendChatMessageStore(
+ @PartMap parts: Map,
+ @Part chatimg: MultipartBody.Part? = null
+ ): Response
+
+ @GET("store/chat")
+ suspend fun getChatListStore(
+ ): Response
+
@Multipart
@POST("sendchat")
suspend fun sendChatMessage(
diff --git a/app/src/main/java/com/alya/ecommerce_serang/data/repository/ChatRepository.kt b/app/src/main/java/com/alya/ecommerce_serang/data/repository/ChatRepository.kt
index ccfb887..7ad1656 100644
--- a/app/src/main/java/com/alya/ecommerce_serang/data/repository/ChatRepository.kt
+++ b/app/src/main/java/com/alya/ecommerce_serang/data/repository/ChatRepository.kt
@@ -90,6 +90,60 @@ class ChatRepository @Inject constructor(
}
}
+ suspend fun sendChatMessageStore(
+ storeId: Int,
+ message: String,
+ productId: Int?, // Nullable and optional
+ imageFile: File? = null // Nullable and optional
+ ): Result {
+ return try {
+ val parts = mutableMapOf()
+
+ // Required fields
+ parts["store_id"] = storeId.toString().toRequestBody("text/plain".toMediaType())
+ parts["message"] = message.toRequestBody("text/plain".toMediaType())
+
+ // Optional: Only include if productId is valid
+ if (productId != null && productId > 0) {
+ parts["product_id"] = productId.toString().toRequestBody("text/plain".toMediaType())
+ }
+
+ // Optional: Only include if imageFile is valid
+ val imagePart = imageFile?.takeIf { it.exists() }?.let { file ->
+// val requestFile = file.asRequestBody("image/*".toMediaType())
+ val mimeType = when {
+ file.name.endsWith(".png", ignoreCase = true) -> "image/png"
+ file.name.endsWith(".jpg", ignoreCase = true) || file.name.endsWith(".jpeg", ignoreCase = true) -> "image/jpeg"
+ else -> "image/jpeg" // fallback
+ }
+ val requestFile = file.asRequestBody(mimeType.toMediaType())
+ MultipartBody.Part.createFormData("chatimg", file.name, requestFile)
+ }
+
+ // Log the parts map keys and values (string representations)
+ Log.d("ChatRepository", "Sending chat message with parts:")
+ parts.forEach { (key, body) ->
+ Log.d("ChatRepository", "Key: $key, Value (approx): ${bodyToString(body)}")
+ }
+ Log.d("ChatRepository", "Sending chat message with imagePart: ${imagePart != null}")
+
+ // Send request
+ val response = apiService.sendChatMessageStore(parts, imagePart)
+
+ if (response.isSuccessful) {
+ response.body()?.let { Result.Success(it) } ?: Result.Error(Exception("Empty response body"))
+ } else {
+ val errorMsg = response.errorBody()?.string().orEmpty()
+ Log.e("ChatRepository", "API Error: ${response.code()} - $errorMsg")
+ Result.Error(Exception("API Error: ${response.code()} - $errorMsg"))
+ }
+
+ } catch (e: Exception) {
+ Log.e("ChatRepository", "Exception sending chat message", e)
+ Result.Error(e)
+ }
+ }
+
// Helper function to get string content from RequestBody (best effort)
private fun bodyToString(requestBody: RequestBody): String {
return try {
@@ -217,4 +271,26 @@ class ChatRepository @Inject constructor(
Result.Error(e)
}
}
+
+ suspend fun getListChatStore(): Result> {
+ return try {
+ Log.d("ChatRepository", "Calling getChatListStore() from ApiService")
+
+ val response = apiService.getChatListStore()
+
+ Log.d("ChatRepository", "Response received: isSuccessful=${response.isSuccessful}, code=${response.code()}")
+
+ if (response.isSuccessful) {
+ val chat = response.body()?.chat ?: emptyList()
+ Log.d("ChatRepository", "Chat list size: ${chat.size}")
+ Result.Success(chat)
+ } else {
+ Log.e("ChatRepository", "Failed response: ${response.errorBody()?.string()}")
+ Result.Error(Exception("Failed to fetch chat list. Code: ${response.code()}"))
+ }
+ } catch (e: Exception) {
+ Log.e("ChatRepository", "Exception during getChatListStore", e)
+ Result.Error(e)
+ }
+ }
}
\ No newline at end of file
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 1f15130..35edf9c 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
@@ -41,6 +41,9 @@ class ChatViewModel @Inject constructor(
private val _chatList = MutableLiveData>>()
val chatList: LiveData>> = _chatList
+ private val _chatListStore = MutableLiveData>>()
+ val chatListStore: LiveData>> = _chatListStore
+
private val _storeDetail = MutableLiveData>()
val storeDetail : LiveData> get() = _storeDetail
@@ -367,6 +370,126 @@ 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
+ }
+ }
+
+ 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(
+ storeId = 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)
*/
@@ -488,6 +611,17 @@ class ChatViewModel @Inject constructor(
_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
+ }
+ }
}
/**
diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/MyStoreActivity.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/MyStoreActivity.kt
index a40035b..545d7b9 100644
--- a/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/MyStoreActivity.kt
+++ b/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/MyStoreActivity.kt
@@ -14,8 +14,8 @@ import com.alya.ecommerce_serang.data.api.retrofit.ApiConfig
import com.alya.ecommerce_serang.data.api.retrofit.ApiService
import com.alya.ecommerce_serang.data.repository.MyStoreRepository
import com.alya.ecommerce_serang.databinding.ActivityMyStoreBinding
-import com.alya.ecommerce_serang.ui.chat.ChatListFragment
import com.alya.ecommerce_serang.ui.profile.mystore.balance.BalanceActivity
+import com.alya.ecommerce_serang.ui.profile.mystore.chat.ChatListStoreActivity
import com.alya.ecommerce_serang.ui.profile.mystore.product.ProductActivity
import com.alya.ecommerce_serang.ui.profile.mystore.profile.DetailStoreProfileActivity
import com.alya.ecommerce_serang.ui.profile.mystore.review.ReviewFragment
@@ -124,10 +124,8 @@ class MyStoreActivity : AppCompatActivity() {
}
binding.layoutInbox.setOnClickListener {
- supportFragmentManager.beginTransaction()
- .replace(android.R.id.content, ChatListFragment())
- .addToBackStack(null)
- .commit()
+ val intent = Intent(this, ChatListStoreActivity::class.java)
+ startActivity(intent)
}
}
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/ChatListAdapter.kt
new file mode 100644
index 0000000..dd42766
--- /dev/null
+++ b/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatListAdapter.kt
@@ -0,0 +1,69 @@
+package com.alya.ecommerce_serang.ui.profile.mystore.chat
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.RecyclerView
+import com.alya.ecommerce_serang.BuildConfig.BASE_URL
+import com.alya.ecommerce_serang.R
+import com.alya.ecommerce_serang.data.api.response.chat.ChatItemList
+import com.alya.ecommerce_serang.databinding.ItemChatBinding
+import com.bumptech.glide.Glide
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import java.util.TimeZone
+
+class ChatListAdapter(
+ private val chatList: List,
+ private val onClick: (ChatItemList) -> Unit
+) : RecyclerView.Adapter() {
+
+ inner class ChatViewHolder(private val binding: ItemChatBinding) :
+ RecyclerView.ViewHolder(binding.root) {
+ fun bind(chat: ChatItemList) {
+ binding.txtStoreName.text = chat.storeName
+ binding.txtMessage.text = chat.message
+ binding.txtTime.text = formatTime(chat.latestMessageTime)
+
+ // Process image URL properly
+ val imageUrl = chat.storeImage?.let {
+ if (it.startsWith("/")) BASE_URL + it else it
+ }
+
+ Glide.with(binding.imgStore.context)
+ .load(imageUrl)
+ .placeholder(R.drawable.ic_person)
+ .error(R.drawable.placeholder_image)
+ .into(binding.imgStore)
+
+ // Handle click event
+ binding.root.setOnClickListener {
+ onClick(chat)
+ }
+ }
+
+ private fun formatTime(isoTime: String): String {
+ return try {
+ val inputFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault())
+ inputFormat.timeZone = TimeZone.getTimeZone("UTC")
+ val date = inputFormat.parse(isoTime)
+
+ val outputFormat = SimpleDateFormat("HH:mm", Locale.getDefault())
+ outputFormat.format(date ?: Date())
+ } catch (e: Exception) {
+ ""
+ }
+ }
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ChatViewHolder {
+ val binding = ItemChatBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+ return ChatViewHolder(binding)
+ }
+
+ override fun getItemCount(): Int = chatList.size
+
+ override fun onBindViewHolder(holder: ChatViewHolder, position: Int) {
+ holder.bind(chatList[position])
+ }
+}
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
new file mode 100644
index 0000000..045c957
--- /dev/null
+++ b/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatListStoreActivity.kt
@@ -0,0 +1,114 @@
+package com.alya.ecommerce_serang.ui.profile.mystore.chat
+
+import android.os.Bundle
+import android.util.Log
+import android.widget.Toast
+import androidx.activity.enableEdgeToEdge
+import androidx.activity.viewModels
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import com.alya.ecommerce_serang.data.api.retrofit.ApiConfig
+import com.alya.ecommerce_serang.data.api.retrofit.ApiService
+import com.alya.ecommerce_serang.data.repository.ChatRepository
+import com.alya.ecommerce_serang.data.repository.Result
+import com.alya.ecommerce_serang.databinding.ActivityChatListStoreBinding
+import com.alya.ecommerce_serang.ui.chat.ChatViewModel
+import com.alya.ecommerce_serang.ui.chat.SocketIOService
+import com.alya.ecommerce_serang.utils.BaseViewModelFactory
+import com.alya.ecommerce_serang.utils.SessionManager
+
+class ChatListStoreActivity : AppCompatActivity() {
+ private lateinit var binding: ActivityChatListStoreBinding
+ private lateinit var socketService: SocketIOService
+ private lateinit var apiService: ApiService
+ private lateinit var sessionManager: SessionManager
+
+ private val TAG = "ChatListStoreActivity"
+
+ private val viewModel: ChatViewModel by viewModels {
+ BaseViewModelFactory {
+ val apiService = ApiConfig.getApiService(sessionManager)
+ val chatRepository = ChatRepository(apiService)
+ ChatViewModel(chatRepository, socketService, sessionManager)
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ // Initialize SessionManager and SocketService
+ sessionManager = SessionManager(this)
+ socketService = SocketIOService(sessionManager)
+
+ // Inflate the layout and set content view
+ binding = ActivityChatListStoreBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+ apiService = ApiConfig.getApiService(sessionManager)
+
+ enableEdgeToEdge()
+
+ setupToolbar()
+
+ ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, windowInsets ->
+ val systemBars = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+ view.setPadding(
+ systemBars.left,
+ systemBars.top,
+ systemBars.right,
+ systemBars.bottom
+ )
+ windowInsets
+ }
+
+ Log.d(TAG, "Fetching chat list from ViewModel")
+ viewModel.getChatListStore()
+ observeChatList()
+ }
+
+ private fun setupToolbar(){
+ binding.header.headerLeftIcon.setOnClickListener{
+ finish()
+ }
+ binding.header.headerTitle.text = "Pesan"
+ }
+
+ private fun observeChatList() {
+ viewModel.chatListStore.observe(this) { result ->
+ Log.d(TAG, "Observer triggered with result: $result")
+
+ when (result) {
+ is Result.Success -> {
+ Log.d(TAG, "Chat list fetch success. Data size: ${result.data.size}")
+ val adapter = ChatListAdapter(result.data) { chatItem ->
+ Log.d(TAG, "Chat item clicked: storeId=${chatItem.storeId}, chatRoomId=${chatItem.chatRoomId}")
+ val intent = ChatStoreActivity.createIntent(
+ context = this,
+ storeId = chatItem.storeId,
+ productId = 0,
+ productName = null,
+ productPrice = "",
+ productImage = null,
+ productRating = null,
+ storeName = chatItem.storeName,
+ chatRoomId = chatItem.chatRoomId,
+ storeImage = chatItem.storeImage
+ )
+ startActivity(intent)
+ }
+ binding.chatListRecyclerView.adapter = adapter
+ Log.d(TAG, "Adapter set successfully")
+ }
+
+ is Result.Error -> {
+ Log.e(TAG, "Failed to load chats: ${result.exception.message}")
+ Toast.makeText(this, "Failed to load chats", Toast.LENGTH_SHORT).show()
+ }
+
+ Result.Loading -> {
+ Log.d(TAG, "Chat list is loading...")
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatListStoreFragment.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatListStoreFragment.kt
new file mode 100644
index 0000000..3e8e0b6
--- /dev/null
+++ b/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatListStoreFragment.kt
@@ -0,0 +1,96 @@
+//package com.alya.ecommerce_serang.ui.profile.mystore.chat
+//
+//import android.os.Bundle
+//import android.view.LayoutInflater
+//import android.view.View
+//import android.view.ViewGroup
+//import android.widget.Toast
+//import androidx.fragment.app.Fragment
+//import androidx.fragment.app.viewModels
+//import com.alya.ecommerce_serang.data.api.retrofit.ApiConfig
+//import com.alya.ecommerce_serang.data.repository.ChatRepository
+//import com.alya.ecommerce_serang.data.repository.Result
+//import com.alya.ecommerce_serang.databinding.FragmentChatListBinding
+//import com.alya.ecommerce_serang.ui.chat.ChatViewModel
+//import com.alya.ecommerce_serang.ui.chat.SocketIOService
+//import com.alya.ecommerce_serang.utils.BaseViewModelFactory
+//import com.alya.ecommerce_serang.utils.SessionManager
+//
+//class ChatListStoreFragment : Fragment() {
+//
+// private var _binding: FragmentChatListBinding? = null
+//
+// private val binding get() = _binding!!
+// private lateinit var socketService: SocketIOService
+// private lateinit var sessionManager: SessionManager
+//
+// private val viewModel: com.alya.ecommerce_serang.ui.chat.ChatViewModel by viewModels {
+// BaseViewModelFactory {
+// val apiService = ApiConfig.getApiService(sessionManager)
+// val chatRepository = ChatRepository(apiService)
+// ChatViewModel(chatRepository, socketService, sessionManager)
+// }
+// }
+// override fun onCreate(savedInstanceState: Bundle?) {
+// super.onCreate(savedInstanceState)
+// sessionManager = SessionManager(requireContext())
+// socketService = SocketIOService(sessionManager)
+//
+// }
+//
+// override fun onCreateView(
+// inflater: LayoutInflater, container: ViewGroup?,
+// savedInstanceState: Bundle?
+// ): View {
+// _binding = FragmentChatListBinding.inflate(inflater, container, false)
+// return _binding!!.root
+// }
+//
+// override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+// super.onViewCreated(view, savedInstanceState)
+//
+// viewModel.getChatListStore()
+// observeChatList()
+// }
+//
+// private fun observeChatList() {
+// viewModel.chatListStore.observe(viewLifecycleOwner) { result ->
+// when (result) {
+// is Result.Success -> {
+// val adapter = ChatListAdapter(result.data) { chatItem ->
+// // Use the ChatActivity.createIntent factory method for proper navigation
+// ChatStoreActivity.createIntent(
+// context = requireActivity(),
+// storeId = chatItem.storeId,
+// productId = 0, // Default value since we don't have it in ChatListItem
+// productName = null, // Null is acceptable as per ChatActivity
+// productPrice = "",
+// productImage = null,
+// productRating = null,
+// storeName = chatItem.storeName,
+// chatRoomId = chatItem.chatRoomId,
+// storeImage = chatItem.storeImage
+// )
+// }
+// binding.chatListRecyclerView.adapter = adapter
+// }
+// is Result.Error -> {
+// Toast.makeText(requireContext(), "Failed to load chats", Toast.LENGTH_SHORT).show()
+// }
+// Result.Loading -> {
+// // Optional: show progress bar
+// }
+// }
+// }
+// }
+//
+//
+// override fun onDestroyView() {
+// super.onDestroyView()
+// _binding = null
+// }
+//
+// companion object{
+//
+// }
+//}
\ No newline at end of file
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
new file mode 100644
index 0000000..bf9541f
--- /dev/null
+++ b/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatStoreActivity.kt
@@ -0,0 +1,593 @@
+package com.alya.ecommerce_serang.ui.profile.mystore.chat
+
+import android.Manifest
+import android.app.Activity
+import android.app.AlertDialog
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.os.Bundle
+import android.provider.MediaStore
+import android.text.Editable
+import android.text.TextWatcher
+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
+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
+import androidx.recyclerview.widget.LinearLayoutManager
+import com.alya.ecommerce_serang.BuildConfig.BASE_URL
+import com.alya.ecommerce_serang.R
+import com.alya.ecommerce_serang.data.api.retrofit.ApiConfig
+import com.alya.ecommerce_serang.data.api.retrofit.ApiService
+import com.alya.ecommerce_serang.databinding.ActivityChatBinding
+import com.alya.ecommerce_serang.ui.auth.LoginActivity
+import com.alya.ecommerce_serang.ui.chat.ChatAdapter
+import com.alya.ecommerce_serang.ui.chat.ChatViewModel
+import com.alya.ecommerce_serang.utils.Constants
+import com.alya.ecommerce_serang.utils.SessionManager
+import com.bumptech.glide.Glide
+import dagger.hilt.android.AndroidEntryPoint
+import java.io.File
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import javax.inject.Inject
+import kotlin.math.max
+
+@AndroidEntryPoint
+class ChatStoreActivity : AppCompatActivity() {
+
+ private lateinit var binding: ActivityChatBinding
+
+ @Inject
+ lateinit var sessionManager: SessionManager
+
+ @Inject
+ lateinit var apiService: ApiService
+
+ private lateinit var chatAdapter: ChatAdapter
+
+ private val viewModel: ChatViewModel by viewModels()
+
+ // For image attachment
+ private var tempImageUri: Uri? = null
+
+ // Typing indicator handler
+ private val typingHandler = android.os.Handler(android.os.Looper.getMainLooper())
+ private val stopTypingRunnable = Runnable {
+ viewModel.sendTypingStatus(false)
+ }
+
+ // Activity Result Launchers
+ private val pickImageLauncher = registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+ ) { result ->
+ if (result.resultCode == Activity.RESULT_OK) {
+ result.data?.data?.let { uri ->
+ handleSelectedImage(uri)
+ }
+ }
+ }
+
+ private val takePictureLauncher = registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+ ) { result ->
+ if (result.resultCode == Activity.RESULT_OK) {
+ tempImageUri?.let { uri ->
+ handleSelectedImage(uri)
+ }
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivityChatBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ sessionManager = SessionManager(this)
+ apiService = ApiConfig.getApiService(sessionManager)
+
+ Log.d("ChatActivity", "Token in storage: '${sessionManager.getToken()}'")
+
+ WindowCompat.setDecorFitsSystemWindows(window, false)
+ enableEdgeToEdge()
+
+ // Apply insets to your root layout
+
+
+ // Get parameters from intent
+ val storeId = intent.getIntExtra(Constants.EXTRA_STORE_ID, 0)
+ val productId = intent.getIntExtra(Constants.EXTRA_PRODUCT_ID, 0)
+ val productName = intent.getStringExtra(Constants.EXTRA_PRODUCT_NAME) ?: ""
+ val productPrice = intent.getStringExtra(Constants.EXTRA_PRODUCT_PRICE) ?: ""
+ val productImage = intent.getStringExtra(Constants.EXTRA_PRODUCT_IMAGE) ?: ""
+ val productRating = intent.getFloatExtra(Constants.EXTRA_PRODUCT_RATING, 0f)
+ 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) ?: ""
+
+ // Check if user is logged in
+ val token = sessionManager.getToken()
+
+ if (token.isEmpty()) {
+ // User not logged in, redirect to login
+ Toast.makeText(this, "Please login first", Toast.LENGTH_SHORT).show()
+ startActivity(Intent(this, LoginActivity::class.java))
+ finish()
+ return
+ }
+
+ binding.tvStoreName.text = storeName
+ val fullImageUrl = when (val img = storeImg) {
+ is String -> {
+ if (img.startsWith("/")) BASE_URL + img.substring(1) else img
+ }
+ else -> R.drawable.placeholder_image
+ }
+
+ Glide.with(this)
+ .load(fullImageUrl)
+ .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.setChatParameters(
+ storeId = storeId,
+ productId = productId,
+ productName = productName,
+ productPrice = productPrice,
+ productImage = productImage,
+ productRating = productRating,
+ storeName = storeName
+ )
+
+ // Setup UI components
+ setupRecyclerView()
+ setupListeners()
+ setupTypingIndicator()
+ observeViewModel()
+
+ // If opened from ChatListFragment with a valid chatRoomId
+ if (chatRoomId > 0) {
+ // Directly set the chatRoomId and load chat history
+ viewModel._chatRoomId.value = chatRoomId
+ }
+ }
+
+ private fun setupRecyclerView() {
+ chatAdapter = ChatAdapter()
+ binding.recyclerChat.apply {
+ adapter = chatAdapter
+ layoutManager = LinearLayoutManager(this@ChatStoreActivity).apply {
+ stackFromEnd = true
+ }
+ }
+// 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 setupListeners() {
+ // Back button
+ binding.btnBack.setOnClickListener {
+ onBackPressed()
+ }
+
+ // Options button
+ binding.btnOptions.setOnClickListener {
+ showOptionsMenu()
+ }
+
+ // Send button
+ binding.btnSend.setOnClickListener {
+ val message = binding.editTextMessage.text.toString().trim()
+ val currentState = viewModel.state.value
+ if (message.isNotEmpty() || (currentState != null && currentState.hasAttachment)) {
+ viewModel.sendMessageStore(message)
+ binding.editTextMessage.text.clear()
+ }
+ }
+
+ // Attachment button
+ binding.btnAttachment.setOnClickListener {
+ checkPermissionsAndShowImagePicker()
+ }
+ }
+
+ private fun setupTypingIndicator() {
+ binding.editTextMessage.addTextChangedListener(object : TextWatcher {
+ override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
+
+ override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
+ viewModel.sendTypingStatus(true)
+
+ // Reset the timer
+ typingHandler.removeCallbacks(stopTypingRunnable)
+ typingHandler.postDelayed(stopTypingRunnable, 1000)
+ }
+
+ override fun afterTextChanged(s: Editable?) {}
+ })
+
+ binding.editTextMessage.requestFocus()
+ 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)
+
+ // Scroll to bottom if new message
+ if (state.messages.isNotEmpty()) {
+ binding.recyclerChat.scrollToPosition(state.messages.size - 1)
+ }
+
+ // Update product info
+ if (!state.productName.isNullOrEmpty()) {
+ binding.tvProductName.text = state.productName
+ binding.tvProductPrice.text = state.productPrice
+ binding.ratingBar.rating = state.productRating
+ binding.tvRating.text = state.productRating.toString()
+ binding.tvSellerName.text = state.storeName
+ binding.tvStoreName.text=state.storeName
+
+ val fullImageUrl = when (val img = state.productImageUrl) {
+ is String -> {
+ if (img.startsWith("/")) BASE_URL + img.substring(1) else img
+ }
+ else -> R.drawable.placeholder_image
+ }
+
+ // Load product image
+ if (!state.productImageUrl.isNullOrEmpty()) {
+ Glide.with(this@ChatStoreActivity)
+ .load(fullImageUrl)
+ .centerCrop()
+ .placeholder(R.drawable.placeholder_image)
+ .error(R.drawable.placeholder_image)
+ .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)
+ } else {
+ binding.editTextMessage.hint = getString(R.string.write_message)
+ }
+
+
+ // Show typing indicator
+ binding.tvTypingIndicator.visibility =
+ if (state.isOtherUserTyping) View.VISIBLE else View.GONE
+
+ // Show error if any
+ state.error?.let { error ->
+ Toast.makeText(this@ChatStoreActivity, error, Toast.LENGTH_SHORT).show()
+ viewModel.clearError()
+ }
+ })
+ }
+
+ private fun showOptionsMenu() {
+ val options = arrayOf(
+ getString(R.string.block_user),
+ getString(R.string.report),
+ getString(R.string.clear_chat),
+ getString(R.string.cancel)
+ )
+
+ AlertDialog.Builder(this)
+ .setTitle(getString(R.string.options))
+ .setItems(options) { dialog, which ->
+ when (which) {
+ 0 -> Toast.makeText(this, R.string.block_user_selected, Toast.LENGTH_SHORT).show()
+ 1 -> Toast.makeText(this, R.string.report_selected, Toast.LENGTH_SHORT).show()
+ 2 -> Toast.makeText(this, R.string.clear_chat_selected, Toast.LENGTH_SHORT).show()
+ }
+ dialog.dismiss()
+ }
+ .show()
+ }
+
+ private fun checkPermissionsAndShowImagePicker() {
+ if (ContextCompat.checkSelfPermission(
+ this,
+ Manifest.permission.READ_EXTERNAL_STORAGE
+ ) != PackageManager.PERMISSION_GRANTED
+ ) {
+ ActivityCompat.requestPermissions(
+ this,
+ arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.CAMERA),
+ Constants.REQUEST_STORAGE_PERMISSION
+ )
+ } else {
+ showImagePickerOptions()
+ }
+ }
+
+ private fun showImagePickerOptions() {
+ val options = arrayOf(
+ getString(R.string.take_photo),
+ getString(R.string.choose_from_gallery),
+ getString(R.string.cancel)
+ )
+
+ AlertDialog.Builder(this)
+ .setTitle(getString(R.string.select_attachment))
+ .setItems(options) { dialog, which ->
+ when (which) {
+ 0 -> openCamera()
+ 1 -> openGallery()
+ }
+ dialog.dismiss()
+ }
+ .show()
+ }
+
+ private fun openCamera() {
+ val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
+ val imageFileName = "IMG_${timeStamp}.jpg"
+ val storageDir = getExternalFilesDir(null)
+ val imageFile = File(storageDir, imageFileName)
+
+ tempImageUri = FileProvider.getUriForFile(
+ this,
+ "${applicationContext.packageName}.fileprovider",
+ imageFile
+ )
+
+ val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply {
+ putExtra(MediaStore.EXTRA_OUTPUT, tempImageUri)
+ }
+
+ takePictureLauncher.launch(intent)
+ }
+
+ private fun openGallery() {
+ val intent = Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI)
+ pickImageLauncher.launch(intent)
+ }
+
+ private fun handleSelectedImage(uri: Uri) {
+ try {
+ Log.d(TAG, "Processing selected image: $uri")
+
+ // Always use the copy-to-cache approach for reliability
+ contentResolver.openInputStream(uri)?.use { inputStream ->
+ val fileName = "chat_img_${System.currentTimeMillis()}.jpg"
+ val outputFile = File(cacheDir, fileName)
+
+ outputFile.outputStream().use { outputStream ->
+ inputStream.copyTo(outputStream)
+ }
+
+ if (outputFile.exists() && outputFile.length() > 0) {
+ if (outputFile.length() > 5 * 1024 * 1024) {
+ Log.e(TAG, "File too large: ${outputFile.length()} bytes")
+ Toast.makeText(this, "Image too large (max 5MB)", Toast.LENGTH_SHORT).show()
+ return
+ }
+
+ Log.d(TAG, "Image processed successfully: ${outputFile.absolutePath}, size: ${outputFile.length()}")
+ viewModel.setSelectedImageFile(outputFile)
+ Toast.makeText(this, "Image selected", Toast.LENGTH_SHORT).show()
+ } else {
+ Log.e(TAG, "Failed to create image file")
+ Toast.makeText(this, "Failed to process image", Toast.LENGTH_SHORT).show()
+ }
+ } ?: run {
+ Log.e(TAG, "Could not open input stream for URI: $uri")
+ Toast.makeText(this, "Could not access image", Toast.LENGTH_SHORT).show()
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error handling selected image", e)
+ Toast.makeText(this, "Error: ${e.message}", Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ override fun onRequestPermissionsResult(
+ requestCode: Int,
+ permissions: Array,
+ grantResults: IntArray
+ ) {
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults)
+ if (requestCode == Constants.REQUEST_STORAGE_PERMISSION) {
+ if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+ showImagePickerOptions()
+ } else {
+ Toast.makeText(this, R.string.permission_denied, Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ typingHandler.removeCallbacks(stopTypingRunnable)
+ }
+
+ companion object {
+ private const val TAG = "ChatActivity"
+
+ /**
+ * Create an intent to start the ChatActivity
+ */
+ fun createIntent(
+ context: Activity,
+ storeId: Int,
+ productId: Int = 0,
+ productName: String? = null,
+ productPrice: String = "",
+ productImage: String? = null,
+ productRating: String? = null,
+ storeName: String? = null,
+ chatRoomId: Int = 0,
+ storeImage: String? = null
+ ): Intent {
+ return Intent(context, ChatStoreActivity::class.java).apply {
+ putExtra(Constants.EXTRA_STORE_ID, storeId)
+ putExtra(Constants.EXTRA_PRODUCT_ID, productId)
+ putExtra(Constants.EXTRA_PRODUCT_NAME, productName)
+ putExtra(Constants.EXTRA_PRODUCT_PRICE, productPrice)
+ putExtra(Constants.EXTRA_PRODUCT_IMAGE, productImage)
+ putExtra(Constants.EXTRA_STORE_IMAGE, storeImage)
+
+ // Convert productRating string to float if provided
+ if (productRating != null) {
+ try {
+ putExtra(Constants.EXTRA_PRODUCT_RATING, productRating.toFloat())
+ } catch (e: NumberFormatException) {
+ putExtra(Constants.EXTRA_PRODUCT_RATING, 0f)
+ }
+ } else {
+ putExtra(Constants.EXTRA_PRODUCT_RATING, 0f)
+ }
+
+ putExtra(Constants.EXTRA_STORE_NAME, storeName)
+
+ if (chatRoomId > 0) {
+ putExtra(Constants.EXTRA_CHAT_ROOM_ID, chatRoomId)
+ }
+ }
+ }
+ }
+}
+
+//if implement typing status
+// private fun handleConnectionState(state: ConnectionState) {
+// when (state) {
+// is ConnectionState.Connected -> {
+// binding.tvConnectionStatus.visibility = View.GONE
+// }
+// is ConnectionState.Connecting -> {
+// binding.tvConnectionStatus.visibility = View.VISIBLE
+// binding.tvConnectionStatus.text = getString(R.string.connecting)
+// }
+// is ConnectionState.Disconnected -> {
+// binding.tvConnectionStatus.visibility = View.VISIBLE
+// binding.tvConnectionStatus.text = getString(R.string.disconnected_reconnecting)
+// }
+// is ConnectionState.Error -> {
+// binding.tvConnectionStatus.visibility = View.VISIBLE
+// binding.tvConnectionStatus.text = getString(R.string.connection_error, state.message)
+// }
+// }
+// }
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_chat_list_store.xml b/app/src/main/res/layout/activity_chat_list_store.xml
new file mode 100644
index 0000000..b3f8edf
--- /dev/null
+++ b/app/src/main/res/layout/activity_chat_list_store.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file