diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 5af131b..8a5179f 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -11,7 +11,7 @@
-
+
@@ -28,15 +28,19 @@
android:theme="@style/Theme.Ecommerce_serang"
android:usesCleartextTraffic="true"
tools:targetApi="31">
-
-
-
-
+
+
+
+
+
+ android:foregroundServiceType="dataSync" />
+
diff --git a/app/src/main/java/com/alya/ecommerce_serang/data/api/dto/ChatRequest.kt b/app/src/main/java/com/alya/ecommerce_serang/data/api/dto/ChatRequest.kt
new file mode 100644
index 0000000..13064e8
--- /dev/null
+++ b/app/src/main/java/com/alya/ecommerce_serang/data/api/dto/ChatRequest.kt
@@ -0,0 +1,5 @@
+package com.alya.ecommerce_serang.data.api.dto
+
+class ChatRequest {
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/alya/ecommerce_serang/data/api/dto/UpdateChatRequest.kt b/app/src/main/java/com/alya/ecommerce_serang/data/api/dto/UpdateChatRequest.kt
new file mode 100644
index 0000000..e21b42f
--- /dev/null
+++ b/app/src/main/java/com/alya/ecommerce_serang/data/api/dto/UpdateChatRequest.kt
@@ -0,0 +1,6 @@
+package com.alya.ecommerce_serang.data.api.dto
+
+data class UpdateChatRequest (
+ val id: Int,
+ val status: String
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/alya/ecommerce_serang/data/api/response/chat/ChatHistoryResponse.kt b/app/src/main/java/com/alya/ecommerce_serang/data/api/response/chat/ChatHistoryResponse.kt
new file mode 100644
index 0000000..4de7329
--- /dev/null
+++ b/app/src/main/java/com/alya/ecommerce_serang/data/api/response/chat/ChatHistoryResponse.kt
@@ -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,
+
+ @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
+)
diff --git a/app/src/main/java/com/alya/ecommerce_serang/data/api/response/chat/SendChatResponse.kt b/app/src/main/java/com/alya/ecommerce_serang/data/api/response/chat/SendChatResponse.kt
new file mode 100644
index 0000000..ff520bd
--- /dev/null
+++ b/app/src/main/java/com/alya/ecommerce_serang/data/api/response/chat/SendChatResponse.kt
@@ -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
+)
diff --git a/app/src/main/java/com/alya/ecommerce_serang/data/api/response/chat/UpdateChatResponse.kt b/app/src/main/java/com/alya/ecommerce_serang/data/api/response/chat/UpdateChatResponse.kt
new file mode 100644
index 0000000..0ea26ae
--- /dev/null
+++ b/app/src/main/java/com/alya/ecommerce_serang/data/api/response/chat/UpdateChatResponse.kt
@@ -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
+)
diff --git a/app/src/main/java/com/alya/ecommerce_serang/data/api/retrofit/ApiService.kt b/app/src/main/java/com/alya/ecommerce_serang/data/api/retrofit/ApiService.kt
index d222885..aa21560 100644
--- a/app/src/main/java/com/alya/ecommerce_serang/data/api/retrofit/ApiService.kt
+++ b/app/src/main/java/com/alya/ecommerce_serang/data/api/retrofit/ApiService.kt
@@ -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
+
+ @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
+
+ @PUT("chatstatus")
+ suspend fun updateChatStatus(
+ @Body request: UpdateChatRequest
+ ): Response
+
+ @GET("chatdetail/{chatRoomId}")
+ suspend fun getChatDetail(
+ @Path("chatRoomId") chatRoomId: Int
+ ): Response
}
\ No newline at end of file
diff --git a/app/src/main/java/com/alya/ecommerce_serang/data/repository/UserRepository.kt b/app/src/main/java/com/alya/ecommerce_serang/data/repository/UserRepository.kt
index 5b4c97c..62910ea 100644
--- a/app/src/main/java/com/alya/ecommerce_serang/data/repository/UserRepository.kt
+++ b/app/src/main/java/com/alya/ecommerce_serang/data/repository/UserRepository.kt
@@ -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 {
+ 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 {
+ 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 {
+ 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)
+ }
+ }
+
}
diff --git a/app/src/main/java/com/alya/ecommerce_serang/di/ChatModule.kt b/app/src/main/java/com/alya/ecommerce_serang/di/ChatModule.kt
new file mode 100644
index 0000000..5e67fb6
--- /dev/null
+++ b/app/src/main/java/com/alya/ecommerce_serang/di/ChatModule.kt
@@ -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)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatActivity.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatActivity.kt
new file mode 100644
index 0000000..432077c
--- /dev/null
+++ b/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatActivity.kt
@@ -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,
+ 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"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatAdapter.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatAdapter.kt
new file mode 100644
index 0000000..1d41f53
--- /dev/null
+++ b/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatAdapter.kt
@@ -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(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() {
+ override fun areItemsTheSame(oldItem: ChatUiMessage, newItem: ChatUiMessage): Boolean {
+ return oldItem.id == newItem.id
+ }
+
+ override fun areContentsTheSame(oldItem: ChatUiMessage, newItem: ChatUiMessage): Boolean {
+ return oldItem == newItem
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatFragment.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatFragment.kt
index 79c13a4..3cb5e4d 100644
--- a/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatFragment.kt
+++ b/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatFragment.kt
@@ -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,
+ 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
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatListFragment.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatListFragment.kt
new file mode 100644
index 0000000..a177a58
--- /dev/null
+++ b/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatListFragment.kt
@@ -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)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatViewModel.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatViewModel.kt
new file mode 100644
index 0000000..fa3c8ee
--- /dev/null
+++ b/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatViewModel.kt
@@ -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 = _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 = 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
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/chat/SocketIOService.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/chat/SocketIOService.kt
new file mode 100644
index 0000000..1ac378f
--- /dev/null
+++ b/app/src/main/java/com/alya/ecommerce_serang/ui/chat/SocketIOService.kt
@@ -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.Disconnected())
+ val connectionState: StateFlow = _connectionState
+
+ private val _newMessages = MutableStateFlow(null)
+ val newMessages: StateFlow = _newMessages
+
+ private val _typingStatus = MutableStateFlow(null)
+ val typingStatus: StateFlow = _typingStatus
+
+ // LiveData for Activity/Fragment observing
+ private val _connectionStateLiveData = MutableLiveData(ConnectionState.Disconnected())
+ val connectionStateLiveData: LiveData = _connectionStateLiveData
+
+ private val _newMessagesLiveData = MutableLiveData()
+ val newMessagesLiveData: LiveData = _newMessagesLiveData
+
+ private val _typingStatusLiveData = MutableLiveData()
+ val typingStatusLiveData: LiveData = _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
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/MyStoreActivity.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/MyStoreActivity.kt
index f39331b..56da085 100644
--- a/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/MyStoreActivity.kt
+++ b/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/MyStoreActivity.kt
@@ -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()
}
diff --git a/app/src/main/java/com/alya/ecommerce_serang/utils/Constants.kt b/app/src/main/java/com/alya/ecommerce_serang/utils/Constants.kt
new file mode 100644
index 0000000..02dcb5e
--- /dev/null
+++ b/app/src/main/java/com/alya/ecommerce_serang/utils/Constants.kt
@@ -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"
+}
\ No newline at end of file
diff --git a/app/src/main/res/drawable/bg_message_received.xml b/app/src/main/res/drawable/bg_message_received.xml
new file mode 100644
index 0000000..128f72d
--- /dev/null
+++ b/app/src/main/res/drawable/bg_message_received.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/bg_message_sent.xml b/app/src/main/res/drawable/bg_message_sent.xml
new file mode 100644
index 0000000..f0f1d90
--- /dev/null
+++ b/app/src/main/res/drawable/bg_message_sent.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_chat.xml b/app/src/main/res/layout/activity_chat.xml
new file mode 100644
index 0000000..074ead1
--- /dev/null
+++ b/app/src/main/res/layout/activity_chat.xml
@@ -0,0 +1,277 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_chat.xml b/app/src/main/res/layout/fragment_chat.xml
index 4f1189a..073b01a 100644
--- a/app/src/main/res/layout/fragment_chat.xml
+++ b/app/src/main/res/layout/fragment_chat.xml
@@ -1,13 +1,265 @@
-
-
+ android:layout_height="wrap_content"
+ android:background="#FFFFFF"
+ android:elevation="4dp"
+ app:layout_constraintTop_toTopOf="parent">
-
\ No newline at end of file
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_chat_list.xml b/app/src/main/res/layout/fragment_chat_list.xml
new file mode 100644
index 0000000..94b500b
--- /dev/null
+++ b/app/src/main/res/layout/fragment_chat_list.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/item_message_received.xml b/app/src/main/res/layout/item_message_received.xml
new file mode 100644
index 0000000..2aa9cd7
--- /dev/null
+++ b/app/src/main/res/layout/item_message_received.xml
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/item_message_sent.xml b/app/src/main/res/layout/item_message_sent.xml
new file mode 100644
index 0000000..fd2aa49
--- /dev/null
+++ b/app/src/main/res/layout/item_message_sent.xml
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml
index ffe5064..d9327b4 100644
--- a/app/src/main/res/navigation/nav_graph.xml
+++ b/app/src/main/res/navigation/nav_graph.xml
@@ -21,9 +21,9 @@
tools:layout="@layout/fragment_profile" />
+ tools:layout="@layout/fragment_chat_list" />