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