From d93874d83191cd376413f7f2d87595246683e5b3 Mon Sep 17 00:00:00 2001 From: shaulascr Date: Tue, 27 May 2025 17:39:51 +0700 Subject: [PATCH 01/10] fixed chat --- app/src/main/AndroidManifest.xml | 7 +- .../data/api/retrofit/ApiService.kt | 7 + .../data/repository/ChatRepository.kt | 149 ++++++++++---- .../ui/auth/RegisterActivity.kt | 2 + .../auth/fragments/RegisterStep3Fragment.kt | 56 ++++++ .../ecommerce_serang/ui/chat/ChatActivity.kt | 184 ++++++++++++------ .../ecommerce_serang/ui/chat/ChatAdapter.kt | 19 +- .../ecommerce_serang/ui/chat/ChatViewModel.kt | 18 +- .../ui/notif/PersonalNotificationAdapter.kt | 11 +- app/src/main/res/layout/activity_chat.xml | 25 +-- app/src/main/res/layout/activity_checkout.xml | 1 + .../layout/activity_detail_order_status.xml | 1 + .../res/layout/activity_detail_payment.xml | 1 + .../res/layout/activity_detail_profile.xml | 1 + .../res/layout/activity_edit_profile_cust.xml | 1 + .../main/res/layout/activity_notification.xml | 30 +-- app/src/main/res/layout/activity_payment.xml | 1 + .../main/res/layout/activity_payment_info.xml | 1 + app/src/main/res/layout/activity_register.xml | 1 + .../res/layout/activity_register_store.xml | 5 +- app/src/main/res/values/themes.xml | 3 + 21 files changed, 384 insertions(+), 140 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c7690ea..4edca1b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -34,6 +34,7 @@ android:exported="false" /> + android:exported="false" + android:windowSoftInputMode="adjustResize|stateHidden" /> @@ -94,6 +97,7 @@ android:exported="false" /> diff --git a/app/src/main/java/com/alya/ecommerce_serang/data/api/retrofit/ApiService.kt b/app/src/main/java/com/alya/ecommerce_serang/data/api/retrofit/ApiService.kt index 2e1e579..6bfc8da 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/data/api/retrofit/ApiService.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/data/api/retrofit/ApiService.kt @@ -443,6 +443,13 @@ interface ApiService { @Part chatimg: MultipartBody.Part? ): Response + @Multipart + @POST("sendchat") + suspend fun sendChatMessage( + @PartMap parts: Map, + @Part chatimg: MultipartBody.Part? = null + ): Response + @PUT("chatstatus") suspend fun updateChatStatus( @Body request: UpdateChatRequest diff --git a/app/src/main/java/com/alya/ecommerce_serang/data/repository/ChatRepository.kt b/app/src/main/java/com/alya/ecommerce_serang/data/repository/ChatRepository.kt index 68c0a94..ccfb887 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/data/repository/ChatRepository.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/data/repository/ChatRepository.kt @@ -8,8 +8,9 @@ import com.alya.ecommerce_serang.data.api.response.chat.ChatItemList import com.alya.ecommerce_serang.data.api.response.chat.SendChatResponse import com.alya.ecommerce_serang.data.api.response.chat.UpdateChatResponse import com.alya.ecommerce_serang.data.api.retrofit.ApiService -import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MediaType.Companion.toMediaType import okhttp3.MultipartBody +import okhttp3.RequestBody import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.RequestBody.Companion.toRequestBody import java.io.File @@ -38,62 +39,130 @@ class ChatRepository @Inject constructor( suspend fun sendChatMessage( storeId: Int, message: String, - productId: Int? = null, - imageFile: File? = null, - chatRoomId: Int? = null // Not used in the actual API call but kept for compatibility + productId: Int?, // Nullable and optional + imageFile: File? = null // Nullable and optional ): Result { return try { - // Create multipart request parts - val storeIdPart = storeId.toString().toRequestBody("text/plain".toMediaTypeOrNull()) - val messagePart = message.toRequestBody("text/plain".toMediaTypeOrNull()) + val parts = mutableMapOf() - // Add product ID part if provided - val productIdPart = if (productId != null && productId > 0) { - productId.toString().toRequestBody("text/plain".toMediaTypeOrNull()) - } else { - null + // Required fields + parts["store_id"] = storeId.toString().toRequestBody("text/plain".toMediaType()) + parts["message"] = message.toRequestBody("text/plain".toMediaType()) + + // Optional: Only include if productId is valid + if (productId != null && productId > 0) { + parts["product_id"] = productId.toString().toRequestBody("text/plain".toMediaType()) } - // Create image part if file is provided - val imagePart = if (imageFile != null && imageFile.exists()) { - val requestFile = imageFile.asRequestBody("image/*".toMediaTypeOrNull()) - MultipartBody.Part.createFormData("chatimg", imageFile.name, requestFile) - } else { - null + // Optional: Only include if imageFile is valid + val imagePart = imageFile?.takeIf { it.exists() }?.let { file -> +// val requestFile = file.asRequestBody("image/*".toMediaType()) + val mimeType = when { + file.name.endsWith(".png", ignoreCase = true) -> "image/png" + file.name.endsWith(".jpg", ignoreCase = true) || file.name.endsWith(".jpeg", ignoreCase = true) -> "image/jpeg" + else -> "image/jpeg" // fallback + } + val requestFile = file.asRequestBody(mimeType.toMediaType()) + MultipartBody.Part.createFormData("chatimg", file.name, requestFile) } - // Debug log the request parameters - Log.d("ChatRepository", "Sending chat with: storeId=$storeId, productId=$productId, " + - "message length=${message.length}, hasImage=${imageFile != null}") + // Log the parts map keys and values (string representations) + Log.d("ChatRepository", "Sending chat message with parts:") + parts.forEach { (key, body) -> + Log.d("ChatRepository", "Key: $key, Value (approx): ${bodyToString(body)}") + } + Log.d("ChatRepository", "Sending chat message with imagePart: ${imagePart != null}") - // Make API call using your actual endpoint and parameter names - val response = apiService.sendChatLine( - storeId = storeIdPart, - message = messagePart, - productId = productIdPart, - chatimg = imagePart - ) - - Log.d("ChatRepository", "check data productId=$productIdPart, storeId=$storeIdPart, messageTxt=$messagePart, chatImg=$imagePart") + // Send request + val response = apiService.sendChatMessage(parts, imagePart) if (response.isSuccessful) { - val body = response.body() - if (body != null) { - Result.Success(body) - } else { - Result.Error(Exception("Empty response body")) - } + response.body()?.let { Result.Success(it) } ?: Result.Error(Exception("Empty response body")) } else { - val errorBody = response.errorBody()?.string() ?: "{}" - Log.e("ChatRepository", "API Error: ${response.code()} - $errorBody") - Result.Error(Exception("API Error: ${response.code()} - $errorBody")) + val errorMsg = response.errorBody()?.string().orEmpty() + Log.e("ChatRepository", "API Error: ${response.code()} - $errorMsg") + Result.Error(Exception("API Error: ${response.code()} - $errorMsg")) } + } catch (e: Exception) { - Log.e("ChatRepository", "Exception sending message", e) + Log.e("ChatRepository", "Exception sending chat message", e) Result.Error(e) } } + // Helper function to get string content from RequestBody (best effort) + private fun bodyToString(requestBody: RequestBody): String { + return try { + val buffer = okio.Buffer() + requestBody.writeTo(buffer) + buffer.readUtf8() + } catch (e: Exception) { + "Could not read body" + } + } + +// suspend fun sendChatMessage( +// storeId: Int, +// message: String, +// productId: Int?, +// imageFile: File? = null, +// chatRoomId: Int? = null +// ): Result { +// return try { +// Log.d(TAG, "=== SEND CHAT MESSAGE ===") +// Log.d(TAG, "StoreId: $storeId") +// Log.d(TAG, "Message: '$message'") +// Log.d(TAG, "ProductId: $productId") +// Log.d(TAG, "ImageFile: ${imageFile?.absolutePath}") +// Log.d(TAG, "ImageFile exists: ${imageFile?.exists()}") +// Log.d(TAG, "ImageFile size: ${imageFile?.length()} bytes") +// +// // Convert primitive fields to RequestBody +// val storeIdBody = storeId.toString().toRequestBody("text/plain".toMediaTypeOrNull()) +// val messageBody = message.toRequestBody("text/plain".toMediaTypeOrNull()) +// val productIdBody = productId?.takeIf { it > 0 } // null if 0 +// ?.toString() +// ?.toRequestBody("text/plain".toMediaTypeOrNull()) +// +// // Convert image file to MultipartBody.Part if exists +// val imagePart: MultipartBody.Part? = imageFile?.takeIf { it.exists() }?.let { file -> +// val requestFile = file.asRequestBody("image/*".toMediaTypeOrNull()) +// MultipartBody.Part.createFormData("chatimg", file.name, requestFile) +// } +// +// +// +// // Call the API +// Log.d(TAG, "Sending request. ProductIdBody is null: ${productIdBody == null}") +// +// val response = apiService.sendChatLine( +// storeId = storeIdBody, +// message = messageBody, +// productId = productIdBody, +// chatimg = imagePart +// ) +// +// // Handle API response +// if (response.isSuccessful) { +// response.body()?.let { +// Log.d(TAG, "Success: ${it.message}") +// Result.Success(it) +// } ?: run { +// Log.e(TAG, "Response body is null") +// Result.Error(Exception("Empty response body")) +// } +// } else { +// val errorMsg = response.errorBody()?.string() ?: "Unknown error" +// Log.e(TAG, "API Error: ${response.code()} - $errorMsg") +// Result.Error(Exception("API Error: ${response.code()} - $errorMsg")) +// } +// +// } catch (e: Exception) { +// Log.e(TAG, "Exception sending chat message", e) +// Result.Error(e) +// } +// } + suspend fun updateMessageStatus( messageId: Int, status: String diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/auth/RegisterActivity.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/auth/RegisterActivity.kt index c8f6585..04b5780 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/auth/RegisterActivity.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/auth/RegisterActivity.kt @@ -59,6 +59,8 @@ class RegisterActivity : AppCompatActivity() { windowInsets } + + Log.d("RegisterActivity", "Token in storage: '${sessionManager.getToken()}'") Log.d("RegisterActivity", "User ID in storage: '${sessionManager.getUserId()}'") diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/auth/fragments/RegisterStep3Fragment.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/auth/fragments/RegisterStep3Fragment.kt index ec34458..e73281e 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/auth/fragments/RegisterStep3Fragment.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/auth/fragments/RegisterStep3Fragment.kt @@ -8,6 +8,9 @@ import android.view.View import android.view.ViewGroup import android.widget.TextView import android.widget.Toast +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsAnimationCompat +import androidx.core.view.WindowInsetsCompat import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import com.alya.ecommerce_serang.R @@ -91,6 +94,8 @@ class RegisterStep3Fragment : Fragment() { // Set up province and city dropdowns setupAutoComplete() + setupEdgeToEdge() + // Set up button listeners binding.btnPrevious.setOnClickListener { // Go back to the previous step @@ -116,6 +121,55 @@ class RegisterStep3Fragment : Fragment() { setupCityObserver() } + private fun setupEdgeToEdge() { + // Apply insets to your fragment's root view + ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, windowInsets -> + val systemBars = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + view.setPadding( + systemBars.left, + systemBars.top, + systemBars.right, + systemBars.bottom + ) + windowInsets + } + + // Set up IME animation callback + ViewCompat.setWindowInsetsAnimationCallback( + binding.root, + object : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_STOP) { + var startBottom = 0f + var endBottom = 0f + + override fun onPrepare(animation: WindowInsetsAnimationCompat) { + startBottom = binding.root.bottom.toFloat() + } + + override fun onStart( + animation: WindowInsetsAnimationCompat, + bounds: WindowInsetsAnimationCompat.BoundsCompat + ): WindowInsetsAnimationCompat.BoundsCompat { + endBottom = binding.root.bottom.toFloat() + return bounds + } + + override fun onProgress( + insets: WindowInsetsCompat, + runningAnimations: MutableList + ): WindowInsetsCompat { + val imeAnimation = runningAnimations.find { + it.typeMask and WindowInsetsCompat.Type.ime() != 0 + } ?: return insets + + binding.root.translationY = + (startBottom - endBottom) * (1 - imeAnimation.interpolatedFraction) + + return insets + } + } + ) + } + private fun setupAutoComplete() { // Same implementation as before binding.autoCompleteProvinsi.setAdapter(provinceAdapter) @@ -351,6 +405,8 @@ class RegisterStep3Fragment : Fragment() { override fun onDestroyView() { super.onDestroyView() + ViewCompat.setOnApplyWindowInsetsListener(binding.root, null) + ViewCompat.setWindowInsetsAnimationCallback(binding.root, null) _binding = null } // diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatActivity.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatActivity.kt index 453c9b2..d9cc397 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatActivity.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatActivity.kt @@ -3,6 +3,7 @@ package com.alya.ecommerce_serang.ui.chat import android.Manifest import android.app.Activity import android.app.AlertDialog +import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.net.Uri @@ -12,6 +13,7 @@ import android.text.Editable import android.text.TextWatcher import android.util.Log import android.view.View +import android.view.inputmethod.InputMethodManager import android.widget.Toast import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts @@ -22,6 +24,7 @@ import androidx.core.content.ContextCompat import androidx.core.content.FileProvider import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsAnimationCompat import androidx.core.view.WindowInsetsCompat import androidx.lifecycle.Observer import androidx.recyclerview.widget.LinearLayoutManager @@ -40,6 +43,7 @@ import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import javax.inject.Inject +import kotlin.math.max @AndroidEntryPoint class ChatActivity : AppCompatActivity() { @@ -100,16 +104,7 @@ class ChatActivity : AppCompatActivity() { enableEdgeToEdge() // Apply insets to your root layout - ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, windowInsets -> - val systemBars = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) - view.setPadding( - systemBars.left, - systemBars.top, - systemBars.right, - systemBars.bottom - ) - windowInsets - } + // Get parameters from intent val storeId = intent.getIntExtra(Constants.EXTRA_STORE_ID, 0) @@ -146,6 +141,84 @@ class ChatActivity : AppCompatActivity() { .placeholder(R.drawable.placeholder_image) .into(binding.imgProfile) + ViewCompat.setOnApplyWindowInsetsListener(binding.layoutChatInput) { view, insets -> + val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime()) + val navBarInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars()) + + val bottomPadding = max(imeInsets.bottom, navBarInsets.bottom) + view.setPadding(view.paddingLeft, view.paddingTop, view.paddingRight, bottomPadding) + insets + } + +// Handle top inset on toolbar (status bar height) + ViewCompat.setOnApplyWindowInsetsListener(binding.chatToolbar) { view, insets -> + val statusBarHeight = insets.getInsets(WindowInsetsCompat.Type.statusBars()).top + view.setPadding(view.paddingLeft, statusBarHeight, view.paddingRight, view.paddingBottom) + insets + } + + ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerChat) { view, insets -> + val navBarInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars()) + val bottomPadding = binding.layoutChatInput.height + navBarInsets.bottom + + view.setPadding( + view.paddingLeft, + view.paddingTop, + view.paddingRight, + bottomPadding + ) + insets + } + +// For RecyclerView, add bottom padding = chat input height + nav bar height (to avoid last message hidden) + + ViewCompat.setWindowInsetsAnimationCallback(binding.root, + object : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_STOP) { + + private var startPaddingBottom = 0 + private var endPaddingBottom = 0 + + override fun onPrepare(animation: WindowInsetsAnimationCompat) { + startPaddingBottom = binding.layoutChatInput.paddingBottom + } + + override fun onStart( + animation: WindowInsetsAnimationCompat, + bounds: WindowInsetsAnimationCompat.BoundsCompat + ): WindowInsetsAnimationCompat.BoundsCompat { + endPaddingBottom = binding.layoutChatInput.paddingBottom + return bounds + } + + override fun onProgress( + insets: WindowInsetsCompat, + runningAnimations: MutableList + ): WindowInsetsCompat { + val imeAnimation = runningAnimations.find { + it.typeMask and WindowInsetsCompat.Type.ime() != 0 + } ?: return insets + + val animatedBottomPadding = startPaddingBottom + + (endPaddingBottom - startPaddingBottom) * imeAnimation.interpolatedFraction + + binding.layoutChatInput.setPadding( + binding.layoutChatInput.paddingLeft, + binding.layoutChatInput.paddingTop, + binding.layoutChatInput.paddingRight, + animatedBottomPadding.toInt() + ) + + binding.recyclerChat.setPadding( + binding.recyclerChat.paddingLeft, + binding.recyclerChat.paddingTop, + binding.recyclerChat.paddingRight, + animatedBottomPadding.toInt() + binding.layoutChatInput.height + ) + + return insets + } + }) + // Set chat parameters to ViewModel viewModel.setChatParameters( storeId = storeId, @@ -178,6 +251,12 @@ class ChatActivity : AppCompatActivity() { stackFromEnd = true } } +// binding.recyclerChat.setPadding( +// binding.recyclerChat.paddingLeft, +// binding.recyclerChat.paddingTop, +// binding.recyclerChat.paddingRight, +// binding.layoutChatInput.height + binding.root.rootWindowInsets?.getInsets(WindowInsetsCompat.Type.navigationBars())?.bottom ?: 0 +// ) } @@ -222,6 +301,11 @@ class ChatActivity : AppCompatActivity() { override fun afterTextChanged(s: Editable?) {} }) + + binding.editTextMessage.requestFocus() + val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.showSoftInput(binding.editTextMessage, InputMethodManager.SHOW_IMPLICIT) + } private fun observeViewModel() { @@ -256,10 +340,17 @@ class ChatActivity : AppCompatActivity() { binding.tvSellerName.text = state.storeName binding.tvStoreName.text=state.storeName + val fullImageUrl = when (val img = state.productImageUrl) { + is String -> { + if (img.startsWith("/")) BASE_URL + img.substring(1) else img + } + else -> R.drawable.placeholder_image + } + // Load product image if (!state.productImageUrl.isNullOrEmpty()) { Glide.with(this@ChatActivity) - .load(BASE_URL + state.productImageUrl) + .load(fullImageUrl) .centerCrop() .placeholder(R.drawable.placeholder_image) .error(R.drawable.placeholder_image) @@ -294,8 +385,6 @@ class ChatActivity : AppCompatActivity() { }) } - - private fun showOptionsMenu() { val options = arrayOf( getString(R.string.block_user), @@ -380,67 +469,36 @@ class ChatActivity : AppCompatActivity() { try { Log.d(TAG, "Processing selected image: $uri") - // First try the direct approach to get the file path - var filePath: String? = null + // Always use the copy-to-cache approach for reliability + contentResolver.openInputStream(uri)?.use { inputStream -> + val fileName = "chat_img_${System.currentTimeMillis()}.jpg" + val outputFile = File(cacheDir, fileName) - // For newer Android versions, we need to handle content URIs properly - if (uri.scheme == "content") { - val cursor = contentResolver.query(uri, null, null, null, null) - cursor?.use { - if (it.moveToFirst()) { - val columnIndex = it.getColumnIndex(MediaStore.Images.Media.DATA) - if (columnIndex != -1) { - filePath = it.getString(columnIndex) - Log.d(TAG, "Found file path from cursor: $filePath") - } - } + outputFile.outputStream().use { outputStream -> + inputStream.copyTo(outputStream) } - // If we couldn't get the path directly, create a copy in our cache directory - if (filePath == null) { - contentResolver.openInputStream(uri)?.use { inputStream -> - val fileName = "img_${System.currentTimeMillis()}.jpg" - val outputFile = File(cacheDir, fileName) - - outputFile.outputStream().use { outputStream -> - inputStream.copyTo(outputStream) - } - - filePath = outputFile.absolutePath - Log.d(TAG, "Created temp file from input stream: $filePath") - } - } - } else if (uri.scheme == "file") { - // Direct file URI - filePath = uri.path - Log.d(TAG, "Got file path directly from URI: $filePath") - } - - // Process the file path - if (filePath != null) { - val file = File(filePath) - if (file.exists()) { - // Check file size (limit to 5MB) - if (file.length() > 5 * 1024 * 1024) { - Toast.makeText(this, "Image too large (max 5MB), please select a smaller image", Toast.LENGTH_SHORT).show() + if (outputFile.exists() && outputFile.length() > 0) { + if (outputFile.length() > 5 * 1024 * 1024) { + Log.e(TAG, "File too large: ${outputFile.length()} bytes") + Toast.makeText(this, "Image too large (max 5MB)", Toast.LENGTH_SHORT).show() return } - // Set the file to the ViewModel - viewModel.setSelectedImageFile(file) - Toast.makeText(this, R.string.image_selected, Toast.LENGTH_SHORT).show() - Log.d(TAG, "Successfully set image file: ${file.absolutePath}, size: ${file.length()} bytes") + 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, "File does not exist: $filePath") - Toast.makeText(this, "Could not access the selected image", Toast.LENGTH_SHORT).show() + Log.e(TAG, "Failed to create image file") + Toast.makeText(this, "Failed to process image", Toast.LENGTH_SHORT).show() } - } else { - Log.e(TAG, "Could not get file path from URI: $uri") - Toast.makeText(this, "Could not process the selected image", Toast.LENGTH_SHORT).show() + } ?: run { + Log.e(TAG, "Could not open input stream for URI: $uri") + Toast.makeText(this, "Could not access image", Toast.LENGTH_SHORT).show() } } catch (e: Exception) { Log.e(TAG, "Error handling selected image", e) - Toast.makeText(this, "Error processing image: ${e.message}", Toast.LENGTH_SHORT).show() + Toast.makeText(this, "Error: ${e.message}", Toast.LENGTH_SHORT).show() } } diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatAdapter.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatAdapter.kt index 0e2f083..484803f 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatAdapter.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatAdapter.kt @@ -78,8 +78,16 @@ class ChatAdapter : ListAdapter(ChatMess // Handle attachment if exists if (message.attachment?.isNotEmpty() == true) { binding.imgAttachment.visibility = View.VISIBLE + + val fullImageUrl = when (val img = message.attachment) { + is String -> { + if (img.startsWith("/")) BASE_URL + img.substring(1) else img + } + else -> R.drawable.placeholder_image + } + Glide.with(binding.root.context) - .load(BASE_URL + message.attachment) + .load(fullImageUrl) .centerCrop() .placeholder(R.drawable.placeholder_image) .error(R.drawable.placeholder_image) @@ -101,10 +109,17 @@ class ChatAdapter : ListAdapter(ChatMess binding.tvTimestamp.text = message.time // Handle attachment if exists + val fullImageUrl = when (val img = message.attachment) { + is String -> { + if (img.startsWith("/")) BASE_URL + img.substring(1) else img + } + else -> R.drawable.placeholder_image + } + if (message.attachment?.isNotEmpty() == true) { binding.imgAttachment.visibility = View.VISIBLE Glide.with(binding.root.context) - .load(BASE_URL + message.attachment) + .load(fullImageUrl) .centerCrop() .placeholder(R.drawable.placeholder_image) .error(R.drawable.placeholder_image) diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatViewModel.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatViewModel.kt index 989dedd..1f15130 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatViewModel.kt @@ -46,7 +46,7 @@ class ChatViewModel @Inject constructor( // Store and product parameters private var storeId: Int = 0 - private var productId: Int? = 0 + private var productId: Int = 0 private var currentUserId: Int? = null private var defaultUserId: Int = 0 @@ -100,8 +100,9 @@ class ChatViewModel @Inject constructor( productRating: Float? = 0f, storeName: String ) { + this.productId = if (productId != null && productId > 0) productId else 0 + this.storeId = storeId - this.productId = productId!! this.productName = productName.toString() this.productPrice = productPrice.toString() this.productImage = productImage.toString() @@ -247,6 +248,11 @@ class ChatViewModel @Inject constructor( * Sends a chat message */ fun sendMessage(message: String) { + Log.d(TAG, "=== SEND MESSAGE ===") + Log.d(TAG, "Message: '$message'") + Log.d(TAG, "Has attachment: ${selectedImageFile != null}") + Log.d(TAG, "Selected image file: ${selectedImageFile?.absolutePath}") + Log.d(TAG, "File exists: ${selectedImageFile?.exists()}") if (message.isBlank() && selectedImageFile == null) { Log.e(TAG, "Cannot send message: Both message and image are empty") return @@ -282,12 +288,14 @@ class ChatViewModel @Inject constructor( // Send the message using the repository // Note: We keep the chatRoomId parameter for compatibility with the repository method signature, // but it's not actually used in the API call + val safeProductId = if (productId == 0) null else productId + + val result = chatRepository.sendChatMessage( storeId = storeId, message = message, - productId = productId, - imageFile = selectedImageFile, - chatRoomId = existingChatRoomId + productId = safeProductId, + imageFile = selectedImageFile ) when (result) { diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/notif/PersonalNotificationAdapter.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/notif/PersonalNotificationAdapter.kt index e60aa1c..cafb9e5 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/notif/PersonalNotificationAdapter.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/notif/PersonalNotificationAdapter.kt @@ -44,7 +44,14 @@ class PersonalNotificationAdapter( fun bind(notification: NotifItem) { binding.apply { - tvNotificationType.text = notification.type + val typeNotif = notification.type.toString() + if(typeNotif == "User"){ + tvNotificationType.text = "Pembelian" + } else if (typeNotif == "Store"){ + tvNotificationType.text = "Penjualan" + } else { + tvNotificationType.text = notification.type + } tvTitle.text = notification.title tvDescription.text = notification.message @@ -63,7 +70,7 @@ class PersonalNotificationAdapter( try { // Parse the date with the expected format from API val inputFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault()) - val outputFormat = SimpleDateFormat("HH:mm", Locale.getDefault()) + val outputFormat = SimpleDateFormat("dd/MM/yyyy HH:mm", Locale.getDefault()) val date = inputFormat.parse(createdAt) date?.let { diff --git a/app/src/main/res/layout/activity_chat.xml b/app/src/main/res/layout/activity_chat.xml index 902de35..d60c39e 100644 --- a/app/src/main/res/layout/activity_chat.xml +++ b/app/src/main/res/layout/activity_chat.xml @@ -4,6 +4,7 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" + android:fitsSystemWindows="false" android:theme="@style/Theme.Ecommerce_serang" tools:context=".ui.chat.ChatActivity"> @@ -55,17 +56,17 @@ app:layout_constraintTop_toTopOf="@+id/imgProfile" app:layout_constraintEnd_toStartOf="@+id/btnOptions" /> - + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_checkout.xml b/app/src/main/res/layout/activity_checkout.xml index ba30bd8..8c793d4 100644 --- a/app/src/main/res/layout/activity_checkout.xml +++ b/app/src/main/res/layout/activity_checkout.xml @@ -7,6 +7,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:background="@color/black_800" + android:theme="@style/Theme.Ecommerce_serang" tools:context=".ui.order.CheckoutActivity"> + android:fontFamily="@font/dmsans_semibold" /> - + + + + + + + + + + + + app:layout_constraintTop_toBottomOf="@id/tabLayout" /> diff --git a/app/src/main/res/layout/activity_register_store.xml b/app/src/main/res/layout/activity_register_store.xml index 36f8916..2ac26d7 100644 --- a/app/src/main/res/layout/activity_register_store.xml +++ b/app/src/main/res/layout/activity_register_store.xml @@ -21,7 +21,7 @@ android:text="Buka Toko" android:textColor="@android:color/white" android:textSize="20sp" - android:textStyle="bold" /> + android:fontFamily="@font/dmsans_medium" /> diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 24782af..adeeaac 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -40,6 +40,9 @@ @android:color/transparent @android:color/transparent + + @style/body_medium + From 1c443232aed521e410c20b6f32951d65fa362cf0 Mon Sep 17 00:00:00 2001 From: shaulascr Date: Tue, 27 May 2025 18:03:42 +0700 Subject: [PATCH 02/10] update font and chat --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5b78d69..7ee4510 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "8.10.0" +agp = "8.9.2" glide = "4.16.0" gson = "2.11.0" hiltAndroid = "2.56.2" # Updated from 2.44 for better compatibility From b1db662494e9f162a2e0c0b801bef83c6a698120 Mon Sep 17 00:00:00 2001 From: shaulascr Date: Wed, 28 May 2025 17:32:36 +0700 Subject: [PATCH 03/10] add all order tab --- .../data/api/dto/OrdersItem.kt | 10 ++- .../data/repository/OrderRepository.kt | 1 + .../ui/order/history/HistoryViewModel.kt | 85 ++++++++++++++++--- .../ui/order/history/OrderHistoryAdapter.kt | 20 ++++- .../ui/order/history/OrderListFragment.kt | 23 +++-- app/src/main/res/values/strings.xml | 2 +- 6 files changed, 107 insertions(+), 34 deletions(-) diff --git a/app/src/main/java/com/alya/ecommerce_serang/data/api/dto/OrdersItem.kt b/app/src/main/java/com/alya/ecommerce_serang/data/api/dto/OrdersItem.kt index 5d16279..439b949 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/data/api/dto/OrdersItem.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/data/api/dto/OrdersItem.kt @@ -27,7 +27,7 @@ data class OrdersItem( val street: String, @field:SerializedName("cancel_date") - val cancelDate: String? = null, + val cancelDate: String, @field:SerializedName("longitude") val longitude: String, @@ -42,7 +42,7 @@ data class OrdersItem( val autoCompletedAt: String? = null, @field:SerializedName("is_store_location") - val isStoreLocation: Boolean? = null, + val isStoreLocation: Boolean? = false, @field:SerializedName("voucher_name") val voucherName: String? = null, @@ -81,7 +81,7 @@ data class OrdersItem( val paymentInfoId: Int? = null, @field:SerializedName("detail") - val detail: String, + val detail: String? = null, @field:SerializedName("postal_code") val postalCode: String, @@ -90,5 +90,7 @@ data class OrdersItem( val orderId: Int, @field:SerializedName("city_id") - val cityId: Int + val cityId: Int, + + var displayStatus: String? = null ) diff --git a/app/src/main/java/com/alya/ecommerce_serang/data/repository/OrderRepository.kt b/app/src/main/java/com/alya/ecommerce_serang/data/repository/OrderRepository.kt index e162141..0f4f587 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/data/repository/OrderRepository.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/data/repository/OrderRepository.kt @@ -511,4 +511,5 @@ class OrderRepository(private val apiService: ApiService) { } } + } \ No newline at end of file diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/order/history/HistoryViewModel.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/order/history/HistoryViewModel.kt index 9c36c28..c2df487 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/order/history/HistoryViewModel.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/order/history/HistoryViewModel.kt @@ -15,8 +15,13 @@ import com.alya.ecommerce_serang.data.api.response.order.CompletedOrderResponse import com.alya.ecommerce_serang.data.repository.OrderRepository import com.alya.ecommerce_serang.data.repository.Result import com.alya.ecommerce_serang.ui.order.address.ViewState +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import java.io.File +import java.text.SimpleDateFormat +import java.util.Locale class HistoryViewModel(private val repository: OrderRepository) : ViewModel() { @@ -57,28 +62,80 @@ class HistoryViewModel(private val repository: OrderRepository) : ViewModel() { fun getOrderList(status: String) { _orders.value = ViewState.Loading viewModelScope.launch { - _orders.value = ViewState.Loading - try { - when (val result = repository.getOrderList(status)) { - is Result.Success -> { - _orders.value = ViewState.Success(result.data.orders) - Log.d("HistoryViewModel", "Orders loaded successfully: ${result.data.orders.size} items") - } - is Result.Error -> { - _orders.value = ViewState.Error(result.exception.message ?: "Unknown error occurred") - Log.e("HistoryViewModel", "Error loading orders", result.exception) - } - is Result.Loading -> { - null + if (status == "all") { + // Get all orders by combining all statuses + getAllOrdersCombined() + } else { + // Get orders for specific status + when (val result = repository.getOrderList(status)) { + is Result.Success -> { + _orders.value = ViewState.Success(result.data.orders) + Log.d(TAG, "Orders loaded successfully: ${result.data.orders.size} items") + } + is Result.Error -> { + _orders.value = ViewState.Error(result.exception.message ?: "Unknown error occurred") + Log.e(TAG, "Error loading orders", result.exception) + } + is Result.Loading -> { + // Keep loading state + } } } } catch (e: Exception) { _orders.value = ViewState.Error("An unexpected error occurred: ${e.message}") - Log.e("HistoryViewModel", "Exception in getOrderList", e) + Log.e(TAG, "Exception in getOrderList", e) } } } + + private suspend fun getAllOrdersCombined() { + try { + val allStatuses = listOf("pending", "unpaid", "processed", "shipped", "completed", "canceled") + val allOrders = mutableListOf() + + // Use coroutineScope to allow launching async blocks + coroutineScope { + val deferreds = allStatuses.map { status -> + async { + when (val result = repository.getOrderList(status)) { + is Result.Success -> { + // Tag each order with the status it was fetched from + result.data.orders.onEach { it.displayStatus = status } + } + is Result.Error -> { + Log.e(TAG, "Error loading orders for status $status", result.exception) + emptyList() + } + is Result.Loading -> emptyList() + } + } + } + + // Await all results and combine + deferreds.awaitAll().forEach { orders -> + allOrders.addAll(orders) + } + } + + // Sort orders + val sortedOrders = allOrders.sortedByDescending { order -> + try { + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault()).parse(order.createdAt) + } catch (e: Exception) { + null + } + } + + _orders.value = ViewState.Success(sortedOrders) + Log.d(TAG, "All orders loaded successfully: ${sortedOrders.size} items") + + } catch (e: Exception) { + _orders.value = ViewState.Error("An unexpected error occurred: ${e.message}") + Log.e(TAG, "Exception in getAllOrdersCombined", e) + } + } + fun confirmOrderCompleted(orderId: Int, status: String) { Log.d(TAG, "Confirming order completed: orderId=$orderId, status=$status") viewModelScope.launch { diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/order/history/OrderHistoryAdapter.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/order/history/OrderHistoryAdapter.kt index 5432f78..a67a2d7 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/order/history/OrderHistoryAdapter.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/order/history/OrderHistoryAdapter.kt @@ -119,8 +119,8 @@ class OrderHistoryAdapter( onOrderClickListener(order) } - //adjust each fragment - adjustButtonsAndText(fragmentStatus, order) + val actualStatus = if (fragmentStatus == "all") order.displayStatus ?: "" else fragmentStatus + adjustButtonsAndText(actualStatus, order) } @@ -253,7 +253,7 @@ class OrderHistoryAdapter( "completed" -> { statusOrder.apply { visibility = View.VISIBLE - text = itemView.context.getString(R.string.shipped_orders) + text = itemView.context.getString(R.string.completed_orders) } deadlineLabel.apply { visibility = View.VISIBLE @@ -268,12 +268,26 @@ class OrderHistoryAdapter( // Handle click event } } + deadlineDate.apply { + visibility = View.VISIBLE + text = formatDate(order.updatedAt) + } } "canceled" -> { statusOrder.apply { visibility = View.VISIBLE text = itemView.context.getString(R.string.canceled_orders) } + + deadlineLabel.apply { + visibility = View.VISIBLE + text = itemView.context.getString(R.string.dl_canceled) + } + + deadlineDate.apply { + visibility = View.VISIBLE + text = formatDate(order.cancelDate) + } } } } diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/order/history/OrderListFragment.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/order/history/OrderListFragment.kt index fcc9837..5e43898 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/order/history/OrderListFragment.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/order/history/OrderListFragment.kt @@ -27,7 +27,6 @@ class OrderListFragment : Fragment() { private val binding get() = _binding!! private lateinit var sessionManager: SessionManager - private val viewModel: HistoryViewModel by viewModels { BaseViewModelFactory { val apiService = ApiConfig.getApiService(sessionManager) @@ -80,10 +79,9 @@ class OrderListFragment : Fragment() { private fun setupRecyclerView() { orderAdapter = OrderHistoryAdapter( onOrderClickListener = { order -> - // Handle order click navigateToOrderDetail(order) }, - viewModel = viewModel // Pass the ViewModel to the adapter + viewModel = viewModel ) orderAdapter.setFragmentStatus(status) @@ -95,6 +93,7 @@ class OrderListFragment : Fragment() { } private fun observeOrderList() { + // Now we only need to observe one LiveData for all cases viewModel.orders.observe(viewLifecycleOwner) { result -> when (result) { is ViewState.Success -> { @@ -115,13 +114,14 @@ class OrderListFragment : Fragment() { Toast.makeText(requireContext(), result.message, Toast.LENGTH_SHORT).show() } is ViewState.Loading -> { - null + binding.progressBar.visibility = View.VISIBLE } } } } private fun loadOrders() { + // Simple - just call getOrderList for any status including "all" viewModel.getOrderList(status) } @@ -130,15 +130,15 @@ class OrderListFragment : Fragment() { ) { result -> if (result.resultCode == Activity.RESULT_OK) { // Refresh order list when returning with OK result - viewModel.getOrderList(status) + loadOrders() } } private fun navigateToOrderDetail(order: OrdersItem) { val intent = Intent(requireContext(), DetailOrderStatusActivity::class.java).apply { putExtra("ORDER_ID", order.orderId) - putExtra("ORDER_STATUS", status) // Pass the current status - } + val actualStatus = if (status == "all") order.displayStatus ?: "" else status + putExtra("ORDER_STATUS", actualStatus) } detailOrderLauncher.launch(intent) } @@ -147,11 +147,11 @@ class OrderListFragment : Fragment() { _binding = null } - private fun observeOrderCompletionStatus(){ - viewModel.orderCompletionStatus.observe(viewLifecycleOwner){ result -> - when(result){ + private fun observeOrderCompletionStatus() { + viewModel.orderCompletionStatus.observe(viewLifecycleOwner) { result -> + when (result) { is Result.Loading -> { - + // Handle loading state if needed } is Result.Success -> { Toast.makeText(requireContext(), "Order completed successfully!", Toast.LENGTH_SHORT).show() @@ -163,5 +163,4 @@ class OrderListFragment : Fragment() { } } } - } \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 43c932d..1623228 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -85,7 +85,7 @@ Semua Pesanan Semua Pesanan Tanggal Pesanan Sampai - Semua Pesanan + Tanggal Pesanan Dibatalkan Kirim Bukti Pembayaran Batalkan Pesanan From 9b0ae95dfc80e936044b280df066c6017448120b Mon Sep 17 00:00:00 2001 From: shaulascr Date: Wed, 28 May 2025 18:51:32 +0700 Subject: [PATCH 04/10] add chat store --- app/src/main/AndroidManifest.xml | 23 +- .../data/api/retrofit/ApiService.kt | 11 + .../data/repository/ChatRepository.kt | 76 +++ .../ecommerce_serang/ui/chat/ChatViewModel.kt | 134 ++++ .../ui/profile/mystore/MyStoreActivity.kt | 8 +- .../profile/mystore/chat/ChatListAdapter.kt | 69 ++ .../mystore/chat/ChatListStoreActivity.kt | 114 ++++ .../mystore/chat/ChatListStoreFragment.kt | 96 +++ .../profile/mystore/chat/ChatStoreActivity.kt | 593 ++++++++++++++++++ .../res/layout/activity_chat_list_store.xml | 40 ++ 10 files changed, 1151 insertions(+), 13 deletions(-) create mode 100644 app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatListAdapter.kt create mode 100644 app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatListStoreActivity.kt create mode 100644 app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatListStoreFragment.kt create mode 100644 app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatStoreActivity.kt create mode 100644 app/src/main/res/layout/activity_chat_list_store.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4edca1b..a5327c7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -29,13 +29,16 @@ android:theme="@style/Theme.Ecommerce_serang" android:usesCleartextTraffic="true" tools:targetApi="31"> + + android:exported="false" + android:windowSoftInputMode="adjustResize" /> @@ -50,8 +53,8 @@ android:exported="false" /> + android:exported="false" + android:windowSoftInputMode="adjustResize" /> @@ -73,6 +76,10 @@ android:enabled="true" android:exported="false" android:foregroundServiceType="dataSync" /> + + android:exported="false" + android:windowSoftInputMode="adjustResize" /> @@ -146,8 +153,8 @@ android:exported="false" /> + android:exported="true" + android:windowSoftInputMode="adjustResize"> diff --git a/app/src/main/java/com/alya/ecommerce_serang/data/api/retrofit/ApiService.kt b/app/src/main/java/com/alya/ecommerce_serang/data/api/retrofit/ApiService.kt index 6bfc8da..8fe84d8 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/data/api/retrofit/ApiService.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/data/api/retrofit/ApiService.kt @@ -443,6 +443,17 @@ interface ApiService { @Part chatimg: MultipartBody.Part? ): Response + @Multipart + @POST("store/sendchat") + suspend fun sendChatMessageStore( + @PartMap parts: Map, + @Part chatimg: MultipartBody.Part? = null + ): Response + + @GET("store/chat") + suspend fun getChatListStore( + ): Response + @Multipart @POST("sendchat") suspend fun sendChatMessage( diff --git a/app/src/main/java/com/alya/ecommerce_serang/data/repository/ChatRepository.kt b/app/src/main/java/com/alya/ecommerce_serang/data/repository/ChatRepository.kt index ccfb887..7ad1656 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/data/repository/ChatRepository.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/data/repository/ChatRepository.kt @@ -90,6 +90,60 @@ class ChatRepository @Inject constructor( } } + suspend fun sendChatMessageStore( + storeId: Int, + message: String, + productId: Int?, // Nullable and optional + imageFile: File? = null // Nullable and optional + ): Result { + return try { + val parts = mutableMapOf() + + // Required fields + parts["store_id"] = storeId.toString().toRequestBody("text/plain".toMediaType()) + parts["message"] = message.toRequestBody("text/plain".toMediaType()) + + // Optional: Only include if productId is valid + if (productId != null && productId > 0) { + parts["product_id"] = productId.toString().toRequestBody("text/plain".toMediaType()) + } + + // Optional: Only include if imageFile is valid + val imagePart = imageFile?.takeIf { it.exists() }?.let { file -> +// val requestFile = file.asRequestBody("image/*".toMediaType()) + val mimeType = when { + file.name.endsWith(".png", ignoreCase = true) -> "image/png" + file.name.endsWith(".jpg", ignoreCase = true) || file.name.endsWith(".jpeg", ignoreCase = true) -> "image/jpeg" + else -> "image/jpeg" // fallback + } + val requestFile = file.asRequestBody(mimeType.toMediaType()) + MultipartBody.Part.createFormData("chatimg", file.name, requestFile) + } + + // Log the parts map keys and values (string representations) + Log.d("ChatRepository", "Sending chat message with parts:") + parts.forEach { (key, body) -> + Log.d("ChatRepository", "Key: $key, Value (approx): ${bodyToString(body)}") + } + Log.d("ChatRepository", "Sending chat message with imagePart: ${imagePart != null}") + + // Send request + val response = apiService.sendChatMessageStore(parts, imagePart) + + if (response.isSuccessful) { + response.body()?.let { Result.Success(it) } ?: Result.Error(Exception("Empty response body")) + } else { + val errorMsg = response.errorBody()?.string().orEmpty() + Log.e("ChatRepository", "API Error: ${response.code()} - $errorMsg") + Result.Error(Exception("API Error: ${response.code()} - $errorMsg")) + } + + } catch (e: Exception) { + Log.e("ChatRepository", "Exception sending chat message", e) + Result.Error(e) + } + } + // Helper function to get string content from RequestBody (best effort) private fun bodyToString(requestBody: RequestBody): String { return try { @@ -217,4 +271,26 @@ class ChatRepository @Inject constructor( Result.Error(e) } } + + suspend fun getListChatStore(): Result> { + return try { + Log.d("ChatRepository", "Calling getChatListStore() from ApiService") + + val response = apiService.getChatListStore() + + Log.d("ChatRepository", "Response received: isSuccessful=${response.isSuccessful}, code=${response.code()}") + + if (response.isSuccessful) { + val chat = response.body()?.chat ?: emptyList() + Log.d("ChatRepository", "Chat list size: ${chat.size}") + Result.Success(chat) + } else { + Log.e("ChatRepository", "Failed response: ${response.errorBody()?.string()}") + Result.Error(Exception("Failed to fetch chat list. Code: ${response.code()}")) + } + } catch (e: Exception) { + Log.e("ChatRepository", "Exception during getChatListStore", e) + Result.Error(e) + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatViewModel.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatViewModel.kt index 1f15130..35edf9c 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatViewModel.kt @@ -41,6 +41,9 @@ class ChatViewModel @Inject constructor( private val _chatList = MutableLiveData>>() val chatList: LiveData>> = _chatList + private val _chatListStore = MutableLiveData>>() + val chatListStore: LiveData>> = _chatListStore + private val _storeDetail = MutableLiveData>() val storeDetail : LiveData> get() = _storeDetail @@ -367,6 +370,126 @@ class ChatViewModel @Inject constructor( } } + fun sendMessageStore(message: String) { + Log.d(TAG, "=== SEND MESSAGE ===") + Log.d(TAG, "Message: '$message'") + Log.d(TAG, "Has attachment: ${selectedImageFile != null}") + Log.d(TAG, "Selected image file: ${selectedImageFile?.absolutePath}") + Log.d(TAG, "File exists: ${selectedImageFile?.exists()}") + if (message.isBlank() && selectedImageFile == null) { + Log.e(TAG, "Cannot send message: Both message and image are empty") + return + } + + // Check if we have the necessary parameters + if (storeId <= 0) { + Log.e(TAG, "Cannot send message: Store ID is invalid") + updateState { it.copy(error = "Cannot send message. Invalid store ID.") } + return + } + + // Get the existing chatRoomId (not used in API but may be needed for Socket.IO) + val existingChatRoomId = _chatRoomId.value ?: 0 + + // Log debug information + Log.d(TAG, "Sending message with params: storeId=$storeId, productId=$productId") + Log.d(TAG, "Current user ID: $currentUserId") + Log.d(TAG, "Has attachment: ${selectedImageFile != null}") + + // Check image file size if present + selectedImageFile?.let { file -> + if (file.exists() && file.length() > 5 * 1024 * 1024) { // 5MB limit + updateState { it.copy(error = "Image file is too large. Please select a smaller image.") } + return + } + } + + viewModelScope.launch { + updateState { it.copy(isSending = true) } + + try { + // Send the message using the repository + // Note: We keep the chatRoomId parameter for compatibility with the repository method signature, + // but it's not actually used in the API call + val safeProductId = if (productId == 0) null else productId + + + val result = chatRepository.sendChatMessageStore( + storeId = storeId, + message = message, + productId = safeProductId, + imageFile = selectedImageFile + ) + + when (result) { + is 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}") + + // Update the chat room ID if it's the first message + val newChatRoomId = chatLine.chatRoomId + if (existingChatRoomId == 0 && newChatRoomId > 0) { + Log.d(TAG, "Chat room created: $newChatRoomId") + _chatRoomId.value = newChatRoomId + + // Now that we have a chat room ID, we can join the Socket.IO room + joinSocketRoom(newChatRoomId) + } + + // Emit the message via Socket.IO for real-time updates + socketService.sendMessage(chatLine) + + // Clear the image attachment + selectedImageFile = null + } + is Result.Error -> { + val errorMsg = if (result.exception.message.isNullOrEmpty() || result.exception.message == "{}") { + "Failed to send message. Please try again." + } else { + result.exception.message + } + + updateState { + it.copy( + isSending = false, + error = errorMsg + ) + } + Log.e(TAG, "Error sending message: ${result.exception.message}") + } + is Result.Loading -> { + updateState { it.copy(isSending = true) } + } + } + } catch (e: Exception) { + Log.e(TAG, "Exception in sendMessage", e) + updateState { + it.copy( + isSending = false, + error = "An unexpected error occurred: ${e.message}" + ) + } + } + } + } + /** * Updates a message status (delivered, read) */ @@ -488,6 +611,17 @@ class ChatViewModel @Inject constructor( _chatList.value = chatRepository.getListChat() } } + + fun getChatListStore() { + Log.d("ChatViewModel", "getChatListStore() called") + _chatListStore.value = Result.Loading + + viewModelScope.launch { + val result = chatRepository.getListChatStore() + Log.d("ChatViewModel", "getChatListStore() result: $result") + _chatListStore.value = result + } + } } /** diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/MyStoreActivity.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/MyStoreActivity.kt index a40035b..545d7b9 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/MyStoreActivity.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/MyStoreActivity.kt @@ -14,8 +14,8 @@ import com.alya.ecommerce_serang.data.api.retrofit.ApiConfig import com.alya.ecommerce_serang.data.api.retrofit.ApiService import com.alya.ecommerce_serang.data.repository.MyStoreRepository import com.alya.ecommerce_serang.databinding.ActivityMyStoreBinding -import com.alya.ecommerce_serang.ui.chat.ChatListFragment import com.alya.ecommerce_serang.ui.profile.mystore.balance.BalanceActivity +import com.alya.ecommerce_serang.ui.profile.mystore.chat.ChatListStoreActivity 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.review.ReviewFragment @@ -124,10 +124,8 @@ class MyStoreActivity : AppCompatActivity() { } binding.layoutInbox.setOnClickListener { - supportFragmentManager.beginTransaction() - .replace(android.R.id.content, ChatListFragment()) - .addToBackStack(null) - .commit() + val intent = Intent(this, ChatListStoreActivity::class.java) + startActivity(intent) } } diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatListAdapter.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatListAdapter.kt new file mode 100644 index 0000000..dd42766 --- /dev/null +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatListAdapter.kt @@ -0,0 +1,69 @@ +package com.alya.ecommerce_serang.ui.profile.mystore.chat + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.alya.ecommerce_serang.BuildConfig.BASE_URL +import com.alya.ecommerce_serang.R +import com.alya.ecommerce_serang.data.api.response.chat.ChatItemList +import com.alya.ecommerce_serang.databinding.ItemChatBinding +import com.bumptech.glide.Glide +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +class ChatListAdapter( + private val chatList: List, + private val onClick: (ChatItemList) -> Unit +) : RecyclerView.Adapter() { + + inner class ChatViewHolder(private val binding: ItemChatBinding) : + RecyclerView.ViewHolder(binding.root) { + fun bind(chat: ChatItemList) { + binding.txtStoreName.text = chat.storeName + binding.txtMessage.text = chat.message + binding.txtTime.text = formatTime(chat.latestMessageTime) + + // Process image URL properly + val imageUrl = chat.storeImage?.let { + if (it.startsWith("/")) BASE_URL + it else it + } + + Glide.with(binding.imgStore.context) + .load(imageUrl) + .placeholder(R.drawable.ic_person) + .error(R.drawable.placeholder_image) + .into(binding.imgStore) + + // Handle click event + binding.root.setOnClickListener { + onClick(chat) + } + } + + private fun formatTime(isoTime: String): String { + return try { + val inputFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault()) + inputFormat.timeZone = TimeZone.getTimeZone("UTC") + val date = inputFormat.parse(isoTime) + + val outputFormat = SimpleDateFormat("HH:mm", Locale.getDefault()) + outputFormat.format(date ?: Date()) + } catch (e: Exception) { + "" + } + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ChatViewHolder { + val binding = ItemChatBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return ChatViewHolder(binding) + } + + override fun getItemCount(): Int = chatList.size + + override fun onBindViewHolder(holder: ChatViewHolder, position: Int) { + holder.bind(chatList[position]) + } +} diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatListStoreActivity.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatListStoreActivity.kt new file mode 100644 index 0000000..045c957 --- /dev/null +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatListStoreActivity.kt @@ -0,0 +1,114 @@ +package com.alya.ecommerce_serang.ui.profile.mystore.chat + +import android.os.Bundle +import android.util.Log +import android.widget.Toast +import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +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.ChatRepository +import com.alya.ecommerce_serang.data.repository.Result +import com.alya.ecommerce_serang.databinding.ActivityChatListStoreBinding +import com.alya.ecommerce_serang.ui.chat.ChatViewModel +import com.alya.ecommerce_serang.ui.chat.SocketIOService +import com.alya.ecommerce_serang.utils.BaseViewModelFactory +import com.alya.ecommerce_serang.utils.SessionManager + +class ChatListStoreActivity : AppCompatActivity() { + private lateinit var binding: ActivityChatListStoreBinding + private lateinit var socketService: SocketIOService + private lateinit var apiService: ApiService + private lateinit var sessionManager: SessionManager + + private val TAG = "ChatListStoreActivity" + + private val viewModel: ChatViewModel by viewModels { + BaseViewModelFactory { + val apiService = ApiConfig.getApiService(sessionManager) + val chatRepository = ChatRepository(apiService) + ChatViewModel(chatRepository, socketService, sessionManager) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Initialize SessionManager and SocketService + sessionManager = SessionManager(this) + socketService = SocketIOService(sessionManager) + + // Inflate the layout and set content view + binding = ActivityChatListStoreBinding.inflate(layoutInflater) + setContentView(binding.root) + apiService = ApiConfig.getApiService(sessionManager) + + enableEdgeToEdge() + + setupToolbar() + + ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, windowInsets -> + val systemBars = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + view.setPadding( + systemBars.left, + systemBars.top, + systemBars.right, + systemBars.bottom + ) + windowInsets + } + + Log.d(TAG, "Fetching chat list from ViewModel") + viewModel.getChatListStore() + observeChatList() + } + + private fun setupToolbar(){ + binding.header.headerLeftIcon.setOnClickListener{ + finish() + } + binding.header.headerTitle.text = "Pesan" + } + + private fun observeChatList() { + viewModel.chatListStore.observe(this) { result -> + Log.d(TAG, "Observer triggered with result: $result") + + when (result) { + is Result.Success -> { + Log.d(TAG, "Chat list fetch success. Data size: ${result.data.size}") + val adapter = ChatListAdapter(result.data) { chatItem -> + Log.d(TAG, "Chat item clicked: storeId=${chatItem.storeId}, chatRoomId=${chatItem.chatRoomId}") + val intent = ChatStoreActivity.createIntent( + context = this, + storeId = chatItem.storeId, + productId = 0, + productName = null, + productPrice = "", + productImage = null, + productRating = null, + storeName = chatItem.storeName, + chatRoomId = chatItem.chatRoomId, + storeImage = chatItem.storeImage + ) + startActivity(intent) + } + binding.chatListRecyclerView.adapter = adapter + Log.d(TAG, "Adapter set successfully") + } + + is Result.Error -> { + Log.e(TAG, "Failed to load chats: ${result.exception.message}") + Toast.makeText(this, "Failed to load chats", Toast.LENGTH_SHORT).show() + } + + Result.Loading -> { + Log.d(TAG, "Chat list is loading...") + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatListStoreFragment.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatListStoreFragment.kt new file mode 100644 index 0000000..3e8e0b6 --- /dev/null +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatListStoreFragment.kt @@ -0,0 +1,96 @@ +//package com.alya.ecommerce_serang.ui.profile.mystore.chat +// +//import android.os.Bundle +//import android.view.LayoutInflater +//import android.view.View +//import android.view.ViewGroup +//import android.widget.Toast +//import androidx.fragment.app.Fragment +//import androidx.fragment.app.viewModels +//import com.alya.ecommerce_serang.data.api.retrofit.ApiConfig +//import com.alya.ecommerce_serang.data.repository.ChatRepository +//import com.alya.ecommerce_serang.data.repository.Result +//import com.alya.ecommerce_serang.databinding.FragmentChatListBinding +//import com.alya.ecommerce_serang.ui.chat.ChatViewModel +//import com.alya.ecommerce_serang.ui.chat.SocketIOService +//import com.alya.ecommerce_serang.utils.BaseViewModelFactory +//import com.alya.ecommerce_serang.utils.SessionManager +// +//class ChatListStoreFragment : Fragment() { +// +// private var _binding: FragmentChatListBinding? = null +// +// private val binding get() = _binding!! +// private lateinit var socketService: SocketIOService +// private lateinit var sessionManager: SessionManager +// +// private val viewModel: com.alya.ecommerce_serang.ui.chat.ChatViewModel by viewModels { +// BaseViewModelFactory { +// val apiService = ApiConfig.getApiService(sessionManager) +// val chatRepository = ChatRepository(apiService) +// ChatViewModel(chatRepository, socketService, sessionManager) +// } +// } +// override fun onCreate(savedInstanceState: Bundle?) { +// super.onCreate(savedInstanceState) +// sessionManager = SessionManager(requireContext()) +// socketService = SocketIOService(sessionManager) +// +// } +// +// override fun onCreateView( +// inflater: LayoutInflater, container: ViewGroup?, +// savedInstanceState: Bundle? +// ): View { +// _binding = FragmentChatListBinding.inflate(inflater, container, false) +// return _binding!!.root +// } +// +// override fun onViewCreated(view: View, savedInstanceState: Bundle?) { +// super.onViewCreated(view, savedInstanceState) +// +// viewModel.getChatListStore() +// observeChatList() +// } +// +// private fun observeChatList() { +// viewModel.chatListStore.observe(viewLifecycleOwner) { result -> +// when (result) { +// is Result.Success -> { +// val adapter = ChatListAdapter(result.data) { chatItem -> +// // Use the ChatActivity.createIntent factory method for proper navigation +// ChatStoreActivity.createIntent( +// context = requireActivity(), +// storeId = chatItem.storeId, +// productId = 0, // Default value since we don't have it in ChatListItem +// productName = null, // Null is acceptable as per ChatActivity +// productPrice = "", +// productImage = null, +// productRating = null, +// storeName = chatItem.storeName, +// chatRoomId = chatItem.chatRoomId, +// storeImage = chatItem.storeImage +// ) +// } +// binding.chatListRecyclerView.adapter = adapter +// } +// is Result.Error -> { +// Toast.makeText(requireContext(), "Failed to load chats", Toast.LENGTH_SHORT).show() +// } +// Result.Loading -> { +// // Optional: show progress bar +// } +// } +// } +// } +// +// +// override fun onDestroyView() { +// super.onDestroyView() +// _binding = null +// } +// +// companion object{ +// +// } +//} \ No newline at end of file diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatStoreActivity.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatStoreActivity.kt new file mode 100644 index 0000000..bf9541f --- /dev/null +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatStoreActivity.kt @@ -0,0 +1,593 @@ +package com.alya.ecommerce_serang.ui.profile.mystore.chat + +import android.Manifest +import android.app.Activity +import android.app.AlertDialog +import android.content.Context +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.view.inputmethod.InputMethodManager +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.WindowCompat +import androidx.core.view.WindowInsetsAnimationCompat +import androidx.core.view.WindowInsetsCompat +import androidx.lifecycle.Observer +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.api.retrofit.ApiService +import com.alya.ecommerce_serang.databinding.ActivityChatBinding +import com.alya.ecommerce_serang.ui.auth.LoginActivity +import com.alya.ecommerce_serang.ui.chat.ChatAdapter +import com.alya.ecommerce_serang.ui.chat.ChatViewModel +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 java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import javax.inject.Inject +import kotlin.math.max + +@AndroidEntryPoint +class ChatStoreActivity : AppCompatActivity() { + + private lateinit var binding: ActivityChatBinding + + @Inject + lateinit var sessionManager: SessionManager + + @Inject + lateinit var apiService: ApiService + + private lateinit var chatAdapter: ChatAdapter + + private val viewModel: ChatViewModel by viewModels() + + // 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 onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityChatBinding.inflate(layoutInflater) + setContentView(binding.root) + + sessionManager = SessionManager(this) + apiService = ApiConfig.getApiService(sessionManager) + + Log.d("ChatActivity", "Token in storage: '${sessionManager.getToken()}'") + + WindowCompat.setDecorFitsSystemWindows(window, false) + enableEdgeToEdge() + + // Apply insets to your root layout + + + // Get parameters from intent + val storeId = intent.getIntExtra(Constants.EXTRA_STORE_ID, 0) + val productId = intent.getIntExtra(Constants.EXTRA_PRODUCT_ID, 0) + val productName = intent.getStringExtra(Constants.EXTRA_PRODUCT_NAME) ?: "" + val productPrice = intent.getStringExtra(Constants.EXTRA_PRODUCT_PRICE) ?: "" + val productImage = intent.getStringExtra(Constants.EXTRA_PRODUCT_IMAGE) ?: "" + val productRating = intent.getFloatExtra(Constants.EXTRA_PRODUCT_RATING, 0f) + val storeName = intent.getStringExtra(Constants.EXTRA_STORE_NAME) ?: "" + val chatRoomId = intent.getIntExtra(Constants.EXTRA_CHAT_ROOM_ID, 0) + val storeImg = intent.getStringExtra(Constants.EXTRA_STORE_IMAGE) ?: "" + + // Check if user is logged in + val token = sessionManager.getToken() + + if (token.isEmpty()) { + // 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 + } + + binding.tvStoreName.text = storeName + val fullImageUrl = when (val img = storeImg) { + 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.imgProfile) + + ViewCompat.setOnApplyWindowInsetsListener(binding.layoutChatInput) { view, insets -> + val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime()) + val navBarInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars()) + + val bottomPadding = max(imeInsets.bottom, navBarInsets.bottom) + view.setPadding(view.paddingLeft, view.paddingTop, view.paddingRight, bottomPadding) + insets + } + +// Handle top inset on toolbar (status bar height) + ViewCompat.setOnApplyWindowInsetsListener(binding.chatToolbar) { view, insets -> + val statusBarHeight = insets.getInsets(WindowInsetsCompat.Type.statusBars()).top + view.setPadding(view.paddingLeft, statusBarHeight, view.paddingRight, view.paddingBottom) + insets + } + + ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerChat) { view, insets -> + val navBarInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars()) + val bottomPadding = binding.layoutChatInput.height + navBarInsets.bottom + + view.setPadding( + view.paddingLeft, + view.paddingTop, + view.paddingRight, + bottomPadding + ) + insets + } + +// For RecyclerView, add bottom padding = chat input height + nav bar height (to avoid last message hidden) + + ViewCompat.setWindowInsetsAnimationCallback(binding.root, + object : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_STOP) { + + private var startPaddingBottom = 0 + private var endPaddingBottom = 0 + + override fun onPrepare(animation: WindowInsetsAnimationCompat) { + startPaddingBottom = binding.layoutChatInput.paddingBottom + } + + override fun onStart( + animation: WindowInsetsAnimationCompat, + bounds: WindowInsetsAnimationCompat.BoundsCompat + ): WindowInsetsAnimationCompat.BoundsCompat { + endPaddingBottom = binding.layoutChatInput.paddingBottom + return bounds + } + + override fun onProgress( + insets: WindowInsetsCompat, + runningAnimations: MutableList + ): WindowInsetsCompat { + val imeAnimation = runningAnimations.find { + it.typeMask and WindowInsetsCompat.Type.ime() != 0 + } ?: return insets + + val animatedBottomPadding = startPaddingBottom + + (endPaddingBottom - startPaddingBottom) * imeAnimation.interpolatedFraction + + binding.layoutChatInput.setPadding( + binding.layoutChatInput.paddingLeft, + binding.layoutChatInput.paddingTop, + binding.layoutChatInput.paddingRight, + animatedBottomPadding.toInt() + ) + + binding.recyclerChat.setPadding( + binding.recyclerChat.paddingLeft, + binding.recyclerChat.paddingTop, + binding.recyclerChat.paddingRight, + animatedBottomPadding.toInt() + binding.layoutChatInput.height + ) + + return insets + } + }) + + // Set chat parameters to ViewModel + viewModel.setChatParameters( + storeId = storeId, + productId = productId, + productName = productName, + productPrice = productPrice, + productImage = productImage, + productRating = productRating, + storeName = storeName + ) + + // Setup UI components + setupRecyclerView() + setupListeners() + setupTypingIndicator() + observeViewModel() + + // If opened from ChatListFragment with a valid chatRoomId + if (chatRoomId > 0) { + // Directly set the chatRoomId and load chat history + viewModel._chatRoomId.value = chatRoomId + } + } + + private fun setupRecyclerView() { + chatAdapter = ChatAdapter() + binding.recyclerChat.apply { + adapter = chatAdapter + layoutManager = LinearLayoutManager(this@ChatStoreActivity).apply { + stackFromEnd = true + } + } +// binding.recyclerChat.setPadding( +// binding.recyclerChat.paddingLeft, +// binding.recyclerChat.paddingTop, +// binding.recyclerChat.paddingRight, +// binding.layoutChatInput.height + binding.root.rootWindowInsets?.getInsets(WindowInsetsCompat.Type.navigationBars())?.bottom ?: 0 +// ) + } + + + 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() + val currentState = viewModel.state.value + if (message.isNotEmpty() || (currentState != null && currentState.hasAttachment)) { + viewModel.sendMessageStore(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?) {} + }) + + binding.editTextMessage.requestFocus() + val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.showSoftInput(binding.editTextMessage, InputMethodManager.SHOW_IMPLICIT) + + } + + private fun observeViewModel() { + viewModel.chatRoomId.observe(this, Observer { chatRoomId -> + if (chatRoomId > 0) { + // Chat room has been created, now we can join the Socket.IO room + viewModel.joinSocketRoom(chatRoomId) + + // Now we can also load chat history + viewModel.loadChatHistory(chatRoomId) + Log.d(TAG, "Chat Activity started - Chat Room: $chatRoomId") + + } + }) + + // Observe state changes using LiveData + viewModel.state.observe(this, Observer { 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 + if (!state.productName.isNullOrEmpty()) { + 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 + binding.tvStoreName.text=state.storeName + + val fullImageUrl = when (val img = state.productImageUrl) { + is String -> { + if (img.startsWith("/")) BASE_URL + img.substring(1) else img + } + else -> R.drawable.placeholder_image + } + + // Load product image + if (!state.productImageUrl.isNullOrEmpty()) { + Glide.with(this@ChatStoreActivity) + .load(fullImageUrl) + .centerCrop() + .placeholder(R.drawable.placeholder_image) + .error(R.drawable.placeholder_image) + .into(binding.imgProduct) + } + + // Make sure the product section is visible + binding.productContainer.visibility = View.VISIBLE + } else { + // Hide the product section if info is missing + binding.productContainer.visibility = View.GONE + } + + + // 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 + + // Show error if any + state.error?.let { error -> + Toast.makeText(this@ChatStoreActivity, error, Toast.LENGTH_SHORT).show() + viewModel.clearError() + } + }) + } + + 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) { + try { + Log.d(TAG, "Processing selected image: $uri") + + // Always use the copy-to-cache approach for reliability + contentResolver.openInputStream(uri)?.use { inputStream -> + val fileName = "chat_img_${System.currentTimeMillis()}.jpg" + val outputFile = File(cacheDir, fileName) + + outputFile.outputStream().use { outputStream -> + inputStream.copyTo(outputStream) + } + + if (outputFile.exists() && outputFile.length() > 0) { + if (outputFile.length() > 5 * 1024 * 1024) { + Log.e(TAG, "File too large: ${outputFile.length()} bytes") + Toast.makeText(this, "Image too large (max 5MB)", Toast.LENGTH_SHORT).show() + return + } + + 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") + Toast.makeText(this, "Failed to process image", Toast.LENGTH_SHORT).show() + } + } ?: run { + Log.e(TAG, "Could not open input stream for URI: $uri") + Toast.makeText(this, "Could not access image", Toast.LENGTH_SHORT).show() + } + } catch (e: Exception) { + Log.e(TAG, "Error handling selected image", e) + Toast.makeText(this, "Error: ${e.message}", Toast.LENGTH_SHORT).show() + } + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == Constants.REQUEST_STORAGE_PERMISSION) { + if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + showImagePickerOptions() + } else { + Toast.makeText(this, R.string.permission_denied, Toast.LENGTH_SHORT).show() + } + } + } + + override fun onDestroy() { + super.onDestroy() + typingHandler.removeCallbacks(stopTypingRunnable) + } + + companion object { + private const val TAG = "ChatActivity" + + /** + * Create an intent to start the ChatActivity + */ + fun createIntent( + context: Activity, + storeId: Int, + productId: Int = 0, + productName: String? = null, + productPrice: String = "", + productImage: String? = null, + productRating: String? = null, + storeName: String? = null, + chatRoomId: Int = 0, + storeImage: String? = null + ): Intent { + return Intent(context, ChatStoreActivity::class.java).apply { + putExtra(Constants.EXTRA_STORE_ID, storeId) + putExtra(Constants.EXTRA_PRODUCT_ID, productId) + putExtra(Constants.EXTRA_PRODUCT_NAME, productName) + putExtra(Constants.EXTRA_PRODUCT_PRICE, productPrice) + putExtra(Constants.EXTRA_PRODUCT_IMAGE, productImage) + putExtra(Constants.EXTRA_STORE_IMAGE, storeImage) + + // Convert productRating string to float if provided + if (productRating != null) { + try { + putExtra(Constants.EXTRA_PRODUCT_RATING, productRating.toFloat()) + } catch (e: NumberFormatException) { + putExtra(Constants.EXTRA_PRODUCT_RATING, 0f) + } + } else { + putExtra(Constants.EXTRA_PRODUCT_RATING, 0f) + } + + putExtra(Constants.EXTRA_STORE_NAME, storeName) + + if (chatRoomId > 0) { + putExtra(Constants.EXTRA_CHAT_ROOM_ID, chatRoomId) + } + } + } + } +} + +//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) +// } +// } +// } \ No newline at end of file diff --git a/app/src/main/res/layout/activity_chat_list_store.xml b/app/src/main/res/layout/activity_chat_list_store.xml new file mode 100644 index 0000000..b3f8edf --- /dev/null +++ b/app/src/main/res/layout/activity_chat_list_store.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file From 3075af3f7bfd923affe0a5a56a9f8c1dc9b91086 Mon Sep 17 00:00:00 2001 From: shaulascr Date: Wed, 28 May 2025 22:38:58 +0700 Subject: [PATCH 05/10] update chat store --- .../data/repository/ChatRepository.kt | 4 +- .../ecommerce_serang/ui/chat/ChatViewModel.kt | 53 +++++++++++++++++-- .../mystore/chat/ChatListStoreActivity.kt | 3 +- .../profile/mystore/chat/ChatStoreActivity.kt | 8 ++- .../alya/ecommerce_serang/utils/Constants.kt | 1 + 5 files changed, 59 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/alya/ecommerce_serang/data/repository/ChatRepository.kt b/app/src/main/java/com/alya/ecommerce_serang/data/repository/ChatRepository.kt index 7ad1656..8838fe1 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/data/repository/ChatRepository.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/data/repository/ChatRepository.kt @@ -91,7 +91,7 @@ class ChatRepository @Inject constructor( } suspend fun sendChatMessageStore( - storeId: Int, + userId: Int, message: String, productId: Int?, // Nullable and optional imageFile: File? = null // Nullable and optional @@ -100,7 +100,7 @@ class ChatRepository @Inject constructor( val parts = mutableMapOf() // Required fields - parts["store_id"] = storeId.toString().toRequestBody("text/plain".toMediaType()) + parts["user_id"] = userId.toString().toRequestBody("text/plain".toMediaType()) parts["message"] = message.toRequestBody("text/plain".toMediaType()) // Optional: Only include if productId is valid diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatViewModel.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatViewModel.kt index 35edf9c..73a57b2 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatViewModel.kt @@ -134,6 +134,47 @@ class ChatViewModel @Inject constructor( } } + fun setChatParametersStore( + storeId: Int, + userId: Int, + productId: Int? = 0, + productName: String? = null, + productPrice: String? = null, + productImage: String? = null, + productRating: Float? = 0f, + storeName: String + ) { + this.productId = if (productId != null && productId > 0) productId else 0 + + this.storeId = storeId + this.defaultUserId = userId // Store the user_id for store-side chat + this.productName = productName.toString() + this.productPrice = productPrice.toString() + this.productImage = productImage.toString() + this.productRating = productRating!! + this.storeName = storeName + // Update state with product info + updateState { + it.copy( + productName = productName.toString(), + productPrice = productPrice.toString(), + productImageUrl = productImage.toString(), + productRating = productRating, + storeName = storeName + ) + } + + // Connect to socket and load chat history + val existingChatRoomId = _chatRoomId.value ?: 0 + if (existingChatRoomId > 0) { + // If we already have a chat room ID, we can load the chat history + loadChatHistory(existingChatRoomId) + + // And join the Socket.IO room + joinSocketRoom(existingChatRoomId) + } + } + fun joinSocketRoom(roomId: Int) { if (roomId <= 0) { Log.e(TAG, "Cannot join room: Invalid room ID") @@ -404,18 +445,20 @@ class ChatViewModel @Inject constructor( } } + if (defaultUserId <= 0) { // Check userId instead of storeId + Log.e(TAG, "Cannot send message: User ID is invalid") + updateState { it.copy(error = "Cannot send message. Invalid user ID.") } + return + } + viewModelScope.launch { updateState { it.copy(isSending = true) } try { - // Send the message using the repository - // Note: We keep the chatRoomId parameter for compatibility with the repository method signature, - // but it's not actually used in the API call val safeProductId = if (productId == 0) null else productId - val result = chatRepository.sendChatMessageStore( - storeId = storeId, + userId = defaultUserId, // Pass userId instead of storeId message = message, productId = safeProductId, imageFile = selectedImageFile diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatListStoreActivity.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatListStoreActivity.kt index 045c957..9620907 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatListStoreActivity.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatListStoreActivity.kt @@ -92,7 +92,8 @@ class ChatListStoreActivity : AppCompatActivity() { productRating = null, storeName = chatItem.storeName, chatRoomId = chatItem.chatRoomId, - storeImage = chatItem.storeImage + storeImage = chatItem.storeImage, + userId = chatItem.userId ) startActivity(intent) } diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatStoreActivity.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatStoreActivity.kt index bf9541f..c872f50 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatStoreActivity.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatStoreActivity.kt @@ -110,6 +110,7 @@ class ChatStoreActivity : AppCompatActivity() { // Get parameters from intent val storeId = intent.getIntExtra(Constants.EXTRA_STORE_ID, 0) + val userId = intent.getIntExtra(Constants.EXTRA_USER_ID, 0) val productId = intent.getIntExtra(Constants.EXTRA_PRODUCT_ID, 0) val productName = intent.getStringExtra(Constants.EXTRA_PRODUCT_NAME) ?: "" val productPrice = intent.getStringExtra(Constants.EXTRA_PRODUCT_PRICE) ?: "" @@ -222,8 +223,9 @@ class ChatStoreActivity : AppCompatActivity() { }) // Set chat parameters to ViewModel - viewModel.setChatParameters( + viewModel.setChatParametersStore( storeId = storeId, + userId = userId, // The user you want to chat with productId = productId, productName = productName, productPrice = productPrice, @@ -540,7 +542,8 @@ class ChatStoreActivity : AppCompatActivity() { productRating: String? = null, storeName: String? = null, chatRoomId: Int = 0, - storeImage: String? = null + storeImage: String? = null, + userId: Int ): Intent { return Intent(context, ChatStoreActivity::class.java).apply { putExtra(Constants.EXTRA_STORE_ID, storeId) @@ -549,6 +552,7 @@ class ChatStoreActivity : AppCompatActivity() { putExtra(Constants.EXTRA_PRODUCT_PRICE, productPrice) putExtra(Constants.EXTRA_PRODUCT_IMAGE, productImage) putExtra(Constants.EXTRA_STORE_IMAGE, storeImage) + putExtra(Constants.EXTRA_USER_ID, userId) // Convert productRating string to float if provided if (productRating != null) { diff --git a/app/src/main/java/com/alya/ecommerce_serang/utils/Constants.kt b/app/src/main/java/com/alya/ecommerce_serang/utils/Constants.kt index 225725f..4ec6689 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/utils/Constants.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/utils/Constants.kt @@ -21,6 +21,7 @@ object Constants { const val EXTRA_PRODUCT_IMAGE = "product_image" const val EXTRA_PRODUCT_RATING = "product_rating" const val EXTRA_STORE_IMAGE = "store_image" + const val EXTRA_USER_ID = "user_id" // Request codes const val REQUEST_IMAGE_PICK = 1001 From f4e130b93a611c0a235ccafeb797c61e0a2da86c Mon Sep 17 00:00:00 2001 From: shaulascr Date: Thu, 29 May 2025 02:46:31 +0700 Subject: [PATCH 06/10] update chat customer and store --- app/src/main/AndroidManifest.xml | 4 +- .../data/repository/ChatRepository.kt | 19 + .../ecommerce_serang/ui/chat/ChatActivity.kt | 332 ++++-- .../ecommerce_serang/ui/chat/ChatAdapter.kt | 156 ++- .../ecommerce_serang/ui/chat/ChatViewModel.kt | 972 +++++++++++------- .../ui/product/DetailProductActivity.kt | 6 +- .../mystore/chat/ChatListStoreActivity.kt | 7 +- .../profile/mystore/chat/ChatStoreActivity.kt | 272 +++-- .../profile/mystore/chat/ChatStoreAdapter.kt | 152 +++ ...ListAdapter.kt => ChatStoreListAdapter.kt} | 8 +- .../alya/ecommerce_serang/utils/Constants.kt | 5 + .../main/res/drawable/bg_product_bubble.xml | 9 + .../main/res/drawable/bg_product_normal.xml | 9 + .../main/res/drawable/bg_product_selected.xml | 9 + .../layout/item_message_product_received.xml | 87 ++ .../res/layout/item_message_product_sent.xml | 95 ++ 16 files changed, 1532 insertions(+), 610 deletions(-) create mode 100644 app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatStoreAdapter.kt rename app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/{ChatListAdapter.kt => ChatStoreListAdapter.kt} (92%) create mode 100644 app/src/main/res/drawable/bg_product_bubble.xml create mode 100644 app/src/main/res/drawable/bg_product_normal.xml create mode 100644 app/src/main/res/drawable/bg_product_selected.xml create mode 100644 app/src/main/res/layout/item_message_product_received.xml create mode 100644 app/src/main/res/layout/item_message_product_sent.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a5327c7..ddbfa98 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -67,7 +67,7 @@ + android:windowSoftInputMode="adjustResize" /> @@ -79,7 +79,7 @@ + android:windowSoftInputMode="adjustResize" /> - val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime()) - val navBarInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars()) - - val bottomPadding = max(imeInsets.bottom, navBarInsets.bottom) - view.setPadding(view.paddingLeft, view.paddingTop, view.paddingRight, bottomPadding) - insets - } - -// Handle top inset on toolbar (status bar height) - ViewCompat.setOnApplyWindowInsetsListener(binding.chatToolbar) { view, insets -> - val statusBarHeight = insets.getInsets(WindowInsetsCompat.Type.statusBars()).top - view.setPadding(view.paddingLeft, statusBarHeight, view.paddingRight, view.paddingBottom) - insets - } - - ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerChat) { view, insets -> - val navBarInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars()) - val bottomPadding = binding.layoutChatInput.height + navBarInsets.bottom - - view.setPadding( - view.paddingLeft, - view.paddingTop, - view.paddingRight, - bottomPadding - ) - insets - } - -// For RecyclerView, add bottom padding = chat input height + nav bar height (to avoid last message hidden) - - ViewCompat.setWindowInsetsAnimationCallback(binding.root, - object : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_STOP) { - - private var startPaddingBottom = 0 - private var endPaddingBottom = 0 - - override fun onPrepare(animation: WindowInsetsAnimationCompat) { - startPaddingBottom = binding.layoutChatInput.paddingBottom - } - - override fun onStart( - animation: WindowInsetsAnimationCompat, - bounds: WindowInsetsAnimationCompat.BoundsCompat - ): WindowInsetsAnimationCompat.BoundsCompat { - endPaddingBottom = binding.layoutChatInput.paddingBottom - return bounds - } - - override fun onProgress( - insets: WindowInsetsCompat, - runningAnimations: MutableList - ): WindowInsetsCompat { - val imeAnimation = runningAnimations.find { - it.typeMask and WindowInsetsCompat.Type.ime() != 0 - } ?: return insets - - val animatedBottomPadding = startPaddingBottom + - (endPaddingBottom - startPaddingBottom) * imeAnimation.interpolatedFraction - - binding.layoutChatInput.setPadding( - binding.layoutChatInput.paddingLeft, - binding.layoutChatInput.paddingTop, - binding.layoutChatInput.paddingRight, - animatedBottomPadding.toInt() - ) - - binding.recyclerChat.setPadding( - binding.recyclerChat.paddingLeft, - binding.recyclerChat.paddingTop, - binding.recyclerChat.paddingRight, - animatedBottomPadding.toInt() + binding.layoutChatInput.height - ) - - return insets - } - }) - // Set chat parameters to ViewModel viewModel.setChatParameters( storeId = storeId, @@ -230,8 +151,14 @@ class ChatActivity : AppCompatActivity() { storeName = storeName ) + if (shouldAttachProduct && productId > 0) { + viewModel.enableProductAttachment() + showProductAttachmentToast() + } + // Setup UI components setupRecyclerView() + setupWindowInsets() setupListeners() setupTypingIndicator() observeViewModel() @@ -243,20 +170,95 @@ class ChatActivity : AppCompatActivity() { } } + private fun showProductAttachmentToast() { + Toast.makeText( + this, + "Product will be attached to your message", + Toast.LENGTH_LONG + ).show() + } + private fun setupRecyclerView() { - chatAdapter = ChatAdapter() + chatAdapter = ChatAdapter { productInfo -> + // This lambda will be called when user taps on a product bubble + handleProductClick(productInfo) + } binding.recyclerChat.apply { adapter = chatAdapter - layoutManager = LinearLayoutManager(this@ChatActivity).apply { - stackFromEnd = true - } + layoutManager = LinearLayoutManager(this@ChatActivity) + // Use clipToPadding to allow content to scroll under padding + clipToPadding = false + // Set minimal padding - we'll handle spacing differently + setPadding(paddingLeft, paddingTop, paddingRight, 16) } -// binding.recyclerChat.setPadding( -// binding.recyclerChat.paddingLeft, -// binding.recyclerChat.paddingTop, -// binding.recyclerChat.paddingRight, -// binding.layoutChatInput.height + binding.root.rootWindowInsets?.getInsets(WindowInsetsCompat.Type.navigationBars())?.bottom ?: 0 -// ) + } + + private fun setupWindowInsets() { + ViewCompat.setOnApplyWindowInsetsListener(binding.chatToolbar) { view, insets -> + val statusBarInsets = insets.getInsets(WindowInsetsCompat.Type.statusBars()) + view.updatePadding(top = statusBarInsets.top) + insets + } + + // Handle IME (keyboard) and navigation bar insets for the input layout only + ViewCompat.setOnApplyWindowInsetsListener(binding.layoutChatInput) { view, insets -> + val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime()) + val navBarInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars()) + + Log.d(TAG, "Insets - IME: ${imeInsets.bottom}, NavBar: ${navBarInsets.bottom}") + + val bottomInset = if (imeInsets.bottom > 0) { + imeInsets.bottom + } else { + navBarInsets.bottom + } + + // Only apply padding to the input layout + view.updatePadding(bottom = bottomInset) + + // When keyboard appears, scroll to bottom to keep last message visible + if (imeInsets.bottom > 0) { + // Keyboard is visible - scroll to bottom with delay to ensure layout is complete + binding.recyclerChat.postDelayed({ + scrollToBottomSmooth() + }, 100) + } + + insets + } + + // Smooth animation for keyboard transitions + ViewCompat.setWindowInsetsAnimationCallback( + binding.layoutChatInput, + object : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_STOP) { + + override fun onProgress( + insets: WindowInsetsCompat, + runningAnimations: MutableList + ): WindowInsetsCompat { + val imeAnimation = runningAnimations.find { + it.typeMask and WindowInsetsCompat.Type.ime() != 0 + } + + if (imeAnimation != null) { + val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime()) + val navBarInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars()) + val targetBottomInset = if (imeInsets.bottom > 0) imeInsets.bottom else navBarInsets.bottom + + // Only animate input layout padding + binding.layoutChatInput.updatePadding(bottom = targetBottomInset) + } + + return insets + } + + override fun onEnd(animation: WindowInsetsAnimationCompat) { + super.onEnd(animation) + // Smooth scroll to bottom after animation + scrollToBottomSmooth() + } + } + ) } @@ -276,8 +278,14 @@ class ChatActivity : AppCompatActivity() { val message = binding.editTextMessage.text.toString().trim() val currentState = viewModel.state.value if (message.isNotEmpty() || (currentState != null && currentState.hasAttachment)) { + // This will automatically handle product attachment if enabled viewModel.sendMessage(message) binding.editTextMessage.text.clear() + + // Instantly scroll to show new message + binding.recyclerChat.postDelayed({ + scrollToBottomInstant() + }, 50) } } @@ -285,6 +293,38 @@ class ChatActivity : AppCompatActivity() { binding.btnAttachment.setOnClickListener { checkPermissionsAndShowImagePicker() } + + // 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() + } + } + + private fun updateProductAttachmentUI(isEnabled: Boolean) { + if (isEnabled) { + // Show visual indicator that product will be attached + binding.productContainer.setBackgroundResource(R.drawable.bg_product_selected) + binding.editTextMessage.hint = "Type your message (product will be attached)" + } else { + // Reset to normal state + binding.productContainer.setBackgroundResource(R.drawable.bg_product_normal) + binding.editTextMessage.hint = getString(R.string.write_message) + } } private fun setupTypingIndicator() { @@ -302,33 +342,64 @@ class ChatActivity : AppCompatActivity() { override fun afterTextChanged(s: Editable?) {} }) + // Focus and show keyboard binding.editTextMessage.requestFocus() - val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - imm.showSoftInput(binding.editTextMessage, InputMethodManager.SHOW_IMPLICIT) + binding.editTextMessage.post { + val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.showSoftInput(binding.editTextMessage, InputMethodManager.SHOW_IMPLICIT) + } + } + private fun scrollToBottomSmooth() { + val messageCount = chatAdapter.itemCount + if (messageCount > 0) { + binding.recyclerChat.post { + // Use smooth scroll to bottom + binding.recyclerChat.smoothScrollToPosition(messageCount - 1) + } + } + } + + private fun scrollToBottomInstant() { + val messageCount = chatAdapter.itemCount + if (messageCount > 0) { + binding.recyclerChat.post { + // Instant scroll for new messages + binding.recyclerChat.scrollToPosition(messageCount - 1) + } + } + } + + // Extension function to make padding updates cleaner + private fun View.updatePadding( + left: Int = paddingLeft, + top: Int = paddingTop, + right: Int = paddingRight, + bottom: Int = paddingBottom + ) { + setPadding(left, top, right, bottom) } private fun observeViewModel() { viewModel.chatRoomId.observe(this, Observer { chatRoomId -> if (chatRoomId > 0) { - // Chat room has been created, now we can join the Socket.IO room viewModel.joinSocketRoom(chatRoomId) - - // Now we can also load chat history viewModel.loadChatHistory(chatRoomId) Log.d(TAG, "Chat Activity started - Chat Room: $chatRoomId") - } }) - // Observe state changes using LiveData viewModel.state.observe(this, Observer { state -> - // Update messages - chatAdapter.submitList(state.messages) + Log.d(TAG, "State updated - Messages: ${state.messages.size}") - // Scroll to bottom if new message - if (state.messages.isNotEmpty()) { - binding.recyclerChat.scrollToPosition(state.messages.size - 1) + // Update messages + val previousCount = chatAdapter.itemCount + chatAdapter.submitList(state.messages) { + Log.d(TAG, "Messages submitted to adapter") + // Only auto-scroll for new messages or initial load + if (previousCount == 0 || state.messages.size > previousCount) { + scrollToBottomInstant() + } } // Update product info @@ -338,7 +409,7 @@ class ChatActivity : AppCompatActivity() { binding.ratingBar.rating = state.productRating binding.tvRating.text = state.productRating.toString() binding.tvSellerName.text = state.storeName - binding.tvStoreName.text=state.storeName + binding.tvStoreName.text = state.storeName val fullImageUrl = when (val img = state.productImageUrl) { is String -> { @@ -347,7 +418,6 @@ class ChatActivity : AppCompatActivity() { else -> R.drawable.placeholder_image } - // Load product image if (!state.productImageUrl.isNullOrEmpty()) { Glide.with(this@ChatActivity) .load(fullImageUrl) @@ -357,13 +427,15 @@ class ChatActivity : AppCompatActivity() { .into(binding.imgProduct) } - // Make sure the product section is visible binding.productContainer.visibility = View.VISIBLE } else { - // Hide the product section if info is missing binding.productContainer.visibility = View.GONE } + updateInputHint(state) + + // Update product card visual feedback + updateProductCardUI(state.hasProductAttachment) // Update attachment hint if (state.hasAttachment) { @@ -372,7 +444,6 @@ class ChatActivity : AppCompatActivity() { binding.editTextMessage.hint = getString(R.string.write_message) } - // Show typing indicator binding.tvTypingIndicator.visibility = if (state.isOtherUserTyping) View.VISIBLE else View.GONE @@ -385,6 +456,45 @@ class ChatActivity : AppCompatActivity() { }) } + private fun updateInputHint(state: ChatUiState) { + binding.editTextMessage.hint = when { + state.hasAttachment -> getString(R.string.image_attached) + state.hasProductAttachment -> "Type your message (product will be attached)" + else -> getString(R.string.write_message) + } + } + + private fun updateProductCardUI(hasProductAttachment: Boolean) { + if (hasProductAttachment) { + // Show visual indicator that product will be attached + binding.productContainer.setBackgroundResource(R.drawable.bg_product_selected) + } else { + // Reset to normal state + binding.productContainer.setBackgroundResource(R.drawable.bg_product_normal) + } + } + + private fun handleProductClick(productInfo: ProductInfo) { + // Navigate to product detail + Toast.makeText(this, "Opening: ${productInfo.productName}", Toast.LENGTH_SHORT).show() + + // You can navigate to product detail here + navigateToProductDetail(productInfo.productId) + } + + private fun navigateToProductDetail(productId: Int) { + try { + val intent = Intent(this, DetailProductActivity::class.java).apply { + putExtra("PRODUCT_ID", productId) + // Add other necessary extras + } + startActivity(intent) + } catch (e: Exception) { + Toast.makeText(this, "Cannot open product details", Toast.LENGTH_SHORT).show() + Log.e(TAG, "Error navigating to product detail", e) + } + } + private fun showOptionsMenu() { val options = arrayOf( getString(R.string.block_user), @@ -538,7 +648,8 @@ class ChatActivity : AppCompatActivity() { productRating: String? = null, storeName: String? = null, chatRoomId: Int = 0, - storeImage: String? = null + storeImage: String? = null, + attachProduct: Boolean = false // NEW: Flag to auto-attach product ) { val intent = Intent(context, ChatActivity::class.java).apply { putExtra(Constants.EXTRA_STORE_ID, storeId) @@ -547,6 +658,7 @@ class ChatActivity : AppCompatActivity() { putExtra(Constants.EXTRA_PRODUCT_PRICE, productPrice) putExtra(Constants.EXTRA_PRODUCT_IMAGE, productImage) putExtra(Constants.EXTRA_STORE_IMAGE, storeImage) + putExtra(Constants.EXTRA_ATTACH_PRODUCT, attachProduct) // NEW // Convert productRating string to float if provided if (productRating != null) { diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatAdapter.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatAdapter.kt index 484803f..05cfbfc 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatAdapter.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatAdapter.kt @@ -8,56 +8,71 @@ 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.ItemMessageProductReceivedBinding +import com.alya.ecommerce_serang.databinding.ItemMessageProductSentBinding import com.alya.ecommerce_serang.databinding.ItemMessageReceivedBinding import com.alya.ecommerce_serang.databinding.ItemMessageSentBinding import com.alya.ecommerce_serang.utils.Constants import com.bumptech.glide.Glide -class ChatAdapter : ListAdapter(ChatMessageDiffCallback()) { +class ChatAdapter( + private val onProductClick: ((ProductInfo) -> Unit)? = null +) : ListAdapter(ChatMessageDiffCallback()) { companion object { private const val VIEW_TYPE_MESSAGE_SENT = 1 private const val VIEW_TYPE_MESSAGE_RECEIVED = 2 + private const val VIEW_TYPE_PRODUCT_SENT = 3 + private const val VIEW_TYPE_PRODUCT_RECEIVED = 4 + } + + override fun getItemViewType(position: Int): Int { + val message = getItem(position) + return when { + message.messageType == MessageType.PRODUCT && message.isSentByMe -> VIEW_TYPE_PRODUCT_SENT + message.messageType == MessageType.PRODUCT && !message.isSentByMe -> VIEW_TYPE_PRODUCT_RECEIVED + message.isSentByMe -> VIEW_TYPE_MESSAGE_SENT + else -> VIEW_TYPE_MESSAGE_RECEIVED + } } 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) + val inflater = LayoutInflater.from(parent.context) + + return when (viewType) { + VIEW_TYPE_MESSAGE_SENT -> { + val binding = ItemMessageSentBinding.inflate(inflater, parent, false) + SentMessageViewHolder(binding) + } + VIEW_TYPE_MESSAGE_RECEIVED -> { + val binding = ItemMessageReceivedBinding.inflate(inflater, parent, false) + ReceivedMessageViewHolder(binding) + } + VIEW_TYPE_PRODUCT_SENT -> { + val binding = ItemMessageProductSentBinding.inflate(inflater, parent, false) + SentProductViewHolder(binding) + } + VIEW_TYPE_PRODUCT_RECEIVED -> { + val binding = ItemMessageProductReceivedBinding.inflate(inflater, parent, false) + ReceivedProductViewHolder(binding) + } + else -> throw IllegalArgumentException("Unknown view type: $viewType") } } 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 + when (holder) { + is SentMessageViewHolder -> holder.bind(message) + is ReceivedMessageViewHolder -> holder.bind(message) + is SentProductViewHolder -> holder.bind(message) + is ReceivedProductViewHolder -> holder.bind(message) } } /** - * ViewHolder for messages sent by the current user + * ViewHolder for regular messages sent by the current user */ inner class SentMessageViewHolder(private val binding: ItemMessageSentBinding) : RecyclerView.ViewHolder(binding.root) { @@ -99,7 +114,7 @@ class ChatAdapter : ListAdapter(ChatMess } /** - * ViewHolder for messages received from other users + * ViewHolder for regular messages received from other users */ inner class ReceivedMessageViewHolder(private val binding: ItemMessageReceivedBinding) : RecyclerView.ViewHolder(binding.root) { @@ -135,6 +150,89 @@ class ChatAdapter : ListAdapter(ChatMess .into(binding.imgAvatar) } } + + /** + * ViewHolder for product messages sent by the current user + */ + inner class SentProductViewHolder(private val binding: ItemMessageProductSentBinding) : + RecyclerView.ViewHolder(binding.root) { + + fun bind(message: ChatUiMessage) { + // For product bubble, we don't show the text message here + binding.tvTimestamp.text = message.time + + // Show message status + val statusIcon = when (message.status) { + Constants.STATUS_SENT -> R.drawable.check_single_24 + Constants.STATUS_DELIVERED -> R.drawable.check_double_24 + Constants.STATUS_READ -> R.drawable.check_double_read_24 + else -> R.drawable.check_single_24 + } + binding.imgStatus.setImageResource(statusIcon) + + // Bind product info + message.productInfo?.let { product -> + binding.tvProductName.text = product.productName + binding.tvProductPrice.text = product.productPrice + + // Load product image + val fullImageUrl = if (product.productImage.startsWith("/")) { + BASE_URL + product.productImage.substring(1) + } else { + product.productImage + } + + Glide.with(binding.root.context) + .load(fullImageUrl) + .centerCrop() + .placeholder(R.drawable.placeholder_image) + .error(R.drawable.placeholder_image) + .into(binding.imgProduct) + + // Handle product click + binding.layoutProduct.setOnClickListener { + onProductClick?.invoke(product) + } + } + } + } + + /** + * ViewHolder for product messages received from other users + */ + inner class ReceivedProductViewHolder(private val binding: ItemMessageProductReceivedBinding) : + RecyclerView.ViewHolder(binding.root) { + + fun bind(message: ChatUiMessage) { + // For product bubble, we don't show the text message here + binding.tvTimestamp.text = message.time + + // Bind product info + message.productInfo?.let { product -> + binding.tvProductName.text = product.productName + binding.tvProductPrice.text = product.productPrice + + // Load product image + val fullImageUrl = if (product.productImage.startsWith("/")) { + BASE_URL + product.productImage.substring(1) + } else { + product.productImage + } + + Glide.with(binding.root.context) + .load(fullImageUrl) + .centerCrop() + .placeholder(R.drawable.placeholder_image) + .error(R.drawable.placeholder_image) + .into(binding.imgProduct) + + // Handle product click + binding.layoutProduct.setOnClickListener { + onProductClick?.invoke(product) + } + } + } + } } /** diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatViewModel.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatViewModel.kt index 73a57b2..2b8383f 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/chat/ChatViewModel.kt @@ -24,12 +24,12 @@ import javax.inject.Inject class ChatViewModel @Inject constructor( private val chatRepository: ChatRepository, private val socketService: SocketIOService, - - private val sessionManager: SessionManager ) : ViewModel() { private val TAG = "ChatViewModel" + // Product attachment flag + private var shouldAttachProduct = false // UI state using LiveData private val _state = MutableLiveData(ChatUiState()) @@ -45,9 +45,9 @@ class ChatViewModel @Inject constructor( val chatListStore: LiveData>> = _chatListStore private val _storeDetail = MutableLiveData>() - val storeDetail : LiveData> get() = _storeDetail + val storeDetail: LiveData> get() = _storeDetail - // Store and product parameters + // Chat parameters private var storeId: Int = 0 private var productId: Int = 0 private var currentUserId: Int? = null @@ -64,36 +64,39 @@ class ChatViewModel @Inject constructor( private var selectedImageFile: File? = null init { - // Try to get current user ID from the repository + Log.d(TAG, "ChatViewModel initialized") + initializeUser() + } + + private fun initializeUser() { viewModelScope.launch { + Log.d(TAG, "Initializing user session...") + when (val result = chatRepository.fetchUserProfile()) { is Result.Success -> { currentUserId = result.data?.userId - Log.e(TAG, "User ID: $currentUserId") + Log.d(TAG, "User session initialized - User ID: $currentUserId") - // Move the validation and subsequent logic inside the coroutine if (currentUserId == null || currentUserId == 0) { - Log.e(TAG, "Error: User ID is not set or invalid") + Log.e(TAG, "Invalid user ID detected") updateState { it.copy(error = "User authentication error. Please login again.") } } else { - // Set up socket listeners + Log.d(TAG, "Setting up socket listeners...") setupSocketListeners() } } is Result.Error -> { - Log.e(TAG, "Error fetching user profile: ${result.exception.message}") + Log.e(TAG, "Failed to fetch user profile: ${result.exception.message}") updateState { it.copy(error = "User authentication error. Please login again.") } } is Result.Loading -> { - // Handle loading state if needed + Log.d(TAG, "Loading user profile...") } } } } - /** - * Set chat parameters received from activity - */ + // set chat parameter for buyer fun setChatParameters( storeId: Int, productId: Int? = 0, @@ -103,8 +106,9 @@ class ChatViewModel @Inject constructor( productRating: Float? = 0f, storeName: String ) { - this.productId = if (productId != null && productId > 0) productId else 0 + Log.d(TAG, "Setting chat parameters - StoreID: $storeId, ProductID: $productId") + this.productId = if (productId != null && productId > 0) productId else 0 this.storeId = storeId this.productName = productName.toString() this.productPrice = productPrice.toString() @@ -112,7 +116,6 @@ class ChatViewModel @Inject constructor( this.productRating = productRating!! this.storeName = storeName - // Update state with product info updateState { it.copy( productName = productName.toString(), @@ -123,17 +126,15 @@ class ChatViewModel @Inject constructor( ) } - // Connect to socket and load chat history val existingChatRoomId = _chatRoomId.value ?: 0 if (existingChatRoomId > 0) { - // If we already have a chat room ID, we can load the chat history + Log.d(TAG, "Loading existing chat room: $existingChatRoomId") loadChatHistory(existingChatRoomId) - - // And join the Socket.IO room joinSocketRoom(existingChatRoomId) } } + // set chat parameter for store fun setChatParametersStore( storeId: Int, userId: Int, @@ -144,16 +145,17 @@ class ChatViewModel @Inject constructor( productRating: Float? = 0f, storeName: String ) { - this.productId = if (productId != null && productId > 0) productId else 0 + Log.d(TAG, "Setting store chat parameters - StoreID: $storeId, UserID: $userId, ProductID: $productId") + this.productId = if (productId != null && productId > 0) productId else 0 this.storeId = storeId - this.defaultUserId = userId // Store the user_id for store-side chat + this.defaultUserId = userId this.productName = productName.toString() this.productPrice = productPrice.toString() this.productImage = productImage.toString() this.productRating = productRating!! this.storeName = storeName - // Update state with product info + updateState { it.copy( productName = productName.toString(), @@ -164,95 +166,111 @@ class ChatViewModel @Inject constructor( ) } - // Connect to socket and load chat history val existingChatRoomId = _chatRoomId.value ?: 0 if (existingChatRoomId > 0) { - // If we already have a chat room ID, we can load the chat history + Log.d(TAG, "Loading existing store chat room: $existingChatRoomId") loadChatHistory(existingChatRoomId) - - // And join the Socket.IO room joinSocketRoom(existingChatRoomId) } } + //enable product attach from detailproductactivity + fun enableProductAttachment() { + Log.d(TAG, "Product attachment enabled - ProductID: $productId, ProductName: $productName") + shouldAttachProduct = true + updateState { it.copy(hasProductAttachment = true) } + } + + // disable product attach + fun disableProductAttachment() { + Log.d(TAG, "Product attachment disabled") + shouldAttachProduct = false + updateState { it.copy(hasProductAttachment = false) } + } + + private fun setupSocketListeners() { + Log.d(TAG, "Setting up socket listeners...") + + viewModelScope.launch { + socketService.connectionState.collect { connectionState -> + Log.d(TAG, "Socket connection state changed: $connectionState") + updateState { it.copy(connectionState = connectionState) } + + if (connectionState is ConnectionState.Connected) { + Log.d(TAG, "Socket connected, joining room...") + socketService.joinRoom() + } + } + } + + viewModelScope.launch { + socketService.newMessages.collect { chatLine -> + chatLine?.let { + Log.d(TAG, "New message received via socket - ID: ${it.id}, SenderID: ${it.senderId}") + val currentMessages = _state.value?.messages ?: listOf() + val updatedMessages = currentMessages.toMutableList().apply { + add(convertChatLineToUiMessage(it)) + } + updateState { it.copy(messages = updatedMessages) } + + if (it.senderId != currentUserId) { + Log.d(TAG, "Marking message as read: ${it.id}") + updateMessageStatus(it.id, Constants.STATUS_READ) + } + } + } + } + + viewModelScope.launch { + socketService.typingStatus.collect { typingStatus -> + typingStatus?.let { + val currentRoomId = _chatRoomId.value ?: 0 + if (typingStatus.roomId == currentRoomId && typingStatus.userId != currentUserId) { + Log.d(TAG, "Typing status updated: ${typingStatus.isTyping}") + updateState { it.copy(isOtherUserTyping = typingStatus.isTyping) } + } + } + } + } + } + fun joinSocketRoom(roomId: Int) { if (roomId <= 0) { Log.e(TAG, "Cannot join room: Invalid room ID") return } + Log.d(TAG, "Joining socket room: $roomId") socketService.joinRoom() } - /** - * 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.value ?: 0) && 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(chatRoomId: Int) { - if (chatRoomId <= 0) { - Log.e(TAG, "Cannot load chat history: Chat room ID is 0") + fun sendTypingStatus(isTyping: Boolean) { + val roomId = _chatRoomId.value ?: 0 + if (roomId <= 0) { + Log.w(TAG, "Cannot send typing status: No active room") return } + Log.d(TAG, "Sending typing status: $isTyping for room: $roomId") + socketService.sendTypingStatus(roomId, isTyping) + } + + // load chat history + fun loadChatHistory(chatRoomId: Int) { + if (chatRoomId <= 0) { + Log.e(TAG, "Cannot load chat history: Invalid chat room ID") + return + } + + Log.d(TAG, "Loading chat history for room: $chatRoomId") + viewModelScope.launch { updateState { it.copy(isLoading = true) } when (val result = chatRepository.getChatHistory(chatRoomId)) { is Result.Success -> { + Log.d(TAG, "Chat history loaded successfully - ${result.data.chat.size} messages") + val messages = result.data.chat.map { chatLine -> convertChatLineToUiMessageHistory(chatLine) } @@ -265,21 +283,24 @@ class ChatViewModel @Inject constructor( ) } - 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) } + val unreadMessages = result.data.chat.filter { + it.senderId != currentUserId && it.status != Constants.STATUS_READ + } + + if (unreadMessages.isNotEmpty()) { + Log.d(TAG, "Marking ${unreadMessages.size} messages as read") + unreadMessages.forEach { updateMessageStatus(it.id, Constants.STATUS_READ) } + } } is Result.Error -> { + Log.e(TAG, "Error loading chat history: ${result.exception.message}") 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) } @@ -288,52 +309,329 @@ class ChatViewModel @Inject constructor( } } - /** - * Sends a chat message - */ + fun getChatList() { + Log.d(TAG, "Getting chat list...") + viewModelScope.launch { + _chatList.value = Result.Loading + _chatList.value = chatRepository.getListChat() + } + } + + fun getChatListStore() { + Log.d(TAG, "Getting store chat list...") + _chatListStore.value = Result.Loading + + viewModelScope.launch { + val result = chatRepository.getListChatStore() + Log.d(TAG, "Store chat list result: $result") + _chatListStore.value = result + } + } + + // handle regular message and product message fun sendMessage(message: String) { Log.d(TAG, "=== SEND MESSAGE ===") Log.d(TAG, "Message: '$message'") - Log.d(TAG, "Has attachment: ${selectedImageFile != null}") - Log.d(TAG, "Selected image file: ${selectedImageFile?.absolutePath}") - Log.d(TAG, "File exists: ${selectedImageFile?.exists()}") + Log.d(TAG, "Should attach product: $shouldAttachProduct") + Log.d(TAG, "Has image attachment: ${selectedImageFile != null}") + Log.d(TAG, "Product ID: $productId") + if (message.isBlank() && selectedImageFile == null) { - Log.e(TAG, "Cannot send message: Both message and image are empty") + Log.w(TAG, "Cannot send message: Both message and image are empty") return } - // Check if we have the necessary parameters if (storeId <= 0) { - Log.e(TAG, "Cannot send message: Store ID is invalid") + Log.e(TAG, "Cannot send message: Invalid store ID") updateState { it.copy(error = "Cannot send message. Invalid store ID.") } return } - // Get the existing chatRoomId (not used in API but may be needed for Socket.IO) - val existingChatRoomId = _chatRoomId.value ?: 0 + // Check for product attachment + if (shouldAttachProduct && productId > 0) { + Log.d(TAG, "Sending message with product attachment") + sendMessageWithProduct(message) + shouldAttachProduct = false + updateState { it.copy(hasProductAttachment = false) } + return + } - // Log debug information - Log.d(TAG, "Sending message with params: storeId=$storeId, productId=$productId") - Log.d(TAG, "Current user ID: $currentUserId") + Log.d(TAG, "Sending regular message") + sendRegularMessage(message) + } + + //send message for store + fun sendMessageStore(message: String) { + Log.d(TAG, "=== SEND MESSAGE STORE ===") + Log.d(TAG, "Message: '$message'") Log.d(TAG, "Has attachment: ${selectedImageFile != null}") + Log.d(TAG, "Default User ID: $defaultUserId") + + if (message.isBlank() && selectedImageFile == null) { + Log.w(TAG, "Cannot send store message: Both message and image are empty") + return + } + + if (storeId <= 0) { + Log.e(TAG, "Cannot send store message: Invalid store ID") + updateState { it.copy(error = "Cannot send message. Invalid store ID.") } + return + } + + if (defaultUserId <= 0) { + Log.e(TAG, "Cannot send store message: Invalid user ID") + updateState { it.copy(error = "Cannot send message. Invalid user ID.") } + return + } - // Check image file size if present selectedImageFile?.let { file -> - if (file.exists() && file.length() > 5 * 1024 * 1024) { // 5MB limit + if (file.exists() && file.length() > 5 * 1024 * 1024) { + Log.e(TAG, "Image file too large: ${file.length()} bytes") updateState { it.copy(error = "Image file is too large. Please select a smaller image.") } return } } + val existingChatRoomId = _chatRoomId.value ?: 0 + Log.d(TAG, "Sending store message - StoreID: $storeId, UserID: $defaultUserId, RoomID: $existingChatRoomId") + viewModelScope.launch { updateState { it.copy(isSending = true) } try { - // Send the message using the repository - // Note: We keep the chatRoomId parameter for compatibility with the repository method signature, - // but it's not actually used in the API call val safeProductId = if (productId == 0) null else productId + val result = chatRepository.sendChatMessageStore( + userId = defaultUserId, + message = message, + productId = safeProductId, + imageFile = selectedImageFile + ) + + when (result) { + is Result.Success -> { + val chatLine = result.data.chatLine + Log.d(TAG, "Store message sent successfully - ID: ${chatLine.id}") + + 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 + ) + } + + handleChatRoomCreation(existingChatRoomId, chatLine.chatRoomId) + socketService.sendMessage(chatLine) + selectedImageFile = null + } + is Result.Error -> { + val errorMsg = result.exception.message?.takeIf { it.isNotBlank() && it != "{}" } + ?: "Failed to send message. Please try again." + + Log.e(TAG, "Error sending store message: $errorMsg") + updateState { + it.copy( + isSending = false, + error = errorMsg + ) + } + } + is Result.Loading -> { + updateState { it.copy(isSending = true) } + } + } + } catch (e: Exception) { + Log.e(TAG, "Exception in sendMessageStore", e) + updateState { + it.copy( + isSending = false, + error = "An unexpected error occurred: ${e.message}" + ) + } + } + } + } + + // send message with product info + private fun sendMessageWithProduct(userMessage: String) { + Log.d(TAG, "Sending message with product attachment") + Log.d(TAG, "User message: '$userMessage'") + Log.d(TAG, "Product: $productName") + + // Send product bubble FIRST + sendProductBubble() + + // Then send user's text message after a small delay + viewModelScope.launch { + kotlinx.coroutines.delay(100) + sendTextMessage(userMessage) + } + } + + // send only text message w/o product info + private fun sendTextMessage(message: String) { + if (message.isBlank()) { + Log.w(TAG, "Cannot send text message: Message is blank") + return + } + + Log.d(TAG, "Sending text message: '$message'") + + viewModelScope.launch { + updateState { it.copy(isSending = true) } + + try { + val result = chatRepository.sendChatMessage( + storeId = storeId, + message = message, + productId = null, + imageFile = selectedImageFile + ) + + when (result) { + is Result.Success -> { + val chatLine = result.data.chatLine + Log.d(TAG, "Text message sent successfully - ID: ${chatLine.id}") + + 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 + ) + } + + val existingChatRoomId = _chatRoomId.value ?: 0 + handleChatRoomCreation(existingChatRoomId, chatLine.chatRoomId) + socketService.sendMessage(chatLine) + selectedImageFile = null + } + is Result.Error -> { + Log.e(TAG, "Error sending text message: ${result.exception.message}") + updateState { + it.copy( + isSending = false, + error = result.exception.message ?: "Failed to send message" + ) + } + } + is Result.Loading -> { + updateState { it.copy(isSending = true) } + } + } + } catch (e: Exception) { + Log.e(TAG, "Exception in sendTextMessage", e) + updateState { + it.copy( + isSending = false, + error = "An unexpected error occurred: ${e.message}" + ) + } + } + } + } + + // send product bubble message + private fun sendProductBubble() { + Log.d(TAG, "Sending product bubble - ProductID: $productId, ProductName: $productName") + + viewModelScope.launch { + try { + val result = chatRepository.sendChatMessage( + storeId = storeId, + message = "", + productId = productId, + imageFile = null + ) + + when (result) { + is Result.Success -> { + val chatLine = result.data.chatLine + Log.d(TAG, "Product bubble sent successfully - ID: ${chatLine.id}") + + val newMessage = convertChatLineToUiMessage(chatLine).copy( + messageType = MessageType.PRODUCT, + productInfo = ProductInfo( + productId = productId, + productName = productName, + productPrice = productPrice, + productImage = productImage, + productRating = productRating, + storeName = storeName + ) + ) + + val currentMessages = _state.value?.messages ?: listOf() + val updatedMessages = currentMessages.toMutableList().apply { + add(newMessage) + } + + updateState { + it.copy( + messages = updatedMessages, + error = null + ) + } + + socketService.sendMessage(chatLine) + } + is Result.Error -> { + Log.e(TAG, "Error sending product bubble: ${result.exception.message}") + updateState { + it.copy( + error = "Failed to send product info: ${result.exception.message}" + ) + } + } + is Result.Loading -> { + // Handle loading if needed + } + } + } catch (e: Exception) { + Log.e(TAG, "Exception in sendProductBubble", e) + updateState { + it.copy( + error = "An unexpected error occurred: ${e.message}" + ) + } + } + } + } + + // send regular message for normal chat + private fun sendRegularMessage(message: String) { + Log.d(TAG, "Sending regular message: '$message'") + + selectedImageFile?.let { file -> + if (file.exists() && file.length() > 5 * 1024 * 1024) { + Log.e(TAG, "Image file too large: ${file.length()} bytes") + updateState { it.copy(error = "Image file is too large. Please select a smaller image.") } + return + } + } + + val existingChatRoomId = _chatRoomId.value ?: 0 + + viewModelScope.launch { + updateState { it.copy(isSending = true) } + + try { + val safeProductId = if (productId == 0) null else productId val result = chatRepository.sendChatMessage( storeId = storeId, @@ -344,10 +642,10 @@ class ChatViewModel @Inject constructor( when (result) { is Result.Success -> { - // Add new message to the list val chatLine = result.data.chatLine - val newMessage = convertChatLineToUiMessage(chatLine) + Log.d(TAG, "Regular message sent successfully - ID: ${chatLine.id}") + val newMessage = convertChatLineToUiMessage(chatLine) val currentMessages = _state.value?.messages ?: listOf() val updatedMessages = currentMessages.toMutableList().apply { add(newMessage) @@ -362,45 +660,28 @@ class ChatViewModel @Inject constructor( ) } - Log.d(TAG, "Message sent successfully: ${chatLine.id}") - - // Update the chat room ID if it's the first message - val newChatRoomId = chatLine.chatRoomId - if (existingChatRoomId == 0 && newChatRoomId > 0) { - Log.d(TAG, "Chat room created: $newChatRoomId") - _chatRoomId.value = newChatRoomId - - // Now that we have a chat room ID, we can join the Socket.IO room - joinSocketRoom(newChatRoomId) - } - - // Emit the message via Socket.IO for real-time updates + handleChatRoomCreation(existingChatRoomId, chatLine.chatRoomId) socketService.sendMessage(chatLine) - - // Clear the image attachment selectedImageFile = null } is Result.Error -> { - val errorMsg = if (result.exception.message.isNullOrEmpty() || result.exception.message == "{}") { - "Failed to send message. Please try again." - } else { - result.exception.message - } + val errorMsg = result.exception.message?.takeIf { it.isNotBlank() && it != "{}" } + ?: "Failed to send message. Please try again." + Log.e(TAG, "Error sending regular message: $errorMsg") updateState { it.copy( isSending = false, error = errorMsg ) } - Log.e(TAG, "Error sending message: ${result.exception.message}") } is Result.Loading -> { updateState { it.copy(isSending = true) } } } } catch (e: Exception) { - Log.e(TAG, "Exception in sendMessage", e) + Log.e(TAG, "Exception in sendRegularMessage", e) updateState { it.copy( isSending = false, @@ -411,138 +692,15 @@ class ChatViewModel @Inject constructor( } } - fun sendMessageStore(message: String) { - Log.d(TAG, "=== SEND MESSAGE ===") - Log.d(TAG, "Message: '$message'") - Log.d(TAG, "Has attachment: ${selectedImageFile != null}") - Log.d(TAG, "Selected image file: ${selectedImageFile?.absolutePath}") - Log.d(TAG, "File exists: ${selectedImageFile?.exists()}") - if (message.isBlank() && selectedImageFile == null) { - Log.e(TAG, "Cannot send message: Both message and image are empty") - return - } - - // Check if we have the necessary parameters - if (storeId <= 0) { - Log.e(TAG, "Cannot send message: Store ID is invalid") - updateState { it.copy(error = "Cannot send message. Invalid store ID.") } - return - } - - // Get the existing chatRoomId (not used in API but may be needed for Socket.IO) - val existingChatRoomId = _chatRoomId.value ?: 0 - - // Log debug information - Log.d(TAG, "Sending message with params: storeId=$storeId, productId=$productId") - Log.d(TAG, "Current user ID: $currentUserId") - Log.d(TAG, "Has attachment: ${selectedImageFile != null}") - - // Check image file size if present - selectedImageFile?.let { file -> - if (file.exists() && file.length() > 5 * 1024 * 1024) { // 5MB limit - updateState { it.copy(error = "Image file is too large. Please select a smaller image.") } - return - } - } - - if (defaultUserId <= 0) { // Check userId instead of storeId - Log.e(TAG, "Cannot send message: User ID is invalid") - updateState { it.copy(error = "Cannot send message. Invalid user ID.") } - return - } - - viewModelScope.launch { - updateState { it.copy(isSending = true) } - - try { - val safeProductId = if (productId == 0) null else productId - - val result = chatRepository.sendChatMessageStore( - userId = defaultUserId, // Pass userId instead of storeId - message = message, - productId = safeProductId, - imageFile = selectedImageFile - ) - - when (result) { - is 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}") - - // Update the chat room ID if it's the first message - val newChatRoomId = chatLine.chatRoomId - if (existingChatRoomId == 0 && newChatRoomId > 0) { - Log.d(TAG, "Chat room created: $newChatRoomId") - _chatRoomId.value = newChatRoomId - - // Now that we have a chat room ID, we can join the Socket.IO room - joinSocketRoom(newChatRoomId) - } - - // Emit the message via Socket.IO for real-time updates - socketService.sendMessage(chatLine) - - // Clear the image attachment - selectedImageFile = null - } - is Result.Error -> { - val errorMsg = if (result.exception.message.isNullOrEmpty() || result.exception.message == "{}") { - "Failed to send message. Please try again." - } else { - result.exception.message - } - - updateState { - it.copy( - isSending = false, - error = errorMsg - ) - } - Log.e(TAG, "Error sending message: ${result.exception.message}") - } - is Result.Loading -> { - updateState { it.copy(isSending = true) } - } - } - } catch (e: Exception) { - Log.e(TAG, "Exception in sendMessage", e) - updateState { - it.copy( - isSending = false, - error = "An unexpected error occurred: ${e.message}" - ) - } - } - } - } - - /** - * Updates a message status (delivered, read) - */ + //update message status fun updateMessageStatus(messageId: Int, status: String) { + Log.d(TAG, "Updating message status - ID: $messageId, Status: $status") + viewModelScope.launch { try { val result = chatRepository.updateMessageStatus(messageId, status) if (result is Result.Success) { - // Update local message status val currentMessages = _state.value?.messages ?: listOf() val updatedMessages = currentMessages.map { message -> if (message.id == messageId) { @@ -552,8 +710,7 @@ class ChatViewModel @Inject constructor( } } updateState { it.copy(messages = updatedMessages) } - - Log.d(TAG, "Message status updated: $messageId -> $status") + Log.d(TAG, "Message status updated successfully") } else if (result is Result.Error) { Log.e(TAG, "Error updating message status: ${result.exception.message}") } @@ -563,118 +720,245 @@ class ChatViewModel @Inject constructor( } } - /** - * Sets the selected image file for attachment - */ + //set image attachment fun setSelectedImageFile(file: File?) { selectedImageFile = file updateState { it.copy(hasAttachment = file != null) } - - Log.d(TAG, "Image attachment ${if (file != null) "selected" else "cleared"}") + Log.d(TAG, "Image attachment ${if (file != null) "selected: ${file.name}" else "cleared"}") } - /** - * Sends typing status to the other user - */ - fun sendTypingStatus(isTyping: Boolean) { - val roomId = _chatRoomId.value ?: 0 - if (roomId <= 0) return - - socketService.sendTypingStatus(roomId, 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 - */ + // convert form chatLine api to UI chat messages 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) - "" - } + val formattedTime = formatTimestamp(chatLine.createdAt) return ChatUiMessage( id = chatLine.id, message = chatLine.message, - attachment = chatLine.attachment ?: "", // Handle null attachment + attachment = chatLine.attachment ?: "", status = chatLine.status, time = formattedTime, isSentByMe = chatLine.senderId == currentUserId ) } + // convert chat history item to ui private fun convertChatLineToUiMessageHistory(chatItem: ChatItem): ChatUiMessage { - // Format the timestamp for display - val formattedTime = try { + val formattedTime = formatTimestamp(chatItem.createdAt) + + val messageType = when { + chatItem.productId > 0 -> MessageType.PRODUCT + !chatItem.attachment.isNullOrEmpty() -> MessageType.IMAGE + else -> MessageType.TEXT + } + + val productInfo = if (messageType == MessageType.PRODUCT) { + if (chatItem.senderId == currentUserId && chatItem.productId == productId) { + ProductInfo( + productId = productId, + productName = productName, + productPrice = productPrice, + productImage = productImage, + productRating = productRating, + storeName = storeName + ) + } else { + ProductInfo( + productId = chatItem.productId, + productName = "Loading...", + productPrice = "Loading...", + productImage = "", + productRating = 0f, + storeName = "Loading..." + ) + } + } else null + + val message = ChatUiMessage( + id = chatItem.id, + message = chatItem.message, + attachment = chatItem.attachment, + status = chatItem.status, + time = formattedTime, + isSentByMe = chatItem.senderId == currentUserId, + messageType = messageType, + productInfo = productInfo + ) + + // Fetch product info for non-current-user products + if (messageType == MessageType.PRODUCT && + (chatItem.senderId != currentUserId || chatItem.productId != productId)) { + fetchProductInfoForHistoryMessage(message, chatItem.productId) + } + + return message + } + + // fetch produc =t info in chat history + private fun fetchProductInfoForHistoryMessage(message: ChatUiMessage, productId: Int) { + Log.d(TAG, "Fetching product info for message ${message.id}, productId: $productId") + + viewModelScope.launch { + try { + val productResult = chatRepository.fetchProductDetail(productId) + + if (productResult != null) { + val product = productResult.product + Log.d(TAG, "Product fetched successfully: ${product.productName}") + + val productInfo = ProductInfo( + productId = product.productId, + productName = product.productName, + productPrice = formatPrice(product.price), + productImage = product.image, + productRating = parseRating(product.rating), + storeName = getStoreName(product.storeId) + ) + + updateMessageWithProductInfo(message.id, productInfo) + } else { + Log.e(TAG, "Failed to fetch product info for productId: $productId") + val errorProductInfo = ProductInfo( + productId = productId, + productName = "Product not available", + productPrice = "N/A", + productImage = "", + productRating = 0f, + storeName = "Unknown Store" + ) + updateMessageWithProductInfo(message.id, errorProductInfo) + } + } catch (e: Exception) { + Log.e(TAG, "Exception fetching product info for message: ${message.id}", e) + val errorProductInfo = ProductInfo( + productId = productId, + productName = "Error loading product", + productPrice = "N/A", + productImage = "", + productRating = 0f, + storeName = "Unknown Store" + ) + updateMessageWithProductInfo(message.id, errorProductInfo) + } + } + } + + //update specific message with product info + private fun updateMessageWithProductInfo(messageId: Int, productInfo: ProductInfo) { + Log.d(TAG, "Updating message $messageId with product info: ${productInfo.productName}") + + val currentMessages = _state.value?.messages ?: listOf() + val updatedMessages = currentMessages.map { message -> + if (message.id == messageId) { + message.copy(productInfo = productInfo) + } else { + message + } + } + updateState { it.copy(messages = updatedMessages) } + } + + //handle chat room when initiate chat + private fun handleChatRoomCreation(existingChatRoomId: Int, newChatRoomId: Int) { + if (existingChatRoomId == 0 && newChatRoomId > 0) { + Log.d(TAG, "Chat room created: $newChatRoomId") + _chatRoomId.value = newChatRoomId + joinSocketRoom(newChatRoomId) + } + } + + //format timestamp + private fun formatTimestamp(timestamp: String): String { + return 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) + val date = inputFormat.parse(timestamp) date?.let { outputFormat.format(it) } ?: "" } catch (e: Exception) { - Log.e(TAG, "Error formatting date: ${chatItem.createdAt}", e) + Log.e(TAG, "Error formatting date: $timestamp", e) "" } + } - return ChatUiMessage( - attachment = chatItem.attachment, // Handle null attachment - id = chatItem.id, - message = chatItem.message, - status = chatItem.status, - time = formattedTime, - isSentByMe = chatItem.senderId == currentUserId, - ) + //format price + private fun formatPrice(price: String): String { + return if (price.startsWith("Rp")) price else "Rp$price" + } + + // parse string to float + private fun parseRating(rating: String): Float { + return try { + rating.toFloat() + } catch (e: Exception) { + Log.w(TAG, "Error parsing rating: $rating", e) + 0f + } + } + + //get store name by Id + private fun getStoreName(storeId: Int): String { + return if (storeId == this.storeId) { + storeName + } else { + "Store #$storeId" + } + } + + // helper function to update live data + private fun updateState(update: (ChatUiState) -> ChatUiState) { + _state.value?.let { + _state.value = update(it) + } + } + + //clear any error messages + fun clearError() { + Log.d(TAG, "Clearing error state") + updateState { it.copy(error = null) } } override fun onCleared() { super.onCleared() - // Disconnect Socket.IO when ViewModel is cleared + Log.d(TAG, "ChatViewModel cleared - Disconnecting socket") socketService.disconnect() - Log.d(TAG, "ViewModel cleared, Socket.IO disconnected") - } - - fun getChatList() { - viewModelScope.launch { - _chatList.value = com.alya.ecommerce_serang.data.repository.Result.Loading - _chatList.value = chatRepository.getListChat() - } - } - - fun getChatListStore() { - Log.d("ChatViewModel", "getChatListStore() called") - _chatListStore.value = Result.Loading - - viewModelScope.launch { - val result = chatRepository.getListChatStore() - Log.d("ChatViewModel", "getChatListStore() result: $result") - _chatListStore.value = result - } } } -/** - * Data class representing the UI state for the chat screen - */ +enum class MessageType { + TEXT, // Regular text message + IMAGE, // Image message + PRODUCT // Product share message +} + +data class ProductInfo( + val productId: Int, + val productName: String, + val productPrice: String, + val productImage: String, + val productRating: Float, + val storeName: String +) + +// representing chat messages to UI +data class ChatUiMessage( + val id: Int, + val message: String, + val attachment: String?, + val status: String, + val time: String, + val isSentByMe: Boolean, + val messageType: MessageType = MessageType.TEXT, + val productInfo: ProductInfo? = null +) + +// representing UI state to screen data class ChatUiState( val messages: List = emptyList(), val isLoading: Boolean = false, val isSending: Boolean = false, val hasAttachment: Boolean = false, + val hasProductAttachment: Boolean = false, val isOtherUserTyping: Boolean = false, val error: String? = null, val connectionState: ConnectionState = ConnectionState.Disconnected(), @@ -685,16 +969,4 @@ data class ChatUiState( val productImageUrl: String = "", val productRating: Float = 0f, val storeName: String = "" -) - -/** - * Data class representing a chat message in the UI - */ -data class ChatUiMessage( - val id: Int, - val message: String, - val attachment: String?, - val status: String, - val time: String, - val isSentByMe: Boolean ) \ No newline at end of file diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/product/DetailProductActivity.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/product/DetailProductActivity.kt index 83d156c..3e62dea 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/product/DetailProductActivity.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/product/DetailProductActivity.kt @@ -483,8 +483,10 @@ class DetailProductActivity : AppCompatActivity() { productRating = productDetail.rating, storeName = storeDetail.data.storeName, chatRoomId = 0, - storeImage = storeDetail.data.storeImage - ) + storeImage = storeDetail.data.storeImage, + attachProduct = true // This will auto-attach the product! + + ) } diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatListStoreActivity.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatListStoreActivity.kt index 9620907..c68e3de 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatListStoreActivity.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatListStoreActivity.kt @@ -80,7 +80,7 @@ class ChatListStoreActivity : AppCompatActivity() { when (result) { is Result.Success -> { Log.d(TAG, "Chat list fetch success. Data size: ${result.data.size}") - val adapter = ChatListAdapter(result.data) { chatItem -> + val adapter = ChatStoreListAdapter(result.data) { chatItem -> Log.d(TAG, "Chat item clicked: storeId=${chatItem.storeId}, chatRoomId=${chatItem.chatRoomId}") val intent = ChatStoreActivity.createIntent( context = this, @@ -93,7 +93,10 @@ class ChatListStoreActivity : AppCompatActivity() { storeName = chatItem.storeName, chatRoomId = chatItem.chatRoomId, storeImage = chatItem.storeImage, - userId = chatItem.userId + userId = chatItem.userId, + userName = chatItem.userName, + userImg = chatItem.userImage + ) startActivity(intent) } diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatStoreActivity.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatStoreActivity.kt index c872f50..8dfabfc 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatStoreActivity.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatStoreActivity.kt @@ -15,7 +15,6 @@ import android.util.Log import android.view.View import android.view.inputmethod.InputMethodManager import android.widget.Toast -import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity @@ -23,7 +22,6 @@ import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.core.content.FileProvider import androidx.core.view.ViewCompat -import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsAnimationCompat import androidx.core.view.WindowInsetsCompat import androidx.lifecycle.Observer @@ -45,7 +43,6 @@ import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import javax.inject.Inject -import kotlin.math.max @AndroidEntryPoint class ChatStoreActivity : AppCompatActivity() { @@ -102,10 +99,10 @@ class ChatStoreActivity : AppCompatActivity() { Log.d("ChatActivity", "Token in storage: '${sessionManager.getToken()}'") - WindowCompat.setDecorFitsSystemWindows(window, false) - enableEdgeToEdge() - - // Apply insets to your root layout +// WindowCompat.setDecorFitsSystemWindows(window, false) +// enableEdgeToEdge() +// +// // Apply insets to your root layout // Get parameters from intent @@ -119,6 +116,8 @@ class ChatStoreActivity : AppCompatActivity() { val storeName = intent.getStringExtra(Constants.EXTRA_STORE_NAME) ?: "" val chatRoomId = intent.getIntExtra(Constants.EXTRA_CHAT_ROOM_ID, 0) val storeImg = intent.getStringExtra(Constants.EXTRA_STORE_IMAGE) ?: "" + val userName = intent.getStringExtra(Constants.EXTRA_USER_NAME) ?: "" + val userImg = intent.getStringExtra(Constants.EXTRA_USER_IMAGE) ?: "" // Check if user is logged in val token = sessionManager.getToken() @@ -131,8 +130,8 @@ class ChatStoreActivity : AppCompatActivity() { return } - binding.tvStoreName.text = storeName - val fullImageUrl = when (val img = storeImg) { + binding.tvStoreName.text = userName + val fullImageUrl = when (val img = userImg) { is String -> { if (img.startsWith("/")) BASE_URL + img.substring(1) else img } @@ -144,84 +143,6 @@ class ChatStoreActivity : AppCompatActivity() { .placeholder(R.drawable.placeholder_image) .into(binding.imgProfile) - ViewCompat.setOnApplyWindowInsetsListener(binding.layoutChatInput) { view, insets -> - val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime()) - val navBarInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars()) - - val bottomPadding = max(imeInsets.bottom, navBarInsets.bottom) - view.setPadding(view.paddingLeft, view.paddingTop, view.paddingRight, bottomPadding) - insets - } - -// Handle top inset on toolbar (status bar height) - ViewCompat.setOnApplyWindowInsetsListener(binding.chatToolbar) { view, insets -> - val statusBarHeight = insets.getInsets(WindowInsetsCompat.Type.statusBars()).top - view.setPadding(view.paddingLeft, statusBarHeight, view.paddingRight, view.paddingBottom) - insets - } - - ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerChat) { view, insets -> - val navBarInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars()) - val bottomPadding = binding.layoutChatInput.height + navBarInsets.bottom - - view.setPadding( - view.paddingLeft, - view.paddingTop, - view.paddingRight, - bottomPadding - ) - insets - } - -// For RecyclerView, add bottom padding = chat input height + nav bar height (to avoid last message hidden) - - ViewCompat.setWindowInsetsAnimationCallback(binding.root, - object : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_STOP) { - - private var startPaddingBottom = 0 - private var endPaddingBottom = 0 - - override fun onPrepare(animation: WindowInsetsAnimationCompat) { - startPaddingBottom = binding.layoutChatInput.paddingBottom - } - - override fun onStart( - animation: WindowInsetsAnimationCompat, - bounds: WindowInsetsAnimationCompat.BoundsCompat - ): WindowInsetsAnimationCompat.BoundsCompat { - endPaddingBottom = binding.layoutChatInput.paddingBottom - return bounds - } - - override fun onProgress( - insets: WindowInsetsCompat, - runningAnimations: MutableList - ): WindowInsetsCompat { - val imeAnimation = runningAnimations.find { - it.typeMask and WindowInsetsCompat.Type.ime() != 0 - } ?: return insets - - val animatedBottomPadding = startPaddingBottom + - (endPaddingBottom - startPaddingBottom) * imeAnimation.interpolatedFraction - - binding.layoutChatInput.setPadding( - binding.layoutChatInput.paddingLeft, - binding.layoutChatInput.paddingTop, - binding.layoutChatInput.paddingRight, - animatedBottomPadding.toInt() - ) - - binding.recyclerChat.setPadding( - binding.recyclerChat.paddingLeft, - binding.recyclerChat.paddingTop, - binding.recyclerChat.paddingRight, - animatedBottomPadding.toInt() + binding.layoutChatInput.height - ) - - return insets - } - }) - // Set chat parameters to ViewModel viewModel.setChatParametersStore( storeId = storeId, @@ -234,8 +155,10 @@ class ChatStoreActivity : AppCompatActivity() { storeName = storeName ) - // Setup UI components + + // Then setup other components setupRecyclerView() + setupWindowInsets() setupListeners() setupTypingIndicator() observeViewModel() @@ -251,18 +174,140 @@ class ChatStoreActivity : AppCompatActivity() { chatAdapter = ChatAdapter() binding.recyclerChat.apply { adapter = chatAdapter - layoutManager = LinearLayoutManager(this@ChatStoreActivity).apply { - stackFromEnd = true - } + layoutManager = LinearLayoutManager(this@ChatStoreActivity) + // Use clipToPadding to allow content to scroll under padding + clipToPadding = false + // Set minimal padding - we'll handle spacing differently + setPadding(paddingLeft, paddingTop, paddingRight, 16) } + } + + private fun setupWindowInsets() { + ViewCompat.setOnApplyWindowInsetsListener(binding.chatToolbar) { view, insets -> + val statusBarInsets = insets.getInsets(WindowInsetsCompat.Type.statusBars()) + view.updatePadding(top = statusBarInsets.top) + insets + } + + // Handle IME (keyboard) and navigation bar insets for the input layout only + ViewCompat.setOnApplyWindowInsetsListener(binding.layoutChatInput) { view, insets -> + val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime()) + val navBarInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars()) + + Log.d(TAG, "Insets - IME: ${imeInsets.bottom}, NavBar: ${navBarInsets.bottom}") + + val bottomInset = if (imeInsets.bottom > 0) { + imeInsets.bottom + } else { + navBarInsets.bottom + } + + // Only apply padding to the input layout + view.updatePadding(bottom = bottomInset) + + // When keyboard appears, scroll to bottom to keep last message visible + if (imeInsets.bottom > 0) { + // Keyboard is visible - scroll to bottom with delay to ensure layout is complete + binding.recyclerChat.postDelayed({ + scrollToBottomSmooth() + }, 100) + } + + insets + } + + // Smooth animation for keyboard transitions + ViewCompat.setWindowInsetsAnimationCallback( + binding.layoutChatInput, + object : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_STOP) { + + override fun onProgress( + insets: WindowInsetsCompat, + runningAnimations: MutableList + ): WindowInsetsCompat { + val imeAnimation = runningAnimations.find { + it.typeMask and WindowInsetsCompat.Type.ime() != 0 + } + + if (imeAnimation != null) { + val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime()) + val navBarInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars()) + val targetBottomInset = if (imeInsets.bottom > 0) imeInsets.bottom else navBarInsets.bottom + + // Only animate input layout padding + binding.layoutChatInput.updatePadding(bottom = targetBottomInset) + } + + return insets + } + + override fun onEnd(animation: WindowInsetsAnimationCompat) { + super.onEnd(animation) + // Smooth scroll to bottom after animation + scrollToBottomSmooth() + } + } + ) + } + +// private fun updateRecyclerViewPadding(inputLayoutBottomPadding: Int) { +// // Calculate total bottom padding needed for RecyclerView +// // This ensures the last message is visible above the input layout +// val inputLayoutHeight = binding.layoutChatInput.height +// val totalBottomPadding = inputLayoutHeight + inputLayoutBottomPadding +// +// binding.recyclerChat.setPadding( +// binding.recyclerChat.paddingLeft, +// binding.recyclerChat.paddingTop, +// binding.recyclerChat.paddingRight, +// totalBottomPadding +// ) +// +// // Scroll to bottom if there are messages +// val messageCount = chatAdapter.itemCount +// if (messageCount > 0) { +// binding.recyclerChat.post { +// binding.recyclerChat.scrollToPosition(messageCount - 1) +// } +// } +// } + // binding.recyclerChat.setPadding( // binding.recyclerChat.paddingLeft, // binding.recyclerChat.paddingTop, // binding.recyclerChat.paddingRight, // binding.layoutChatInput.height + binding.root.rootWindowInsets?.getInsets(WindowInsetsCompat.Type.navigationBars())?.bottom ?: 0 // ) + + private fun scrollToBottomSmooth() { + val messageCount = chatAdapter.itemCount + if (messageCount > 0) { + binding.recyclerChat.post { + // Use smooth scroll to bottom + binding.recyclerChat.smoothScrollToPosition(messageCount - 1) + } + } } + private fun scrollToBottomInstant() { + val messageCount = chatAdapter.itemCount + if (messageCount > 0) { + binding.recyclerChat.post { + // Instant scroll for new messages + binding.recyclerChat.scrollToPosition(messageCount - 1) + } + } + } + + // Extension function to make padding updates cleaner + private fun View.updatePadding( + left: Int = paddingLeft, + top: Int = paddingTop, + right: Int = paddingRight, + bottom: Int = paddingBottom + ) { + setPadding(left, top, right, bottom) + } private fun setupListeners() { // Back button @@ -282,6 +327,11 @@ class ChatStoreActivity : AppCompatActivity() { if (message.isNotEmpty() || (currentState != null && currentState.hasAttachment)) { viewModel.sendMessageStore(message) binding.editTextMessage.text.clear() + + // Instantly scroll to show new message + binding.recyclerChat.postDelayed({ + scrollToBottomInstant() + }, 50) } } @@ -306,33 +356,34 @@ class ChatStoreActivity : AppCompatActivity() { override fun afterTextChanged(s: Editable?) {} }) + // Focus and show keyboard binding.editTextMessage.requestFocus() - val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - imm.showSoftInput(binding.editTextMessage, InputMethodManager.SHOW_IMPLICIT) - + binding.editTextMessage.post { + val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.showSoftInput(binding.editTextMessage, InputMethodManager.SHOW_IMPLICIT) + } } private fun observeViewModel() { viewModel.chatRoomId.observe(this, Observer { chatRoomId -> if (chatRoomId > 0) { - // Chat room has been created, now we can join the Socket.IO room viewModel.joinSocketRoom(chatRoomId) - - // Now we can also load chat history viewModel.loadChatHistory(chatRoomId) Log.d(TAG, "Chat Activity started - Chat Room: $chatRoomId") - } }) - // Observe state changes using LiveData viewModel.state.observe(this, Observer { state -> - // Update messages - chatAdapter.submitList(state.messages) + Log.d(TAG, "State updated - Messages: ${state.messages.size}") - // Scroll to bottom if new message - if (state.messages.isNotEmpty()) { - binding.recyclerChat.scrollToPosition(state.messages.size - 1) + // Update messages + val previousCount = chatAdapter.itemCount + chatAdapter.submitList(state.messages) { + Log.d(TAG, "Messages submitted to adapter") + // Only auto-scroll for new messages or initial load + if (previousCount == 0 || state.messages.size > previousCount) { + scrollToBottomInstant() + } } // Update product info @@ -342,7 +393,7 @@ class ChatStoreActivity : AppCompatActivity() { binding.ratingBar.rating = state.productRating binding.tvRating.text = state.productRating.toString() binding.tvSellerName.text = state.storeName - binding.tvStoreName.text=state.storeName +// binding.tvStoreName.text = state.storeName val fullImageUrl = when (val img = state.productImageUrl) { is String -> { @@ -351,7 +402,6 @@ class ChatStoreActivity : AppCompatActivity() { else -> R.drawable.placeholder_image } - // Load product image if (!state.productImageUrl.isNullOrEmpty()) { Glide.with(this@ChatStoreActivity) .load(fullImageUrl) @@ -361,14 +411,11 @@ class ChatStoreActivity : AppCompatActivity() { .into(binding.imgProduct) } - // Make sure the product section is visible binding.productContainer.visibility = View.VISIBLE } else { - // Hide the product section if info is missing binding.productContainer.visibility = View.GONE } - // Update attachment hint if (state.hasAttachment) { binding.editTextMessage.hint = getString(R.string.image_attached) @@ -376,7 +423,6 @@ class ChatStoreActivity : AppCompatActivity() { binding.editTextMessage.hint = getString(R.string.write_message) } - // Show typing indicator binding.tvTypingIndicator.visibility = if (state.isOtherUserTyping) View.VISIBLE else View.GONE @@ -543,7 +589,9 @@ class ChatStoreActivity : AppCompatActivity() { storeName: String? = null, chatRoomId: Int = 0, storeImage: String? = null, - userId: Int + userId: Int, + userName: String, + userImg: String? = null ): Intent { return Intent(context, ChatStoreActivity::class.java).apply { putExtra(Constants.EXTRA_STORE_ID, storeId) @@ -553,6 +601,8 @@ class ChatStoreActivity : AppCompatActivity() { putExtra(Constants.EXTRA_PRODUCT_IMAGE, productImage) putExtra(Constants.EXTRA_STORE_IMAGE, storeImage) putExtra(Constants.EXTRA_USER_ID, userId) + putExtra(Constants.EXTRA_USER_NAME,userName) + putExtra(Constants.EXTRA_USER_IMAGE, userImg) // Convert productRating string to float if provided if (productRating != null) { diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatStoreAdapter.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatStoreAdapter.kt new file mode 100644 index 0000000..8fcc2b9 --- /dev/null +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatStoreAdapter.kt @@ -0,0 +1,152 @@ +package com.alya.ecommerce_serang.ui.profile.mystore.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.alya.ecommerce_serang.ui.chat.ChatUiMessage +import com.alya.ecommerce_serang.utils.Constants +import com.bumptech.glide.Glide + +class ChatStoreAdapter : ListAdapter(ChatMessageDiffCallback()) { + + 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.check_single_24 + Constants.STATUS_DELIVERED -> R.drawable.check_double_24 + Constants.STATUS_READ -> R.drawable.check_double_read_24 + else -> R.drawable.check_single_24 + } + binding.imgStatus.setImageResource(statusIcon) + + // Handle attachment if exists + if (message.attachment?.isNotEmpty() == true) { + binding.imgAttachment.visibility = View.VISIBLE + + val fullImageUrl = when (val img = message.attachment) { + is String -> { + if (img.startsWith("/")) BASE_URL + img.substring(1) else img + } + else -> R.drawable.placeholder_image + } + + Glide.with(binding.root.context) + .load(fullImageUrl) + .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 + val fullImageUrl = when (val img = message.attachment) { + is String -> { + if (img.startsWith("/")) BASE_URL + img.substring(1) else img + } + else -> R.drawable.placeholder_image + } + + if (message.attachment?.isNotEmpty() == true) { + binding.imgAttachment.visibility = View.VISIBLE + Glide.with(binding.root.context) + .load(fullImageUrl) + .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.placeholder_image) // Replace with actual avatar URL if available + .circleCrop() + .into(binding.imgAvatar) + } + } +} + +/** + * DiffUtil callback for optimizing RecyclerView updates + */ +class ChatMessageDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: ChatUiMessage, newItem: ChatUiMessage): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: ChatUiMessage, newItem: ChatUiMessage): Boolean { + return oldItem == newItem + } +} \ No newline at end of file diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatListAdapter.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatStoreListAdapter.kt similarity index 92% rename from app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatListAdapter.kt rename to app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatStoreListAdapter.kt index dd42766..8d5f471 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatListAdapter.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatStoreListAdapter.kt @@ -13,20 +13,20 @@ import java.util.Date import java.util.Locale import java.util.TimeZone -class ChatListAdapter( +class ChatStoreListAdapter( private val chatList: List, private val onClick: (ChatItemList) -> Unit -) : RecyclerView.Adapter() { +) : RecyclerView.Adapter() { inner class ChatViewHolder(private val binding: ItemChatBinding) : RecyclerView.ViewHolder(binding.root) { fun bind(chat: ChatItemList) { - binding.txtStoreName.text = chat.storeName + binding.txtStoreName.text = chat.userName binding.txtMessage.text = chat.message binding.txtTime.text = formatTime(chat.latestMessageTime) // Process image URL properly - val imageUrl = chat.storeImage?.let { + val imageUrl = chat.userImage?.let { if (it.startsWith("/")) BASE_URL + it else it } diff --git a/app/src/main/java/com/alya/ecommerce_serang/utils/Constants.kt b/app/src/main/java/com/alya/ecommerce_serang/utils/Constants.kt index 4ec6689..13f2c58 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/utils/Constants.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/utils/Constants.kt @@ -22,6 +22,11 @@ object Constants { const val EXTRA_PRODUCT_RATING = "product_rating" const val EXTRA_STORE_IMAGE = "store_image" const val EXTRA_USER_ID = "user_id" + const val EXTRA_USER_NAME = "user_name" + const val EXTRA_USER_IMAGE = "user_image" + const val EXTRA_ATTACH_PRODUCT = "extra_attach_product" + + // Request codes const val REQUEST_IMAGE_PICK = 1001 diff --git a/app/src/main/res/drawable/bg_product_bubble.xml b/app/src/main/res/drawable/bg_product_bubble.xml new file mode 100644 index 0000000..024858f --- /dev/null +++ b/app/src/main/res/drawable/bg_product_bubble.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_product_normal.xml b/app/src/main/res/drawable/bg_product_normal.xml new file mode 100644 index 0000000..ce46e64 --- /dev/null +++ b/app/src/main/res/drawable/bg_product_normal.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_product_selected.xml b/app/src/main/res/drawable/bg_product_selected.xml new file mode 100644 index 0000000..907f113 --- /dev/null +++ b/app/src/main/res/drawable/bg_product_selected.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_message_product_received.xml b/app/src/main/res/layout/item_message_product_received.xml new file mode 100644 index 0000000..8b6e8a8 --- /dev/null +++ b/app/src/main/res/layout/item_message_product_received.xml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_message_product_sent.xml b/app/src/main/res/layout/item_message_product_sent.xml new file mode 100644 index 0000000..d6ad233 --- /dev/null +++ b/app/src/main/res/layout/item_message_product_sent.xml @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file From b9f387a6267af2a9d95e6169341f3ec20765da59 Mon Sep 17 00:00:00 2001 From: shaulascr Date: Thu, 29 May 2025 02:49:02 +0700 Subject: [PATCH 07/10] delete chatliststore and chatstoreadapter --- .../mystore/chat/ChatListStoreFragment.kt | 96 ----------- .../profile/mystore/chat/ChatStoreAdapter.kt | 152 ------------------ 2 files changed, 248 deletions(-) delete mode 100644 app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatListStoreFragment.kt delete mode 100644 app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatStoreAdapter.kt diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatListStoreFragment.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatListStoreFragment.kt deleted file mode 100644 index 3e8e0b6..0000000 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatListStoreFragment.kt +++ /dev/null @@ -1,96 +0,0 @@ -//package com.alya.ecommerce_serang.ui.profile.mystore.chat -// -//import android.os.Bundle -//import android.view.LayoutInflater -//import android.view.View -//import android.view.ViewGroup -//import android.widget.Toast -//import androidx.fragment.app.Fragment -//import androidx.fragment.app.viewModels -//import com.alya.ecommerce_serang.data.api.retrofit.ApiConfig -//import com.alya.ecommerce_serang.data.repository.ChatRepository -//import com.alya.ecommerce_serang.data.repository.Result -//import com.alya.ecommerce_serang.databinding.FragmentChatListBinding -//import com.alya.ecommerce_serang.ui.chat.ChatViewModel -//import com.alya.ecommerce_serang.ui.chat.SocketIOService -//import com.alya.ecommerce_serang.utils.BaseViewModelFactory -//import com.alya.ecommerce_serang.utils.SessionManager -// -//class ChatListStoreFragment : Fragment() { -// -// private var _binding: FragmentChatListBinding? = null -// -// private val binding get() = _binding!! -// private lateinit var socketService: SocketIOService -// private lateinit var sessionManager: SessionManager -// -// private val viewModel: com.alya.ecommerce_serang.ui.chat.ChatViewModel by viewModels { -// BaseViewModelFactory { -// val apiService = ApiConfig.getApiService(sessionManager) -// val chatRepository = ChatRepository(apiService) -// ChatViewModel(chatRepository, socketService, sessionManager) -// } -// } -// override fun onCreate(savedInstanceState: Bundle?) { -// super.onCreate(savedInstanceState) -// sessionManager = SessionManager(requireContext()) -// socketService = SocketIOService(sessionManager) -// -// } -// -// override fun onCreateView( -// inflater: LayoutInflater, container: ViewGroup?, -// savedInstanceState: Bundle? -// ): View { -// _binding = FragmentChatListBinding.inflate(inflater, container, false) -// return _binding!!.root -// } -// -// override fun onViewCreated(view: View, savedInstanceState: Bundle?) { -// super.onViewCreated(view, savedInstanceState) -// -// viewModel.getChatListStore() -// observeChatList() -// } -// -// private fun observeChatList() { -// viewModel.chatListStore.observe(viewLifecycleOwner) { result -> -// when (result) { -// is Result.Success -> { -// val adapter = ChatListAdapter(result.data) { chatItem -> -// // Use the ChatActivity.createIntent factory method for proper navigation -// ChatStoreActivity.createIntent( -// context = requireActivity(), -// storeId = chatItem.storeId, -// productId = 0, // Default value since we don't have it in ChatListItem -// productName = null, // Null is acceptable as per ChatActivity -// productPrice = "", -// productImage = null, -// productRating = null, -// storeName = chatItem.storeName, -// chatRoomId = chatItem.chatRoomId, -// storeImage = chatItem.storeImage -// ) -// } -// binding.chatListRecyclerView.adapter = adapter -// } -// is Result.Error -> { -// Toast.makeText(requireContext(), "Failed to load chats", Toast.LENGTH_SHORT).show() -// } -// Result.Loading -> { -// // Optional: show progress bar -// } -// } -// } -// } -// -// -// override fun onDestroyView() { -// super.onDestroyView() -// _binding = null -// } -// -// companion object{ -// -// } -//} \ No newline at end of file diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatStoreAdapter.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatStoreAdapter.kt deleted file mode 100644 index 8fcc2b9..0000000 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/profile/mystore/chat/ChatStoreAdapter.kt +++ /dev/null @@ -1,152 +0,0 @@ -package com.alya.ecommerce_serang.ui.profile.mystore.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.alya.ecommerce_serang.ui.chat.ChatUiMessage -import com.alya.ecommerce_serang.utils.Constants -import com.bumptech.glide.Glide - -class ChatStoreAdapter : ListAdapter(ChatMessageDiffCallback()) { - - 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.check_single_24 - Constants.STATUS_DELIVERED -> R.drawable.check_double_24 - Constants.STATUS_READ -> R.drawable.check_double_read_24 - else -> R.drawable.check_single_24 - } - binding.imgStatus.setImageResource(statusIcon) - - // Handle attachment if exists - if (message.attachment?.isNotEmpty() == true) { - binding.imgAttachment.visibility = View.VISIBLE - - val fullImageUrl = when (val img = message.attachment) { - is String -> { - if (img.startsWith("/")) BASE_URL + img.substring(1) else img - } - else -> R.drawable.placeholder_image - } - - Glide.with(binding.root.context) - .load(fullImageUrl) - .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 - val fullImageUrl = when (val img = message.attachment) { - is String -> { - if (img.startsWith("/")) BASE_URL + img.substring(1) else img - } - else -> R.drawable.placeholder_image - } - - if (message.attachment?.isNotEmpty() == true) { - binding.imgAttachment.visibility = View.VISIBLE - Glide.with(binding.root.context) - .load(fullImageUrl) - .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.placeholder_image) // Replace with actual avatar URL if available - .circleCrop() - .into(binding.imgAvatar) - } - } -} - -/** - * DiffUtil callback for optimizing RecyclerView updates - */ -class ChatMessageDiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: ChatUiMessage, newItem: ChatUiMessage): Boolean { - return oldItem.id == newItem.id - } - - override fun areContentsTheSame(oldItem: ChatUiMessage, newItem: ChatUiMessage): Boolean { - return oldItem == newItem - } -} \ No newline at end of file From e2d909d21864eddb2095301ac07905a582fcf88f Mon Sep 17 00:00:00 2001 From: shaulascr Date: Thu, 29 May 2025 16:39:50 +0700 Subject: [PATCH 08/10] update payment and add evidence --- app/src/main/AndroidManifest.xml | 5 +- .../data/repository/ProductRepository.kt | 52 ++++ .../ecommerce_serang/ui/home/HomeFragment.kt | 6 +- .../ui/home/SearchResultAdapter.kt | 8 +- .../detail/AddEvidencePaymentActivity.kt | 239 ++++++++++++------ .../ui/order/detail/PaymentActivity.kt | 103 +++++--- .../category/CategoryProductsActivity.kt | 188 ++++++++++++++ .../category/CategoryProductsViewModel.kt | 49 ++++ .../category/ProductsCategoryAdapter.kt | 73 ++++++ .../res/layout/activity_category_products.xml | 178 +++++++++++++ app/src/main/res/layout/activity_payment.xml | 15 +- app/src/main/res/layout/item_product_grid.xml | 115 +++++---- app/src/main/res/values/colors.xml | 2 + 13 files changed, 862 insertions(+), 171 deletions(-) create mode 100644 app/src/main/java/com/alya/ecommerce_serang/ui/product/category/CategoryProductsActivity.kt create mode 100644 app/src/main/java/com/alya/ecommerce_serang/ui/product/category/CategoryProductsViewModel.kt create mode 100644 app/src/main/java/com/alya/ecommerce_serang/ui/product/category/ProductsCategoryAdapter.kt create mode 100644 app/src/main/res/layout/activity_category_products.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ddbfa98..cedc9c4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -29,6 +29,9 @@ android:theme="@style/Theme.Ecommerce_serang" android:usesCleartextTraffic="true" tools:targetApi="31"> + @@ -76,11 +79,11 @@ android:enabled="true" android:exported="false" android:foregroundServiceType="dataSync" /> + - diff --git a/app/src/main/java/com/alya/ecommerce_serang/data/repository/ProductRepository.kt b/app/src/main/java/com/alya/ecommerce_serang/data/repository/ProductRepository.kt index 85aae9e..3c31d1a 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/data/repository/ProductRepository.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/data/repository/ProductRepository.kt @@ -286,6 +286,58 @@ class ProductRepository(private val apiService: ApiService) { } } + suspend fun getProductsByCategory(categoryId: Int): Result> = + withContext(Dispatchers.IO) { + try { + Log.d(TAG, "Attempting to fetch products for category: $categoryId") + val response = apiService.getAllProduct() + + if (response.isSuccessful) { + val allProducts = response.body()?.products ?: emptyList() + + // Filter products by category_id + val filteredProducts = allProducts.filter { product -> + product.categoryId == categoryId + } + + Log.d(TAG, "Filtered products for category $categoryId: ${filteredProducts.size} products") + + Result.Success(filteredProducts) + } else { + val errorBody = response.errorBody()?.string() ?: "Unknown error" + Log.e(TAG, "Failed to fetch products. Code: ${response.code()}, Error: $errorBody") + Result.Error(Exception("Failed to fetch products. Code: ${response.code()}")) + } + } catch (e: Exception) { + Log.e(TAG, "Exception while fetching products by category", e) + Result.Error(e) + } + } + + // Optional: Get category by ID if needed + suspend fun getCategoryById(categoryId: Int): Result = + withContext(Dispatchers.IO) { + try { + Log.d(TAG, "Attempting to fetch category: $categoryId") + val response = apiService.allCategory() + + if (response.isSuccessful) { + val categories = response.body()?.category ?: emptyList() + val category = categories.find { it.id == categoryId } + + Log.d(TAG, "Category found: ${category?.name}") + Result.Success(category) + } else { + val errorBody = response.errorBody()?.string() ?: "Unknown error" + Log.e(TAG, "Failed to fetch category. Code: ${response.code()}, Error: $errorBody") + Result.Error(Exception("Failed to fetch category. Code: ${response.code()}")) + } + } catch (e: Exception) { + Log.e(TAG, "Exception while fetching category by ID", e) + Result.Error(e) + } + } + companion object { private const val TAG = "ProductRepository" } diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/home/HomeFragment.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/home/HomeFragment.kt index a902329..db00d1b 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/home/HomeFragment.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/home/HomeFragment.kt @@ -24,6 +24,7 @@ import com.alya.ecommerce_serang.databinding.FragmentHomeBinding import com.alya.ecommerce_serang.ui.cart.CartActivity import com.alya.ecommerce_serang.ui.notif.NotificationActivity import com.alya.ecommerce_serang.ui.product.DetailProductActivity +import com.alya.ecommerce_serang.ui.product.category.CategoryProductsActivity import com.alya.ecommerce_serang.utils.BaseViewModelFactory import com.alya.ecommerce_serang.utils.HorizontalMarginItemDecoration import com.alya.ecommerce_serang.utils.SessionManager @@ -211,7 +212,10 @@ class HomeFragment : Fragment() { } private fun handleCategoryProduct(category: CategoryItem) { - // Your implementation + // Navigate to CategoryProductsActivity when category is clicked + val intent = Intent(requireContext(), CategoryProductsActivity::class.java) + intent.putExtra(CategoryProductsActivity.EXTRA_CATEGORY, category) + startActivity(intent) } override fun onDestroyView() { diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/home/SearchResultAdapter.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/home/SearchResultAdapter.kt index 7a95fdc..555976f 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/home/SearchResultAdapter.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/home/SearchResultAdapter.kt @@ -42,19 +42,19 @@ class SearchResultsAdapter( } fun bind(product: ProductsItem) { - binding.productName.text = product.name - binding.productPrice.text = (product.price) + binding.tvProductName.text = product.name + binding.tvProductPrice.text = (product.price) // Load image with Glide Glide.with(binding.root.context) .load(product.image) .placeholder(R.drawable.placeholder_image) // .error(R.drawable.error_image) - .into(binding.productImage) + .into(binding.ivProductImage) // Set store name if available product.storeId?.toString().let { - binding.storeName.text = it + binding.tvStoreName.text = it } } } diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/order/detail/AddEvidencePaymentActivity.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/order/detail/AddEvidencePaymentActivity.kt index 77d4e91..fc37df7 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/order/detail/AddEvidencePaymentActivity.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/order/detail/AddEvidencePaymentActivity.kt @@ -2,11 +2,14 @@ package com.alya.ecommerce_serang.ui.order.detail import android.Manifest import android.R +import android.app.Activity import android.app.DatePickerDialog +import android.content.Intent import android.content.pm.PackageManager import android.graphics.BitmapFactory import android.net.Uri import android.os.Bundle +import android.provider.MediaStore import android.util.Log import android.view.View import android.webkit.MimeTypeMap @@ -46,6 +49,7 @@ class AddEvidencePaymentActivity : AppCompatActivity() { private lateinit var productPrice: String private var selectedImageUri: Uri? = null + private val viewModel: PaymentViewModel by viewModels { BaseViewModelFactory { val apiService = ApiConfig.getApiService(sessionManager) @@ -62,55 +66,65 @@ class AddEvidencePaymentActivity : AppCompatActivity() { "Cash on Delivery" ) - private val getContent = registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? -> - uri?.let { - selectedImageUri = it - binding.ivUploadedImage.setImageURI(selectedImageUri) - binding.ivUploadedImage.visibility = View.VISIBLE - binding.layoutUploadPlaceholder.visibility = View.GONE +// private val getContent = registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? -> +// uri?.let { +// selectedImageUri = it +// binding.ivUploadedImage.setImageURI(selectedImageUri) +// binding.ivUploadedImage.visibility = View.VISIBLE +// binding.layoutUploadPlaceholder.visibility = View.GONE +// } +// } + + private val pickImageLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == Activity.RESULT_OK) { + result.data?.data?.let { uri -> + handleSelectedImage(uri) + } } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - binding = ActivityAddEvidencePaymentBinding.inflate(layoutInflater) - setContentView(binding.root) - sessionManager = SessionManager(this) + try { + binding = ActivityAddEvidencePaymentBinding.inflate(layoutInflater) + setContentView(binding.root) - intent.extras?.let { bundle -> - orderId = bundle.getInt("ORDER_ID", 0) - paymentInfoId = bundle.getInt("PAYMENT_INFO_ID", 0) - productPrice = intent.getStringExtra("TOTAL_AMOUNT") ?: "Rp0" + sessionManager = SessionManager(this) + intent.extras?.let { bundle -> + orderId = bundle.getInt("ORDER_ID", 0) + paymentInfoId = bundle.getInt("PAYMENT_INFO_ID", 0) + productPrice = intent.getStringExtra("TOTAL_AMOUNT") ?: "Rp0" + Log.d(TAG, "Intent data: OrderID=$orderId, PaymentInfoId=$paymentInfoId, Price=$productPrice") + } + + WindowCompat.setDecorFitsSystemWindows(window, false) + enableEdgeToEdge() + + ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, windowInsets -> + val systemBars = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + view.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) + windowInsets + } + + Log.d(TAG, "7. About to setup toolbar - COMMENTING OUT PROBLEMATIC LINE") + // COMMENT OUT THIS LINE TEMPORARILY: +// binding.toolbar.navigationIcon.apply { finish() } + + setupUI() + + viewModel.getOrderDetails(orderId) + + setupListeners() + setupObservers() + + } catch (e: Exception) { + Log.e(TAG, "ERROR in AddEvidencePaymentActivity onCreate: ${e.message}", e) + Toast.makeText(this, "Error: ${e.message}", Toast.LENGTH_LONG).show() } - - WindowCompat.setDecorFitsSystemWindows(window, false) - - enableEdgeToEdge() - - // Apply insets to your root layout - ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, windowInsets -> - val systemBars = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) - view.setPadding( - systemBars.left, - systemBars.top, - systemBars.right, - systemBars.bottom - ) - windowInsets - } - - binding.toolbar.navigationIcon.apply { - onBackPressed() - } - - setupUI() - viewModel.getOrderDetails(orderId) - - - setupListeners() - setupObservers() } private fun setupUI() { @@ -126,11 +140,11 @@ class AddEvidencePaymentActivity : AppCompatActivity() { // Upload image button binding.tvAddPhoto.setOnClickListener { - checkPermissionAndPickImage() + checkPermissionsAndShowImagePicker() } binding.frameUploadImage.setOnClickListener { - checkPermissionAndPickImage() + checkPermissionsAndShowImagePicker() } // Date picker @@ -158,6 +172,7 @@ class AddEvidencePaymentActivity : AppCompatActivity() { // Submit button binding.btnSubmit.setOnClickListener { validateAndUpload() + Log.d(TAG, "AddEvidencePaymentActivity onCreate completed") } } @@ -182,6 +197,112 @@ class AddEvidencePaymentActivity : AppCompatActivity() { } } + private fun checkPermissionsAndShowImagePicker() { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { + // For Android 13+ (API 33+), use READ_MEDIA_IMAGES + if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_MEDIA_IMAGES) != PackageManager.PERMISSION_GRANTED) { + ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.READ_MEDIA_IMAGES), REQUEST_CODE_STORAGE_PERMISSION) + } else { + showImagePickerOptions() + } + } else { + // For older versions, use READ_EXTERNAL_STORAGE + if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { + ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), REQUEST_CODE_STORAGE_PERMISSION) + } else { + showImagePickerOptions() + } + } + } + + // Exact same approach as ChatActivity + private fun showImagePickerOptions() { + val options = arrayOf( + "Pilih dari Galeri", + "Batal" + ) + + androidx.appcompat.app.AlertDialog.Builder(this) + .setTitle("Pilih Bukti Pembayaran") + .setItems(options) { dialog, which -> + when (which) { + 0 -> openGallery() // Gallery + 1 -> dialog.dismiss() // Cancel + } + } + .show() + } + + // Using the same gallery opening method as ChatActivity + private fun openGallery() { + try { + val intent = Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI) + pickImageLauncher.launch(intent) + } catch (e: Exception) { + Log.e(TAG, "Error opening gallery", e) + Toast.makeText(this, "Gagal membuka galeri", Toast.LENGTH_SHORT).show() + } + } + + private fun handleSelectedImage(uri: Uri) { + try { + Log.d(TAG, "Processing selected image: $uri") + + // Use the same copy-to-cache approach as ChatActivity + contentResolver.openInputStream(uri)?.use { inputStream -> + val fileName = "evidence_${System.currentTimeMillis()}.jpg" + val outputFile = File(cacheDir, fileName) + + outputFile.outputStream().use { outputStream -> + inputStream.copyTo(outputStream) + } + + if (outputFile.exists() && outputFile.length() > 0) { + // Check file size (max 5MB like ChatActivity) + if (outputFile.length() > 5 * 1024 * 1024) { + Log.e(TAG, "File too large: ${outputFile.length()} bytes") + Toast.makeText(this, "Gambar terlalu besar (maksimal 5MB)", Toast.LENGTH_SHORT).show() + outputFile.delete() + return + } + + // Success - update UI + selectedImageUri = Uri.fromFile(outputFile) + binding.ivUploadedImage.setImageURI(selectedImageUri) + binding.ivUploadedImage.visibility = View.VISIBLE + binding.layoutUploadPlaceholder.visibility = View.GONE + + Log.d(TAG, "Image processed successfully: ${outputFile.absolutePath}, size: ${outputFile.length()}") + Toast.makeText(this, "Gambar berhasil dipilih", Toast.LENGTH_SHORT).show() + } else { + Log.e(TAG, "Failed to create image file") + Toast.makeText(this, "Gagal memproses gambar", Toast.LENGTH_SHORT).show() + } + } ?: run { + Log.e(TAG, "Could not open input stream for URI: $uri") + Toast.makeText(this, "Tidak dapat mengakses gambar", Toast.LENGTH_SHORT).show() + } + } catch (e: Exception) { + Log.e(TAG, "Error handling selected image", e) + Toast.makeText(this, "Error: ${e.message}", Toast.LENGTH_SHORT).show() + } + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == REQUEST_CODE_STORAGE_PERMISSION) { + if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + showImagePickerOptions() + } else { + Toast.makeText(this, "Izin diperlukan untuk mengakses galeri", Toast.LENGTH_SHORT).show() + } + } + } + private fun validateAndUpload() { // Validate all fields if (selectedImageUri == null) { @@ -301,40 +422,6 @@ class AddEvidencePaymentActivity : AppCompatActivity() { } - - - private fun checkPermissionAndPickImage() { - val permission = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { - Manifest.permission.READ_MEDIA_IMAGES - } else { - Manifest.permission.READ_EXTERNAL_STORAGE - } - - if (ContextCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED) { - ActivityCompat.requestPermissions(this, arrayOf(permission), REQUEST_CODE_STORAGE_PERMISSION) - } else { - pickImage() - } - } - - override fun onRequestPermissionsResult( - requestCode: Int, - permissions: Array, - grantResults: IntArray - ) { - super.onRequestPermissionsResult(requestCode, permissions, grantResults) - if (requestCode == REQUEST_CODE_STORAGE_PERMISSION && grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { - pickImage() - } else { - Toast.makeText(this, "Izin dibutuhkan untuk memilih gambar", Toast.LENGTH_SHORT).show() - } - } - - private fun pickImage() { - getContent.launch("image/*") - } - - private fun showDatePicker() { val calendar = Calendar.getInstance() val year = calendar.get(Calendar.YEAR) diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/order/detail/PaymentActivity.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/order/detail/PaymentActivity.kt index 72f824e..d2c525a 100644 --- a/app/src/main/java/com/alya/ecommerce_serang/ui/order/detail/PaymentActivity.kt +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/order/detail/PaymentActivity.kt @@ -3,6 +3,7 @@ package com.alya.ecommerce_serang.ui.order.detail import android.content.Intent import android.os.Bundle import android.util.Log +import android.view.View import android.widget.Toast import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels @@ -43,65 +44,88 @@ class PaymentActivity : AppCompatActivity() { sessionManager = SessionManager(this) - WindowCompat.setDecorFitsSystemWindows(window, false) + setupWindowInsets() - enableEdgeToEdge() - - // Apply insets to your root layout - ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, windowInsets -> - val systemBars = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) - view.setPadding( - systemBars.left, - systemBars.top, - systemBars.right, - systemBars.bottom - ) - windowInsets - } - - // Mengambil data dari intent + // Get data from intent val orderId = intent.getIntExtra("ORDER_ID", 0) val paymentInfoId = intent.getIntExtra("ORDER_PAYMENT_ID", 0) if (orderId == 0) { Toast.makeText(this, "ID pesanan tidak valid", Toast.LENGTH_SHORT).show() finish() + return } - // Setup toolbar + // Setup observers FIRST + observeData() + + // Setup UI + setupToolbar() + setupClickListeners(orderId, paymentInfoId) + + // Load data LAST + Log.d(TAG, "Fetching order details for Order ID: $orderId") + viewModel.getOrderDetails(orderId) + } + + private fun setupWindowInsets() { + WindowCompat.setDecorFitsSystemWindows(window, false) + enableEdgeToEdge() + + ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, windowInsets -> + val systemBars = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + view.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) + windowInsets + } + } + + private fun setupToolbar() { binding.toolbar.setNavigationOnClickListener { - onBackPressedDispatcher finish() } + } - // Setup petunjuk transfer + private fun setupClickListeners(orderId: Int, paymentInfoId: Int) { + // Instructions clicks binding.layoutMBankingInstructions.setOnClickListener { - // Tampilkan instruksi mBanking showInstructions("mBanking") } binding.layoutATMInstructions.setOnClickListener { - // Tampilkan instruksi ATM showInstructions("ATM") } - // Setup button upload bukti bayar - binding.btnUploadPaymentProof.setOnClickListener { -// Intent ke activity upload bukti bayar - val intent = Intent(this, AddEvidencePaymentActivity::class.java) - intent.putExtra("ORDER_ID", orderId) - intent.putExtra("PAYMENT_INFO_ID", paymentInfoId) - intent.putExtra("TOTAL_AMOUNT", binding.tvTotalAmount.text.toString()) - Log.d(TAG, "Received Order ID: $orderId, Payment Info ID: $paymentInfoId, Total Amount: ${binding.tvTotalAmount.text}") + // Upload button +// binding.btnUploadPaymentProof.setOnClickListener { view -> +// Log.d(TAG, "Button clicked - showing toast") +// Toast.makeText(this@PaymentActivity, "Button works! OrderID: $orderId", Toast.LENGTH_LONG).show() +// } + binding.btnUploadPaymentProof.apply { + isEnabled = true + isClickable = true - startActivity(intent) + setOnClickListener { + Log.d(TAG, "Button clicked!") + + val intent = Intent(this@PaymentActivity, AddEvidencePaymentActivity::class.java).apply { + putExtra("ORDER_ID", orderId) + putExtra("PAYMENT_INFO_ID", paymentInfoId) + putExtra("TOTAL_AMOUNT", binding.tvTotalAmount.text.toString()) + } + + Log.d(TAG, "Starting AddEvidencePaymentActivity with Order ID: $orderId, Payment Info ID: $paymentInfoId") + startActivity(intent) + } + + // Debug touch events + setOnTouchListener { _, event -> + Log.d(TAG, "Button touched: ${event.action}") + false + } } - // Observe data - observeData() - // Load data - Log.d(TAG, "Fetching order details for Order ID: $orderId") - viewModel.getOrderDetails(orderId) + // Debug button state + Log.d(TAG, "Button setup - isEnabled: ${binding.btnUploadPaymentProof.isEnabled}, isClickable: ${binding.btnUploadPaymentProof.isClickable}") } private fun observeData() { @@ -124,13 +148,14 @@ class PaymentActivity : AppCompatActivity() { setupPaymentDueDate(order.updatedAt) } - // Observe loading state viewModel.isLoading.observe(this) { isLoading -> - // Show loading indicator if needed - // binding.progressBar.visibility = if (isLoading) View.VISIBLE else View.GONE + Log.d(TAG, "Loading state changed: $isLoading") + // Fix this line: + binding.progressBar.visibility = if (isLoading) View.VISIBLE else View.GONE + + Log.d(TAG, "Button enabled: ${binding.btnUploadPaymentProof.isEnabled}") } - // Observe error viewModel.error.observe(this) { error -> if (error.isNotEmpty()) { Toast.makeText(this, error, Toast.LENGTH_SHORT).show() diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/product/category/CategoryProductsActivity.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/product/category/CategoryProductsActivity.kt new file mode 100644 index 0000000..fb80bbb --- /dev/null +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/product/category/CategoryProductsActivity.kt @@ -0,0 +1,188 @@ +package com.alya.ecommerce_serang.ui.product.category + +import android.content.Intent +import android.os.Bundle +import android.view.MenuItem +import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.isVisible +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.GridLayoutManager +import com.alya.ecommerce_serang.BuildConfig.BASE_URL +import com.alya.ecommerce_serang.R +import com.alya.ecommerce_serang.data.api.dto.CategoryItem +import com.alya.ecommerce_serang.data.api.dto.ProductsItem +import com.alya.ecommerce_serang.data.api.retrofit.ApiConfig +import com.alya.ecommerce_serang.data.repository.ProductRepository +import com.alya.ecommerce_serang.databinding.ActivityCategoryProductsBinding +import com.alya.ecommerce_serang.ui.product.DetailProductActivity +import com.alya.ecommerce_serang.utils.BaseViewModelFactory +import com.alya.ecommerce_serang.utils.SessionManager +import com.bumptech.glide.Glide +import kotlinx.coroutines.launch + +class CategoryProductsActivity : AppCompatActivity() { + + private lateinit var binding: ActivityCategoryProductsBinding + private lateinit var sessionManager: SessionManager + private var productsAdapter: ProductsCategoryAdapter? = null + + private val viewModel: CategoryProductsViewModel by viewModels { + BaseViewModelFactory { + val apiService = ApiConfig.getApiService(sessionManager) + val productRepository = ProductRepository(apiService) + CategoryProductsViewModel(productRepository) + } + } + + companion object { + const val EXTRA_CATEGORY = "extra_category" + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityCategoryProductsBinding.inflate(layoutInflater) + setContentView(binding.root) + + sessionManager = SessionManager(this) + + WindowCompat.setDecorFitsSystemWindows(window, false) + + enableEdgeToEdge() + + // Apply insets to your root layout + ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, windowInsets -> + val systemBars = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + view.setPadding( + systemBars.left, + systemBars.top, + systemBars.right, + systemBars.bottom + ) + windowInsets + } + + val category = intent.getParcelableExtra(EXTRA_CATEGORY) + if (category == null) { + finish() + return + } + + setupUI(category) + setupRecyclerView() + observeViewModel() + + // Load products for this category using category.id (not store_type_id) + viewModel.loadProductsByCategory(category.id) + } + + private fun setupUI(category: CategoryItem) { + binding.apply { + // Setup toolbar + setSupportActionBar(toolbar) + supportActionBar?.apply { + setDisplayHomeAsUpEnabled(true) +// title = category.name + } + + val fullImageUrl = if (category.image.startsWith("/")) { + BASE_URL + category.image.removePrefix("/") // Append base URL if the path starts with "/" + } else { + category.image // Use as is if it's already a full URL + } + + // Load category image + Glide.with(this@CategoryProductsActivity) + .load(fullImageUrl) + .placeholder(R.drawable.placeholder_image) + .error(R.drawable.placeholder_image) + .into(ivCategoryHeader) + + tvCategoryTitle.text = category.name + } + } + + private fun setupRecyclerView() { + productsAdapter = ProductsCategoryAdapter( + products = emptyList(), + onClick = { product -> handleProductClick(product) } + ) + + binding.rvProducts.apply { + layoutManager = GridLayoutManager(this@CategoryProductsActivity, 2) + adapter = productsAdapter +// addItemDecoration(GridSpacingItemDecoration(2, 16, true)) + } + } + + private fun observeViewModel() { + lifecycleScope.launch { + lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.uiState.collect { state -> + when (state) { + is CategoryProductsUiState.Loading -> { + binding.progressBar.isVisible = true + binding.rvProducts.isVisible = false + binding.layoutError.isVisible = false + binding.layoutEmpty.isVisible = false + } + is CategoryProductsUiState.Success -> { + binding.progressBar.isVisible = false + binding.layoutError.isVisible = false + + if (state.products.isEmpty()) { + binding.rvProducts.isVisible = false + binding.layoutEmpty.isVisible = true + } else { + binding.rvProducts.isVisible = true + binding.layoutEmpty.isVisible = false + productsAdapter?.updateProducts(state.products) + } + } + is CategoryProductsUiState.Error -> { + binding.progressBar.isVisible = false + binding.rvProducts.isVisible = false + binding.layoutEmpty.isVisible = false + binding.layoutError.isVisible = true + binding.tvErrorMessage.text = state.message + + binding.btnRetry.setOnClickListener { + val category = intent.getParcelableExtra( + EXTRA_CATEGORY + ) + category?.let { viewModel.loadProductsByCategory(it.id) } + } + } + } + } + } + } + } + + private fun handleProductClick(product: ProductsItem) { + val intent = Intent(this, DetailProductActivity::class.java) + intent.putExtra("PRODUCT_ID", product.id) + startActivity(intent) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + android.R.id.home -> { + onBackPressed() + true + } + else -> super.onOptionsItemSelected(item) + } + } + + override fun onDestroy() { + super.onDestroy() + productsAdapter = null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/product/category/CategoryProductsViewModel.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/product/category/CategoryProductsViewModel.kt new file mode 100644 index 0000000..91c78a8 --- /dev/null +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/product/category/CategoryProductsViewModel.kt @@ -0,0 +1,49 @@ +package com.alya.ecommerce_serang.ui.product.category + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.alya.ecommerce_serang.data.api.dto.ProductsItem +import com.alya.ecommerce_serang.data.repository.ProductRepository +import com.alya.ecommerce_serang.data.repository.Result +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class CategoryProductsViewModel( + private val productRepository: ProductRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(CategoryProductsUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + fun loadProductsByCategory(categoryId: Int) { + viewModelScope.launch { + _uiState.value = CategoryProductsUiState.Loading + + when (val result = productRepository.getProductsByCategory(categoryId)) { + is com.alya.ecommerce_serang.data.repository.Result.Success -> { + _uiState.value = CategoryProductsUiState.Success(result.data) + } + is com.alya.ecommerce_serang.data.repository.Result.Error -> { + _uiState.value = CategoryProductsUiState.Error( + result.exception.message ?: "Failed to load products" + ) + } + is Result.Loading -> { + // Handle if needed + } + } + } + } + + fun retry(categoryId: Int) { + loadProductsByCategory(categoryId) + } +} + +sealed class CategoryProductsUiState { + object Loading : CategoryProductsUiState() + data class Success(val products: List) : CategoryProductsUiState() + data class Error(val message: String) : CategoryProductsUiState() +} \ No newline at end of file diff --git a/app/src/main/java/com/alya/ecommerce_serang/ui/product/category/ProductsCategoryAdapter.kt b/app/src/main/java/com/alya/ecommerce_serang/ui/product/category/ProductsCategoryAdapter.kt new file mode 100644 index 0000000..7e82b2b --- /dev/null +++ b/app/src/main/java/com/alya/ecommerce_serang/ui/product/category/ProductsCategoryAdapter.kt @@ -0,0 +1,73 @@ +package com.alya.ecommerce_serang.ui.product.category + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.RecyclerView +import com.alya.ecommerce_serang.BuildConfig +import com.alya.ecommerce_serang.R +import com.alya.ecommerce_serang.data.api.dto.ProductsItem +import com.alya.ecommerce_serang.databinding.ItemProductGridBinding +import com.bumptech.glide.Glide +import java.text.NumberFormat +import java.util.Locale + +class ProductsCategoryAdapter( + private var products: List, + private val onClick: (ProductsItem) -> Unit +) : RecyclerView.Adapter() { + + fun updateProducts(newProducts: List) { + products = newProducts + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ProductViewHolder { + val binding = ItemProductGridBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + return ProductViewHolder(binding) + } + + override fun onBindViewHolder(holder: ProductViewHolder, position: Int) { + holder.bind(products[position]) + } + + override fun getItemCount(): Int = products.size + + inner class ProductViewHolder( + private val binding: ItemProductGridBinding + ) : RecyclerView.ViewHolder(binding.root) { + + fun bind(product: ProductsItem) { + binding.apply { + tvProductName.text = product.name + val priceValue = product.price.toDoubleOrNull() ?: 0.0 + tvProductPrice.text = "Rp ${NumberFormat.getNumberInstance(Locale("id", "ID")).format(priceValue.toInt())}" + // Load product image + Glide.with(itemView.context) + .load("${BuildConfig.BASE_URL}${product.image}") + .placeholder(R.drawable.placeholder_image) + .error(R.drawable.placeholder_image) + .centerCrop() + .into(ivProductImage) + + // Set click listener + root.setOnClickListener { + onClick(product) + } + + // Optional: Show stock status + if (product.stock > 0) { + tvStockStatus.text = "Stock: ${product.stock}" + tvStockStatus.setTextColor(ContextCompat.getColor(itemView.context, R.color.green)) + } else { + tvStockStatus.text = "Out of Stock" + tvStockStatus.setTextColor(ContextCompat.getColor(itemView.context, R.color.red)) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/activity_category_products.xml b/app/src/main/res/layout/activity_category_products.xml new file mode 100644 index 0000000..2c023dc --- /dev/null +++ b/app/src/main/res/layout/activity_category_products.xml @@ -0,0 +1,178 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +