mirror of
https://github.com/shaulascr/ecommerce_serang.git
synced 2025-08-13 10:42:21 +00:00
fixing chat activity and fragment
This commit is contained in:
@ -11,7 +11,7 @@
|
|||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||||
android:maxSdkVersion="32" />
|
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" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
|
||||||
@ -28,15 +28,19 @@
|
|||||||
android:theme="@style/Theme.Ecommerce_serang"
|
android:theme="@style/Theme.Ecommerce_serang"
|
||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
tools:targetApi="31">
|
tools:targetApi="31">
|
||||||
<!-- <provider-->
|
<activity
|
||||||
<!-- android:name="androidx.startup.InitializationProvider"-->
|
android:name=".ui.chat.ChatActivity"
|
||||||
<!-- android:authorities="${applicationId}.androidx-startup"-->
|
android:exported="false" />
|
||||||
<!-- tools:node="remove" />-->
|
<!-- <provider -->
|
||||||
|
<!-- android:name="androidx.startup.InitializationProvider" -->
|
||||||
|
<!-- android:authorities="${applicationId}.androidx-startup" -->
|
||||||
|
<!-- tools:node="remove" /> -->
|
||||||
<service
|
<service
|
||||||
android:name=".ui.notif.SimpleWebSocketService"
|
android:name=".ui.notif.SimpleWebSocketService"
|
||||||
android:enabled="true"
|
android:enabled="true"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="dataSync" />
|
android:foregroundServiceType="dataSync" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.notif.NotificationActivity"
|
android:name=".ui.notif.NotificationActivity"
|
||||||
android:exported="false" />
|
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.RegisterRequest
|
||||||
import com.alya.ecommerce_serang.data.api.dto.SearchRequest
|
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.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.ViewStoreProductsResponse
|
||||||
import com.alya.ecommerce_serang.data.api.response.auth.LoginResponse
|
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.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.AddCartResponse
|
||||||
import com.alya.ecommerce_serang.data.api.response.cart.ListCartResponse
|
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.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.AddEvidenceResponse
|
||||||
import com.alya.ecommerce_serang.data.api.response.order.ComplaintResponse
|
import com.alya.ecommerce_serang.data.api.response.order.ComplaintResponse
|
||||||
import com.alya.ecommerce_serang.data.api.response.order.CompletedOrderResponse
|
import com.alya.ecommerce_serang.data.api.response.order.CompletedOrderResponse
|
||||||
@ -209,4 +213,23 @@ interface ApiService {
|
|||||||
|
|
||||||
@GET("search")
|
@GET("search")
|
||||||
suspend fun getSearchHistory(): Response<SearchHistoryResponse>
|
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.LoginRequest
|
||||||
import com.alya.ecommerce_serang.data.api.dto.OtpRequest
|
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.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.dto.UserProfile
|
||||||
import com.alya.ecommerce_serang.data.api.response.auth.LoginResponse
|
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.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 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) {
|
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
|
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.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.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
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.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.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() {
|
class ChatFragment : Fragment() {
|
||||||
|
|
||||||
companion object {
|
private var _binding: FragmentChatBinding? = null
|
||||||
fun newInstance() = ChatFragment()
|
private val binding get() = _binding!!
|
||||||
}
|
|
||||||
|
|
||||||
private val viewModel: ChatViewModel by viewModels()
|
private val viewModel: ChatViewModel by viewModels()
|
||||||
|
private val args: ChatFragmentArgs by navArgs()
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
private lateinit var chatAdapter: ChatAdapter
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
|
|
||||||
// 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(
|
override fun onCreateView(
|
||||||
inflater: LayoutInflater, container: ViewGroup?,
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
savedInstanceState: Bundle?
|
savedInstanceState: Bundle?
|
||||||
): View {
|
): 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.api.retrofit.ApiService
|
||||||
import com.alya.ecommerce_serang.data.repository.MyStoreRepository
|
import com.alya.ecommerce_serang.data.repository.MyStoreRepository
|
||||||
import com.alya.ecommerce_serang.databinding.ActivityMyStoreBinding
|
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.balance.BalanceActivity
|
||||||
import com.alya.ecommerce_serang.ui.profile.mystore.product.ProductActivity
|
import com.alya.ecommerce_serang.ui.profile.mystore.product.ProductActivity
|
||||||
import com.alya.ecommerce_serang.ui.profile.mystore.profile.DetailStoreProfileActivity
|
import com.alya.ecommerce_serang.ui.profile.mystore.profile.DetailStoreProfileActivity
|
||||||
@ -124,7 +124,7 @@ class MyStoreActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
binding.layoutInbox.setOnClickListener {
|
binding.layoutInbox.setOnClickListener {
|
||||||
supportFragmentManager.beginTransaction()
|
supportFragmentManager.beginTransaction()
|
||||||
.replace(android.R.id.content, ChatFragment())
|
.replace(android.R.id.content, ChatListFragment())
|
||||||
.addToBackStack(null)
|
.addToBackStack(null)
|
||||||
.commit()
|
.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"?>
|
<?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"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
tools:context=".ui.chat.ChatFragment">
|
tools:context=".ui.chat.ChatFragment">
|
||||||
|
|
||||||
<TextView
|
<androidx.appcompat.widget.Toolbar
|
||||||
|
android:id="@+id/chatToolbar"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="wrap_content"
|
||||||
android:text="Hello" />
|
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" />
|
tools:layout="@layout/fragment_profile" />
|
||||||
<fragment
|
<fragment
|
||||||
android:id="@+id/chatFragment"
|
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"
|
android:label="fragment_chat"
|
||||||
tools:layout="@layout/fragment_chat" />
|
tools:layout="@layout/fragment_chat_list" />
|
||||||
<fragment
|
<fragment
|
||||||
android:id="@+id/searchHomeFragment"
|
android:id="@+id/searchHomeFragment"
|
||||||
android:name="com.alya.ecommerce_serang.ui.home.SearchHomeFragment"
|
android:name="com.alya.ecommerce_serang.ui.home.SearchHomeFragment"
|
||||||
|
Reference in New Issue
Block a user