fixing chat activity and fragment

This commit is contained in:
shaulascr
2025-05-01 03:53:40 +07:00
parent 2bc4bda536
commit adeb0537f3
25 changed files with 2588 additions and 25 deletions

View File

@ -11,7 +11,7 @@
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
@ -28,15 +28,19 @@
android:theme="@style/Theme.Ecommerce_serang"
android:usesCleartextTraffic="true"
tools:targetApi="31">
<!-- <provider-->
<!-- android:name="androidx.startup.InitializationProvider"-->
<!-- android:authorities="${applicationId}.androidx-startup"-->
<!-- tools:node="remove" />-->
<activity
android:name=".ui.chat.ChatActivity"
android:exported="false" />
<!-- <provider -->
<!-- android:name="androidx.startup.InitializationProvider" -->
<!-- android:authorities="${applicationId}.androidx-startup" -->
<!-- tools:node="remove" /> -->
<service
android:name=".ui.notif.SimpleWebSocketService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="dataSync" />
android:foregroundServiceType="dataSync" />
<activity
android:name=".ui.notif.NotificationActivity"
android:exported="false" />

View File

@ -0,0 +1,5 @@
package com.alya.ecommerce_serang.data.api.dto
class ChatRequest {
}

View File

@ -0,0 +1,6 @@
package com.alya.ecommerce_serang.data.api.dto
data class UpdateChatRequest (
val id: Int,
val status: String
)

View File

@ -0,0 +1,40 @@
package com.alya.ecommerce_serang.data.api.response.chat
import com.google.gson.annotations.SerializedName
import java.io.File
data class ChatHistoryResponse(
@field:SerializedName("chat")
val chat: List<ChatItem>,
@field:SerializedName("message")
val message: String
)
data class ChatItem(
@field:SerializedName("attachment")
val attachment: File? = null,
@field:SerializedName("product_id")
val productId: Int,
@field:SerializedName("chat_room_id")
val chatRoomId: Int,
@field:SerializedName("created_at")
val createdAt: String,
@field:SerializedName("id")
val id: Int,
@field:SerializedName("message")
val message: String,
@field:SerializedName("sender_id")
val senderId: Int,
@field:SerializedName("status")
val status: String
)

View File

@ -0,0 +1,39 @@
package com.alya.ecommerce_serang.data.api.response.chat
import com.google.gson.annotations.SerializedName
data class SendChatResponse(
@field:SerializedName("chatLine")
val chatLine: ChatLine,
@field:SerializedName("message")
val message: String
)
data class ChatLine(
@field:SerializedName("attachment")
val attachment: String,
@field:SerializedName("product_id")
val productId: Int,
@field:SerializedName("chat_room_id")
val chatRoomId: Int,
@field:SerializedName("created_at")
val createdAt: String,
@field:SerializedName("id")
val id: Int,
@field:SerializedName("message")
val message: String,
@field:SerializedName("sender_id")
val senderId: Int,
@field:SerializedName("status")
val status: String
)

View File

@ -0,0 +1,39 @@
package com.alya.ecommerce_serang.data.api.response.chat
import com.google.gson.annotations.SerializedName
data class UpdateChatResponse(
@field:SerializedName("address")
val address: Address,
@field:SerializedName("message")
val message: String
)
data class Address(
@field:SerializedName("attachment")
val attachment: Any,
@field:SerializedName("product_id")
val productId: Int,
@field:SerializedName("chat_room_id")
val chatRoomId: Int,
@field:SerializedName("created_at")
val createdAt: String,
@field:SerializedName("id")
val id: Int,
@field:SerializedName("message")
val message: String,
@field:SerializedName("sender_id")
val senderId: Int,
@field:SerializedName("status")
val status: String
)

View File

@ -12,6 +12,7 @@ import com.alya.ecommerce_serang.data.api.dto.OtpRequest
import com.alya.ecommerce_serang.data.api.dto.RegisterRequest
import com.alya.ecommerce_serang.data.api.dto.SearchRequest
import com.alya.ecommerce_serang.data.api.dto.UpdateCart
import com.alya.ecommerce_serang.data.api.dto.UpdateChatRequest
import com.alya.ecommerce_serang.data.api.response.ViewStoreProductsResponse
import com.alya.ecommerce_serang.data.api.response.auth.LoginResponse
import com.alya.ecommerce_serang.data.api.response.auth.OtpResponse
@ -19,6 +20,9 @@ import com.alya.ecommerce_serang.data.api.response.auth.RegisterResponse
import com.alya.ecommerce_serang.data.api.response.cart.AddCartResponse
import com.alya.ecommerce_serang.data.api.response.cart.ListCartResponse
import com.alya.ecommerce_serang.data.api.response.cart.UpdateCartResponse
import com.alya.ecommerce_serang.data.api.response.chat.ChatHistoryResponse
import com.alya.ecommerce_serang.data.api.response.chat.SendChatResponse
import com.alya.ecommerce_serang.data.api.response.chat.UpdateChatResponse
import com.alya.ecommerce_serang.data.api.response.order.AddEvidenceResponse
import com.alya.ecommerce_serang.data.api.response.order.ComplaintResponse
import com.alya.ecommerce_serang.data.api.response.order.CompletedOrderResponse
@ -209,4 +213,23 @@ interface ApiService {
@GET("search")
suspend fun getSearchHistory(): Response<SearchHistoryResponse>
@Multipart
@POST("sendchat")
suspend fun sendChatLine(
@Part("store_id") storeId: RequestBody,
@Part("message") message: RequestBody,
@Part("product_id") productId: RequestBody,
@Part("chatimg") chatimg: MultipartBody.Part
): Response<SendChatResponse>
@PUT("chatstatus")
suspend fun updateChatStatus(
@Body request: UpdateChatRequest
): Response<UpdateChatResponse>
@GET("chatdetail/{chatRoomId}")
suspend fun getChatDetail(
@Path("chatRoomId") chatRoomId: Int
): Response<ChatHistoryResponse>
}

View File

@ -3,10 +3,19 @@ package com.alya.ecommerce_serang.data.repository
import com.alya.ecommerce_serang.data.api.dto.LoginRequest
import com.alya.ecommerce_serang.data.api.dto.OtpRequest
import com.alya.ecommerce_serang.data.api.dto.RegisterRequest
import com.alya.ecommerce_serang.data.api.dto.UpdateChatRequest
import com.alya.ecommerce_serang.data.api.dto.UserProfile
import com.alya.ecommerce_serang.data.api.response.auth.LoginResponse
import com.alya.ecommerce_serang.data.api.response.auth.OtpResponse
import com.alya.ecommerce_serang.data.api.response.chat.ChatHistoryResponse
import com.alya.ecommerce_serang.data.api.response.chat.SendChatResponse
import com.alya.ecommerce_serang.data.api.response.chat.UpdateChatResponse
import com.alya.ecommerce_serang.data.api.retrofit.ApiService
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.File
class UserRepository(private val apiService: ApiService) {
@ -56,6 +65,101 @@ class UserRepository(private val apiService: ApiService) {
}
}
suspend fun sendChatMessage(
storeId: Int,
message: String,
productId: Int,
imageFile: File? = null
): Result<SendChatResponse> {
return try {
// Create request bodies for text fields
val storeIdBody = storeId.toString().toRequestBody("text/plain".toMediaTypeOrNull())
val messageBody = message.toRequestBody("text/plain".toMediaTypeOrNull())
val productIdBody = productId.toString().toRequestBody("text/plain".toMediaTypeOrNull())
// Create multipart body for the image file
val imageMultipart = if (imageFile != null && imageFile.exists()) {
val requestFile = imageFile.asRequestBody("image/*".toMediaTypeOrNull())
MultipartBody.Part.createFormData("chatimg", imageFile.name, requestFile)
} else {
// Create an empty part if no image is provided
val emptyRequest = "".toRequestBody("text/plain".toMediaTypeOrNull())
MultipartBody.Part.createFormData("chatimg", "", emptyRequest)
}
// Make the API call
val response = apiService.sendChatLine(
storeId = storeIdBody,
message = messageBody,
productId = productIdBody,
chatimg = imageMultipart
)
if (response.isSuccessful) {
response.body()?.let {
Result.Success(it)
} ?: Result.Error(Exception("Send chat response is empty"))
} else {
Result.Error(Exception(response.errorBody()?.string() ?: "Unknown error"))
}
} catch (e: Exception) {
Result.Error(e)
}
}
/**
* Updates the status of a message (sent, delivered, read)
*
* @param messageId The ID of the message to update
* @param status The new status to set
* @return Result containing the updated message details or error
*/
suspend fun updateMessageStatus(
messageId: Int,
status: String
): Result<UpdateChatResponse> {
return try {
val requestBody = UpdateChatRequest(
id = messageId,
status = status
)
val response = apiService.updateChatStatus(requestBody)
if (response.isSuccessful) {
response.body()?.let {
Result.Success(it)
} ?: Result.Error(Exception("Update status response is empty"))
} else {
Result.Error(Exception(response.errorBody()?.string() ?: "Unknown error"))
}
} catch (e: Exception) {
Result.Error(e)
}
}
/**
* Gets the chat history for a specific chat room
*
* @param chatRoomId The ID of the chat room
* @return Result containing the list of chat messages or error
*/
suspend fun getChatHistory(chatRoomId: Int): Result<ChatHistoryResponse> {
return try {
val response = apiService.getChatDetail(chatRoomId)
if (response.isSuccessful) {
response.body()?.let {
Result.Success(it)
} ?: Result.Error(Exception("Chat history response is empty"))
} else {
Result.Error(Exception(response.errorBody()?.string() ?: "Unknown error"))
}
} catch (e: Exception) {
Result.Error(e)
}
}
}

View File

@ -0,0 +1,36 @@
package com.alya.ecommerce_serang.di
import android.content.Context
import com.alya.ecommerce_serang.data.api.retrofit.ApiService
import com.alya.ecommerce_serang.data.repository.UserRepository
import com.alya.ecommerce_serang.ui.chat.SocketIOService
import com.alya.ecommerce_serang.utils.SessionManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object ChatModule {
@Provides
@Singleton
fun provideSessionManager(@ApplicationContext context: Context): SessionManager {
return SessionManager(context)
}
@Provides
@Singleton
fun provideChatRepository(apiService: ApiService): UserRepository {
return UserRepository(apiService)
}
@Provides
@Singleton
fun provideSocketIOService(sessionManager: SessionManager): SocketIOService {
return SocketIOService(sessionManager)
}
}

View File

@ -0,0 +1,392 @@
package com.alya.ecommerce_serang.ui.chat
import android.app.Activity
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.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.WindowInsetsCompat
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
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.repository.ProductRepository
import com.alya.ecommerce_serang.data.repository.UserRepository
import com.alya.ecommerce_serang.databinding.ActivityChatBinding
import com.alya.ecommerce_serang.ui.auth.LoginActivity
import com.alya.ecommerce_serang.ui.product.ProductUserViewModel
import com.alya.ecommerce_serang.utils.BaseViewModelFactory
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 kotlinx.coroutines.launch
import java.util.Locale
import javax.inject.Inject
@AndroidEntryPoint
class ChatActivity : AppCompatActivity() {
private lateinit var binding: ActivityChatBinding
@Inject
lateinit var sessionManager: SessionManager
private lateinit var socketService: SocketIOService
@Inject
private lateinit var chatAdapter: ChatAdapter
private val viewModel: ChatViewModel by viewModels {
BaseViewModelFactory {
val apiService = ApiConfig.getApiService(sessionManager)
val userRepository = UserRepository(apiService)
ChatViewModel(userRepository, socketService, sessionManager)
}
}
// For image attachment
private var tempImageUri: Uri? = null
// Chat parameters from intent
private var chatRoomId: Int = 0
private var storeId: Int = 0
private var productId: Int = 0
// 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)
// Get parameters from intent
chatRoomId = intent.getIntExtra(Constants.EXTRA_CHAT_ROOM_ID, 0)
storeId = intent.getIntExtra(Constants.EXTRA_STORE_ID, 0)
productId = intent.getIntExtra(Constants.EXTRA_PRODUCT_ID, 0)
// Check if user is logged in
val userId = sessionManager.getUserId()
val token = sessionManager.getToken()
if (userId.isNullOrEmpty() || token.isNullOrEmpty()) {
// 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
}
Log.d(TAG, "Chat Activity started - User ID: $userId, Chat Room: $chatRoomId")
// Initialize ViewModel
initViewModel()
// Setup UI components
setupRecyclerView()
setupListeners()
setupTypingIndicator()
observeViewModel()
}
private fun initViewModel() {
// Set chat parameters to ViewModel
viewModel.setChatParameters(
chatRoomId = chatRoomId,
storeId = storeId,
productId = productId,
productName = intent.getStringExtra(Constants.EXTRA_PRODUCT_NAME) ?: "",
productPrice = intent.getStringExtra(Constants.EXTRA_PRODUCT_PRICE) ?: "",
productImage = intent.getStringExtra(Constants.EXTRA_PRODUCT_IMAGE) ?: "",
productRating = intent.getFloatExtra(Constants.EXTRA_PRODUCT_RATING, 0f),
storeName = intent.getStringExtra(Constants.EXTRA_STORE_NAME) ?: ""
)
}
private fun setupRecyclerView() {
chatAdapter = ChatAdapter()
binding.recyclerChat.apply {
adapter = chatAdapter
layoutManager = LinearLayoutManager(this@ChatActivity).apply {
stackFromEnd = true
}
}
}
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()
if (message.isNotEmpty() || viewModel.state.value?.hasAttachment ?: false) {
viewModel.sendMessage(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?) {}
})
}
private fun observeViewModel() {
lifecycleScope.launch {
viewModel.state.collectLatest { 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
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
// Load product image
if (state.productImageUrl.isNotEmpty()) {
Glide.with(this@ChatActivity)
.load(BASE_URL + state.productImageUrl)
.centerCrop()
.placeholder(R.drawable.placeholder_image)
.error(R.drawable.placeholder_image)
.into(binding.imgProduct)
}
// Show/hide loading indicators
// binding.progressBar.visibility = if (state.isLoading) View.VISIBLE else View.GONE
binding.btnSend.isEnabled = !state.isSending
// 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
// Handle connection state
handleConnectionState(state.connectionState)
// Show error if any
state.error?.let { error ->
Toast.makeText(this@ChatActivity, error, Toast.LENGTH_SHORT).show()
viewModel.clearError()
}
}
}
}
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)
}
}
}
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) {
// Get the file from Uri
val filePathColumn = arrayOf(MediaStore.Images.Media.DATA)
val cursor = contentResolver.query(uri, filePathColumn, null, null, null)
cursor?.moveToFirst()
val columnIndex = cursor?.getColumnIndex(filePathColumn[0])
val filePath = cursor?.getString(columnIndex ?: 0)
cursor?.close()
if (filePath != null) {
viewModel.setSelectedImageFile(File(filePath))
Toast.makeText(this, R.string.image_selected, 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"
}
}

View File

@ -0,0 +1,135 @@
package com.alya.ecommerce_serang.ui.chat
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.alya.ecommerce_serang.BuildConfig.BASE_URL
import com.alya.ecommerce_serang.R
import com.alya.ecommerce_serang.databinding.ItemMessageReceivedBinding
import com.alya.ecommerce_serang.databinding.ItemMessageSentBinding
import com.bumptech.glide.Glide
class ChatAdapter : ListAdapter<ChatUiMessage, RecyclerView.ViewHolder>(ChatDiffCallback()) {
companion object {
private const val VIEW_TYPE_MESSAGE_SENT = 1
private const val VIEW_TYPE_MESSAGE_RECEIVED = 2
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return if (viewType == VIEW_TYPE_MESSAGE_SENT) {
val binding = ItemMessageSentBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
SentMessageViewHolder(binding)
} else {
val binding = ItemMessageReceivedBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
ReceivedMessageViewHolder(binding)
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val message = getItem(position)
when (holder.itemViewType) {
VIEW_TYPE_MESSAGE_SENT -> (holder as SentMessageViewHolder).bind(message)
VIEW_TYPE_MESSAGE_RECEIVED -> (holder as ReceivedMessageViewHolder).bind(message)
}
}
override fun getItemViewType(position: Int): Int {
val message = getItem(position)
return if (message.isSentByMe) {
VIEW_TYPE_MESSAGE_SENT
} else {
VIEW_TYPE_MESSAGE_RECEIVED
}
}
/**
* ViewHolder for messages sent by the current user
*/
inner class SentMessageViewHolder(private val binding: ItemMessageSentBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(message: ChatUiMessage) {
binding.tvMessage.text = message.message
binding.tvTimestamp.text = message.time
// Show message status
val statusIcon = when (message.status) {
Constants.STATUS_SENT -> R.drawable.ic_check
Constants.STATUS_DELIVERED -> R.drawable.ic_double_check
Constants.STATUS_READ -> R.drawable.ic_double_check_read
else -> R.drawable.ic_check
}
binding.imgStatus.setImageResource(statusIcon)
// Handle attachment if exists
if (message.attachment.isNotEmpty()) {
binding.imgAttachment.visibility = View.VISIBLE
Glide.with(binding.root.context)
.load(BASE_URL + message.attachment)
.centerCrop()
.placeholder(R.drawable.placeholder_image)
.error(R.drawable.placeholder_image)
.into(binding.imgAttachment)
} else {
binding.imgAttachment.visibility = View.GONE
}
}
}
/**
* ViewHolder for messages received from other users
*/
inner class ReceivedMessageViewHolder(private val binding: ItemMessageReceivedBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(message: ChatUiMessage) {
binding.tvMessage.text = message.message
binding.tvTimestamp.text = message.time
// Handle attachment if exists
if (message.attachment.isNotEmpty()) {
binding.imgAttachment.visibility = View.VISIBLE
Glide.with(binding.root.context)
.load(BASE_URL + message.attachment)
.centerCrop()
.placeholder(R.drawable.placeholder_image)
.error(R.drawable.placeholder_image)
.into(binding.imgAttachment)
} else {
binding.imgAttachment.visibility = View.GONE
}
// Load avatar image
Glide.with(binding.root.context)
.load(R.drawable.ic_person) // Replace with actual avatar URL if available
.circleCrop()
.into(binding.imgAvatar)
}
}
}
/**
* DiffCallback for optimizing RecyclerView updates
*/
class ChatDiffCallback : DiffUtil.ItemCallback<ChatUiMessage>() {
override fun areItemsTheSame(oldItem: ChatUiMessage, newItem: ChatUiMessage): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: ChatUiMessage, newItem: ChatUiMessage): Boolean {
return oldItem == newItem
}
}

View File

@ -1,32 +1,343 @@
package com.alya.ecommerce_serang.ui.chat
import android.Manifest
import android.app.Activity
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 androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.LinearLayoutManager
import com.alya.ecommerce_serang.BuildConfig.BASE_URL
import com.alya.ecommerce_serang.R
import com.alya.ecommerce_serang.utils.viewmodel.ChatViewModel
import com.alya.ecommerce_serang.databinding.FragmentChatBinding
import com.alya.ecommerce_serang.utils.Constants
import com.bumptech.glide.Glide
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import java.io.File
import java.text.SimpleDateFormat
import java.util.Locale
/**
* A simple [Fragment] subclass.
* Use the [ChatFragment.newInstance] factory method to
* create an instance of this fragment.
*/
@AndroidEntryPoint
class ChatFragment : Fragment() {
companion object {
fun newInstance() = ChatFragment()
}
private var _binding: FragmentChatBinding? = null
private val binding get() = _binding!!
private val viewModel: ChatViewModel by viewModels()
private val args: ChatFragmentArgs by navArgs()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
private lateinit var chatAdapter: ChatAdapter
// TODO: Use the ViewModel
// 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 onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return inflater.inflate(R.layout.fragment_chat, container, false)
_binding = FragmentChatBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupRecyclerView()
setupListeners()
setupTypingIndicator()
observeViewModel()
}
private fun setupRecyclerView() {
chatAdapter = ChatAdapter()
binding.recyclerChat.apply {
adapter = chatAdapter
layoutManager = LinearLayoutManager(requireContext()).apply {
stackFromEnd = true
}
}
}
private fun setupListeners() {
// Back button
binding.btnBack.setOnClickListener {
requireActivity().onBackPressed()
}
// Options button
binding.btnOptions.setOnClickListener {
showOptionsMenu()
}
// Send button
binding.btnSend.setOnClickListener {
val message = binding.editTextMessage.text.toString().trim()
if (message.isNotEmpty() || viewModel.state.value.hasAttachment) {
viewModel.sendMessage(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?) {}
})
}
private fun observeViewModel() {
viewLifecycleOwner.lifecycleScope.launch {
viewModel.state.collectLatest { 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
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
// Load product image
if (state.productImageUrl.isNotEmpty()) {
Glide.with(requireContext())
.load(BASE_URL + state.productImageUrl)
.centerCrop()
.placeholder(R.drawable.placeholder_image)
.error(R.drawable.placeholder_image)
.into(binding.imgProduct)
}
// Show/hide loading indicators
binding.progressBar.visibility = if (state.isLoading) View.VISIBLE else View.GONE
binding.btnSend.isEnabled = !state.isSending
// 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
// Handle connection state
handleConnectionState(state.connectionState)
// Show error if any
state.error?.let { error ->
Toast.makeText(requireContext(), error, Toast.LENGTH_SHORT).show()
viewModel.clearError()
}
}
}
}
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)
}
}
}
private fun showOptionsMenu() {
val options = arrayOf(
getString(R.string.block_user),
getString(R.string.report),
getString(R.string.clear_chat),
getString(R.string.cancel)
)
androidx.appcompat.app.AlertDialog.Builder(requireContext())
.setTitle(getString(R.string.options))
.setItems(options) { dialog, which ->
when (which) {
0 -> Toast.makeText(requireContext(), R.string.block_user_selected, Toast.LENGTH_SHORT).show()
1 -> Toast.makeText(requireContext(), R.string.report_selected, Toast.LENGTH_SHORT).show()
2 -> Toast.makeText(requireContext(), R.string.clear_chat_selected, Toast.LENGTH_SHORT).show()
}
dialog.dismiss()
}
.show()
}
private fun checkPermissionsAndShowImagePicker() {
if (ContextCompat.checkSelfPermission(
requireContext(),
Manifest.permission.READ_EXTERNAL_STORAGE
) != PackageManager.PERMISSION_GRANTED
) {
ActivityCompat.requestPermissions(
requireActivity(),
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)
)
androidx.appcompat.app.AlertDialog.Builder(requireContext())
.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 = requireContext().getExternalFilesDir(null)
val imageFile = File(storageDir, imageFileName)
tempImageUri = FileProvider.getUriForFile(
requireContext(),
"${requireContext().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) {
// Get the file from Uri
val filePathColumn = arrayOf(MediaStore.Images.Media.DATA)
val cursor = requireContext().contentResolver.query(uri, filePathColumn, null, null, null)
cursor?.moveToFirst()
val columnIndex = cursor?.getColumnIndex(filePathColumn[0])
val filePath = cursor?.getString(columnIndex ?: 0)
cursor?.close()
if (filePath != null) {
viewModel.setSelectedImageFile(File(filePath))
Toast.makeText(requireContext(), R.string.image_selected, 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(requireContext(), R.string.permission_denied, Toast.LENGTH_SHORT).show()
}
}
}
override fun onDestroyView() {
super.onDestroyView()
typingHandler.removeCallbacks(stopTypingRunnable)
_binding = null
}
}

View File

@ -0,0 +1,32 @@
package com.alya.ecommerce_serang.ui.chat
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import com.alya.ecommerce_serang.R
import com.alya.ecommerce_serang.utils.viewmodel.ChatViewModel
class ChatListFragment : Fragment() {
companion object {
fun newInstance() = ChatListFragment()
}
private val viewModel: ChatViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// TODO: Use the ViewModel
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return inflater.inflate(R.layout.fragment_chat_list, container, false)
}
}

View File

@ -0,0 +1,412 @@
package com.alya.ecommerce_serang.ui.chat
import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.alya.ecommerce_serang.data.api.response.chat.ChatItem
import com.alya.ecommerce_serang.data.api.response.chat.ChatLine
import com.alya.ecommerce_serang.data.repository.Result
import com.alya.ecommerce_serang.data.repository.UserRepository
import com.alya.ecommerce_serang.utils.Constants
import com.alya.ecommerce_serang.utils.SessionManager
import kotlinx.coroutines.launch
import java.io.File
import java.util.Locale
import java.util.TimeZone
import javax.inject.Inject
class ChatViewModel @Inject constructor(
private val chatRepository: UserRepository,
private val socketService: SocketIOService,
private val sessionManager: SessionManager
) : ViewModel() {
private val TAG = "ChatViewModel"
// UI state using LiveData
private val _state = MutableLiveData(ChatUiState())
val state: LiveData<ChatUiState> = _state
// Chat parameters
private var chatRoomId: Int = 0
private var storeId: Int = 0
private var productId: Int = 0
private var currentUserId: Int = 0
// Product details for display
private var productName: String = ""
private var productPrice: String = ""
private var productImage: String = ""
private var productRating: Float = 0f
private var storeName: String = ""
// For image attachment
private var selectedImageFile: File? = null
init {
// Try to get current user ID from SessionManager
currentUserId = sessionManager.getUserId()?.toIntOrNull() ?: 0
if (currentUserId == 0) {
Log.e(TAG, "Error: User ID is not set or invalid")
updateState { it.copy(error = "User authentication error. Please login again.") }
} else {
// Set up socket listeners
setupSocketListeners()
}
}
/**
* Set chat parameters received from activity
*/
fun setChatParameters(
chatRoomId: Int,
storeId: Int,
productId: Int,
productName: String,
productPrice: String,
productImage: String,
productRating: Float,
storeName: String
) {
this.chatRoomId = chatRoomId
this.storeId = storeId
this.productId = productId
this.productName = productName
this.productPrice = productPrice
this.productImage = productImage
this.productRating = productRating
this.storeName = storeName
// Update state with product info
updateState {
it.copy(
productName = productName,
productPrice = productPrice,
productImageUrl = productImage,
productRating = productRating,
storeName = storeName
)
}
// Connect to socket and load chat history
socketService.connect()
loadChatHistory()
}
/**
* Sets up listeners for Socket.IO events
*/
private fun setupSocketListeners() {
viewModelScope.launch {
// Listen for connection state changes
socketService.connectionState.collect { connectionState ->
updateState { it.copy(connectionState = connectionState) }
// Join room when connected
if (connectionState is ConnectionState.Connected) {
socketService.joinRoom()
}
}
}
viewModelScope.launch {
// Listen for new messages
socketService.newMessages.collect { chatLine ->
chatLine?.let {
val currentMessages = _state.value?.messages ?: listOf()
val updatedMessages = currentMessages.toMutableList().apply {
add(convertChatLineToUiMessage(it))
}
updateState { it.copy(messages = updatedMessages) }
// Update message status if received from others
if (it.senderId != currentUserId) {
updateMessageStatus(it.id, Constants.STATUS_READ)
}
}
}
}
viewModelScope.launch {
// Listen for typing status updates
socketService.typingStatus.collect { typingStatus ->
typingStatus?.let {
if (typingStatus.roomId == chatRoomId && typingStatus.userId != currentUserId) {
updateState { it.copy(isOtherUserTyping = typingStatus.isTyping) }
}
}
}
}
}
/**
* Helper function to update LiveData state
*/
private fun updateState(update: (ChatUiState) -> ChatUiState) {
_state.value?.let {
_state.value = update(it)
}
}
/**
* Loads chat history
*/
fun loadChatHistory() {
if (chatRoomId == 0) {
Log.e(TAG, "Cannot load chat history: Chat room ID is 0")
return
}
viewModelScope.launch {
updateState { it.copy(isLoading = true) }
when (val result = chatRepository.getChatHistory(chatRoomId)) {
is com.alya.ecommerce_serang.data.repository.Result.Success -> {
val messages = result.data.chat.map { chatLine ->
convertChatLineToUiMessageHistory(chatLine)
}
updateState {
it.copy(
messages = messages,
isLoading = false,
error = null
)
}
Log.d(TAG, "Loaded ${messages.size} messages for chat room $chatRoomId")
// Update status of unread messages
result.data.chat
.filter { it.senderId != currentUserId && it.status != Constants.STATUS_READ }
.forEach { updateMessageStatus(it.id, Constants.STATUS_READ) }
}
is com.alya.ecommerce_serang.data.repository.Result.Error -> {
updateState {
it.copy(
isLoading = false,
error = result.exception.message
)
}
Log.e(TAG, "Error loading chat history: ${result.exception.message}")
}
is Result.Loading -> {
updateState { it.copy(isLoading = true) }
}
}
}
}
/**
* Sends a chat message
*/
fun sendMessage(message: String) {
if (message.isBlank() && selectedImageFile == null) return
if (storeId == 0 || productId == 0) {
Log.e(TAG, "Cannot send message: Store ID or Product ID is 0")
updateState { it.copy(error = "Cannot send message. Invalid parameters.") }
return
}
viewModelScope.launch {
updateState { it.copy(isSending = true) }
when (val result = chatRepository.sendChatMessage(
storeId = storeId,
message = message,
productId = productId,
imageFile = selectedImageFile
)) {
is com.alya.ecommerce_serang.data.repository.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}")
// Emit the message via Socket.IO for real-time updates
socketService.sendMessage(chatLine)
// Clear the image attachment
selectedImageFile = null
}
is com.alya.ecommerce_serang.data.repository.Result.Error -> {
updateState {
it.copy(
isSending = false,
error = result.exception.message
)
}
Log.e(TAG, "Error sending message: ${result.exception.message}")
}
is com.alya.ecommerce_serang.data.repository.Result.Loading -> {
updateState { it.copy(isSending = true) }
}
}
}
}
/**
* Updates a message status (delivered, read)
*/
fun updateMessageStatus(messageId: Int, status: String) {
viewModelScope.launch {
try {
val result = chatRepository.updateMessageStatus(messageId, status)
if (result is com.alya.ecommerce_serang.data.repository.Result.Success) {
// Update local message status
val currentMessages = _state.value?.messages ?: listOf()
val updatedMessages = currentMessages.map { message ->
if (message.id == messageId) {
message.copy(status = status)
} else {
message
}
}
updateState { it.copy(messages = updatedMessages) }
Log.d(TAG, "Message status updated: $messageId -> $status")
} else if (result is com.alya.ecommerce_serang.data.repository.Result.Error) {
Log.e(TAG, "Error updating message status: ${result.exception.message}")
}
} catch (e: Exception) {
Log.e(TAG, "Exception updating message status", e)
}
}
}
/**
* Sets the selected image file for attachment
*/
fun setSelectedImageFile(file: File?) {
selectedImageFile = file
updateState { it.copy(hasAttachment = file != null) }
Log.d(TAG, "Image attachment ${if (file != null) "selected" else "cleared"}")
}
/**
* Sends typing status to the other user
*/
fun sendTypingStatus(isTyping: Boolean) {
if (chatRoomId == 0) return
socketService.sendTypingStatus(chatRoomId, isTyping)
}
/**
* Clears any error message in the state
*/
fun clearError() {
updateState { it.copy(error = null) }
}
/**
* Converts a ChatLine from API to a UI message model
*/
private fun convertChatLineToUiMessage(chatLine: ChatLine): ChatUiMessage {
// Format the timestamp for display
val formattedTime = try {
val inputFormat = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault())
inputFormat.timeZone = TimeZone.getTimeZone("UTC")
val outputFormat = java.text.SimpleDateFormat("HH:mm", Locale.getDefault())
val date = inputFormat.parse(chatLine.createdAt)
date?.let { outputFormat.format(it) } ?: ""
} catch (e: Exception) {
Log.e(TAG, "Error formatting date: ${chatLine.createdAt}", e)
""
}
return ChatUiMessage(
id = chatLine.id,
message = chatLine.message,
attachment = chatLine.attachment,
status = chatLine.status,
time = formattedTime,
isSentByMe = chatLine.senderId == currentUserId
)
}
private fun convertChatLineToUiMessageHistory(chatItem: ChatItem): ChatUiMessage {
// Format the timestamp for display
val formattedTime = try {
val inputFormat = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault())
inputFormat.timeZone = TimeZone.getTimeZone("UTC")
val outputFormat = java.text.SimpleDateFormat("HH:mm", Locale.getDefault())
val date = inputFormat.parse(chatItem.createdAt)
date?.let { outputFormat.format(it) } ?: ""
} catch (e: Exception) {
Log.e(TAG, "Error formatting date: ${chatItem.createdAt}", e)
""
}
return ChatUiMessage(
attachment = "",
id = chatItem.id,
message = chatItem.message,
status = chatItem.status,
time = formattedTime,
isSentByMe = chatItem.senderId == currentUserId,
)
}
override fun onCleared() {
super.onCleared()
// Disconnect Socket.IO when ViewModel is cleared
socketService.disconnect()
Log.d(TAG, "ViewModel cleared, Socket.IO disconnected")
}
}
/**
* Data class representing the UI state for the chat screen
*/
data class ChatUiState(
val messages: List<ChatUiMessage> = emptyList(),
val isLoading: Boolean = false,
val isSending: Boolean = false,
val hasAttachment: Boolean = false,
val isOtherUserTyping: Boolean = false,
val error: String? = null,
val connectionState: ConnectionState = ConnectionState.Disconnected(),
// Product info
val productName: String = "",
val productPrice: String = "",
val productImageUrl: String = "",
val productRating: Float = 0f,
val storeName: String = ""
)
/**
* Data class representing a chat message in the UI
*/
data class ChatUiMessage(
val id: Int,
val message: String,
val attachment: String,
val status: String,
val time: String,
val isSentByMe: Boolean
)

