add chat store

This commit is contained in:
shaulascr
2025-05-28 18:51:32 +07:00
parent b1db662494
commit 9b0ae95dfc
10 changed files with 1151 additions and 13 deletions

View File

@ -29,13 +29,16 @@
android:theme="@style/Theme.Ecommerce_serang"
android:usesCleartextTraffic="true"
tools:targetApi="31">
<activity
android:name=".ui.profile.mystore.chat.ChatListStoreActivity"
android:exported="false" />
<activity
android:name=".ui.product.storeDetail.StoreDetailActivity"
android:exported="false" />
<activity
android:name=".ui.auth.RegisterStoreActivity"
android:windowSoftInputMode="adjustResize"
android:exported="false" />
android:exported="false"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".ui.profile.editprofile.EditProfileCustActivity"
android:exported="false" />
@ -50,8 +53,8 @@
android:exported="false" />
<activity
android:name=".ui.profile.mystore.profile.EditStoreProfileActivity"
android:windowSoftInputMode="adjustResize"
android:exported="false" />
android:exported="false"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".ui.profile.mystore.sells.shipment.DetailShipmentActivity"
android:exported="false" />
@ -73,6 +76,10 @@
android:enabled="true"
android:exported="false"
android:foregroundServiceType="dataSync" />
<activity
android:name=".ui.profile.mystore.chat.ChatStoreActivity"
android:exported="false"
android:windowSoftInputMode="adjustResize|stateHidden" />
<activity
android:name=".ui.profile.mystore.profile.shipping_service.ShippingServiceActivity"
@ -97,8 +104,8 @@
android:exported="false" />
<activity
android:name=".ui.order.address.AddAddressActivity"
android:windowSoftInputMode="adjustResize"
android:exported="false" />
android:exported="false"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".ui.order.address.AddressActivity"
android:exported="false" />
@ -146,8 +153,8 @@
android:exported="false" />
<activity
android:name=".ui.auth.RegisterActivity"
android:windowSoftInputMode="adjustResize"
android:exported="true">
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

View File

@ -443,6 +443,17 @@ interface ApiService {
@Part chatimg: MultipartBody.Part?
): Response<SendChatResponse>
@Multipart
@POST("store/sendchat")
suspend fun sendChatMessageStore(
@PartMap parts: Map<String, @JvmSuppressWildcards RequestBody>,
@Part chatimg: MultipartBody.Part? = null
): Response<SendChatResponse>
@GET("store/chat")
suspend fun getChatListStore(
): Response<ChatListResponse>
@Multipart
@POST("sendchat")
suspend fun sendChatMessage(

View File

@ -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<SendChatResponse> {
return try {
val parts = mutableMapOf<String, RequestBody>()
// 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<List<ChatItemList>> {
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)
}
}
}

View File

@ -41,6 +41,9 @@ class ChatViewModel @Inject constructor(
private val _chatList = MutableLiveData<Result<List<ChatItemList>>>()
val chatList: LiveData<Result<List<ChatItemList>>> = _chatList
private val _chatListStore = MutableLiveData<Result<List<ChatItemList>>>()
val chatListStore: LiveData<Result<List<ChatItemList>>> = _chatListStore
private val _storeDetail = MutableLiveData<Result<StoreProduct?>>()
val storeDetail : LiveData<Result<StoreProduct?>> 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
}
}
}
/**

View File

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

View File

@ -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<ChatItemList>,
private val onClick: (ChatItemList) -> Unit
) : RecyclerView.Adapter<ChatListAdapter.ChatViewHolder>() {
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])
}
}

View File

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

View File

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

View File

@ -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<WindowInsetsAnimationCompat>
): WindowInsetsCompat {
val imeAnimation = runningAnimations.find {
it.typeMask and WindowInsetsCompat.Type.ime() != 0
} ?: return insets
val animatedBottomPadding = startPaddingBottom +
(endPaddingBottom - startPaddingBottom) * imeAnimation.interpolatedFraction
binding.layoutChatInput.setPadding(
binding.layoutChatInput.paddingLeft,
binding.layoutChatInput.paddingTop,
binding.layoutChatInput.paddingRight,
animatedBottomPadding.toInt()
)
binding.recyclerChat.setPadding(
binding.recyclerChat.paddingLeft,
binding.recyclerChat.paddingTop,
binding.recyclerChat.paddingRight,
animatedBottomPadding.toInt() + binding.layoutChatInput.height
)
return insets
}
})
// Set chat parameters to ViewModel
viewModel.setChatParameters(
storeId = storeId,
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<out String>,
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)
// }
// }
// }

View File

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.profile.mystore.chat.ChatListStoreActivity">
<include
android:id="@+id/header"
layout="@layout/header" />
<!-- <TextView-->
<!-- android:id="@+id/chatHeaderTitle"-->
<!-- android:layout_width="match_parent"-->
<!-- android:layout_height="wrap_content"-->
<!-- android:text="Pesan"-->
<!-- android:textSize="24sp"-->
<!-- android:padding="16dp"-->
<!-- android:layout_marginHorizontal="8dp"-->
<!-- android:fontFamily="@font/dmsans_bold" />-->
<com.google.android.material.divider.MaterialDivider
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:dividerColor="@color/black_100" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/chatListRecyclerView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:padding="8dp"
android:clipToPadding="false"
tools:listitem="@layout/item_chat"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
</LinearLayout>