update icon, chat, balance, and count order

This commit is contained in:
shaulascr
2025-06-25 17:19:44 +07:00
parent 25764c6b89
commit 0cbaecd0cd
29 changed files with 488 additions and 262 deletions

View File

@ -5,6 +5,7 @@ import com.alya.ecommerce_serang.data.api.dto.Store
import com.alya.ecommerce_serang.data.api.response.auth.ListStoreTypeResponse
import com.alya.ecommerce_serang.data.api.response.customer.product.StoreResponse
import com.alya.ecommerce_serang.data.api.response.store.profile.StoreDataResponse
import com.alya.ecommerce_serang.data.api.response.store.sells.OrderListResponse
import com.alya.ecommerce_serang.data.api.retrofit.ApiService
import okhttp3.MultipartBody
import okhttp3.RequestBody
@ -71,4 +72,90 @@ class MyStoreRepository(private val apiService: ApiService) {
street, subdistrict, detail, postalCode, latitude, longitude, userPhone, storeType, storeimg
)
}
suspend fun getSellList(status: String): Result<OrderListResponse> {
return try {
Log.d("SellsRepository", "Add Evidence : $status")
val response = apiService.getSellList(status)
if (response.isSuccessful) {
val allListSell = response.body()
if (allListSell != null) {
Log.d("SellsRepository", "Add Evidence successfully: ${allListSell.message}")
Result.Success(allListSell)
} else {
Log.e("SellsRepository", "Response body was null")
Result.Error(Exception("Empty response from server"))
}
} else {
val errorBody = response.errorBody()?.string() ?: "Unknown error"
Log.e("SellsRepository", "Error Add Evidence : $errorBody")
Result.Error(Exception(errorBody))
}
} catch (e: Exception) {
Log.e("SellsRepository", "Exception Add Evidence ", e)
Result.Error(e)
}
}
suspend fun getBalance(): Result<com.alya.ecommerce_serang.data.api.response.store.StoreResponse> {
return try {
val response = apiService.getMyStoreData()
if (response.isSuccessful) {
val body = response.body()
?: return Result.Error(IllegalStateException("Response body is null"))
// Validate the balance field
val balanceRaw = body.store.balance
balanceRaw.toDoubleOrNull()
?: return Result.Error(NumberFormatException("Invalid balance format: $balanceRaw"))
Result.Success(body)
} else {
Result.Error(
Exception("Failed to load balance: ${response.code()} ${response.message()}")
)
}
} catch (e: Exception) {
Log.e("MyStoreRepository", "Error fetching balance", e)
Result.Error(e)
}
}
// private fun fetchBalance() {
// showLoading(true)
// lifecycleScope.launch {
// try {
// val response = ApiConfig.getApiService(sessionManager).getMyStoreData()
// if (response.isSuccessful && response.body() != null) {
// val storeData = response.body()!!
// val balance = storeData.store.balance
//
// // Format the balance
// try {
// val balanceValue = balance.toDouble()
// binding.tvBalance.text = String.format("Rp%,.0f", balanceValue)
// } catch (e: Exception) {
// binding.tvBalance.text = "Rp$balance"
// }
// } else {
// Toast.makeText(
// this@BalanceActivity,
// "Gagal memuat data saldo: ${response.message()}",
// Toast.LENGTH_SHORT
// ).show()
// }
// } catch (e: Exception) {
// Log.e(TAG, "Error fetching balance", e)
// Toast.makeText(
// this@BalanceActivity,
// "Error: ${e.message}",
// Toast.LENGTH_SHORT
// ).show()
// } finally {
// showLoading(false)
// }
// }
// }
}

View File