View File

@ -0,0 +1,252 @@
package com.alya.ecommerce_serang.ui.chat
import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.alya.ecommerce_serang.BuildConfig
import com.alya.ecommerce_serang.data.api.response.chat.ChatLine
import com.alya.ecommerce_serang.utils.Constants
import com.alya.ecommerce_serang.utils.SessionManager
import com.google.gson.Gson
import io.socket.client.IO
import io.socket.client.Socket
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.json.JSONObject
import java.net.URISyntaxException
class SocketIOService(
private val sessionManager: SessionManager
) {
private val TAG = "SocketIOService"
// Socket.IO client
private var socket: Socket? = null
// Connection state
private var isConnected = false
// StateFlows for internal observing (these are needed for suspend functions in ViewModel)
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected())
val connectionState: StateFlow<ConnectionState> = _connectionState
private val _newMessages = MutableStateFlow<ChatLine?>(null)
val newMessages: StateFlow<ChatLine?> = _newMessages
private val _typingStatus = MutableStateFlow<TypingStatus?>(null)
val typingStatus: StateFlow<TypingStatus?> = _typingStatus
// LiveData for Activity/Fragment observing
private val _connectionStateLiveData = MutableLiveData<ConnectionState>(ConnectionState.Disconnected())
val connectionStateLiveData: LiveData<ConnectionState> = _connectionStateLiveData
private val _newMessagesLiveData = MutableLiveData<ChatLine?>()
val newMessagesLiveData: LiveData<ChatLine?> = _newMessagesLiveData
private val _typingStatusLiveData = MutableLiveData<TypingStatus?>()
val typingStatusLiveData: LiveData<TypingStatus?> = _typingStatusLiveData
/**
* Initializes the Socket.IO client
*/
init {
try {
// Get token from SessionManager
val token = sessionManager.getToken()
// Set up Socket.IO options with auth token
val options = IO.Options().apply {
forceNew = true
reconnection = true
reconnectionAttempts = 5
reconnectionDelay = 3000
// Add auth information
if (!token.isNullOrEmpty()) {
auth = mapOf("token" to token)
}
}
// Create Socket.IO client
socket = IO.socket(BuildConfig.BASE_URL, options)
// Set up event listeners
setupSocketListeners()
Log.d(TAG, "Socket.IO initialized with token: $token")
} catch (e: URISyntaxException) {
Log.e(TAG, "Error initializing Socket.IO client", e)
_connectionState.value = ConnectionState.Error("Error initializing Socket.IO: ${e.message}")
_connectionStateLiveData.value = ConnectionState.Error("Error initializing Socket.IO: ${e.message}")
}
}
/**
* Sets up Socket.IO event listeners
*/
private fun setupSocketListeners() {
socket?.let { socket ->
// Connection events
socket.on(Socket.EVENT_CONNECT) {
Log.d(TAG, "Socket.IO connected")
isConnected = true
_connectionState.value = ConnectionState.Connected
_connectionStateLiveData.postValue(ConnectionState.Connected)
}
socket.on(Socket.EVENT_DISCONNECT) {
Log.d(TAG, "Socket.IO disconnected")
isConnected = false
_connectionState.value = ConnectionState.Disconnected("Disconnected from server")
_connectionStateLiveData.postValue(ConnectionState.Disconnected("Disconnected from server"))
}
socket.on(Socket.EVENT_CONNECT_ERROR) { args ->
val error = if (args.isNotEmpty() && args[0] != null) args[0].toString() else "Unknown error"
Log.e(TAG, "Socket.IO connection error: $error")
isConnected = false
_connectionState.value = ConnectionState.Error("Connection error: $error")
_connectionStateLiveData.postValue(ConnectionState.Error("Connection error: $error"))
}
// Chat events
socket.on(Constants.EVENT_NEW_MESSAGE) { args ->
try {
if (args.isNotEmpty() && args[0] != null) {
val messageJson = args[0].toString()
Log.d(TAG, "Received new message: $messageJson")
val chatLine = Gson().fromJson(messageJson, ChatLine::class.java)
_newMessages.value = chatLine
_newMessagesLiveData.postValue(chatLine)
}
} catch (e: Exception) {
Log.e(TAG, "Error parsing new message event", e)
}
}
socket.on(Constants.EVENT_TYPING) { args ->
try {
if (args.isNotEmpty() && args[0] != null) {
val typingData = args[0] as JSONObject
val userId = typingData.getInt("userId")
val roomId = typingData.getInt("roomId")
val isTyping = typingData.getBoolean("isTyping")
Log.d(TAG, "Received typing status: User $userId in room $roomId is typing: $isTyping")
val status = TypingStatus(userId, roomId, isTyping)
_typingStatus.value = status
_typingStatusLiveData.postValue(status)
}
} catch (e: Exception) {
Log.e(TAG, "Error parsing typing event", e)
}
}
}
}
/**
* Connects to the Socket.IO server
*/
fun connect() {
if (isConnected) return
Log.d(TAG, "Connecting to Socket.IO server...")
_connectionState.value = ConnectionState.Connecting
_connectionStateLiveData.value = ConnectionState.Connecting
socket?.connect()
}
/**
* Joins a specific chat room
*/
fun joinRoom() {
if (!isConnected) {
connect()
return
}
// Get user ID from SessionManager
val userId = sessionManager.getUserId()
if (userId.isNullOrEmpty()) {
Log.e(TAG, "Cannot join room: User ID is null or empty")
return
}
// Join the room using the current user's ID
socket?.emit("joinRoom", userId)
Log.d(TAG, "Joined room for user: $userId")
}
/**
* Emits a new message event
*/
fun sendMessage(message: ChatLine) {
if (!isConnected) {
connect()
return
}
val messageJson = Gson().toJson(message)
socket?.emit(Constants.EVENT_NEW_MESSAGE, messageJson)
Log.d(TAG, "Sent message via Socket.IO: $messageJson")
}
/**
* Sends typing status update
*/
fun sendTypingStatus(roomId: Int, isTyping: Boolean) {
if (!isConnected) return
// Get user ID from SessionManager
val userId = sessionManager.getUserId()?.toIntOrNull()
if (userId == null) {
Log.e(TAG, "Cannot send typing status: User ID is null or invalid")
return
}
val typingData = JSONObject().apply {
put("userId", userId)
put("roomId", roomId)
put("isTyping", isTyping)
}
socket?.emit(Constants.EVENT_TYPING, typingData)
Log.d(TAG, "Sent typing status: User $userId in room $roomId is typing: $isTyping")
}
/**
* Disconnects from the Socket.IO server
*/
fun disconnect() {
Log.d(TAG, "Disconnecting from Socket.IO server...")
socket?.disconnect()
isConnected = false
_connectionState.value = ConnectionState.Disconnected("Disconnected by user")
_connectionStateLiveData.postValue(ConnectionState.Disconnected("Disconnected by user"))
}
/**
* Returns whether the socket is connected
*/
val isSocketConnected: Boolean
get() = isConnected
}
/**
* Sealed class representing connection states
*/
sealed class ConnectionState {
object Connecting : ConnectionState()
object Connected : ConnectionState()
data class Disconnected(val reason: String = "") : ConnectionState()
data class Error(val message: String) : ConnectionState()
}
/**
* Data class for typing status events
*/
data class TypingStatus(
val userId: Int,
val roomId: Int,
val isTyping: Boolean
)

View File

@ -11,7 +11,7 @@ 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.ChatFragment
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.product.ProductActivity
import com.alya.ecommerce_serang.ui.profile.mystore.profile.DetailStoreProfileActivity
@ -124,7 +124,7 @@ class MyStoreActivity : AppCompatActivity() {
binding.layoutInbox.setOnClickListener {
supportFragmentManager.beginTransaction()
.replace(android.R.id.content, ChatFragment())
.replace(android.R.id.content, ChatListFragment())
.addToBackStack(null)
.commit()
}

View File

@ -0,0 +1,40 @@
package com.alya.ecommerce_serang.utils
object Constants {
// API Endpoints
const val ENDPOINT_SEND_CHAT = "/sendchat"
const val ENDPOINT_UPDATE_CHAT_STATUS = "/chatstatus"
const val ENDPOINT_GET_CHAT_DETAIL = "/chatdetail"
// Shared Preferences
const val PREF_NAME = "app_preferences"
const val KEY_USER_ID = "user_id"
const val KEY_TOKEN = "token"
// Intent extras
const val EXTRA_CHAT_ROOM_ID = "chat_room_id"
const val EXTRA_STORE_ID = "store_id"
const val EXTRA_PRODUCT_ID = "product_id"
const val EXTRA_STORE_NAME = "store_name"
const val EXTRA_PRODUCT_NAME = "product_name"
const val EXTRA_PRODUCT_PRICE = "product_price"
const val EXTRA_PRODUCT_IMAGE = "product_image"
const val EXTRA_PRODUCT_RATING = "product_rating"
// Request codes
const val REQUEST_IMAGE_PICK = 1001
const val REQUEST_CAMERA = 1002
const val REQUEST_STORAGE_PERMISSION = 1003
// Socket.IO events
const val EVENT_JOIN_ROOM = "joinRoom"
const val EVENT_NEW_MESSAGE = "new_message"
const val EVENT_MESSAGE_DELIVERED = "message_delivered"
const val EVENT_MESSAGE_READ = "message_read"
const val EVENT_TYPING = "typing"
// Message status
const val STATUS_SENT = "sent"
const val STATUS_DELIVERED = "delivered"
const val STATUS_READ = "read"
}

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#E0E0E0" />
<corners android:radius="16dp" />
<padding
android:bottom="8dp"
android:left="12dp"
android:right="12dp"
android:top="8dp" />
</shape>

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#E1F5FE" />
<corners android:radius="16dp" />
<padding
android:bottom="8dp"
android:left="12dp"
android:right="12dp"
android:top="8dp" />
</shape>

View File

@ -0,0 +1,277 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.chat.ChatActivity">
<androidx.appcompat.widget.Toolbar
android:id="@+id/chatToolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#FFFFFF"
android:elevation="4dp"
app:layout_constraintTop_toTopOf="parent">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageButton
android:id="@+id/btnBack"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Back"
android:src="@drawable/ic_back_24"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent" />
<de.hdodenhof.circleimageview.CircleImageView
android:id="@+id/imgProfile"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginStart="8dp"
android:src="@drawable/ic_person"
app:layout_constraintStart_toEndOf="@+id/btnBack"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent" />
<TextView
android:id="@+id/tvStoreName"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:text="SnackEnak"
android:textColor="@android:color/black"
android:textSize="16sp"
android:textStyle="bold"
app:layout_constraintStart_toEndOf="@+id/imgProfile"
app:layout_constraintTop_toTopOf="@+id/imgProfile"
app:layout_constraintEnd_toStartOf="@+id/btnOptions" />
<TextView
android:id="@+id/tvLastActive"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="Aktif 3 jam lalu"
android:textColor="#888888"
android:textSize="12sp"
app:layout_constraintStart_toEndOf="@+id/imgProfile"
app:layout_constraintTop_toBottomOf="@+id/tvStoreName"
app:layout_constraintEnd_toEndOf="@+id/tvStoreName" />
<ImageButton
android:id="@+id/btnOptions"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Options"
android:src="@drawable/ic_arrow_right"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.appcompat.widget.Toolbar>
<!-- Product Card -->
<androidx.cardview.widget.CardView
android:id="@+id/cardProduct"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
app:cardCornerRadius="8dp"
app:cardElevation="4dp"
app:layout_constraintTop_toBottomOf="@+id/chatToolbar">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp">
<ImageView
android:id="@+id/imgProduct"
android:layout_width="0dp"
android:layout_height="0dp"
android:scaleType="centerCrop"
android:src="@drawable/placeholder_image"
app:layout_constraintDimensionRatio="1:1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/tvProductName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Keripik Balado"
android:textColor="@android:color/black"
android:textSize="18sp"
android:textStyle="bold"
app:layout_constraintTop_toBottomOf="@+id/imgProduct" />
<TextView
android:id="@+id/tvProductPrice"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="Rp65.000"
android:textColor="@android:color/black"
android:textSize="16sp"
android:textStyle="bold"
app:layout_constraintTop_toBottomOf="@+id/tvProductName" />
<RatingBar
android:id="@+id/ratingBar"
style="?android:attr/ratingBarStyleSmall"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:isIndicator="true"
android:numStars="5"
android:rating="5.0"
android:stepSize="0.1"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tvProductPrice" />
<TextView
android:id="@+id/tvRating"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:text="5.0"
android:textColor="#F9A825"
android:textSize="12sp"
app:layout_constraintBottom_toBottomOf="@+id/ratingBar"
app:layout_constraintStart_toEndOf="@+id/ratingBar"
app:layout_constraintTop_toTopOf="@+id/ratingBar" />
<TextView
android:id="@+id/tvSellerName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="SnackEnak"
android:textColor="#666666"
android:textSize="12sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/ratingBar" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>
<!-- Chat messages -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerChat"
android:layout_width="match_parent"
android:layout_height="0dp"
android:clipToPadding="false"
android:paddingTop="8dp"
android:paddingBottom="8dp"
app:layout_constraintBottom_toTopOf="@+id/layoutChatInput"
app:layout_constraintTop_toBottomOf="@+id/cardProduct" />
<!-- Chat input area -->
<LinearLayout
android:id="@+id/layoutChatInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#FFFFFF"
android:elevation="8dp"
android:orientation="horizontal"
android:padding="8dp"
app:layout_constraintBottom_toBottomOf="parent">
<ImageButton
android:id="@+id/btnAttachment"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_gravity="center_vertical"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Attachment"
android:src="@drawable/ic_attachment" />
<EditText
android:id="@+id/editTextMessage"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:layout_weight="1"
android:hint="Tulis pesan"
android:inputType="textMultiLine"
android:maxLines="4"
android:minHeight="40dp"
android:padding="8dp" />
<ImageButton
android:id="@+id/btnSend"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_gravity="center_vertical"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Send"
android:src="@drawable/ic_send" />
</LinearLayout>
<TextView
android:id="@+id/tvTypingIndicator"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="4dp"
android:text="User is typing..."
android:textColor="#666666"
android:textSize="12sp"
android:textStyle="italic"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@+id/layoutChatInput"
tools:visibility="visible" />
<!-- Bottom navigation -->
<LinearLayout
android:id="@+id/bottomNavigation"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#FFFFFF"
android:elevation="8dp"
android:orientation="horizontal"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent">
<ImageButton
android:id="@+id/btnHome"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_weight="1"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Home"
android:src="@drawable/ic_home" />
<ImageButton
android:id="@+id/btnMenu"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_weight="1"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Menu"
android:src="@drawable/ic_menu" />
<ImageButton
android:id="@+id/btnNotification"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_weight="1"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Notification"
android:src="@drawable/ic_notification" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,13 +1,265 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"
tools:context=".ui.chat.ChatFragment">
<TextView
<androidx.appcompat.widget.Toolbar
android:id="@+id/chatToolbar"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:text="Hello" />
android:layout_height="wrap_content"
android:background="#FFFFFF"
android:elevation="4dp"
app:layout_constraintTop_toTopOf="parent">
</FrameLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageButton
android:id="@+id/btnBack"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Back"
android:src="@drawable/ic_back_24"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent" />
<de.hdodenhof.circleimageview.CircleImageView
android:id="@+id/imgProfile"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginStart="8dp"
android:src="@drawable/placeholder_image"
app:layout_constraintStart_toEndOf="@+id/btnBack"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent" />
<TextView
android:id="@+id/tvStoreName"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:text="SnackEnak"
android:textColor="@android:color/black"
android:textSize="16sp"
android:textStyle="bold"
app:layout_constraintStart_toEndOf="@+id/imgProfile"
app:layout_constraintTop_toTopOf="@+id/imgProfile"
app:layout_constraintEnd_toStartOf="@+id/btnOptions" />
<TextView
android:id="@+id/tvLastActive"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="Aktif 3 jam lalu"
android:textColor="#888888"
android:textSize="12sp"
app:layout_constraintStart_toEndOf="@+id/imgProfile"
app:layout_constraintTop_toBottomOf="@+id/tvStoreName"
app:layout_constraintEnd_toEndOf="@+id/tvStoreName" />
<ImageButton
android:id="@+id/btnOptions"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Options"
android:src="@drawable/ic_arrow_right"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.appcompat.widget.Toolbar>
<!-- Connection Status -->
<TextView
android:id="@+id/tvConnectionStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#FFC107"
android:padding="4dp"
android:textAlignment="center"
android:textColor="#000000"
android:text="Connecting..."
android:visibility="gone"
app:layout_constraintTop_toBottomOf="@+id/chatToolbar"
tools:visibility="visible" />
<!-- Product Card -->
<androidx.cardview.widget.CardView
android:id="@+id/cardProduct"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
app:cardCornerRadius="8dp"
app:cardElevation="4dp"
app:layout_constraintTop_toBottomOf="@+id/tvConnectionStatus">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp">
<ImageView
android:id="@+id/imgProduct"
android:layout_width="0dp"
android:layout_height="0dp"
android:scaleType="centerCrop"
android:src="@drawable/placeholder_image"
app:layout_constraintDimensionRatio="1:1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/tvProductName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Keripik Balado"
android:textColor="@android:color/black"
android:textSize="18sp"
android:textStyle="bold"
app:layout_constraintTop_toBottomOf="@+id/imgProduct" />
<TextView
android:id="@+id/tvProductPrice"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="Rp65.000"
android:textColor="@android:color/black"
android:textSize="16sp"
android:textStyle="bold"
app:layout_constraintTop_toBottomOf="@+id/tvProductName" />
<RatingBar
android:id="@+id/ratingBar"
style="?android:attr/ratingBarStyleSmall"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:isIndicator="true"
android:numStars="5"
android:rating="5.0"
android:stepSize="0.1"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tvProductPrice" />
<TextView
android:id="@+id/tvRating"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:text="5.0"
android:textColor="#F9A825"
android:textSize="12sp"
app:layout_constraintBottom_toBottomOf="@+id/ratingBar"
app:layout_constraintStart_toEndOf="@+id/ratingBar"
app:layout_constraintTop_toTopOf="@+id/ratingBar" />
<TextView
android:id="@+id/tvSellerName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="SnackEnak"
android:textColor="#666666"
android:textSize="12sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/ratingBar" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>
<!-- Progress Bar -->
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible" />
<!-- Chat messages -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerChat"
android:layout_width="match_parent"
android:layout_height="0dp"
android:clipToPadding="false"
android:paddingTop="8dp"
android:paddingBottom="8dp"
app:layout_constraintBottom_toTopOf="@+id/tvTypingIndicator"
app:layout_constraintTop_toBottomOf="@+id/cardProduct" />
<!-- Typing indicator -->
<TextView
android:id="@+id/tvTypingIndicator"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="4dp"
android:text="User is typing..."
android:textColor="#666666"
android:textSize="12sp"
android:textStyle="italic"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@+id/layoutChatInput"
tools:visibility="visible" />
<!-- Chat input area -->
<LinearLayout
android:id="@+id/layoutChatInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#FFFFFF"
android:elevation="8dp"
android:orientation="horizontal"
android:padding="8dp"
app:layout_constraintBottom_toBottomOf="parent">
<ImageButton
android:id="@+id/btnAttachment"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_gravity="center_vertical"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Attachment"
android:src="@drawable/ic_attachment" />
<EditText
android:id="@+id/editTextMessage"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:layout_weight="1"
android:background="@drawable/bg_edit_text_rounded"
android:hint="Tulis pesan"
android:inputType="textMultiLine"
android:maxLines="4"
android:minHeight="40dp"
android:padding="8dp" />
<ImageButton
android:id="@+id/btnSend"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_gravity="center_vertical"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Send"
android:src="@drawable/ic_send" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.chat.ChatListFragment">
<TextView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:text="Hello" />
</FrameLayout>

View File

@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="8dp"
android:paddingTop="4dp"
android:paddingEnd="60dp"
android:paddingBottom="4dp">
<de.hdodenhof.circleimageview.CircleImageView
android:id="@+id/imgAvatar"
android:layout_width="32dp"
android:layout_height="32dp"
android:src="@drawable/profile_placeholder"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/layoutMessage" />
<LinearLayout
android:id="@+id/layoutMessage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:background="@drawable/bg_message_received"
android:orientation="vertical"
app:layout_constraintStart_toEndOf="@+id/imgAvatar"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:id="@+id/tvMessage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxWidth="270dp"
android:textColor="@android:color/black"
android:textSize="14sp"
tools:text="Boleh banget teh. Teteh mau nawar berapa?" />
<ImageView
android:id="@+id/imgAttachment"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:adjustViewBounds="true"
android:maxWidth="220dp"
android:scaleType="fitCenter"
android:visibility="gone"
tools:src="@drawable/placeholder_image"
tools:visibility="visible" />
</LinearLayout>
<TextView
android:id="@+id/tvTimestamp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="#888888"
android:textSize="10sp"
app:layout_constraintStart_toStartOf="@+id/layoutMessage"
app:layout_constraintTop_toBottomOf="@+id/layoutMessage"
tools:text="12:30" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,65 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="60dp"
android:paddingTop="4dp"
android:paddingEnd="8dp"
android:paddingBottom="4dp">
<LinearLayout
android:id="@+id/layoutMessage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/bg_message_sent"
android:orientation="vertical"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:id="@+id/tvMessage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxWidth="270dp"
android:textColor="@android:color/black"
android:textSize="14sp"
tools:text="Beli 1, 60 rb bisa teh?" />
<ImageView
android:id="@+id/imgAttachment"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:adjustViewBounds="true"
android:maxWidth="220dp"
android:scaleType="fitCenter"
android:visibility="gone"
tools:src="@drawable/placeholder_image"
tools:visibility="visible" />
</LinearLayout>
<TextView
android:id="@+id/tvTimestamp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:layout_marginEnd="4dp"
android:textColor="#888888"
android:textSize="10sp"
app:layout_constraintEnd_toStartOf="@+id/imgStatus"
app:layout_constraintTop_toBottomOf="@+id/layoutMessage"
tools:text="12:30" />
<ImageView
android:id="@+id/imgStatus"
android:layout_width="16dp"
android:layout_height="16dp"
android:src="@drawable/placeholder_image"
app:layout_constraintBottom_toBottomOf="@+id/tvTimestamp"
app:layout_constraintEnd_toEndOf="@+id/layoutMessage"
app:layout_constraintTop_toTopOf="@+id/tvTimestamp" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -21,9 +21,9 @@
tools:layout="@layout/fragment_profile" />
<fragment
android:id="@+id/chatFragment"
android:name="com.alya.ecommerce_serang.ui.chat.ChatFragment"
android:name="com.alya.ecommerce_serang.ui.chat.ChatListFragment"
android:label="fragment_chat"
tools:layout="@layout/fragment_chat" />
tools:layout="@layout/fragment_chat_list" />
<fragment
android:id="@+id/searchHomeFragment"
android:name="com.alya.ecommerce_serang.ui.home.SearchHomeFragment"