mirror of
https://github.com/shaulascr/ecommerce_serang.git
synced 2025-08-10 17:32:22 +00:00
fixing chat activity and fragment
This commit is contained in:
@ -11,7 +11,7 @@
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
|
||||
@ -28,15 +28,19 @@
|
||||
android:theme="@style/Theme.Ecommerce_serang"
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:targetApi="31">
|
||||
<!-- <provider-->
|
||||
<!-- android:name="androidx.startup.InitializationProvider"-->
|
||||
<!-- android:authorities="${applicationId}.androidx-startup"-->
|
||||
<!-- tools:node="remove" />-->
|
||||
<activity
|
||||
android:name=".ui.chat.ChatActivity"
|
||||
android:exported="false" />
|
||||
<!-- <provider -->
|
||||
<!-- android:name="androidx.startup.InitializationProvider" -->
|
||||
<!-- android:authorities="${applicationId}.androidx-startup" -->
|
||||
<!-- tools:node="remove" /> -->
|
||||
<service
|
||||
android:name=".ui.notif.SimpleWebSocketService"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.notif.NotificationActivity"
|
||||
android:exported="false" />
|
||||
|
@ -0,0 +1,5 @@
|
||||
package com.alya.ecommerce_serang.data.api.dto
|
||||
|
||||
class ChatRequest {
|
||||
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
package com.alya.ecommerce_serang.data.api.dto
|
||||
|
||||
data class UpdateChatRequest (
|
||||
val id: Int,
|
||||
val status: String
|
||||
)
|
@ -0,0 +1,40 @@
|
||||
package com.alya.ecommerce_serang.data.api.response.chat
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import java.io.File
|
||||
|
||||
data class ChatHistoryResponse(
|
||||
|
||||
@field:SerializedName("chat")
|
||||
val chat: List<ChatItem>,
|
||||
|
||||
@field:SerializedName("message")
|
||||
val message: String
|
||||
)
|
||||
|
||||
data class ChatItem(
|
||||
|
||||
@field:SerializedName("attachment")
|
||||
val attachment: File? = null,
|
||||
|
||||
@field:SerializedName("product_id")
|
||||
val productId: Int,
|
||||
|
||||
@field:SerializedName("chat_room_id")
|
||||
val chatRoomId: Int,
|
||||
|
||||
@field:SerializedName("created_at")
|
||||
val createdAt: String,
|
||||
|
||||
@field:SerializedName("id")
|
||||
val id: Int,
|
||||
|
||||
@field:SerializedName("message")
|
||||
val message: String,
|
||||
|
||||
@field:SerializedName("sender_id")
|
||||
val senderId: Int,
|
||||
|
||||
@field:SerializedName("status")
|
||||
val status: String
|
||||
)
|
@ -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
|
||||
)
|
@ -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
|
||||
)
|
@ -12,6 +12,7 @@ import com.alya.ecommerce_serang.data.api.dto.OtpRequest
|
||||
import com.alya.ecommerce_serang.data.api.dto.RegisterRequest
|
||||
import com.alya.ecommerce_serang.data.api.dto.SearchRequest
|
||||
import com.alya.ecommerce_serang.data.api.dto.UpdateCart
|
||||
import com.alya.ecommerce_serang.data.api.dto.UpdateChatRequest
|
||||
import com.alya.ecommerce_serang.data.api.response.ViewStoreProductsResponse
|
||||
import com.alya.ecommerce_serang.data.api.response.auth.LoginResponse
|
||||
import com.alya.ecommerce_serang.data.api.response.auth.OtpResponse
|
||||
@ -19,6 +20,9 @@ import com.alya.ecommerce_serang.data.api.response.auth.RegisterResponse
|
||||
import com.alya.ecommerce_serang.data.api.response.cart.AddCartResponse
|
||||
import com.alya.ecommerce_serang.data.api.response.cart.ListCartResponse
|
||||
import com.alya.ecommerce_serang.data.api.response.cart.UpdateCartResponse
|
||||
import com.alya.ecommerce_serang.data.api.response.chat.ChatHistoryResponse
|
||||
import com.alya.ecommerce_serang.data.api.response.chat.SendChatResponse
|
||||
import com.alya.ecommerce_serang.data.api.response.chat.UpdateChatResponse
|
||||
import com.alya.ecommerce_serang.data.api.response.order.AddEvidenceResponse
|
||||
import com.alya.ecommerce_serang.data.api.response.order.ComplaintResponse
|
||||
import com.alya.ecommerce_serang.data.api.response.order.CompletedOrderResponse
|
||||
@ -209,4 +213,23 @@ interface ApiService {
|
||||
|
||||
@GET("search")
|
||||
suspend fun getSearchHistory(): Response<SearchHistoryResponse>
|
||||
|
||||
@Multipart
|
||||
@POST("sendchat")
|
||||
suspend fun sendChatLine(
|
||||
@Part("store_id") storeId: RequestBody,
|
||||
@Part("message") message: RequestBody,
|
||||
@Part("product_id") productId: RequestBody,
|
||||
@Part("chatimg") chatimg: MultipartBody.Part
|
||||
): Response<SendChatResponse>
|
||||
|
||||
@PUT("chatstatus")
|
||||
suspend fun updateChatStatus(
|
||||
@Body request: UpdateChatRequest
|
||||
): Response<UpdateChatResponse>
|
||||
|
||||
@GET("chatdetail/{chatRoomId}")
|
||||
suspend fun getChatDetail(
|
||||
@Path("chatRoomId") chatRoomId: Int
|
||||
): Response<ChatHistoryResponse>
|
||||
}
|
@ -3,10 +3,19 @@ package com.alya.ecommerce_serang.data.repository
|
||||
import com.alya.ecommerce_serang.data.api.dto.LoginRequest
|
||||
import com.alya.ecommerce_serang.data.api.dto.OtpRequest
|
||||
import com.alya.ecommerce_serang.data.api.dto.RegisterRequest
|
||||
import com.alya.ecommerce_serang.data.api.dto.UpdateChatRequest
|
||||
import com.alya.ecommerce_serang.data.api.dto.UserProfile
|
||||
import com.alya.ecommerce_serang.data.api.response.auth.LoginResponse
|
||||
import com.alya.ecommerce_serang.data.api.response.auth.OtpResponse
|
||||
import com.alya.ecommerce_serang.data.api.response.chat.ChatHistoryResponse
|
||||
import com.alya.ecommerce_serang.data.api.response.chat.SendChatResponse
|
||||
import com.alya.ecommerce_serang.data.api.response.chat.UpdateChatResponse
|
||||
import com.alya.ecommerce_serang.data.api.retrofit.ApiService
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.File
|
||||
|
||||
class UserRepository(private val apiService: ApiService) {
|
||||
|
||||
@ -56,6 +65,101 @@ class UserRepository(private val apiService: ApiService) {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun sendChatMessage(
|
||||
storeId: Int,
|
||||
message: String,
|
||||
productId: Int,
|
||||
imageFile: File? = null
|
||||
): Result<SendChatResponse> {
|
||||
return try {
|
||||
// Create request bodies for text fields
|
||||
val storeIdBody = storeId.toString().toRequestBody("text/plain".toMediaTypeOrNull())
|
||||
val messageBody = message.toRequestBody("text/plain".toMediaTypeOrNull())
|
||||
val productIdBody = productId.toString().toRequestBody("text/plain".toMediaTypeOrNull())
|
||||
|
||||
// Create multipart body for the image file
|
||||
val imageMultipart = if (imageFile != null && imageFile.exists()) {
|
||||
val requestFile = imageFile.asRequestBody("image/*".toMediaTypeOrNull())
|
||||
MultipartBody.Part.createFormData("chatimg", imageFile.name, requestFile)
|
||||
} else {
|
||||
// Create an empty part if no image is provided
|
||||
val emptyRequest = "".toRequestBody("text/plain".toMediaTypeOrNull())
|
||||
MultipartBody.Part.createFormData("chatimg", "", emptyRequest)
|
||||
}
|
||||
|
||||
// Make the API call
|
||||
val response = apiService.sendChatLine(
|
||||
storeId = storeIdBody,
|
||||
message = messageBody,
|
||||
productId = productIdBody,
|
||||
chatimg = imageMultipart
|
||||
)
|
||||
|
||||
if (response.isSuccessful) {
|
||||
response.body()?.let {
|
||||
Result.Success(it)
|
||||
} ?: Result.Error(Exception("Send chat response is empty"))
|
||||
} else {
|
||||
Result.Error(Exception(response.errorBody()?.string() ?: "Unknown error"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.Error(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the status of a message (sent, delivered, read)
|
||||
*
|
||||
* @param messageId The ID of the message to update
|
||||
* @param status The new status to set
|
||||
* @return Result containing the updated message details or error
|
||||
*/
|
||||
suspend fun updateMessageStatus(
|
||||
messageId: Int,
|
||||
status: String
|
||||
): Result<UpdateChatResponse> {
|
||||
return try {
|
||||
val requestBody = UpdateChatRequest(
|
||||
id = messageId,
|
||||
status = status
|
||||
)
|
||||
|
||||
val response = apiService.updateChatStatus(requestBody)
|
||||
|
||||
if (response.isSuccessful) {
|
||||
response.body()?.let {
|
||||
Result.Success(it)
|
||||
} ?: Result.Error(Exception("Update status response is empty"))
|
||||
} else {
|
||||
Result.Error(Exception(response.errorBody()?.string() ?: "Unknown error"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.Error(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the chat history for a specific chat room
|
||||
*
|
||||
* @param chatRoomId The ID of the chat room
|
||||
* @return Result containing the list of chat messages or error
|
||||
*/
|
||||
suspend fun getChatHistory(chatRoomId: Int): Result<ChatHistoryResponse> {
|
||||
return try {
|
||||
val response = apiService.getChatDetail(chatRoomId)
|
||||
|
||||
if (response.isSuccessful) {
|
||||
response.body()?.let {
|
||||
Result.Success(it)
|
||||
} ?: Result.Error(Exception("Chat history response is empty"))
|
||||
} else {
|
||||
Result.Error(Exception(response.errorBody()?.string() ?: "Unknown error"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.Error(e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
36
app/src/main/java/com/alya/ecommerce_serang/di/ChatModule.kt
Normal file
36
app/src/main/java/com/alya/ecommerce_serang/di/ChatModule.kt
Normal file
@ -0,0 +1,36 @@
|
||||
package com.alya.ecommerce_serang.di
|
||||
|
||||
import android.content.Context
|
||||
import com.alya.ecommerce_serang.data.api.retrofit.ApiService
|
||||
import com.alya.ecommerce_serang.data.repository.UserRepository
|
||||
import com.alya.ecommerce_serang.ui.chat.SocketIOService
|
||||
import com.alya.ecommerce_serang.utils.SessionManager
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object ChatModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideSessionManager(@ApplicationContext context: Context): SessionManager {
|
||||
return SessionManager(context)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideChatRepository(apiService: ApiService): UserRepository {
|
||||
return UserRepository(apiService)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideSocketIOService(sessionManager: SessionManager): SocketIOService {
|
||||
return SocketIOService(sessionManager)
|
||||
}
|
||||
}
|
@ -0,0 +1,392 @@
|
||||
package com.alya.ecommerce_serang.ui.chat
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.provider.MediaStore
|
||||
import android.text.Editable
|
||||
import android.text.TextWatcher
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.alya.ecommerce_serang.BuildConfig.BASE_URL
|
||||
import com.alya.ecommerce_serang.R
|
||||
import com.alya.ecommerce_serang.data.api.retrofit.ApiConfig
|
||||
import com.alya.ecommerce_serang.data.repository.ProductRepository
|
||||
import com.alya.ecommerce_serang.data.repository.UserRepository
|
||||
import com.alya.ecommerce_serang.databinding.ActivityChatBinding
|
||||
import com.alya.ecommerce_serang.ui.auth.LoginActivity
|
||||
import com.alya.ecommerce_serang.ui.product.ProductUserViewModel
|
||||
import com.alya.ecommerce_serang.utils.BaseViewModelFactory
|
||||
import com.alya.ecommerce_serang.utils.Constants
|
||||
import com.alya.ecommerce_serang.utils.SessionManager
|
||||
import com.bumptech.glide.Glide
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class ChatActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var binding: ActivityChatBinding
|
||||
|
||||
@Inject
|
||||
lateinit var sessionManager: SessionManager
|
||||
private lateinit var socketService: SocketIOService
|
||||
|
||||
|
||||
@Inject
|
||||
private lateinit var chatAdapter: ChatAdapter
|
||||
|
||||
private val viewModel: ChatViewModel by viewModels {
|
||||
BaseViewModelFactory {
|
||||
val apiService = ApiConfig.getApiService(sessionManager)
|
||||
val userRepository = UserRepository(apiService)
|
||||
ChatViewModel(userRepository, socketService, sessionManager)
|
||||
}
|
||||
}
|
||||
|
||||
// For image attachment
|
||||
private var tempImageUri: Uri? = null
|
||||
|
||||
// Chat parameters from intent
|
||||
private var chatRoomId: Int = 0
|
||||
private var storeId: Int = 0
|
||||
private var productId: Int = 0
|
||||
|
||||
// Typing indicator handler
|
||||
private val typingHandler = android.os.Handler(android.os.Looper.getMainLooper())
|
||||
private val stopTypingRunnable = Runnable {
|
||||
viewModel.sendTypingStatus(false)
|
||||
}
|
||||
|
||||
// Activity Result Launchers
|
||||
private val pickImageLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
result.data?.data?.let { uri ->
|
||||
handleSelectedImage(uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val takePictureLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
tempImageUri?.let { uri ->
|
||||
handleSelectedImage(uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityChatBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
// Get parameters from intent
|
||||
chatRoomId = intent.getIntExtra(Constants.EXTRA_CHAT_ROOM_ID, 0)
|
||||
storeId = intent.getIntExtra(Constants.EXTRA_STORE_ID, 0)
|
||||
productId = intent.getIntExtra(Constants.EXTRA_PRODUCT_ID, 0)
|
||||
|
||||
// Check if user is logged in
|
||||
val userId = sessionManager.getUserId()
|
||||
val token = sessionManager.getToken()
|
||||
|
||||
if (userId.isNullOrEmpty() || token.isNullOrEmpty()) {
|
||||
// User not logged in, redirect to login
|
||||
Toast.makeText(this, "Please login first", Toast.LENGTH_SHORT).show()
|
||||
startActivity(Intent(this, LoginActivity::class.java))
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(TAG, "Chat Activity started - User ID: $userId, Chat Room: $chatRoomId")
|
||||
|
||||
// Initialize ViewModel
|
||||
initViewModel()
|
||||
|
||||
// Setup UI components
|
||||
setupRecyclerView()
|
||||
setupListeners()
|
||||
setupTypingIndicator()
|
||||
observeViewModel()
|
||||
}
|
||||
|
||||
private fun initViewModel() {
|
||||
// Set chat parameters to ViewModel
|
||||
viewModel.setChatParameters(
|
||||
chatRoomId = chatRoomId,
|
||||
storeId = storeId,
|
||||
productId = productId,
|
||||
productName = intent.getStringExtra(Constants.EXTRA_PRODUCT_NAME) ?: "",
|
||||
productPrice = intent.getStringExtra(Constants.EXTRA_PRODUCT_PRICE) ?: "",
|
||||
productImage = intent.getStringExtra(Constants.EXTRA_PRODUCT_IMAGE) ?: "",
|
||||
productRating = intent.getFloatExtra(Constants.EXTRA_PRODUCT_RATING, 0f),
|
||||
storeName = intent.getStringExtra(Constants.EXTRA_STORE_NAME) ?: ""
|
||||
)
|
||||
}
|
||||
|
||||
private fun setupRecyclerView() {
|
||||
chatAdapter = ChatAdapter()
|
||||
binding.recyclerChat.apply {
|
||||
adapter = chatAdapter
|
||||
layoutManager = LinearLayoutManager(this@ChatActivity).apply {
|
||||
stackFromEnd = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupListeners() {
|
||||
// Back button
|
||||
binding.btnBack.setOnClickListener {
|
||||
onBackPressed()
|
||||
}
|
||||
|
||||
// Options button
|
||||
binding.btnOptions.setOnClickListener {
|
||||
showOptionsMenu()
|
||||
}
|
||||
|
||||
// Send button
|
||||
binding.btnSend.setOnClickListener {
|
||||
val message = binding.editTextMessage.text.toString().trim()
|
||||
if (message.isNotEmpty() || viewModel.state.value?.hasAttachment ?: false) {
|
||||
viewModel.sendMessage(message)
|
||||
binding.editTextMessage.text.clear()
|
||||
}
|
||||
}
|
||||
|
||||
// Attachment button
|
||||
binding.btnAttachment.setOnClickListener {
|
||||
checkPermissionsAndShowImagePicker()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupTypingIndicator() {
|
||||
binding.editTextMessage.addTextChangedListener(object : TextWatcher {
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
|
||||
viewModel.sendTypingStatus(true)
|
||||
|
||||
// Reset the timer
|
||||
typingHandler.removeCallbacks(stopTypingRunnable)
|
||||
typingHandler.postDelayed(stopTypingRunnable, 1000)
|
||||
}
|
||||
|
||||
override fun afterTextChanged(s: Editable?) {}
|
||||
})
|
||||
}
|
||||
|
||||
private fun observeViewModel() {
|
||||
lifecycleScope.launch {
|
||||
viewModel.state.collectLatest { state ->
|
||||
// Update messages
|
||||
chatAdapter.submitList(state.messages)
|
||||
|
||||
// Scroll to bottom if new message
|
||||
if (state.messages.isNotEmpty()) {
|
||||
binding.recyclerChat.scrollToPosition(state.messages.size - 1)
|
||||
}
|
||||
|
||||
// Update product info
|
||||
binding.tvProductName.text = state.productName
|
||||
binding.tvProductPrice.text = state.productPrice
|
||||
binding.ratingBar.rating = state.productRating
|
||||
binding.tvRating.text = state.productRating.toString()
|
||||
binding.tvSellerName.text = state.storeName
|
||||
|
||||
// Load product image
|
||||
if (state.productImageUrl.isNotEmpty()) {
|
||||
Glide.with(this@ChatActivity)
|
||||
.load(BASE_URL + state.productImageUrl)
|
||||
.centerCrop()
|
||||
.placeholder(R.drawable.placeholder_image)
|
||||
.error(R.drawable.placeholder_image)
|
||||
.into(binding.imgProduct)
|
||||
}
|
||||
|
||||
// Show/hide loading indicators
|
||||
// binding.progressBar.visibility = if (state.isLoading) View.VISIBLE else View.GONE
|
||||
binding.btnSend.isEnabled = !state.isSending
|
||||
|
||||
// Update attachment hint
|
||||
if (state.hasAttachment) {
|
||||
binding.editTextMessage.hint = getString(R.string.image_attached)
|
||||
} else {
|
||||
binding.editTextMessage.hint = getString(R.string.write_message)
|
||||
}
|
||||
|
||||
// Show typing indicator
|
||||
binding.tvTypingIndicator.visibility =
|
||||
if (state.isOtherUserTyping) View.VISIBLE else View.GONE
|
||||
|
||||
// Handle connection state
|
||||
handleConnectionState(state.connectionState)
|
||||
|
||||
// Show error if any
|
||||
state.error?.let { error ->
|
||||
Toast.makeText(this@ChatActivity, error, Toast.LENGTH_SHORT).show()
|
||||
viewModel.clearError()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleConnectionState(state: ConnectionState) {
|
||||
when (state) {
|
||||
is ConnectionState.Connected -> {
|
||||
binding.tvConnectionStatus.visibility = View.GONE
|
||||
}
|
||||
is ConnectionState.Connecting -> {
|
||||
binding.tvConnectionStatus.visibility = View.VISIBLE
|
||||
binding.tvConnectionStatus.text = getString(R.string.connecting)
|
||||
}
|
||||
is ConnectionState.Disconnected -> {
|
||||
binding.tvConnectionStatus.visibility = View.VISIBLE
|
||||
binding.tvConnectionStatus.text = getString(R.string.disconnected_reconnecting)
|
||||
}
|
||||
is ConnectionState.Error -> {
|
||||
binding.tvConnectionStatus.visibility = View.VISIBLE
|
||||
binding.tvConnectionStatus.text = getString(R.string.connection_error, state.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showOptionsMenu() {
|
||||
val options = arrayOf(
|
||||
getString(R.string.block_user),
|
||||
getString(R.string.report),
|
||||
getString(R.string.clear_chat),
|
||||
getString(R.string.cancel)
|
||||
)
|
||||
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(getString(R.string.options))
|
||||
.setItems(options) { dialog, which ->
|
||||
when (which) {
|
||||
0 -> Toast.makeText(this, R.string.block_user_selected, Toast.LENGTH_SHORT).show()
|
||||
1 -> Toast.makeText(this, R.string.report_selected, Toast.LENGTH_SHORT).show()
|
||||
2 -> Toast.makeText(this, R.string.clear_chat_selected, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
dialog.dismiss()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun checkPermissionsAndShowImagePicker() {
|
||||
if (ContextCompat.checkSelfPermission(
|
||||
this,
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
ActivityCompat.requestPermissions(
|
||||
this,
|
||||
arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.CAMERA),
|
||||
Constants.REQUEST_STORAGE_PERMISSION
|
||||
)
|
||||
} else {
|
||||
showImagePickerOptions()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showImagePickerOptions() {
|
||||
val options = arrayOf(
|
||||
getString(R.string.take_photo),
|
||||
getString(R.string.choose_from_gallery),
|
||||
getString(R.string.cancel)
|
||||
)
|
||||
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(getString(R.string.select_attachment))
|
||||
.setItems(options) { dialog, which ->
|
||||
when (which) {
|
||||
0 -> openCamera()
|
||||
1 -> openGallery()
|
||||
}
|
||||
dialog.dismiss()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun openCamera() {
|
||||
val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
|
||||
val imageFileName = "IMG_${timeStamp}.jpg"
|
||||
val storageDir = getExternalFilesDir(null)
|
||||
val imageFile = File(storageDir, imageFileName)
|
||||
|
||||
tempImageUri = FileProvider.getUriForFile(
|
||||
this,
|
||||
"${applicationContext.packageName}.fileprovider",
|
||||
imageFile
|
||||
)
|
||||
|
||||
val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply {
|
||||
putExtra(MediaStore.EXTRA_OUTPUT, tempImageUri)
|
||||
}
|
||||
|
||||
takePictureLauncher.launch(intent)
|
||||
}
|
||||
|
||||
private fun openGallery() {
|
||||
val intent = Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI)
|
||||
pickImageLauncher.launch(intent)
|
||||
}
|
||||
|
||||
private fun handleSelectedImage(uri: Uri) {
|
||||
// Get the file from Uri
|
||||
val filePathColumn = arrayOf(MediaStore.Images.Media.DATA)
|
||||
val cursor = contentResolver.query(uri, filePathColumn, null, null, null)
|
||||
cursor?.moveToFirst()
|
||||
val columnIndex = cursor?.getColumnIndex(filePathColumn[0])
|
||||
val filePath = cursor?.getString(columnIndex ?: 0)
|
||||
cursor?.close()
|
||||
|
||||
if (filePath != null) {
|
||||
viewModel.setSelectedImageFile(File(filePath))
|
||||
Toast.makeText(this, R.string.image_selected, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int,
|
||||
permissions: Array<out String>,
|
||||
grantResults: IntArray
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
if (requestCode == Constants.REQUEST_STORAGE_PERMISSION) {
|
||||
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||
showImagePickerOptions()
|
||||
} else {
|
||||
Toast.makeText(this, R.string.permission_denied, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
typingHandler.removeCallbacks(stopTypingRunnable)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ChatActivity"
|
||||
}
|
||||
}
|
@ -0,0 +1,135 @@
|
||||
package com.alya.ecommerce_serang.ui.chat
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.alya.ecommerce_serang.BuildConfig.BASE_URL
|
||||
import com.alya.ecommerce_serang.R
|
||||
import com.alya.ecommerce_serang.databinding.ItemMessageReceivedBinding
|
||||
import com.alya.ecommerce_serang.databinding.ItemMessageSentBinding
|
||||
import com.bumptech.glide.Glide
|
||||
|
||||
class ChatAdapter : ListAdapter<ChatUiMessage, RecyclerView.ViewHolder>(ChatDiffCallback()) {
|
||||
|
||||
companion object {
|
||||
private const val VIEW_TYPE_MESSAGE_SENT = 1
|
||||
private const val VIEW_TYPE_MESSAGE_RECEIVED = 2
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
return if (viewType == VIEW_TYPE_MESSAGE_SENT) {
|
||||
val binding = ItemMessageSentBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
SentMessageViewHolder(binding)
|
||||
} else {
|
||||
val binding = ItemMessageReceivedBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
ReceivedMessageViewHolder(binding)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
val message = getItem(position)
|
||||
|
||||
when (holder.itemViewType) {
|
||||
VIEW_TYPE_MESSAGE_SENT -> (holder as SentMessageViewHolder).bind(message)
|
||||
VIEW_TYPE_MESSAGE_RECEIVED -> (holder as ReceivedMessageViewHolder).bind(message)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int): Int {
|
||||
val message = getItem(position)
|
||||
return if (message.isSentByMe) {
|
||||
VIEW_TYPE_MESSAGE_SENT
|
||||
} else {
|
||||
VIEW_TYPE_MESSAGE_RECEIVED
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ViewHolder for messages sent by the current user
|
||||
*/
|
||||
inner class SentMessageViewHolder(private val binding: ItemMessageSentBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
fun bind(message: ChatUiMessage) {
|
||||
binding.tvMessage.text = message.message
|
||||
binding.tvTimestamp.text = message.time
|
||||
|
||||
// Show message status
|
||||
val statusIcon = when (message.status) {
|
||||
Constants.STATUS_SENT -> R.drawable.ic_check
|
||||
Constants.STATUS_DELIVERED -> R.drawable.ic_double_check
|
||||
Constants.STATUS_READ -> R.drawable.ic_double_check_read
|
||||
else -> R.drawable.ic_check
|
||||
}
|
||||
binding.imgStatus.setImageResource(statusIcon)
|
||||
|
||||
// Handle attachment if exists
|
||||
if (message.attachment.isNotEmpty()) {
|
||||
binding.imgAttachment.visibility = View.VISIBLE
|
||||
Glide.with(binding.root.context)
|
||||
.load(BASE_URL + message.attachment)
|
||||
.centerCrop()
|
||||
.placeholder(R.drawable.placeholder_image)
|
||||
.error(R.drawable.placeholder_image)
|
||||
.into(binding.imgAttachment)
|
||||
} else {
|
||||
binding.imgAttachment.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ViewHolder for messages received from other users
|
||||
*/
|
||||
inner class ReceivedMessageViewHolder(private val binding: ItemMessageReceivedBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
fun bind(message: ChatUiMessage) {
|
||||
binding.tvMessage.text = message.message
|
||||
binding.tvTimestamp.text = message.time
|
||||
|
||||
// Handle attachment if exists
|
||||
if (message.attachment.isNotEmpty()) {
|
||||
binding.imgAttachment.visibility = View.VISIBLE
|
||||
Glide.with(binding.root.context)
|
||||
.load(BASE_URL + message.attachment)
|
||||
.centerCrop()
|
||||
.placeholder(R.drawable.placeholder_image)
|
||||
.error(R.drawable.placeholder_image)
|
||||
.into(binding.imgAttachment)
|
||||
} else {
|
||||
binding.imgAttachment.visibility = View.GONE
|
||||
}
|
||||
|
||||
// Load avatar image
|
||||
Glide.with(binding.root.context)
|
||||
.load(R.drawable.ic_person) // Replace with actual avatar URL if available
|
||||
.circleCrop()
|
||||
.into(binding.imgAvatar)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DiffCallback for optimizing RecyclerView updates
|
||||
*/
|
||||
class ChatDiffCallback : DiffUtil.ItemCallback<ChatUiMessage>() {
|
||||
override fun areItemsTheSame(oldItem: ChatUiMessage, newItem: ChatUiMessage): Boolean {
|
||||
return oldItem.id == newItem.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: ChatUiMessage, newItem: ChatUiMessage): Boolean {
|
||||
return oldItem == newItem
|
||||
}
|
||||
}
|
@ -1,32 +1,343 @@
|
||||
package com.alya.ecommerce_serang.ui.chat
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.provider.MediaStore
|
||||
import android.text.Editable
|
||||
import android.text.TextWatcher
|
||||
import androidx.fragment.app.Fragment
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.alya.ecommerce_serang.BuildConfig.BASE_URL
|
||||
import com.alya.ecommerce_serang.R
|
||||
import com.alya.ecommerce_serang.utils.viewmodel.ChatViewModel
|
||||
import com.alya.ecommerce_serang.databinding.FragmentChatBinding
|
||||
import com.alya.ecommerce_serang.utils.Constants
|
||||
import com.bumptech.glide.Glide
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
|
||||
/**
|
||||
* A simple [Fragment] subclass.
|
||||
* Use the [ChatFragment.newInstance] factory method to
|
||||
* create an instance of this fragment.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class ChatFragment : Fragment() {
|
||||
|
||||
companion object {
|
||||
fun newInstance() = ChatFragment()
|
||||
}
|
||||
private var _binding: FragmentChatBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
private val viewModel: ChatViewModel by viewModels()
|
||||
private val args: ChatFragmentArgs by navArgs()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
private lateinit var chatAdapter: ChatAdapter
|
||||
|
||||
// TODO: Use the ViewModel
|
||||
// For image attachment
|
||||
private var tempImageUri: Uri? = null
|
||||
|
||||
// Typing indicator handler
|
||||
private val typingHandler = android.os.Handler(android.os.Looper.getMainLooper())
|
||||
private val stopTypingRunnable = Runnable {
|
||||
viewModel.sendTypingStatus(false)
|
||||
}
|
||||
|
||||
// Activity Result Launchers
|
||||
private val pickImageLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
result.data?.data?.let { uri ->
|
||||
handleSelectedImage(uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val takePictureLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
tempImageUri?.let { uri ->
|
||||
handleSelectedImage(uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?,
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
return inflater.inflate(R.layout.fragment_chat, container, false)
|
||||
_binding = FragmentChatBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
setupRecyclerView()
|
||||
setupListeners()
|
||||
setupTypingIndicator()
|
||||
observeViewModel()
|
||||
}
|
||||
|
||||
private fun setupRecyclerView() {
|
||||
chatAdapter = ChatAdapter()
|
||||
binding.recyclerChat.apply {
|
||||
adapter = chatAdapter
|
||||
layoutManager = LinearLayoutManager(requireContext()).apply {
|
||||
stackFromEnd = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupListeners() {
|
||||
// Back button
|
||||
binding.btnBack.setOnClickListener {
|
||||
requireActivity().onBackPressed()
|
||||
}
|
||||
|
||||
// Options button
|
||||
binding.btnOptions.setOnClickListener {
|
||||
showOptionsMenu()
|
||||
}
|
||||
|
||||
// Send button
|
||||
binding.btnSend.setOnClickListener {
|
||||
val message = binding.editTextMessage.text.toString().trim()
|
||||
if (message.isNotEmpty() || viewModel.state.value.hasAttachment) {
|
||||
viewModel.sendMessage(message)
|
||||
binding.editTextMessage.text.clear()
|
||||
}
|
||||
}
|
||||
|
||||
// Attachment button
|
||||
binding.btnAttachment.setOnClickListener {
|
||||
checkPermissionsAndShowImagePicker()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupTypingIndicator() {
|
||||
binding.editTextMessage.addTextChangedListener(object : TextWatcher {
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
|
||||
viewModel.sendTypingStatus(true)
|
||||
|
||||
// Reset the timer
|
||||
typingHandler.removeCallbacks(stopTypingRunnable)
|
||||
typingHandler.postDelayed(stopTypingRunnable, 1000)
|
||||
}
|
||||
|
||||
override fun afterTextChanged(s: Editable?) {}
|
||||
})
|
||||
}
|
||||
|
||||
private fun observeViewModel() {
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewModel.state.collectLatest { state ->
|
||||
// Update messages
|
||||
chatAdapter.submitList(state.messages)
|
||||
|
||||
// Scroll to bottom if new message
|
||||
if (state.messages.isNotEmpty()) {
|
||||
binding.recyclerChat.scrollToPosition(state.messages.size - 1)
|
||||
}
|
||||
|
||||
// Update product info
|
||||
binding.tvProductName.text = state.productName
|
||||
binding.tvProductPrice.text = state.productPrice
|
||||
binding.ratingBar.rating = state.productRating
|
||||
binding.tvRating.text = state.productRating.toString()
|
||||
binding.tvSellerName.text = state.storeName
|
||||
|
||||
// Load product image
|
||||
if (state.productImageUrl.isNotEmpty()) {
|
||||
Glide.with(requireContext())
|
||||
.load(BASE_URL + state.productImageUrl)
|
||||
.centerCrop()
|
||||
.placeholder(R.drawable.placeholder_image)
|
||||
.error(R.drawable.placeholder_image)
|
||||
.into(binding.imgProduct)
|
||||
}
|
||||
|
||||
// Show/hide loading indicators
|
||||
binding.progressBar.visibility = if (state.isLoading) View.VISIBLE else View.GONE
|
||||
binding.btnSend.isEnabled = !state.isSending
|
||||
|
||||
// Update attachment hint
|
||||
if (state.hasAttachment) {
|
||||
binding.editTextMessage.hint = getString(R.string.image_attached)
|
||||
} else {
|
||||
binding.editTextMessage.hint = getString(R.string.write_message)
|
||||
}
|
||||
|
||||
// Show typing indicator
|
||||
binding.tvTypingIndicator.visibility =
|
||||
if (state.isOtherUserTyping) View.VISIBLE else View.GONE
|
||||
|
||||
// Handle connection state
|
||||
handleConnectionState(state.connectionState)
|
||||
|
||||
// Show error if any
|
||||
state.error?.let { error ->
|
||||
Toast.makeText(requireContext(), error, Toast.LENGTH_SHORT).show()
|
||||
viewModel.clearError()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleConnectionState(state: ConnectionState) {
|
||||
when (state) {
|
||||
is ConnectionState.Connected -> {
|
||||
binding.tvConnectionStatus.visibility = View.GONE
|
||||
}
|
||||
is ConnectionState.Connecting -> {
|
||||
binding.tvConnectionStatus.visibility = View.VISIBLE
|
||||
binding.tvConnectionStatus.text = getString(R.string.connecting)
|
||||
}
|
||||
is ConnectionState.Disconnected -> {
|
||||
binding.tvConnectionStatus.visibility = View.VISIBLE
|
||||
binding.tvConnectionStatus.text = getString(R.string.disconnected_reconnecting)
|
||||
}
|
||||
is ConnectionState.Error -> {
|
||||
binding.tvConnectionStatus.visibility = View.VISIBLE
|
||||
binding.tvConnectionStatus.text = getString(R.string.connection_error, state.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showOptionsMenu() {
|
||||
val options = arrayOf(
|
||||
getString(R.string.block_user),
|
||||
getString(R.string.report),
|
||||
getString(R.string.clear_chat),
|
||||
getString(R.string.cancel)
|
||||
)
|
||||
|
||||
androidx.appcompat.app.AlertDialog.Builder(requireContext())
|
||||
.setTitle(getString(R.string.options))
|
||||
.setItems(options) { dialog, which ->
|
||||
when (which) {
|
||||
0 -> Toast.makeText(requireContext(), R.string.block_user_selected, Toast.LENGTH_SHORT).show()
|
||||
1 -> Toast.makeText(requireContext(), R.string.report_selected, Toast.LENGTH_SHORT).show()
|
||||
2 -> Toast.makeText(requireContext(), R.string.clear_chat_selected, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
dialog.dismiss()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun checkPermissionsAndShowImagePicker() {
|
||||
if (ContextCompat.checkSelfPermission(
|
||||
requireContext(),
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
ActivityCompat.requestPermissions(
|
||||
requireActivity(),
|
||||
arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.CAMERA),
|
||||
Constants.REQUEST_STORAGE_PERMISSION
|
||||
)
|
||||
} else {
|
||||
showImagePickerOptions()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showImagePickerOptions() {
|
||||
val options = arrayOf(
|
||||
getString(R.string.take_photo),
|
||||
getString(R.string.choose_from_gallery),
|
||||
getString(R.string.cancel)
|
||||
)
|
||||
|
||||
androidx.appcompat.app.AlertDialog.Builder(requireContext())
|
||||
.setTitle(getString(R.string.select_attachment))
|
||||
.setItems(options) { dialog, which ->
|
||||
when (which) {
|
||||
0 -> openCamera()
|
||||
1 -> openGallery()
|
||||
}
|
||||
dialog.dismiss()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun openCamera() {
|
||||
val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
|
||||
val imageFileName = "IMG_${timeStamp}.jpg"
|
||||
val storageDir = requireContext().getExternalFilesDir(null)
|
||||
val imageFile = File(storageDir, imageFileName)
|
||||
|
||||
tempImageUri = FileProvider.getUriForFile(
|
||||
requireContext(),
|
||||
"${requireContext().packageName}.fileprovider",
|
||||
imageFile
|
||||
)
|
||||
|
||||
val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply {
|
||||
putExtra(MediaStore.EXTRA_OUTPUT, tempImageUri)
|
||||
}
|
||||
|
||||
takePictureLauncher.launch(intent)
|
||||
}
|
||||
|
||||
private fun openGallery() {
|
||||
val intent = Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI)
|
||||
pickImageLauncher.launch(intent)
|
||||
}
|
||||
|
||||
private fun handleSelectedImage(uri: Uri) {
|
||||
// Get the file from Uri
|
||||
val filePathColumn = arrayOf(MediaStore.Images.Media.DATA)
|
||||
val cursor = requireContext().contentResolver.query(uri, filePathColumn, null, null, null)
|
||||
cursor?.moveToFirst()
|
||||
val columnIndex = cursor?.getColumnIndex(filePathColumn[0])
|
||||
val filePath = cursor?.getString(columnIndex ?: 0)
|
||||
cursor?.close()
|
||||
|
||||
if (filePath != null) {
|
||||
viewModel.setSelectedImageFile(File(filePath))
|
||||
Toast.makeText(requireContext(), R.string.image_selected, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int,
|
||||
permissions: Array<out String>,
|
||||
grantResults: IntArray
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
if (requestCode == Constants.REQUEST_STORAGE_PERMISSION) {
|
||||
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||
showImagePickerOptions()
|
||||
} else {
|
||||
Toast.makeText(requireContext(), R.string.permission_denied, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
typingHandler.removeCallbacks(stopTypingRunnable)
|
||||
_binding = null
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -0,0 +1,412 @@
|
||||
package com.alya.ecommerce_serang.ui.chat
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.alya.ecommerce_serang.data.api.response.chat.ChatItem
|
||||
import com.alya.ecommerce_serang.data.api.response.chat.ChatLine
|
||||
import com.alya.ecommerce_serang.data.repository.Result
|
||||
import com.alya.ecommerce_serang.data.repository.UserRepository
|
||||
import com.alya.ecommerce_serang.utils.Constants
|
||||
import com.alya.ecommerce_serang.utils.SessionManager
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
import javax.inject.Inject
|
||||
|
||||
class ChatViewModel @Inject constructor(
|
||||
private val chatRepository: UserRepository,
|
||||
private val socketService: SocketIOService,
|
||||
private val sessionManager: SessionManager
|
||||
) : ViewModel() {
|
||||
|
||||
private val TAG = "ChatViewModel"
|
||||
|
||||
// UI state using LiveData
|
||||
private val _state = MutableLiveData(ChatUiState())
|
||||
val state: LiveData<ChatUiState> = _state
|
||||
|
||||
// Chat parameters
|
||||
private var chatRoomId: Int = 0
|
||||
private var storeId: Int = 0
|
||||
private var productId: Int = 0
|
||||
private var currentUserId: Int = 0
|
||||
|
||||
// Product details for display
|
||||
private var productName: String = ""
|
||||
private var productPrice: String = ""
|
||||
private var productImage: String = ""
|
||||
private var productRating: Float = 0f
|
||||
private var storeName: String = ""
|
||||
|
||||
// For image attachment
|
||||
private var selectedImageFile: File? = null
|
||||
|
||||
init {
|
||||
// Try to get current user ID from SessionManager
|
||||
currentUserId = sessionManager.getUserId()?.toIntOrNull() ?: 0
|
||||
|
||||
if (currentUserId == 0) {
|
||||
Log.e(TAG, "Error: User ID is not set or invalid")
|
||||
updateState { it.copy(error = "User authentication error. Please login again.") }
|
||||
} else {
|
||||
// Set up socket listeners
|
||||
setupSocketListeners()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set chat parameters received from activity
|
||||
*/
|
||||
fun setChatParameters(
|
||||
chatRoomId: Int,
|
||||
storeId: Int,
|
||||
productId: Int,
|
||||
productName: String,
|
||||
productPrice: String,
|
||||
productImage: String,
|
||||
productRating: Float,
|
||||
storeName: String
|
||||
) {
|
||||
this.chatRoomId = chatRoomId
|
||||
this.storeId = storeId
|
||||
this.productId = productId
|
||||
this.productName = productName
|
||||
this.productPrice = productPrice
|
||||
this.productImage = productImage
|
||||
this.productRating = productRating
|
||||
this.storeName = storeName
|
||||
|
||||
// Update state with product info
|
||||
updateState {
|
||||
it.copy(
|
||||
productName = productName,
|
||||
productPrice = productPrice,
|
||||
productImageUrl = productImage,
|
||||
productRating = productRating,
|
||||
storeName = storeName
|
||||
)
|
||||
}
|
||||
|
||||
// Connect to socket and load chat history
|
||||
socketService.connect()
|
||||
loadChatHistory()
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up listeners for Socket.IO events
|
||||
*/
|
||||
private fun setupSocketListeners() {
|
||||
viewModelScope.launch {
|
||||
// Listen for connection state changes
|
||||
socketService.connectionState.collect { connectionState ->
|
||||
updateState { it.copy(connectionState = connectionState) }
|
||||
|
||||
// Join room when connected
|
||||
if (connectionState is ConnectionState.Connected) {
|
||||
socketService.joinRoom()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
// Listen for new messages
|
||||
socketService.newMessages.collect { chatLine ->
|
||||
chatLine?.let {
|
||||
val currentMessages = _state.value?.messages ?: listOf()
|
||||
val updatedMessages = currentMessages.toMutableList().apply {
|
||||
add(convertChatLineToUiMessage(it))
|
||||
}
|
||||
updateState { it.copy(messages = updatedMessages) }
|
||||
|
||||
// Update message status if received from others
|
||||
if (it.senderId != currentUserId) {
|
||||
updateMessageStatus(it.id, Constants.STATUS_READ)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
// Listen for typing status updates
|
||||
socketService.typingStatus.collect { typingStatus ->
|
||||
typingStatus?.let {
|
||||
if (typingStatus.roomId == chatRoomId && typingStatus.userId != currentUserId) {
|
||||
updateState { it.copy(isOtherUserTyping = typingStatus.isTyping) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to update LiveData state
|
||||
*/
|
||||
private fun updateState(update: (ChatUiState) -> ChatUiState) {
|
||||
_state.value?.let {
|
||||
_state.value = update(it)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads chat history
|
||||
*/
|
||||
fun loadChatHistory() {
|
||||
if (chatRoomId == 0) {
|
||||
Log.e(TAG, "Cannot load chat history: Chat room ID is 0")
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
updateState { it.copy(isLoading = true) }
|
||||
|
||||
when (val result = chatRepository.getChatHistory(chatRoomId)) {
|
||||
is com.alya.ecommerce_serang.data.repository.Result.Success -> {
|
||||
val messages = result.data.chat.map { chatLine ->
|
||||
convertChatLineToUiMessageHistory(chatLine)
|
||||
}
|
||||
|
||||
updateState {
|
||||
it.copy(
|
||||
messages = messages,
|
||||
isLoading = false,
|
||||
error = null
|
||||
)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Loaded ${messages.size} messages for chat room $chatRoomId")
|
||||
|
||||
// Update status of unread messages
|
||||
result.data.chat
|
||||
.filter { it.senderId != currentUserId && it.status != Constants.STATUS_READ }
|
||||
.forEach { updateMessageStatus(it.id, Constants.STATUS_READ) }
|
||||
}
|
||||
is com.alya.ecommerce_serang.data.repository.Result.Error -> {
|
||||
updateState {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
error = result.exception.message
|
||||
)
|
||||
}
|
||||
Log.e(TAG, "Error loading chat history: ${result.exception.message}")
|
||||
}
|
||||
is Result.Loading -> {
|
||||
updateState { it.copy(isLoading = true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a chat message
|
||||
*/
|
||||
fun sendMessage(message: String) {
|
||||
if (message.isBlank() && selectedImageFile == null) return
|
||||
|
||||
if (storeId == 0 || productId == 0) {
|
||||
Log.e(TAG, "Cannot send message: Store ID or Product ID is 0")
|
||||
updateState { it.copy(error = "Cannot send message. Invalid parameters.") }
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
updateState { it.copy(isSending = true) }
|
||||
|
||||
when (val result = chatRepository.sendChatMessage(
|
||||
storeId = storeId,
|
||||
message = message,
|
||||
productId = productId,
|
||||
imageFile = selectedImageFile
|
||||
)) {
|
||||
is com.alya.ecommerce_serang.data.repository.Result.Success -> {
|
||||
// Add new message to the list
|
||||
val chatLine = result.data.chatLine
|
||||
val newMessage = convertChatLineToUiMessage(chatLine)
|
||||
|
||||
val currentMessages = _state.value?.messages ?: listOf()
|
||||
val updatedMessages = currentMessages.toMutableList().apply {
|
||||
add(newMessage)
|
||||
}
|
||||
|
||||
updateState {
|
||||
it.copy(
|
||||
messages = updatedMessages,
|
||||
isSending = false,
|
||||
hasAttachment = false,
|
||||
error = null
|
||||
)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Message sent successfully: ${chatLine.id}")
|
||||
|
||||
// Emit the message via Socket.IO for real-time updates
|
||||
socketService.sendMessage(chatLine)
|
||||
|
||||
// Clear the image attachment
|
||||
selectedImageFile = null
|
||||
}
|
||||
is com.alya.ecommerce_serang.data.repository.Result.Error -> {
|
||||
updateState {
|
||||
it.copy(
|
||||
isSending = false,
|
||||
error = result.exception.message
|
||||
)
|
||||
}
|
||||
Log.e(TAG, "Error sending message: ${result.exception.message}")
|
||||
}
|
||||
is com.alya.ecommerce_serang.data.repository.Result.Loading -> {
|
||||
updateState { it.copy(isSending = true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a message status (delivered, read)
|
||||
*/
|
||||
fun updateMessageStatus(messageId: Int, status: String) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val result = chatRepository.updateMessageStatus(messageId, status)
|
||||
|
||||
if (result is com.alya.ecommerce_serang.data.repository.Result.Success) {
|
||||
// Update local message status
|
||||
val currentMessages = _state.value?.messages ?: listOf()
|
||||
val updatedMessages = currentMessages.map { message ->
|
||||
if (message.id == messageId) {
|
||||
message.copy(status = status)
|
||||
} else {
|
||||
message
|
||||
}
|
||||
}
|
||||
updateState { it.copy(messages = updatedMessages) }
|
||||
|
||||
Log.d(TAG, "Message status updated: $messageId -> $status")
|
||||
} else if (result is com.alya.ecommerce_serang.data.repository.Result.Error) {
|
||||
Log.e(TAG, "Error updating message status: ${result.exception.message}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Exception updating message status", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the selected image file for attachment
|
||||
*/
|
||||
fun setSelectedImageFile(file: File?) {
|
||||
selectedImageFile = file
|
||||
updateState { it.copy(hasAttachment = file != null) }
|
||||
|
||||
Log.d(TAG, "Image attachment ${if (file != null) "selected" else "cleared"}")
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends typing status to the other user
|
||||
*/
|
||||
fun sendTypingStatus(isTyping: Boolean) {
|
||||
if (chatRoomId == 0) return
|
||||
|
||||
socketService.sendTypingStatus(chatRoomId, isTyping)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears any error message in the state
|
||||
*/
|
||||
fun clearError() {
|
||||
updateState { it.copy(error = null) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a ChatLine from API to a UI message model
|
||||
*/
|
||||
private fun convertChatLineToUiMessage(chatLine: ChatLine): ChatUiMessage {
|
||||
// Format the timestamp for display
|
||||
val formattedTime = try {
|
||||
val inputFormat = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault())
|
||||
inputFormat.timeZone = TimeZone.getTimeZone("UTC")
|
||||
val outputFormat = java.text.SimpleDateFormat("HH:mm", Locale.getDefault())
|
||||
|
||||
val date = inputFormat.parse(chatLine.createdAt)
|
||||
date?.let { outputFormat.format(it) } ?: ""
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error formatting date: ${chatLine.createdAt}", e)
|
||||
""
|
||||
}
|
||||
|
||||
return ChatUiMessage(
|
||||
id = chatLine.id,
|
||||
message = chatLine.message,
|
||||
attachment = chatLine.attachment,
|
||||
status = chatLine.status,
|
||||
time = formattedTime,
|
||||
isSentByMe = chatLine.senderId == currentUserId
|
||||
)
|
||||
}
|
||||
|
||||
private fun convertChatLineToUiMessageHistory(chatItem: ChatItem): ChatUiMessage {
|
||||
// Format the timestamp for display
|
||||
val formattedTime = try {
|
||||
val inputFormat = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault())
|
||||
inputFormat.timeZone = TimeZone.getTimeZone("UTC")
|
||||
val outputFormat = java.text.SimpleDateFormat("HH:mm", Locale.getDefault())
|
||||
|
||||
val date = inputFormat.parse(chatItem.createdAt)
|
||||
date?.let { outputFormat.format(it) } ?: ""
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error formatting date: ${chatItem.createdAt}", e)
|
||||
""
|
||||
}
|
||||
|
||||
return ChatUiMessage(
|
||||
attachment = "",
|
||||
id = chatItem.id,
|
||||
message = chatItem.message,
|
||||
status = chatItem.status,
|
||||
time = formattedTime,
|
||||
isSentByMe = chatItem.senderId == currentUserId,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
// Disconnect Socket.IO when ViewModel is cleared
|
||||
socketService.disconnect()
|
||||
Log.d(TAG, "ViewModel cleared, Socket.IO disconnected")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Data class representing the UI state for the chat screen
|
||||
*/
|
||||
data class ChatUiState(
|
||||
val messages: List<ChatUiMessage> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val isSending: Boolean = false,
|
||||
val hasAttachment: Boolean = false,
|
||||
val isOtherUserTyping: Boolean = false,
|
||||
val error: String? = null,
|
||||
val connectionState: ConnectionState = ConnectionState.Disconnected(),
|
||||
|
||||
// Product info
|
||||
val productName: String = "",
|
||||
val productPrice: String = "",
|
||||
val productImageUrl: String = "",
|
||||
val productRating: Float = 0f,
|
||||
val storeName: String = ""
|
||||
)
|
||||
|
||||
/**
|
||||
* Data class representing a chat message in the UI
|
||||
*/
|
||||
data class ChatUiMessage(
|
||||
val id: Int,
|
||||
val message: String,
|
||||
val attachment: String,
|
||||
val status: String,
|
||||
val time: String,
|
||||
val isSentByMe: Boolean
|
||||
)
|
@ -0,0 +1,252 @@
|
||||
package com.alya.ecommerce_serang.ui.chat
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import com.alya.ecommerce_serang.BuildConfig
|
||||
import com.alya.ecommerce_serang.data.api.response.chat.ChatLine
|
||||
import com.alya.ecommerce_serang.utils.Constants
|
||||
import com.alya.ecommerce_serang.utils.SessionManager
|
||||
import com.google.gson.Gson
|
||||
import io.socket.client.IO
|
||||
import io.socket.client.Socket
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.json.JSONObject
|
||||
import java.net.URISyntaxException
|
||||
|
||||
class SocketIOService(
|
||||
private val sessionManager: SessionManager
|
||||
) {
|
||||
private val TAG = "SocketIOService"
|
||||
|
||||
// Socket.IO client
|
||||
private var socket: Socket? = null
|
||||
|
||||
// Connection state
|
||||
private var isConnected = false
|
||||
|
||||
// StateFlows for internal observing (these are needed for suspend functions in ViewModel)
|
||||
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected())
|
||||
val connectionState: StateFlow<ConnectionState> = _connectionState
|
||||
|
||||
private val _newMessages = MutableStateFlow<ChatLine?>(null)
|
||||
val newMessages: StateFlow<ChatLine?> = _newMessages
|
||||
|
||||
private val _typingStatus = MutableStateFlow<TypingStatus?>(null)
|
||||
val typingStatus: StateFlow<TypingStatus?> = _typingStatus
|
||||
|
||||
// LiveData for Activity/Fragment observing
|
||||
private val _connectionStateLiveData = MutableLiveData<ConnectionState>(ConnectionState.Disconnected())
|
||||
val connectionStateLiveData: LiveData<ConnectionState> = _connectionStateLiveData
|
||||
|
||||
private val _newMessagesLiveData = MutableLiveData<ChatLine?>()
|
||||
val newMessagesLiveData: LiveData<ChatLine?> = _newMessagesLiveData
|
||||
|
||||
private val _typingStatusLiveData = MutableLiveData<TypingStatus?>()
|
||||
val typingStatusLiveData: LiveData<TypingStatus?> = _typingStatusLiveData
|
||||
|
||||
/**
|
||||
* Initializes the Socket.IO client
|
||||
*/
|
||||
init {
|
||||
try {
|
||||
// Get token from SessionManager
|
||||
val token = sessionManager.getToken()
|
||||
|
||||
// Set up Socket.IO options with auth token
|
||||
val options = IO.Options().apply {
|
||||
forceNew = true
|
||||
reconnection = true
|
||||
reconnectionAttempts = 5
|
||||
reconnectionDelay = 3000
|
||||
|
||||
// Add auth information
|
||||
if (!token.isNullOrEmpty()) {
|
||||
auth = mapOf("token" to token)
|
||||
}
|
||||
}
|
||||
|
||||
// Create Socket.IO client
|
||||
socket = IO.socket(BuildConfig.BASE_URL, options)
|
||||
|
||||
// Set up event listeners
|
||||
setupSocketListeners()
|
||||
|
||||
Log.d(TAG, "Socket.IO initialized with token: $token")
|
||||
} catch (e: URISyntaxException) {
|
||||
Log.e(TAG, "Error initializing Socket.IO client", e)
|
||||
_connectionState.value = ConnectionState.Error("Error initializing Socket.IO: ${e.message}")
|
||||
_connectionStateLiveData.value = ConnectionState.Error("Error initializing Socket.IO: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up Socket.IO event listeners
|
||||
*/
|
||||
private fun setupSocketListeners() {
|
||||
socket?.let { socket ->
|
||||
// Connection events
|
||||
socket.on(Socket.EVENT_CONNECT) {
|
||||
Log.d(TAG, "Socket.IO connected")
|
||||
isConnected = true
|
||||
_connectionState.value = ConnectionState.Connected
|
||||
_connectionStateLiveData.postValue(ConnectionState.Connected)
|
||||
}
|
||||
|
||||
socket.on(Socket.EVENT_DISCONNECT) {
|
||||
Log.d(TAG, "Socket.IO disconnected")
|
||||
isConnected = false
|
||||
_connectionState.value = ConnectionState.Disconnected("Disconnected from server")
|
||||
_connectionStateLiveData.postValue(ConnectionState.Disconnected("Disconnected from server"))
|
||||
}
|
||||
|
||||
socket.on(Socket.EVENT_CONNECT_ERROR) { args ->
|
||||
val error = if (args.isNotEmpty() && args[0] != null) args[0].toString() else "Unknown error"
|
||||
Log.e(TAG, "Socket.IO connection error: $error")
|
||||
isConnected = false
|
||||
_connectionState.value = ConnectionState.Error("Connection error: $error")
|
||||
_connectionStateLiveData.postValue(ConnectionState.Error("Connection error: $error"))
|
||||
}
|
||||
|
||||
// Chat events
|
||||
socket.on(Constants.EVENT_NEW_MESSAGE) { args ->
|
||||
try {
|
||||
if (args.isNotEmpty() && args[0] != null) {
|
||||
val messageJson = args[0].toString()
|
||||
Log.d(TAG, "Received new message: $messageJson")
|
||||
val chatLine = Gson().fromJson(messageJson, ChatLine::class.java)
|
||||
_newMessages.value = chatLine
|
||||
_newMessagesLiveData.postValue(chatLine)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error parsing new message event", e)
|
||||
}
|
||||
}
|
||||
|
||||
socket.on(Constants.EVENT_TYPING) { args ->
|
||||
try {
|
||||
if (args.isNotEmpty() && args[0] != null) {
|
||||
val typingData = args[0] as JSONObject
|
||||
val userId = typingData.getInt("userId")
|
||||
val roomId = typingData.getInt("roomId")
|
||||
val isTyping = typingData.getBoolean("isTyping")
|
||||
|
||||
Log.d(TAG, "Received typing status: User $userId in room $roomId is typing: $isTyping")
|
||||
val status = TypingStatus(userId, roomId, isTyping)
|
||||
_typingStatus.value = status
|
||||
_typingStatusLiveData.postValue(status)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error parsing typing event", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects to the Socket.IO server
|
||||
*/
|
||||
fun connect() {
|
||||
if (isConnected) return
|
||||
|
||||
Log.d(TAG, "Connecting to Socket.IO server...")
|
||||
_connectionState.value = ConnectionState.Connecting
|
||||
_connectionStateLiveData.value = ConnectionState.Connecting
|
||||
socket?.connect()
|
||||
}
|
||||
|
||||
/**
|
||||
* Joins a specific chat room
|
||||
*/
|
||||
fun joinRoom() {
|
||||
if (!isConnected) {
|
||||
connect()
|
||||
return
|
||||
}
|
||||
|
||||
// Get user ID from SessionManager
|
||||
val userId = sessionManager.getUserId()
|
||||
if (userId.isNullOrEmpty()) {
|
||||
Log.e(TAG, "Cannot join room: User ID is null or empty")
|
||||
return
|
||||
}
|
||||
|
||||
// Join the room using the current user's ID
|
||||
socket?.emit("joinRoom", userId)
|
||||
Log.d(TAG, "Joined room for user: $userId")
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits a new message event
|
||||
*/
|
||||
fun sendMessage(message: ChatLine) {
|
||||
if (!isConnected) {
|
||||
connect()
|
||||
return
|
||||
}
|
||||
|
||||
val messageJson = Gson().toJson(message)
|
||||
socket?.emit(Constants.EVENT_NEW_MESSAGE, messageJson)
|
||||
Log.d(TAG, "Sent message via Socket.IO: $messageJson")
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends typing status update
|
||||
*/
|
||||
fun sendTypingStatus(roomId: Int, isTyping: Boolean) {
|
||||
if (!isConnected) return
|
||||
|
||||
// Get user ID from SessionManager
|
||||
val userId = sessionManager.getUserId()?.toIntOrNull()
|
||||
if (userId == null) {
|
||||
Log.e(TAG, "Cannot send typing status: User ID is null or invalid")
|
||||
return
|
||||
}
|
||||
|
||||
val typingData = JSONObject().apply {
|
||||
put("userId", userId)
|
||||
put("roomId", roomId)
|
||||
put("isTyping", isTyping)
|
||||
}
|
||||
|
||||
socket?.emit(Constants.EVENT_TYPING, typingData)
|
||||
Log.d(TAG, "Sent typing status: User $userId in room $roomId is typing: $isTyping")
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnects from the Socket.IO server
|
||||
*/
|
||||
fun disconnect() {
|
||||
Log.d(TAG, "Disconnecting from Socket.IO server...")
|
||||
socket?.disconnect()
|
||||
isConnected = false
|
||||
_connectionState.value = ConnectionState.Disconnected("Disconnected by user")
|
||||
_connectionStateLiveData.postValue(ConnectionState.Disconnected("Disconnected by user"))
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the socket is connected
|
||||
*/
|
||||
val isSocketConnected: Boolean
|
||||
get() = isConnected
|
||||
}
|
||||
|
||||
/**
|
||||
* Sealed class representing connection states
|
||||
*/
|
||||
sealed class ConnectionState {
|
||||
object Connecting : ConnectionState()
|
||||
object Connected : ConnectionState()
|
||||
data class Disconnected(val reason: String = "") : ConnectionState()
|
||||
data class Error(val message: String) : ConnectionState()
|
||||
}
|
||||
|
||||
/**
|
||||
* Data class for typing status events
|
||||
*/
|
||||
data class TypingStatus(
|
||||
val userId: Int,
|
||||
val roomId: Int,
|
||||
val isTyping: Boolean
|
||||
)
|
@ -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()
|
||||
}
|
||||
|
@ -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"
|
||||
}
|
11
app/src/main/res/drawable/bg_message_received.xml
Normal file
11
app/src/main/res/drawable/bg_message_received.xml
Normal file
@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#E0E0E0" />
|
||||
<corners android:radius="16dp" />
|
||||
<padding
|
||||
android:bottom="8dp"
|
||||
android:left="12dp"
|
||||
android:right="12dp"
|
||||
android:top="8dp" />
|
||||
</shape>
|
11
app/src/main/res/drawable/bg_message_sent.xml
Normal file
11
app/src/main/res/drawable/bg_message_sent.xml
Normal file
@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#E1F5FE" />
|
||||
<corners android:radius="16dp" />
|
||||
<padding
|
||||
android:bottom="8dp"
|
||||
android:left="12dp"
|
||||
android:right="12dp"
|
||||
android:top="8dp" />
|
||||
</shape>
|
277
app/src/main/res/layout/activity_chat.xml
Normal file
277
app/src/main/res/layout/activity_chat.xml
Normal file
@ -0,0 +1,277 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/main"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".ui.chat.ChatActivity">
|
||||
|
||||
<androidx.appcompat.widget.Toolbar
|
||||
android:id="@+id/chatToolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="#FFFFFF"
|
||||
android:elevation="4dp"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btnBack"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="Back"
|
||||
android:src="@drawable/ic_back_24"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent" />
|
||||
|
||||
<de.hdodenhof.circleimageview.CircleImageView
|
||||
android:id="@+id/imgProfile"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:src="@drawable/ic_person"
|
||||
app:layout_constraintStart_toEndOf="@+id/btnBack"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvStoreName"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:text="SnackEnak"
|
||||
android:textColor="@android:color/black"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
app:layout_constraintStart_toEndOf="@+id/imgProfile"
|
||||
app:layout_constraintTop_toTopOf="@+id/imgProfile"
|
||||
app:layout_constraintEnd_toStartOf="@+id/btnOptions" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvLastActive"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:text="Aktif 3 jam lalu"
|
||||
android:textColor="#888888"
|
||||
android:textSize="12sp"
|
||||
app:layout_constraintStart_toEndOf="@+id/imgProfile"
|
||||
app:layout_constraintTop_toBottomOf="@+id/tvStoreName"
|
||||
app:layout_constraintEnd_toEndOf="@+id/tvStoreName" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btnOptions"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="Options"
|
||||
android:src="@drawable/ic_arrow_right"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</androidx.appcompat.widget.Toolbar>
|
||||
|
||||
<!-- Product Card -->
|
||||
<androidx.cardview.widget.CardView
|
||||
android:id="@+id/cardProduct"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="8dp"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:cardElevation="4dp"
|
||||
app:layout_constraintTop_toBottomOf="@+id/chatToolbar">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="16dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/imgProduct"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:scaleType="centerCrop"
|
||||
android:src="@drawable/placeholder_image"
|
||||
app:layout_constraintDimensionRatio="1:1"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvProductName"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="Keripik Balado"
|
||||
android:textColor="@android:color/black"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold"
|
||||
app:layout_constraintTop_toBottomOf="@+id/imgProduct" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvProductPrice"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="Rp65.000"
|
||||
android:textColor="@android:color/black"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
app:layout_constraintTop_toBottomOf="@+id/tvProductName" />
|
||||
|
||||
<RatingBar
|
||||
android:id="@+id/ratingBar"
|
||||
style="?android:attr/ratingBarStyleSmall"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:isIndicator="true"
|
||||
android:numStars="5"
|
||||
android:rating="5.0"
|
||||
android:stepSize="0.1"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/tvProductPrice" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvRating"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="4dp"
|
||||
android:text="5.0"
|
||||
android:textColor="#F9A825"
|
||||
android:textSize="12sp"
|
||||
app:layout_constraintBottom_toBottomOf="@+id/ratingBar"
|
||||
app:layout_constraintStart_toEndOf="@+id/ratingBar"
|
||||
app:layout_constraintTop_toTopOf="@+id/ratingBar" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvSellerName"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="SnackEnak"
|
||||
android:textColor="#666666"
|
||||
android:textSize="12sp"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/ratingBar" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<!-- Chat messages -->
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recyclerChat"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:clipToPadding="false"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="8dp"
|
||||
app:layout_constraintBottom_toTopOf="@+id/layoutChatInput"
|
||||
app:layout_constraintTop_toBottomOf="@+id/cardProduct" />
|
||||
|
||||
<!-- Chat input area -->
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutChatInput"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="#FFFFFF"
|
||||
android:elevation="8dp"
|
||||
android:orientation="horizontal"
|
||||
android:padding="8dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent">
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btnAttachment"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="Attachment"
|
||||
android:src="@drawable/ic_attachment" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/editTextMessage"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_weight="1"
|
||||
android:hint="Tulis pesan"
|
||||
android:inputType="textMultiLine"
|
||||
android:maxLines="4"
|
||||
android:minHeight="40dp"
|
||||
android:padding="8dp" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btnSend"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="Send"
|
||||
android:src="@drawable/ic_send" />
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvTypingIndicator"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="4dp"
|
||||
android:text="User is typing..."
|
||||
android:textColor="#666666"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="italic"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toTopOf="@+id/layoutChatInput"
|
||||
tools:visibility="visible" />
|
||||
<!-- Bottom navigation -->
|
||||
<LinearLayout
|
||||
android:id="@+id/bottomNavigation"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="#FFFFFF"
|
||||
android:elevation="8dp"
|
||||
android:orientation="horizontal"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="parent">
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btnHome"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_weight="1"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="Home"
|
||||
android:src="@drawable/ic_home" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btnMenu"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_weight="1"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="Menu"
|
||||
android:src="@drawable/ic_menu" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btnNotification"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_weight="1"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="Notification"
|
||||
android:src="@drawable/ic_notification" />
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
@ -1,13 +1,265 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
tools:context=".ui.chat.ChatFragment">
|
||||
|
||||
<TextView
|
||||
<androidx.appcompat.widget.Toolbar
|
||||
android:id="@+id/chatToolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:text="Hello" />
|
||||
android:layout_height="wrap_content"
|
||||
android:background="#FFFFFF"
|
||||
android:elevation="4dp"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
</FrameLayout>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btnBack"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="Back"
|
||||
android:src="@drawable/ic_back_24"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent" />
|
||||
|
||||
<de.hdodenhof.circleimageview.CircleImageView
|
||||
android:id="@+id/imgProfile"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:src="@drawable/placeholder_image"
|
||||
app:layout_constraintStart_toEndOf="@+id/btnBack"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvStoreName"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:text="SnackEnak"
|
||||
android:textColor="@android:color/black"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
app:layout_constraintStart_toEndOf="@+id/imgProfile"
|
||||
app:layout_constraintTop_toTopOf="@+id/imgProfile"
|
||||
app:layout_constraintEnd_toStartOf="@+id/btnOptions" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvLastActive"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:text="Aktif 3 jam lalu"
|
||||
android:textColor="#888888"
|
||||
android:textSize="12sp"
|
||||
app:layout_constraintStart_toEndOf="@+id/imgProfile"
|
||||
app:layout_constraintTop_toBottomOf="@+id/tvStoreName"
|
||||
app:layout_constraintEnd_toEndOf="@+id/tvStoreName" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btnOptions"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="Options"
|
||||
android:src="@drawable/ic_arrow_right"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</androidx.appcompat.widget.Toolbar>
|
||||
|
||||
<!-- Connection Status -->
|
||||
<TextView
|
||||
android:id="@+id/tvConnectionStatus"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="#FFC107"
|
||||
android:padding="4dp"
|
||||
android:textAlignment="center"
|
||||
android:textColor="#000000"
|
||||
android:text="Connecting..."
|
||||
android:visibility="gone"
|
||||
app:layout_constraintTop_toBottomOf="@+id/chatToolbar"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<!-- Product Card -->
|
||||
<androidx.cardview.widget.CardView
|
||||
android:id="@+id/cardProduct"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="8dp"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:cardElevation="4dp"
|
||||
app:layout_constraintTop_toBottomOf="@+id/tvConnectionStatus">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="16dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/imgProduct"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:scaleType="centerCrop"
|
||||
android:src="@drawable/placeholder_image"
|
||||
app:layout_constraintDimensionRatio="1:1"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvProductName"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="Keripik Balado"
|
||||
android:textColor="@android:color/black"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold"
|
||||
app:layout_constraintTop_toBottomOf="@+id/imgProduct" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvProductPrice"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="Rp65.000"
|
||||
android:textColor="@android:color/black"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
app:layout_constraintTop_toBottomOf="@+id/tvProductName" />
|
||||
|
||||
<RatingBar
|
||||
android:id="@+id/ratingBar"
|
||||
style="?android:attr/ratingBarStyleSmall"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:isIndicator="true"
|
||||
android:numStars="5"
|
||||
android:rating="5.0"
|
||||
android:stepSize="0.1"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/tvProductPrice" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvRating"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="4dp"
|
||||
android:text="5.0"
|
||||
android:textColor="#F9A825"
|
||||
android:textSize="12sp"
|
||||
app:layout_constraintBottom_toBottomOf="@+id/ratingBar"
|
||||
app:layout_constraintStart_toEndOf="@+id/ratingBar"
|
||||
app:layout_constraintTop_toTopOf="@+id/ratingBar" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvSellerName"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="SnackEnak"
|
||||
android:textColor="#666666"
|
||||
android:textSize="12sp"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/ratingBar" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<ProgressBar
|
||||
android:id="@+id/progressBar"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<!-- Chat messages -->
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recyclerChat"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:clipToPadding="false"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="8dp"
|
||||
app:layout_constraintBottom_toTopOf="@+id/tvTypingIndicator"
|
||||
app:layout_constraintTop_toBottomOf="@+id/cardProduct" />
|
||||
|
||||
<!-- Typing indicator -->
|
||||
<TextView
|
||||
android:id="@+id/tvTypingIndicator"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="4dp"
|
||||
android:text="User is typing..."
|
||||
android:textColor="#666666"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="italic"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toTopOf="@+id/layoutChatInput"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<!-- Chat input area -->
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutChatInput"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="#FFFFFF"
|
||||
android:elevation="8dp"
|
||||
android:orientation="horizontal"
|
||||
android:padding="8dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent">
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btnAttachment"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="Attachment"
|
||||
android:src="@drawable/ic_attachment" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/editTextMessage"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_weight="1"
|
||||
android:background="@drawable/bg_edit_text_rounded"
|
||||
android:hint="Tulis pesan"
|
||||
android:inputType="textMultiLine"
|
||||
android:maxLines="4"
|
||||
android:minHeight="40dp"
|
||||
android:padding="8dp" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btnSend"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="Send"
|
||||
android:src="@drawable/ic_send" />
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
13
app/src/main/res/layout/fragment_chat_list.xml
Normal file
13
app/src/main/res/layout/fragment_chat_list.xml
Normal file
@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".ui.chat.ChatListFragment">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:text="Hello" />
|
||||
|
||||
</FrameLayout>
|
64
app/src/main/res/layout/item_message_received.xml
Normal file
64
app/src/main/res/layout/item_message_received.xml
Normal file
@ -0,0 +1,64 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingStart="8dp"
|
||||
android:paddingTop="4dp"
|
||||
android:paddingEnd="60dp"
|
||||
android:paddingBottom="4dp">
|
||||
|
||||
<de.hdodenhof.circleimageview.CircleImageView
|
||||
android:id="@+id/imgAvatar"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:src="@drawable/profile_placeholder"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="@+id/layoutMessage" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutMessage"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:background="@drawable/bg_message_received"
|
||||
android:orientation="vertical"
|
||||
app:layout_constraintStart_toEndOf="@+id/imgAvatar"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvMessage"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:maxWidth="270dp"
|
||||
android:textColor="@android:color/black"
|
||||
android:textSize="14sp"
|
||||
tools:text="Boleh banget teh. Teteh mau nawar berapa?" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/imgAttachment"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:adjustViewBounds="true"
|
||||
android:maxWidth="220dp"
|
||||
android:scaleType="fitCenter"
|
||||
android:visibility="gone"
|
||||
tools:src="@drawable/placeholder_image"
|
||||
tools:visibility="visible" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvTimestamp"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:textColor="#888888"
|
||||
android:textSize="10sp"
|
||||
app:layout_constraintStart_toStartOf="@+id/layoutMessage"
|
||||
app:layout_constraintTop_toBottomOf="@+id/layoutMessage"
|
||||
tools:text="12:30" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
65
app/src/main/res/layout/item_message_sent.xml
Normal file
65
app/src/main/res/layout/item_message_sent.xml
Normal file
@ -0,0 +1,65 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingStart="60dp"
|
||||
android:paddingTop="4dp"
|
||||
android:paddingEnd="8dp"
|
||||
android:paddingBottom="4dp">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutMessage"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/bg_message_sent"
|
||||
android:orientation="vertical"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvMessage"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:maxWidth="270dp"
|
||||
android:textColor="@android:color/black"
|
||||
android:textSize="14sp"
|
||||
tools:text="Beli 1, 60 rb bisa teh?" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/imgAttachment"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:adjustViewBounds="true"
|
||||
android:maxWidth="220dp"
|
||||
android:scaleType="fitCenter"
|
||||
android:visibility="gone"
|
||||
tools:src="@drawable/placeholder_image"
|
||||
tools:visibility="visible" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvTimestamp"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:textColor="#888888"
|
||||
android:textSize="10sp"
|
||||
app:layout_constraintEnd_toStartOf="@+id/imgStatus"
|
||||
app:layout_constraintTop_toBottomOf="@+id/layoutMessage"
|
||||
tools:text="12:30" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/imgStatus"
|
||||
android:layout_width="16dp"
|
||||
android:layout_height="16dp"
|
||||
android:src="@drawable/placeholder_image"
|
||||
app:layout_constraintBottom_toBottomOf="@+id/tvTimestamp"
|
||||
app:layout_constraintEnd_toEndOf="@+id/layoutMessage"
|
||||
app:layout_constraintTop_toTopOf="@+id/tvTimestamp" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
@ -21,9 +21,9 @@
|
||||
tools:layout="@layout/fragment_profile" />
|
||||
<fragment
|
||||
android:id="@+id/chatFragment"
|
||||
android:name="com.alya.ecommerce_serang.ui.chat.ChatFragment"
|
||||
android:name="com.alya.ecommerce_serang.ui.chat.ChatListFragment"
|
||||
android:label="fragment_chat"
|
||||
tools:layout="@layout/fragment_chat" />
|
||||
tools:layout="@layout/fragment_chat_list" />
|
||||
<fragment
|
||||
android:id="@+id/searchHomeFragment"
|
||||
android:name="com.alya.ecommerce_serang.ui.home.SearchHomeFragment"
|
||||
|
Reference in New Issue
Block a user