diff --git a/README.md b/README.md index a8bf0de8b..6f9284e08 100644 --- a/README.md +++ b/README.md @@ -1 +1,56 @@ -# android-shopping-order \ No newline at end of file +# android-shopping-order + +### 1,2단계 기능 요구 사항 +- 스켈레톤 UI 노출 + - [x] 상품 목록 + - [x] 장바구니 +- 서버 연동 + - [x] 장바구니 아이템 + - [ ] 주문 + - [x] 상품 +- [x] 사용자 인증 정보 저장 +- [x] 장바구니 화면에서 특정 상품만 골라 주문하기 버튼을 누를 수 있다. +- [x] 별도의 화면에서 상품 추천 알고리즘으로 사용자에게 적절한 상품을 추천해준다. (쿠팡 UX 참고) +- [x] 상품 추천 알고리즘은 최근 본 상품 카테고리를 기반으로 최대 10개 노출한다. +- [x] 예를 들어 가장 최근에 본 상품이 fashion 카테고리라면, fashion 상품 10개 노출 +- [x] 해당 카테고리 상품이 10개 미만이라면 해당하는 개수만큼만 노출 +- [x] 장바구니에 이미 추가된 상품이라면 미노출 +- [x] 추천된 상품을 해당 화면에서 바로 추가하여 같이 주문할 수 있다. + +### 코니 요구사항 +- [x] 리드미 작성, PR 작성 잘하기 + +상품 목록 화면 구현 +- [x] 최근 본 상품이 0개인 경우, 최근 본 상품 목록 삭제 + +Cart 화면 구현 +- [x] 추천된 상품을 해당 화면에서 바로 추가하여 같이 주문할 수 있다. 구현 +- [x] 총 가격은 변하는데 왜 체크 박스는 변하지 않는 기능 수정 + +- [x] ShoppingApplication에 thread { DevMockServer.start() } 삭제 +- [x] ShoppingApplication에서 정말 SharedPreferences에 값을 저장하고 있는 지 확인 + SharedPreference에서 어떻게 동작하는 지 확인 +- [x] CatalogViewModel에서 catalogProduct, quantity 사용하지 않는 값 삭제 +- [x] CatalogViewModel에서 interface에서 remoteCatalogProductRepositoryImpl 실제 구현체 주입 -> 수정 +- [x] 최근 상품 목록이 0개인 경우, 최근 본 상품 목록 뷰가 보이지 않게 하기 +- [x] baseUrl 노출하지 않도록 수정 +- [x] 사용자 정보 local.properties에 저장 -> BuildConfig로 갖고 오게 끔 구현 + - [ ] 레벨업 부분 ) 인증에 필요한 key은 secerets에 저장 +- CartActivity에서 생각해야 할 부분 + - [x] CartActivity에서 hasHandledTotalCount가 정말 필요한 로직일지 생각 + - [x] View에서 totalCount가 몇인지에 따라서 어떤한 fragment를 commit할지 아닌 ViewModel에서 어떠한 상태를 두고 그 상태에 따라서 화면을 이동하는 것 + - [x] View까지 와서 단순 ViewModel의 함수를 호출하는 observe를 해야하는 지 생각 -> ViewModel의 일은 스스로 하게 변 +- CartRecommendationFragment + - [x] 로그 삭제 + - [x] 이런 식으로 Unit으로 처리를 하게 될 함수가 필수 overide 라면 ProductActionListener 에서 저 함수가 필수가 아니었던 게 아닌지 생각 + - [x] collect는 viewLifecycleOwner 따라가게끔 해주셨는데, 데이터 바인딩에 사용될 lifecycleOwner 는 this 를 넘기고 있음 +- CartSelectionFragment + - [x] 아직 View가 만들어지기 전에 view에 필요한 데이터를 미리 observe를 하고 adpater를 세팅할 필요한지 + - [x] onCreateView에서는 어떠한 동작을 해야 하고 어떠한 단계인지 생각 + - [x] ViewModelStoreOwner를 Activity로 잡은 점 + - [x] Application이 왜 필요로 하는가? + - [x] Enabled 된 것이랑 updateCartItems 를 호출하는 상관관계 -> 전반적으로 view까지 observe가 되어야 했던 값들일까 하는 의문 + - [x] 데이터 바인딩 적용 + - [x] updateCartItems()가 view에서 처리해야하는 지 생각 +- CartViewModel + - [x] totalCount가 -1 라는 것은 논리적으로 읽을 수 없음 -> 상수화 +----- https://github.com/woowacourse/android-shopping-order/pull/111#discussion_r2113932605 여기 부터 적용 다시 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0e9da592f..5bf773121 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,7 +1,11 @@ +import java.util.Properties + plugins { alias(libs.plugins.android.application) alias(libs.plugins.android.junit5) alias(libs.plugins.kotlin.android) + id("kotlin-kapt") + id("kotlin-parcelize") } android { @@ -16,7 +20,18 @@ android { versionName = "1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - testInstrumentationRunnerArguments["runnerBuilder"] = "de.mannodermaus.junit5.AndroidJUnit5Builder" + testInstrumentationRunnerArguments["runnerBuilder"] = + "de.mannodermaus.junit5.AndroidJUnit5Builder" + + val localProperties = Properties() + val localPropertiesFile = rootProject.file("local.properties") + if (localPropertiesFile.exists()) { + localProperties.load(localPropertiesFile.inputStream()) + } + + buildConfigField("String", "USER_ID", "\"${localProperties.getProperty("USER_ID")}\"") + buildConfigField("String", "USER_PASSWORD", "\"${localProperties.getProperty("USER_PASSWORD")}\"") + buildConfigField("String", "BASE_URL", "\"${localProperties.getProperty("BASE_URL")}\"") } buildTypes { @@ -41,17 +56,36 @@ android { excludes += "win32-x86*/**" } } + buildFeatures { + dataBinding = true + buildConfig = true + } } dependencies { + implementation(libs.glide) + implementation(libs.androidx.room.runtime) + kapt(libs.androidx.room.compiler) implementation(libs.androidx.activity.ktx) implementation(libs.androidx.appcompat) implementation(libs.androidx.constraintlayout) implementation(libs.androidx.core.ktx) implementation(libs.google.material) + implementation(libs.androidx.activity) + implementation(libs.okhttp) + implementation(libs.gson) + implementation(libs.mockwebserver) + implementation(libs.shimmer) + implementation(libs.retrofit) + implementation(libs.converter.gson) + implementation(libs.logging.interceptor) + implementation(libs.androidx.fragment.ktx) + + testImplementation(libs.androidx.core.testing) testImplementation(libs.assertj.core) testImplementation(libs.junit.jupiter) testImplementation(libs.kotest.runner.junit5) + androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.test.ext.junit) androidTestImplementation(libs.androidx.test.runner) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index fdde9c82d..48af2e804 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,7 +2,12 @@ + + + + @@ -23,4 +34,4 @@ - + \ No newline at end of file diff --git a/app/src/main/java/woowacourse/shopping/MainActivity.kt b/app/src/main/java/woowacourse/shopping/MainActivity.kt deleted file mode 100644 index ef3691611..000000000 --- a/app/src/main/java/woowacourse/shopping/MainActivity.kt +++ /dev/null @@ -1,20 +0,0 @@ -package woowacourse.shopping - -import android.os.Bundle -import androidx.activity.enableEdgeToEdge -import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat - -class MainActivity : AppCompatActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdge() - setContentView(R.layout.activity_main) - ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets -> - val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) - v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) - insets - } - } -} diff --git a/app/src/main/java/woowacourse/shopping/ShoppingApplication.kt b/app/src/main/java/woowacourse/shopping/ShoppingApplication.kt new file mode 100644 index 000000000..1d6ac29c9 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/ShoppingApplication.kt @@ -0,0 +1,14 @@ +package woowacourse.shopping + +import android.app.Application +import woowacourse.shopping.data.database.ShoppingDatabase + +class ShoppingApplication : Application() { + lateinit var database: ShoppingDatabase + private set + + override fun onCreate() { + super.onCreate() + database = ShoppingDatabase.getInstance(applicationContext) + } +} diff --git a/app/src/main/java/woowacourse/shopping/cart/CartActivity.kt b/app/src/main/java/woowacourse/shopping/cart/CartActivity.kt new file mode 100644 index 000000000..4ce88fcd8 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/cart/CartActivity.kt @@ -0,0 +1,79 @@ +package woowacourse.shopping.cart + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.enableEdgeToEdge +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.databinding.DataBindingUtil +import androidx.fragment.app.commit +import androidx.lifecycle.ViewModelProvider +import woowacourse.shopping.R +import woowacourse.shopping.cart.recoomendation.RecommendationFragment +import woowacourse.shopping.cart.selection.SelectionFragment +import woowacourse.shopping.databinding.ActivityCartBinding + +class CartActivity : AppCompatActivity() { + private val binding: ActivityCartBinding by lazy { + DataBindingUtil.setContentView(this, R.layout.activity_cart) + } + private val viewModel: CartViewModel by lazy { + ViewModelProvider( + this, + CartViewModelFactory(), + )[CartViewModel::class.java] + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + initView() + initActionBar() + initDataBinding() + observeData() + } + + private fun initView() { + ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) + insets + } + } + + private fun initActionBar() { + supportActionBar?.setDisplayHomeAsUpEnabled(true) + supportActionBar?.title = getString(R.string.text_cart_action_bar) + } + + override fun onSupportNavigateUp(): Boolean { + finish() + return super.onSupportNavigateUp() + } + + private fun initDataBinding() { + binding.vm = viewModel + binding.lifecycleOwner = this + } + + private fun observeData() { + viewModel.totalPurchaseCount.observe(this) { totalPurchaseCount -> + val fragment = + when (totalPurchaseCount) { + 0 -> RecommendationFragment() + else -> SelectionFragment() + } + supportFragmentManager.commit { + setReorderingAllowed(true) + replace(R.id.fragment_container_cart_selection, fragment) + } + } + } + + companion object { + fun newIntent(context: Context): Intent = Intent(context, CartActivity::class.java) + } +} diff --git a/app/src/main/java/woowacourse/shopping/cart/CartAdapter.kt b/app/src/main/java/woowacourse/shopping/cart/CartAdapter.kt new file mode 100644 index 000000000..42c315771 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/cart/CartAdapter.kt @@ -0,0 +1,64 @@ +package woowacourse.shopping.cart + +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import woowacourse.shopping.cart.CartItem.PaginationButtonItem +import woowacourse.shopping.cart.CartItem.ProductItem +import woowacourse.shopping.product.catalog.ProductUiModel +import woowacourse.shopping.product.catalog.QuantityControlListener + +class CartAdapter( + cartItems: List, + private val onDeleteProductClick: DeleteProductClickListener, + private val onPaginationButtonClick: PaginationButtonClickListener, + private val quantityControlListener: QuantityControlListener, + private val onCheckClick: CheckClickListener, +) : RecyclerView.Adapter() { + private val cartItems: MutableList = cartItems.toMutableList() + + fun setCartItems(cartProducts: List) { + notifyItemRangeRemoved(0, cartItems.size) + cartItems.clear() + cartItems.addAll(cartProducts) + notifyItemRangeInserted(0, cartItems.size) + } + + fun setCartItem(product: ProductUiModel) { + val index = cartItems.filterIsInstance().indexOfFirst { it.productItem.id == product.id } + cartItems[index] = ProductItem(product) + notifyItemChanged(index) + } + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): RecyclerView.ViewHolder = + if (viewType == CART_PRODUCT) { + CartViewHolder.from(parent, onDeleteProductClick, quantityControlListener, onCheckClick) + } else { + PaginationButtonViewHolder.from(parent, onPaginationButtonClick) + } + + override fun onBindViewHolder( + holder: RecyclerView.ViewHolder, + position: Int, + ) { + when (holder) { + is CartViewHolder -> holder.bind((cartItems[position] as ProductItem).productItem) + is PaginationButtonViewHolder -> holder.bind(cartItems[position] as PaginationButtonItem) + } + } + + override fun getItemViewType(position: Int): Int = + when (cartItems[position]) { + is PaginationButtonItem -> PAGINATION_BUTTON + is ProductItem -> CART_PRODUCT + } + + override fun getItemCount(): Int = cartItems.size + + companion object { + private const val CART_PRODUCT = 1 + private const val PAGINATION_BUTTON = 2 + } +} diff --git a/app/src/main/java/woowacourse/shopping/cart/CartItem.kt b/app/src/main/java/woowacourse/shopping/cart/CartItem.kt new file mode 100644 index 000000000..b497d39b6 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/cart/CartItem.kt @@ -0,0 +1,15 @@ +package woowacourse.shopping.cart + +import woowacourse.shopping.product.catalog.ProductUiModel + +sealed class CartItem { + data class ProductItem( + val productItem: ProductUiModel, + ) : CartItem() + + data class PaginationButtonItem( + val page: Int, + val isNextButtonEnabled: Boolean, + val isPrevButtonEnabled: Boolean, + ) : CartItem() +} diff --git a/app/src/main/java/woowacourse/shopping/cart/CartViewHolder.kt b/app/src/main/java/woowacourse/shopping/cart/CartViewHolder.kt new file mode 100644 index 000000000..4ef238aff --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/cart/CartViewHolder.kt @@ -0,0 +1,34 @@ +package woowacourse.shopping.cart + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import woowacourse.shopping.databinding.CartItemBinding +import woowacourse.shopping.product.catalog.ProductUiModel +import woowacourse.shopping.product.catalog.QuantityControlListener + +class CartViewHolder( + private val binding: CartItemBinding, +) : RecyclerView.ViewHolder(binding.root) { + fun bind(cartProduct: ProductUiModel) { + binding.cartProduct = cartProduct + binding.checkboxSelection.isChecked = cartProduct.isChecked + } + + companion object { + fun from( + parent: ViewGroup, + onDeleteProductClick: DeleteProductClickListener, + quantityControlListener: QuantityControlListener, + onCheckClick: CheckClickListener, + ): CartViewHolder { + val inflater = LayoutInflater.from(parent.context) + val binding = CartItemBinding.inflate(inflater, parent, false) + binding.clickListener = onDeleteProductClick + binding.checkClickListener = onCheckClick + binding.layoutQuantityControlBar.quantityControlListener = quantityControlListener + + return CartViewHolder(binding) + } + } +} diff --git a/app/src/main/java/woowacourse/shopping/cart/CartViewModel.kt b/app/src/main/java/woowacourse/shopping/cart/CartViewModel.kt new file mode 100644 index 000000000..4d2cb6cab --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/cart/CartViewModel.kt @@ -0,0 +1,226 @@ +package woowacourse.shopping.cart + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import woowacourse.shopping.cart.CartItem.ProductItem +import woowacourse.shopping.data.repository.CartRepository +import woowacourse.shopping.product.catalog.ProductUiModel +import kotlin.collections.map + +class CartViewModel( + private val cartRepository: CartRepository, +) : ViewModel() { + private val _cartProducts = MutableLiveData>(emptyList()) + val cartProducts: LiveData> = _cartProducts + + private val _isNextButtonEnabled = MutableLiveData(false) + val isNextButtonEnabled: LiveData = _isNextButtonEnabled + + private val _isPrevButtonEnabled = MutableLiveData(false) + val isPrevButtonEnabled: LiveData = _isPrevButtonEnabled + + private val _page = MutableLiveData(INITIAL_PAGE) + val page: LiveData = _page + + private val _updatedItem = MutableLiveData() + val updatedItem: LiveData = _updatedItem + + private val _loadingState: MutableLiveData = + MutableLiveData(true) + val loadingState: LiveData get() = _loadingState + + private val _totalPurchaseCount = MutableLiveData(INITIAL_CART_ITEM_COUNT) + val totalPurchaseCount: LiveData get() = _totalPurchaseCount + + private val _totalPurchaseAmount = MutableLiveData(0) + val totalPurchaseAmount: LiveData get() = _totalPurchaseAmount + + private val _allChecked = MutableLiveData(true) + val allChecked: LiveData get() = _allChecked + + init { + loadCartProducts() + } + + fun onAllSelectedProducts() { + val isChecked = _allChecked.value ?: true + _cartProducts.value = + _cartProducts.value?.map { + it.copy(isChecked = !isChecked) + } + _allChecked.value = !isChecked + fetchTotalPurchaseAmount() + } + + fun fetchTotalPurchaseAmount() { + val amount = + _cartProducts.value?.filter { it.isChecked == true }?.sumOf { it.price * it.quantity } + ?: 0 + _totalPurchaseAmount.postValue(amount) + } + + fun fetchTotalPurchaseCount() { + val counts = _cartProducts.value?.count { it.isChecked == true } ?: 0 + _totalPurchaseCount.postValue(counts) + } + + fun deleteCartProduct(cartProduct: ProductItem) { + cartRepository.deleteCartProduct(cartProduct.productItem) { result -> + if (result) { + val newCartProducts = + _cartProducts.value?.filterNot { it == cartProduct.productItem } ?: emptyList() + _cartProducts.postValue(newCartProducts) + val currentPage = page.value ?: INITIAL_PAGE + val startIndex = currentPage * PAGE_SIZE + if (startIndex >= newCartProducts.size && currentPage > 0) { + decreasePage() + } + loadCartProducts() + } + } + } + + fun onPaginationButtonClick(buttonDirection: Int) { + cartRepository.getTotalProductsCount { totalSize -> + val currentPage = page.value ?: INITIAL_PAGE + val lastPage = (totalSize - 1) / PAGE_SIZE + + when (buttonDirection) { + PREV_BUTTON -> { + if (currentPage > 0) { + decreasePage() + loadCartProducts() + } + } + + NEXT_BUTTON -> { + if (currentPage < lastPage) { + increasePage() + loadCartProducts() + } + } + } + } + } + + fun updateQuantity( + event: Int, + product: ProductUiModel, + ) { + when (event) { + DECREASE_BUTTON -> decreaseQuantity(product) + INCREASE_BUTTON -> increaseQuantity(product) + } + } + + fun increaseQuantity(product: ProductUiModel) { + cartRepository.updateCartProduct( + product, + product.quantity + A_COUNT, + ) { success -> + if (success) { + val newProduct = product.copy(quantity = product.quantity + A_COUNT) + updateItem(newProduct) + } + } + } + + fun decreaseQuantity(product: ProductUiModel) { + if (product.quantity > INITIAL_PRODUCT_COUNT) { + val newProduct = product.copy(quantity = product.quantity - A_COUNT) + cartRepository.updateCartProduct( + product, + product.quantity - A_COUNT, + ) { success -> + if (success) { + updateItem(newProduct) + } + } + } + } + + private fun updateItem(newProduct: ProductUiModel) { + _updatedItem.postValue(newProduct) + _cartProducts.value = + _cartProducts.value?.map { + if (it.id == newProduct.id) newProduct else it + } + fetchTotalPurchaseAmount() + fetchTotalPurchaseCount() + } + + fun changeProductSelection(product: ProductUiModel) { + val newProduct = product.copy(isChecked = !product.isChecked ?: true) + _cartProducts.value = + _cartProducts.value?.map { + if (it.id == newProduct.id) newProduct else it + } + _cartProducts.value?.let { list -> + if (list.isEmpty()) return@let + val firstChecked = list[0].isChecked ?: false + val allSame = list.all { (it.isChecked ?: false) == firstChecked } + if (allSame) { + _allChecked.value = firstChecked + } + } + fetchTotalPurchaseAmount() + fetchTotalPurchaseCount() + } + + private fun checkNextButtonEnabled(totalSize: Int) { + val currentPage = page.value ?: INITIAL_PAGE + val lastPage = (totalSize - 1) / PAGE_SIZE + _isNextButtonEnabled.postValue(currentPage < lastPage) + } + + private fun checkPrevButtonEnabled() { + val currentPage = page.value ?: INITIAL_PAGE + _isPrevButtonEnabled.postValue(currentPage >= 1) + } + + private fun increasePage() { + _page.postValue(page.value?.plus(1)) + } + + private fun decreasePage() { + _page.postValue(page.value?.minus(1)) + } + + private fun loadCartProducts(pageSize: Int = PAGE_SIZE) { + cartRepository.getTotalProductsCount { totalSize -> + _totalPurchaseCount.postValue(totalSize) + val currentPage = page.value ?: INITIAL_PAGE + val startIndex = currentPage * pageSize + if (startIndex >= totalSize) { + return@getTotalProductsCount + } + cartRepository + .getCartProductsInRange(currentPage, pageSize) { cartProducts -> + val pagedProducts: List = + cartProducts.map { + it.copy(isChecked = true) + } + _cartProducts.postValue(pagedProducts) + checkNextButtonEnabled(totalSize) + checkPrevButtonEnabled() + _loadingState.postValue(false) + val amount = + pagedProducts.filter { it.isChecked == true }.sumOf { it.price * it.quantity } + _totalPurchaseAmount.postValue(amount) + } + } + } + + companion object { + private const val PAGE_SIZE = 5 + private const val INITIAL_PAGE = 0 + private const val PREV_BUTTON = 1 + private const val NEXT_BUTTON = 2 + private const val DECREASE_BUTTON = 0 + private const val INCREASE_BUTTON = 1 + private const val INITIAL_CART_ITEM_COUNT = 0 + private const val INITIAL_PRODUCT_COUNT: Int = 1 + private const val A_COUNT: Int = 1 + } +} diff --git a/app/src/main/java/woowacourse/shopping/cart/CartViewModelFactory.kt b/app/src/main/java/woowacourse/shopping/cart/CartViewModelFactory.kt new file mode 100644 index 000000000..6eac18b80 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/cart/CartViewModelFactory.kt @@ -0,0 +1,24 @@ +package woowacourse.shopping.cart + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import woowacourse.shopping.data.repository.CartRepository +import woowacourse.shopping.data.repository.CartRepositoryImpl +import woowacourse.shopping.data.source.CartProductRemoteDataSource + +@Suppress("UNCHECKED_CAST") +class CartViewModelFactory : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(CartViewModel::class.java)) { + @Suppress("UNCHECKED_CAST") + val cartRepository: CartRepository = + CartRepositoryImpl.Companion.initialize( + cartProductDataSource = CartProductRemoteDataSource(), + ) + return CartViewModel( + cartRepository = cartRepository, + ) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +} diff --git a/app/src/main/java/woowacourse/shopping/cart/CheckClickListener.kt b/app/src/main/java/woowacourse/shopping/cart/CheckClickListener.kt new file mode 100644 index 000000000..307181a6b --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/cart/CheckClickListener.kt @@ -0,0 +1,7 @@ +package woowacourse.shopping.cart + +import woowacourse.shopping.product.catalog.ProductUiModel + +fun interface CheckClickListener { + fun onClick(product: ProductUiModel) +} diff --git a/app/src/main/java/woowacourse/shopping/cart/DeleteProductClickListener.kt b/app/src/main/java/woowacourse/shopping/cart/DeleteProductClickListener.kt new file mode 100644 index 000000000..0e4eee4a7 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/cart/DeleteProductClickListener.kt @@ -0,0 +1,7 @@ +package woowacourse.shopping.cart + +import woowacourse.shopping.product.catalog.ProductUiModel + +fun interface DeleteProductClickListener { + fun onClick(cartProduct: ProductUiModel) +} diff --git a/app/src/main/java/woowacourse/shopping/cart/PaginationButtonClickListener.kt b/app/src/main/java/woowacourse/shopping/cart/PaginationButtonClickListener.kt new file mode 100644 index 000000000..174f337b2 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/cart/PaginationButtonClickListener.kt @@ -0,0 +1,5 @@ +package woowacourse.shopping.cart + +fun interface PaginationButtonClickListener { + fun onClick(dir: Int) +} diff --git a/app/src/main/java/woowacourse/shopping/cart/PaginationButtonViewHolder.kt b/app/src/main/java/woowacourse/shopping/cart/PaginationButtonViewHolder.kt new file mode 100644 index 000000000..c30d671cb --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/cart/PaginationButtonViewHolder.kt @@ -0,0 +1,28 @@ +package woowacourse.shopping.cart + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import woowacourse.shopping.cart.CartItem.PaginationButtonItem +import woowacourse.shopping.databinding.PaginationButtonItemBinding + +class PaginationButtonViewHolder( + private val binding: PaginationButtonItemBinding, +) : RecyclerView.ViewHolder(binding.root) { + fun bind(paginationButtonItem: PaginationButtonItem) { + binding.paginationButtonItem = paginationButtonItem + binding.executePendingBindings() + } + + companion object { + fun from( + parent: ViewGroup, + onPaginationButtonClick: PaginationButtonClickListener, + ): PaginationButtonViewHolder { + val inflater = LayoutInflater.from(parent.context) + val binding = PaginationButtonItemBinding.inflate(inflater, parent, false) + binding.clickListener = onPaginationButtonClick + return PaginationButtonViewHolder(binding) + } + } +} diff --git a/app/src/main/java/woowacourse/shopping/cart/recoomendation/RecommendationFragment.kt b/app/src/main/java/woowacourse/shopping/cart/recoomendation/RecommendationFragment.kt new file mode 100644 index 000000000..d4a80645e --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/cart/recoomendation/RecommendationFragment.kt @@ -0,0 +1,105 @@ +package woowacourse.shopping.cart.recoomendation + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.databinding.DataBindingUtil +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelProvider +import woowacourse.shopping.R +import woowacourse.shopping.ShoppingApplication +import woowacourse.shopping.databinding.FragmentCartRecommendationBinding +import woowacourse.shopping.product.catalog.CatalogItem.ProductItem +import woowacourse.shopping.product.catalog.ProductActionListener +import woowacourse.shopping.product.catalog.ProductAdapter +import woowacourse.shopping.product.catalog.ProductUiModel +import woowacourse.shopping.product.detail.DetailActivity + +class RecommendationFragment : Fragment() { + private lateinit var binding: FragmentCartRecommendationBinding + private val viewModel: RecommendationFragmentViewModel by lazy { + ViewModelProvider( + this, + RecommendationFragmentViewModelFactory(requireActivity().application as ShoppingApplication), + )[RecommendationFragmentViewModel::class.java] + } + + private val adapter: ProductAdapter by lazy { + ProductAdapter( + products = emptyList(), + productActionListener = + object : ProductActionListener { + override fun onProductClick(product: ProductUiModel) { + val intent = DetailActivity.newIntent(requireContext(), product) + startActivity(intent) + } + + override fun onQuantityAddClick(product: ProductUiModel) { + viewModel.increaseQuantity(product) + } + }, + quantityControlListener = { event, product -> + if (event == 1) { + viewModel.increaseQuantity(product) + } else { + viewModel.decreaseQuantity(product) + } + }, + ) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View? { + initViewBinding(inflater, container) + return binding.root + } + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + observeData() + } + + private fun initViewBinding( + inflater: LayoutInflater, + container: ViewGroup?, + ) { + binding = + DataBindingUtil.inflate( + inflater, + R.layout.fragment_cart_recommendation, + container, + false, + ) + binding.apply { + lifecycleOwner = viewLifecycleOwner + recyclerViewCartRecommendation.adapter = adapter + vm = viewModel + } + } + + private fun observeData() { + viewModel.recommendedProducts.observe(viewLifecycleOwner) { products -> + (binding.recyclerViewCartRecommendation.adapter as ProductAdapter).setItems( + products.map { + ProductItem( + it, + ) + }, + ) + } + viewModel.updatedItem.observe(viewLifecycleOwner) { product -> + if (product != null) { + (binding.recyclerViewCartRecommendation.adapter as ProductAdapter).updateItem( + product, + ) + } + } + } +} diff --git a/app/src/main/java/woowacourse/shopping/cart/recoomendation/RecommendationFragmentViewModel.kt b/app/src/main/java/woowacourse/shopping/cart/recoomendation/RecommendationFragmentViewModel.kt new file mode 100644 index 000000000..80674e6d0 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/cart/recoomendation/RecommendationFragmentViewModel.kt @@ -0,0 +1,113 @@ +package woowacourse.shopping.cart.recoomendation + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import woowacourse.shopping.data.repository.CartRecommendationRepository +import woowacourse.shopping.product.catalog.ProductUiModel + +class RecommendationFragmentViewModel( + private val cartRecommendationRepository: CartRecommendationRepository, +) : ViewModel() { + private val _recommendedProducts = MutableLiveData>(emptyList()) + val recommendedProducts: LiveData> get() = _recommendedProducts + + private val _totalPurchasePrice = MutableLiveData(INITIAL_TOTAL_PURCHASE_PRICE) + val totalPurchasePrice: LiveData get() = _totalPurchasePrice + + private val _selectedProductsCount = MutableLiveData(INITIAL_SELECTED_PRODUCTS_COUNT) + val selectedProductsCount: LiveData get() = _selectedProductsCount + + private val _updatedItem = MutableLiveData() + val updatedItem: LiveData = _updatedItem + + init { + loadRecentlyViewedProduct() + } + + fun loadRecentlyViewedProduct() { + cartRecommendationRepository.getRecommendedProducts { products -> + _recommendedProducts.postValue( + products, + ) + } + } + + fun fetchTotalCount() { + cartRecommendationRepository.getSelectedProductsCount { count -> + _selectedProductsCount.postValue(count) + } + } + + fun fetchPurchasePrice() { + val amount = + _recommendedProducts.value?.sumOf { it.price * it.quantity } + ?: INITIAL_TOTAL_PURCHASE_PRICE + _totalPurchasePrice.postValue(amount) + } + + fun increaseQuantity(product: ProductUiModel) { + when (product.quantity) { + INITIAL_PRODUCT_COUNT -> { + val newProduct = product.copy(quantity = A_COUNT) + cartRecommendationRepository.insertCartProduct(newProduct) { product -> + updateItem(product) + } + } + + else -> { + cartRecommendationRepository.updateCartProduct( + product, + product.quantity + A_COUNT, + ) { success -> + if (success) { + val newProduct = product.copy(quantity = product.quantity + A_COUNT) + updateItem(newProduct) + } + } + } + } + } + + fun decreaseQuantity(product: ProductUiModel) { + when (product.quantity) { + A_COUNT -> { + val newProduct = product.copy(quantity = INITIAL_PRODUCT_COUNT) + cartRecommendationRepository.deleteCartProduct(product) { success -> + if (success) { + updateItem(newProduct) + } + } + } + + else -> { + val newProduct = product.copy(quantity = product.quantity - A_COUNT) + cartRecommendationRepository.updateCartProduct( + product, + product.quantity - A_COUNT, + ) { success -> + if (success) { + updateItem(newProduct) + } + } + } + } + } + + private fun updateItem(newProduct: ProductUiModel) { + _updatedItem.postValue(newProduct) + _recommendedProducts.value = + _recommendedProducts.value?.map { + if (it.id == newProduct.id) newProduct else it + } + fetchTotalCount() + fetchPurchasePrice() + } + + companion object { + private const val INITIAL_TOTAL_PURCHASE_PRICE: Int = 0 + private const val INITIAL_SELECTED_PRODUCTS_COUNT: Int = 0 + private const val INITIAL_PRODUCT_COUNT: Int = 0 + private const val A_COUNT: Int = 1 + } +} diff --git a/app/src/main/java/woowacourse/shopping/cart/recoomendation/RecommendationFragmentViewModelFactory.kt b/app/src/main/java/woowacourse/shopping/cart/recoomendation/RecommendationFragmentViewModelFactory.kt new file mode 100644 index 000000000..6c84f3d8a --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/cart/recoomendation/RecommendationFragmentViewModelFactory.kt @@ -0,0 +1,34 @@ +package woowacourse.shopping.cart.recoomendation + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import woowacourse.shopping.ShoppingApplication +import woowacourse.shopping.data.repository.CartRecommendationRepository +import woowacourse.shopping.data.repository.CartRecommendationRepositoryImpl +import woowacourse.shopping.data.source.CartProductRemoteDataSource +import woowacourse.shopping.data.source.CatalogProductDataSourceRemoteDataSource +import woowacourse.shopping.data.source.RecentlyViewedProductDataSourceRemoteDataSource + +@Suppress("UNCHECKED_CAST") +class RecommendationFragmentViewModelFactory( + private val application: ShoppingApplication, +) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(RecommendationFragmentViewModel::class.java)) { + @Suppress("UNCHECKED_CAST") + val cartRecommendationRepository: CartRecommendationRepository = + CartRecommendationRepositoryImpl.initialize( + cartProductDataSource = CartProductRemoteDataSource(), + catalogProductDataSource = CatalogProductDataSourceRemoteDataSource(), + recentlyViewProductDataSource = + RecentlyViewedProductDataSourceRemoteDataSource.initialize( + application.database.recentlyViewedProductDao(), + ), + ) + return RecommendationFragmentViewModel( + cartRecommendationRepository = cartRecommendationRepository, + ) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +} diff --git a/app/src/main/java/woowacourse/shopping/cart/selection/SelectionFragment.kt b/app/src/main/java/woowacourse/shopping/cart/selection/SelectionFragment.kt new file mode 100644 index 000000000..ff9391446 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/cart/selection/SelectionFragment.kt @@ -0,0 +1,88 @@ +package woowacourse.shopping.cart.selection + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.databinding.DataBindingUtil +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelProvider +import woowacourse.shopping.R +import woowacourse.shopping.cart.CartAdapter +import woowacourse.shopping.cart.CartItem +import woowacourse.shopping.cart.CartViewModel +import woowacourse.shopping.cart.CartViewModelFactory +import woowacourse.shopping.cart.DeleteProductClickListener +import woowacourse.shopping.databinding.FragmentCartSelectionBinding +import woowacourse.shopping.product.catalog.ProductUiModel + +class SelectionFragment : Fragment() { + private lateinit var binding: FragmentCartSelectionBinding + private val viewModel: CartViewModel by lazy { + ViewModelProvider( + this, + CartViewModelFactory(), + )[CartViewModel::class.java] + } + private val adapter: CartAdapter by lazy { + CartAdapter( + cartItems = emptyList(), + onDeleteProductClick = + DeleteProductClickListener { product -> + viewModel.deleteCartProduct(CartItem.ProductItem(product)) + }, + onPaginationButtonClick = viewModel::onPaginationButtonClick, + quantityControlListener = viewModel::updateQuantity, + onCheckClick = viewModel::changeProductSelection, + ) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + binding = + DataBindingUtil.inflate(inflater, R.layout.fragment_cart_selection, container, false) + binding.apply { + lifecycleOwner = this@SelectionFragment + recyclerViewCart.adapter = adapter + vm = viewModel + } + return binding.root + } + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + observeCartViewModel() + } + + private fun observeCartViewModel() { + viewModel.apply { + cartProducts.observe(viewLifecycleOwner) { + updateCartItems() + } + isNextButtonEnabled.observe(viewLifecycleOwner) { updateCartItems() } + isPrevButtonEnabled.observe(viewLifecycleOwner) { updateCartItems() } + page.observe(viewLifecycleOwner) { updateCartItems() } + updatedItem.observe(viewLifecycleOwner) { item -> + (binding.recyclerViewCart.adapter as CartAdapter).setCartItem(item) + } + } + } + + private fun updateCartItems() { + val products: List = viewModel.cartProducts.value ?: return + val isNext: Boolean = viewModel.isNextButtonEnabled.value == true + val isPrev: Boolean = viewModel.isPrevButtonEnabled.value == true + val page: Int = viewModel.page.value ?: 0 + val paginationItem = CartItem.PaginationButtonItem(page + 1, isNext, isPrev) + val cartItems: List = products.map { CartItem.ProductItem(it) } + val cartItemsWithPaginationBtn = + if (cartItems.isEmpty()) cartItems else cartItems + paginationItem + (binding.recyclerViewCart.adapter as CartAdapter).setCartItems(cartItemsWithPaginationBtn) + } +} diff --git a/app/src/main/java/woowacourse/shopping/data/dao/CartProductDao.kt b/app/src/main/java/woowacourse/shopping/data/dao/CartProductDao.kt new file mode 100644 index 000000000..7cbf9492f --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/data/dao/CartProductDao.kt @@ -0,0 +1,44 @@ +package woowacourse.shopping.data.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy.Companion.REPLACE +import androidx.room.Query +import woowacourse.shopping.data.entity.CartProductEntity + +@Dao +interface CartProductDao { + @Insert(onConflict = REPLACE) + fun insertCartProduct(cartProduct: CartProductEntity) + + @Delete + fun deleteCartProduct(cartProduct: CartProductEntity) + + @Query("SELECT * FROM CartProducts WHERE uid = :id") + fun getCartProduct(id: Int): CartProductEntity? + + @Query("SELECT * FROM CartProducts LIMIT :endIndex OFFSET :startIndex") + fun getCartProductsInRange( + startIndex: Int, + endIndex: Int, + ): List + + @Query("SELECT * FROM CartProducts WHERE uid IN (:uids)") + fun getCartProductsByUids(uids: List): List + + @Query("UPDATE CartProducts SET quantity = quantity + :diff WHERE uid = :id") + fun updateProduct( + id: Int, + diff: Int, + ) + + @Query("SELECT quantity FROM CartProducts WHERE uid = :uid") + fun getProductQuantity(uid: Int): Int? + + @Query("SELECT COUNT(*) FROM CartProducts") + fun getAllProductsSize(): Int + + @Query("SELECT SUM(quantity) FROM CartProducts") + fun getCartItemSize(): Int +} diff --git a/app/src/main/java/woowacourse/shopping/data/dao/RecentlyViewedProductDao.kt b/app/src/main/java/woowacourse/shopping/data/dao/RecentlyViewedProductDao.kt new file mode 100644 index 000000000..6ea42c1c7 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/data/dao/RecentlyViewedProductDao.kt @@ -0,0 +1,19 @@ +package woowacourse.shopping.data.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy.Companion.REPLACE +import androidx.room.Query +import woowacourse.shopping.data.entity.RecentlyViewedProductEntity + +@Dao +interface RecentlyViewedProductDao { + @Insert(onConflict = REPLACE) + fun insertRecentlyViewedProductUid(recentlyViewedProductEntity: RecentlyViewedProductEntity) + + @Query("SELECT productUid FROM RecentlyViewedProducts ORDER BY timestamp DESC") + fun getRecentlyViewedProductUids(): List + + @Query("SELECT productUid FROM RecentlyViewedProducts ORDER BY timestamp DESC LIMIT 1") + fun getLatestViewedProductUid(): Int +} diff --git a/app/src/main/java/woowacourse/shopping/data/database/ShoppingDatabase.kt b/app/src/main/java/woowacourse/shopping/data/database/ShoppingDatabase.kt new file mode 100644 index 000000000..9bb4e983b --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/data/database/ShoppingDatabase.kt @@ -0,0 +1,37 @@ +package woowacourse.shopping.data.database + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import woowacourse.shopping.data.dao.CartProductDao +import woowacourse.shopping.data.dao.RecentlyViewedProductDao +import woowacourse.shopping.data.entity.CartProductEntity +import woowacourse.shopping.data.entity.RecentlyViewedProductEntity + +@Database(entities = [CartProductEntity::class, RecentlyViewedProductEntity::class], version = 1) +abstract class ShoppingDatabase : RoomDatabase() { + abstract fun cartProductDao(): CartProductDao + + abstract fun recentlyViewedProductDao(): RecentlyViewedProductDao + + companion object { + @Volatile + private var iNSTANCE: ShoppingDatabase? = null + + fun getInstance(context: Context): ShoppingDatabase = + iNSTANCE ?: synchronized(this) { + val instance = + Room + .databaseBuilder( + context.applicationContext, + ShoppingDatabase::class.java, + "app_database", + ).fallbackToDestructiveMigration() + .build() + + iNSTANCE = instance + instance + } + } +} diff --git a/app/src/main/java/woowacourse/shopping/data/dto/cartitem/Content.kt b/app/src/main/java/woowacourse/shopping/data/dto/cartitem/Content.kt new file mode 100644 index 000000000..c89c3ad60 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/data/dto/cartitem/Content.kt @@ -0,0 +1,12 @@ +package woowacourse.shopping.data.dto.cartitem + +import com.google.gson.annotations.SerializedName + +data class Content( + @SerializedName("id") + val id: Long, + @SerializedName("product") + val product: Product, + @SerializedName("quantity") + val quantity: Int, +) diff --git a/app/src/main/java/woowacourse/shopping/data/dto/cartitem/Pageable.kt b/app/src/main/java/woowacourse/shopping/data/dto/cartitem/Pageable.kt new file mode 100644 index 000000000..62478d512 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/data/dto/cartitem/Pageable.kt @@ -0,0 +1,18 @@ +package woowacourse.shopping.data.dto.cartitem + +import com.google.gson.annotations.SerializedName + +data class Pageable( + @SerializedName("offset") + val offset: Long, + @SerializedName("pageNumber") + val pageNumber: Int, + @SerializedName("pageSize") + val pageSize: Int, + @SerializedName("paged") + val paged: Boolean, + @SerializedName("sort") + val sort: Sort, + @SerializedName("unpaged") + val unpaged: Boolean, +) diff --git a/app/src/main/java/woowacourse/shopping/data/dto/cartitem/Product.kt b/app/src/main/java/woowacourse/shopping/data/dto/cartitem/Product.kt new file mode 100644 index 000000000..f404da982 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/data/dto/cartitem/Product.kt @@ -0,0 +1,16 @@ +package woowacourse.shopping.data.dto.cartitem + +import com.google.gson.annotations.SerializedName + +data class Product( + @SerializedName("category") + val category: String, + @SerializedName("id") + val id: Long, + @SerializedName("imageUrl") + val imageUrl: String, + @SerializedName("name") + val name: String, + @SerializedName("price") + val price: Int, +) diff --git a/app/src/main/java/woowacourse/shopping/data/dto/cartitem/ProductResponse.kt b/app/src/main/java/woowacourse/shopping/data/dto/cartitem/ProductResponse.kt new file mode 100644 index 000000000..780ee90c3 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/data/dto/cartitem/ProductResponse.kt @@ -0,0 +1,28 @@ +package woowacourse.shopping.data.dto.cartitem + +import com.google.gson.annotations.SerializedName + +data class ProductResponse( + @SerializedName("content") + val content: List, + @SerializedName("empty") + val empty: Boolean, + @SerializedName("first") + val first: Boolean, + @SerializedName("last") + val last: Boolean, + @SerializedName("number") + val number: Int, + @SerializedName("numberOfElements") + val numberOfElements: Int, + @SerializedName("pageable") + val pageable: Pageable, + @SerializedName("size") + val size: Int, + @SerializedName("sort") + val sort: Sort, + @SerializedName("totalElements") + val totalElements: Long, + @SerializedName("totalPages") + val totalPages: Int, +) diff --git a/app/src/main/java/woowacourse/shopping/data/dto/cartitem/Quantity.kt b/app/src/main/java/woowacourse/shopping/data/dto/cartitem/Quantity.kt new file mode 100644 index 000000000..6ca4759c8 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/data/dto/cartitem/Quantity.kt @@ -0,0 +1,8 @@ +package woowacourse.shopping.data.dto.cartitem + +import com.google.gson.annotations.SerializedName + +data class Quantity( + @SerializedName("quantity") + val value: Int, +) diff --git a/app/src/main/java/woowacourse/shopping/data/dto/cartitem/Sort.kt b/app/src/main/java/woowacourse/shopping/data/dto/cartitem/Sort.kt new file mode 100644 index 000000000..522c50b5f --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/data/dto/cartitem/Sort.kt @@ -0,0 +1,12 @@ +package woowacourse.shopping.data.dto.cartitem + +import com.google.gson.annotations.SerializedName + +data class Sort( + @SerializedName("empty") + val empty: Boolean, + @SerializedName("sorted") + val sorted: Boolean, + @SerializedName("unsorted") + val unsorted: Boolean, +) diff --git a/app/src/main/java/woowacourse/shopping/data/dto/cartitem/UpdateCartItemRequest.kt b/app/src/main/java/woowacourse/shopping/data/dto/cartitem/UpdateCartItemRequest.kt new file mode 100644 index 000000000..1698ef56b --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/data/dto/cartitem/UpdateCartItemRequest.kt @@ -0,0 +1,6 @@ +package woowacourse.shopping.data.dto.cartitem + +data class UpdateCartItemRequest( + val productId: Int, + val quantity: Int, +) diff --git a/app/src/main/java/woowacourse/shopping/data/dto/product/Content.kt b/app/src/main/java/woowacourse/shopping/data/dto/product/Content.kt new file mode 100644 index 000000000..349cd7129 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/data/dto/product/Content.kt @@ -0,0 +1,16 @@ +package woowacourse.shopping.data.dto.product + +import com.google.gson.annotations.SerializedName + +data class Content( + @SerializedName("category") + val category: String, + @SerializedName("id") + val id: Long, + @SerializedName("imageUrl") + val imageUrl: String, + @SerializedName("name") + val name: String, + @SerializedName("price") + val price: Int, +) diff --git a/app/src/main/java/woowacourse/shopping/data/dto/product/Pageable.kt b/app/src/main/java/woowacourse/shopping/data/dto/product/Pageable.kt new file mode 100644 index 000000000..f19aef173 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/data/dto/product/Pageable.kt @@ -0,0 +1,18 @@ +package woowacourse.shopping.data.dto.product + +import com.google.gson.annotations.SerializedName + +data class Pageable( + @SerializedName("offset") + val offset: Long, + @SerializedName("pageNumber") + val pageNumber: Int, + @SerializedName("pageSize") + val pageSize: Int, + @SerializedName("paged") + val paged: Boolean, + @SerializedName("sort") + val sort: Sort, + @SerializedName("unpaged") + val unpaged: Boolean, +) diff --git a/app/src/main/java/woowacourse/shopping/data/dto/product/ProductResponse.kt b/app/src/main/java/woowacourse/shopping/data/dto/product/ProductResponse.kt new file mode 100644 index 000000000..60b564690 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/data/dto/product/ProductResponse.kt @@ -0,0 +1,28 @@ +package woowacourse.shopping.data.dto.product + +import com.google.gson.annotations.SerializedName + +data class ProductResponse( + @SerializedName("content") + val content: List, + @SerializedName("empty") + val empty: Boolean, + @SerializedName("first") + val first: Boolean, + @SerializedName("last") + val last: Boolean, + @SerializedName("number") + val number: Int, + @SerializedName("numberOfElements") + val numberOfElements: Int, + @SerializedName("pageable") + val pageable: Pageable, + @SerializedName("size") + val size: Int, + @SerializedName("sort") + val sort: Sort, + @SerializedName("totalElements") + val totalElements: Long, + @SerializedName("totalPages") + val totalPages: Int, +) diff --git a/app/src/main/java/woowacourse/shopping/data/dto/product/Sort.kt b/app/src/main/java/woowacourse/shopping/data/dto/product/Sort.kt new file mode 100644 index 000000000..3a91d02f0 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/data/dto/product/Sort.kt @@ -0,0 +1,12 @@ +package woowacourse.shopping.data.dto.product + +import com.google.gson.annotations.SerializedName + +data class Sort( + @SerializedName("empty") + val empty: Boolean, + @SerializedName("sorted") + val sorted: Boolean, + @SerializedName("unsorted") + val unsorted: Boolean, +) diff --git a/app/src/main/java/woowacourse/shopping/data/entity/CartProductEntity.kt b/app/src/main/java/woowacourse/shopping/data/entity/CartProductEntity.kt new file mode 100644 index 000000000..98674fb95 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/data/entity/CartProductEntity.kt @@ -0,0 +1,13 @@ +package woowacourse.shopping.data.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "CartProducts") +data class CartProductEntity( + @PrimaryKey(autoGenerate = true) val uid: Int, + val imageUrl: String, + val name: String, + val price: Int, + val quantity: Int, +) diff --git a/app/src/main/java/woowacourse/shopping/data/entity/RecentlyViewedProductEntity.kt b/app/src/main/java/woowacourse/shopping/data/entity/RecentlyViewedProductEntity.kt new file mode 100644 index 000000000..beae18f75 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/data/entity/RecentlyViewedProductEntity.kt @@ -0,0 +1,10 @@ +package woowacourse.shopping.data.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "RecentlyViewedProducts") +class RecentlyViewedProductEntity( + @PrimaryKey val productUid: Int, + val timestamp: Long = System.currentTimeMillis(), +) diff --git a/app/src/main/java/woowacourse/shopping/data/mapper/CartProductEntityMapper.kt b/app/src/main/java/woowacourse/shopping/data/mapper/CartProductEntityMapper.kt new file mode 100644 index 000000000..20df99715 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/data/mapper/CartProductEntityMapper.kt @@ -0,0 +1,27 @@ +package woowacourse.shopping.data.mapper + +import woowacourse.shopping.data.entity.CartProductEntity +import woowacourse.shopping.product.catalog.ProductUiModel + +fun CartProductEntity.toUiModel(): ProductUiModel = + with(this) { + ProductUiModel( + uid, + imageUrl, + name, + price, + quantity, + 0, + ) + } + +fun ProductUiModel.toEntity(): CartProductEntity = + with(this) { + CartProductEntity( + id, + imageUrl, + name, + price, + quantity, + ) + } diff --git a/app/src/main/java/woowacourse/shopping/data/repository/CartProductRepository.kt b/app/src/main/java/woowacourse/shopping/data/repository/CartProductRepository.kt new file mode 100644 index 000000000..cdbe0e726 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/data/repository/CartProductRepository.kt @@ -0,0 +1,36 @@ +package woowacourse.shopping.data.repository + +import woowacourse.shopping.product.catalog.ProductUiModel + +interface CartProductRepository { + fun insertCartProduct( + cartProduct: ProductUiModel, + callback: (ProductUiModel) -> Unit, + ) + + fun deleteCartProduct( + cartProduct: ProductUiModel, + callback: (Boolean) -> Unit, + ) + + fun getCartProductsInRange( + currentPage: Int, + pageSize: Int, + callback: (List) -> Unit, + ) + + fun updateProduct( + cartProduct: ProductUiModel, + quantity: Int, + callback: (Boolean) -> Unit, + ) + + fun getCartItemSize(callback: (Int) -> Unit) + + fun getTotalElements(callback: (Int) -> Unit) + + fun getCartProducts( + totalElements: Int, + callback: (List) -> Unit, + ) +} diff --git a/app/src/main/java/woowacourse/shopping/data/repository/CartRecommendationRepository.kt b/app/src/main/java/woowacourse/shopping/data/repository/CartRecommendationRepository.kt new file mode 100644 index 000000000..3715633dd --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/data/repository/CartRecommendationRepository.kt @@ -0,0 +1,25 @@ +package woowacourse.shopping.data.repository + +import woowacourse.shopping.product.catalog.ProductUiModel + +interface CartRecommendationRepository { + fun getRecommendedProducts(callback: (List) -> Unit) + + fun getSelectedProductsCount(callback: (Int) -> Unit) + + fun insertCartProduct( + cartProduct: ProductUiModel, + callback: (ProductUiModel) -> Unit, + ) + + fun updateCartProduct( + cartProduct: ProductUiModel, + newCount: Int, + callback: (Boolean) -> Unit, + ) + + fun deleteCartProduct( + cartProduct: ProductUiModel, + callback: (Boolean) -> Unit, + ) +} diff --git a/app/src/main/java/woowacourse/shopping/data/repository/CartRecommendationRepositoryImpl.kt b/app/src/main/java/woowacourse/shopping/data/repository/CartRecommendationRepositoryImpl.kt new file mode 100644 index 000000000..9621bc7ed --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/data/repository/CartRecommendationRepositoryImpl.kt @@ -0,0 +1,80 @@ +package woowacourse.shopping.data.repository + +import woowacourse.shopping.data.source.CartProductDataSource +import woowacourse.shopping.data.source.CatalogProductDataSource +import woowacourse.shopping.data.source.RecentlyViewedProductDataSource +import woowacourse.shopping.product.catalog.ProductUiModel + +class CartRecommendationRepositoryImpl( + private val cartProductDataSource: CartProductDataSource, + private val catalogProductDataSource: CatalogProductDataSource, + private val recentlyViewProductDataSource: RecentlyViewedProductDataSource, +) : CartRecommendationRepository { + override fun getRecommendedProducts(callback: (List) -> Unit) { + recentlyViewProductDataSource.getLatestViewedProduct { id -> + catalogProductDataSource.getProduct(id) { categoryProduct -> + val category = categoryProduct.category ?: "" + catalogProductDataSource.getRecommendedProducts( + category, + ZERO, + TOTAL_RECOMMENDATION_PRODUCTS_COUNT, + ) { products -> + callback(products) + } + } + } + } + + override fun getSelectedProductsCount(callback: (Int) -> Unit) { + cartProductDataSource.getTotalElements { count -> + callback(count) + } + } + + override fun insertCartProduct( + cartProduct: ProductUiModel, + callback: (ProductUiModel) -> Unit, + ) { + cartProductDataSource.insertCartProduct(cartProduct) { product -> + callback(product) + } + } + + override fun updateCartProduct( + cartProduct: ProductUiModel, + newCount: Int, + callback: (Boolean) -> Unit, + ) { + cartProductDataSource.updateProduct(cartProduct, newCount) { result -> + callback(result) + } + } + + override fun deleteCartProduct( + cartProduct: ProductUiModel, + callback: (Boolean) -> Unit, + ) { + cartProductDataSource.deleteCartProduct(cartProduct) { result -> + callback(result) + } + } + + companion object { + private const val ZERO: Int = 0 + private const val TOTAL_RECOMMENDATION_PRODUCTS_COUNT: Int = 10 + + private var instance: CartRecommendationRepository? = null + + @Synchronized + fun initialize( + cartProductDataSource: CartProductDataSource, + catalogProductDataSource: CatalogProductDataSource, + recentlyViewProductDataSource: RecentlyViewedProductDataSource, + ): CartRecommendationRepository = + instance ?: CartRecommendationRepositoryImpl( + cartProductDataSource, + catalogProductDataSource, + recentlyViewProductDataSource, + ).also { instance = it } + } +} diff --git a/app/src/main/java/woowacourse/shopping/data/repository/CartRepository.kt b/app/src/main/java/woowacourse/shopping/data/repository/CartRepository.kt new file mode 100644 index 000000000..257006efc --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/data/repository/CartRepository.kt @@ -0,0 +1,24 @@ +package woowacourse.shopping.data.repository + +import woowacourse.shopping.product.catalog.ProductUiModel + +interface CartRepository { + fun getTotalProductsCount(callback: (Int) -> Unit) + + fun updateCartProduct( + cartProduct: ProductUiModel, + newCount: Int, + callback: (Boolean) -> Unit, + ) + + fun deleteCartProduct( + cartProduct: ProductUiModel, + callback: (Boolean) -> Unit, + ) + + fun getCartProductsInRange( + currentPage: Int, + pageSize: Int, + callback: (List) -> Unit, + ) +} diff --git a/app/src/main/java/woowacourse/shopping/data/repository/CartRepositoryImpl.kt b/app/src/main/java/woowacourse/shopping/data/repository/CartRepositoryImpl.kt new file mode 100644 index 000000000..e64736dce --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/data/repository/CartRepositoryImpl.kt @@ -0,0 +1,53 @@ +package woowacourse.shopping.data.repository + +import woowacourse.shopping.data.source.CartProductDataSource +import woowacourse.shopping.product.catalog.ProductUiModel + +class CartRepositoryImpl( + private val cartProductDataSource: CartProductDataSource, +) : CartRepository { + override fun getTotalProductsCount(callback: (Int) -> Unit) { + cartProductDataSource.getTotalElements { count -> + callback(count) + } + } + + override fun updateCartProduct( + cartProduct: ProductUiModel, + newCount: Int, + callback: (Boolean) -> Unit, + ) { + cartProductDataSource.updateProduct(cartProduct, newCount) { result -> + callback(result) + } + } + + override fun deleteCartProduct( + cartProduct: ProductUiModel, + callback: (Boolean) -> Unit, + ) { + cartProductDataSource.deleteCartProduct(cartProduct) { result -> + callback(result) + } + } + + override fun getCartProductsInRange( + currentPage: Int, + pageSize: Int, + callback: (List) -> Unit, + ) { + cartProductDataSource.getCartProductsInRange(currentPage, pageSize) { products -> + callback(products) + } + } + + companion object { + private var instance: CartRepositoryImpl? = null + + @Synchronized + fun initialize(cartProductDataSource: CartProductDataSource): CartRepositoryImpl = + instance ?: CartRepositoryImpl( + cartProductDataSource, + ).also { instance = it } + } +} diff --git a/app/src/main/java/woowacourse/shopping/data/repository/CatalogProductRepository.kt b/app/src/main/java/woowacourse/shopping/data/repository/CatalogProductRepository.kt new file mode 100644 index 000000000..7a2546f20 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/data/repository/CatalogProductRepository.kt @@ -0,0 +1,30 @@ +package woowacourse.shopping.data.repository + +import woowacourse.shopping.product.catalog.ProductUiModel + +interface CatalogProductRepository { + fun getRecommendedProducts( + category: String, + page: Int, + size: Int, + callback: (List) -> Unit, + ) + + fun getAllProductsSize(callback: (Int) -> Unit) + + fun getCartProductsByUids( + uids: List, + callback: (List) -> Unit, + ) + + fun getProductsByPage( + page: Int, + size: Int, + callback: (List) -> Unit, + ) + + fun getProduct( + id: Int, + callback: (ProductUiModel) -> Unit, + ) +} diff --git a/app/src/main/java/woowacourse/shopping/data/repository/RecentlyViewedProductRepository.kt b/app/src/main/java/woowacourse/shopping/data/repository/RecentlyViewedProductRepository.kt new file mode 100644 index 000000000..05238703d --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/data/repository/RecentlyViewedProductRepository.kt @@ -0,0 +1,12 @@ +package woowacourse.shopping.data.repository + +import woowacourse.shopping.data.entity.CartProductEntity +import woowacourse.shopping.product.catalog.ProductUiModel + +interface RecentlyViewedProductRepository { + fun insertRecentlyViewedProductUid(uid: Int) + + fun getRecentlyViewedProducts(callback: (List) -> Unit) + + fun getLatestViewedProduct(callback: (ProductUiModel) -> Unit) +} diff --git a/app/src/main/java/woowacourse/shopping/data/repository/RecentlyViewedProductRepositoryImpl.kt b/app/src/main/java/woowacourse/shopping/data/repository/RecentlyViewedProductRepositoryImpl.kt new file mode 100644 index 000000000..65231bc30 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/data/repository/RecentlyViewedProductRepositoryImpl.kt @@ -0,0 +1,38 @@ +package woowacourse.shopping.data.repository + +import woowacourse.shopping.data.dao.RecentlyViewedProductDao +import woowacourse.shopping.data.entity.CartProductEntity +import woowacourse.shopping.data.entity.RecentlyViewedProductEntity +import woowacourse.shopping.data.mapper.toEntity +import woowacourse.shopping.product.catalog.ProductUiModel +import kotlin.concurrent.thread + +class RecentlyViewedProductRepositoryImpl( + val recentlyViewedProductDao: RecentlyViewedProductDao, + val catalogProductRepository: CatalogProductRepository, +) : RecentlyViewedProductRepository { + override fun insertRecentlyViewedProductUid(uid: Int) { + thread { + recentlyViewedProductDao.insertRecentlyViewedProductUid(RecentlyViewedProductEntity(uid)) + } + } + + override fun getRecentlyViewedProducts(callback: (List) -> Unit) { + thread { + val uids = recentlyViewedProductDao.getRecentlyViewedProductUids() + catalogProductRepository.getCartProductsByUids(uids) { products -> + val entities = products.map { it.toEntity() } + callback(entities) + } + } + } + + override fun getLatestViewedProduct(callback: (ProductUiModel) -> Unit) { + thread { + val uid = recentlyViewedProductDao.getLatestViewedProductUid() + catalogProductRepository.getProduct(uid) { product -> + callback(product) + } + } + } +} diff --git a/app/src/main/java/woowacourse/shopping/data/repository/RemoteCartProductRepositoryImpl.kt b/app/src/main/java/woowacourse/shopping/data/repository/RemoteCartProductRepositoryImpl.kt new file mode 100644 index 000000000..013c0ec91 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/data/repository/RemoteCartProductRepositoryImpl.kt @@ -0,0 +1,250 @@ +package woowacourse.shopping.data.repository + +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response +import woowacourse.shopping.data.dto.cartitem.Content +import woowacourse.shopping.data.dto.cartitem.ProductResponse +import woowacourse.shopping.data.dto.cartitem.Quantity +import woowacourse.shopping.data.dto.cartitem.UpdateCartItemRequest +import woowacourse.shopping.data.service.CartItemService +import woowacourse.shopping.data.service.RetrofitProductService +import woowacourse.shopping.product.catalog.ProductUiModel + +class RemoteCartProductRepositoryImpl : CartProductRepository { + val retrofitService = RetrofitProductService.INSTANCE.create(CartItemService::class.java) + + override fun insertCartProduct( + cartProduct: ProductUiModel, + callback: (ProductUiModel) -> Unit, + ) { + retrofitService + .postCartItems( + request = + UpdateCartItemRequest( + productId = cartProduct.id, + quantity = cartProduct.quantity, + ), + ).enqueue( + object : Callback { + override fun onResponse( + call: Call, + response: Response, + ) { + if (response.isSuccessful) { + val locationHeader = response.headers()["location"] + val id = locationHeader?.substringAfterLast("/")?.toIntOrNull() + callback(cartProduct.copy(cartItemId = id)) + } + } + + override fun onFailure( + call: Call, + t: Throwable, + ) { + println("error : $t") + } + }, + ) + } + + override fun deleteCartProduct( + cartProduct: ProductUiModel, + callback: (Boolean) -> Unit, + ) { + if (cartProduct.cartItemId != null) { + retrofitService.deleteCartItem(cartItemId = cartProduct.cartItemId).enqueue( + object : Callback { + override fun onResponse( + call: Call, + response: Response, + ) { + if (response.isSuccessful) { + callback(true) + } else { + callback(false) + } + } + + override fun onFailure( + call: Call, + t: Throwable, + ) { + println("error : $t") + } + }, + ) + } + } + + override fun getCartProductsInRange( + currentPage: Int, + pageSize: Int, + callback: (List) -> Unit, + ) { + retrofitService + .requestCartItems( + page = currentPage, + size = pageSize, + ).enqueue( + object : Callback { + override fun onResponse( + call: Call, + response: Response, + ) { + if (response.isSuccessful) { + val body: ProductResponse? = response.body() + val content: List = body?.content ?: return + val products: List = + content.map { + ProductUiModel( + id = it.product.id.toInt(), + imageUrl = it.product.imageUrl, + name = it.product.name, + price = it.product.price, + cartItemId = it.id.toInt(), + quantity = it.quantity, + ) + } + callback(products) + println("body : $body") + } + } + + override fun onFailure( + call: Call, + t: Throwable, + ) { + println("error : $t") + } + }, + ) + } + + override fun updateProduct( + cartProduct: ProductUiModel, + quantity: Int, + callback: (Boolean) -> Unit, + ) { + retrofitService + .patchCartItemQuantity( + cartItemId = cartProduct.cartItemId!!, + quantity = Quantity(quantity), + ).enqueue( + object : Callback { + override fun onResponse( + call: Call, + response: Response, + ) { + if (response.isSuccessful) { + callback(true) + } else { + callback(false) + } + } + + override fun onFailure( + call: Call, + t: Throwable, + ) { + callback(false) + } + }, + ) + } + + override fun getCartItemSize(callback: (Int) -> Unit) { + retrofitService.getCartItemsCount().enqueue( + object : Callback { + override fun onResponse( + call: Call, + response: Response, + ) { + if (response.isSuccessful) { + val body: Quantity = response.body() ?: return + callback(body.value) + println("body : $body") + } + } + + override fun onFailure( + call: Call, + t: Throwable, + ) { + println("error : $t") + } + }, + ) + } + + override fun getTotalElements(callback: (Int) -> Unit) { + retrofitService + .requestCartItems( + page = 0, + size = 1, + ).enqueue( + object : Callback { + override fun onResponse( + call: Call, + response: Response, + ) { + if (response.isSuccessful) { + val body: ProductResponse = response.body() ?: return + val totalElements = body.totalElements.toInt() + callback(totalElements) + } + } + + override fun onFailure( + call: Call, + t: Throwable, + ) { + println("error : $t") + } + }, + ) + } + + override fun getCartProducts( + totalElements: Int, + callback: (List) -> Unit, + ) { + retrofitService + .requestCartItems( + page = 0, + size = totalElements, + ).enqueue( + object : Callback { + override fun onResponse( + call: Call, + response: Response, + ) { + if (response.isSuccessful) { + val body: ProductResponse = response.body() ?: return + val content: List = body.content + val products: List = + content.map { + ProductUiModel( + id = it.product.id.toInt(), + imageUrl = it.product.imageUrl, + name = it.product.name, + price = it.product.price, + quantity = it.quantity, + cartItemId = it.id.toInt(), + ) + } + callback(products) + println("body : $body") + } + } + + override fun onFailure( + call: Call, + t: Throwable, + ) { + println("error : $t") + } + }, + ) + } +} diff --git a/app/src/main/java/woowacourse/shopping/data/repository/RemoteCatalogProductRepositoryImpl.kt b/app/src/main/java/woowacourse/shopping/data/repository/RemoteCatalogProductRepositoryImpl.kt new file mode 100644 index 000000000..dcc71a1d1 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/data/repository/RemoteCatalogProductRepositoryImpl.kt @@ -0,0 +1,187 @@ +package woowacourse.shopping.data.repository + +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response +import woowacourse.shopping.data.dto.product.Content +import woowacourse.shopping.data.dto.product.ProductResponse +import woowacourse.shopping.data.service.ProductService +import woowacourse.shopping.data.service.RetrofitProductService +import woowacourse.shopping.product.catalog.ProductUiModel + +class RemoteCatalogProductRepositoryImpl : CatalogProductRepository { + val retrofitService = RetrofitProductService.INSTANCE.create(ProductService::class.java) + + override fun getRecommendedProducts( + category: String, + page: Int, + size: Int, + callback: (List) -> Unit, + ) { + retrofitService + .requestProducts( + category = category, + page = page, + size = size, + ).enqueue( + object : Callback { + override fun onResponse( + call: Call, + response: Response, + ) { + if (response.isSuccessful) { + val body: ProductResponse? = response.body() + val content: List? = body?.content + val products: List? = + content?.mapNotNull { + ProductUiModel( + id = it.id.toInt(), + imageUrl = it.imageUrl, + name = it.name, + price = it.price, + ) + } + callback(products ?: emptyList()) + println("body : $body") + } + } + + override fun onFailure( + call: Call, + t: Throwable, + ) { + println("error : $t") + } + }, + ) + } + + override fun getAllProductsSize(callback: (Int) -> Unit) { + retrofitService.requestProducts().enqueue( + object : Callback { + override fun onResponse( + call: Call, + response: Response, + ) { + if (response.isSuccessful) { + val body: ProductResponse? = response.body() + callback(body?.totalElements?.toInt() ?: 0) + println("body : $body") + } + } + + override fun onFailure( + call: Call, + t: Throwable, + ) { + println("error : $t") + } + }, + ) + } + + override fun getCartProductsByUids( + uids: List, + callback: (List) -> Unit, + ) { + val resultsMap = mutableMapOf() + var completedCount = 0 + + if (uids.isEmpty()) { + callback(emptyList()) + return + } + + uids.forEach { uid -> + getProduct(uid) { product -> + resultsMap[uid] = product + completedCount++ + + if (completedCount == uids.size) { + val orderedResults = uids.mapNotNull { resultsMap[it] } + callback(orderedResults) + } + } + } + } + + override fun getProductsByPage( + page: Int, + size: Int, + callback: (List) -> Unit, + ) { + retrofitService + .requestProducts( + page = page, + size = size, + ).enqueue( + object : Callback { + override fun onResponse( + call: Call, + response: Response, + ) { + if (response.isSuccessful) { + val body: ProductResponse? = response.body() + val content: List? = body?.content + val products: List? = + content?.mapNotNull { + ProductUiModel( + id = it.id.toInt(), + imageUrl = it.imageUrl, + name = it.name, + price = it.price, + ) + } + callback(products ?: emptyList()) + println("body : $body") + } + } + + override fun onFailure( + call: Call, + t: Throwable, + ) { + println("error : $t") + } + }, + ) + } + + override fun getProduct( + id: Int, + callback: (ProductUiModel) -> Unit, + ) { + retrofitService + .requestDetailProduct( + id = id, + ).enqueue( + object : Callback { + override fun onResponse( + call: Call, + response: Response, + ) { + if (response.isSuccessful) { + val body: Content = response.body() ?: return + val product = + ProductUiModel( + id = body.id.toInt(), + imageUrl = body.imageUrl, + name = body.name, + price = body.price, + category = body.category, + ) + callback(product) + println("body : $body") + } + } + + override fun onFailure( + call: Call, + t: Throwable, + ) { + println("error : $t") + } + }, + ) + } +} diff --git a/app/src/main/java/woowacourse/shopping/data/service/CartItemService.kt b/app/src/main/java/woowacourse/shopping/data/service/CartItemService.kt new file mode 100644 index 000000000..9e1b4f38a --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/data/service/CartItemService.kt @@ -0,0 +1,48 @@ +package woowacourse.shopping.data.service + +import retrofit2.Call +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.PATCH +import retrofit2.http.POST +import retrofit2.http.Path +import retrofit2.http.Query +import woowacourse.shopping.data.dto.cartitem.ProductResponse +import woowacourse.shopping.data.dto.cartitem.Quantity +import woowacourse.shopping.data.dto.cartitem.UpdateCartItemRequest + +interface CartItemService { + @GET("/cart-items") + fun requestCartItems( + @Header("accept") accept: String = "*/*", + @Query("page") page: Int = 0, + @Query("size") size: Int = 1, + @Query("sort") sort: List = listOf(), + ): Call + + @POST("/cart-items") + fun postCartItems( + @Header("accept") accept: String = "*/*", + @Body request: UpdateCartItemRequest, + ): Call + + @DELETE("/cart-items/{id}") + fun deleteCartItem( + @Header("accept") accept: String = "*/*", + @Path("id") cartItemId: Int, + ): Call + + @PATCH("/cart-items/{id}") + fun patchCartItemQuantity( + @Header("accept") accept: String = "*/*", + @Path("id") cartItemId: Int, + @Body quantity: Quantity, + ): Call + + @GET("/cart-items/counts") + fun getCartItemsCount( + @Header("accept") accept: String = "*/*", + ): Call +} diff --git a/app/src/main/java/woowacourse/shopping/data/service/Interceptor.kt b/app/src/main/java/woowacourse/shopping/data/service/Interceptor.kt new file mode 100644 index 000000000..e97e2ad4e --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/data/service/Interceptor.kt @@ -0,0 +1,25 @@ +package woowacourse.shopping.data.service + +import okhttp3.Interceptor +import okhttp3.Response + +class Interceptor( + private val base64: String?, + private val isAuth: Boolean = false, +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val originRequest = chain.request() + + val modifiedRequest = + when (isAuth) { + true -> + originRequest.newBuilder() + .addHeader("Authorization", "Basic $base64") + .build() + false -> + originRequest + } + + return chain.proceed(modifiedRequest) + } +} diff --git a/app/src/main/java/woowacourse/shopping/data/service/ProductService.kt b/app/src/main/java/woowacourse/shopping/data/service/ProductService.kt new file mode 100644 index 000000000..af1a75883 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/data/service/ProductService.kt @@ -0,0 +1,25 @@ +package woowacourse.shopping.data.service + +import retrofit2.Call +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.Path +import retrofit2.http.Query +import woowacourse.shopping.data.dto.product.Content +import woowacourse.shopping.data.dto.product.ProductResponse + +interface ProductService { + @GET("/products") + fun requestProducts( + @Header("accept") accept: String = "*/*", + @Query("category") category: String? = null, + @Query("page") page: Int = 0, + @Query("size") size: Int = 1, + ): Call + + @GET("/products/{id}") + fun requestDetailProduct( + @Header("accept") accept: String = "*/*", + @Path("id") id: Int = 0, + ): Call +} diff --git a/app/src/main/java/woowacourse/shopping/data/service/RetrofitProductService.kt b/app/src/main/java/woowacourse/shopping/data/service/RetrofitProductService.kt new file mode 100644 index 000000000..b6088e963 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/data/service/RetrofitProductService.kt @@ -0,0 +1,52 @@ +package woowacourse.shopping.data.service + +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.logging.HttpLoggingInterceptor +import okhttp3.logging.HttpLoggingInterceptor.Level +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import woowacourse.shopping.BuildConfig +import java.util.Base64 + +object RetrofitProductService { + private val logging = + HttpLoggingInterceptor().apply { + level = Level.BODY + } + + private val base64Credentials: String by lazy { + val username = BuildConfig.USER_ID + val password = BuildConfig.USER_PASSWORD + val credentials = "$username:$password" + Base64.getEncoder().encodeToString(credentials.toByteArray()) + } + + private val authInterceptor = + Interceptor { chain -> + val original: Request = chain.request() + val requestWithAuth: Request = + original + .newBuilder() + .header("Authorization", "Basic $base64Credentials") + .build() + chain.proceed(requestWithAuth) + } + + private val okHttpClient = + OkHttpClient + .Builder() + .addInterceptor(authInterceptor) + .addInterceptor(logging) + .build() + + val INSTANCE: Retrofit by lazy { + Retrofit + .Builder() + .client(okHttpClient) + .baseUrl(BuildConfig.BASE_URL) + .addConverterFactory(GsonConverterFactory.create()) + .build() + } +} diff --git a/app/src/main/java/woowacourse/shopping/data/source/CartProductDataSource.kt b/app/src/main/java/woowacourse/shopping/data/source/CartProductDataSource.kt new file mode 100644 index 000000000..055c5b5c7 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/data/source/CartProductDataSource.kt @@ -0,0 +1,36 @@ +package woowacourse.shopping.data.source + +import woowacourse.shopping.product.catalog.ProductUiModel + +interface CartProductDataSource { + fun insertCartProduct( + cartProduct: ProductUiModel, + callback: (ProductUiModel) -> Unit, + ) + + fun deleteCartProduct( + cartProduct: ProductUiModel, + callback: (Boolean) -> Unit, + ) + + fun getCartProductsInRange( + currentPage: Int, + pageSize: Int, + callback: (List) -> Unit, + ) + + fun updateProduct( + cartProduct: ProductUiModel, + quantity: Int, + callback: (Boolean) -> Unit, + ) + + fun getCartItemSize(callback: (Int) -> Unit) + + fun getTotalElements(callback: (Int) -> Unit) + + fun getCartProducts( + totalElements: Int, + callback: (List) -> Unit, + ) +} diff --git a/app/src/main/java/woowacourse/shopping/data/source/CartProductRemoteDataSource.kt b/app/src/main/java/woowacourse/shopping/data/source/CartProductRemoteDataSource.kt new file mode 100644 index 000000000..ff73c4203 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/data/source/CartProductRemoteDataSource.kt @@ -0,0 +1,254 @@ +package woowacourse.shopping.data.source + +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response +import woowacourse.shopping.data.dto.cartitem.Content +import woowacourse.shopping.data.dto.cartitem.ProductResponse +import woowacourse.shopping.data.dto.cartitem.Quantity +import woowacourse.shopping.data.dto.cartitem.UpdateCartItemRequest +import woowacourse.shopping.data.service.CartItemService +import woowacourse.shopping.data.service.RetrofitProductService +import woowacourse.shopping.product.catalog.ProductUiModel +import kotlin.text.substringAfterLast + +class CartProductRemoteDataSource( + private val retrofitService: CartItemService = + RetrofitProductService.INSTANCE.create( + CartItemService::class.java, + ), +) : CartProductDataSource { + override fun insertCartProduct( + cartProduct: ProductUiModel, + callback: (ProductUiModel) -> Unit, + ) { + retrofitService + .postCartItems( + request = + UpdateCartItemRequest( + productId = cartProduct.id, + quantity = cartProduct.quantity, + ), + ).enqueue( + object : Callback { + override fun onResponse( + call: Call, + response: Response, + ) { + if (response.isSuccessful) { + val locationHeader = response.headers()["location"] + val id = locationHeader?.substringAfterLast("/")?.toIntOrNull() + callback(cartProduct.copy(cartItemId = id)) + } + } + + override fun onFailure( + call: Call, + t: Throwable, + ) { + println("error : $t") + } + }, + ) + } + + override fun deleteCartProduct( + cartProduct: ProductUiModel, + callback: (Boolean) -> Unit, + ) { + if (cartProduct.cartItemId != null) { + retrofitService.deleteCartItem(cartItemId = cartProduct.cartItemId).enqueue( + object : Callback { + override fun onResponse( + call: Call, + response: Response, + ) { + if (response.isSuccessful) { + callback(true) + } else { + callback(false) + } + } + + override fun onFailure( + call: Call, + t: Throwable, + ) { + println("error : $t") + } + }, + ) + } + } + + override fun getCartProductsInRange( + currentPage: Int, + pageSize: Int, + callback: (List) -> Unit, + ) { + retrofitService + .requestCartItems( + page = currentPage, + size = pageSize, + ).enqueue( + object : Callback { + override fun onResponse( + call: Call, + response: Response, + ) { + if (response.isSuccessful) { + val body: ProductResponse? = response.body() + val content: List = body?.content ?: return + val products: List = + content.map { + ProductUiModel( + id = it.product.id.toInt(), + imageUrl = it.product.imageUrl, + name = it.product.name, + price = it.product.price, + cartItemId = it.id.toInt(), + quantity = it.quantity, + ) + } + callback(products) + println("body : $body") + } + } + + override fun onFailure( + call: Call, + t: Throwable, + ) { + println("error : $t") + } + }, + ) + } + + override fun updateProduct( + cartProduct: ProductUiModel, + quantity: Int, + callback: (Boolean) -> Unit, + ) { + retrofitService + .patchCartItemQuantity( + cartItemId = cartProduct.cartItemId!!, + quantity = Quantity(quantity), + ).enqueue( + object : Callback { + override fun onResponse( + call: Call, + response: Response, + ) { + if (response.isSuccessful) { + callback(true) + } else { + callback(false) + } + } + + override fun onFailure( + call: Call, + t: Throwable, + ) { + callback(false) + } + }, + ) + } + + override fun getCartItemSize(callback: (Int) -> Unit) { + retrofitService.getCartItemsCount().enqueue( + object : Callback { + override fun onResponse( + call: Call, + response: Response, + ) { + if (response.isSuccessful) { + val body: Quantity = response.body() ?: return + callback(body.value) + println("body : $body") + } + } + + override fun onFailure( + call: Call, + t: Throwable, + ) { + println("error : $t") + } + }, + ) + } + + override fun getTotalElements(callback: (Int) -> Unit) { + retrofitService + .requestCartItems( + page = 0, + size = 1, + ).enqueue( + object : Callback { + override fun onResponse( + call: Call, + response: Response, + ) { + if (response.isSuccessful) { + val body: ProductResponse = response.body() ?: return + val totalElements = body.totalElements.toInt() + callback(totalElements) + } + } + + override fun onFailure( + call: Call, + t: Throwable, + ) { + println("error : $t") + } + }, + ) + } + + override fun getCartProducts( + totalElements: Int, + callback: (List) -> Unit, + ) { + retrofitService + .requestCartItems( + page = 0, + size = totalElements, + ).enqueue( + object : Callback { + override fun onResponse( + call: Call, + response: Response, + ) { + if (response.isSuccessful) { + val body: ProductResponse = response.body() ?: return + val content: List = body.content + val products: List = + content.map { + ProductUiModel( + id = it.product.id.toInt(), + imageUrl = it.product.imageUrl, + name = it.product.name, + price = it.product.price, + quantity = it.quantity, + cartItemId = it.id.toInt(), + ) + } + callback(products) + println("body : $body") + } + } + + override fun onFailure( + call: Call, + t: Throwable, + ) { + println("error : $t") + } + }, + ) + } +} diff --git a/app/src/main/java/woowacourse/shopping/data/source/CatalogProductDataSource.kt b/app/src/main/java/woowacourse/shopping/data/source/CatalogProductDataSource.kt new file mode 100644 index 000000000..e082f2ac7 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/data/source/CatalogProductDataSource.kt @@ -0,0 +1,30 @@ +package woowacourse.shopping.data.source + +import woowacourse.shopping.product.catalog.ProductUiModel + +interface CatalogProductDataSource { + fun getRecommendedProducts( + category: String, + page: Int, + size: Int, + callback: (List) -> Unit, + ) + + fun getAllProductsSize(callback: (Int) -> Unit) + + fun getCartProductsByUids( + uids: List, + callback: (List) -> Unit, + ) + + fun getProductsByPage( + page: Int, + size: Int, + callback: (List) -> Unit, + ) + + fun getProduct( + id: Int, + callback: (ProductUiModel) -> Unit, + ) +} diff --git a/app/src/main/java/woowacourse/shopping/data/source/CatalogProductDataSourceRemoteDataSource.kt b/app/src/main/java/woowacourse/shopping/data/source/CatalogProductDataSourceRemoteDataSource.kt new file mode 100644 index 000000000..275406a3c --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/data/source/CatalogProductDataSourceRemoteDataSource.kt @@ -0,0 +1,190 @@ +package woowacourse.shopping.data.source + +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response +import woowacourse.shopping.data.dto.product.Content +import woowacourse.shopping.data.dto.product.ProductResponse +import woowacourse.shopping.data.service.ProductService +import woowacourse.shopping.data.service.RetrofitProductService +import woowacourse.shopping.product.catalog.ProductUiModel + +class CatalogProductDataSourceRemoteDataSource( + private val retrofitService: ProductService = + RetrofitProductService.INSTANCE.create( + ProductService::class.java, + ), +) : CatalogProductDataSource { + override fun getRecommendedProducts( + category: String, + page: Int, + size: Int, + callback: (List) -> Unit, + ) { + retrofitService + .requestProducts( + category = category, + page = page, + size = size, + ).enqueue( + object : Callback { + override fun onResponse( + call: Call, + response: Response, + ) { + if (response.isSuccessful) { + val body: ProductResponse? = response.body() + val content: List? = body?.content + val products: List? = + content?.mapNotNull { + ProductUiModel( + id = it.id.toInt(), + imageUrl = it.imageUrl, + name = it.name, + price = it.price, + ) + } + callback(products ?: emptyList()) + println("body : $body") + } + } + + override fun onFailure( + call: Call, + t: Throwable, + ) { + println("error : $t") + } + }, + ) + } + + override fun getAllProductsSize(callback: (Int) -> Unit) { + retrofitService.requestProducts().enqueue( + object : Callback { + override fun onResponse( + call: Call, + response: Response, + ) { + if (response.isSuccessful) { + val body: ProductResponse? = response.body() + callback(body?.totalElements?.toInt() ?: 0) + println("body : $body") + } + } + + override fun onFailure( + call: Call, + t: Throwable, + ) { + println("error : $t") + } + }, + ) + } + + override fun getCartProductsByUids( + uids: List, + callback: (List) -> Unit, + ) { + val resultsMap = mutableMapOf() + var completedCount = 0 + + if (uids.isEmpty()) { + callback(emptyList()) + return + } + + uids.forEach { uid -> + getProduct(uid) { product -> + resultsMap[uid] = product + completedCount++ + + if (completedCount == uids.size) { + val orderedResults = uids.mapNotNull { resultsMap[it] } + callback(orderedResults) + } + } + } + } + + override fun getProductsByPage( + page: Int, + size: Int, + callback: (List) -> Unit, + ) { + retrofitService + .requestProducts( + page = page, + size = size, + ).enqueue( + object : Callback { + override fun onResponse( + call: Call, + response: Response, + ) { + if (response.isSuccessful) { + val body: ProductResponse? = response.body() + val content: List? = body?.content + val products: List? = + content?.mapNotNull { + ProductUiModel( + id = it.id.toInt(), + imageUrl = it.imageUrl, + name = it.name, + price = it.price, + ) + } + callback(products ?: emptyList()) + println("body : $body") + } + } + + override fun onFailure( + call: Call, + t: Throwable, + ) { + println("error : $t") + } + }, + ) + } + + override fun getProduct( + id: Int, + callback: (ProductUiModel) -> Unit, + ) { + retrofitService + .requestDetailProduct( + id = id, + ).enqueue( + object : Callback { + override fun onResponse( + call: Call, + response: Response, + ) { + if (response.isSuccessful) { + val body: Content = response.body() ?: return + val product = + ProductUiModel( + id = body.id.toInt(), + imageUrl = body.imageUrl, + name = body.name, + price = body.price, + category = body.category, + ) + callback(product) + println("body : $body") + } + } + + override fun onFailure( + call: Call, + t: Throwable, + ) { + println("error : $t") + } + }, + ) + } +} diff --git a/app/src/main/java/woowacourse/shopping/data/source/RecentlyViewedProductDataSource.kt b/app/src/main/java/woowacourse/shopping/data/source/RecentlyViewedProductDataSource.kt new file mode 100644 index 000000000..7abf11df6 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/data/source/RecentlyViewedProductDataSource.kt @@ -0,0 +1,9 @@ +package woowacourse.shopping.data.source + +interface RecentlyViewedProductDataSource { + fun insertRecentlyViewedProductUid(uid: Int) + + fun getRecentlyViewedProducts(callback: (List) -> Unit) + + fun getLatestViewedProduct(callback: (Int) -> Unit) +} diff --git a/app/src/main/java/woowacourse/shopping/data/source/RecentlyViewedProductDataSourceRemoteDataSource.kt b/app/src/main/java/woowacourse/shopping/data/source/RecentlyViewedProductDataSourceRemoteDataSource.kt new file mode 100644 index 000000000..abc75d097 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/data/source/RecentlyViewedProductDataSourceRemoteDataSource.kt @@ -0,0 +1,39 @@ +package woowacourse.shopping.data.source + +import woowacourse.shopping.data.dao.RecentlyViewedProductDao +import woowacourse.shopping.data.entity.RecentlyViewedProductEntity +import kotlin.concurrent.thread + +class RecentlyViewedProductDataSourceRemoteDataSource( + private val recentlyViewedProductDao: RecentlyViewedProductDao, +) : RecentlyViewedProductDataSource { + override fun insertRecentlyViewedProductUid(uid: Int) { + thread { + recentlyViewedProductDao.insertRecentlyViewedProductUid(RecentlyViewedProductEntity(uid)) + } + } + + override fun getRecentlyViewedProducts(callback: (List) -> Unit) { + thread { + val uids = recentlyViewedProductDao.getRecentlyViewedProductUids() + callback(uids) + } + } + + override fun getLatestViewedProduct(callback: (Int) -> Unit) { + thread { + val uid = recentlyViewedProductDao.getLatestViewedProductUid() + callback(uid) + } + } + + companion object { + private var instance: RecentlyViewedProductDataSourceRemoteDataSource? = null + + @Synchronized + fun initialize(recentlyViewedProductDao: RecentlyViewedProductDao): RecentlyViewedProductDataSourceRemoteDataSource = + instance ?: RecentlyViewedProductDataSourceRemoteDataSource(recentlyViewedProductDao).also { + instance = it + } + } +} diff --git a/app/src/main/java/woowacourse/shopping/domain/LoadingState.kt b/app/src/main/java/woowacourse/shopping/domain/LoadingState.kt new file mode 100644 index 000000000..b21fc2015 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/domain/LoadingState.kt @@ -0,0 +1,19 @@ +package woowacourse.shopping.domain + +import android.view.View + +data class LoadingState( + val isLoading: Boolean, + val shimmerVisibility: Int, + val recyclerViewVisibility: Int, +) { + companion object { + fun loading(): LoadingState { + return LoadingState(true, View.VISIBLE, View.GONE) + } + + fun loaded(): LoadingState { + return LoadingState(false, View.GONE, View.VISIBLE) + } + } +} diff --git a/app/src/main/java/woowacourse/shopping/product/catalog/CatalogActivity.kt b/app/src/main/java/woowacourse/shopping/product/catalog/CatalogActivity.kt new file mode 100644 index 000000000..70a5b9b45 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/product/catalog/CatalogActivity.kt @@ -0,0 +1,164 @@ +package woowacourse.shopping.product.catalog + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.Menu +import android.view.View +import androidx.activity.enableEdgeToEdge +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.databinding.DataBindingUtil +import androidx.lifecycle.ViewModelProvider +import androidx.recyclerview.widget.GridLayoutManager +import woowacourse.shopping.R +import woowacourse.shopping.ShoppingApplication +import woowacourse.shopping.cart.CartActivity +import woowacourse.shopping.databinding.ActivityCatalogBinding +import woowacourse.shopping.databinding.MenuCartLayoutBinding +import woowacourse.shopping.product.detail.DetailActivity + +class CatalogActivity : AppCompatActivity() { + private lateinit var binding: ActivityCatalogBinding + private val viewModel: CatalogViewModel by lazy { + ViewModelProvider( + this, + CatalogViewModelFactory(application as ShoppingApplication), + )[CatalogViewModel::class.java] + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + binding = DataBindingUtil.setContentView(this, R.layout.activity_catalog) + + applyWindowInsets() + + setProductAdapter() + setRecentlyViewedProductAdapter() + observeCatalogProducts() + + binding.vm = viewModel + binding.lifecycleOwner = this + } + + override fun onResume() { + super.onResume() + viewModel.loadCatalogUntilCurrentPage() + viewModel.fetchTotalCount() + viewModel.loadRecentlyViewedProducts() + } + + override fun onCreateOptionsMenu(menu: Menu?): Boolean { + menuInflater.inflate(R.menu.cart_menu_item, menu) + + val menuItem = menu?.findItem(R.id.action_cart) + val binding = + DataBindingUtil.inflate( + LayoutInflater.from(this), + R.layout.menu_cart_layout, + null, + false, + ) + binding.layoutCartMenu.setOnClickListener { + startActivity(CartActivity.newIntent(this)) + } + binding.vm = viewModel + binding.lifecycleOwner = this + + menuItem?.actionView = binding.root + return true + } + + private fun setProductAdapter() { + val adapter = + ProductAdapter( + products = emptyList(), + productActionListener = + object : ProductActionListener { + override fun onProductClick(product: ProductUiModel) { + val intent = DetailActivity.newIntent(this@CatalogActivity, product) + startActivity(intent) + } + + override fun onLoadButtonClick() { + viewModel.loadNextCatalogProducts() + } + + override fun onQuantityAddClick(product: ProductUiModel) { + viewModel.increaseQuantity(product) + } + }, + quantityControlListener = { event, product -> + if (event == 1) { + viewModel.increaseQuantity(product) + } else { + viewModel.decreaseQuantity(product) + } + }, + ) + + binding.recyclerViewProducts.adapter = adapter + val gridLayoutManager = GridLayoutManager(this, 2) + gridLayoutManager.spanSizeLookup = + object : GridLayoutManager.SpanSizeLookup() { + override fun getSpanSize(position: Int): Int = spanSizeByPosition(position, adapter.itemCount) + } + binding.recyclerViewProducts.layoutManager = gridLayoutManager + } + + private fun setRecentlyViewedProductAdapter() { + val adapter = + RecentlyViewedProductAdapter( + products = emptyList(), + ) { + val intent = DetailActivity.newIntent(this@CatalogActivity, it) + startActivity(intent) + } + binding.recyclerViewRecentlyViewedProducts.adapter = adapter + } + + private fun observeCatalogProducts() { + viewModel.catalogItems.observe(this) { value -> + (binding.recyclerViewProducts.adapter as ProductAdapter).setItems(value) + } + viewModel.updatedItem.observe(this) { product -> + if (product != null) { + (binding.recyclerViewProducts.adapter as ProductAdapter).updateItem(product) + } + } + viewModel.recentlyViewedProducts.observe(this) { products -> + (binding.recyclerViewRecentlyViewedProducts.adapter as RecentlyViewedProductAdapter).setItems( + products, + ) + } + viewModel.loadingState.observe(this) { + when (it.isLoading) { + true -> { + binding.shimmerFrameLayoutProducts.startShimmer() + binding.shimmerFrameLayoutProducts.visibility = View.VISIBLE + } + + false -> { + binding.shimmerFrameLayoutProducts.stopShimmer() + binding.shimmerFrameLayoutProducts.visibility = View.GONE + } + } + } + binding.lifecycleOwner = this + } + + private fun applyWindowInsets() { + ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) + insets + } + } + + private fun spanSizeByPosition( + position: Int, + itemCount: Int, + ): Int = if (position == itemCount - 1 && itemCount % 20 == 1) 2 else 1 +} diff --git a/app/src/main/java/woowacourse/shopping/product/catalog/CatalogItem.kt b/app/src/main/java/woowacourse/shopping/product/catalog/CatalogItem.kt new file mode 100644 index 000000000..48496899e --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/product/catalog/CatalogItem.kt @@ -0,0 +1,15 @@ +package woowacourse.shopping.product.catalog + +import woowacourse.shopping.domain.LoadingState + +sealed class CatalogItem { + data class ProductItem( + val productItem: ProductUiModel, + ) : CatalogItem() + + data object LoadMoreButtonItem : CatalogItem() + + data class LoadingStateProductItem( + val loadingState: LoadingState, + ) : CatalogItem() +} diff --git a/app/src/main/java/woowacourse/shopping/product/catalog/CatalogViewModel.kt b/app/src/main/java/woowacourse/shopping/product/catalog/CatalogViewModel.kt new file mode 100644 index 000000000..a308cff65 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/product/catalog/CatalogViewModel.kt @@ -0,0 +1,170 @@ +package woowacourse.shopping.product.catalog + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import woowacourse.shopping.data.mapper.toUiModel +import woowacourse.shopping.data.repository.CartProductRepository +import woowacourse.shopping.data.repository.CatalogProductRepository +import woowacourse.shopping.data.repository.RecentlyViewedProductRepository +import woowacourse.shopping.domain.LoadingState +import woowacourse.shopping.product.catalog.CatalogItem.ProductItem + +class CatalogViewModel( + private val catalogProductRepository: CatalogProductRepository, + private val cartProductRepository: CartProductRepository, + private val recentlyViewedProductRepository: RecentlyViewedProductRepository, +) : ViewModel() { + private val _catalogItems = + MutableLiveData>(emptyList()) + val catalogItems: LiveData> = _catalogItems + + private val _page = MutableLiveData(INITIAL_PAGE) + val page: LiveData = _page + + private val _updatedItem = MutableLiveData() + val updatedItem: LiveData = _updatedItem + + private val _recentlyViewedProducts = + MutableLiveData>(emptyList()) + val recentlyViewedProducts: LiveData> = _recentlyViewedProducts + + private val _loadingState: MutableLiveData = + MutableLiveData(LoadingState.loaded()) + val loadingState: LiveData get() = _loadingState + + private val _cartItemCount = MutableLiveData(INITIAL_CART_ITEM_COUNT) + val cartItemCount: LiveData get() = _cartItemCount + + init { + fetchTotalCount() + catalogProductRepository.getAllProductsSize { allProductsSize -> + loadCatalog(PAGE_SIZE, 20, allProductsSize) + } + } + + fun fetchTotalCount() { + cartProductRepository.getTotalElements { count -> + _cartItemCount.postValue(count) + } + } + + fun increaseQuantity(product: ProductUiModel) { + if (product.quantity == 0) { + cartProductRepository.insertCartProduct(product.copy(quantity = 1)) { product -> + _updatedItem.postValue(product) + } + } else { + cartProductRepository.updateProduct(product, product.quantity + 1) { result -> + if (result == true) { + _updatedItem.postValue(product.copy(quantity = product.quantity + 1)) + } + } + } + fetchTotalCount() + } + + fun decreaseQuantity(product: ProductUiModel) { + if (product.quantity == 1) { + cartProductRepository.deleteCartProduct(product) { result -> + if (result == true) { + _updatedItem.postValue(product.copy(quantity = 0)) + } + } + } else { + cartProductRepository.updateProduct(product, product.quantity - 1) { result -> + if (result == true) { + _updatedItem.postValue(product.copy(quantity = product.quantity - 1)) + } + } + } + fetchTotalCount() + } + + fun loadNextCatalogProducts() { + increasePage() + val currentPage = page.value ?: 0 + + catalogProductRepository.getAllProductsSize { allProductSize -> + val startIndex = currentPage * PAGE_SIZE + val endIndex = minOf(startIndex + PAGE_SIZE, allProductSize) + loadCatalog(endIndex, 20, allProductSize) + } + } + + fun loadCatalogUntilCurrentPage() { + _catalogItems.value = emptyList() + val currentPage = page.value ?: 0 + + catalogProductRepository.getAllProductsSize { allProductSize -> + val endIndex = minOf((currentPage + 1) * PAGE_SIZE, allProductSize) + + loadCatalog(endIndex, 20, allProductSize) + } + } + + fun loadCatalog( + endIndex: Int, + size: Int = 20, + allProductsSize: Int, + ) { + _loadingState.postValue(LoadingState.loading()) + + catalogProductRepository.getProductsByPage( + page.value ?: 0, + size, + ) { pagedProducts -> + cartProductRepository.getTotalElements { totalElements -> + cartProductRepository.getCartProducts(totalElements) { cartProducts -> + val cartProductMap: Map = + cartProducts.associateBy { it.id } + + val mergedProducts = + pagedProducts.map { product -> + val cartProduct = cartProductMap[product.id] + if (cartProduct != null) { + product.copy( + quantity = cartProduct.quantity, + cartItemId = cartProduct.cartItemId, + ) + } else { + product + } + } + + val items = mergedProducts.map { ProductItem(it) } + val prevItems = + _catalogItems.value + .orEmpty() + .filterNot { it is CatalogItem.LoadMoreButtonItem } + val hasNextPage = endIndex < allProductsSize + val updatedItems = + if (hasNextPage) { + prevItems + items + CatalogItem.LoadMoreButtonItem + } else { + prevItems + items + } + + _catalogItems.postValue(updatedItems) + _loadingState.postValue(LoadingState.loaded()) + } + } + } + } + + fun loadRecentlyViewedProducts() { + recentlyViewedProductRepository.getRecentlyViewedProducts { products -> + _recentlyViewedProducts.postValue(products.map { it.toUiModel() }) + } + } + + private fun increasePage() { + _page.value = _page.value?.plus(1) + } + + companion object { + private const val PAGE_SIZE = 20 + private const val INITIAL_PAGE = 0 + private const val INITIAL_CART_ITEM_COUNT = 0 + } +} diff --git a/app/src/main/java/woowacourse/shopping/product/catalog/CatalogViewModelFactory.kt b/app/src/main/java/woowacourse/shopping/product/catalog/CatalogViewModelFactory.kt new file mode 100644 index 000000000..0a34feb9b --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/product/catalog/CatalogViewModelFactory.kt @@ -0,0 +1,30 @@ +package woowacourse.shopping.product.catalog + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import woowacourse.shopping.ShoppingApplication +import woowacourse.shopping.data.database.ShoppingDatabase +import woowacourse.shopping.data.repository.RecentlyViewedProductRepositoryImpl +import woowacourse.shopping.data.repository.RemoteCartProductRepositoryImpl +import woowacourse.shopping.data.repository.RemoteCatalogProductRepositoryImpl + +class CatalogViewModelFactory( + private val application: ShoppingApplication, +) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(CatalogViewModel::class.java)) { + @Suppress("UNCHECKED_CAST") + return CatalogViewModel( + cartProductRepository = + RemoteCartProductRepositoryImpl(), + recentlyViewedProductRepository = + RecentlyViewedProductRepositoryImpl( + ShoppingDatabase.getInstance(application).recentlyViewedProductDao(), + RemoteCatalogProductRepositoryImpl(), + ), + catalogProductRepository = RemoteCatalogProductRepositoryImpl(), + ) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +} diff --git a/app/src/main/java/woowacourse/shopping/product/catalog/LoadButtonViewHolder.kt b/app/src/main/java/woowacourse/shopping/product/catalog/LoadButtonViewHolder.kt new file mode 100644 index 000000000..de5a2cfc5 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/product/catalog/LoadButtonViewHolder.kt @@ -0,0 +1,22 @@ +package woowacourse.shopping.product.catalog + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import woowacourse.shopping.databinding.LoadMoreButtonItemBinding + +class LoadButtonViewHolder( + binding: LoadMoreButtonItemBinding, +) : RecyclerView.ViewHolder(binding.root) { + companion object { + fun from( + parent: ViewGroup, + productActionListener: ProductActionListener, + ): LoadButtonViewHolder { + val inflater = LayoutInflater.from(parent.context) + val binding = LoadMoreButtonItemBinding.inflate(inflater, parent, false) + binding.productActionListener = productActionListener + return LoadButtonViewHolder(binding) + } + } +} diff --git a/app/src/main/java/woowacourse/shopping/product/catalog/LoadingStateProductViewHolder.kt b/app/src/main/java/woowacourse/shopping/product/catalog/LoadingStateProductViewHolder.kt new file mode 100644 index 000000000..fe6c1104d --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/product/catalog/LoadingStateProductViewHolder.kt @@ -0,0 +1,18 @@ +package woowacourse.shopping.product.catalog + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import woowacourse.shopping.databinding.ShimmerProductItemBinding + +class LoadingStateProductViewHolder( + val binding: ShimmerProductItemBinding, +) : RecyclerView.ViewHolder(binding.root) { + companion object { + fun from(parent: ViewGroup): LoadingStateProductViewHolder { + val inflater = LayoutInflater.from(parent.context) + val binding = ShimmerProductItemBinding.inflate(inflater, parent, false) + return LoadingStateProductViewHolder(binding) + } + } +} diff --git a/app/src/main/java/woowacourse/shopping/product/catalog/ProductActionListener.kt b/app/src/main/java/woowacourse/shopping/product/catalog/ProductActionListener.kt new file mode 100644 index 000000000..1d7cae050 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/product/catalog/ProductActionListener.kt @@ -0,0 +1,9 @@ +package woowacourse.shopping.product.catalog + +interface ProductActionListener { + fun onProductClick(product: ProductUiModel) + + fun onLoadButtonClick() = Unit + + fun onQuantityAddClick(product: ProductUiModel) +} diff --git a/app/src/main/java/woowacourse/shopping/product/catalog/ProductAdapter.kt b/app/src/main/java/woowacourse/shopping/product/catalog/ProductAdapter.kt new file mode 100644 index 000000000..27d2de377 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/product/catalog/ProductAdapter.kt @@ -0,0 +1,64 @@ +package woowacourse.shopping.product.catalog + +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView + +class ProductAdapter( + products: List, + private val productActionListener: ProductActionListener, + private val quantityControlListener: QuantityControlListener, +) : RecyclerView.Adapter() { + private val products: MutableList = products.toMutableList() + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): RecyclerView.ViewHolder = + when (viewType) { + VIEW_TYPE_PRODUCT -> + ProductViewHolder.from(parent, productActionListener, quantityControlListener) + + VIEW_TYPE_LOAD_MORE -> LoadButtonViewHolder.from(parent, productActionListener) + + else -> LoadingStateProductViewHolder.from(parent) + } + + override fun onBindViewHolder( + holder: RecyclerView.ViewHolder, + position: Int, + ) { + when (holder) { + is ProductViewHolder -> { + holder.bind((products[position] as CatalogItem.ProductItem).productItem) + } + } + } + + override fun getItemViewType(position: Int): Int = + when (products[position]) { + CatalogItem.LoadMoreButtonItem -> VIEW_TYPE_LOAD_MORE + is CatalogItem.ProductItem -> VIEW_TYPE_PRODUCT + is CatalogItem.LoadingStateProductItem -> VIEW_TYPE_LOADING_PRODUCT + } + + fun setItems(items: List) { + products.clear() + products.addAll(items) + notifyDataSetChanged() + } + + fun updateItem(product: ProductUiModel) { + val index: Int = + products.indexOfFirst { (it as CatalogItem.ProductItem).productItem.id == product.id } + products[index] = CatalogItem.ProductItem(product) + notifyItemChanged(index) + } + + override fun getItemCount(): Int = products.size + + companion object { + private const val VIEW_TYPE_PRODUCT = 1 + private const val VIEW_TYPE_LOAD_MORE = 2 + private const val VIEW_TYPE_LOADING_PRODUCT = 3 + } +} diff --git a/app/src/main/java/woowacourse/shopping/product/catalog/ProductUiModel.kt b/app/src/main/java/woowacourse/shopping/product/catalog/ProductUiModel.kt new file mode 100644 index 000000000..b19c0fdb0 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/product/catalog/ProductUiModel.kt @@ -0,0 +1,16 @@ +package woowacourse.shopping.product.catalog + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class ProductUiModel( + val id: Int, + val imageUrl: String, + val name: String, + val price: Int, + val quantity: Int = 0, + val cartItemId: Int? = null, + val isChecked: Boolean = true, + val category: String? = null, +) : Parcelable diff --git a/app/src/main/java/woowacourse/shopping/product/catalog/ProductViewHolder.kt b/app/src/main/java/woowacourse/shopping/product/catalog/ProductViewHolder.kt new file mode 100644 index 000000000..11a617736 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/product/catalog/ProductViewHolder.kt @@ -0,0 +1,41 @@ +package woowacourse.shopping.product.catalog + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import woowacourse.shopping.databinding.ProductItemBinding + +class ProductViewHolder( + private val binding: ProductItemBinding, +) : RecyclerView.ViewHolder(binding.root) { + fun bind(product: ProductUiModel) { + val quantityControlBar = binding.layoutQuantityControlBar.linearLayoutQuantityControlBar + val fabBtn = binding.layoutQuantityControlBar.fabQuantityAdd + + binding.product = product + binding.layoutQuantityControlBar.product = product + + if (product.quantity == 0) { + quantityControlBar.visibility = View.INVISIBLE + fabBtn.visibility = View.VISIBLE + } else { + quantityControlBar.visibility = View.VISIBLE + fabBtn.visibility = View.INVISIBLE + } + } + + companion object { + fun from( + parent: ViewGroup, + productActionListener: ProductActionListener, + quantityControlListener: QuantityControlListener, + ): ProductViewHolder { + val inflater = LayoutInflater.from(parent.context) + val binding = ProductItemBinding.inflate(inflater, parent, false) + binding.productActionListener = productActionListener + binding.layoutQuantityControlBar.quantityControlListener = quantityControlListener + return ProductViewHolder(binding) + } + } +} diff --git a/app/src/main/java/woowacourse/shopping/product/catalog/QuantityControlListener.kt b/app/src/main/java/woowacourse/shopping/product/catalog/QuantityControlListener.kt new file mode 100644 index 000000000..14c136b9f --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/product/catalog/QuantityControlListener.kt @@ -0,0 +1,8 @@ +package woowacourse.shopping.product.catalog + +fun interface QuantityControlListener { + fun onClick( + event: Int, + product: ProductUiModel, + ) +} diff --git a/app/src/main/java/woowacourse/shopping/product/catalog/RecentlyViewedProductAdapter.kt b/app/src/main/java/woowacourse/shopping/product/catalog/RecentlyViewedProductAdapter.kt new file mode 100644 index 000000000..f305ba3ce --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/product/catalog/RecentlyViewedProductAdapter.kt @@ -0,0 +1,31 @@ +package woowacourse.shopping.product.catalog + +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView + +class RecentlyViewedProductAdapter( + products: List, + private val recentlyViewedProductClickListener: RecentlyViewedProductClickListener, +) : RecyclerView.Adapter() { + private val products: MutableList = products.toMutableList() + + fun setItems(products: List) { + this.products.clear() + this.products.addAll(products) + notifyDataSetChanged() + } + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): RecentlyViewedProductViewHolder = RecentlyViewedProductViewHolder.from(parent, recentlyViewedProductClickListener) + + override fun onBindViewHolder( + holder: RecentlyViewedProductViewHolder, + position: Int, + ) { + holder.bind(products[position]) + } + + override fun getItemCount(): Int = products.size +} diff --git a/app/src/main/java/woowacourse/shopping/product/catalog/RecentlyViewedProductClickListener.kt b/app/src/main/java/woowacourse/shopping/product/catalog/RecentlyViewedProductClickListener.kt new file mode 100644 index 000000000..5531a8d35 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/product/catalog/RecentlyViewedProductClickListener.kt @@ -0,0 +1,5 @@ +package woowacourse.shopping.product.catalog + +fun interface RecentlyViewedProductClickListener { + fun onClick(productUiModel: ProductUiModel) +} diff --git a/app/src/main/java/woowacourse/shopping/product/catalog/RecentlyViewedProductViewHolder.kt b/app/src/main/java/woowacourse/shopping/product/catalog/RecentlyViewedProductViewHolder.kt new file mode 100644 index 000000000..b69373eb5 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/product/catalog/RecentlyViewedProductViewHolder.kt @@ -0,0 +1,26 @@ +package woowacourse.shopping.product.catalog + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import woowacourse.shopping.databinding.RecentlyViewedProductItemBinding + +class RecentlyViewedProductViewHolder( + private val binding: RecentlyViewedProductItemBinding, +) : RecyclerView.ViewHolder(binding.root) { + fun bind(product: ProductUiModel) { + binding.product = product + } + + companion object { + fun from( + parent: ViewGroup, + recentlyViewedProductClickListener: RecentlyViewedProductClickListener, + ): RecentlyViewedProductViewHolder { + val inflater = LayoutInflater.from(parent.context) + val binding = RecentlyViewedProductItemBinding.inflate(inflater, parent, false) + binding.clickListener = recentlyViewedProductClickListener + return RecentlyViewedProductViewHolder(binding) + } + } +} diff --git a/app/src/main/java/woowacourse/shopping/product/detail/AddToCartClickListener.kt b/app/src/main/java/woowacourse/shopping/product/detail/AddToCartClickListener.kt new file mode 100644 index 000000000..f326168a9 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/product/detail/AddToCartClickListener.kt @@ -0,0 +1,7 @@ +package woowacourse.shopping.product.detail + +import woowacourse.shopping.product.catalog.ProductUiModel + +fun interface AddToCartClickListener { + fun onClick(product: ProductUiModel) +} diff --git a/app/src/main/java/woowacourse/shopping/product/detail/DetailActivity.kt b/app/src/main/java/woowacourse/shopping/product/detail/DetailActivity.kt new file mode 100644 index 000000000..546bf1280 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/product/detail/DetailActivity.kt @@ -0,0 +1,144 @@ +package woowacourse.shopping.product.detail + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.widget.Toast +import androidx.activity.enableEdgeToEdge +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.databinding.DataBindingUtil +import androidx.lifecycle.ViewModelProvider +import woowacourse.shopping.R +import woowacourse.shopping.ShoppingApplication +import woowacourse.shopping.databinding.ActivityDetailBinding +import woowacourse.shopping.product.catalog.ProductUiModel +import woowacourse.shopping.product.catalog.QuantityControlListener +import woowacourse.shopping.util.intentParcelableExtra + +class DetailActivity : AppCompatActivity() { + private lateinit var binding: ActivityDetailBinding + private lateinit var viewModel: DetailViewModel + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + binding = DataBindingUtil.setContentView(this, R.layout.activity_detail) + + applyWindowInsets() + setSupportActionBar() + setAddToCartClickListener() + + val product: ProductUiModel? = + intent.intentParcelableExtra(KEY_PRODUCT_DETAIL, ProductUiModel::class.java) + product?.let { setViewModel(product) } + observeProduct() + binding.vm = viewModel + binding.lifecycleOwner = this + binding.layoutQuantityControlBar.quantityControlListener = + QuantityControlListener { event, _ -> + if (event == 1) { + viewModel.increaseQuantity() + } else { + viewModel.decreaseQuantity() + } + } + binding.layoutLatestViewedProduct.latestViewedProductClickListener = + LatestViewedProductClickListener { product -> + val intent = DetailActivity.newIntent(this, product) + intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP + startActivity(intent) + } + viewModel.setLatestViewedProduct() + } + + private fun setViewModel(product: ProductUiModel) { + viewModel = + ViewModelProvider( + this, + DetailViewModelFactory(product, application as ShoppingApplication), + )[DetailViewModel::class.java] + } + + private fun observeProduct() { + binding.vm = viewModel + binding.lifecycleOwner = this + + viewModel.product.observe(this) { + viewModel.setQuantity() + viewModel.setPriceSum() + viewModel.addToRecentlyViewedProduct() + binding.layoutQuantityControlBar.product = it + } + + viewModel.quantity.observe(this) { + viewModel.setPriceSum() + } + + viewModel.latestViewedProduct.observe(this) { product -> + binding.layoutLatestViewedProduct.product = product + } + } + + override fun onCreateOptionsMenu(menu: Menu?): Boolean { + menuInflater.inflate(R.menu.detail_back_menu_item, menu) + return super.onCreateOptionsMenu(menu) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean = + when (item.itemId) { + R.id.menu_detail_back -> { + finish() + true + } + + else -> super.onOptionsItemSelected(item) + } + + private fun setAddToCartClickListener() { + binding.addToCartClickListener = + AddToCartClickListener { product -> + showToastMessage() + viewModel.addToCart() + finish() + } + } + + private fun setSupportActionBar() { + supportActionBar?.setDisplayHomeAsUpEnabled(false) + supportActionBar?.setDisplayShowHomeEnabled(false) + supportActionBar?.title = "" + } + + private fun showToastMessage() { + Toast + .makeText(this, getString(R.string.text_add_to_cart_success), Toast.LENGTH_SHORT) + .show() + } + + private fun applyWindowInsets() { + ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) + insets + } + } + + private fun productFromIntent(): ProductUiModel? = intent.intentParcelableExtra(KEY_PRODUCT_DETAIL, ProductUiModel::class.java) + + companion object { + private const val KEY_PRODUCT_DETAIL = "productDetail" + + fun newIntent( + context: Context, + product: ProductUiModel, + ): Intent = + Intent(context, DetailActivity::class.java).apply { + putExtra(KEY_PRODUCT_DETAIL, product) + } + } +} diff --git a/app/src/main/java/woowacourse/shopping/product/detail/DetailViewModel.kt b/app/src/main/java/woowacourse/shopping/product/detail/DetailViewModel.kt new file mode 100644 index 000000000..2e5102641 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/product/detail/DetailViewModel.kt @@ -0,0 +1,70 @@ +package woowacourse.shopping.product.detail + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import woowacourse.shopping.data.repository.CartProductRepository +import woowacourse.shopping.data.repository.CatalogProductRepository +import woowacourse.shopping.data.repository.RecentlyViewedProductRepository +import woowacourse.shopping.product.catalog.ProductUiModel + +class DetailViewModel( + product: ProductUiModel, + private val cartProductRepository: CartProductRepository, + private val recentlyViewedProductRepository: RecentlyViewedProductRepository, + private val catalogProductRepository: CatalogProductRepository, +) : ViewModel() { + private val _product = MutableLiveData(product) + val product: LiveData = _product + + private val _quantity = MutableLiveData(1) + val quantity: LiveData = _quantity + + private val _price = MutableLiveData(0) + val price: LiveData = _price + + private val _latestViewedProduct = MutableLiveData() + val latestViewedProduct: LiveData = _latestViewedProduct + + fun increaseQuantity() { + _quantity.value = _quantity.value?.plus(1) + setPriceSum() + } + + fun decreaseQuantity() { + if ((_quantity.value ?: 0) <= 0) return + _quantity.value = _quantity.value?.minus(1) + setPriceSum() + } + + fun setQuantity() { +// _quantity.value = productData.quantity + } + + fun setPriceSum() { + _price.value = (product.value?.price ?: 0) * (quantity.value ?: 0) + } + + fun addToCart() { + val addedProduct = product.value?.copy(quantity = quantity.value ?: 0) + + if (addedProduct?.cartItemId != null) { + cartProductRepository.updateProduct(addedProduct, addedProduct.quantity) {} + } else { + addedProduct?.let { + cartProductRepository.insertCartProduct(it) {} + } + } + } + + fun addToRecentlyViewedProduct() { + val uid = product.value?.id ?: return + recentlyViewedProductRepository.insertRecentlyViewedProductUid(uid) + } + + fun setLatestViewedProduct() { + recentlyViewedProductRepository.getLatestViewedProduct { product -> + _latestViewedProduct.postValue(product) + } + } +} diff --git a/app/src/main/java/woowacourse/shopping/product/detail/DetailViewModelFactory.kt b/app/src/main/java/woowacourse/shopping/product/detail/DetailViewModelFactory.kt new file mode 100644 index 000000000..a7aeff2ad --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/product/detail/DetailViewModelFactory.kt @@ -0,0 +1,32 @@ +package woowacourse.shopping.product.detail + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import woowacourse.shopping.ShoppingApplication +import woowacourse.shopping.data.database.ShoppingDatabase +import woowacourse.shopping.data.repository.RecentlyViewedProductRepositoryImpl +import woowacourse.shopping.data.repository.RemoteCartProductRepositoryImpl +import woowacourse.shopping.data.repository.RemoteCatalogProductRepositoryImpl +import woowacourse.shopping.product.catalog.ProductUiModel + +class DetailViewModelFactory( + private val product: ProductUiModel, + private val application: ShoppingApplication, +) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(DetailViewModel::class.java)) { + @Suppress("UNCHECKED_CAST") + return DetailViewModel( + product = product, + cartProductRepository = RemoteCartProductRepositoryImpl(), + recentlyViewedProductRepository = + RecentlyViewedProductRepositoryImpl( + ShoppingDatabase.getInstance(application).recentlyViewedProductDao(), + RemoteCatalogProductRepositoryImpl(), + ), + catalogProductRepository = RemoteCatalogProductRepositoryImpl(), + ) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +} diff --git a/app/src/main/java/woowacourse/shopping/product/detail/LatestViewedProductClickListener.kt b/app/src/main/java/woowacourse/shopping/product/detail/LatestViewedProductClickListener.kt new file mode 100644 index 000000000..f5b9e7f61 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/product/detail/LatestViewedProductClickListener.kt @@ -0,0 +1,7 @@ +package woowacourse.shopping.product.detail + +import woowacourse.shopping.product.catalog.ProductUiModel + +fun interface LatestViewedProductClickListener { + fun onClick(product: ProductUiModel) +} diff --git a/app/src/main/java/woowacourse/shopping/util/BindingAdapter.kt b/app/src/main/java/woowacourse/shopping/util/BindingAdapter.kt new file mode 100644 index 000000000..d89a8b42a --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/util/BindingAdapter.kt @@ -0,0 +1,21 @@ +package woowacourse.shopping.util + +import android.widget.ImageView +import androidx.databinding.BindingAdapter +import com.bumptech.glide.Glide +import woowacourse.shopping.R +import woowacourse.shopping.product.catalog.ProductUiModel + +object BindingAdapter { + @JvmStatic + @BindingAdapter("imageUrl") + fun ImageView.loadImage(productUiModel: ProductUiModel) { + Glide + .with(this) + .load(productUiModel.imageUrl) + .placeholder(R.drawable.iced_americano) + .fallback(R.drawable.iced_americano) + .error(R.drawable.iced_americano) + .into(this) + } +} diff --git a/app/src/main/java/woowacourse/shopping/util/Event.kt b/app/src/main/java/woowacourse/shopping/util/Event.kt new file mode 100644 index 000000000..01991e997 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/util/Event.kt @@ -0,0 +1,26 @@ +package woowacourse.shopping.util + +/** + * Used as a wrapper for data that is exposed via a LiveData that represents an event. + */ +open class Event(private val content: T) { + var hasBeenHandled = false + private set // Allow external read but not write + + /** + * Returns the content and prevents its use again. + */ + fun getContentIfNotHandled(): T? { + return if (hasBeenHandled) { + null + } else { + hasBeenHandled = true + content + } + } + + /** + * Returns the content, even if it's already been handled. + */ + fun peekContent(): T = content +} diff --git a/app/src/main/java/woowacourse/shopping/util/IntentCompat.kt b/app/src/main/java/woowacourse/shopping/util/IntentCompat.kt new file mode 100644 index 000000000..cd8908008 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/util/IntentCompat.kt @@ -0,0 +1,16 @@ +package woowacourse.shopping.util + +import android.content.Intent +import android.os.Build +import android.os.Parcelable + +@Suppress("DEPRECATION") +fun Intent.intentParcelableExtra( + key: String, + clazz: Class, +): T? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getParcelableExtra(key, clazz) + } else { + getParcelableExtra(key) as? T + } diff --git a/app/src/main/java/woowacourse/shopping/util/MutableSingleLiveData.kt b/app/src/main/java/woowacourse/shopping/util/MutableSingleLiveData.kt new file mode 100644 index 000000000..9d2aa1523 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/util/MutableSingleLiveData.kt @@ -0,0 +1,16 @@ + +package woowacourse.shopping.util + +class MutableSingleLiveData : SingleLiveData { + constructor() : super() + + constructor(value: T) : super(value) + + public override fun postValue(value: T) { + super.postValue(value) + } + + public override fun setValue(value: T) { + super.setValue(value) + } +} diff --git a/app/src/main/java/woowacourse/shopping/util/SingleLiveData.kt b/app/src/main/java/woowacourse/shopping/util/SingleLiveData.kt new file mode 100644 index 000000000..fa7595683 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/util/SingleLiveData.kt @@ -0,0 +1,38 @@ +package woowacourse.shopping.util + +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.MutableLiveData + +abstract class SingleLiveData { + private val liveData = MutableLiveData>() + + protected constructor() + + protected constructor(value: T) { + liveData.value = Event(value) + } + + protected open fun setValue(value: T) { + liveData.value = Event(value) + } + + protected open fun postValue(value: T) { + liveData.postValue(Event(value)) + } + + fun getValue() = liveData.value?.peekContent() + + fun observe( + owner: LifecycleOwner, + onResult: (T) -> Unit, + ) { + liveData.observe(owner) { it.getContentIfNotHandled()?.let(onResult) } + } + + fun observePeek( + owner: LifecycleOwner, + onResult: (T) -> Unit, + ) { + liveData.observe(owner) { onResult(it.peekContent()) } + } +} diff --git a/app/src/main/res/drawable/add_to_cart_button.xml b/app/src/main/res/drawable/add_to_cart_button.xml new file mode 100644 index 000000000..7bd9207b5 --- /dev/null +++ b/app/src/main/res/drawable/add_to_cart_button.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/badge_background.xml b/app/src/main/res/drawable/badge_background.xml new file mode 100644 index 000000000..0eb7296a0 --- /dev/null +++ b/app/src/main/res/drawable/badge_background.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/app/src/main/res/drawable/cart_item_background.xml b/app/src/main/res/drawable/cart_item_background.xml new file mode 100644 index 000000000..773c7c86d --- /dev/null +++ b/app/src/main/res/drawable/cart_item_background.xml @@ -0,0 +1,10 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/cart_next_button_active_background.xml b/app/src/main/res/drawable/cart_next_button_active_background.xml new file mode 100644 index 000000000..ea9c722cc --- /dev/null +++ b/app/src/main/res/drawable/cart_next_button_active_background.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/cart_next_button_background.xml b/app/src/main/res/drawable/cart_next_button_background.xml new file mode 100644 index 000000000..78755a34d --- /dev/null +++ b/app/src/main/res/drawable/cart_next_button_background.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/cart_prev_button_active_background.xml b/app/src/main/res/drawable/cart_prev_button_active_background.xml new file mode 100644 index 000000000..e9ded913a --- /dev/null +++ b/app/src/main/res/drawable/cart_prev_button_active_background.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/cart_prev_button_background.xml b/app/src/main/res/drawable/cart_prev_button_background.xml new file mode 100644 index 000000000..b6a7b8757 --- /dev/null +++ b/app/src/main/res/drawable/cart_prev_button_background.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/iced_americano.png b/app/src/main/res/drawable/iced_americano.png new file mode 100644 index 000000000..f7fd5fbe8 Binary files /dev/null and b/app/src/main/res/drawable/iced_americano.png differ diff --git a/app/src/main/res/drawable/icon_add.xml b/app/src/main/res/drawable/icon_add.xml new file mode 100644 index 000000000..1f81f6d22 --- /dev/null +++ b/app/src/main/res/drawable/icon_add.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/icon_back.png b/app/src/main/res/drawable/icon_back.png new file mode 100644 index 000000000..3bda3b7e1 Binary files /dev/null and b/app/src/main/res/drawable/icon_back.png differ diff --git a/app/src/main/res/drawable/icon_cart.png b/app/src/main/res/drawable/icon_cart.png new file mode 100644 index 000000000..60435f754 Binary files /dev/null and b/app/src/main/res/drawable/icon_cart.png differ diff --git a/app/src/main/res/drawable/icon_delete.png b/app/src/main/res/drawable/icon_delete.png new file mode 100644 index 000000000..88d4126fb Binary files /dev/null and b/app/src/main/res/drawable/icon_delete.png differ diff --git a/app/src/main/res/drawable/pagination_next_selector.xml b/app/src/main/res/drawable/pagination_next_selector.xml new file mode 100644 index 000000000..205b30fce --- /dev/null +++ b/app/src/main/res/drawable/pagination_next_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/pagination_prev_selector.xml b/app/src/main/res/drawable/pagination_prev_selector.xml new file mode 100644 index 000000000..8aac6cfa8 --- /dev/null +++ b/app/src/main/res/drawable/pagination_prev_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/quantity_control_background.xml b/app/src/main/res/drawable/quantity_control_background.xml new file mode 100644 index 000000000..c22826bc4 --- /dev/null +++ b/app/src/main/res/drawable/quantity_control_background.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_cart.xml b/app/src/main/res/layout/activity_cart.xml new file mode 100644 index 000000000..4391db228 --- /dev/null +++ b/app/src/main/res/layout/activity_cart.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_catalog.xml b/app/src/main/res/layout/activity_catalog.xml new file mode 100644 index 000000000..e06a28c9c --- /dev/null +++ b/app/src/main/res/layout/activity_catalog.xml @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_detail.xml b/app/src/main/res/layout/activity_detail.xml new file mode 100644 index 000000000..2944e969d --- /dev/null +++ b/app/src/main/res/layout/activity_detail.xml @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +