Merge branch 'screen-features'

This commit is contained in:
shaulascr
2025-05-29 16:40:25 +07:00
13 changed files with 862 additions and 171 deletions

View File

@ -29,6 +29,9 @@
android:theme="@style/Theme.Ecommerce_serang"
android:usesCleartextTraffic="true"
tools:targetApi="31">
<activity
android:name=".ui.product.category.CategoryProductsActivity"
android:exported="false" />
<activity
android:name=".ui.profile.mystore.chat.ChatListStoreActivity"
android:exported="false" />
@ -69,11 +72,11 @@
android:enabled="true"
android:exported="false"
android:foregroundServiceType="dataSync" />
<activity
android:name=".ui.profile.mystore.chat.ChatStoreActivity"
android:exported="false"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".ui.profile.mystore.profile.shipping_service.ShippingServiceActivity"
android:exported="false" />

View File

@ -288,6 +288,58 @@ class ProductRepository(private val apiService: ApiService) {
}
}
suspend fun getProductsByCategory(categoryId: Int): Result<List<ProductsItem>> =
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<CategoryItem?> =
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"
}

View File

@ -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() {

View File

@ -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
}
}
}

View File

@ -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<out String>,
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<out String>,
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)

View File

@ -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()

View File

@ -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<CategoryItem>(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<CategoryItem>(
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
}
}

View File

@ -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>(CategoryProductsUiState.Loading)
val uiState: StateFlow<CategoryProductsUiState> = _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<ProductsItem>) : CategoryProductsUiState()
data class Error(val message: String) : CategoryProductsUiState()
}

View File

@ -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<ProductsItem>,
private val onClick: (ProductsItem) -> Unit
) : RecyclerView.Adapter<ProductsCategoryAdapter.ProductViewHolder>() {
fun updateProducts(newProducts: List<ProductsItem>) {
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))
}
}
}
}
}

View File

@ -0,0 +1,178 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.product.category.CategoryProductsActivity">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar">
<com.google.android.material.appbar.CollapsingToolbarLayout
android:layout_width="match_parent"
android:layout_height="200dp"
app:layout_scrollFlags="scroll|exitUntilCollapsed"
app:contentScrim="?attr/colorPrimary"
app:expandedTitleMarginStart="48dp"
app:expandedTitleMarginEnd="64dp">
<ImageView
android:id="@+id/iv_category_header"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="centerCrop"
app:layout_collapseMode="parallax"
android:contentDescription="Category Header Image" />
<View
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/blue_50" />
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:layout_collapseMode="pin"
app:popupTheme="@style/ThemeOverlay.AppCompat.Light" />
</com.google.android.material.appbar.CollapsingToolbarLayout>
</com.google.android.material.appbar.AppBarLayout>
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<!-- Category Title Section -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/tv_category_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Category Name"
android:textSize="24sp"
android:textStyle="bold"
android:textColor="@color/black_500"
android:gravity="center" />
<View
android:layout_width="60dp"
android:layout_height="3dp"
android:layout_gravity="center"
android:layout_marginTop="8dp"
android:background="@color/blue_500" />
</LinearLayout>
<!-- Products Section -->
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp">
<!-- Loading State -->
<ProgressBar
android:id="@+id/progress_bar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginTop="32dp"
android:visibility="gone" />
<!-- Products RecyclerView -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_products"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="8dp"
android:clipToPadding="false"
tools:listitem="@layout/item_product_grid" />
<!-- Empty State -->
<LinearLayout
android:id="@+id/layout_empty"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:orientation="vertical"
android:padding="32dp"
android:gravity="center"
android:visibility="gone">
<ImageView
android:layout_width="120dp"
android:layout_height="120dp"
android:src="@drawable/baseline_search_24"
android:layout_marginBottom="16dp"
android:contentDescription="No Products" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="No products found in this category"
android:textSize="16sp"
android:textColor="@color/black_200"
android:gravity="center" />
</LinearLayout>
<!-- Error State -->
<LinearLayout
android:id="@+id/layout_error"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:orientation="vertical"
android:padding="32dp"
android:gravity="center"
android:visibility="gone">
<ImageView
android:layout_width="120dp"
android:layout_height="120dp"
android:src="@drawable/baseline_alarm_24"
android:layout_marginBottom="16dp"
android:contentDescription="Error" />
<TextView
android:id="@+id/tv_error_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Something went wrong"
android:textSize="16sp"
android:textColor="@color/black_200"
android:gravity="center"
android:layout_marginBottom="16dp" />
<Button
android:id="@+id/btn_retry"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Retry"
style="@style/Widget.MaterialComponents.Button.OutlinedButton" />
</LinearLayout>
</FrameLayout>
</LinearLayout>
</androidx.core.widget.NestedScrollView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -230,7 +230,20 @@
android:background="@drawable/bg_button_filled"
android:text="Kirim Bukti Bayar"
android:textAllCaps="false"
android:textColor="@android:color/white" />
android:textColor="@android:color/white"
android:enabled="true"
android:clickable="true"
android:focusable="true" />
</LinearLayout>
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,67 +1,84 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
app:cardCornerRadius="8dp"
app:cardElevation="2dp">
app:cardCornerRadius="12dp"
app:cardElevation="4dp"
android:foreground="?android:attr/selectableItemBackground">
<androidx.constraintlayout.widget.ConstraintLayout
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
android:layout_height="wrap_content"
android:orientation="vertical">
<!-- Product Image -->
<ImageView
android:id="@+id/productImage"
android:id="@+id/iv_product_image"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_height="160dp"
android:scaleType="centerCrop"
app:layout_constraintDimensionRatio="1:1"
app:layout_constraintTop_toTopOf="parent"
android:background="@color/light_gray"
android:contentDescription="Product Image"
tools:src="@drawable/placeholder_image" />
<TextView
android:id="@+id/productName"
<!-- Product Info -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:ellipsize="end"
android:maxLines="2"
android:textSize="14sp"
app:layout_constraintTop_toBottomOf="@id/productImage"
tools:text="Product Name" />
android:orientation="vertical"
android:padding="12dp">
<TextView
android:id="@+id/storeName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="4dp"
android:layout_marginEnd="8dp"
android:ellipsize="end"
android:maxLines="1"
android:textSize="12sp"
android:fontFamily="@font/dmsans_medium"
app:layout_constraintTop_toBottomOf="@id/productName"
tools:text="Store Name" />
<!-- Product Name -->
<TextView
android:id="@+id/tv_product_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Product Name"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="@color/black_500"
android:maxLines="2"
android:ellipsize="end"
tools:text="Sample Product Name" />
<TextView
android:id="@+id/productPrice"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="4dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="8dp"
android:textColor="@color/blue1"
android:textSize="16sp"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@id/storeName"
tools:text="Rp 150.000" />
<!-- Product Price -->
<TextView
android:id="@+id/tv_product_price"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="Rp 0"
android:textSize="16sp"
android:textStyle="bold"
android:textColor="@color/blue_500"
tools:text="Rp 25,000" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Stock Status -->
<TextView
android:id="@+id/tv_stock_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="Stock: 0"
android:textSize="12sp"
android:textColor="@color/black_200"
tools:text="Stock: 15" />
<TextView
android:id="@+id/tv_store_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="Toko Jaya"
android:fontFamily="@font/dmsans_semibold"
android:textSize="14sp"
android:textColor="@color/black_200"
tools:text="Stock: 15" />
</LinearLayout>
</LinearLayout>
</androidx.cardview.widget.CardView>

View File

@ -42,6 +42,8 @@
<color name="soft_gray">#7D8FAB</color>
<color name="blue1">#489EC6</color>
<color name="yellow">#faf069</color>
<color name="green">#A5C882</color>
<color name="red">#EC4E20</color>
<color name="bottom_navigation_icon_color_active">#489EC6</color>
<color name="bottom_navigation_icon_color_inactive">#8E8E8E</color>