From adeb0537f354aeca55f960a25ede689dfd3d2c58 Mon Sep 17 00:00:00 2001 From: shaulascr Date: Thu, 1 May 2025 03:53:40 +0700 Subject: [PATCH] fixing chat activity and fragment --- app/src/main/AndroidManifest.xml | 16 +- .../data/api/dto/ChatRequest.kt | 5 + .../data/api/dto/UpdateChatRequest.kt | 6 + .../api/response/chat/ChatHistoryResponse.kt | 40 ++ .../api/response/chat/SendChatResponse.kt | 39 ++ .../api/response/chat/UpdateChatResponse.kt | 39 ++ .../data/api/retrofit/ApiService.kt | 23 + .../data/repository/UserRepository.kt | 104 +++++ .../alya/ecommerce_serang/di/ChatModule.kt | 36 ++ .../ecommerce_serang/ui/chat/ChatActivity.kt | 392 +++++++++++++++++ .../ecommerce_serang/ui/chat/ChatAdapter.kt | 135 ++++++ .../ecommerce_serang/ui/chat/ChatFragment.kt | 331 +++++++++++++- .../ui/chat/ChatListFragment.kt | 32 ++ .../ecommerce_serang/ui/chat/ChatViewModel.kt | 412 ++++++++++++++++++ .../ui/chat/SocketIOService.kt | 252 +++++++++++ .../ui/profile/mystore/MyStoreActivity.kt | 4 +- .../alya/ecommerce_serang/utils/Constants.kt | 40 ++ .../main/res/drawable/bg_message_received.xml | 11 + app/src/main/res/drawable/bg_message_sent.xml | 11 + app/src/main/res/layout/activity_chat.xml | 277 ++++++++++++ app/src/main/res/layout/fragment_chat.xml | 262 ++++++++++- .../main/res/layout/fragment_chat_list.xml | 13 + .../main/res/layout/item_message_received.xml | 64 +++ app/src/main/res/layout/item_message_sent.xml | 65 +++ app/src/main/res/navigation/nav_graph.xml | 4 +- 25 files changed, 2588 insertions(+), 25 deletions(-) create mode 100644 app/src/main/java/com/alya/ecommerce_serang/data/api/dto/ChatRequest.kt create mode 100644 app/src/main/java/com/alya/ecommerce_serang/data/api/dto/UpdateChatRequest.kt create mode 100644 app/src/main/java/com/alya/ecommerce_serang/data/api/response/chat/ChatHistoryResponse.kt create mode 100644 app/src/main/java/com/alya/ecommerce_serang/data/api/response/chat/SendChatResponse.kt create mode 100644 app/src/main/java/com/alya/ecommerce_serang/data/api/response/chat/UpdateChatResponse.kt create mode 100644 app/src/main/java/com/alya/ecommerce_serang/di/ChatModule.kt create mode 100644 app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatActivity.kt create mode 100644 app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatAdapter.kt create mode 100644 app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatListFragment.kt create mode 100644 app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatViewModel.kt create mode 100644 app/src/main/java/com/alya/ecommerce_serang/ui/chat/SocketIOService.kt create mode 100644 app/src/main/java/com/alya/ecommerce_serang/utils/Constants.kt create mode 100644 app/src/main/res/drawable/bg_message_received.xml create mode 100644 app/src/main/res/drawable/bg_message_sent.xml create mode 100644 app/src/main/res/layout/activity_chat.xml create mode 100644 app/src/main/res/layout/fragment_chat_list.xml create mode 100644 app/src/main/res/layout/item_message_received.xml create mode 100644 app/src/main/res/layout/item_message_sent.xml 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" />