@ -63,6 +63,8 @@ class ChatActivity : AppCompatActivity() {
// For image attachment
private var tempImageUri: Uri? = null
private var imageAttach = false
// Typing indicator handler
private val typingHandler = android.os.Handler(android.os.Looper.getMainLooper())
private val stopTypingRunnable = Runnable {
@ -269,6 +271,7 @@ class ChatActivity : AppCompatActivity() {
}
// Options button
binding.btnOptions.visibility = View.GONE
binding.btnOptions.setOnClickListener {
showOptionsMenu()
}
@ -281,6 +284,7 @@ class ChatActivity : AppCompatActivity() {
// This will automatically handle product attachment if enabled
viewModel.sendMessage(message)
binding.editTextMessage.text.clear()
binding.layoutAttachImage.visibility = View.GONE
// Instantly scroll to show new message
binding.recyclerChat.postDelayed({
@ -291,24 +295,33 @@ class ChatActivity : AppCompatActivity() {
// Attachment button
binding.btnAttachment.setOnClickListener {
this.currentFocus?.let { view ->
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
imm?.hideSoftInputFromWindow(view.windowToken, 0)
}
checkPermissionsAndShowImagePicker()
}
binding.btnCloseChat.setOnClickListener{
binding.layoutAttachImage.visibility = View.GONE
imageAttach = false
viewModel.clearSelectedImage()
}
// Product card click to enable/disable product attachment
binding.productContainer.setOnClickListener {
toggleProductAttachment()
}
}
private fun toggleProductAttachment() {
val currentState = viewModel.state.value
if (currentState?.hasProductAttachment == true) {
// Disable product attachment
viewModel.disableProductAttachment()
updateProductAttachmentUI(false)
Toast.makeText(this, "Product attachment disabled", Toast.LENGTH_SHORT).show()
} else {
// Enable product attachment
viewModel.enableProductAttachment()
updateProductAttachmentUI(true)
Toast.makeText(this, "Product will be attached to your next message", Toast.LENGTH_SHORT).show()
@ -405,7 +418,7 @@ class ChatActivity : AppCompatActivity() {
}
}
// Update product info
// layout attach product
if (!state.productName.isNullOrEmpty()) {
binding.tvProductName.text = state.productName
binding.tvProductPrice.text = state.productPrice
@ -440,15 +453,11 @@ class ChatActivity : AppCompatActivity() {
// Update attachment hint
if (state.hasAttachment) {
binding.editTextMessage.hint = getString(R.string.image_attached)
binding.layoutAttachImage.visibility = View.VISIBLE
} else {
binding.editTextMessage.hint = getString(R.string.write_message)
}
// Show typing indicator
binding.tvTypingIndicator.visibility =
if (state.isOtherUserTyping) View.VISIBLE else View.GONE
// Show error if any
state.error?.let { error ->
Toast.makeText(this@ChatActivity, error, Toast.LENGTH_SHORT).show()
@ -459,7 +468,7 @@ class ChatActivity : AppCompatActivity() {
private fun updateInputHint(state: ChatUiState) {
binding.editTextMessage.hint = when {
state.hasAttachment -> getString(R.string.image_attached)
state.hasAttachment -> getString(R.string.write_message)
state.hasProductAttachment -> "Type your message (product will be attached)"
else -> getString(R.string.write_message)
}
@ -504,6 +513,7 @@ class ChatActivity : AppCompatActivity() {
getString(R.string.cancel)
)
AlertDialog.Builder(this)
.setTitle(getString(R.string.options))
.setItems(options) { dialog, which ->
@ -578,7 +588,21 @@ class ChatActivity : AppCompatActivity() {
private fun handleSelectedImage(uri: Uri) {
try {
Log.d(TAG, "Processing selected image: $uri")
Log.d(TAG, "Processing selected image: ${uri.toString()}")
imageAttach = true
binding.layoutAttachImage.visibility = View.VISIBLE
val fullImageUrl = when (val img = uri.toString()) {
is String -> {
if (img.startsWith("/")) BASE_URL + img.substring(1) else img
}
else -> R.drawable.placeholder_image
}
Glide.with(this)
.load(fullImageUrl)
.placeholder(R.drawable.placeholder_image)
.into(binding.ivAttach)
Log.d(TAG, "Display attach image: $uri")
// Always use the copy-to-cache approach for reliability
contentResolver.openInputStream(uri)?.use { inputStream ->
@ -598,6 +622,7 @@ class ChatActivity : AppCompatActivity() {
Log.d(TAG, "Image processed successfully: ${outputFile.absolutePath}, size: ${outputFile.length()}")
viewModel.setSelectedImageFile(outputFile)
Toast.makeText(this, "Image selected", Toast.LENGTH_SHORT).show()
} else {
Log.e(TAG, "Failed to create image file")
@ -681,25 +706,4 @@ class ChatActivity : AppCompatActivity() {
context.startActivity(intent)
}
}
}
//if implement typing status
// 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)
// }
// }
// }
}

View File

@ -23,6 +23,28 @@ import java.util.Locale
import java.util.TimeZone
import javax.inject.Inject
/**
* ChatViewModel - Manages chat functionality for both buyers and store owners
*
* ARCHITECTURE OVERVIEW:
* - Handles real-time messaging via Socket.IO
* - Manages chat state using LiveData/MutableLiveData pattern
* - Supports multiple message types: TEXT, IMAGE, PRODUCT
* - Maintains separate flows for buyer and store owner chat
*
* KEY RESPONSIBILITIES:
* 1. Socket connection management and real-time message handling
* 2. Message sending/receiving with different attachment types
* 3. Chat history loading and message status updates
* 4. Product attachment functionality for commerce integration
* 5. User session management and authentication
*
* STATE MANAGEMENT PATTERN:
* - All UI state updates go through updateState() helper function
* - State updates are atomic and follow immutable pattern
* - Error states are cleared explicitly via clearError()
*/
@HiltViewModel
class ChatViewModel @Inject constructor(
private val chatRepository: ChatRepository,
@ -730,6 +752,19 @@ class ChatViewModel @Inject constructor(
Log.d(TAG, "Image attachment ${if (file != null) "selected: ${file.name}" else "cleared"}")
}
fun clearSelectedImage() {
Log.d(TAG, "Clearing selected image attachment")
selectedImageFile?.let { file ->
Log.d(TAG, "Clearing image file: ${file.name}")
}
selectedImageFile = null
updateState { it.copy(hasAttachment = false) }
Log.d(TAG, "Image attachment cleared successfully")
}
// convert form chatLine api to UI chat messages
private fun convertChatLineToUiMessage(chatLine: ChatLine): ChatUiMessage {
val formattedTime = formatTimestamp(chatLine.createdAt)

View File

@ -7,12 +7,14 @@ import android.widget.Toast
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.alya.ecommerce_serang.BuildConfig.BASE_URL
import com.alya.ecommerce_serang.R
import com.alya.ecommerce_serang.data.api.dto.Store
import com.alya.ecommerce_serang.data.api.retrofit.ApiConfig
import com.alya.ecommerce_serang.data.api.retrofit.ApiService
import com.alya.ecommerce_serang.data.repository.MyStoreRepository
import com.alya.ecommerce_serang.data.repository.Result
import com.alya.ecommerce_serang.databinding.ActivityMyStoreBinding
import com.alya.ecommerce_serang.ui.profile.mystore.balance.BalanceActivity
import com.alya.ecommerce_serang.ui.profile.mystore.chat.ChatListStoreActivity
@ -24,6 +26,7 @@ import com.alya.ecommerce_serang.utils.BaseViewModelFactory
import com.alya.ecommerce_serang.utils.SessionManager
import com.alya.ecommerce_serang.utils.viewmodel.MyStoreViewModel
import com.bumptech.glide.Glide
import kotlinx.coroutines.launch
class MyStoreActivity : AppCompatActivity() {
private lateinit var binding: ActivityMyStoreBinding
@ -66,6 +69,9 @@ class MyStoreActivity : AppCompatActivity() {
}
setUpClickListeners()
getCountOrder()
viewModel.fetchBalance()
fetchBalance()
}
private fun myStoreProfileOverview(store: Store){
@ -147,6 +153,46 @@ class MyStoreActivity : AppCompatActivity() {
}
}
private fun getCountOrder(){
lifecycleScope.launch {
try {
val allCounts = viewModel.getAllStatusCounts()
val totalUnpaid = allCounts["unpaid"]
val totalPaid = allCounts["paid"]
val totalProcessed = allCounts["processed"]
Log.d("MyStoreActivity",
"Total orders: unpaid=$totalUnpaid, processed=$totalProcessed, paid=$totalPaid")
binding.tvNumPesananMasuk.text = totalUnpaid.toString()
binding.tvNumPembayaran.text = totalPaid.toString()
binding.tvNumPerluDikirim.text = totalProcessed.toString()
} catch (e:Exception){
Log.e("MyStoreActivity", "Error getting order counts: ${e.message}")
}
}
}
private fun fetchBalance(){
viewModel.balanceResult.observe(this){result ->
when (result) {
is com.alya.ecommerce_serang.data.repository.Result.Loading ->
null
// binding.progressBar.isVisible = true
is com.alya.ecommerce_serang.data.repository.Result.Success ->
viewModel.formattedBalance.observe(this) {
binding.tvBalance.text = it
}
is Result.Error -> {
// binding.progressBar.isVisible = false
Log.e(
"MyStoreActivity",
"Gagal memuat saldo: ${result.exception.localizedMessage}"
)
}
}
}
}
companion object {
private const val PROFILE_REQUEST_CODE = 100
}

View File

@ -408,6 +408,10 @@ class BalanceActivity : AppCompatActivity() {
}
}
private fun navigateTotalBalance(){
}
companion object {
private const val TOP_UP_REQUEST_CODE = 101
}

View File

@ -426,8 +426,8 @@ class ChatStoreActivity : AppCompatActivity() {
}
// Show typing indicator
binding.tvTypingIndicator.visibility =
if (state.isOtherUserTyping) View.VISIBLE else View.GONE
// binding.tvTypingIndicator.visibility =
// if (state.isOtherUserTyping) View.VISIBLE else View.GONE
// Show error if any
state.error?.let { error ->
@ -520,6 +520,19 @@ class ChatStoreActivity : AppCompatActivity() {
private fun handleSelectedImage(uri: Uri) {
try {
Log.d(TAG, "Processing selected image: $uri")
binding.layoutAttachImage.visibility = View.VISIBLE
val fullImageUrl = when (val img = uri.toString()) {
is String -> {
if (img.startsWith("/")) BASE_URL + img.substring(1) else img
}
else -> R.drawable.placeholder_image
}
Glide.with(this)
.load(fullImageUrl)
.placeholder(R.drawable.placeholder_image)
.into(binding.ivAttach)
Log.d(TAG, "Display attach image: $uri")
// Always use the copy-to-cache approach for reliability
contentResolver.openInputStream(uri)?.use { inputStream ->

View File

@ -9,6 +9,7 @@ import android.view.ViewGroup
import android.widget.Toast
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.alya.ecommerce_serang.data.api.response.store.sells.OrdersItem
import com.alya.ecommerce_serang.data.api.retrofit.ApiConfig
@ -16,12 +17,14 @@ import com.alya.ecommerce_serang.data.repository.Result
import com.alya.ecommerce_serang.data.repository.SellsRepository
import com.alya.ecommerce_serang.databinding.FragmentSellsListBinding
import com.alya.ecommerce_serang.ui.order.address.ViewState
import com.alya.ecommerce_serang.ui.profile.mystore.MyStoreActivity
import com.alya.ecommerce_serang.ui.profile.mystore.sells.payment.DetailPaymentActivity
import com.alya.ecommerce_serang.ui.profile.mystore.sells.shipment.DetailShipmentActivity
import com.alya.ecommerce_serang.utils.BaseViewModelFactory
import com.alya.ecommerce_serang.utils.SessionManager
import com.alya.ecommerce_serang.utils.viewmodel.SellsViewModel
import com.google.gson.Gson
import kotlinx.coroutines.launch
class SellsListFragment : Fragment() {
@ -84,6 +87,7 @@ class SellsListFragment : Fragment() {
observeSellsList()
observePaymentConfirmation()
loadSells()
// getAllOrderCountsAndNavigate()
}
private fun setupRecyclerView() {
@ -183,6 +187,30 @@ class SellsListFragment : Fragment() {
context.startActivity(intent)
}
private fun getAllOrderCountsAndNavigate() {
lifecycleScope.launch {
try {
// Show loading if needed
binding.progressBar.visibility = View.VISIBLE
val allCounts = viewModel.getAllStatusCounts()
binding.progressBar.visibility = View.GONE
val intent = Intent(requireContext(), MyStoreActivity::class.java)
intent.putExtra("total_unpaid", allCounts["unpaid"])
intent.putExtra("total_paid", allCounts["paid"])
intent.putExtra("total_processed", allCounts["processed"])
Log.d("SellsListFragment", "Total orders: unpaid=${allCounts["unpaid"]}, processed=${allCounts["processed"]}, Paid=${allCounts["paid"]}")
} catch (e: Exception) {
binding.progressBar.visibility = View.GONE
Log.e(TAG, "Error getting order counts: ${e.message}")
}
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null

View File

@ -1,11 +1,14 @@
package com.alya.ecommerce_serang.utils.viewmodel
import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.map
import androidx.lifecycle.viewModelScope
import com.alya.ecommerce_serang.data.api.dto.Store
import com.alya.ecommerce_serang.data.api.response.auth.StoreTypesItem
import com.alya.ecommerce_serang.data.api.response.store.StoreResponse
import com.alya.ecommerce_serang.data.api.response.store.profile.StoreDataResponse
import com.alya.ecommerce_serang.data.repository.MyStoreRepository
import com.alya.ecommerce_serang.data.repository.Result
@ -13,6 +16,8 @@ import kotlinx.coroutines.launch
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody
import java.text.NumberFormat
import java.util.Locale
class MyStoreViewModel(private val repository: MyStoreRepository): ViewModel() {
private val _myStoreProfile = MutableLiveData<Store?>()
@ -30,6 +35,9 @@ class MyStoreViewModel(private val repository: MyStoreRepository): ViewModel() {
private val _errorMessage = MutableLiveData<String>()
val errorMessage : LiveData<String> = _errorMessage
private val _balanceResult = MutableLiveData<Result<StoreResponse>>()
val balanceResult: LiveData<Result<StoreResponse>> get() = _balanceResult
fun loadMyStore(){
viewModelScope.launch {
when (val result = repository.fetchMyStoreProfile()){
@ -100,6 +108,56 @@ class MyStoreViewModel(private val repository: MyStoreRepository): ViewModel() {
}
}
suspend fun getTotalOrdersByStatus(status: String): Int {
return try {
when (val result = repository.getSellList(status)) {
is Result.Success -> {
// Access the orders list from the response
result.data.orders.size ?: 0
}
is Result.Error -> {
Log.e("SellsViewModel", "Error getting orders count: ${result.exception.message}")
0
}
is Result.Loading -> 0
}
} catch (e: Exception) {
Log.e("SellsViewModel", "Exception getting orders count", e)
0
}
}
//count the order
suspend fun getAllStatusCounts(): Map<String, Int> {
val statuses = listOf( "unpaid", "paid", "processed")
val counts = mutableMapOf<String, Int>()
statuses.forEach { status ->
counts[status] = getTotalOrdersByStatus(status)
Log.d("SellsViewModel", "Status: $status, countOrder=${counts[status]}")
}
return counts
}
val formattedBalance: LiveData<String> = balanceResult.map { result ->
when (result) {
is Result.Success -> {
val raw = result.data.store.balance.toDouble()
NumberFormat.getCurrencyInstance(Locale("in", "ID")).format(raw)
}
else -> ""
}
}
/** Trigger the network call */
fun fetchBalance() {
viewModelScope.launch {
_balanceResult.value = Result.Loading
_balanceResult.value = repository.getBalance()
}
}
private fun String.toRequestBody(): RequestBody =
RequestBody.create("text/plain".toMediaTypeOrNull(), this)
}

View File

@ -146,6 +146,38 @@ class SellsViewModel(private val repository: SellsRepository) : ViewModel() {
Log.d(TAG, "========== getSellList method completed ==========")
}
//get total order each status
suspend fun getTotalOrdersByStatus(status: String): Int {
return try {
when (val result = repository.getSellList(status)) {
is Result.Success -> {
// Access the orders list from the response
result.data.orders.size ?: 0
}
is Result.Error -> {
Log.e("SellsViewModel", "Error getting orders count: ${result.exception.message}")
0
}
is Result.Loading -> 0
}
} catch (e: Exception) {
Log.e("SellsViewModel", "Exception getting orders count", e)
0
}
}
//count the order
suspend fun getAllStatusCounts(): Map<String, Int> {
val statuses = listOf( "unpaid", "paid", "processed")
val counts = mutableMapOf<String, Int>()
statuses.forEach { status ->
counts[status] = getTotalOrdersByStatus(status)
Log.d("SellsViewModel", "Status: $status, countOrder=${counts[status]}")
}
return counts
}
fun getSellDetails(orderId: Int) {
Log.d(TAG, "========== Starting getSellDetails ==========")