diff --git a/README.md b/README.md index 6df14d369d..42536180f7 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,59 @@ - 서버 통신을 위한 JSON 직렬화 라이브러리를 선택하고 PR에 선택 이유를 남긴다. - 기능 요구 사항에 대한 테스트를 작성해야 한다. +> Keyword: 데이터 포맷, Retrofit + +## 🧱 기능 요구 사항 (3,4단계) + +### 📕 기획 명세 + +- Coroutines을 적용하여 비동기 요청을 리팩터링한다. + - 단, Flow를 사용하진 않는다. +- 장바구니에 담긴 상품을 최종 주문할 수 있다. + - 배송비는 기본 3,000원이다. +- 결제 화면에서 적용 가능한 쿠폰을 조회하고 적용할 수 있다. + - 쿠폰은 1개만 적용 가능하다. +- 결제 수단은 구현하지 않는다. + - 결제하기 버튼을 누르면 바로 최종 주문이 완료된다. + - 최종 주문이 완료되면 상품 목록으로 이동과 함께 주문 완료 토스트 메시지를 노출한다. + +> Keyword: 코루틴, UI 핸들링 + +### 📗 API 명세 + +- 최종 주문이 완료되면 장바구니에서 주문된 상품이 초기화되는 것이 정상이다. +- 쿠폰을 사용해도 사라지지 않는 것이 정상이다. + +### 📘 이벤트 명세 + +- 다음과 같은 4가지 유형의 쿠폰을 사용할 수 있어야 한다. (쿠폰은 데이터는 API 문서에서 확인할 수 있다) 각 쿠폰의 특성을 잘 이해하고, 해당 쿠폰을 적용했을 때의 할인 효과를 정확히 계산할 수 있도록 구현한다. + +1. 5,000원 할인 쿠폰 + - 쿠폰 코드: FIXED5000 + - 할인 금액: 5,000원 + - 최소 주문 금액: 100,000원 + - 만료일: 2024년 11월 30일 + +2. 2개 구매 시 1개 무료 쿠폰 + - 쿠폰 코드: BOGO + - 구매 수량: 2 + - 무료 제공 수량: 1 + - 만료일: 2024년 5월 30일 + - BOGO 쿠폰은 장바구니에 동일한 제품을 3개를 담은 상태에서 사용하면, 1개 분량의 금액을 할인한다. + - 3개씩 담은 제품이 여러개인 경우, 1개당 금액이 가장 비싼 제품에 적용한다. + +3. 5만원 이상 구매 시 무료 배송 쿠폰 + - 쿠폰 코드: FREESHIPPING + - 최소 주문 금액: 50,000원 + - 만료일: 2024년 8월 31일 + - 배송비 무료 쿠폰은 도서 및 산간 지역인 경우에도 무료 배송이 가능하다. + +4. 미라클모닝 30% 할인 쿠폰 + - 쿠폰 코드: MIRACLESALE + - 할인율: 30% + - 사용 가능 시간: 오전 4시부터 7시까지 + - 만료일: 2024년 7월 31일 + ## 🛠️ 구현할 기능 (1,2단계) - [x] 상품 로딩 전 스켈레톤 UI를 구현한다. @@ -31,3 +84,13 @@ - [x] 추천 상품은 10개까지 노출한다. - [x] 장바구니에 이미 추가된 상품은 추천 상품에 보이지 않는다. - [x] 상품을 주문할 수 있다. + +## 🛠️ 구현할 기능 (3,4단계) + +- [x] 모든 API 및 DAO 함수에 suspend를 적용한다. +- [x] 배송비를 3,000원 추가한다. +- [x] 결제 화면에서 쿠폰을 조회한다. +- [x] 결제 화면에서 쿠폰을 적용한다. +- [x] 쿠폰은 1개만 적용 가능하다. +- [x] 결제하기 버튼을 누르면 주문이 완료된다. +- [x] 주문이 완료되면 주문 완료 토스트 메시지를 보여준다. diff --git a/app/src/androidTest/java/woowacourse/shopping/data/dao/HistoryDaoTest.kt b/app/src/androidTest/java/woowacourse/shopping/data/dao/HistoryDaoTest.kt index 1edd839f64..c2b617fccc 100644 --- a/app/src/androidTest/java/woowacourse/shopping/data/dao/HistoryDaoTest.kt +++ b/app/src/androidTest/java/woowacourse/shopping/data/dao/HistoryDaoTest.kt @@ -4,13 +4,19 @@ import android.content.Context import androidx.room.Room import androidx.test.core.app.ApplicationProvider import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before import org.junit.Test +import org.junit.jupiter.api.extension.ExtendWith import woowacourse.shopping.DUMMY_HISTORY_PRODUCT_1 import woowacourse.shopping.DUMMY_HISTORY_PRODUCT_2 import woowacourse.shopping.data.database.ShoppingDatabase +import woowacourse.shopping.util.CoroutinesTestExtension +@ExperimentalCoroutinesApi +@ExtendWith(CoroutinesTestExtension::class) @Suppress("ktlint:standard:function-naming") class HistoryDaoTest { private lateinit var database: ShoppingDatabase @@ -28,108 +34,114 @@ class HistoryDaoTest { } @Test - fun 검색_기록을_저장하고_조회한다() { - // given - val historyProduct = DUMMY_HISTORY_PRODUCT_1 - dao.insertHistory(historyProduct) - - // when - val result = dao.getHistoryProducts() - - // then - assertThat(result).hasSize(1) - assertThat(result[0].productId).isEqualTo(DUMMY_HISTORY_PRODUCT_1.productId) - } + fun 검색_기록을_저장하고_조회한다() = + runTest { + // given + val historyProduct = DUMMY_HISTORY_PRODUCT_1 + dao.insertHistory(historyProduct) + + // when + val result = dao.getHistoryProducts() + + // then + assertThat(result).hasSize(1) + assertThat(result[0].productId).isEqualTo(DUMMY_HISTORY_PRODUCT_1.productId) + } @Test - fun 검색_기록을_최신순으로_조회한다() { - // given - dao.insertHistory(DUMMY_HISTORY_PRODUCT_1.copy(timestamp = 1)) - dao.insertHistory(DUMMY_HISTORY_PRODUCT_2.copy(timestamp = 0)) - - // when - val result = dao.getHistoryProducts() - - // then - assertThat( - result.map { it.productId }, - ).containsExactly(DUMMY_HISTORY_PRODUCT_1.productId, DUMMY_HISTORY_PRODUCT_2.productId).inOrder() - } + fun 검색_기록을_최신순으로_조회한다() = + runTest { + // given + dao.insertHistory(DUMMY_HISTORY_PRODUCT_1.copy(timestamp = 1)) + dao.insertHistory(DUMMY_HISTORY_PRODUCT_2.copy(timestamp = 0)) + + // when + val result = dao.getHistoryProducts() + + // then + assertThat( + result.map { it.productId }, + ).containsExactly(DUMMY_HISTORY_PRODUCT_1.productId, DUMMY_HISTORY_PRODUCT_2.productId).inOrder() + } @Test - fun 검색_기록이_제한을_초과하면_오래된_기록부터_삭제된다() { - // given - repeat(5) { index -> - dao.insertHistory( - DUMMY_HISTORY_PRODUCT_1.copy( - productId = index.toLong(), - timestamp = index.toLong(), - ), + fun 검색_기록이_제한을_초과하면_오래된_기록부터_삭제된다() = + runTest { + // given + repeat(5) { index -> + dao.insertHistory( + DUMMY_HISTORY_PRODUCT_1.copy( + productId = index.toLong(), + timestamp = index.toLong(), + ), + ) + } + + // when + dao.insertHistoryWithLimit( + history = DUMMY_HISTORY_PRODUCT_2, + limit = 5, ) - } - // when - dao.insertHistoryWithLimit( - history = DUMMY_HISTORY_PRODUCT_2, - limit = 5, - ) - - // then - val result = dao.getHistoryProducts() - assertThat(result).hasSize(5) - assertThat(result.any { it.productId == 0L }).isFalse() - assertThat(result.first().productId).isEqualTo(DUMMY_HISTORY_PRODUCT_2.productId) - } + // then + val result = dao.getHistoryProducts() + assertThat(result).hasSize(5) + assertThat(result.any { it.productId == 0L }).isFalse() + assertThat(result.first().productId).isEqualTo(DUMMY_HISTORY_PRODUCT_2.productId) + } @Test - fun 최근_검색_기록을_조회한다() { - // given - dao.insertHistory(DUMMY_HISTORY_PRODUCT_1.copy(timestamp = 0)) - dao.insertHistory(DUMMY_HISTORY_PRODUCT_2.copy(timestamp = 1)) + fun 최근_검색_기록을_조회한다() = + runTest { + // given + dao.insertHistory(DUMMY_HISTORY_PRODUCT_1.copy(timestamp = 0)) + dao.insertHistory(DUMMY_HISTORY_PRODUCT_2.copy(timestamp = 1)) - // when - val result = dao.getRecentHistoryProduct() + // when + val result = dao.getRecentHistoryProduct() - // then - assertThat(result?.productId).isEqualTo(DUMMY_HISTORY_PRODUCT_2.productId) - } + // then + assertThat(result?.productId).isEqualTo(DUMMY_HISTORY_PRODUCT_2.productId) + } @Test - fun 총_검색_기록_개수를_조회한다() { - // given - dao.insertHistory(DUMMY_HISTORY_PRODUCT_1) - dao.insertHistory(DUMMY_HISTORY_PRODUCT_2) + fun 총_검색_기록_개수를_조회한다() = + runTest { + // given + dao.insertHistory(DUMMY_HISTORY_PRODUCT_1) + dao.insertHistory(DUMMY_HISTORY_PRODUCT_2) - // when - val count = dao.getHistoryCount() + // when + val count = dao.getHistoryCount() - // then - assertThat(count).isEqualTo(2) - } + // then + assertThat(count).isEqualTo(2) + } @Test - fun 오래된_검색_기록을_원하는_개수만큼_삭제한다() { - // given - val base = System.currentTimeMillis() - repeat(5) { index -> - dao.insertHistory( - DUMMY_HISTORY_PRODUCT_1.copy( - productId = index.toLong(), - timestamp = base + index, - ), - ) + fun 오래된_검색_기록을_원하는_개수만큼_삭제한다() = + runTest { + // given + val base = System.currentTimeMillis() + repeat(5) { index -> + dao.insertHistory( + DUMMY_HISTORY_PRODUCT_1.copy( + productId = index.toLong(), + timestamp = base + index, + ), + ) + } + + // when + dao.deleteOldestHistories(2) + + // then + val result = dao.getHistoryProducts() + assertThat(result).hasSize(3) + assertThat(result.map { it.productId }).doesNotContain(0) + assertThat(result.map { it.productId }).doesNotContain(1) } - // when - dao.deleteOldestHistories(2) - - // then - val result = dao.getHistoryProducts() - assertThat(result).hasSize(3) - assertThat(result.map { it.productId }).doesNotContain(0) - assertThat(result.map { it.productId }).doesNotContain(1) - } - @After fun tearDown() { database.close() diff --git a/app/src/androidTest/java/woowacourse/shopping/study/data/dao/CartDaoStudyTest.kt b/app/src/androidTest/java/woowacourse/shopping/study/data/dao/CartDaoStudyTest.kt index dc8bf921cc..ece8d488da 100644 --- a/app/src/androidTest/java/woowacourse/shopping/study/data/dao/CartDaoStudyTest.kt +++ b/app/src/androidTest/java/woowacourse/shopping/study/data/dao/CartDaoStudyTest.kt @@ -1,6 +1,7 @@ +@file:Suppress("ktlint:standard:no-empty-file") + package woowacourse.shopping.study.data.dao -class CartDaoStudyTest /* import android.content.Context import android.os.SystemClock diff --git a/app/src/androidTest/java/woowacourse/shopping/util/CoroutinesTestExtension.kt b/app/src/androidTest/java/woowacourse/shopping/util/CoroutinesTestExtension.kt new file mode 100644 index 0000000000..298c34ef4d --- /dev/null +++ b/app/src/androidTest/java/woowacourse/shopping/util/CoroutinesTestExtension.kt @@ -0,0 +1,25 @@ +package woowacourse.shopping.util + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.jupiter.api.extension.AfterEachCallback +import org.junit.jupiter.api.extension.BeforeEachCallback +import org.junit.jupiter.api.extension.ExtensionContext + +@ExperimentalCoroutinesApi +class CoroutinesTestExtension( + private val dispatcher: TestDispatcher = UnconfinedTestDispatcher(), +) : BeforeEachCallback, + AfterEachCallback { + override fun beforeEach(context: ExtensionContext?) { + Dispatchers.setMain(dispatcher) + } + + override fun afterEach(context: ExtensionContext?) { + Dispatchers.resetMain() + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 94ee3079b1..2939be77ac 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -18,7 +18,8 @@ tools:targetApi="31"> + android:exported="false" + android:parentActivityName=".ui.catalog.CatalogActivity" /> @@ -30,7 +31,11 @@ + android:exported="false" + android:parentActivityName=".ui.catalog.CatalogActivity" /> + - diff --git a/app/src/main/java/woowacourse/shopping/ShoppingApp.kt b/app/src/main/java/woowacourse/shopping/ShoppingApp.kt index d60ed9ed0e..14fa0904f5 100644 --- a/app/src/main/java/woowacourse/shopping/ShoppingApp.kt +++ b/app/src/main/java/woowacourse/shopping/ShoppingApp.kt @@ -1,13 +1,13 @@ package woowacourse.shopping import android.app.Application -import woowacourse.shopping.di.DatabaseModule -import woowacourse.shopping.di.PreferenceModule +import woowacourse.shopping.di.DatabaseInjection +import woowacourse.shopping.di.PreferenceInjection class ShoppingApp : Application() { override fun onCreate() { super.onCreate() - DatabaseModule.init(this) - PreferenceModule.init(this) + DatabaseInjection.init(this) + PreferenceInjection.init(this) } } diff --git a/app/src/main/java/woowacourse/shopping/data/api/CartApi.kt b/app/src/main/java/woowacourse/shopping/data/api/CartApi.kt index d91875058c..5def9d47b4 100644 --- a/app/src/main/java/woowacourse/shopping/data/api/CartApi.kt +++ b/app/src/main/java/woowacourse/shopping/data/api/CartApi.kt @@ -1,6 +1,5 @@ package woowacourse.shopping.data.api -import retrofit2.Call import retrofit2.http.Body import retrofit2.http.DELETE import retrofit2.http.GET @@ -15,27 +14,27 @@ import woowacourse.shopping.data.model.response.CartItemsResponse interface CartApi { @GET("/cart-items") - fun getCartItems( + suspend fun getCartItems( @Query("page") page: Int, @Query("size") size: Int, - ): Call + ): CartItemsResponse @POST("/cart-items") - fun postCartItem( + suspend fun postCartItem( @Body cartItemRequest: CartItemRequest, - ): Call + ): Unit @DELETE("/cart-items/{id}") - fun deleteCartItem( + suspend fun deleteCartItem( @Path("id") id: Long, - ): Call + ): Unit @PATCH("/cart-items/{id}") - fun patchCartItem( + suspend fun patchCartItem( @Path("id") id: Long, @Body cartItemQuantityRequest: CartItemQuantityRequest, - ): Call + ): Unit @GET("/cart-items/counts") - fun getCartItemsCount(): Call + suspend fun getCartItemsCount(): CartItemsQuantityResponse } diff --git a/app/src/main/java/woowacourse/shopping/data/api/CouponApi.kt b/app/src/main/java/woowacourse/shopping/data/api/CouponApi.kt new file mode 100644 index 0000000000..1cf1ba559b --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/data/api/CouponApi.kt @@ -0,0 +1,9 @@ +package woowacourse.shopping.data.api + +import retrofit2.http.GET +import woowacourse.shopping.data.model.response.CouponResponse + +interface CouponApi { + @GET("/coupons") + suspend fun getCoupons(): List +} diff --git a/app/src/main/java/woowacourse/shopping/data/api/OrderApi.kt b/app/src/main/java/woowacourse/shopping/data/api/OrderApi.kt index e9056a672e..d097fd66e1 100644 --- a/app/src/main/java/woowacourse/shopping/data/api/OrderApi.kt +++ b/app/src/main/java/woowacourse/shopping/data/api/OrderApi.kt @@ -1,13 +1,12 @@ package woowacourse.shopping.data.api -import retrofit2.Call import retrofit2.http.Body import retrofit2.http.POST import woowacourse.shopping.data.model.request.OrderProductsRequest interface OrderApi { @POST("/orders") - fun postOrderProducts( + suspend fun postOrderProducts( @Body orderProductsRequest: OrderProductsRequest, - ): Call + ): Unit } diff --git a/app/src/main/java/woowacourse/shopping/data/api/ProductApi.kt b/app/src/main/java/woowacourse/shopping/data/api/ProductApi.kt index 87ecb28a28..80d1ce5c7b 100644 --- a/app/src/main/java/woowacourse/shopping/data/api/ProductApi.kt +++ b/app/src/main/java/woowacourse/shopping/data/api/ProductApi.kt @@ -1,6 +1,5 @@ package woowacourse.shopping.data.api -import retrofit2.Call import retrofit2.http.GET import retrofit2.http.Path import retrofit2.http.Query @@ -9,14 +8,14 @@ import woowacourse.shopping.data.model.response.ProductsResponse interface ProductApi { @GET("/products") - fun getProducts( + suspend fun getProducts( @Query("category") category: String? = null, @Query("page") page: Int, @Query("size") size: Int, - ): Call + ): ProductsResponse @GET("/products/{id}") - fun getProductDetail( + suspend fun getProduct( @Path("id") id: Long, - ): Call + ): ProductDetailResponse } diff --git a/app/src/main/java/woowacourse/shopping/data/dao/HistoryDao.kt b/app/src/main/java/woowacourse/shopping/data/dao/HistoryDao.kt index f5d4d086ca..2f30b920fc 100644 --- a/app/src/main/java/woowacourse/shopping/data/dao/HistoryDao.kt +++ b/app/src/main/java/woowacourse/shopping/data/dao/HistoryDao.kt @@ -10,10 +10,10 @@ import woowacourse.shopping.data.model.entity.HistoryProductEntity @Dao interface HistoryDao { @Insert(onConflict = OnConflictStrategy.REPLACE) - fun insertHistory(history: HistoryProductEntity) + suspend fun insertHistory(history: HistoryProductEntity) @Transaction - fun insertHistoryWithLimit( + suspend fun insertHistoryWithLimit( history: HistoryProductEntity, limit: Int, ) { @@ -26,10 +26,10 @@ interface HistoryDao { } @Query("SELECT * FROM search_history ORDER BY timestamp DESC") - fun getHistoryProducts(): List + suspend fun getHistoryProducts(): List @Query("SELECT COUNT(*) FROM search_history") - fun getHistoryCount(): Int + suspend fun getHistoryCount(): Int @Query( """ @@ -42,8 +42,8 @@ interface HistoryDao { ) """, ) - fun deleteOldestHistories(count: Int) + suspend fun deleteOldestHistories(count: Int) @Query("SELECT * FROM search_history ORDER BY timestamp DESC LIMIT 1") - fun getRecentHistoryProduct(): HistoryProductEntity? + suspend fun getRecentHistoryProduct(): HistoryProductEntity? } diff --git a/app/src/main/java/woowacourse/shopping/data/interceptor/ShoppingAuthInterceptor.kt b/app/src/main/java/woowacourse/shopping/data/interceptor/ShoppingAuthInterceptor.kt index 521c1be36a..e8c6dce77a 100644 --- a/app/src/main/java/woowacourse/shopping/data/interceptor/ShoppingAuthInterceptor.kt +++ b/app/src/main/java/woowacourse/shopping/data/interceptor/ShoppingAuthInterceptor.kt @@ -3,13 +3,14 @@ package woowacourse.shopping.data.interceptor import android.util.Base64 import okhttp3.Interceptor import okhttp3.Response +import woowacourse.shopping.BuildConfig +import woowacourse.shopping.data.preference.AuthSharedPreference class ShoppingAuthInterceptor( - private val username: String, - private val password: String, + private val authSharedPreference: AuthSharedPreference, ) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { - val credentials = "$username:$password" + val credentials = "${getUsername()}:${getPassword()}" val basic = "Basic " + Base64.encodeToString(credentials.toByteArray(), Base64.NO_WRAP) val newRequest = @@ -21,4 +22,16 @@ class ShoppingAuthInterceptor( return chain.proceed(newRequest) } + + private fun getUsername(): String = + authSharedPreference.getAuthUsername() + ?: BuildConfig.DEFAULT_USERNAME.apply { + authSharedPreference.putAuthId(this) + } + + private fun getPassword(): String = + authSharedPreference.getAuthPassword() + ?: BuildConfig.DEFAULT_PASSWORD.apply { + authSharedPreference.putAuthPassword(this) + } } diff --git a/app/src/main/java/woowacourse/shopping/data/model/response/CartItemsResponse.kt b/app/src/main/java/woowacourse/shopping/data/model/response/CartItemsResponse.kt index f71b5ea2d2..f2391ac9e3 100644 --- a/app/src/main/java/woowacourse/shopping/data/model/response/CartItemsResponse.kt +++ b/app/src/main/java/woowacourse/shopping/data/model/response/CartItemsResponse.kt @@ -18,9 +18,9 @@ data class CartItemsResponse( @SerialName("number") val number: Int, @SerialName("sort") - val sort: Sort, + val sort: SortResponse, @SerialName("pageable") - val pageable: Pageable, + val pageable: PageableResponse, @SerialName("first") val first: Boolean, @SerialName("last") @@ -69,40 +69,4 @@ data class CartItemsResponse( ) } } - - @Serializable - data class Sort( - @SerialName("empty") - val empty: Boolean, - @SerialName("sorted") - val sorted: Boolean, - @SerialName("unsorted") - val unsorted: Boolean, - ) - - @Serializable - data class Pageable( - @SerialName("offset") - val offset: Long, - @SerialName("sort") - val sort: Sort, - @SerialName("paged") - val paged: Boolean, - @SerialName("pageNumber") - val pageNumber: Int, - @SerialName("pageSize") - val pageSize: Int, - @SerialName("unpaged") - val unpaged: Boolean, - ) { - @Serializable - data class Sort( - @SerialName("empty") - val empty: Boolean, - @SerialName("sorted") - val sorted: Boolean, - @SerialName("unsorted") - val unsorted: Boolean, - ) - } } diff --git a/app/src/main/java/woowacourse/shopping/data/model/response/CouponResponse.kt b/app/src/main/java/woowacourse/shopping/data/model/response/CouponResponse.kt new file mode 100644 index 0000000000..1724d1c133 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/data/model/response/CouponResponse.kt @@ -0,0 +1,80 @@ +package woowacourse.shopping.data.model.response + +import android.util.Log +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import woowacourse.shopping.domain.model.CouponDetail +import woowacourse.shopping.domain.model.CouponDiscountType +import woowacourse.shopping.domain.model.CouponDiscountType.BUY_X_GET_Y +import woowacourse.shopping.domain.model.CouponDiscountType.FIXED +import woowacourse.shopping.domain.model.CouponDiscountType.FREE_SHIPPING +import woowacourse.shopping.domain.model.CouponDiscountType.PERCENTAGE +import java.time.LocalDate +import java.time.LocalTime + +@Serializable +data class CouponResponse( + @SerialName("id") + val id: Int, + @SerialName("code") + val code: String, + @SerialName("description") + val description: String, + @SerialName("expirationDate") + val expirationDate: String, + @SerialName("discount") + val discount: Int?, + @SerialName("minimumAmount") + val minimumAmount: Int?, + @SerialName("discountType") + val discountType: String, + @SerialName("buyQuantity") + val buyQuantity: Int?, + @SerialName("getQuantity") + val getQuantity: Int?, + @SerialName("availableTime") + val availableTime: AvailableTime?, +) { + @Serializable + data class AvailableTime( + @SerialName("start") + val start: String, + @SerialName("end") + val end: String, + ) + + companion object { + fun CouponResponse.toDomain(): CouponDetail? { + return CouponDetail( + id = id, + code = code, + name = description, + expirationDate = LocalDate.parse(expirationDate), + discount = discount, + minimumPurchase = minimumAmount, + discountType = discountType.toDiscountType() ?: return null, + buyQuantity = buyQuantity, + getQuantity = getQuantity, + availableTime = + availableTime?.let { + CouponDetail.AvailableTime( + start = LocalTime.parse(it.start), + end = LocalTime.parse(it.end), + ) + }, + ) + } + + private fun String.toDiscountType(): CouponDiscountType? = + when (this) { + "fixed" -> FIXED + "buyXgetY" -> BUY_X_GET_Y + "freeShipping" -> FREE_SHIPPING + "percentage" -> PERCENTAGE + else -> { + Log.e("[CouponResponse]", "유효하지 않은 쿠폰 타입이 존재합니다!") + null + } + } + } +} diff --git a/app/src/main/java/woowacourse/shopping/data/model/response/PageableResponse.kt b/app/src/main/java/woowacourse/shopping/data/model/response/PageableResponse.kt new file mode 100644 index 0000000000..d30991ac99 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/data/model/response/PageableResponse.kt @@ -0,0 +1,20 @@ +package woowacourse.shopping.data.model.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class PageableResponse( + @SerialName("offset") + val offset: Long, + @SerialName("sort") + val sort: SortResponse, + @SerialName("paged") + val paged: Boolean, + @SerialName("pageNumber") + val pageNumber: Int, + @SerialName("pageSize") + val pageSize: Int, + @SerialName("unpaged") + val unpaged: Boolean, +) diff --git a/app/src/main/java/woowacourse/shopping/data/model/response/ProductDetailResponse.kt b/app/src/main/java/woowacourse/shopping/data/model/response/ProductDetailResponse.kt index abcd281fb8..0ca6a78cfa 100644 --- a/app/src/main/java/woowacourse/shopping/data/model/response/ProductDetailResponse.kt +++ b/app/src/main/java/woowacourse/shopping/data/model/response/ProductDetailResponse.kt @@ -2,6 +2,7 @@ package woowacourse.shopping.data.model.response import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import woowacourse.shopping.domain.model.ProductDetail @Serializable data class ProductDetailResponse( @@ -15,4 +16,15 @@ data class ProductDetailResponse( val imageUrl: String, @SerialName("category") val category: String, -) +) { + companion object { + fun ProductDetailResponse.toDomain(): ProductDetail = + ProductDetail( + id = id, + name = name, + category = category, + imageUrl = imageUrl, + price = price, + ) + } +} diff --git a/app/src/main/java/woowacourse/shopping/data/model/response/ProductsResponse.kt b/app/src/main/java/woowacourse/shopping/data/model/response/ProductsResponse.kt index 9d038f004c..242fc02f4a 100644 --- a/app/src/main/java/woowacourse/shopping/data/model/response/ProductsResponse.kt +++ b/app/src/main/java/woowacourse/shopping/data/model/response/ProductsResponse.kt @@ -18,9 +18,9 @@ data class ProductsResponse( @SerialName("number") val number: Int, @SerialName("sort") - val sort: Sort, + val sort: SortResponse, @SerialName("pageable") - val pageable: Pageable, + val pageable: PageableResponse, @SerialName("first") val first: Boolean, @SerialName("last") @@ -58,40 +58,4 @@ data class ProductsResponse( ) } } - - @Serializable - data class Sort( - @SerialName("empty") - val empty: Boolean, - @SerialName("sorted") - val sorted: Boolean, - @SerialName("unsorted") - val unsorted: Boolean, - ) - - @Serializable - data class Pageable( - @SerialName("offset") - val offset: Long, - @SerialName("sort") - val sort: Sort, - @SerialName("paged") - val paged: Boolean, - @SerialName("pageNumber") - val pageNumber: Int, - @SerialName("pageSize") - val pageSize: Int, - @SerialName("unpaged") - val unpaged: Boolean, - ) { - @Serializable - data class Sort( - @SerialName("empty") - val empty: Boolean, - @SerialName("sorted") - val sorted: Boolean, - @SerialName("unsorted") - val unsorted: Boolean, - ) - } } diff --git a/app/src/main/java/woowacourse/shopping/data/model/response/SortResponse.kt b/app/src/main/java/woowacourse/shopping/data/model/response/SortResponse.kt new file mode 100644 index 0000000000..4af05b81c4 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/data/model/response/SortResponse.kt @@ -0,0 +1,14 @@ +package woowacourse.shopping.data.model.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SortResponse( + @SerialName("empty") + val empty: Boolean, + @SerialName("sorted") + val sorted: Boolean, + @SerialName("unsorted") + val unsorted: Boolean, +) diff --git a/app/src/main/java/woowacourse/shopping/data/repository/CartRepository.kt b/app/src/main/java/woowacourse/shopping/data/repository/CartRepository.kt index 4691ed028d..fef8b5bf17 100644 --- a/app/src/main/java/woowacourse/shopping/data/repository/CartRepository.kt +++ b/app/src/main/java/woowacourse/shopping/data/repository/CartRepository.kt @@ -1,13 +1,8 @@ package woowacourse.shopping.data.repository -import retrofit2.Call -import retrofit2.Callback -import retrofit2.Response import woowacourse.shopping.data.api.CartApi import woowacourse.shopping.data.model.request.CartItemQuantityRequest import woowacourse.shopping.data.model.request.CartItemRequest -import woowacourse.shopping.data.model.response.CartItemsQuantityResponse -import woowacourse.shopping.data.model.response.CartItemsResponse import woowacourse.shopping.data.model.response.CartItemsResponse.Content.Companion.toDomain import woowacourse.shopping.domain.model.Page import woowacourse.shopping.domain.model.Products @@ -16,129 +11,43 @@ import woowacourse.shopping.domain.repository.CartRepository class CartRepository( private val api: CartApi, ) : CartRepository { - override fun fetchCartProducts( + override suspend fun fetchCartProducts( page: Int, size: Int, - callback: (Result) -> Unit, - ) { - api.getCartItems(page, size).enqueue( - object : Callback { - override fun onResponse( - call: Call, - response: Response, - ) { - val body = response.body() - val items = body?.content?.map { it.toDomain() } ?: emptyList() - val pageInfo = Page(page, body?.first ?: true, body?.last ?: true) - callback(Result.success(Products(items, pageInfo))) - } - - override fun onFailure( - call: Call, - t: Throwable, - ) { - callback(Result.failure(t)) - } - }, - ) + ): Products { + val response = api.getCartItems(page, size) + val items = response.content.map { it.toDomain() } + val pageInfo = Page(page, response.first, response.last) + return Products(items, pageInfo) } - override fun fetchAllCartProducts(callback: (Result) -> Unit) { + override suspend fun fetchAllCartProducts(): Products { val firstPage = 0 val maxSize = Int.MAX_VALUE - fetchCartProducts(firstPage, maxSize, callback) - } - override fun fetchCartItemCount(callback: (Result) -> Unit) { - api.getCartItemsCount().enqueue( - object : Callback { - override fun onResponse( - call: Call, - response: Response, - ) { - callback(Result.success(response.body()?.quantity ?: 0)) - } + return fetchCartProducts(firstPage, maxSize) + } - override fun onFailure( - call: Call, - t: Throwable, - ) { - callback(Result.failure(t)) - } - }, - ) + override suspend fun fetchCartItemCount(): Int { + val response = api.getCartItemsCount() + return response.quantity } - override fun addCartProduct( + override suspend fun addCartProduct( productId: Long, quantity: Int, - callback: (Result) -> Unit, ) { - val request = CartItemRequest(productId = productId, quantity = quantity) - api.postCartItem(request).enqueue( - object : Callback { - override fun onResponse( - call: Call, - response: Response, - ) { - callback(Result.success(Unit)) - } - - override fun onFailure( - call: Call, - t: Throwable, - ) { - callback(Result.failure(t)) - } - }, - ) + api.postCartItem(CartItemRequest(productId = productId, quantity = quantity)) } - override fun updateCartProduct( + override suspend fun updateCartProduct( cartId: Long, quantity: Int, - callback: (Result) -> Unit, ) { - val request = CartItemQuantityRequest(quantity) - api.patchCartItem(cartId, request).enqueue( - object : Callback { - override fun onResponse( - call: Call, - response: Response, - ) { - callback(Result.success(Unit)) - } - - override fun onFailure( - call: Call, - t: Throwable, - ) { - callback(Result.failure(t)) - } - }, - ) + api.patchCartItem(cartId, CartItemQuantityRequest(quantity)) } - override fun deleteCartProduct( - cartId: Long, - callback: (Result) -> Unit, - ) { - api.deleteCartItem(cartId).enqueue( - object : Callback { - override fun onResponse( - call: Call, - response: Response, - ) { - callback(Result.success(Unit)) - } - - override fun onFailure( - call: Call, - t: Throwable, - ) { - callback(Result.failure(t)) - } - }, - ) + override suspend fun deleteCartProduct(cartId: Long) { + api.deleteCartItem(cartId) } } diff --git a/app/src/main/java/woowacourse/shopping/data/repository/CouponRepository.kt b/app/src/main/java/woowacourse/shopping/data/repository/CouponRepository.kt new file mode 100644 index 0000000000..57ede9cc35 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/data/repository/CouponRepository.kt @@ -0,0 +1,16 @@ +package woowacourse.shopping.data.repository + +import woowacourse.shopping.data.api.CouponApi +import woowacourse.shopping.data.model.response.CouponResponse.Companion.toDomain +import woowacourse.shopping.domain.model.Coupon +import woowacourse.shopping.domain.model.CouponDetail.Companion.toCoupon +import woowacourse.shopping.domain.repository.CouponRepository + +class CouponRepository( + private val api: CouponApi, +) : CouponRepository { + override suspend fun fetchAllCoupons(): List = + api + .getCoupons() + .mapNotNull { it.toDomain()?.toCoupon() } +} diff --git a/app/src/main/java/woowacourse/shopping/data/repository/HistoryRepository.kt b/app/src/main/java/woowacourse/shopping/data/repository/HistoryRepository.kt index 6aa1f2c975..de5500535c 100644 --- a/app/src/main/java/woowacourse/shopping/data/repository/HistoryRepository.kt +++ b/app/src/main/java/woowacourse/shopping/data/repository/HistoryRepository.kt @@ -6,42 +6,27 @@ import woowacourse.shopping.data.model.entity.HistoryProductEntity.Companion.toD import woowacourse.shopping.domain.model.HistoryProduct import woowacourse.shopping.domain.model.ProductDetail import woowacourse.shopping.domain.repository.HistoryRepository -import kotlin.concurrent.thread class HistoryRepository( private val dao: HistoryDao, ) : HistoryRepository { - override fun fetchAllHistory(callback: (List) -> Unit) { - thread { - callback( - dao.getHistoryProducts().map { it.toDomain() }, - ) - } - } + override suspend fun fetchAllHistory(): List = dao.getHistoryProducts().map { it.toDomain() } + + override suspend fun fetchRecentHistory(): HistoryProduct? = dao.getRecentHistoryProduct()?.toDomain() - override fun addHistoryWithLimit( + override suspend fun addHistoryWithLimit( productDetail: ProductDetail, limit: Int, ) { - thread { - dao.insertHistoryWithLimit( - history = - HistoryProductEntity( - productId = productDetail.id, - name = productDetail.name, - imageUrl = productDetail.imageUrl, - category = productDetail.category, - ), - limit = limit, - ) - } - } - - override fun fetchRecentHistory(callback: (HistoryProduct?) -> Unit) { - thread { - callback( - dao.getRecentHistoryProduct()?.toDomain(), - ) - } + dao.insertHistoryWithLimit( + history = + HistoryProductEntity( + productId = productDetail.id, + name = productDetail.name, + imageUrl = productDetail.imageUrl, + category = productDetail.category, + ), + limit = limit, + ) } } diff --git a/app/src/main/java/woowacourse/shopping/data/repository/OrderRepository.kt b/app/src/main/java/woowacourse/shopping/data/repository/OrderRepository.kt index a84f14b4d0..90ea369a08 100644 --- a/app/src/main/java/woowacourse/shopping/data/repository/OrderRepository.kt +++ b/app/src/main/java/woowacourse/shopping/data/repository/OrderRepository.kt @@ -1,8 +1,5 @@ package woowacourse.shopping.data.repository -import retrofit2.Call -import retrofit2.Callback -import retrofit2.Response import woowacourse.shopping.data.api.OrderApi import woowacourse.shopping.data.model.request.OrderProductsRequest import woowacourse.shopping.domain.repository.OrderRepository @@ -10,26 +7,7 @@ import woowacourse.shopping.domain.repository.OrderRepository class OrderRepository( private val api: OrderApi, ) : OrderRepository { - override fun postOrderProducts( - cartIds: List, - callback: (Result) -> Unit, - ) { - api.postOrderProducts(OrderProductsRequest(cartIds)).enqueue( - object : Callback { - override fun onResponse( - call: Call, - response: Response, - ) { - callback(Result.success(Unit)) - } - - override fun onFailure( - call: Call, - t: Throwable, - ) { - callback(Result.failure(t)) - } - }, - ) + override suspend fun postOrderProducts(cartIds: List) { + api.postOrderProducts(OrderProductsRequest(cartIds)) } } diff --git a/app/src/main/java/woowacourse/shopping/data/repository/ProductRepository.kt b/app/src/main/java/woowacourse/shopping/data/repository/ProductRepository.kt index dbdf3bdf4c..8af2484dad 100644 --- a/app/src/main/java/woowacourse/shopping/data/repository/ProductRepository.kt +++ b/app/src/main/java/woowacourse/shopping/data/repository/ProductRepository.kt @@ -1,68 +1,34 @@ package woowacourse.shopping.data.repository -import retrofit2.Call -import retrofit2.Callback -import retrofit2.Response import woowacourse.shopping.data.api.ProductApi -import woowacourse.shopping.data.model.response.ProductsResponse +import woowacourse.shopping.data.model.response.ProductDetailResponse.Companion.toDomain import woowacourse.shopping.data.model.response.ProductsResponse.Content.Companion.toDomain import woowacourse.shopping.domain.model.Page import woowacourse.shopping.domain.model.Product +import woowacourse.shopping.domain.model.ProductDetail import woowacourse.shopping.domain.model.Products import woowacourse.shopping.domain.repository.ProductRepository class ProductRepository( private val api: ProductApi, ) : ProductRepository { - override fun fetchProducts( + override suspend fun fetchProducts( page: Int, size: Int, category: String?, - callback: (Result) -> Unit, - ) { - api.getProducts(category, page, size).enqueue( - object : Callback { - override fun onResponse( - call: Call, - response: Response, - ) { - val body = response.body() - val items = body?.content?.map { it.toDomain() } ?: emptyList() - val pageInfo = Page(page, body?.first ?: false, body?.last ?: false) - callback(Result.success(Products(items, pageInfo))) - } - - override fun onFailure( - call: Call, - t: Throwable, - ) { - callback(Result.failure(t)) - } - }, - ) + ): Products { + val response = api.getProducts(category, page, size) + val items = response.content.map { it.toDomain() } + val pageInfo = Page(page, response.first, response.last) + return Products(items, pageInfo) } - override fun fetchAllProducts(callback: (Result>) -> Unit) { + override suspend fun fetchAllProducts(): List { val firstPage = 0 val maxSize = Int.MAX_VALUE - - api.getProducts(page = firstPage, size = maxSize).enqueue( - object : Callback { - override fun onResponse( - call: Call, - response: Response, - ) { - val products = response.body()?.content?.map { it.toDomain() } ?: emptyList() - callback(Result.success(products)) - } - - override fun onFailure( - call: Call, - t: Throwable, - ) { - callback(Result.failure(t)) - } - }, - ) + val response = api.getProducts(page = firstPage, size = maxSize) + return response.content.map { it.toDomain() } } + + override suspend fun fetchProduct(productId: Long): ProductDetail = api.getProduct(productId).toDomain() } diff --git a/app/src/main/java/woowacourse/shopping/di/DatabaseModule.kt b/app/src/main/java/woowacourse/shopping/di/DatabaseInjection.kt similarity index 94% rename from app/src/main/java/woowacourse/shopping/di/DatabaseModule.kt rename to app/src/main/java/woowacourse/shopping/di/DatabaseInjection.kt index 7ef48d3429..078d825cbd 100644 --- a/app/src/main/java/woowacourse/shopping/di/DatabaseModule.kt +++ b/app/src/main/java/woowacourse/shopping/di/DatabaseInjection.kt @@ -3,7 +3,7 @@ package woowacourse.shopping.di import android.content.Context import woowacourse.shopping.data.database.ShoppingDatabase -object DatabaseModule { +object DatabaseInjection { private var _database: ShoppingDatabase? = null val database: ShoppingDatabase get() = _database ?: throw IllegalStateException() diff --git a/app/src/main/java/woowacourse/shopping/di/NetworkModule.kt b/app/src/main/java/woowacourse/shopping/di/NetworkInjection.kt similarity index 64% rename from app/src/main/java/woowacourse/shopping/di/NetworkModule.kt rename to app/src/main/java/woowacourse/shopping/di/NetworkInjection.kt index c493220ce3..702d39efea 100644 --- a/app/src/main/java/woowacourse/shopping/di/NetworkModule.kt +++ b/app/src/main/java/woowacourse/shopping/di/NetworkInjection.kt @@ -5,26 +5,18 @@ import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import woowacourse.shopping.BuildConfig import woowacourse.shopping.data.api.CartApi +import woowacourse.shopping.data.api.CouponApi import woowacourse.shopping.data.api.OrderApi import woowacourse.shopping.data.api.ProductApi import woowacourse.shopping.data.interceptor.ShoppingAuthInterceptor -import woowacourse.shopping.di.PreferenceModule.authSharedPreference +import woowacourse.shopping.di.PreferenceInjection.authSharedPreference -object NetworkModule { +object NetworkInjection { private val okHttpClient: OkHttpClient by lazy { OkHttpClient .Builder() .addInterceptor( - ShoppingAuthInterceptor( - authSharedPreference.getAuthUsername() - ?: BuildConfig.DEFAULT_USERNAME.apply { - authSharedPreference.putAuthId(this) - }, - authSharedPreference.getAuthPassword() - ?: BuildConfig.DEFAULT_PASSWORD.apply { - authSharedPreference.putAuthPassword(this) - }, - ), + ShoppingAuthInterceptor(authSharedPreference), ).build() } @@ -46,4 +38,7 @@ object NetworkModule { val orderApi: OrderApi by lazy { retrofit.create(OrderApi::class.java) } + val couponApi: CouponApi by lazy { + retrofit.create(CouponApi::class.java) + } } diff --git a/app/src/main/java/woowacourse/shopping/di/PreferenceModule.kt b/app/src/main/java/woowacourse/shopping/di/PreferenceInjection.kt similarity index 94% rename from app/src/main/java/woowacourse/shopping/di/PreferenceModule.kt rename to app/src/main/java/woowacourse/shopping/di/PreferenceInjection.kt index c9946318b3..47d8fcfcd9 100644 --- a/app/src/main/java/woowacourse/shopping/di/PreferenceModule.kt +++ b/app/src/main/java/woowacourse/shopping/di/PreferenceInjection.kt @@ -5,7 +5,7 @@ import android.content.Context import woowacourse.shopping.data.preference.AuthSharedPreference @SuppressLint("StaticFieldLeak") -object PreferenceModule { +object PreferenceInjection { private var _authSharedPreference: AuthSharedPreference? = null val authSharedPreference: AuthSharedPreference get() = _authSharedPreference ?: throw IllegalStateException() diff --git a/app/src/main/java/woowacourse/shopping/di/RepositoryInjection.kt b/app/src/main/java/woowacourse/shopping/di/RepositoryInjection.kt new file mode 100644 index 0000000000..515bbd4684 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/di/RepositoryInjection.kt @@ -0,0 +1,39 @@ +package woowacourse.shopping.di + +import woowacourse.shopping.di.DatabaseInjection.database +import woowacourse.shopping.di.NetworkInjection.cartApi +import woowacourse.shopping.di.NetworkInjection.couponApi +import woowacourse.shopping.di.NetworkInjection.orderApi +import woowacourse.shopping.di.NetworkInjection.productApi +import woowacourse.shopping.domain.repository.CartRepository +import woowacourse.shopping.domain.repository.CouponRepository +import woowacourse.shopping.domain.repository.HistoryRepository +import woowacourse.shopping.domain.repository.OrderRepository +import woowacourse.shopping.domain.repository.ProductRepository + +object RepositoryInjection { + val cartRepository: CartRepository by lazy { + woowacourse.shopping.data.repository + .CartRepository(cartApi) + } + + val productRepository: ProductRepository by lazy { + woowacourse.shopping.data.repository + .ProductRepository(productApi) + } + + val historyRepository: HistoryRepository by lazy { + woowacourse.shopping.data.repository + .HistoryRepository(database.historyDao()) + } + + val orderRepository: OrderRepository by lazy { + woowacourse.shopping.data.repository + .OrderRepository(orderApi) + } + + val couponRepository: CouponRepository by lazy { + woowacourse.shopping.data.repository + .CouponRepository(couponApi) + } +} diff --git a/app/src/main/java/woowacourse/shopping/di/RepositoryModule.kt b/app/src/main/java/woowacourse/shopping/di/RepositoryModule.kt deleted file mode 100644 index 261405e7ad..0000000000 --- a/app/src/main/java/woowacourse/shopping/di/RepositoryModule.kt +++ /dev/null @@ -1,28 +0,0 @@ -package woowacourse.shopping.di - -import woowacourse.shopping.di.DatabaseModule.database -import woowacourse.shopping.di.NetworkModule.cartApi -import woowacourse.shopping.di.NetworkModule.orderApi -import woowacourse.shopping.di.NetworkModule.productApi - -object RepositoryModule { - val cartRepository: woowacourse.shopping.domain.repository.CartRepository by lazy { - woowacourse.shopping.data.repository - .CartRepository(cartApi) - } - - val productRepository: woowacourse.shopping.domain.repository.ProductRepository by lazy { - woowacourse.shopping.data.repository - .ProductRepository(productApi) - } - - val historyRepository: woowacourse.shopping.domain.repository.HistoryRepository by lazy { - woowacourse.shopping.data.repository - .HistoryRepository(database.historyDao()) - } - - val orderRepository: woowacourse.shopping.domain.repository.OrderRepository by lazy { - woowacourse.shopping.data.repository - .OrderRepository(orderApi) - } -} diff --git a/app/src/main/java/woowacourse/shopping/di/UseCaseModule.kt b/app/src/main/java/woowacourse/shopping/di/UseCaseInjection.kt similarity index 75% rename from app/src/main/java/woowacourse/shopping/di/UseCaseModule.kt rename to app/src/main/java/woowacourse/shopping/di/UseCaseInjection.kt index 6f11ae71bb..aebf55968a 100644 --- a/app/src/main/java/woowacourse/shopping/di/UseCaseModule.kt +++ b/app/src/main/java/woowacourse/shopping/di/UseCaseInjection.kt @@ -1,17 +1,19 @@ package woowacourse.shopping.di -import woowacourse.shopping.di.RepositoryModule.cartRepository -import woowacourse.shopping.di.RepositoryModule.historyRepository -import woowacourse.shopping.di.RepositoryModule.orderRepository -import woowacourse.shopping.di.RepositoryModule.productRepository +import woowacourse.shopping.di.RepositoryInjection.cartRepository +import woowacourse.shopping.di.RepositoryInjection.couponRepository +import woowacourse.shopping.di.RepositoryInjection.historyRepository +import woowacourse.shopping.di.RepositoryInjection.orderRepository +import woowacourse.shopping.di.RepositoryInjection.productRepository import woowacourse.shopping.domain.usecase.AddSearchHistoryUseCase import woowacourse.shopping.domain.usecase.DecreaseCartProductQuantityUseCase import woowacourse.shopping.domain.usecase.GetCartProductsQuantityUseCase import woowacourse.shopping.domain.usecase.GetCartProductsUseCase import woowacourse.shopping.domain.usecase.GetCartRecommendProductsUseCase import woowacourse.shopping.domain.usecase.GetCatalogProductUseCase -import woowacourse.shopping.domain.usecase.GetCatalogProductsByIdsUseCase +import woowacourse.shopping.domain.usecase.GetCatalogProductsByProductIdsUseCase import woowacourse.shopping.domain.usecase.GetCatalogProductsUseCase +import woowacourse.shopping.domain.usecase.GetCouponsUseCase import woowacourse.shopping.domain.usecase.GetRecentSearchHistoryUseCase import woowacourse.shopping.domain.usecase.GetSearchHistoryUseCase import woowacourse.shopping.domain.usecase.IncreaseCartProductQuantityUseCase @@ -19,7 +21,7 @@ import woowacourse.shopping.domain.usecase.OrderProductsUseCase import woowacourse.shopping.domain.usecase.RemoveCartProductUseCase import woowacourse.shopping.domain.usecase.UpdateCartProductUseCase -object UseCaseModule { +object UseCaseInjection { val getCartProductsUseCase by lazy { GetCartProductsUseCase(cartRepository) } @@ -60,8 +62,8 @@ object UseCaseModule { GetCatalogProductUseCase(productRepository, cartRepository) } - val getCatalogProductsByIdsUseCase by lazy { - GetCatalogProductsByIdsUseCase(productRepository, cartRepository) + val getCatalogProductsByProductIdsUseCase by lazy { + GetCatalogProductsByProductIdsUseCase(productRepository, cartRepository) } val getCartProductsQuantityUseCase by lazy { @@ -73,6 +75,9 @@ object UseCaseModule { } val orderProductsUseCase by lazy { - OrderProductsUseCase(productRepository, cartRepository, orderRepository) + OrderProductsUseCase(orderRepository) + } + val getCouponsUseCase by lazy { + GetCouponsUseCase(couponRepository) } } diff --git a/app/src/main/java/woowacourse/shopping/domain/model/Coupon.kt b/app/src/main/java/woowacourse/shopping/domain/model/Coupon.kt new file mode 100644 index 0000000000..60c514e6d7 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/domain/model/Coupon.kt @@ -0,0 +1,24 @@ +package woowacourse.shopping.domain.model + +import java.time.LocalDateTime + +sealed interface Coupon { + val detail: CouponDetail + + val isSelected: Boolean + + fun apply( + products: Products, + nowDateTime: LocalDateTime = LocalDateTime.now(), + ): Price + + fun getIsAvailable( + products: Products, + nowDateTime: LocalDateTime = LocalDateTime.now(), + ): Boolean + + fun copy( + detail: CouponDetail = this.detail, + isSelected: Boolean = this.isSelected, + ): Coupon +} diff --git a/app/src/main/java/woowacourse/shopping/domain/model/CouponDetail.kt b/app/src/main/java/woowacourse/shopping/domain/model/CouponDetail.kt new file mode 100644 index 0000000000..6d61189aba --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/domain/model/CouponDetail.kt @@ -0,0 +1,36 @@ +package woowacourse.shopping.domain.model + +import woowacourse.shopping.domain.model.CouponDiscountType.BUY_X_GET_Y +import woowacourse.shopping.domain.model.CouponDiscountType.FIXED +import woowacourse.shopping.domain.model.CouponDiscountType.FREE_SHIPPING +import woowacourse.shopping.domain.model.CouponDiscountType.PERCENTAGE +import java.time.LocalDate +import java.time.LocalTime + +data class CouponDetail( + val id: Int, + val code: String, + val name: String, + val expirationDate: LocalDate, + val discount: Int?, + val minimumPurchase: Int?, + val discountType: CouponDiscountType, + val buyQuantity: Int?, + val getQuantity: Int?, + val availableTime: AvailableTime?, +) { + data class AvailableTime( + val start: LocalTime, + val end: LocalTime, + ) + + companion object { + fun CouponDetail.toCoupon(): Coupon = + when (this.discountType) { + FIXED -> FixedDiscountCoupon(this) + PERCENTAGE -> PercentageDiscountCoupon(this) + FREE_SHIPPING -> FreeShippingCoupon(this) + BUY_X_GET_Y -> QuantityBonusCoupon(this) + } + } +} diff --git a/app/src/main/java/woowacourse/shopping/domain/model/CouponDiscountType.kt b/app/src/main/java/woowacourse/shopping/domain/model/CouponDiscountType.kt new file mode 100644 index 0000000000..09335abc06 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/domain/model/CouponDiscountType.kt @@ -0,0 +1,8 @@ +package woowacourse.shopping.domain.model + +enum class CouponDiscountType { + FIXED, + BUY_X_GET_Y, + FREE_SHIPPING, + PERCENTAGE, +} diff --git a/app/src/main/java/woowacourse/shopping/domain/model/Coupons.kt b/app/src/main/java/woowacourse/shopping/domain/model/Coupons.kt new file mode 100644 index 0000000000..a739e62dcf --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/domain/model/Coupons.kt @@ -0,0 +1,42 @@ +package woowacourse.shopping.domain.model + +import java.time.LocalDateTime + +@JvmInline +value class Coupons( + val value: List, +) { + fun selectCoupon(couponId: Int): Coupons { + val selectedCoupon = value.find { it.detail.id == couponId } ?: return this + + return Coupons( + value.map { + when { + it.detail.id == couponId -> it.copy(isSelected = !selectedCoupon.isSelected) + else -> it.copy(isSelected = false) + } + }, + ) + } + + fun applyCoupon( + products: Products, + nowDateTime: LocalDateTime, + ): Price = + value.find { it.isSelected }?.apply(products, nowDateTime) + ?: Price(products.selectedProductsPrice) + + fun filterAvailableCoupons( + products: Products, + nowDateTime: LocalDateTime, + ): Coupons = + Coupons( + value.filter { coupon -> + coupon.getIsAvailable(products, nowDateTime) + }, + ) + + companion object { + val EMPTY_COUPONS: Coupons = Coupons(emptyList()) + } +} diff --git a/app/src/main/java/woowacourse/shopping/domain/model/FixedDiscountCoupon.kt b/app/src/main/java/woowacourse/shopping/domain/model/FixedDiscountCoupon.kt new file mode 100644 index 0000000000..5e319e0d89 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/domain/model/FixedDiscountCoupon.kt @@ -0,0 +1,47 @@ +package woowacourse.shopping.domain.model + +import woowacourse.shopping.domain.model.Price.Companion.DEFAULT_SHIPPING_PRICE +import woowacourse.shopping.domain.model.Price.Companion.MINIMUM_PRICE +import java.time.LocalDateTime + +class FixedDiscountCoupon( + override val detail: CouponDetail, + override val isSelected: Boolean = false, +) : Coupon { + override fun apply( + products: Products, + nowDateTime: LocalDateTime, + ): Price { + val original = products.selectedProductsPrice + + val discount = + if (getIsAvailable(products, nowDateTime)) { + detail.discount ?: MINIMUM_PRICE + } else { + MINIMUM_PRICE + } + + val shipping = DEFAULT_SHIPPING_PRICE + + return Price( + original = original, + discount = discount, + shipping = shipping, + ) + } + + override fun getIsAvailable( + products: Products, + nowDateTime: LocalDateTime, + ): Boolean { + val isExceedingMinimumPurchase = products.selectedProductsPrice >= (detail.minimumPurchase ?: MINIMUM_PRICE) + val isDateOkay = detail.expirationDate >= nowDateTime.toLocalDate() + + return isExceedingMinimumPurchase && isDateOkay + } + + override fun copy( + detail: CouponDetail, + isSelected: Boolean, + ): Coupon = FixedDiscountCoupon(detail, isSelected) +} diff --git a/app/src/main/java/woowacourse/shopping/domain/model/FreeShippingCoupon.kt b/app/src/main/java/woowacourse/shopping/domain/model/FreeShippingCoupon.kt new file mode 100644 index 0000000000..2772a7271f --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/domain/model/FreeShippingCoupon.kt @@ -0,0 +1,44 @@ +package woowacourse.shopping.domain.model + +import woowacourse.shopping.domain.model.Price.Companion.DEFAULT_SHIPPING_PRICE +import woowacourse.shopping.domain.model.Price.Companion.MINIMUM_PRICE +import java.time.LocalDateTime + +class FreeShippingCoupon( + override val detail: CouponDetail, + override val isSelected: Boolean = false, +) : Coupon { + override fun apply( + products: Products, + nowDateTime: LocalDateTime, + ): Price { + val original = products.selectedProductsPrice + + val isAvailable = getIsAvailable(products, nowDateTime) + + val discount = if (isAvailable) DEFAULT_SHIPPING_PRICE else MINIMUM_PRICE + + val shipping = DEFAULT_SHIPPING_PRICE + + return Price( + original = original, + discount = discount, + shipping = shipping, + ) + } + + override fun getIsAvailable( + products: Products, + nowDateTime: LocalDateTime, + ): Boolean { + val isExceedingMinimumPurchase = products.selectedProductsPrice >= (detail.minimumPurchase ?: MINIMUM_PRICE) + val isDateOkay = detail.expirationDate >= nowDateTime.toLocalDate() + + return isExceedingMinimumPurchase && isDateOkay + } + + override fun copy( + detail: CouponDetail, + isSelected: Boolean, + ): Coupon = FreeShippingCoupon(detail, isSelected) +} diff --git a/app/src/main/java/woowacourse/shopping/domain/model/Page.kt b/app/src/main/java/woowacourse/shopping/domain/model/Page.kt index 5ed7c57353..cac0cb3f06 100644 --- a/app/src/main/java/woowacourse/shopping/domain/model/Page.kt +++ b/app/src/main/java/woowacourse/shopping/domain/model/Page.kt @@ -5,7 +5,7 @@ data class Page( val isFirst: Boolean, val isLast: Boolean, ) { - val isSingle: Boolean get() = isFirst == isLast + val isSingle: Boolean get() = isFirst && isLast operator fun plus(step: Int): Page = copy(current = current + step) diff --git a/app/src/main/java/woowacourse/shopping/domain/model/PercentageDiscountCoupon.kt b/app/src/main/java/woowacourse/shopping/domain/model/PercentageDiscountCoupon.kt new file mode 100644 index 0000000000..001fcb3645 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/domain/model/PercentageDiscountCoupon.kt @@ -0,0 +1,53 @@ +package woowacourse.shopping.domain.model + +import woowacourse.shopping.domain.model.Price.Companion.DEFAULT_SHIPPING_PRICE +import woowacourse.shopping.domain.model.Price.Companion.MINIMUM_PRICE +import java.time.LocalDateTime + +class PercentageDiscountCoupon( + override val detail: CouponDetail, + override val isSelected: Boolean = false, +) : Coupon { + override fun apply( + products: Products, + nowDateTime: LocalDateTime, + ): Price { + val original = products.selectedProductsPrice + + val isAvailable = getIsAvailable(products, nowDateTime) + + val discountRate = (detail.discount ?: MINIMUM_PRICE).coerceIn(0, 100) + val discount = if (isAvailable) (original * discountRate / 100.0).toInt() else MINIMUM_PRICE + + val shipping = DEFAULT_SHIPPING_PRICE + + return Price( + original = original, + discount = discount, + shipping = shipping, + ) + } + + override fun getIsAvailable( + products: Products, + nowDateTime: LocalDateTime, + ): Boolean { + val minimumPurchase = detail.minimumPurchase ?: MINIMUM_PRICE + val now = nowDateTime.toLocalTime() + + val isAmountOkay = products.selectedProductsPrice >= minimumPurchase + val isTimeOkay = + detail.availableTime?.let { + now.isAfter(it.start) && now.isBefore(it.end) + } ?: true + + val isDateOkay = detail.expirationDate >= nowDateTime.toLocalDate() + + return isAmountOkay && isTimeOkay && isDateOkay + } + + override fun copy( + detail: CouponDetail, + isSelected: Boolean, + ): Coupon = PercentageDiscountCoupon(detail, isSelected) +} diff --git a/app/src/main/java/woowacourse/shopping/domain/model/Price.kt b/app/src/main/java/woowacourse/shopping/domain/model/Price.kt new file mode 100644 index 0000000000..7248a0aa66 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/domain/model/Price.kt @@ -0,0 +1,15 @@ +package woowacourse.shopping.domain.model + +data class Price( + val original: Int, + val discount: Int = MINIMUM_PRICE, + val shipping: Int = DEFAULT_SHIPPING_PRICE, +) { + val result: Int get() = (original - discount + shipping).coerceAtLeast(MINIMUM_PRICE) + + companion object { + const val MINIMUM_PRICE: Int = 0 + const val DEFAULT_SHIPPING_PRICE: Int = 3_000 + val EMPTY_PRICE: Price = Price(MINIMUM_PRICE) + } +} diff --git a/app/src/main/java/woowacourse/shopping/domain/model/Product.kt b/app/src/main/java/woowacourse/shopping/domain/model/Product.kt index 58a4a25f0a..4a6f75f223 100644 --- a/app/src/main/java/woowacourse/shopping/domain/model/Product.kt +++ b/app/src/main/java/woowacourse/shopping/domain/model/Product.kt @@ -13,14 +13,12 @@ data class Product( fun increaseQuantity(delta: Int = DEFAULT_QUANTITY_DELTA): Product = copy(quantity = quantity + delta) fun decreaseQuantity(delta: Int = DEFAULT_QUANTITY_DELTA): Product = - if (quantity - delta >= 0) { + if (quantity - delta >= MINIMUM_QUANTITY) { copy(quantity = quantity - delta) } else { - copy(quantity = 0) + copy(quantity = MINIMUM_QUANTITY) } - fun toggleSelection(): Product = copy(isSelected = !isSelected) - companion object { const val MINIMUM_QUANTITY = 0 val EMPTY_PRODUCT = Product(EMPTY_PRODUCT_DETAIL, null, MINIMUM_QUANTITY) diff --git a/app/src/main/java/woowacourse/shopping/domain/model/Products.kt b/app/src/main/java/woowacourse/shopping/domain/model/Products.kt index 473e0ae947..56739ff426 100644 --- a/app/src/main/java/woowacourse/shopping/domain/model/Products.kt +++ b/app/src/main/java/woowacourse/shopping/domain/model/Products.kt @@ -1,13 +1,14 @@ package woowacourse.shopping.domain.model import woowacourse.shopping.domain.model.Page.Companion.EMPTY_PAGE -import woowacourse.shopping.domain.model.Product.Companion.MINIMUM_QUANTITY data class Products( val products: List, val page: Page = EMPTY_PAGE, ) { val isAllSelected: Boolean get() = products.all { it.isSelected } + val selectedProductsQuantity: Int get() = products.filter { it.isSelected }.sumOf { it.quantity } + val selectedProductsPrice: Int = products.filter { it.isSelected }.sumOf { it.totalPrice } operator fun plus(other: Products): Products { val mergedProducts = products + other.products @@ -17,20 +18,15 @@ data class Products( ) } - fun updateProductQuantity( - productId: Long, + fun updateSelection( + product: Product, + isSelected: Boolean = !product.isSelected, + ): Products = updateProduct(product.copy(isSelected = isSelected)) + + fun updateQuantity( + product: Product, quantity: Int, - ): Products { - val updatedProducts = - products.map { product -> - if (product.productDetail.id == productId) { - product.copy(quantity = quantity) - } else { - product - } - } - return copy(products = updatedProducts) - } + ): Products = updateProduct(product.copy(quantity = quantity)) fun updateProduct(newProduct: Product): Products { val updatedProducts = @@ -54,32 +50,13 @@ data class Products( fun getProductByProductId(productId: Long): Product? = products.find { it.productDetail.id == productId } - fun toggleSelectionByCartId(cartId: Long): Products = - copy( - products = - products.map { product -> - if (product.cartId == cartId) { - product.toggleSelection() - } else { - product - } - }, - ) - - fun getSelectedCartProductsPrice(): Int = products.filter { it.isSelected }.sumOf { it.productDetail.price * it.quantity } - - fun getSelectedCartRecommendProductsPrice(): Int = - products.filter { it.quantity > MINIMUM_QUANTITY }.sumOf { it.productDetail.price * it.quantity } - - fun updateAllSelection(): Products = copy(products = products.map { it.copy(isSelected = !isAllSelected) }) - - fun getSelectedCartProductIds(): List = products.filter { it.isSelected }.map { it.productDetail.id } + fun getProductByCartId(cartId: Long): Product? = products.find { it.cartId == cartId } - fun getSelectedCartProductQuantity(): Int = products.filter { it.isSelected }.sumOf { it.quantity } + fun toggleAllSelection(): Products = copy(products = products.map { it.copy(isSelected = !isAllSelected) }) - fun getSelectedCartRecommendProductIds(): List = products.filter { it.quantity > MINIMUM_QUANTITY }.map { it.productDetail.id } + fun getSelectedProductIds(): List = products.filter { it.isSelected }.map { it.productDetail.id } - fun getSelectedCartRecommendProductQuantity(): Int = products.filter { it.quantity > MINIMUM_QUANTITY }.sumOf { it.quantity } + fun getSelectedCartIds(): Set = products.filter { it.isSelected }.mapNotNull { it.cartId }.toSet() companion object { val EMPTY_PRODUCTS = Products(emptyList(), EMPTY_PAGE) diff --git a/app/src/main/java/woowacourse/shopping/domain/model/QuantityBonusCoupon.kt b/app/src/main/java/woowacourse/shopping/domain/model/QuantityBonusCoupon.kt new file mode 100644 index 0000000000..6a0f9f5ab0 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/domain/model/QuantityBonusCoupon.kt @@ -0,0 +1,63 @@ +package woowacourse.shopping.domain.model + +import woowacourse.shopping.domain.model.Price.Companion.DEFAULT_SHIPPING_PRICE +import woowacourse.shopping.domain.model.Product.Companion.MINIMUM_QUANTITY +import java.time.LocalDateTime + +class QuantityBonusCoupon( + override val detail: CouponDetail, + override val isSelected: Boolean = false, +) : Coupon { + override fun apply( + products: Products, + nowDateTime: LocalDateTime, + ): Price { + val original = products.selectedProductsPrice + if (!getIsAvailable(products, nowDateTime)) return Price(original = original) + + val targetProduct = + products.products + .filter { it.isSelected && it.quantity >= getRequiredTotalQuantity() } + .maxByOrNull { it.productDetail.price } + ?: return Price(original = original) + + val productPrice = targetProduct.productDetail.price + + val discount = + with(detail) { + if (buyQuantity == null || getQuantity == null) return Price(original = original) + val quantityForDiscount = targetProduct.quantity / (buyQuantity + getQuantity) * getQuantity + productPrice * quantityForDiscount + } + + return Price( + original = original, + discount = discount, + shipping = DEFAULT_SHIPPING_PRICE, + ) + } + + override fun getIsAvailable( + products: Products, + nowDateTime: LocalDateTime, + ): Boolean { + val isValidQuantity = + products.products.any { + it.isSelected && it.quantity >= getRequiredTotalQuantity() + } + val isDateOkay = detail.expirationDate >= nowDateTime.toLocalDate() + + return isValidQuantity && isDateOkay + } + + private fun getRequiredTotalQuantity(): Int { + val buy = detail.buyQuantity ?: MINIMUM_QUANTITY + val get = detail.getQuantity ?: MINIMUM_QUANTITY + return buy + get + } + + override fun copy( + detail: CouponDetail, + isSelected: Boolean, + ): Coupon = QuantityBonusCoupon(detail, isSelected) +} diff --git a/app/src/main/java/woowacourse/shopping/domain/repository/CartRepository.kt b/app/src/main/java/woowacourse/shopping/domain/repository/CartRepository.kt index 3207703719..3718ea89ae 100644 --- a/app/src/main/java/woowacourse/shopping/domain/repository/CartRepository.kt +++ b/app/src/main/java/woowacourse/shopping/domain/repository/CartRepository.kt @@ -3,30 +3,24 @@ package woowacourse.shopping.domain.repository import woowacourse.shopping.domain.model.Products interface CartRepository { - fun fetchCartProducts( + suspend fun fetchCartProducts( page: Int, size: Int, - callback: (Result) -> Unit, - ) + ): Products - fun fetchAllCartProducts(callback: (Result) -> Unit) + suspend fun fetchAllCartProducts(): Products - fun fetchCartItemCount(callback: (Result) -> Unit) + suspend fun fetchCartItemCount(): Int - fun addCartProduct( + suspend fun addCartProduct( productId: Long, quantity: Int, - callback: (Result) -> Unit, - ) + ): Unit - fun deleteCartProduct( - cartId: Long, - callback: (Result) -> Unit, - ) + suspend fun deleteCartProduct(cartId: Long): Unit - fun updateCartProduct( + suspend fun updateCartProduct( cartId: Long, quantity: Int, - callback: (Result) -> Unit, - ) + ): Unit } diff --git a/app/src/main/java/woowacourse/shopping/domain/repository/CouponRepository.kt b/app/src/main/java/woowacourse/shopping/domain/repository/CouponRepository.kt new file mode 100644 index 0000000000..7896a36f11 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/domain/repository/CouponRepository.kt @@ -0,0 +1,7 @@ +package woowacourse.shopping.domain.repository + +import woowacourse.shopping.domain.model.Coupon + +interface CouponRepository { + suspend fun fetchAllCoupons(): List +} diff --git a/app/src/main/java/woowacourse/shopping/domain/repository/HistoryRepository.kt b/app/src/main/java/woowacourse/shopping/domain/repository/HistoryRepository.kt index 7a85dbbc68..ae93059058 100644 --- a/app/src/main/java/woowacourse/shopping/domain/repository/HistoryRepository.kt +++ b/app/src/main/java/woowacourse/shopping/domain/repository/HistoryRepository.kt @@ -4,12 +4,12 @@ import woowacourse.shopping.domain.model.HistoryProduct import woowacourse.shopping.domain.model.ProductDetail interface HistoryRepository { - fun fetchAllHistory(callback: (List) -> Unit) + suspend fun fetchAllHistory(): List - fun fetchRecentHistory(callback: (HistoryProduct?) -> Unit) + suspend fun fetchRecentHistory(): HistoryProduct? - fun addHistoryWithLimit( + suspend fun addHistoryWithLimit( productDetail: ProductDetail, limit: Int, - ) + ): Unit } diff --git a/app/src/main/java/woowacourse/shopping/domain/repository/OrderRepository.kt b/app/src/main/java/woowacourse/shopping/domain/repository/OrderRepository.kt index 919e8dfd6d..75d9ff5631 100644 --- a/app/src/main/java/woowacourse/shopping/domain/repository/OrderRepository.kt +++ b/app/src/main/java/woowacourse/shopping/domain/repository/OrderRepository.kt @@ -1,8 +1,5 @@ package woowacourse.shopping.domain.repository interface OrderRepository { - fun postOrderProducts( - cartIds: List, - callback: (Result) -> Unit, - ) + suspend fun postOrderProducts(cartIds: List): Unit } diff --git a/app/src/main/java/woowacourse/shopping/domain/repository/ProductRepository.kt b/app/src/main/java/woowacourse/shopping/domain/repository/ProductRepository.kt index dda9aacb8d..67a367221f 100644 --- a/app/src/main/java/woowacourse/shopping/domain/repository/ProductRepository.kt +++ b/app/src/main/java/woowacourse/shopping/domain/repository/ProductRepository.kt @@ -1,15 +1,17 @@ package woowacourse.shopping.domain.repository import woowacourse.shopping.domain.model.Product +import woowacourse.shopping.domain.model.ProductDetail import woowacourse.shopping.domain.model.Products interface ProductRepository { - fun fetchProducts( + suspend fun fetchProducts( page: Int, size: Int, category: String? = null, - callback: (Result) -> Unit, - ) + ): Products - fun fetchAllProducts(callback: (Result>) -> Unit) + suspend fun fetchAllProducts(): List + + suspend fun fetchProduct(productId: Long): ProductDetail } diff --git a/app/src/main/java/woowacourse/shopping/domain/usecase/AddSearchHistoryUseCase.kt b/app/src/main/java/woowacourse/shopping/domain/usecase/AddSearchHistoryUseCase.kt index f9c835e4e0..88ccc2d22f 100644 --- a/app/src/main/java/woowacourse/shopping/domain/usecase/AddSearchHistoryUseCase.kt +++ b/app/src/main/java/woowacourse/shopping/domain/usecase/AddSearchHistoryUseCase.kt @@ -3,16 +3,16 @@ package woowacourse.shopping.domain.usecase import woowacourse.shopping.domain.model.ProductDetail import woowacourse.shopping.domain.model.ProductDetail.Companion.EMPTY_PRODUCT_DETAIL import woowacourse.shopping.domain.repository.HistoryRepository -import kotlin.concurrent.thread class AddSearchHistoryUseCase( private val repository: HistoryRepository, ) { - operator fun invoke(productDetail: ProductDetail) { - thread { - if (productDetail == EMPTY_PRODUCT_DETAIL) return@thread - repository.addHistoryWithLimit(productDetail, MAX_HISTORY_COUNT) + suspend operator fun invoke(productDetail: ProductDetail) { + if (productDetail == EMPTY_PRODUCT_DETAIL) { + throw IllegalArgumentException("[AddSearchHistoryUseCase] 유효하지 않은 상품입니다.") } + + repository.addHistoryWithLimit(productDetail, MAX_HISTORY_COUNT) } companion object { diff --git a/app/src/main/java/woowacourse/shopping/domain/usecase/DecreaseCartProductQuantityUseCase.kt b/app/src/main/java/woowacourse/shopping/domain/usecase/DecreaseCartProductQuantityUseCase.kt index ac8ac0381b..f3b9b88184 100644 --- a/app/src/main/java/woowacourse/shopping/domain/usecase/DecreaseCartProductQuantityUseCase.kt +++ b/app/src/main/java/woowacourse/shopping/domain/usecase/DecreaseCartProductQuantityUseCase.kt @@ -7,47 +7,20 @@ import woowacourse.shopping.domain.repository.CartRepository class DecreaseCartProductQuantityUseCase( private val repository: CartRepository, ) { - operator fun invoke( + suspend operator fun invoke( product: Product, step: Int = DEFAULT_QUANTITY_STEP, - callback: (quantity: Result) -> Unit = {}, - ) { - if (product.cartId == null) return + ): Int { + val cartId = product.cartId ?: throw IllegalArgumentException("[DecreaseCartProductQuantityUseCase] 유효하지 않은 상품") + val newQuantity = (product.quantity - step).coerceAtLeast(MINIMUM_QUANTITY) - if (newQuantity <= MINIMUM_QUANTITY) { - deleteCartProduct(product.cartId, callback) + return if (newQuantity <= MINIMUM_QUANTITY) { + repository.deleteCartProduct(cartId) + MINIMUM_QUANTITY } else { - updateCartProduct(product.cartId, newQuantity, callback) - } - } - - private fun deleteCartProduct( - cartId: Long, - callback: (Result) -> Unit, - ) { - repository.deleteCartProduct(cartId) { result -> - result - .onSuccess { - callback(Result.success(MINIMUM_QUANTITY)) - }.onFailure { - callback(Result.failure(it)) - } - } - } - - private fun updateCartProduct( - cartId: Long, - newQuantity: Int, - callback: (Result) -> Unit, - ) { - repository.updateCartProduct(cartId, newQuantity) { result -> - result - .onSuccess { - callback(Result.success(newQuantity)) - }.onFailure { - callback(Result.failure(it)) - } + repository.updateCartProduct(cartId, newQuantity) + newQuantity } } diff --git a/app/src/main/java/woowacourse/shopping/domain/usecase/GetCartProductsQuantityUseCase.kt b/app/src/main/java/woowacourse/shopping/domain/usecase/GetCartProductsQuantityUseCase.kt index e00ad52fe1..c7df8c44e8 100644 --- a/app/src/main/java/woowacourse/shopping/domain/usecase/GetCartProductsQuantityUseCase.kt +++ b/app/src/main/java/woowacourse/shopping/domain/usecase/GetCartProductsQuantityUseCase.kt @@ -5,9 +5,5 @@ import woowacourse.shopping.domain.repository.CartRepository class GetCartProductsQuantityUseCase( private val repository: CartRepository, ) { - operator fun invoke(callback: (quantity: Result) -> Unit) { - repository.fetchCartItemCount { result -> - callback(result) - } - } + suspend operator fun invoke(): Int = repository.fetchCartItemCount() } diff --git a/app/src/main/java/woowacourse/shopping/domain/usecase/GetCartProductsUseCase.kt b/app/src/main/java/woowacourse/shopping/domain/usecase/GetCartProductsUseCase.kt index f7ebd06c0f..4075e8f72d 100644 --- a/app/src/main/java/woowacourse/shopping/domain/usecase/GetCartProductsUseCase.kt +++ b/app/src/main/java/woowacourse/shopping/domain/usecase/GetCartProductsUseCase.kt @@ -6,13 +6,8 @@ import woowacourse.shopping.domain.repository.CartRepository class GetCartProductsUseCase( private val repository: CartRepository, ) { - operator fun invoke( + suspend operator fun invoke( page: Int, size: Int, - callback: (products: Result) -> Unit, - ) { - repository.fetchCartProducts(page, size) { result -> - callback(result) - } - } + ): Products = repository.fetchCartProducts(page, size) } diff --git a/app/src/main/java/woowacourse/shopping/domain/usecase/GetCartRecommendProductsUseCase.kt b/app/src/main/java/woowacourse/shopping/domain/usecase/GetCartRecommendProductsUseCase.kt index 831a05b7b9..60db817a5f 100644 --- a/app/src/main/java/woowacourse/shopping/domain/usecase/GetCartRecommendProductsUseCase.kt +++ b/app/src/main/java/woowacourse/shopping/domain/usecase/GetCartRecommendProductsUseCase.kt @@ -1,8 +1,6 @@ package woowacourse.shopping.domain.usecase -import woowacourse.shopping.domain.model.HistoryProduct import woowacourse.shopping.domain.model.Products -import woowacourse.shopping.domain.model.Products.Companion.EMPTY_PRODUCTS import woowacourse.shopping.domain.repository.CartRepository import woowacourse.shopping.domain.repository.HistoryRepository import woowacourse.shopping.domain.repository.ProductRepository @@ -12,48 +10,34 @@ class GetCartRecommendProductsUseCase( private val cartRepository: CartRepository, private val historyRepository: HistoryRepository, ) { - operator fun invoke(callback: (products: Result) -> Unit) { - historyRepository.fetchRecentHistory { historyProduct -> - if (historyProduct == null) { - callback(Result.success(EMPTY_PRODUCTS)) - } else { - filterProductsByCategory(historyProduct, callback) - } - } + suspend operator fun invoke(): Products { + val recent = historyRepository.fetchRecentHistory() + return filterProductsByCategory(recent?.category) } - private fun filterProductsByCategory( - historyProduct: HistoryProduct, - callback: (products: Result) -> Unit, - ) { - productRepository.fetchProducts(0, Int.MAX_VALUE, historyProduct.category) { result -> - result - .onSuccess { catalogProducts -> - combineCartProducts(catalogProducts, callback) - }.onFailure { - callback(Result.failure(it)) - } - } + private suspend fun filterProductsByCategory(category: String?): Products { + val products = + productRepository.fetchProducts( + page = 0, + size = Int.MAX_VALUE, + category = category, + ) + return combineCartProducts(products) } - private fun combineCartProducts( - catalogProducts: Products, - callback: (products: Result) -> Unit, - ) { - cartRepository.fetchAllCartProducts { result -> - result - .onSuccess { cartProducts -> - val cartProductIds = cartProducts.products.map { product -> product.productDetail.id } - val filteredProducts = - catalogProducts.products - .filterNot { product -> - product.productDetail.id in cartProductIds - }.take(10) + private suspend fun combineCartProducts(catalogProducts: Products): Products { + val cartProducts = cartRepository.fetchAllCartProducts() + val cartProductIds = cartProducts.products.map { it.productDetail.id } - callback(Result.success(Products(filteredProducts))) - }.onFailure { - callback(Result.failure(it)) - } - } + val filteredProducts = + catalogProducts.products + .filterNot { it.productDetail.id in cartProductIds } + .take(RECOMMEND_PRODUCT_COUNT) + + return Products(filteredProducts) + } + + companion object { + private const val RECOMMEND_PRODUCT_COUNT = 10 } } diff --git a/app/src/main/java/woowacourse/shopping/domain/usecase/GetCatalogProductUseCase.kt b/app/src/main/java/woowacourse/shopping/domain/usecase/GetCatalogProductUseCase.kt index b7de810776..7e80b1f183 100644 --- a/app/src/main/java/woowacourse/shopping/domain/usecase/GetCatalogProductUseCase.kt +++ b/app/src/main/java/woowacourse/shopping/domain/usecase/GetCatalogProductUseCase.kt @@ -8,51 +8,31 @@ class GetCatalogProductUseCase( private val productRepository: ProductRepository, private val cartRepository: CartRepository, ) { - operator fun invoke( - productId: Long, - callback: (product: Result) -> Unit, - ) { - productRepository.fetchAllProducts { result -> - result - .onSuccess { catalogProducts -> - catalogProducts.find { product -> product.productDetail.id == productId }?.let { catalogProduct -> - combineCartProduct(productId, catalogProduct, callback) - } ?: return@fetchAllProducts callback(Result.success(null)) - }.onFailure { - callback(Result.failure(it)) - } - } + suspend operator fun invoke(productId: Long): Product { + val productDetail = productRepository.fetchProduct(productId) + val product = Product(productDetail) + return combineCartProduct(productId, product) } - private fun combineCartProduct( + private suspend fun combineCartProduct( productId: Long, catalogProduct: Product, - callback: (product: Result) -> Unit, - ) { - cartRepository.fetchAllCartProducts { result -> - result - .onSuccess { cartProducts -> - val cartProductsByProductId: Map = - cartProducts.products.associateBy { product -> - product.productDetail.id - } - val updatedProduct = getUpdatedProduct(cartProductsByProductId, productId, catalogProduct) + ): Product { + val cartProducts = cartRepository.fetchAllCartProducts() + val cartProductsByProductId = cartProducts.products.associateBy { it.productDetail.id } - callback(Result.success(updatedProduct)) - }.onFailure { - callback(Result.failure(it)) - } - } + return updateProduct(cartProductsByProductId, productId, catalogProduct) } - private fun getUpdatedProduct( + private fun updateProduct( cartProducts: Map, productId: Long, catalogProduct: Product, - ) = cartProducts[productId]?.let { cartProduct -> - catalogProduct.copy( - cartId = cartProduct.cartId, - quantity = cartProduct.quantity, - ) - } ?: catalogProduct + ): Product = + cartProducts[productId]?.let { cartProduct -> + catalogProduct.copy( + cartId = cartProduct.cartId, + quantity = cartProduct.quantity, + ) + } ?: catalogProduct } diff --git a/app/src/main/java/woowacourse/shopping/domain/usecase/GetCatalogProductsByIdsUseCase.kt b/app/src/main/java/woowacourse/shopping/domain/usecase/GetCatalogProductsByIdsUseCase.kt deleted file mode 100644 index 785fed8118..0000000000 --- a/app/src/main/java/woowacourse/shopping/domain/usecase/GetCatalogProductsByIdsUseCase.kt +++ /dev/null @@ -1,61 +0,0 @@ -package woowacourse.shopping.domain.usecase - -import woowacourse.shopping.domain.model.Product -import woowacourse.shopping.domain.repository.CartRepository -import woowacourse.shopping.domain.repository.ProductRepository - -class GetCatalogProductsByIdsUseCase( - private val productRepository: ProductRepository, - private val cartRepository: CartRepository, -) { - operator fun invoke( - productIds: List, - callback: (products: Result>) -> Unit, - ) { - productRepository.fetchAllProducts { result -> - result - .onSuccess { catalogProducts -> - val filteredCatalogProducts: List = catalogProducts.filter { it.productDetail.id in productIds } - combineCartProducts(filteredCatalogProducts, callback) - }.onFailure { - callback(Result.failure(it)) - } - } - } - - private fun combineCartProducts( - filteredCatalogProducts: List, - callback: (products: Result>) -> Unit, - ) { - cartRepository.fetchAllCartProducts { result -> - result - .onSuccess { cartProducts -> - val cartProductsByProductId = - cartProducts.products.associateBy { product -> - product.productDetail.id - } - val updatedCartProducts = getUpdatedCartProducts(filteredCatalogProducts, cartProductsByProductId) - - callback(Result.success(updatedCartProducts)) - }.onFailure { - callback(Result.failure(it)) - return@fetchAllCartProducts - } - } - } - - private fun getUpdatedCartProducts( - filteredCatalogProducts: List, - cartProducts: Map, - ) = filteredCatalogProducts.map { catalogProduct -> - val cartProduct = cartProducts[catalogProduct.productDetail.id] - if (cartProduct != null) { - catalogProduct.copy( - cartId = cartProduct.cartId, - quantity = cartProduct.quantity, - ) - } else { - catalogProduct - } - } -} diff --git a/app/src/main/java/woowacourse/shopping/domain/usecase/GetCatalogProductsByProductIdsUseCase.kt b/app/src/main/java/woowacourse/shopping/domain/usecase/GetCatalogProductsByProductIdsUseCase.kt new file mode 100644 index 0000000000..7c93d10892 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/domain/usecase/GetCatalogProductsByProductIdsUseCase.kt @@ -0,0 +1,44 @@ +package woowacourse.shopping.domain.usecase + +import woowacourse.shopping.domain.model.Product +import woowacourse.shopping.domain.model.Products +import woowacourse.shopping.domain.repository.CartRepository +import woowacourse.shopping.domain.repository.ProductRepository + +class GetCatalogProductsByProductIdsUseCase( + private val productRepository: ProductRepository, + private val cartRepository: CartRepository, +) { + suspend operator fun invoke(productIds: List): List { + val products = + productIds.map { id -> + val productDetail = productRepository.fetchProduct(id) + Product(productDetail) + } + + return combineCartProducts(products) + } + + private suspend fun combineCartProducts(catalogProducts: List): List { + val cartProducts: Products = cartRepository.fetchAllCartProducts() + val cartProductsByProductId = cartProducts.products.associateBy { it.productDetail.id } + + return updateProducts(catalogProducts, cartProductsByProductId) + } + + private fun updateProducts( + catalogProducts: List, + cartProducts: Map, + ): List = + catalogProducts.map { catalogProduct -> + val cartProduct = cartProducts[catalogProduct.productDetail.id] + if (cartProduct != null) { + catalogProduct.copy( + cartId = cartProduct.cartId, + quantity = cartProduct.quantity, + ) + } else { + catalogProduct + } + } +} diff --git a/app/src/main/java/woowacourse/shopping/domain/usecase/GetCatalogProductsUseCase.kt b/app/src/main/java/woowacourse/shopping/domain/usecase/GetCatalogProductsUseCase.kt index eccb8dd530..4c0310c5af 100644 --- a/app/src/main/java/woowacourse/shopping/domain/usecase/GetCatalogProductsUseCase.kt +++ b/app/src/main/java/woowacourse/shopping/domain/usecase/GetCatalogProductsUseCase.kt @@ -9,53 +9,33 @@ class GetCatalogProductsUseCase( private val productRepository: ProductRepository, private val cartRepository: CartRepository, ) { - operator fun invoke( + suspend operator fun invoke( page: Int, size: Int, - callback: (products: Result) -> Unit, - ) { - productRepository.fetchProducts(page, size) { result -> - result - .onSuccess { catalogProducts -> - combineCartProducts(catalogProducts, callback) - }.onFailure { - callback(Result.failure(it)) - } - } + ): Products { + val catalogProducts = productRepository.fetchProducts(page, size) + return combineCartProducts(catalogProducts) } - private fun combineCartProducts( - catalogProducts: Products, - callback: (products: Result) -> Unit, - ) { - cartRepository.fetchAllCartProducts { result -> - result - .onSuccess { cartProducts -> - val cartProductsByProductId = - cartProducts.products.associateBy { product -> - product.productDetail.id - } - val updatedProducts = getUpdatedProducts(catalogProducts, cartProductsByProductId) + private suspend fun combineCartProducts(catalogProducts: Products): Products { + val cartProducts = cartRepository.fetchAllCartProducts() + val cartProductsByProductId = cartProducts.products.associateBy { it.productDetail.id } - callback(Result.success(catalogProducts.copy(products = updatedProducts))) - }.onFailure { - callback(Result.failure(it)) - } - } + val updatedProducts = updateProducts(catalogProducts, cartProductsByProductId) + + return catalogProducts.copy(products = updatedProducts) } - private fun getUpdatedProducts( + private fun updateProducts( catalogProducts: Products, cartProducts: Map, - ) = catalogProducts.products.map { catalogProduct -> - val cartProduct = cartProducts[catalogProduct.productDetail.id] - if (cartProduct != null) { - catalogProduct.copy( - cartId = cartProduct.cartId, - quantity = cartProduct.quantity, - ) - } else { - catalogProduct + ): List = + catalogProducts.products.map { catalogProduct -> + cartProducts[catalogProduct.productDetail.id]?.let { cartProduct -> + catalogProduct.copy( + cartId = cartProduct.cartId, + quantity = cartProduct.quantity, + ) + } ?: catalogProduct } - } } diff --git a/app/src/main/java/woowacourse/shopping/domain/usecase/GetCouponsUseCase.kt b/app/src/main/java/woowacourse/shopping/domain/usecase/GetCouponsUseCase.kt new file mode 100644 index 0000000000..237db002fd --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/domain/usecase/GetCouponsUseCase.kt @@ -0,0 +1,13 @@ +package woowacourse.shopping.domain.usecase + +import woowacourse.shopping.domain.model.Coupons +import woowacourse.shopping.domain.repository.CouponRepository + +class GetCouponsUseCase( + private val repository: CouponRepository, +) { + suspend operator fun invoke(): Coupons { + val coupons = repository.fetchAllCoupons() + return Coupons(coupons) + } +} diff --git a/app/src/main/java/woowacourse/shopping/domain/usecase/GetRecentSearchHistoryUseCase.kt b/app/src/main/java/woowacourse/shopping/domain/usecase/GetRecentSearchHistoryUseCase.kt index 39d4d2870d..4515c4f1b3 100644 --- a/app/src/main/java/woowacourse/shopping/domain/usecase/GetRecentSearchHistoryUseCase.kt +++ b/app/src/main/java/woowacourse/shopping/domain/usecase/GetRecentSearchHistoryUseCase.kt @@ -6,9 +6,5 @@ import woowacourse.shopping.domain.repository.HistoryRepository class GetRecentSearchHistoryUseCase( private val repository: HistoryRepository, ) { - operator fun invoke(callback: (product: HistoryProduct?) -> Unit) { - repository.fetchRecentHistory { historyProduct -> - callback(historyProduct) - } - } + suspend operator fun invoke(): HistoryProduct? = repository.fetchRecentHistory() } diff --git a/app/src/main/java/woowacourse/shopping/domain/usecase/GetSearchHistoryUseCase.kt b/app/src/main/java/woowacourse/shopping/domain/usecase/GetSearchHistoryUseCase.kt index 28ab209aaa..f11a5be95f 100644 --- a/app/src/main/java/woowacourse/shopping/domain/usecase/GetSearchHistoryUseCase.kt +++ b/app/src/main/java/woowacourse/shopping/domain/usecase/GetSearchHistoryUseCase.kt @@ -6,9 +6,5 @@ import woowacourse.shopping.domain.repository.HistoryRepository class GetSearchHistoryUseCase( private val repository: HistoryRepository, ) { - operator fun invoke(callback: (products: List) -> Unit) { - repository.fetchAllHistory { historyProducts -> - callback(historyProducts) - } - } + suspend operator fun invoke(): List = repository.fetchAllHistory() } diff --git a/app/src/main/java/woowacourse/shopping/domain/usecase/IncreaseCartProductQuantityUseCase.kt b/app/src/main/java/woowacourse/shopping/domain/usecase/IncreaseCartProductQuantityUseCase.kt index 54d562c572..a1bb4abff2 100644 --- a/app/src/main/java/woowacourse/shopping/domain/usecase/IncreaseCartProductQuantityUseCase.kt +++ b/app/src/main/java/woowacourse/shopping/domain/usecase/IncreaseCartProductQuantityUseCase.kt @@ -6,48 +6,33 @@ import woowacourse.shopping.domain.repository.CartRepository class IncreaseCartProductQuantityUseCase( private val repository: CartRepository, ) { - operator fun invoke( + suspend operator fun invoke( product: Product, step: Int = DEFAULT_QUANTITY_STEP, - callback: (quantity: Result) -> Unit = {}, - ) { + ): Int { val newQuantity = product.quantity + step if (product.cartId == null) { - addCartProduct(product, newQuantity, callback) + addCartProduct(product, newQuantity) } else { - updateCartProduct(product.cartId, newQuantity, callback) + updateCartProduct(product.cartId, newQuantity) } + + return newQuantity } - private fun addCartProduct( + private suspend fun addCartProduct( product: Product, newQuantity: Int, - callback: (quantity: Result) -> Unit, ) { - repository.addCartProduct(product.productDetail.id, newQuantity) { result -> - result - .onSuccess { - callback(Result.success(newQuantity)) - }.onFailure { - callback(Result.failure(it)) - } - } + repository.addCartProduct(product.productDetail.id, newQuantity) } - private fun updateCartProduct( + private suspend fun updateCartProduct( cartId: Long, newQuantity: Int, - callback: (quantity: Result) -> Unit, ) { - repository.updateCartProduct(cartId, newQuantity) { result -> - result - .onSuccess { - callback(Result.success(newQuantity)) - }.onFailure { - callback(Result.failure(it)) - } - } + repository.updateCartProduct(cartId, newQuantity) } companion object { diff --git a/app/src/main/java/woowacourse/shopping/domain/usecase/OrderProductsUseCase.kt b/app/src/main/java/woowacourse/shopping/domain/usecase/OrderProductsUseCase.kt index 4cbf6f64f9..7b54fb5f86 100644 --- a/app/src/main/java/woowacourse/shopping/domain/usecase/OrderProductsUseCase.kt +++ b/app/src/main/java/woowacourse/shopping/domain/usecase/OrderProductsUseCase.kt @@ -1,78 +1,9 @@ package woowacourse.shopping.domain.usecase -import woowacourse.shopping.domain.model.Product -import woowacourse.shopping.domain.repository.CartRepository import woowacourse.shopping.domain.repository.OrderRepository -import woowacourse.shopping.domain.repository.ProductRepository class OrderProductsUseCase( - private val productRepository: ProductRepository, - private val cartRepository: CartRepository, private val orderRepository: OrderRepository, ) { - operator fun invoke( - productIds: Set, - callback: (Result) -> Unit = {}, - ) { - getCatalogProductsByIds(productIds) { result -> - result - .onSuccess { catalogProducts -> - val cartIds = catalogProducts.mapNotNull { it.cartId } - orderRepository.postOrderProducts(cartIds, callback) - }.onFailure { - callback(Result.failure(it)) - } - } - } - - private fun getCatalogProductsByIds( - productIds: Set, - callback: (products: Result>) -> Unit, - ) { - productRepository.fetchAllProducts { result -> - result - .onSuccess { catalogProducts -> - val filteredCatalogProducts: List = catalogProducts.filter { it.productDetail.id in productIds } - combineCartProducts(filteredCatalogProducts, callback) - }.onFailure { - callback(Result.failure(it)) - } - } - } - - private fun combineCartProducts( - filteredCatalogProducts: List, - callback: (products: Result>) -> Unit, - ) { - cartRepository.fetchAllCartProducts { result -> - result - .onSuccess { cartProducts -> - val cartProductsByProductId = - cartProducts.products.associateBy { product -> - product.productDetail.id - } - val updatedCartProducts = getUpdatedCartProducts(filteredCatalogProducts, cartProductsByProductId) - - callback(Result.success(updatedCartProducts)) - }.onFailure { - callback(Result.failure(it)) - return@fetchAllCartProducts - } - } - } - - private fun getUpdatedCartProducts( - filteredCatalogProducts: List, - cartProducts: Map, - ) = filteredCatalogProducts.map { catalogProduct -> - val cartProduct = cartProducts[catalogProduct.productDetail.id] - if (cartProduct != null) { - catalogProduct.copy( - cartId = cartProduct.cartId, - quantity = cartProduct.quantity, - ) - } else { - catalogProduct - } - } + suspend operator fun invoke(cartIds: Set): Unit = orderRepository.postOrderProducts(cartIds.toList()) } diff --git a/app/src/main/java/woowacourse/shopping/domain/usecase/RemoveCartProductUseCase.kt b/app/src/main/java/woowacourse/shopping/domain/usecase/RemoveCartProductUseCase.kt index 73672059a8..3c40bba8f2 100644 --- a/app/src/main/java/woowacourse/shopping/domain/usecase/RemoveCartProductUseCase.kt +++ b/app/src/main/java/woowacourse/shopping/domain/usecase/RemoveCartProductUseCase.kt @@ -5,12 +5,5 @@ import woowacourse.shopping.domain.repository.CartRepository class RemoveCartProductUseCase( private val repository: CartRepository, ) { - operator fun invoke( - cartId: Long, - callback: (Result) -> Unit, - ) { - repository.deleteCartProduct(cartId) { result -> - callback(result) - } - } + suspend operator fun invoke(cartId: Long): Unit = repository.deleteCartProduct(cartId) } diff --git a/app/src/main/java/woowacourse/shopping/domain/usecase/UpdateCartProductUseCase.kt b/app/src/main/java/woowacourse/shopping/domain/usecase/UpdateCartProductUseCase.kt index 386c12aa19..113d2427ec 100644 --- a/app/src/main/java/woowacourse/shopping/domain/usecase/UpdateCartProductUseCase.kt +++ b/app/src/main/java/woowacourse/shopping/domain/usecase/UpdateCartProductUseCase.kt @@ -5,20 +5,14 @@ import woowacourse.shopping.domain.repository.CartRepository class UpdateCartProductUseCase( private val repository: CartRepository, ) { - operator fun invoke( + suspend operator fun invoke( productId: Long, cartId: Long?, quantity: Int, - callback: (Result) -> Unit, - ) { + ): Unit = if (cartId == null) { - repository.addCartProduct(productId, quantity) { result -> - callback(result) - } + repository.addCartProduct(productId, quantity) } else { - repository.updateCartProduct(cartId, quantity) { result -> - callback(result) - } + repository.updateCartProduct(cartId, quantity) } - } } diff --git a/app/src/main/java/woowacourse/shopping/ui/cart/CartActivity.kt b/app/src/main/java/woowacourse/shopping/ui/cart/CartActivity.kt index 7197dc16ad..7d4d0197e7 100644 --- a/app/src/main/java/woowacourse/shopping/ui/cart/CartActivity.kt +++ b/app/src/main/java/woowacourse/shopping/ui/cart/CartActivity.kt @@ -13,6 +13,8 @@ import woowacourse.shopping.databinding.ActivityCartBinding import woowacourse.shopping.ui.cart.CartActivity.OnClickHandler import woowacourse.shopping.ui.common.DataBindingActivity import woowacourse.shopping.ui.model.ActivityResult +import woowacourse.shopping.ui.model.CartProductUiModel +import woowacourse.shopping.ui.payment.PaymentActivity class CartActivity : DataBindingActivity(R.layout.activity_cart) { private val viewModel: CartViewModel by viewModels { CartViewModel.Factory } @@ -61,32 +63,37 @@ class CartActivity : DataBindingActivity(R.layout.activity_ binding.cartCheckAllButtonText.visibility = View.GONE } - is CartRecommendFragment -> viewModel.orderProducts() + is CartRecommendFragment -> { + val intent = PaymentActivity.newIntent(this, viewModel.getSelectedProductIds().toLongArray()) + startActivity(intent) + finish() + } } } } private fun initObservers() { - viewModel.editedProductIds.observe(this) { editedProductIds -> - setResult( - ActivityResult.CART_PRODUCT_EDITED.code, - Intent().apply { - putExtra( - ActivityResult.CART_PRODUCT_EDITED.key, - editedProductIds.toLongArray(), - ) - }, - ) + viewModel.uiModel.observe(this) { uiModel -> + handleCartProducts(uiModel) + handleErrorMessage(uiModel) } + } - viewModel.isOrdered.observe(this) { - finish() - } + private fun handleCartProducts(uiModel: CartProductUiModel) { + setResult( + ActivityResult.CART_PRODUCT_EDITED.code, + Intent().apply { + putExtra( + ActivityResult.CART_PRODUCT_EDITED.key, + uiModel.editedProductIds.toLongArray(), + ) + }, + ) + } - viewModel.isError.observe(this) { errorMessage -> - errorMessage?.let { - Snackbar.make(binding.root, it, Snackbar.LENGTH_SHORT).show() - } + private fun handleErrorMessage(uiModel: CartProductUiModel) { + uiModel.connectionErrorMessage?.let { + Snackbar.make(binding.root, it, Snackbar.LENGTH_SHORT).show() } } diff --git a/app/src/main/java/woowacourse/shopping/ui/cart/CartProductFragment.kt b/app/src/main/java/woowacourse/shopping/ui/cart/CartProductFragment.kt index fa90023f4b..ea06b73871 100644 --- a/app/src/main/java/woowacourse/shopping/ui/cart/CartProductFragment.kt +++ b/app/src/main/java/woowacourse/shopping/ui/cart/CartProductFragment.kt @@ -32,9 +32,8 @@ class CartProductFragment : DataBindingFragment(R.la } private fun initObservers() { - viewModel.cartProducts.observe(requireActivity()) { products -> - cartProductAdapter.submitItems(products.products) - viewModel.updateOrderInfo() + viewModel.uiModel.observe(requireActivity()) { uiModel -> + cartProductAdapter.submitItems(uiModel.cartProducts.products) } } diff --git a/app/src/main/java/woowacourse/shopping/ui/cart/CartRecommendFragment.kt b/app/src/main/java/woowacourse/shopping/ui/cart/CartRecommendFragment.kt index dc59fbb513..3579ff3db8 100644 --- a/app/src/main/java/woowacourse/shopping/ui/cart/CartRecommendFragment.kt +++ b/app/src/main/java/woowacourse/shopping/ui/cart/CartRecommendFragment.kt @@ -24,9 +24,8 @@ class CartRecommendFragment : DataBindingFragment( ) { super.onViewCreated(view, savedInstanceState) binding.cartItemsRecommendContainer.adapter = adapter - viewModel.recommendedProducts.observe(requireActivity()) { recommendedProducts -> - adapter.submitList(recommendedProducts.products) - viewModel.updateOrderInfo() + viewModel.uiModel.observe(requireActivity()) { uiModel -> + adapter.submitList(uiModel.recommendedProducts.products) } } diff --git a/app/src/main/java/woowacourse/shopping/ui/cart/CartViewModel.kt b/app/src/main/java/woowacourse/shopping/ui/cart/CartViewModel.kt index 8a275d30bf..c65c95430a 100644 --- a/app/src/main/java/woowacourse/shopping/ui/cart/CartViewModel.kt +++ b/app/src/main/java/woowacourse/shopping/ui/cart/CartViewModel.kt @@ -5,26 +5,26 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.CreationExtras -import woowacourse.shopping.di.UseCaseModule.decreaseCartProductQuantityUseCase -import woowacourse.shopping.di.UseCaseModule.getCartProductsUseCase -import woowacourse.shopping.di.UseCaseModule.getCartRecommendProductsUseCase -import woowacourse.shopping.di.UseCaseModule.increaseCartProductQuantityUseCase -import woowacourse.shopping.di.UseCaseModule.orderProductsUseCase -import woowacourse.shopping.di.UseCaseModule.removeCartProductUseCase +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.launch +import woowacourse.shopping.di.UseCaseInjection.decreaseCartProductQuantityUseCase +import woowacourse.shopping.di.UseCaseInjection.getCartProductsUseCase +import woowacourse.shopping.di.UseCaseInjection.getCartRecommendProductsUseCase +import woowacourse.shopping.di.UseCaseInjection.getCatalogProductUseCase +import woowacourse.shopping.di.UseCaseInjection.increaseCartProductQuantityUseCase +import woowacourse.shopping.di.UseCaseInjection.removeCartProductUseCase import woowacourse.shopping.domain.model.Page import woowacourse.shopping.domain.model.Page.Companion.EMPTY_PAGE import woowacourse.shopping.domain.model.Product.Companion.MINIMUM_QUANTITY -import woowacourse.shopping.domain.model.Products -import woowacourse.shopping.domain.model.Products.Companion.EMPTY_PRODUCTS import woowacourse.shopping.domain.usecase.DecreaseCartProductQuantityUseCase import woowacourse.shopping.domain.usecase.GetCartProductsUseCase import woowacourse.shopping.domain.usecase.GetCartRecommendProductsUseCase +import woowacourse.shopping.domain.usecase.GetCatalogProductUseCase import woowacourse.shopping.domain.usecase.IncreaseCartProductQuantityUseCase -import woowacourse.shopping.domain.usecase.OrderProductsUseCase import woowacourse.shopping.domain.usecase.RemoveCartProductUseCase -import woowacourse.shopping.util.MutableSingleLiveData -import woowacourse.shopping.util.SingleLiveData +import woowacourse.shopping.ui.model.CartProductUiModel class CartViewModel( private val getCartProductsUseCase: GetCartProductsUseCase, @@ -32,46 +32,38 @@ class CartViewModel( private val increaseCartProductQuantityUseCase: IncreaseCartProductQuantityUseCase, private val decreaseCartProductQuantityUseCase: DecreaseCartProductQuantityUseCase, private val getCartRecommendProductsUseCase: GetCartRecommendProductsUseCase, - private val orderProductsUseCase: OrderProductsUseCase, + private val getCatalogProductUseCase: GetCatalogProductUseCase, ) : ViewModel() { - private val _cartProducts: MutableLiveData = MutableLiveData(EMPTY_PRODUCTS) - val cartProducts: LiveData get() = _cartProducts - - private val _recommendedProducts: MutableLiveData = MutableLiveData(EMPTY_PRODUCTS) - val recommendedProducts: LiveData get() = _recommendedProducts - - private val _editedProductIds: MutableLiveData> = MutableLiveData(emptySet()) - val editedProductIds: LiveData> get() = _editedProductIds - - private val _totalOrderPrice: MutableLiveData = MutableLiveData(0) - val totalOrderPrice: LiveData get() = _totalOrderPrice - - private val _isOrdered: MutableSingleLiveData = MutableSingleLiveData() - val isOrdered: SingleLiveData get() = _isOrdered - - private val _isLoading: MutableLiveData = MutableLiveData(true) - val isLoading: LiveData get() = _isLoading - - private val _isError: MutableLiveData = MutableLiveData() - val isError: LiveData get() = _isError + private val _uiModel: MutableLiveData = MutableLiveData(CartProductUiModel()) + val uiModel: LiveData get() = _uiModel init { loadCartProducts() } - private fun loadCartProducts(page: Page = cartProducts.value?.page ?: EMPTY_PAGE) { - _isLoading.value = true - getCartProductsUseCase( - page = page.current, - size = DEFAULT_PAGE_SIZE, - ) { result -> - result - .onSuccess { cartProducts -> - _cartProducts.postValue(cartProducts) - _isLoading.postValue(false) - }.onFailure { - _isError.postValue(it.message) - } + private fun loadCartProducts(page: Page = uiModel.value?.cartProducts?.page ?: EMPTY_PAGE) { + updateUiModel { current -> + current.copy(isProductsLoading = true) + } + + viewModelScope.launch( + CoroutineExceptionHandler { _, e -> + updateUiModel { current -> current.copy(connectionErrorMessage = e.message.toString()) } + Log.e(TAG, e.message.toString()) + }, + ) { + val products = + getCartProductsUseCase( + page = page.current, + size = DEFAULT_PAGE_SIZE, + ) + + updateUiModel { current -> + current.copy( + cartProducts = products, + isProductsLoading = false, + ) + } } } @@ -79,158 +71,185 @@ class CartViewModel( cartId: Long, productId: Long, ) { - removeCartProductUseCase(cartId) { result -> - result - .onSuccess { - _editedProductIds.postValue(editedProductIds.value?.plus(productId)) - loadCartProducts() - }.onFailure { - _isError.postValue(it.message) - } + viewModelScope.launch( + CoroutineExceptionHandler { _, e -> + updateUiModel { current -> current.copy(connectionErrorMessage = e.message.toString()) } + Log.e(TAG, e.message.toString()) + }, + ) { + removeCartProductUseCase(cartId) + updateUiModel { current -> + current.copy(editedProductIds = current.editedProductIds.plus(productId)) + } + loadCartProducts() } } fun increasePage(step: Int = DEFAULT_PAGE_STEP) { - val page = cartProducts.value?.page ?: EMPTY_PAGE + val page = uiModel.value?.cartProducts?.page ?: EMPTY_PAGE loadCartProducts(page.copy(current = page.current + step)) } fun decreasePage(step: Int = DEFAULT_PAGE_STEP) { - val page = cartProducts.value?.page ?: EMPTY_PAGE + val page = uiModel.value?.cartProducts?.page ?: EMPTY_PAGE loadCartProducts(page.copy(current = page.current - step)) } fun increaseCartProductQuantity(productId: Long) { - increaseCartProductQuantityUseCase( - product = cartProducts.value?.getProductByProductId(productId) ?: return, - ) { result -> - result - .onSuccess { newQuantity -> - _cartProducts.postValue( - cartProducts.value?.updateProductQuantity( - productId, - newQuantity, - ), - ) - _editedProductIds.value = editedProductIds.value?.plus(productId) - }.onFailure { - Log.e("CartViewModel", it.message.toString()) - } + viewModelScope.launch( + CoroutineExceptionHandler { _, e -> + Log.e(TAG, e.message.toString()) + }, + ) { + val product = uiModel.value?.cartProducts?.getProductByProductId(productId) ?: return@launch + val newQuantity = increaseCartProductQuantityUseCase(product) + + updateUiModel { current -> + current.copy( + cartProducts = current.cartProducts.updateQuantity(product, newQuantity), + editedProductIds = current.editedProductIds.plus(productId), + isProductsLoading = false, + ) + } } } fun decreaseCartProductQuantity(productId: Long) { - decreaseCartProductQuantityUseCase( - product = cartProducts.value?.getProductByProductId(productId) ?: return, - ) { result -> - result - .onSuccess { newQuantity -> - if (newQuantity > MINIMUM_QUANTITY) { - _cartProducts.postValue( - cartProducts.value?.updateProductQuantity( - productId, - newQuantity, - ), + viewModelScope.launch( + CoroutineExceptionHandler { _, e -> + Log.e(TAG, e.message.toString()) + }, + ) { + val product = uiModel.value?.cartProducts?.getProductByProductId(productId) ?: return@launch + val newQuantity = decreaseCartProductQuantityUseCase(product) + + when (newQuantity > MINIMUM_QUANTITY) { + true -> { + updateUiModel { current -> + current.copy( + cartProducts = current.cartProducts.updateQuantity(product, newQuantity), + editedProductIds = current.editedProductIds.plus(productId), ) - } else { - loadCartProducts() } - _editedProductIds.value = editedProductIds.value?.plus(productId) - }.onFailure { - Log.e("CartViewModel", it.message.toString()) } + + false -> loadCartProducts() + } } } fun toggleCartProductSelection(cartId: Long) { - _cartProducts.value = cartProducts.value?.toggleSelectionByCartId(cartId) - } + val cartProduct = uiModel.value?.cartProducts?.getProductByCartId(cartId) ?: return - fun toggleAllCartProductsSelection() { - _cartProducts.value = cartProducts.value?.updateAllSelection() + updateUiModel { current -> + current.copy(cartProducts = current.cartProducts.updateSelection(cartProduct)) + } } - fun updateOrderInfo() { - _totalOrderPrice.value = - (cartProducts.value?.getSelectedCartProductsPrice() ?: 0) + - (recommendedProducts.value?.getSelectedCartRecommendProductsPrice() ?: 0) + fun toggleAllCartProductsSelection() { + updateUiModel { current -> + current.copy( + cartProducts = current.cartProducts.toggleAllSelection(), + ) + } } fun loadRecommendedProducts() { - getCartRecommendProductsUseCase { result -> - result - .onSuccess { products -> - _recommendedProducts.postValue(products) - }.onFailure { - _isError.postValue(it.message) - } + viewModelScope.launch( + CoroutineExceptionHandler { _, e -> + updateUiModel { current -> current.copy(connectionErrorMessage = e.message.toString()) } + Log.e(TAG, e.message.toString()) + }, + ) { + val products = getCartRecommendProductsUseCase() + + updateUiModel { current -> + current.copy(recommendedProducts = products) + } } } fun increaseRecommendedProductQuantity(productId: Long) { - increaseCartProductQuantityUseCase( - product = recommendedProducts.value?.getProductByProductId(productId) ?: return, - ) { result -> - result - .onSuccess { newQuantity -> - _recommendedProducts.postValue( - recommendedProducts.value?.updateProductQuantity( - productId, - newQuantity, - ), - ) - _editedProductIds.value = editedProductIds.value?.plus(productId) - }.onFailure { - Log.e("CartViewModel", it.message.toString()) + viewModelScope.launch( + CoroutineExceptionHandler { _, e -> + Log.e(TAG, e.message.toString()) + }, + ) { + val product = uiModel.value?.recommendedProducts?.getProductByProductId(productId) ?: return@launch + val newQuantity = increaseCartProductQuantityUseCase(product) + + when (product.quantity <= MINIMUM_QUANTITY) { + true -> updateRecommendedProduct(productId) + false -> { + updateUiModel { current -> + current.copy( + recommendedProducts = current.recommendedProducts.updateQuantity(product, newQuantity), + editedProductIds = current.editedProductIds.plus(productId), + ) + } } + } + } + } + + private fun updateRecommendedProduct(productId: Long) { + viewModelScope.launch( + CoroutineExceptionHandler { _, e -> + Log.e(TAG, e.message.toString()) + }, + ) { + val product = getCatalogProductUseCase(productId) + + updateUiModel { current -> + current.copy( + recommendedProducts = current.recommendedProducts.updateProduct(product.copy(isSelected = true)), + ) + } } } fun decreaseRecommendedProductQuantity(productId: Long) { - decreaseCartProductQuantityUseCase( - product = recommendedProducts.value?.getProductByProductId(productId) ?: return, - ) { result -> - result - .onSuccess { newQuantity -> - if (newQuantity > MINIMUM_QUANTITY) { - _recommendedProducts.postValue( - recommendedProducts.value?.updateProductQuantity( - productId, - newQuantity, - ), + viewModelScope.launch( + CoroutineExceptionHandler { _, e -> + Log.e(TAG, e.message.toString()) + }, + ) { + val product = uiModel.value?.recommendedProducts?.getProductByProductId(productId) ?: return@launch + val newQuantity = decreaseCartProductQuantityUseCase(product) + + when (product.quantity <= MINIMUM_QUANTITY) { + true -> updateRecommendedProduct(productId) + false -> { + updateUiModel { current -> + current.copy( + recommendedProducts = current.recommendedProducts.updateQuantity(product, newQuantity), + editedProductIds = current.editedProductIds.plus(productId), ) - } else { - loadCartProducts() } - _editedProductIds.value = editedProductIds.value?.plus(productId) - }.onFailure { - Log.e("CartViewModel", it.message.toString()) } + } } } - fun orderProducts() { - val selectedCartProductsIds: List = cartProducts.value?.getSelectedCartProductIds() ?: emptyList() - val selectedRecommendedProductsIds: List = recommendedProducts.value?.getSelectedCartRecommendProductIds() ?: emptyList() - val selectedProductIds: Set = (selectedCartProductsIds + selectedRecommendedProductsIds).toSet() + fun getSelectedProductIds(): Set { + val uiModel = uiModel.value ?: return emptySet() - if (selectedProductIds.isEmpty()) return + val selectedCartProductsIds: List = uiModel.cartProducts.getSelectedProductIds() + val selectedRecommendedProductsIds: List = uiModel.recommendedProducts.getSelectedProductIds() - orderProductsUseCase.invoke(selectedProductIds) { result -> - result - .onSuccess { - _editedProductIds.postValue(selectedProductIds) - _isOrdered.postValue(Unit) - }.onFailure { - _isError.postValue(it.message) - } - } + return (selectedCartProductsIds + selectedRecommendedProductsIds).toSet() + } + + private fun updateUiModel(update: (CartProductUiModel) -> CartProductUiModel) { + val current = _uiModel.value ?: return + _uiModel.value = update(current) } companion object { const val DEFAULT_PAGE_STEP: Int = 1 const val PAGE_INDEX_OFFSET: Int = 1 - const val DEFAULT_PAGE_SIZE: Int = 5 + private const val DEFAULT_PAGE_SIZE: Int = 5 + private const val TAG: String = "CartViewModel" val Factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory { @@ -245,7 +264,7 @@ class CartViewModel( increaseCartProductQuantityUseCase = increaseCartProductQuantityUseCase, decreaseCartProductQuantityUseCase = decreaseCartProductQuantityUseCase, getCartRecommendProductsUseCase = getCartRecommendProductsUseCase, - orderProductsUseCase = orderProductsUseCase, + getCatalogProductUseCase = getCatalogProductUseCase, ) as T } } diff --git a/app/src/main/java/woowacourse/shopping/ui/cart/adapter/CartProductViewHolder.kt b/app/src/main/java/woowacourse/shopping/ui/cart/adapter/CartProductViewHolder.kt index 2577a00a39..fc46fb1f56 100644 --- a/app/src/main/java/woowacourse/shopping/ui/cart/adapter/CartProductViewHolder.kt +++ b/app/src/main/java/woowacourse/shopping/ui/cart/adapter/CartProductViewHolder.kt @@ -13,16 +13,16 @@ class CartProductViewHolder( binding.onClickHandler = onClickHandler } - fun bind(product: Product) { - binding.product = product + fun bind(item: Product) { + binding.product = item binding.cartProductCount.setOnClickHandler( object : CartCountView.OnClickHandler { override fun onIncreaseClick() { - onClickHandler.onIncreaseClick(product.productDetail.id) + onClickHandler.onIncreaseClick(item.productDetail.id) } override fun onDecreaseClick() { - onClickHandler.onDecreaseClick(product.productDetail.id) + onClickHandler.onDecreaseClick(item.productDetail.id) } }, ) diff --git a/app/src/main/java/woowacourse/shopping/ui/cart/adapter/CartRecommendViewHolder.kt b/app/src/main/java/woowacourse/shopping/ui/cart/adapter/CartRecommendViewHolder.kt index 6b54874a42..61204386a2 100644 --- a/app/src/main/java/woowacourse/shopping/ui/cart/adapter/CartRecommendViewHolder.kt +++ b/app/src/main/java/woowacourse/shopping/ui/cart/adapter/CartRecommendViewHolder.kt @@ -13,10 +13,13 @@ class CartRecommendViewHolder private constructor( ) : RecyclerView.ViewHolder(binding.root) { private lateinit var item: Product + init { + binding.onClickHandler = onClickHandler + } + fun bind(item: Product) { this.item = item - binding.product = this.item - binding.onClickHandler = onClickHandler + binding.product = item binding.cartRecommendProductCount.setOnClickHandler( object : CartCountView.OnClickHandler { override fun onIncreaseClick() { diff --git a/app/src/main/java/woowacourse/shopping/ui/catalog/CatalogActivity.kt b/app/src/main/java/woowacourse/shopping/ui/catalog/CatalogActivity.kt index afb816fdc4..efb297e975 100644 --- a/app/src/main/java/woowacourse/shopping/ui/catalog/CatalogActivity.kt +++ b/app/src/main/java/woowacourse/shopping/ui/catalog/CatalogActivity.kt @@ -19,6 +19,7 @@ import woowacourse.shopping.ui.catalog.adapter.product.CatalogAdapter.OnClickHan import woowacourse.shopping.ui.catalog.adapter.product.CatalogLayoutManager import woowacourse.shopping.ui.common.DataBindingActivity import woowacourse.shopping.ui.model.ActivityResult +import woowacourse.shopping.ui.model.CatalogUiModel import woowacourse.shopping.ui.productdetail.ProductDetailActivity class CatalogActivity : DataBindingActivity(R.layout.activity_catalog) { @@ -76,7 +77,7 @@ class CatalogActivity : DataBindingActivity(R.layout.act } override fun onLoadMoreClick() { - viewModel.loadMoreCartProducts() + viewModel.loadMoreCatalogProducts() } } @@ -112,19 +113,25 @@ class CatalogActivity : DataBindingActivity(R.layout.act } private fun initObservers() { - viewModel.products.observe(this) { products -> - catalogAdapter.submitItems(products.products, !products.page.isLast) - viewModel.loadCartProductsQuantity() + viewModel.uiModel.observe(this) { uiModel -> + handleCatalogProducts(uiModel) + handleHistoryProducts(uiModel) + handlerErrorMessage(uiModel) } + } - viewModel.historyProducts.observe(this) { products -> - historyProductAdapter.submitItems(products) - } + private fun handleCatalogProducts(uiModel: CatalogUiModel) { + catalogAdapter.submitItems(uiModel.catalogProducts.products, !uiModel.catalogProducts.page.isLast) + viewModel.loadCartProductsQuantity() + } - viewModel.isError.observe(this) { errorMessage -> - errorMessage?.let { - Snackbar.make(binding.root, it, Snackbar.LENGTH_SHORT).show() - } + private fun handleHistoryProducts(uiModel: CatalogUiModel) { + historyProductAdapter.submitItems(uiModel.historyProducts) + } + + private fun handlerErrorMessage(uiModel: CatalogUiModel) { + uiModel.connectionErrorMessage?.let { + Snackbar.make(binding.root, it, Snackbar.LENGTH_SHORT).show() } } @@ -152,7 +159,7 @@ class CatalogActivity : DataBindingActivity(R.layout.act ) ActivityResult.CART_PRODUCT_EDITED.code -> - viewModel.loadCartProductsByIds( + viewModel.loadCartProductsByProductIds( result.data ?.getLongArrayExtra(ActivityResult.CART_PRODUCT_EDITED.key) ?.toList() ?: emptyList(), diff --git a/app/src/main/java/woowacourse/shopping/ui/catalog/CatalogViewModel.kt b/app/src/main/java/woowacourse/shopping/ui/catalog/CatalogViewModel.kt index 2ef33a0078..d491e43bed 100644 --- a/app/src/main/java/woowacourse/shopping/ui/catalog/CatalogViewModel.kt +++ b/app/src/main/java/woowacourse/shopping/ui/catalog/CatalogViewModel.kt @@ -5,151 +5,171 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.CreationExtras -import woowacourse.shopping.di.UseCaseModule.decreaseCartProductQuantityUseCase -import woowacourse.shopping.di.UseCaseModule.getCartProductsQuantityUseCase -import woowacourse.shopping.di.UseCaseModule.getCatalogProductUseCase -import woowacourse.shopping.di.UseCaseModule.getCatalogProductsByIdsUseCase -import woowacourse.shopping.di.UseCaseModule.getCatalogProductsUseCase -import woowacourse.shopping.di.UseCaseModule.getSearchHistoryUseCase -import woowacourse.shopping.di.UseCaseModule.increaseCartProductQuantityUseCase -import woowacourse.shopping.domain.model.HistoryProduct +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.launch +import woowacourse.shopping.di.UseCaseInjection.decreaseCartProductQuantityUseCase +import woowacourse.shopping.di.UseCaseInjection.getCartProductsQuantityUseCase +import woowacourse.shopping.di.UseCaseInjection.getCatalogProductUseCase +import woowacourse.shopping.di.UseCaseInjection.getCatalogProductsByProductIdsUseCase +import woowacourse.shopping.di.UseCaseInjection.getCatalogProductsUseCase +import woowacourse.shopping.di.UseCaseInjection.getSearchHistoryUseCase +import woowacourse.shopping.di.UseCaseInjection.increaseCartProductQuantityUseCase import woowacourse.shopping.domain.model.Page.Companion.UNINITIALIZED_PAGE -import woowacourse.shopping.domain.model.Products -import woowacourse.shopping.domain.model.Products.Companion.EMPTY_PRODUCTS import woowacourse.shopping.domain.usecase.DecreaseCartProductQuantityUseCase import woowacourse.shopping.domain.usecase.GetCartProductsQuantityUseCase import woowacourse.shopping.domain.usecase.GetCatalogProductUseCase -import woowacourse.shopping.domain.usecase.GetCatalogProductsByIdsUseCase +import woowacourse.shopping.domain.usecase.GetCatalogProductsByProductIdsUseCase import woowacourse.shopping.domain.usecase.GetCatalogProductsUseCase import woowacourse.shopping.domain.usecase.GetSearchHistoryUseCase import woowacourse.shopping.domain.usecase.IncreaseCartProductQuantityUseCase +import woowacourse.shopping.ui.model.CatalogUiModel class CatalogViewModel( private val getCatalogProductsUseCase: GetCatalogProductsUseCase, private val getCatalogProductUseCase: GetCatalogProductUseCase, - private val getCatalogProductsByIdsUseCase: GetCatalogProductsByIdsUseCase, + private val getCatalogProductsByProductIdsUseCase: GetCatalogProductsByProductIdsUseCase, private val getSearchHistoryUseCase: GetSearchHistoryUseCase, private val increaseCartProductQuantityUseCase: IncreaseCartProductQuantityUseCase, private val decreaseCartProductQuantityUseCase: DecreaseCartProductQuantityUseCase, private val getCartProductsQuantityUseCase: GetCartProductsQuantityUseCase, ) : ViewModel() { - private val _products: MutableLiveData = MutableLiveData(EMPTY_PRODUCTS) - val products: LiveData get() = _products - - private val _historyProducts: MutableLiveData> = - MutableLiveData(emptyList()) - val historyProducts: LiveData> get() = _historyProducts - - private val _cartProductsQuantity: MutableLiveData = - MutableLiveData(INITIAL_PRODUCT_QUANTITY) - val cartProductsQuantity: LiveData get() = _cartProductsQuantity - - private val _isLoading: MutableLiveData = MutableLiveData(true) - val isLoading: LiveData get() = _isLoading - - private val _isError: MutableLiveData = MutableLiveData() - val isError: LiveData get() = _isError + private val _uiModel: MutableLiveData = MutableLiveData(CatalogUiModel()) + val uiModel: LiveData get() = _uiModel init { - loadCartProducts() + loadCatalogProducts() } - private fun loadCartProducts( - page: Int = products.value?.page?.current ?: UNINITIALIZED_PAGE, + private fun loadCatalogProducts( + page: Int = + uiModel.value + ?.catalogProducts + ?.page + ?.current ?: UNINITIALIZED_PAGE, count: Int = SHOWN_PRODUCTS_COUNT, ) { - _isLoading.value = true - getCatalogProductsUseCase( - page = page, - size = count, - ) { result -> - result - .onSuccess { newProducts -> - _products.postValue(products.value?.plus(newProducts)) - _isLoading.value = false - }.onFailure { - _isError.postValue(it.message) - } + viewModelScope.launch( + CoroutineExceptionHandler { _, e -> + updateUiModel { current -> current.copy(connectionErrorMessage = e.message.toString()) } + Log.e(TAG, e.message.toString()) + }, + ) { + val products = getCatalogProductsUseCase(page, count) + + updateUiModel { current -> + current.copy( + catalogProducts = current.catalogProducts.plus(products), + isProductsLoading = false, + ) + } } } - fun loadMoreCartProducts() { + fun loadMoreCatalogProducts() { val currentPage = - products.value + uiModel.value + ?.catalogProducts ?.page ?.current ?.plus(DEFAULT_PAGE_STEP) ?: UNINITIALIZED_PAGE - loadCartProducts(page = currentPage) + + loadCatalogProducts(page = currentPage) } fun loadHistoryProducts() { - getSearchHistoryUseCase { historyProducts -> - _historyProducts.postValue(historyProducts) + viewModelScope.launch( + CoroutineExceptionHandler { _, e -> + Log.e(TAG, e.message.toString()) + }, + ) { + val products = getSearchHistoryUseCase() + + updateUiModel { current -> + current.copy( + historyProducts = products, + ) + } } } fun increaseCartProduct(productId: Long) { - runCatching { - increaseCartProductQuantityUseCase( - product = products.value?.getProductByProductId(productId) ?: return, - ) - }.onSuccess { + viewModelScope.launch( + CoroutineExceptionHandler { _, e -> + Log.e(TAG, e.message.toString()) + }, + ) { + val product = uiModel.value?.catalogProducts?.getProductByProductId(productId) ?: return@launch + increaseCartProductQuantityUseCase(product) loadCartProduct(productId) } } fun decreaseCartProduct(productId: Long) { - runCatching { - decreaseCartProductQuantityUseCase( - product = products.value?.getProductByProductId(productId) ?: return, - ) - }.onSuccess { + viewModelScope.launch( + CoroutineExceptionHandler { _, e -> + Log.e(TAG, e.message.toString()) + }, + ) { + val product = uiModel.value?.catalogProducts?.getProductByProductId(productId) ?: return@launch + decreaseCartProductQuantityUseCase(product) loadCartProduct(productId) } } fun loadCartProduct(productId: Long) { - getCatalogProductUseCase(productId) { result -> - result - .onSuccess { cartProduct -> - _products.postValue( - products.value?.updateProduct( - cartProduct ?: return@getCatalogProductUseCase, - ), - ) - }.onFailure { - _isError.postValue(it.message) - } + viewModelScope.launch( + CoroutineExceptionHandler { _, e -> + Log.e(TAG, e.message.toString()) + }, + ) { + val product = getCatalogProductUseCase(productId) + + updateUiModel { current -> + current.copy( + catalogProducts = current.catalogProducts.updateProduct(product), + ) + } } } - fun loadCartProductsByIds(ids: List) { - getCatalogProductsByIdsUseCase(ids) { result -> - result - .onSuccess { cartProducts -> - _products.postValue(products.value?.updateProducts(cartProducts)) - }.onFailure { - _isError.postValue(it.message) - } + fun loadCartProductsByProductIds(productIds: List) { + viewModelScope.launch( + CoroutineExceptionHandler { _, e -> + Log.e(TAG, e.message.toString()) + }, + ) { + val products = getCatalogProductsByProductIdsUseCase(productIds) + + updateUiModel { current -> + current.copy( + catalogProducts = current.catalogProducts.updateProducts(products), + ) + } } } fun loadCartProductsQuantity() { - getCartProductsQuantityUseCase { result -> - result - .onSuccess { quantity -> - _cartProductsQuantity.postValue(quantity) - }.onFailure { - Log.e("CatalogViewModel", it.message.toString()) - } + viewModelScope.launch( + CoroutineExceptionHandler { _, e -> + Log.e(TAG, e.message.toString()) + }, + ) { + val quantity = getCartProductsQuantityUseCase() + updateUiModel { current -> current.copy(cartProductsQuantity = quantity) } } } + private fun updateUiModel(update: (CatalogUiModel) -> CatalogUiModel) { + val current = _uiModel.value ?: return + _uiModel.value = update(current) + } + companion object { + private const val TAG: String = "CatalogViewModel" private const val DEFAULT_PAGE_STEP: Int = 1 private const val SHOWN_PRODUCTS_COUNT: Int = 20 - private const val INITIAL_PRODUCT_QUANTITY: Int = 0 val Factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory { @@ -161,7 +181,7 @@ class CatalogViewModel( CatalogViewModel( getCatalogProductsUseCase = getCatalogProductsUseCase, getCatalogProductUseCase = getCatalogProductUseCase, - getCatalogProductsByIdsUseCase = getCatalogProductsByIdsUseCase, + getCatalogProductsByProductIdsUseCase = getCatalogProductsByProductIdsUseCase, getSearchHistoryUseCase = getSearchHistoryUseCase, increaseCartProductQuantityUseCase = increaseCartProductQuantityUseCase, decreaseCartProductQuantityUseCase = decreaseCartProductQuantityUseCase, diff --git a/app/src/main/java/woowacourse/shopping/ui/model/CartProductUiModel.kt b/app/src/main/java/woowacourse/shopping/ui/model/CartProductUiModel.kt new file mode 100644 index 0000000000..d37974dc79 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/ui/model/CartProductUiModel.kt @@ -0,0 +1,12 @@ +package woowacourse.shopping.ui.model + +import woowacourse.shopping.domain.model.Products +import woowacourse.shopping.domain.model.Products.Companion.EMPTY_PRODUCTS + +data class CartProductUiModel( + val cartProducts: Products = EMPTY_PRODUCTS, + val recommendedProducts: Products = EMPTY_PRODUCTS, + val editedProductIds: Set = emptySet(), + val isProductsLoading: Boolean = true, + val connectionErrorMessage: String? = null, +) diff --git a/app/src/main/java/woowacourse/shopping/ui/model/CatalogUiModel.kt b/app/src/main/java/woowacourse/shopping/ui/model/CatalogUiModel.kt new file mode 100644 index 0000000000..af4bfe0db4 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/ui/model/CatalogUiModel.kt @@ -0,0 +1,17 @@ +package woowacourse.shopping.ui.model + +import woowacourse.shopping.domain.model.HistoryProduct +import woowacourse.shopping.domain.model.Products +import woowacourse.shopping.domain.model.Products.Companion.EMPTY_PRODUCTS + +data class CatalogUiModel( + val catalogProducts: Products = EMPTY_PRODUCTS, + val historyProducts: List = emptyList(), + val cartProductsQuantity: Int = INITIAL_PRODUCT_QUANTITY, + val isProductsLoading: Boolean = true, + val connectionErrorMessage: String? = null, +) { + companion object { + private const val INITIAL_PRODUCT_QUANTITY: Int = 0 + } +} diff --git a/app/src/main/java/woowacourse/shopping/ui/model/PaymentUiModel.kt b/app/src/main/java/woowacourse/shopping/ui/model/PaymentUiModel.kt new file mode 100644 index 0000000000..d46f101dbd --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/ui/model/PaymentUiModel.kt @@ -0,0 +1,16 @@ +package woowacourse.shopping.ui.model + +import woowacourse.shopping.domain.model.Coupons +import woowacourse.shopping.domain.model.Coupons.Companion.EMPTY_COUPONS +import woowacourse.shopping.domain.model.Price +import woowacourse.shopping.domain.model.Price.Companion.EMPTY_PRICE +import woowacourse.shopping.domain.model.Products +import woowacourse.shopping.domain.model.Products.Companion.EMPTY_PRODUCTS + +data class PaymentUiModel( + val products: Products = EMPTY_PRODUCTS, + val coupons: Coupons = EMPTY_COUPONS, + val price: Price = EMPTY_PRICE, + val isOrderSuccess: Boolean? = null, + val connectionErrorMessage: String? = null, +) diff --git a/app/src/main/java/woowacourse/shopping/ui/model/ProductDetailUiModel.kt b/app/src/main/java/woowacourse/shopping/ui/model/ProductDetailUiModel.kt new file mode 100644 index 0000000000..60446b0935 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/ui/model/ProductDetailUiModel.kt @@ -0,0 +1,12 @@ +package woowacourse.shopping.ui.model + +import woowacourse.shopping.domain.model.HistoryProduct +import woowacourse.shopping.domain.model.Product +import woowacourse.shopping.domain.model.Product.Companion.EMPTY_PRODUCT + +data class ProductDetailUiModel( + val product: Product = EMPTY_PRODUCT, + val lastHistoryProduct: HistoryProduct? = null, + val isCartProductUpdateSuccess: Boolean? = null, + val connectionErrorMessage: String? = null, +) diff --git a/app/src/main/java/woowacourse/shopping/ui/payment/PaymentActivity.kt b/app/src/main/java/woowacourse/shopping/ui/payment/PaymentActivity.kt new file mode 100644 index 0000000000..a8170e989a --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/ui/payment/PaymentActivity.kt @@ -0,0 +1,97 @@ +package woowacourse.shopping.ui.payment + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.MenuItem +import android.widget.Toast +import androidx.activity.viewModels +import com.google.android.material.snackbar.Snackbar +import woowacourse.shopping.R +import woowacourse.shopping.databinding.ActivityPaymentBinding +import woowacourse.shopping.ui.common.DataBindingActivity +import woowacourse.shopping.ui.model.PaymentUiModel +import woowacourse.shopping.ui.payment.adapter.PaymentCouponAdapter + +class PaymentActivity : DataBindingActivity(R.layout.activity_payment) { + private val viewModel: PaymentViewModel by viewModels { PaymentViewModel.Factory } + private val paymentCouponAdapter: PaymentCouponAdapter by lazy { + PaymentCouponAdapter { couponId -> viewModel.selectCoupon(couponId) } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + initViewBinding() + initPaymentProductsInfo() + initCouponsView() + initObservers() + initSupportActionBar() + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == android.R.id.home) finish() + return super.onOptionsItemSelected(item) + } + + private fun initViewBinding() { + binding.lifecycleOwner = this + binding.viewModel = viewModel + } + + private fun initPaymentProductsInfo() { + val productIds: List = intent.getLongArrayExtra(KEY_PRODUCT_IDS)?.toList() ?: emptyList() + viewModel.loadProducts(productIds) + } + + private fun initCouponsView() { + binding.paymentCouponsContainer.adapter = paymentCouponAdapter + binding.paymentCouponsContainer.isNestedScrollingEnabled = false + binding.paymentCouponsContainer.itemAnimator = null + } + + private fun initObservers() { + viewModel.uiModel.observe(this) { uiModel -> + handleCoupons(uiModel) + handleErrorMessage(uiModel) + handleOrderResult(uiModel) + } + } + + private fun handleCoupons(uiModel: PaymentUiModel) { + if (uiModel.coupons.value.isEmpty() && uiModel.products.products.isNotEmpty()) { + viewModel.loadCoupons(uiModel.products) + } + paymentCouponAdapter.submitList(uiModel.coupons.value) + } + + private fun handleErrorMessage(uiModel: PaymentUiModel) { + uiModel.connectionErrorMessage?.let { + Snackbar.make(binding.root, it, Snackbar.LENGTH_SHORT).show() + } + } + + private fun handleOrderResult(uiModel: PaymentUiModel) { + if (uiModel.isOrderSuccess == true) { + Toast.makeText(this, getString(R.string.payment_order_success), Toast.LENGTH_SHORT).show() + finish() + } + } + + private fun initSupportActionBar() { + supportActionBar?.setDisplayHomeAsUpEnabled(true) + supportActionBar?.title = getString(R.string.cart_title) + } + + companion object { + private const val KEY_PRODUCT_IDS = "PRODUCT_IDS" + + fun newIntent( + context: Context, + productIds: LongArray, + ): Intent = + Intent(context, PaymentActivity::class.java).apply { + putExtra(KEY_PRODUCT_IDS, productIds) + } + } +} diff --git a/app/src/main/java/woowacourse/shopping/ui/payment/PaymentViewModel.kt b/app/src/main/java/woowacourse/shopping/ui/payment/PaymentViewModel.kt new file mode 100644 index 0000000000..633fb0513e --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/ui/payment/PaymentViewModel.kt @@ -0,0 +1,131 @@ +package woowacourse.shopping.ui.payment + +import android.util.Log +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.CreationExtras +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.launch +import woowacourse.shopping.di.UseCaseInjection.getCatalogProductsByProductIdsUseCase +import woowacourse.shopping.di.UseCaseInjection.getCouponsUseCase +import woowacourse.shopping.di.UseCaseInjection.orderProductsUseCase +import woowacourse.shopping.domain.model.Products +import woowacourse.shopping.domain.usecase.GetCatalogProductsByProductIdsUseCase +import woowacourse.shopping.domain.usecase.GetCouponsUseCase +import woowacourse.shopping.domain.usecase.OrderProductsUseCase +import woowacourse.shopping.ui.model.PaymentUiModel +import java.time.LocalDateTime + +class PaymentViewModel( + private val getCatalogProductsByProductIdsUseCase: GetCatalogProductsByProductIdsUseCase, + private val getCouponsUseCase: GetCouponsUseCase, + private val orderProductsUseCase: OrderProductsUseCase, +) : ViewModel() { + private val _uiModel: MutableLiveData = MutableLiveData(PaymentUiModel()) + val uiModel: LiveData get() = _uiModel + + fun loadProducts(productIds: List) { + viewModelScope.launch( + CoroutineExceptionHandler { _, e -> + updateUiModel { current -> current.copy(connectionErrorMessage = e.message.toString()) } + Log.e(TAG, e.message.toString()) + }, + ) { + val products = + Products( + getCatalogProductsByProductIdsUseCase(productIds) + .filterNot { it.cartId == null } + .map { it.copy(isSelected = true) }, + ) + updateUiModel { current -> + current.copy( + products = products, + ) + } + } + } + + fun loadCoupons( + products: Products, + nowDateTime: LocalDateTime = LocalDateTime.now(), + ) { + viewModelScope.launch( + CoroutineExceptionHandler { _, e -> + updateUiModel { current -> current.copy(connectionErrorMessage = e.message.toString()) } + Log.e(TAG, e.message.toString()) + }, + ) { + val coupons = getCouponsUseCase() + updateUiModel { current -> + current.copy( + coupons = coupons.filterAvailableCoupons(products, nowDateTime), + price = current.coupons.applyCoupon(products, nowDateTime), + ) + } + } + } + + fun selectCoupon( + couponId: Int, + nowDateTime: LocalDateTime = LocalDateTime.now(), + ) { + updateUiModel { current -> + current.copy( + coupons = current.coupons.selectCoupon(couponId), + ) + } + updateUiModel { current -> + current.copy( + price = current.coupons.applyCoupon(current.products, nowDateTime), + ) + } + } + + fun orderProducts() { + viewModelScope.launch( + CoroutineExceptionHandler { _, e -> + updateUiModel { current -> + current.copy( + isOrderSuccess = false, + connectionErrorMessage = e.message.toString(), + ) + } + Log.e(TAG, e.message.toString()) + }, + ) { + val cartIds: Set = uiModel.value?.products?.getSelectedCartIds() ?: return@launch + orderProductsUseCase(cartIds) + + updateUiModel { current -> + current.copy(isOrderSuccess = true) + } + } + } + + private fun updateUiModel(update: (PaymentUiModel) -> PaymentUiModel) { + val current = _uiModel.value ?: return + _uiModel.value = update(current) + } + + companion object { + const val MAX_USABLE_COUPON_COUNT: Int = 1 + private const val TAG: String = "PaymentViewModel" + + val Factory: ViewModelProvider.Factory = + object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create( + modelClass: Class, + extras: CreationExtras, + ): T = + PaymentViewModel( + getCatalogProductsByProductIdsUseCase = getCatalogProductsByProductIdsUseCase, + getCouponsUseCase = getCouponsUseCase, + orderProductsUseCase = orderProductsUseCase, + ) as T + } + } +} diff --git a/app/src/main/java/woowacourse/shopping/ui/payment/adapter/PaymentCouponAdapter.kt b/app/src/main/java/woowacourse/shopping/ui/payment/adapter/PaymentCouponAdapter.kt new file mode 100644 index 0000000000..f7b034e8d3 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/ui/payment/adapter/PaymentCouponAdapter.kt @@ -0,0 +1,21 @@ +package woowacourse.shopping.ui.payment.adapter + +import android.view.ViewGroup +import androidx.recyclerview.widget.ListAdapter +import woowacourse.shopping.domain.model.Coupon + +class PaymentCouponAdapter( + private val onClickHandler: PaymentCouponViewHolder.OnClickHandler, +) : ListAdapter(PaymentCouponDiffCallback) { + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): PaymentCouponViewHolder = PaymentCouponViewHolder.from(parent, onClickHandler) + + override fun onBindViewHolder( + holder: PaymentCouponViewHolder, + position: Int, + ) { + holder.bind(getItem(position)) + } +} diff --git a/app/src/main/java/woowacourse/shopping/ui/payment/adapter/PaymentCouponDiffCallback.kt b/app/src/main/java/woowacourse/shopping/ui/payment/adapter/PaymentCouponDiffCallback.kt new file mode 100644 index 0000000000..c99bdc52b5 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/ui/payment/adapter/PaymentCouponDiffCallback.kt @@ -0,0 +1,16 @@ +package woowacourse.shopping.ui.payment.adapter + +import androidx.recyclerview.widget.DiffUtil +import woowacourse.shopping.domain.model.Coupon + +object PaymentCouponDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: Coupon, + newItem: Coupon, + ): Boolean = oldItem.detail.id == newItem.detail.id + + override fun areContentsTheSame( + oldItem: Coupon, + newItem: Coupon, + ): Boolean = oldItem.detail == newItem.detail && oldItem.isSelected == newItem.isSelected +} diff --git a/app/src/main/java/woowacourse/shopping/ui/payment/adapter/PaymentCouponViewHolder.kt b/app/src/main/java/woowacourse/shopping/ui/payment/adapter/PaymentCouponViewHolder.kt new file mode 100644 index 0000000000..6b80288298 --- /dev/null +++ b/app/src/main/java/woowacourse/shopping/ui/payment/adapter/PaymentCouponViewHolder.kt @@ -0,0 +1,34 @@ +package woowacourse.shopping.ui.payment.adapter + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import woowacourse.shopping.databinding.ItemPaymentCouponBinding +import woowacourse.shopping.domain.model.Coupon + +class PaymentCouponViewHolder private constructor( + private val binding: ItemPaymentCouponBinding, + onClickHandler: OnClickHandler, +) : RecyclerView.ViewHolder(binding.root) { + init { + binding.onClickHandler = onClickHandler + } + + fun bind(item: Coupon) { + binding.coupon = item + } + + fun interface OnClickHandler { + fun onCouponSelected(couponId: Int) + } + + companion object { + fun from( + parent: ViewGroup, + onClickHandler: OnClickHandler, + ): PaymentCouponViewHolder { + val inflate = ItemPaymentCouponBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return PaymentCouponViewHolder(inflate, onClickHandler) + } + } +} diff --git a/app/src/main/java/woowacourse/shopping/ui/productdetail/ProductDetailActivity.kt b/app/src/main/java/woowacourse/shopping/ui/productdetail/ProductDetailActivity.kt index 5eae215457..05f21157d4 100644 --- a/app/src/main/java/woowacourse/shopping/ui/productdetail/ProductDetailActivity.kt +++ b/app/src/main/java/woowacourse/shopping/ui/productdetail/ProductDetailActivity.kt @@ -13,6 +13,7 @@ import woowacourse.shopping.databinding.ActivityProductDetailBinding import woowacourse.shopping.ui.common.DataBindingActivity import woowacourse.shopping.ui.custom.CartCountView import woowacourse.shopping.ui.model.ActivityResult +import woowacourse.shopping.ui.model.ProductDetailUiModel class ProductDetailActivity : DataBindingActivity(R.layout.activity_product_detail) { private val viewModel: ProductDetailViewModel by viewModels { ProductDetailViewModel.Factory } @@ -65,33 +66,35 @@ class ProductDetailActivity : DataBindingActivity( } private fun initObservers() { - viewModel.product.observe(this) { product -> - product?.let { - viewModel.addHistoryProduct(product.productDetail) - } + viewModel.uiModel.observe(this) { uiModel -> + handleHistoryProduct(uiModel) + handleCartProductAddResult(uiModel) + handleErrorMessage(uiModel) } + } - viewModel.onCartProductAddSuccess.observe(this) { isSuccess -> - isSuccess?.let { handleCartProductAddResult(it) } - } + private fun handleHistoryProduct(uiModel: ProductDetailUiModel) { + viewModel.addHistoryProduct(uiModel.product.productDetail) + } - viewModel.isError.observe(this) { errorMessage -> - errorMessage?.let { - Snackbar.make(binding.root, it, Snackbar.LENGTH_SHORT).show() + private fun handleCartProductAddResult(uiModel: ProductDetailUiModel) { + uiModel.isCartProductUpdateSuccess?.let { isSuccess -> + if (isSuccess) { + setResult( + ActivityResult.PRODUCT_DETAIL_CART_UPDATED.code, + Intent().apply { + putExtra(ActivityResult.PRODUCT_DETAIL_CART_UPDATED.key, productId) + }, + ) + Toast.makeText(this, getString(R.string.product_detail_cart_add_success), Toast.LENGTH_SHORT).show() + finish() } } } - private fun handleCartProductAddResult(isSuccess: Boolean) { - if (isSuccess) { - setResult( - ActivityResult.PRODUCT_DETAIL_CART_UPDATED.code, - Intent().apply { - putExtra(ActivityResult.PRODUCT_DETAIL_CART_UPDATED.key, productId) - }, - ) - Toast.makeText(this, getString(R.string.product_detail_cart_add_success), Toast.LENGTH_SHORT).show() - finish() + private fun handleErrorMessage(uiModel: ProductDetailUiModel) { + uiModel.connectionErrorMessage?.let { + Snackbar.make(binding.root, it, Snackbar.LENGTH_SHORT).show() } } diff --git a/app/src/main/java/woowacourse/shopping/ui/productdetail/ProductDetailViewModel.kt b/app/src/main/java/woowacourse/shopping/ui/productdetail/ProductDetailViewModel.kt index 784d9a5e65..4bc0a85a17 100644 --- a/app/src/main/java/woowacourse/shopping/ui/productdetail/ProductDetailViewModel.kt +++ b/app/src/main/java/woowacourse/shopping/ui/productdetail/ProductDetailViewModel.kt @@ -1,24 +1,24 @@ package woowacourse.shopping.ui.productdetail +import android.util.Log import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.CreationExtras -import woowacourse.shopping.di.UseCaseModule.addSearchHistoryUseCase -import woowacourse.shopping.di.UseCaseModule.getCatalogProductUseCase -import woowacourse.shopping.di.UseCaseModule.getRecentSearchHistoryUseCase -import woowacourse.shopping.di.UseCaseModule.updateCartProductUseCase -import woowacourse.shopping.domain.model.HistoryProduct -import woowacourse.shopping.domain.model.Product -import woowacourse.shopping.domain.model.Product.Companion.EMPTY_PRODUCT +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.launch +import woowacourse.shopping.di.UseCaseInjection.addSearchHistoryUseCase +import woowacourse.shopping.di.UseCaseInjection.getCatalogProductUseCase +import woowacourse.shopping.di.UseCaseInjection.getRecentSearchHistoryUseCase +import woowacourse.shopping.di.UseCaseInjection.updateCartProductUseCase import woowacourse.shopping.domain.model.ProductDetail import woowacourse.shopping.domain.usecase.AddSearchHistoryUseCase import woowacourse.shopping.domain.usecase.GetCatalogProductUseCase import woowacourse.shopping.domain.usecase.GetRecentSearchHistoryUseCase import woowacourse.shopping.domain.usecase.UpdateCartProductUseCase -import woowacourse.shopping.util.MutableSingleLiveData -import woowacourse.shopping.util.SingleLiveData +import woowacourse.shopping.ui.model.ProductDetailUiModel class ProductDetailViewModel( private val getCatalogProductUseCase: GetCatalogProductUseCase, @@ -26,66 +26,79 @@ class ProductDetailViewModel( private val addSearchHistoryUseCase: AddSearchHistoryUseCase, private val updateCartProductUseCase: UpdateCartProductUseCase, ) : ViewModel() { - private val _product: MutableLiveData = - MutableLiveData(EMPTY_PRODUCT) - val product: LiveData get() = _product + private val _uiModel: MutableLiveData = MutableLiveData(ProductDetailUiModel()) + val uiModel: LiveData get() = _uiModel - private val _lastHistoryProduct: MutableLiveData = MutableLiveData(null) - val lastHistoryProduct: LiveData get() = _lastHistoryProduct - - private val _onCartProductAddSuccess: MutableSingleLiveData = - MutableSingleLiveData(null) - val onCartProductAddSuccess: SingleLiveData get() = _onCartProductAddSuccess - - private val _isError: MutableLiveData = MutableLiveData() - val isError: LiveData get() = _isError - - fun loadProductDetail(id: Long) { - getCatalogProductUseCase(id) { result -> - result - .onSuccess { catalogProduct -> - _product.postValue(catalogProduct ?: EMPTY_PRODUCT) - }.onFailure { - _isError.postValue(it.message) - } + fun loadProductDetail(productId: Long) { + viewModelScope.launch( + CoroutineExceptionHandler { _, e -> + updateUiModel { current -> current.copy(connectionErrorMessage = e.message.toString()) } + Log.e(TAG, e.message.toString()) + }, + ) { + val product = getCatalogProductUseCase(productId) + updateUiModel { current -> current.copy(product = product) } } } fun loadLastHistoryProduct() { - getRecentSearchHistoryUseCase { historyProduct -> - _lastHistoryProduct.postValue(historyProduct) + viewModelScope.launch( + CoroutineExceptionHandler { _, e -> + updateUiModel { current -> current.copy(connectionErrorMessage = e.message.toString()) } + Log.e(TAG, e.message.toString()) + }, + ) { + val product = getRecentSearchHistoryUseCase() + updateUiModel { current -> current.copy(lastHistoryProduct = product) } } } fun addHistoryProduct(productDetail: ProductDetail) { - addSearchHistoryUseCase(productDetail) + viewModelScope.launch { + viewModelScope.launch( + CoroutineExceptionHandler { _, e -> + Log.e(TAG, e.message.toString()) + }, + ) { + addSearchHistoryUseCase(productDetail) + } + } } fun decreaseCartProductQuantity() { - _product.value = product.value?.decreaseQuantity() + val uiModel = uiModel.value ?: return + updateUiModel { current -> current.copy(product = uiModel.product.decreaseQuantity()) } } fun increaseCartProductQuantity() { - _product.value = product.value?.increaseQuantity() + val uiModel = uiModel.value ?: return + updateUiModel { current -> current.copy(product = uiModel.product.increaseQuantity()) } } fun updateCartProduct() { - val product: Product = product.value ?: return - updateCartProductUseCase( - productId = product.productDetail.id, - cartId = product.cartId, - quantity = product.quantity, - ) { result -> - result - .onSuccess { - _onCartProductAddSuccess.postValue(true) - }.onFailure { - _isError.postValue(it.message) - } + val uiModel = uiModel.value ?: return + viewModelScope.launch( + CoroutineExceptionHandler { _, e -> + updateUiModel { current -> current.copy(connectionErrorMessage = e.message.toString()) } + Log.e(TAG, e.message.toString()) + }, + ) { + updateCartProductUseCase( + productId = uiModel.product.productDetail.id, + cartId = uiModel.product.cartId, + quantity = uiModel.product.quantity, + ) + updateUiModel { current -> current.copy(isCartProductUpdateSuccess = true) } } } + private fun updateUiModel(update: (ProductDetailUiModel) -> ProductDetailUiModel) { + val current = _uiModel.value ?: return + _uiModel.value = update(current) + } + companion object { + private const val TAG: String = "ProductDetailViewModel" val Factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") diff --git a/app/src/main/java/woowacourse/shopping/util/Event.kt b/app/src/main/java/woowacourse/shopping/util/Event.kt deleted file mode 100644 index de6defd373..0000000000 --- a/app/src/main/java/woowacourse/shopping/util/Event.kt +++ /dev/null @@ -1,28 +0,0 @@ -package woowacourse.shopping.util - -/** - * 출처: MVVM 피드백 강의자료 - * 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? = - 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/MutableSingleLiveData.kt b/app/src/main/java/woowacourse/shopping/util/MutableSingleLiveData.kt deleted file mode 100644 index 80a1241f5a..0000000000 --- a/app/src/main/java/woowacourse/shopping/util/MutableSingleLiveData.kt +++ /dev/null @@ -1,18 +0,0 @@ -package woowacourse.shopping.util - -/** - * 출처: MVVM 피드백 강의자료 - */ -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 deleted file mode 100644 index cf24416365..0000000000 --- a/app/src/main/java/woowacourse/shopping/util/SingleLiveData.kt +++ /dev/null @@ -1,41 +0,0 @@ -package woowacourse.shopping.util - -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.MutableLiveData - -/** - * 출처: MVVM 피드백 강의자료 - */ -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/bg_payment_white_radius_4dp_stroke_gray_4.xml b/app/src/main/res/drawable/bg_payment_white_radius_4dp_stroke_gray_4.xml new file mode 100644 index 0000000000..86512d03c8 --- /dev/null +++ b/app/src/main/res/drawable/bg_payment_white_radius_4dp_stroke_gray_4.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 index a00c5c9ab9..5e21c07fd3 100644 --- a/app/src/main/res/layout/activity_cart.xml +++ b/app/src/main/res/layout/activity_cart.xml @@ -45,9 +45,8 @@ android:layout_height="wrap_content" android:layout_marginStart="12dp" android:layout_marginBottom="12dp" - android:checked="@{viewModel.cartProducts.allSelected}" + android:checked="@{viewModel.uiModel.cartProducts.allSelected}" android:onClick="@{() -> viewModel.toggleAllCartProductsSelection()}" - app:isGone="@{viewModel.cartProducts.products.empty}" app:layout_constraintBottom_toBottomOf="@id/cartOrderInfoContainer" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="@id/cartOrderInfoContainer" @@ -61,7 +60,6 @@ android:text="@string/cart_select_all" android:textColor="@color/white" android:textStyle="bold" - app:isGone="@{viewModel.cartProducts.products.empty}" app:layout_constraintBottom_toBottomOf="@id/cartCheckAllButton" app:layout_constraintEnd_toEndOf="@id/cartCheckAllButton" app:layout_constraintStart_toStartOf="@id/cartCheckAllButton" /> @@ -70,7 +68,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="12dp" - android:text="@{@string/cart_order_price(viewModel.totalOrderPrice)}" + android:text="@{@string/cart_order_price(viewModel.uiModel.cartProducts.selectedProductsPrice + viewModel.uiModel.recommendedProducts.selectedProductsPrice)}" android:textColor="@color/white" android:textSize="18sp" android:textStyle="bold" @@ -88,7 +86,7 @@ android:maxWidth="156dp" android:onClick="@{()->onClickHandler.onOrderClick()}" android:paddingHorizontal="16dp" - android:text="@{@string/cart_order(viewModel.cartProducts.getSelectedCartProductQuantity() + viewModel.recommendedProducts.getSelectedCartRecommendProductQuantity())}" + android:text="@{@string/cart_order(viewModel.uiModel.cartProducts.selectedProductsQuantity + viewModel.uiModel.recommendedProducts.selectedProductsQuantity)}" android:textColor="@color/white" android:textSize="18sp" android:textStyle="bold" diff --git a/app/src/main/res/layout/activity_catalog.xml b/app/src/main/res/layout/activity_catalog.xml index 9d2e8269e1..12be2be8df 100644 --- a/app/src/main/res/layout/activity_catalog.xml +++ b/app/src/main/res/layout/activity_catalog.xml @@ -25,7 +25,7 @@ android:id="@+id/productsHistoryContainer" android:layout_width="0dp" android:layout_height="228dp" - app:isGone="@{viewModel.historyProducts.empty || viewModel.loading}" + app:isGone="@{viewModel.uiModel.historyProducts.empty || viewModel.uiModel.productsLoading}" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent"> @@ -82,7 +82,7 @@ android:layout_height="0dp" android:layout_marginTop="20dp" android:background="@color/white" - app:isGone="@{!viewModel.loading}" + app:isGone="@{!viewModel.uiModel.productsLoading}" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" diff --git a/app/src/main/res/layout/activity_payment.xml b/app/src/main/res/layout/activity_payment.xml new file mode 100644 index 0000000000..5c912cf728 --- /dev/null +++ b/app/src/main/res/layout/activity_payment.xml @@ -0,0 +1,206 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_product_detail.xml b/app/src/main/res/layout/activity_product_detail.xml index 7c2ed1aa4b..4d5b6434d4 100644 --- a/app/src/main/res/layout/activity_product_detail.xml +++ b/app/src/main/res/layout/activity_product_detail.xml @@ -35,7 +35,7 @@ android:layout_width="0dp" android:layout_height="0dp" android:scaleType="centerCrop" - app:imageUrl="@{viewModel.product.productDetail.imageUrl}" + app:imageUrl="@{viewModel.uiModel.product.productDetail.imageUrl}" app:layout_constraintDimensionRatio="1" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" @@ -48,7 +48,7 @@ android:layout_height="wrap_content" android:layout_marginHorizontal="18dp" android:layout_marginVertical="16dp" - android:text="@{viewModel.product.productDetail.name}" + android:text="@{viewModel.uiModel.product.productDetail.name}" android:textColor="@color/black" android:textSize="24sp" android:textStyle="bold" @@ -74,7 +74,7 @@ android:layout_height="wrap_content" android:layout_marginStart="18dp" android:layout_marginTop="16dp" - android:text="@{@string/catalog_price(viewModel.product.totalPrice)}" + android:text="@{@string/catalog_price(viewModel.uiModel.product.totalPrice)}" android:textColor="@color/black" android:textSize="20sp" app:layout_constraintStart_toStartOf="parent" @@ -86,7 +86,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="18dp" - app:count="@{viewModel.product.quantity}" + app:count="@{viewModel.uiModel.product.quantity}" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="@id/productDetailPrice" /> @@ -97,10 +97,10 @@ android:layout_marginHorizontal="18dp" android:layout_marginVertical="30dp" android:background="@drawable/bg_products_gray_4_radius_right_4dp" - android:onClick="@{()->onClickHandler.onLastHistoryProductClick(viewModel.lastHistoryProduct.productId)}" + android:onClick="@{()->onClickHandler.onLastHistoryProductClick(viewModel.uiModel.lastHistoryProduct.productId)}" android:orientation="vertical" android:paddingHorizontal="18dp" - app:isGone="@{viewModel.lastHistoryProduct == null}" + app:isGone="@{viewModel.uiModel.lastHistoryProduct == null}" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" @@ -122,7 +122,7 @@ android:layout_marginBottom="16dp" android:ellipsize="end" android:maxLines="1" - android:text="@{viewModel.lastHistoryProduct.name}" + android:text="@{viewModel.uiModel.lastHistoryProduct.name}" android:textColor="@color/gray_5" android:textSize="18sp" tools:text="마지막으로 본 상품 대충 나올 수 있지" /> @@ -135,7 +135,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@drawable/selector_cart_add" - android:enabled="@{viewModel.product.quantity > 0}" + android:enabled="@{viewModel.uiModel.product.quantity > 0}" android:onClick="@{()->onClickHandler.onAddCartProductClick()}" android:paddingVertical="12dp" android:text="@string/product_detail_cart_add" diff --git a/app/src/main/res/layout/fragment_cart_product.xml b/app/src/main/res/layout/fragment_cart_product.xml index 9bca9546ef..03b47f0512 100644 --- a/app/src/main/res/layout/fragment_cart_product.xml +++ b/app/src/main/res/layout/fragment_cart_product.xml @@ -30,7 +30,7 @@ android:layout_height="wrap_content" android:layout_marginBottom="20dp" android:gravity="center" - app:isGone="@{viewModel.cartProducts.page.single}" + app:isGone="@{viewModel.uiModel.cartProducts.page.single}" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent"> @@ -41,7 +41,7 @@ android:layout_marginEnd="16dp" android:background="@drawable/selector_cart_previous" android:clickable="true" - android:enabled="@{!viewModel.cartProducts.page.first}" + android:enabled="@{!viewModel.uiModel.cartProducts.page.first}" android:onClick="@{()->viewModel.decreasePage(viewModel.DEFAULT_PAGE_STEP)}" android:paddingHorizontal="16dp" android:paddingVertical="8dp" @@ -54,7 +54,7 @@ android:id="@+id/cartPageText" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:text="@{String.valueOf(viewModel.cartProducts.page.current + viewModel.PAGE_INDEX_OFFSET)}" + android:text="@{String.valueOf(viewModel.uiModel.cartProducts.page.current + viewModel.PAGE_INDEX_OFFSET)}" android:textColor="@color/gray_5" android:textSize="22sp" android:textStyle="bold" @@ -66,7 +66,7 @@ android:layout_marginStart="16dp" android:background="@drawable/selector_cart_next" android:clickable="true" - android:enabled="@{!viewModel.cartProducts.page.last}" + android:enabled="@{!viewModel.uiModel.cartProducts.page.last}" android:onClick="@{()->viewModel.increasePage(viewModel.DEFAULT_PAGE_STEP)}" android:paddingHorizontal="16dp" android:paddingVertical="8dp" @@ -80,7 +80,7 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:src="@drawable/img_cart_empty" - app:isGone="@{!viewModel.cartProducts.products.empty || viewModel.loading}" + app:isGone="@{!viewModel.uiModel.cartProducts.products.empty || viewModel.uiModel.productsLoading}" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" @@ -92,7 +92,7 @@ android:layout_height="0dp" android:layout_marginTop="20dp" android:background="@color/white" - app:isGone="@{!viewModel.loading}" + app:isGone="@{!viewModel.uiModel.productsLoading}" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" @@ -122,4 +122,4 @@ - \ No newline at end of file + diff --git a/app/src/main/res/layout/item_payment_coupon.xml b/app/src/main/res/layout/item_payment_coupon.xml new file mode 100644 index 0000000000..47fe1fc83a --- /dev/null +++ b/app/src/main/res/layout/item_payment_coupon.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/layout_catalog_cart_quantity.xml b/app/src/main/res/layout/layout_catalog_cart_quantity.xml index 13f89cc38d..2412063a5d 100644 --- a/app/src/main/res/layout/layout_catalog_cart_quantity.xml +++ b/app/src/main/res/layout/layout_catalog_cart_quantity.xml @@ -33,14 +33,14 @@ android:layout_marginBottom="14dp" android:background="@drawable/bg_products_mint_1_circle" android:gravity="center" - android:text="@{String.valueOf(viewModel.cartProductsQuantity)}" + android:text="@{String.valueOf(viewModel.uiModel.cartProductsQuantity)}" android:textColor="@color/white" android:textSize="11sp" android:textStyle="bold" - app:isGone="@{viewModel.cartProductsQuantity <= 0}" + app:isGone="@{viewModel.uiModel.cartProductsQuantity <= 0}" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent" tools:text="1" tools:visibility="visible" /> - \ No newline at end of file + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6090a2cb2f..3464933401 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -26,4 +26,18 @@ 주문하기(%d) 이런 상품은 어떠세요? * 최근 본 상품 기반으로 좋아하실 것 같은 상품들을 추천해드려요. + + + 성공적으로 주문이 완료되었습니다! + 만료일: %d년 %d월 %d일 + 최소 주문 금액: %,d원 + 적용 가능한 쿠폰 + * 쿠폰은 %d개만 적용 가능합니다. + 주문 금액 + 쿠폰 할인 금액 + 배송비 + 총 결제 금액 + %,d원 + -%,d원 + 결제하기 diff --git a/app/src/test/java/woowacourse/shopping/domain/model/CartProductDetailTest.kt b/app/src/test/java/woowacourse/shopping/domain/model/CartProductDetailTest.kt deleted file mode 100644 index 9c4669a2d8..0000000000 --- a/app/src/test/java/woowacourse/shopping/domain/model/CartProductDetailTest.kt +++ /dev/null @@ -1,19 +0,0 @@ -package woowacourse.shopping.domain.model - -import com.google.common.truth.Truth.assertThat -import org.junit.jupiter.api.Test -import woowacourse.shopping.model.DUMMY_CART_PRODUCT_1 - -class CartProductDetailTest { - @Test - fun `총 가격은 단가와 수량의 곱으로 계산된다`() { - // given - val cartProduct = DUMMY_CART_PRODUCT_1 - - // when - val total = cartProduct.totalPrice - - // then - assertThat(total).isEqualTo(cartProduct.productDetail.price * cartProduct.quantity) - } -} diff --git a/app/src/test/java/woowacourse/shopping/domain/model/CartProductsTest.kt b/app/src/test/java/woowacourse/shopping/domain/model/CartProductsTest.kt deleted file mode 100644 index 02998201cb..0000000000 --- a/app/src/test/java/woowacourse/shopping/domain/model/CartProductsTest.kt +++ /dev/null @@ -1,30 +0,0 @@ -package woowacourse.shopping.domain.model - -import com.google.common.truth.Truth.assertThat -import org.junit.jupiter.api.Test -import woowacourse.shopping.model.DUMMY_CART_PRODUCTS_1 -import woowacourse.shopping.model.DUMMY_CART_PRODUCT_2 - -class CartProductsTest { - @Test - fun `상품 수량을 수정하면 해당 상품을 반영한다`() { - // given - val original = DUMMY_CART_PRODUCTS_1 - val targetId = DUMMY_CART_PRODUCT_2.productDetail.id - val newQuantity = 100 - - // when - val updated = original.updateCartProductQuantity(targetId, newQuantity) - - // then - val modified = updated.products.first { it.productDetail.id == targetId } - assertThat(modified.quantity).isEqualTo(newQuantity) - - val originalTarget = original.products.first { it.productDetail.id == targetId } - assertThat(originalTarget.quantity).isNotEqualTo(newQuantity) - - val otherOriginal = original.products.filter { it.productDetail.id != targetId } - val otherUpdated = updated.products.filter { it.productDetail.id != targetId } - assertThat(otherUpdated).containsExactlyElementsIn(otherOriginal) - } -} diff --git a/app/src/test/java/woowacourse/shopping/domain/model/CouponsTest.kt b/app/src/test/java/woowacourse/shopping/domain/model/CouponsTest.kt new file mode 100644 index 0000000000..41b00e8ed3 --- /dev/null +++ b/app/src/test/java/woowacourse/shopping/domain/model/CouponsTest.kt @@ -0,0 +1,48 @@ +package woowacourse.shopping.domain.model + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import woowacourse.shopping.model.DUMMY_COUPONS_1 + +class CouponsTest { + @Test + fun `쿠폰 ID를 입력하면 쿠폰이 선택된다`() { + // given + val coupons = DUMMY_COUPONS_1 + + // when + val selected = coupons.selectCoupon(2) + + // then + assertThat(selected.value.count { it.isSelected }).isEqualTo(1) + assertThat(selected.value.find { it.detail.id == 2 }?.isSelected).isTrue() + assertThat(selected.value.filter { it.detail.id != 2 }.all { !it.isSelected }).isTrue() + } + + @Test + fun `같은 쿠폰 ID를 두 번 선택하면 선택이 해제된다`() { + // given + val coupons = DUMMY_COUPONS_1 + + // when + val onceSelected = coupons.selectCoupon(1) + val twiceSelected = onceSelected.selectCoupon(1) + + // then + assertThat(twiceSelected.value.all { !it.isSelected }).isTrue() + } + + @Test + fun `다른 쿠폰을 선택하면 이전 쿠폰은 해제되고 새 쿠폰만 선택된다`() { + // given + val selected1 = DUMMY_COUPONS_1.selectCoupon(1) + + // when + val selected3 = selected1.selectCoupon(3) + + // then + assertThat(selected3.value.count { it.isSelected }).isEqualTo(1) + assertThat(selected3.value.find { it.detail.id == 3 }?.isSelected).isTrue() + assertThat(selected3.value.filter { it.detail.id != 3 }.all { !it.isSelected }).isTrue() + } +} diff --git a/app/src/test/java/woowacourse/shopping/domain/model/FixedDiscountCouponTest.kt b/app/src/test/java/woowacourse/shopping/domain/model/FixedDiscountCouponTest.kt new file mode 100644 index 0000000000..e424392a0b --- /dev/null +++ b/app/src/test/java/woowacourse/shopping/domain/model/FixedDiscountCouponTest.kt @@ -0,0 +1,88 @@ +package woowacourse.shopping.domain.model + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import woowacourse.shopping.model.DUMMY_COUPON_1 +import woowacourse.shopping.model.DUMMY_LOCAL_DATE_TIME_1 +import woowacourse.shopping.model.DUMMY_PRODUCTS_2 +import woowacourse.shopping.model.DUMMY_PRODUCTS_3 +import woowacourse.shopping.model.DUMMY_PRODUCTS_4 + +class FixedDiscountCouponTest { + @Test + fun `조건을 만족하면 쿠폰 할인이 적용된다`() { + // given + val coupon = DUMMY_COUPON_1 + val products = DUMMY_PRODUCTS_3 + + // when + val price = coupon.apply(products, DUMMY_LOCAL_DATE_TIME_1) + + // then + assertThat(price.original).isEqualTo(1119200) + assertThat(price.discount).isEqualTo(5000) + assertThat(price.shipping).isEqualTo(3000) + assertThat(price.result).isEqualTo(1117200) + } + + @Test + fun `조건을 만족하지 않으면 할인이 적용되지 않는다`() { + // given + val coupon = DUMMY_COUPON_1 + val products = DUMMY_PRODUCTS_4 + + // when + val price = coupon.apply(products, DUMMY_LOCAL_DATE_TIME_1) + + // then + assertThat(price.original).isEqualTo(11900) + assertThat(price.discount).isEqualTo(0) + assertThat(price.result).isEqualTo(14900) + } + + @Test + fun `유효기간이 지나면 할인은 적용되지 않는다`() { + // given + val expiredCoupon = + DUMMY_COUPON_1.copy( + detail = + DUMMY_COUPON_1.detail.copy( + expirationDate = DUMMY_LOCAL_DATE_TIME_1.toLocalDate().minusDays(1), + ), + ) + val products = DUMMY_PRODUCTS_3 + + // when + val price = expiredCoupon.apply(products, DUMMY_LOCAL_DATE_TIME_1) + + // then + assertThat(price.discount).isEqualTo(0) + assertThat(price.result).isEqualTo(products.selectedProductsPrice + 3000) + } + + @Test + fun `최소 금액과 날짜 조건을 만족하면 쿠폰을 사용할 수 있다`() { + // given + val coupon = DUMMY_COUPON_1 + val products = DUMMY_PRODUCTS_3 + + // when + val result = coupon.getIsAvailable(products, DUMMY_LOCAL_DATE_TIME_1) + + // then + assertThat(result).isTrue() + } + + @Test + fun `최소 금액을 만족하지 않으면 쿠폰을 사용할 수 없다`() { + // given + val coupon = DUMMY_COUPON_1 + val products = DUMMY_PRODUCTS_2 + + // when + val result = coupon.getIsAvailable(products, DUMMY_LOCAL_DATE_TIME_1) + + // then + assertThat(result).isFalse() + } +} diff --git a/app/src/test/java/woowacourse/shopping/domain/model/FreeShippingCouponTest.kt b/app/src/test/java/woowacourse/shopping/domain/model/FreeShippingCouponTest.kt new file mode 100644 index 0000000000..12281ebb7b --- /dev/null +++ b/app/src/test/java/woowacourse/shopping/domain/model/FreeShippingCouponTest.kt @@ -0,0 +1,88 @@ +package woowacourse.shopping.domain.model + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import woowacourse.shopping.model.DUMMY_COUPON_3 +import woowacourse.shopping.model.DUMMY_LOCAL_DATE_TIME_1 +import woowacourse.shopping.model.DUMMY_PRODUCTS_2 +import woowacourse.shopping.model.DUMMY_PRODUCTS_3 +import woowacourse.shopping.model.DUMMY_PRODUCTS_4 + +class FreeShippingCouponTest { + @Test + fun `조건을 만족하면 배송비 금액만큼 할인된다`() { + // given + val coupon = DUMMY_COUPON_3 + val products = DUMMY_PRODUCTS_3 + + // when + val price = coupon.apply(products, DUMMY_LOCAL_DATE_TIME_1) + + // then + assertThat(price.original).isEqualTo(1119200) + assertThat(price.discount).isEqualTo(3000) + assertThat(price.shipping).isEqualTo(3000) + assertThat(price.result).isEqualTo(1119200) + } + + @Test + fun `조건을 만족하지 않으면 할인은 적용되지 않는다`() { + // given + val coupon = DUMMY_COUPON_3 + val products = DUMMY_PRODUCTS_4 + + // when + val price = coupon.apply(products, DUMMY_LOCAL_DATE_TIME_1) + + // then + assertThat(price.original).isEqualTo(11900) + assertThat(price.discount).isEqualTo(0) + assertThat(price.result).isEqualTo(14900) + } + + @Test + fun `유효기간이 지나면 할인은 적용되지 않는다`() { + // given + val expiredCoupon = + DUMMY_COUPON_3.copy( + detail = + DUMMY_COUPON_3.detail.copy( + expirationDate = DUMMY_LOCAL_DATE_TIME_1.toLocalDate().minusDays(1), + ), + ) + val products = DUMMY_PRODUCTS_3 + + // when + val price = expiredCoupon.apply(products, DUMMY_LOCAL_DATE_TIME_1) + + // then + assertThat(price.discount).isEqualTo(0) + assertThat(price.result).isEqualTo(products.selectedProductsPrice + 3000) + } + + @Test + fun `최소 금액과 날짜 조건을 만족하면 쿠폰을 사용할 수 있다`() { + // given + val coupon = DUMMY_COUPON_3 + val products = DUMMY_PRODUCTS_3 + + // when + val result = coupon.getIsAvailable(products, DUMMY_LOCAL_DATE_TIME_1) + + // then + assertThat(result).isTrue() + } + + @Test + fun `최소 금액을 만족하지 않으면 쿠폰을 사용할 수 없다`() { + // given + val coupon = DUMMY_COUPON_3 + val products = DUMMY_PRODUCTS_2 + + // when + val result = coupon.getIsAvailable(products, DUMMY_LOCAL_DATE_TIME_1) + + // then + assertThat(result).isFalse() + } +} diff --git a/app/src/test/java/woowacourse/shopping/domain/model/PageTest.kt b/app/src/test/java/woowacourse/shopping/domain/model/PageTest.kt new file mode 100644 index 0000000000..ad1b156f24 --- /dev/null +++ b/app/src/test/java/woowacourse/shopping/domain/model/PageTest.kt @@ -0,0 +1,30 @@ +package woowacourse.shopping.domain.model + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class PageTest { + @Test + fun `시작 페이지와 종료 페이지가 같으면 한 페이지만 존재함을 알려준다`() { + // given + val singlePage = Page(current = 0, isFirst = true, isLast = true) + + // when + val result = singlePage.isSingle + + // then + assertThat(result).isTrue() + } + + @Test + fun `isFirst와 isLast가 다르면 단일 페이지가 아님을 알려준다`() { + // given + val middlePage = Page(current = 2, isFirst = false, isLast = false) + + // when + val result = middlePage.isSingle + + // then + assertThat(result).isFalse() + } +} diff --git a/app/src/test/java/woowacourse/shopping/domain/model/PercentageDiscountCouponTest.kt b/app/src/test/java/woowacourse/shopping/domain/model/PercentageDiscountCouponTest.kt new file mode 100644 index 0000000000..22a94d1d7b --- /dev/null +++ b/app/src/test/java/woowacourse/shopping/domain/model/PercentageDiscountCouponTest.kt @@ -0,0 +1,87 @@ +package woowacourse.shopping.domain.model + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import woowacourse.shopping.model.DUMMY_COUPON_4 +import woowacourse.shopping.model.DUMMY_LOCAL_DATE_TIME_1 +import woowacourse.shopping.model.DUMMY_LOCAL_DATE_TIME_2 +import woowacourse.shopping.model.DUMMY_PRODUCTS_3 +import woowacourse.shopping.model.DUMMY_PRODUCTS_4 + +class PercentageDiscountCouponTest { + @Test + fun `조건을 만족하면 퍼센트 할인 쿠폰이 적용된다`() { + // given + val coupon = DUMMY_COUPON_4 + val products = DUMMY_PRODUCTS_3 + + // when + val price = coupon.apply(products, DUMMY_LOCAL_DATE_TIME_1) + + // then + assertThat(price.original).isEqualTo(1119200) + assertThat(price.discount).isEqualTo((1119200 * 0.3).toInt()) + assertThat(price.shipping).isEqualTo(3000) + assertThat(price.result).isEqualTo(1119200 - (1119200 * 0.3).toInt() + 3000) + } + + @Test + fun `조건을 만족하지 않으면 할인은 적용되지 않는다`() { + // given + val coupon = DUMMY_COUPON_4 + val products = DUMMY_PRODUCTS_4 + + // when + val price = coupon.apply(products, DUMMY_LOCAL_DATE_TIME_2) + + // then + assertThat(price.original).isEqualTo(11900) + assertThat(price.discount).isEqualTo(0) + assertThat(price.result).isEqualTo(14900) + } + + @Test + fun `할인 시간이 아닐 경우 쿠폰을 사용할 수 없다`() { + // given + val products = DUMMY_PRODUCTS_3 + val invalidTime = DUMMY_LOCAL_DATE_TIME_2 + + // when + val result = DUMMY_COUPON_4.getIsAvailable(products, invalidTime) + + // then + assertThat(result).isFalse() + } + + @Test + fun `유효기간이 지나면 쿠폰을 사용할 수 없다`() { + // given + val expiredCoupon = + DUMMY_COUPON_4.copy( + detail = + DUMMY_COUPON_4.detail.copy( + expirationDate = DUMMY_LOCAL_DATE_TIME_1.toLocalDate().minusDays(1), + ), + ) + val products = DUMMY_PRODUCTS_3 + + // when + val result = expiredCoupon.getIsAvailable(products, DUMMY_LOCAL_DATE_TIME_1) + + // then + assertThat(result).isFalse() + } + + @Test + fun `할인 시간과 유효기간이 유효하면 쿠폰을 사용할 수 있다`() { + // given + val coupon = DUMMY_COUPON_4 + val products = DUMMY_PRODUCTS_3 + + // when + val result = coupon.getIsAvailable(products, DUMMY_LOCAL_DATE_TIME_1) + + // then + assertThat(result).isTrue() + } +} diff --git a/app/src/test/java/woowacourse/shopping/domain/model/PriceTest.kt b/app/src/test/java/woowacourse/shopping/domain/model/PriceTest.kt new file mode 100644 index 0000000000..12c3980ad1 --- /dev/null +++ b/app/src/test/java/woowacourse/shopping/domain/model/PriceTest.kt @@ -0,0 +1,23 @@ +package woowacourse.shopping.domain.model + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class PriceTest { + @Test + fun `할인 가격과 배송비에 따라 최종 금액을 반환한다`() { + // given + val price = + Price( + original = 10_000, + discount = 2_000, + shipping = 3_000, + ) + + // when + val result = price.result + + // then + assertThat(result).isEqualTo(11_000) + } +} diff --git a/app/src/test/java/woowacourse/shopping/domain/model/ProductDetailTest.kt b/app/src/test/java/woowacourse/shopping/domain/model/ProductDetailTest.kt deleted file mode 100644 index 1ba9d15cc0..0000000000 --- a/app/src/test/java/woowacourse/shopping/domain/model/ProductDetailTest.kt +++ /dev/null @@ -1,57 +0,0 @@ -package woowacourse.shopping.domain.model - -import com.google.common.truth.Truth.assertThat -import org.junit.jupiter.api.Test -import woowacourse.shopping.model.DUMMY_CATALOG_PRODUCT_1 - -class ProductDetailTest { - @Test - fun `수량을 증가시키면 해당 수량만큼 증가된 객체를 반환한다`() { - // given - val original = DUMMY_CATALOG_PRODUCT_1 - - // when - val increased = original.increaseQuantity() - - // then - assertThat(increased.quantity).isEqualTo(6) - assertThat(increased.productDetail).isEqualTo(original.productDetail) - } - - @Test - fun `수량을 감소시키면 해당 수량만큼 감소된 객체를 반환한다`() { - // given - val original = DUMMY_CATALOG_PRODUCT_1 - - // when - val decreased = original.decreaseQuantity() - - // then - assertThat(decreased.quantity).isEqualTo(4) - assertThat(decreased.productDetail).isEqualTo(original.productDetail) - } - - @Test - fun `수량을 감소시킬 때 0 미만으로 떨어지지 않도록 한다`() { - // given - val original = DUMMY_CATALOG_PRODUCT_1 - - // when - val decreased = original.decreaseQuantity(10) - - // then - assertThat(decreased.quantity).isEqualTo(0) - } - - @Test - fun `총 가격은 단가와 수량의 곱으로 계산된다`() { - // given - val catalogProduct = DUMMY_CATALOG_PRODUCT_1 - - // when - val total = catalogProduct.totalPrice - - // then - assertThat(total).isEqualTo(catalogProduct.productDetail.price * catalogProduct.quantity) - } -} diff --git a/app/src/test/java/woowacourse/shopping/domain/model/ProductTest.kt b/app/src/test/java/woowacourse/shopping/domain/model/ProductTest.kt new file mode 100644 index 0000000000..a8d095f7ff --- /dev/null +++ b/app/src/test/java/woowacourse/shopping/domain/model/ProductTest.kt @@ -0,0 +1,55 @@ +package woowacourse.shopping.domain.model + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import woowacourse.shopping.model.DUMMY_PRODUCT_1 + +class ProductTest { + @Test + fun `금액에 따른 총 가격을 반환한다`() { + // given + val product = DUMMY_PRODUCT_1 + + // when + val price = product.totalPrice + + // then + assertThat(price).isEqualTo(11900 * 5) + } + + @Test + fun `수량을 증가시키면 수량이 증가된 객체를 반환한다`() { + // given + val product = DUMMY_PRODUCT_1.copy(quantity = 3) + + // when + val updated = product.increaseQuantity() + + // then + assertThat(updated.quantity).isEqualTo(4) + } + + @Test + fun `수량을 감소시키면 수량이 감소된 객체를 반환한다`() { + // given + val product = DUMMY_PRODUCT_1.copy(quantity = 3) + + // when + val updated = product.decreaseQuantity() + + // then + assertThat(updated.quantity).isEqualTo(2) + } + + @Test + fun `수량은 0개 이하로 감소되지 않는다`() { + // given + val product = DUMMY_PRODUCT_1.copy(quantity = 0) + + // when + val updated = product.decreaseQuantity() + + // then + assertThat(updated.quantity).isEqualTo(0) + } +} diff --git a/app/src/test/java/woowacourse/shopping/domain/model/ProductsTest.kt b/app/src/test/java/woowacourse/shopping/domain/model/ProductsTest.kt index 2ac5a56cbf..aef6c2efce 100644 --- a/app/src/test/java/woowacourse/shopping/domain/model/ProductsTest.kt +++ b/app/src/test/java/woowacourse/shopping/domain/model/ProductsTest.kt @@ -1,112 +1,183 @@ package woowacourse.shopping.domain.model -import com.google.common.truth.Truth.assertThat +import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test -import woowacourse.shopping.model.DUMMY_CATALOG_PRODUCTS_1 -import woowacourse.shopping.model.DUMMY_CATALOG_PRODUCT_1 -import woowacourse.shopping.model.DUMMY_CATALOG_PRODUCT_2 -import woowacourse.shopping.model.DUMMY_CATALOG_PRODUCT_3 +import woowacourse.shopping.model.DUMMY_PRODUCTS_1 +import woowacourse.shopping.model.DUMMY_PRODUCTS_2 +import woowacourse.shopping.model.DUMMY_PRODUCT_1 +import woowacourse.shopping.model.DUMMY_PRODUCT_2 +import woowacourse.shopping.model.DUMMY_PRODUCT_3 class ProductsTest { @Test - fun `두 상품 목록을 병합하면 기존 순서를 유지하며 결합되고 hasMore는 우측 값을 따른다`() { + fun `모든 상품이 선택되어 있으면 모든 상품이 선택되어 있음을 반환한다`() { // given - val left = Products(listOf(DUMMY_CATALOG_PRODUCT_1), hasMore = true) - val right = Products(listOf(DUMMY_CATALOG_PRODUCT_2), hasMore = false) + val selectedProducts = + DUMMY_PRODUCTS_1.copy( + products = DUMMY_PRODUCTS_1.products.map { it.copy(isSelected = true) }, + ) // when - val result = left + right + val result = selectedProducts.isAllSelected // then - assertThat(result.products) - .containsExactly( - DUMMY_CATALOG_PRODUCT_1, - DUMMY_CATALOG_PRODUCT_2, - ).inOrder() - assertThat(result.hasMore).isFalse() + assertThat(result).isTrue() } @Test - fun `상품 수량을 변경하면 해당 상품만 반영되고 나머지는 유지된다`() { + fun `선택된 상품들의 총 수량을 반환한다`() { // given - val original = DUMMY_CATALOG_PRODUCTS_1 - val newQuantity = 100 + val selected = + DUMMY_PRODUCTS_1.copy( + products = DUMMY_PRODUCTS_1.products.map { it.copy(isSelected = true) }, + ) // when - val updated = - original.updateProductQuantity( - DUMMY_CATALOG_PRODUCT_2.productDetail.id, - newQuantity, + val quantity = selected.selectedProductsQuantity + + // then + assertThat(quantity).isEqualTo(5 + 6 + 7) + } + + @Test + fun `선택된 상품들의 총 가격을 반환한다`() { + // given + val selected = + DUMMY_PRODUCTS_1.copy( + products = DUMMY_PRODUCTS_1.products.map { it.copy(isSelected = true) }, ) + // when + val price = selected.selectedProductsPrice + + // then + val expected = + DUMMY_PRODUCT_1.totalPrice + + DUMMY_PRODUCT_2.totalPrice + + DUMMY_PRODUCT_3.totalPrice + + assertThat(price).isEqualTo(expected) + } + + @Test + fun `상품 선택 상태를 반전한다`() { + // given + val products = DUMMY_PRODUCTS_1 + val target = products.products.first() + + // when + val updated = products.updateSelection(target) + // then - val modified = - updated.products.first { it.productDetail.id == DUMMY_CATALOG_PRODUCT_2.productDetail.id } - assertThat(modified.quantity).isEqualTo(newQuantity) + assertThat(updated.getProductByProductId(target.productDetail.id)?.isSelected).isEqualTo(!target.isSelected) + } - val unmodified = - updated.products.first { it.productDetail.id == DUMMY_CATALOG_PRODUCT_1.productDetail.id } - assertThat(unmodified.quantity).isEqualTo(DUMMY_CATALOG_PRODUCT_1.quantity) + @Test + fun `특정 상품의 수량을 업데이트한다`() { + // given + val products = DUMMY_PRODUCTS_1 + val target = products.products.first() + + // when + val updated = products.updateQuantity(target, 10) + + // then + assertThat(updated.getProductByProductId(target.productDetail.id)?.quantity).isEqualTo(10) } @Test - fun `단일 상품 정보를 갱신하면 해당 상품만 변경된다`() { + fun `전체 선택 상태를 반전한다`() { // given - val original = DUMMY_CATALOG_PRODUCTS_1 - val updatedProduct = DUMMY_CATALOG_PRODUCT_2.copy(quantity = 100) + val products = DUMMY_PRODUCTS_1 // when - val result = original.updateProduct(updatedProduct) + val toggled = products.toggleAllSelection() // then - val modified = - result.products.first { it.productDetail.id == updatedProduct.productDetail.id } - assertThat(modified.quantity).isEqualTo(100) + assertThat(toggled.isAllSelected).isTrue() + } + + @Test + fun `상품의 ID로 상품을 조회할 수 있다`() { + // given + val products = DUMMY_PRODUCTS_1 + val id = DUMMY_PRODUCT_3.productDetail.id - val unmodified = - result.products.first { it.productDetail.id == DUMMY_CATALOG_PRODUCT_1.productDetail.id } - assertThat(unmodified.quantity).isEqualTo(DUMMY_CATALOG_PRODUCT_1.quantity) + // when + val found = products.getProductByProductId(id) + + // then + assertThat(found?.productDetail?.id).isEqualTo(id) } @Test - fun `복수 상품 정보를 갱신하면 매칭되는 항목만 반영된다`() { + fun `장바구니 ID로 상품을 조회할 수 있다`() { // given - val original = DUMMY_CATALOG_PRODUCTS_1 + val products = DUMMY_PRODUCTS_1 + val cartId = DUMMY_PRODUCT_2.cartId - val updatedList = - listOf( - DUMMY_CATALOG_PRODUCT_1.copy(quantity = 1), - DUMMY_CATALOG_PRODUCT_3.copy(quantity = 2), + // when + val found = products.getProductByCartId(cartId!!) + + // then + assertThat(found?.cartId).isEqualTo(cartId) + } + + @Test + fun `선택된 상품들의 상품 ID 목록을 반환한다`() { + // given + val selected = + DUMMY_PRODUCTS_1.copy( + products = + DUMMY_PRODUCTS_1.products.mapIndexed { index, product -> + if (index < 3) product.copy(isSelected = true) else product + }, ) // when - val result = original.updateProducts(updatedList) + val ids = selected.getSelectedProductIds() // then - assertThat(result.products.first { it.productDetail.id == DUMMY_CATALOG_PRODUCT_1.productDetail.id }.quantity).isEqualTo( - 1, - ) - assertThat(result.products.first { it.productDetail.id == DUMMY_CATALOG_PRODUCT_2.productDetail.id }.quantity) - .isEqualTo(DUMMY_CATALOG_PRODUCT_2.quantity) - assertThat(result.products.first { it.productDetail.id == DUMMY_CATALOG_PRODUCT_3.productDetail.id }.quantity).isEqualTo( - 2, + assertThat(ids).containsExactly( + DUMMY_PRODUCT_1.productDetail.id, + DUMMY_PRODUCT_2.productDetail.id, + DUMMY_PRODUCT_3.productDetail.id, ) } @Test - fun `총 수량은 모든 상품 수량의 합과 같다`() { + fun `선택된 상품들의 장바구니 ID 목록을 반환한다`() { // given - val products = - Products( - listOf( - DUMMY_CATALOG_PRODUCT_1.copy(quantity = 1), - DUMMY_CATALOG_PRODUCT_2.copy(quantity = 2), - DUMMY_CATALOG_PRODUCT_3.copy(quantity = 3), - ), - hasMore = false, + val selected = + DUMMY_PRODUCTS_1.copy( + products = + DUMMY_PRODUCTS_1.products.mapIndexed { index, product -> + if (index < 3) product.copy(isSelected = true) else product + }, ) + // when + val cartIds = selected.getSelectedCartIds() + + // then + assertThat(cartIds).containsExactly( + DUMMY_PRODUCT_1.cartId, + DUMMY_PRODUCT_2.cartId, + DUMMY_PRODUCT_3.cartId, + ) + } + + @Test + fun `두 Products를 병합하면 상품 목록이 추가된다`() { + // given + val left = DUMMY_PRODUCTS_1 + val right = DUMMY_PRODUCTS_2.copy(page = Page(3, isFirst = false, isLast = true)) + + // when + val merged = left + right + // then - assertThat(products.catalogProductsQuantity).isEqualTo(6) + assertThat(merged.products).hasSize(left.products.size + right.products.size) + assertThat(merged.page).isEqualTo(right.page) } } diff --git a/app/src/test/java/woowacourse/shopping/domain/model/QuantityBonusCouponTest.kt b/app/src/test/java/woowacourse/shopping/domain/model/QuantityBonusCouponTest.kt new file mode 100644 index 0000000000..3bf8c7497a --- /dev/null +++ b/app/src/test/java/woowacourse/shopping/domain/model/QuantityBonusCouponTest.kt @@ -0,0 +1,92 @@ +package woowacourse.shopping.domain.model + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import woowacourse.shopping.model.DUMMY_COUPON_2 +import woowacourse.shopping.model.DUMMY_LOCAL_DATE_TIME_1 +import woowacourse.shopping.model.DUMMY_PRODUCT_DETAIL_2 +import woowacourse.shopping.model.DUMMY_PRODUCT_DETAIL_3 +import java.time.LocalDate + +class QuantityBonusCouponTest { + @Test + fun `조건을 만족하면 가장 비싼 상품이 할인된다`() { + // given + val coupon = DUMMY_COUPON_2 + val product1 = Product(DUMMY_PRODUCT_DETAIL_2.copy(price = 5000), cartId = 1, quantity = 3, isSelected = true) + val product2 = Product(DUMMY_PRODUCT_DETAIL_3.copy(price = 2500), cartId = 1, quantity = 3, isSelected = true) + val products = Products(listOf(product1, product2)) + + // when + val price = coupon.apply(products, DUMMY_LOCAL_DATE_TIME_1) + + // then + assertThat(price.original).isEqualTo(22500) + assertThat(price.discount).isEqualTo(5000) + assertThat(price.shipping).isEqualTo(3000) + assertThat(price.result).isEqualTo(20500) + } + + @Test + fun `수량이 6개이면 2세트가 적용되어 2개 할인된다`() { + // given + val coupon = DUMMY_COUPON_2 + val product = Product(DUMMY_PRODUCT_DETAIL_2.copy(price = 5000), cartId = 1, quantity = 6, isSelected = true) + val products = Products(listOf(product)) + + // when + val price = coupon.apply(products, DUMMY_LOCAL_DATE_TIME_1) + + // then + assertThat(price.original).isEqualTo(30000) + assertThat(price.discount).isEqualTo(5000 * 2) + assertThat(price.result).isEqualTo(23000) + } + + @Test + fun `조건을 만족하지 않으면 쿠폰은 적용되지 않는다`() { + // given + val coupon = DUMMY_COUPON_2 + val product = Product(DUMMY_PRODUCT_DETAIL_2.copy(price = 5000), cartId = 1, quantity = 2, isSelected = true) + val products = Products(listOf(product)) + + // when + val price = coupon.apply(products, DUMMY_LOCAL_DATE_TIME_1) + + // then + assertThat(price.original).isEqualTo(10000) + assertThat(price.discount).isEqualTo(0) + assertThat(price.result).isEqualTo(13000) + } + + @Test + fun `유효기간이 지나면 쿠폰은 사용할 수 없다`() { + // given + val expiredCoupon = + DUMMY_COUPON_2.copy( + detail = DUMMY_COUPON_2.detail.copy(expirationDate = LocalDate.of(2020, 1, 1)), + ) + val product = Product(DUMMY_PRODUCT_DETAIL_2, cartId = 1, quantity = 3, isSelected = true) + val products = Products(listOf(product)) + + // when + val result = expiredCoupon.getIsAvailable(products, DUMMY_LOCAL_DATE_TIME_1) + + // then + assertThat(result).isFalse() + } + + @Test + fun `수량 조건과 유효기간을 만족하면 쿠폰을 사용할 수 있다`() { + // given + val coupon = DUMMY_COUPON_2 + val product = Product(DUMMY_PRODUCT_DETAIL_2, cartId = 1, quantity = 3, isSelected = true) + val products = Products(listOf(product)) + + // when + val result = coupon.getIsAvailable(products, DUMMY_LOCAL_DATE_TIME_1) + + // then + assertThat(result).isTrue() + } +} diff --git a/app/src/test/java/woowacourse/shopping/model/Fixture.kt b/app/src/test/java/woowacourse/shopping/model/Fixture.kt index c1d0b6afe0..f519e0bb33 100644 --- a/app/src/test/java/woowacourse/shopping/model/Fixture.kt +++ b/app/src/test/java/woowacourse/shopping/model/Fixture.kt @@ -1,10 +1,24 @@ package woowacourse.shopping.model +import woowacourse.shopping.domain.model.Coupon +import woowacourse.shopping.domain.model.CouponDetail +import woowacourse.shopping.domain.model.CouponDiscountType.BUY_X_GET_Y +import woowacourse.shopping.domain.model.CouponDiscountType.FIXED +import woowacourse.shopping.domain.model.CouponDiscountType.FREE_SHIPPING +import woowacourse.shopping.domain.model.CouponDiscountType.PERCENTAGE +import woowacourse.shopping.domain.model.Coupons +import woowacourse.shopping.domain.model.FixedDiscountCoupon +import woowacourse.shopping.domain.model.FreeShippingCoupon import woowacourse.shopping.domain.model.HistoryProduct import woowacourse.shopping.domain.model.Page +import woowacourse.shopping.domain.model.PercentageDiscountCoupon import woowacourse.shopping.domain.model.Product import woowacourse.shopping.domain.model.ProductDetail import woowacourse.shopping.domain.model.Products +import woowacourse.shopping.domain.model.QuantityBonusCoupon +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime val DUMMY_PRODUCT_DETAIL_1 = ProductDetail( @@ -38,24 +52,15 @@ val DUMMY_PRODUCT_DETAIL_4 = id = 4, name = "치밥하기 좋은 순살 바베큐치킨", imageUrl = "https://product-image.kurly.com/hdims/resize/%5E%3E360x%3E468/cropcenter/360x468/quality/85/src/product/image/f864b361-85da-4482-aec8-909397caac4e.jpg", - price = 13990, + price = 139900, category = "공백제이", ) -val DUMMY_PRODUCT_DETAIL_5 = - ProductDetail( - id = 5, - name = "[이연복의 목란] 짜장면 2인분", - imageUrl = "https://product-image.kurly.com/hdims/resize/%5E%3E360x%3E468/cropcenter/360x468/quality/85/src/product/image/90256eb2-b02f-493a-ab7a-29a8724254e4.jpeg", - price = 9980, - category = "공백제이", - ) - -val DUMMY_CART_PRODUCT_1 = Product(DUMMY_PRODUCT_DETAIL_1, 1) -val DUMMY_CART_PRODUCT_2 = Product(DUMMY_PRODUCT_DETAIL_2, 1) -val DUMMY_CART_PRODUCT_3 = Product(DUMMY_PRODUCT_DETAIL_3, 1) -val DUMMY_CART_PRODUCT_4 = Product(DUMMY_PRODUCT_DETAIL_4, 1) -val DUMMY_CART_PRODUCT_5 = Product(DUMMY_PRODUCT_DETAIL_5, 1) +val DUMMY_PRODUCT_1 = Product(DUMMY_PRODUCT_DETAIL_1, 1, 5) +val DUMMY_PRODUCT_2 = Product(DUMMY_PRODUCT_DETAIL_2, 2, 6) +val DUMMY_PRODUCT_3 = Product(DUMMY_PRODUCT_DETAIL_3, 3, 7) +val DUMMY_PRODUCT_4 = Product(DUMMY_PRODUCT_DETAIL_4, 4, 8, true) +val DUMMY_PRODUCT_5 = Product(DUMMY_PRODUCT_DETAIL_1, 1, 1, true) val DUMMY_HISTORY_PRODUCT_1 = HistoryProduct( @@ -65,33 +70,110 @@ val DUMMY_HISTORY_PRODUCT_1 = category = "공백제이", ) -val DUMMY_CATALOG_PRODUCT_1 = Product(DUMMY_PRODUCT_DETAIL_1, quantity = 5) -val DUMMY_CATALOG_PRODUCT_2 = Product(DUMMY_PRODUCT_DETAIL_2, quantity = 6) -val DUMMY_CATALOG_PRODUCT_3 = Product(DUMMY_PRODUCT_DETAIL_3, quantity = 7) - -val DUMMY_CART_PRODUCTS_1 = +val DUMMY_PRODUCTS_1 = Products( products = listOf( - DUMMY_CART_PRODUCT_1, - DUMMY_CART_PRODUCT_2, - DUMMY_CART_PRODUCT_3, - DUMMY_CART_PRODUCT_4, - DUMMY_CART_PRODUCT_5, + DUMMY_PRODUCT_1, + DUMMY_PRODUCT_2, + DUMMY_PRODUCT_3, ), page = Page(2, isFirst = false, isLast = false), ) -val DUMMY_CATALOG_PRODUCTS_1 = +val DUMMY_PRODUCTS_2 = Products( - products = - listOf( - DUMMY_CATALOG_PRODUCT_1, - DUMMY_CATALOG_PRODUCT_2, - DUMMY_CATALOG_PRODUCT_3, - ), + products = listOf(DUMMY_PRODUCT_1), ) -val DUMMY_CATALOG_PRODUCTS_2 = + +val DUMMY_PRODUCTS_3 = Products( - products = listOf(DUMMY_CATALOG_PRODUCT_1), + products = listOf(DUMMY_PRODUCT_4), ) + +val DUMMY_PRODUCTS_4 = + Products( + products = listOf(DUMMY_PRODUCT_5), + ) + +val DUMMY_COUPON_1: Coupon = + FixedDiscountCoupon( + detail = + CouponDetail( + id = 1, + code = "FIXED5000", + name = "5,000원 할인 쿠폰", + expirationDate = LocalDate.of(2025, 11, 30), + discount = 5000, + minimumPurchase = 100000, + discountType = FIXED, + buyQuantity = null, + getQuantity = null, + availableTime = null, + ), + isSelected = false, + ) + +val DUMMY_COUPON_2: Coupon = + QuantityBonusCoupon( + detail = + CouponDetail( + id = 2, + code = "BOGO", + name = "2개 구매 시 1개 무료 쿠폰", + expirationDate = LocalDate.of(2025, 6, 30), + discount = null, + minimumPurchase = null, + discountType = BUY_X_GET_Y, + buyQuantity = 2, + getQuantity = 1, + availableTime = null, + ), + isSelected = false, + ) + +val DUMMY_COUPON_3: Coupon = + FreeShippingCoupon( + detail = + CouponDetail( + id = 3, + code = "FREESHIPPING", + name = "5만원 이상 구매 시 무료 배송 쿠폰", + expirationDate = LocalDate.of(2025, 8, 31), + discount = null, + minimumPurchase = 50000, + discountType = FREE_SHIPPING, + buyQuantity = null, + getQuantity = null, + availableTime = null, + ), + isSelected = false, + ) + +val DUMMY_COUPON_4: Coupon = + PercentageDiscountCoupon( + detail = + CouponDetail( + id = 4, + code = "MIRACLESALE", + name = "미라클모닝 30% 할인 쿠폰", + expirationDate = LocalDate.of(2025, 7, 31), + discount = 30, + minimumPurchase = null, + discountType = PERCENTAGE, + buyQuantity = null, + getQuantity = null, + availableTime = + CouponDetail.AvailableTime( + start = LocalTime.of(4, 0, 0), + end = LocalTime.of(7, 0, 0), + ), + ), + isSelected = false, + ) + +val DUMMY_COUPONS_1: Coupons = Coupons(listOf(DUMMY_COUPON_1, DUMMY_COUPON_2, DUMMY_COUPON_3, DUMMY_COUPON_4)) + +val DUMMY_LOCAL_DATE_TIME_1: LocalDateTime = LocalDateTime.of(2025, 6, 15, 6, 30) + +val DUMMY_LOCAL_DATE_TIME_2: LocalDateTime = LocalDateTime.of(2025, 6, 15, 18, 30) diff --git a/app/src/test/java/woowacourse/shopping/ui/cart/CartViewModelTest.kt b/app/src/test/java/woowacourse/shopping/ui/cart/CartViewModelTest.kt index a784f2abae..8ecef8e9a4 100644 --- a/app/src/test/java/woowacourse/shopping/ui/cart/CartViewModelTest.kt +++ b/app/src/test/java/woowacourse/shopping/ui/cart/CartViewModelTest.kt @@ -1,47 +1,53 @@ package woowacourse.shopping.ui.cart import com.google.common.truth.Truth.assertThat -import io.mockk.Runs -import io.mockk.clearMocks -import io.mockk.every -import io.mockk.just +import io.mockk.coEvery import io.mockk.mockk import io.mockk.unmockkAll -import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith -import woowacourse.shopping.domain.model.CartProducts import woowacourse.shopping.domain.usecase.DecreaseCartProductQuantityUseCase import woowacourse.shopping.domain.usecase.GetCartProductsUseCase +import woowacourse.shopping.domain.usecase.GetCartRecommendProductsUseCase +import woowacourse.shopping.domain.usecase.GetCatalogProductUseCase import woowacourse.shopping.domain.usecase.IncreaseCartProductQuantityUseCase import woowacourse.shopping.domain.usecase.RemoveCartProductUseCase -import woowacourse.shopping.model.DUMMY_CART_PRODUCTS_1 -import woowacourse.shopping.model.DUMMY_PRODUCT_Detail_1 +import woowacourse.shopping.model.DUMMY_PRODUCTS_1 +import woowacourse.shopping.model.DUMMY_PRODUCT_1 +import woowacourse.shopping.ui.model.CartProductUiModel +import woowacourse.shopping.util.CoroutinesTestExtension import woowacourse.shopping.util.InstantTaskExecutorExtension import woowacourse.shopping.util.getOrAwaitValue +import woowacourse.shopping.util.setUpTestLiveData +@OptIn(ExperimentalCoroutinesApi::class) @ExtendWith(InstantTaskExecutorExtension::class) +@ExtendWith(CoroutinesTestExtension::class) class CartViewModelTest { private lateinit var getCartProductsUseCase: GetCartProductsUseCase private lateinit var removeCartProductUseCase: RemoveCartProductUseCase private lateinit var increaseCartProductQuantityUseCase: IncreaseCartProductQuantityUseCase private lateinit var decreaseCartProductQuantityUseCase: DecreaseCartProductQuantityUseCase + private lateinit var getCartRecommendProductsUseCase: GetCartRecommendProductsUseCase + private lateinit var getCatalogProductUseCase: GetCatalogProductUseCase + private lateinit var viewModel: CartViewModel @BeforeEach - fun setup() { + fun setUp() { getCartProductsUseCase = mockk() removeCartProductUseCase = mockk() increaseCartProductQuantityUseCase = mockk() decreaseCartProductQuantityUseCase = mockk() + getCartRecommendProductsUseCase = mockk() + getCatalogProductUseCase = mockk() - every { - getCartProductsUseCase.invoke(any(), any(), any()) - } answers { - thirdArg<(CartProducts) -> Unit>().invoke(DUMMY_CART_PRODUCTS_1) - } + coEvery { getCartProductsUseCase(any(), any()) } returns DUMMY_PRODUCTS_1 viewModel = CartViewModel( @@ -49,108 +55,141 @@ class CartViewModelTest { removeCartProductUseCase, increaseCartProductQuantityUseCase, decreaseCartProductQuantityUseCase, + getCartRecommendProductsUseCase, + getCatalogProductUseCase, ) } @Test - fun `장바구니 상품 목록을 불러온다`() { - // given & when & then - assertThat(viewModel.cartProducts.getOrAwaitValue().products).hasSize(5) - assertThat(viewModel.cartProducts.getOrAwaitValue().products).containsExactlyElementsIn(DUMMY_CART_PRODUCTS_1.products) + fun `초기 로딩 시 장바구니 상품 목록을 불러온다`() { + val state = viewModel.uiModel.getOrAwaitValue() + assertThat(state.cartProducts.products).containsExactlyElementsIn(DUMMY_PRODUCTS_1.products) } @Test - fun `장바구니 상품 수량을 증가시키고 수정된 상품 목록에 추가한다`() { - // given - val productId = DUMMY_PRODUCT_Detail_1.id - val newQuantity = 10 - - every { - increaseCartProductQuantityUseCase.invoke(eq(productId), any(), any()) - } answers { - thirdArg<(Int) -> Unit>().invoke(newQuantity) + fun `장바구니 상품을 제거하면 UI 상태에서 제거되고 상품 목록을 다시 불러온다`() = + runTest { + coEvery { removeCartProductUseCase(DUMMY_PRODUCT_1.cartId!!) } returns Unit + coEvery { getCartProductsUseCase(any(), any()) } returns DUMMY_PRODUCTS_1 + + setUpTestLiveData(CartProductUiModel(cartProducts = DUMMY_PRODUCTS_1), "_uiModel", viewModel) + + viewModel.removeCartProduct(DUMMY_PRODUCT_1.cartId!!, DUMMY_PRODUCT_1.productDetail.id) + advanceUntilIdle() + + val state = viewModel.uiModel.getOrAwaitValue() + assertThat(state.editedProductIds).contains(DUMMY_PRODUCT_1.productDetail.id) } - // when - viewModel.increaseCartProductQuantity(productId) + @Test + fun `장바구니 상품 수량을 증가시키면 변경된 수량이 UI에 반영된다`() = + runTest { + coEvery { increaseCartProductQuantityUseCase(any()) } returns 10 - // then - val updatedProduct = - viewModel.cartProducts - .getOrAwaitValue() - .products - .find { it.productDetail.id == productId } - assertThat(updatedProduct?.quantity).isEqualTo(newQuantity) - assertThat(viewModel.editedProductIds.getOrAwaitValue()).contains(productId) - } + setUpTestLiveData(CartProductUiModel(cartProducts = DUMMY_PRODUCTS_1), "_uiModel", viewModel) + + viewModel.increaseCartProductQuantity(DUMMY_PRODUCT_1.productDetail.id) + advanceUntilIdle() + + val state = viewModel.uiModel.getOrAwaitValue() + assertThat(state.cartProducts.getProductByProductId(DUMMY_PRODUCT_1.productDetail.id)?.quantity).isEqualTo(10) + } @Test - fun `장바구니 상품 수량을 감소시키고 장바구니 상품 목록을 불러온 뒤 수정된 상품 목록에 추가한다`() { - // given - val productId = DUMMY_PRODUCT_Detail_1.id - - every { - decreaseCartProductQuantityUseCase.invoke(eq(productId), any(), any()) - } answers { - thirdArg<(Int) -> Unit>().invoke(0) + fun `장바구니 상품 수량을 감소시키면 변경된 수량이 UI에 반영된다`() = + runTest { + coEvery { decreaseCartProductQuantityUseCase(any()) } returns 3 + + setUpTestLiveData(CartProductUiModel(cartProducts = DUMMY_PRODUCTS_1), "_uiModel", viewModel) + + viewModel.decreaseCartProductQuantity(DUMMY_PRODUCT_1.productDetail.id) + advanceUntilIdle() + + val state = viewModel.uiModel.getOrAwaitValue() + assertThat(state.cartProducts.getProductByProductId(DUMMY_PRODUCT_1.productDetail.id)?.quantity).isEqualTo(3) } - // when - viewModel.decreaseCartProductQuantity(productId) + @Test + fun `장바구니 상품의 선택 상태를 토글하면 상태가 반전된다`() { + setUpTestLiveData(CartProductUiModel(cartProducts = DUMMY_PRODUCTS_1), "_uiModel", viewModel) + + val before = + viewModel.uiModel + .getOrAwaitValue() + .cartProducts + .getProductByCartId(DUMMY_PRODUCT_1.cartId!!) + ?.isSelected + + viewModel.toggleCartProductSelection(DUMMY_PRODUCT_1.cartId!!) + + val after = + viewModel.uiModel + .getOrAwaitValue() + .cartProducts + .getProductByCartId(DUMMY_PRODUCT_1.cartId!!) + ?.isSelected - // then - verify { getCartProductsUseCase.invoke(any(), any(), any()) } - assertThat(viewModel.editedProductIds.getOrAwaitValue()).contains(productId) + assertThat(before).isNotEqualTo(after) } @Test - fun `장바구니 상품을 제거하고 상품 목록을 불러온다`() { - // given - val productId = DUMMY_PRODUCT_Detail_1.id - - every { removeCartProductUseCase.invoke(productId) } just Runs - every { getCartProductsUseCase.invoke(any(), any(), any()) } answers { - thirdArg<(CartProducts) -> Unit>().invoke(DUMMY_CART_PRODUCTS_1) - } + fun `전체 장바구니 상품의 선택 상태를 토글하면 전체 상태가 반전된다`() { + setUpTestLiveData(CartProductUiModel(cartProducts = DUMMY_PRODUCTS_1), "_uiModel", viewModel) - // when - viewModel.removeCartProduct(productId) + viewModel.toggleAllCartProductsSelection() - // then - verify { removeCartProductUseCase.invoke(productId) } - verify { getCartProductsUseCase.invoke(any(), any(), any()) } - assertThat(viewModel.editedProductIds.getOrAwaitValue()).contains(productId) + val state = viewModel.uiModel.getOrAwaitValue() + assertThat(state.cartProducts.products.all { it.isSelected }).isTrue() } @Test - fun `장바구니 상품 페이지를 증가시키고 상품 목록을 불러온다`() { - // given - clearMocks(getCartProductsUseCase) - - every { - getCartProductsUseCase.invoke(any(), any(), any()) - } answers { - thirdArg<(CartProducts) -> Unit>().invoke(DUMMY_CART_PRODUCTS_1) + fun `추천 상품 수량을 증가시키면 반영된다`() = + runTest { + val updatedQuantity = 2 + val updated = DUMMY_PRODUCT_1.copy(quantity = updatedQuantity) + + coEvery { increaseCartProductQuantityUseCase(any()) } returns updatedQuantity + + setUpTestLiveData(CartProductUiModel(recommendedProducts = DUMMY_PRODUCTS_1), "_uiModel", viewModel) + + viewModel.increaseRecommendedProductQuantity(DUMMY_PRODUCT_1.productDetail.id) + advanceUntilIdle() + + val state = viewModel.uiModel.getOrAwaitValue() + assertThat(state.recommendedProducts.getProductByProductId(updated.productDetail.id)?.quantity).isEqualTo(updatedQuantity) } - // when - viewModel.increasePage() + @Test + fun `추천 상품 수량을 감소시키면 반영된다`() = + runTest { + val updatedQuantity = 1 + val updated = DUMMY_PRODUCT_1.copy(quantity = updatedQuantity) - // then - verify { getCartProductsUseCase.invoke(2, 5, any()) } - assertThat(viewModel.pageState.getOrAwaitValue().current).isEqualTo(2) - } + coEvery { decreaseCartProductQuantityUseCase(any()) } returns updatedQuantity + + setUpTestLiveData(CartProductUiModel(recommendedProducts = DUMMY_PRODUCTS_1), "_uiModel", viewModel) + + viewModel.decreaseRecommendedProductQuantity(DUMMY_PRODUCT_1.productDetail.id) + advanceUntilIdle() + + val state = viewModel.uiModel.getOrAwaitValue() + assertThat(state.recommendedProducts.getProductByProductId(updated.productDetail.id)?.quantity).isEqualTo(updatedQuantity) + } @Test - fun `장바구니 최대 상품 페이지 수를 불러온다`() { - // given - val expected = 100 + fun `선택된 장바구니 및 추천 상품 ID를 반환한다`() { + val modifiedState = + CartProductUiModel( + cartProducts = DUMMY_PRODUCTS_1.toggleAllSelection(), + recommendedProducts = DUMMY_PRODUCTS_1.toggleAllSelection(), + ) + + setUpTestLiveData(modifiedState, "_uiModel", viewModel) - // when - viewModel.updateTotalPage(expected) + val selectedIds = viewModel.getSelectedProductIds() + val expectedIds = (DUMMY_PRODUCTS_1.products + DUMMY_PRODUCTS_1.products).map { it.productDetail.id }.toSet() - // then - assertThat(viewModel.pageState.getOrAwaitValue().total).isEqualTo(expected) + assertThat(selectedIds).isEqualTo(expectedIds) } @AfterEach diff --git a/app/src/test/java/woowacourse/shopping/ui/catalog/CatalogViewModelTest.kt b/app/src/test/java/woowacourse/shopping/ui/catalog/CatalogViewModelTest.kt index 40bbf9ccd1..072996eba6 100644 --- a/app/src/test/java/woowacourse/shopping/ui/catalog/CatalogViewModelTest.kt +++ b/app/src/test/java/woowacourse/shopping/ui/catalog/CatalogViewModelTest.kt @@ -1,196 +1,210 @@ package woowacourse.shopping.ui.catalog +import android.util.Log import com.google.common.truth.Truth.assertThat +import io.mockk.coEvery import io.mockk.every import io.mockk.mockk +import io.mockk.mockkStatic import io.mockk.unmockkAll +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith -import woowacourse.shopping.domain.model.HistoryProduct -import woowacourse.shopping.domain.model.Product -import woowacourse.shopping.domain.model.Products import woowacourse.shopping.domain.usecase.DecreaseCartProductQuantityUseCase +import woowacourse.shopping.domain.usecase.GetCartProductsQuantityUseCase import woowacourse.shopping.domain.usecase.GetCatalogProductUseCase -import woowacourse.shopping.domain.usecase.GetCatalogProductsByIdsUseCase +import woowacourse.shopping.domain.usecase.GetCatalogProductsByProductIdsUseCase import woowacourse.shopping.domain.usecase.GetCatalogProductsUseCase import woowacourse.shopping.domain.usecase.GetSearchHistoryUseCase import woowacourse.shopping.domain.usecase.IncreaseCartProductQuantityUseCase -import woowacourse.shopping.model.DUMMY_CATALOG_PRODUCTS_1 -import woowacourse.shopping.model.DUMMY_CATALOG_PRODUCTS_2 -import woowacourse.shopping.model.DUMMY_CATALOG_PRODUCT_1 -import woowacourse.shopping.model.DUMMY_CATALOG_PRODUCT_2 import woowacourse.shopping.model.DUMMY_HISTORY_PRODUCT_1 -import woowacourse.shopping.model.DUMMY_PRODUCT_Detail_1 +import woowacourse.shopping.model.DUMMY_PRODUCTS_1 +import woowacourse.shopping.model.DUMMY_PRODUCTS_2 +import woowacourse.shopping.model.DUMMY_PRODUCTS_3 +import woowacourse.shopping.model.DUMMY_PRODUCT_1 +import woowacourse.shopping.ui.model.CatalogUiModel +import woowacourse.shopping.util.CoroutinesTestExtension import woowacourse.shopping.util.InstantTaskExecutorExtension import woowacourse.shopping.util.getOrAwaitValue import woowacourse.shopping.util.setUpTestLiveData +@OptIn(ExperimentalCoroutinesApi::class) @ExtendWith(InstantTaskExecutorExtension::class) +@ExtendWith(CoroutinesTestExtension::class) class CatalogViewModelTest { private lateinit var getCatalogProductsUseCase: GetCatalogProductsUseCase private lateinit var getCatalogProductUseCase: GetCatalogProductUseCase - private lateinit var getCatalogProductsByIdsUseCase: GetCatalogProductsByIdsUseCase + private lateinit var getCatalogProductsByProductIdsUseCase: GetCatalogProductsByProductIdsUseCase private lateinit var getSearchHistoryUseCase: GetSearchHistoryUseCase private lateinit var increaseCartProductQuantityUseCase: IncreaseCartProductQuantityUseCase private lateinit var decreaseCartProductQuantityUseCase: DecreaseCartProductQuantityUseCase + private lateinit var getCartProductsQuantityUseCase: GetCartProductsQuantityUseCase + private lateinit var viewModel: CatalogViewModel @BeforeEach fun setup() { getCatalogProductsUseCase = mockk() getCatalogProductUseCase = mockk() - getCatalogProductsByIdsUseCase = mockk() + getCatalogProductsByProductIdsUseCase = mockk() getSearchHistoryUseCase = mockk() increaseCartProductQuantityUseCase = mockk() decreaseCartProductQuantityUseCase = mockk() + getCartProductsQuantityUseCase = mockk() - every { - getCatalogProductsUseCase.invoke(any(), any(), any()) - } answers { - thirdArg<(Products) -> Unit>().invoke(DUMMY_CATALOG_PRODUCTS_1) - } + coEvery { getCatalogProductsUseCase(any(), any()) } returns DUMMY_PRODUCTS_1 viewModel = CatalogViewModel( getCatalogProductsUseCase, getCatalogProductUseCase, - getCatalogProductsByIdsUseCase, + getCatalogProductsByProductIdsUseCase, getSearchHistoryUseCase, increaseCartProductQuantityUseCase, decreaseCartProductQuantityUseCase, + getCartProductsQuantityUseCase, ) + + mockkStatic(Log::class) + every { Log.e(any(), any()) } returns 0 } @Test - fun `전체 상품 목록과 다른 상품 존재 여부를 불러온다`() { - // given - setUpTestLiveData(DUMMY_CATALOG_PRODUCTS_1, "_catalogProducts", viewModel) + fun `ViewModel이 초기화되면 상품 목록을 불러온다`() { + val state = viewModel.uiModel.getOrAwaitValue() + assertThat(state.catalogProducts.products).containsExactlyElementsIn(DUMMY_PRODUCTS_1.products) + assertThat(state.isProductsLoading).isFalse() + } - val newProduct = DUMMY_CATALOG_PRODUCT_2 - val newData = Products(products = listOf(newProduct), hasMore = false) + @Test + fun `상품을 더 불러오면 페이지가 증가하고 기존 목록에 추가된다`() = + runTest { + coEvery { getCatalogProductsUseCase(any(), any()) } returns DUMMY_PRODUCTS_3 + + setUpTestLiveData( + CatalogUiModel(catalogProducts = DUMMY_PRODUCTS_1), + "_uiModel", + viewModel, + ) + + viewModel.loadMoreCatalogProducts() + advanceUntilIdle() - every { - getCatalogProductsUseCase.invoke(any(), any(), any()) - } answers { - thirdArg<(Products) -> Unit>().invoke(newData) + val state = viewModel.uiModel.getOrAwaitValue() + assertThat(state.catalogProducts.products).containsExactlyElementsIn( + DUMMY_PRODUCTS_1.products + DUMMY_PRODUCTS_3.products, + ) } - // when - viewModel.loadCartProducts() + @Test + fun `장바구니 수량을 증가시키면 상품 수량에 반영된다`() = + runTest { + val updatedProduct = DUMMY_PRODUCT_1.copy(quantity = 10) - // then - val result = viewModel.products.getOrAwaitValue() - assertThat(result.products).containsExactlyElementsIn(DUMMY_CATALOG_PRODUCTS_1.products + newProduct) - assertThat(result.hasMore).isFalse() - } + coEvery { increaseCartProductQuantityUseCase(any()) } returns 10 + coEvery { getCatalogProductUseCase(DUMMY_PRODUCT_1.productDetail.id) } returns updatedProduct - @Test - fun `최근에 탐색한 상품 목록을 불러온다`() { - // given - val expected = listOf(DUMMY_HISTORY_PRODUCT_1) - - every { - getSearchHistoryUseCase.invoke(any()) - } answers { - firstArg<(List) -> Unit>().invoke(expected) - } + setUpTestLiveData( + CatalogUiModel(catalogProducts = DUMMY_PRODUCTS_2), + "_uiModel", + viewModel, + ) - // when - viewModel.loadHistoryProducts() + viewModel.increaseCartProduct(DUMMY_PRODUCT_1.productDetail.id) + advanceUntilIdle() - // then - val result = viewModel.historyProducts.getOrAwaitValue() - assertThat(result).containsExactlyElementsIn(expected) - } + val result = viewModel.uiModel.getOrAwaitValue() + assertThat(result.catalogProducts.getProductByProductId(updatedProduct.productDetail.id)?.quantity).isEqualTo(10) + } @Test - fun `장바구니 상품 갯수를 증가시킨다`() { - // given - val productId = DUMMY_CATALOG_PRODUCT_1.productDetail.id - setUpTestLiveData(DUMMY_CATALOG_PRODUCTS_1, "_catalogProducts", viewModel) - - every { - increaseCartProductQuantityUseCase.invoke(eq(productId), any(), any()) - } answers { - thirdArg<(Int) -> Unit>().invoke(10) - } + fun `장바구니 수량을 감소시키면 상품 수량에 반영된다`() = + runTest { + val updatedProduct = DUMMY_PRODUCT_1.copy(quantity = 3) - // when - viewModel.increaseCartProduct(productId) + coEvery { decreaseCartProductQuantityUseCase(any()) } returns 3 + coEvery { getCatalogProductUseCase(DUMMY_PRODUCT_1.productDetail.id) } returns updatedProduct - // then - val updated = viewModel.products.getOrAwaitValue() - assertThat(updated.products.first { it.productDetail.id == productId }.quantity).isEqualTo( - 10, - ) - } + setUpTestLiveData( + CatalogUiModel(catalogProducts = DUMMY_PRODUCTS_2), + "_uiModel", + viewModel, + ) - @Test - fun `장바구니 상품 갯수를 감소시킨다`() { - // given - val productId = DUMMY_CATALOG_PRODUCT_1.productDetail.id - setUpTestLiveData(DUMMY_CATALOG_PRODUCTS_1, "_catalogProducts", viewModel) - - every { - decreaseCartProductQuantityUseCase.invoke(eq(productId), any(), any()) - } answers { - thirdArg<(Int) -> Unit>().invoke(1) + viewModel.decreaseCartProduct(DUMMY_PRODUCT_1.productDetail.id) + advanceUntilIdle() + + val result = viewModel.uiModel.getOrAwaitValue() + assertThat(result.catalogProducts.getProductByProductId(updatedProduct.productDetail.id)?.quantity).isEqualTo(3) } - // when - viewModel.decreaseCartProduct(productId) + @Test + fun `특정 상품 정보를 불러오면 상품 목록에 반영된다`() = + runTest { + val updatedProduct = DUMMY_PRODUCT_1.copy(quantity = 1234) - // then - val updated = viewModel.products.getOrAwaitValue() - assertThat(updated.products.first { it.productDetail.id == productId }.quantity).isEqualTo(1) - } + coEvery { getCatalogProductUseCase(DUMMY_PRODUCT_1.productDetail.id) } returns updatedProduct - @Test - fun `특정 상품의 정보를 불러와 상품 목록에 반영한다`() { - // given - val productId = DUMMY_CATALOG_PRODUCT_1.productDetail.id - setUpTestLiveData(DUMMY_CATALOG_PRODUCTS_1, "_catalogProducts", viewModel) + setUpTestLiveData( + CatalogUiModel(catalogProducts = DUMMY_PRODUCTS_1), + "_uiModel", + viewModel, + ) - val updatedProduct = DUMMY_CATALOG_PRODUCT_1.copy(quantity = 100) + viewModel.loadCartProduct(DUMMY_PRODUCT_1.productDetail.id) + advanceUntilIdle() - every { - getCatalogProductUseCase.invoke(eq(productId), any()) - } answers { - secondArg<(Product?) -> Unit>().invoke(updatedProduct) + val result = viewModel.uiModel.getOrAwaitValue() + assertThat(result.catalogProducts.getProductByProductId(updatedProduct.productDetail.id)?.quantity).isEqualTo(1234) } - // when - viewModel.loadCartProduct(productId) + @Test + fun `여러 상품 정보를 불러오면 상품 목록에 반영된다`() = + runTest { + val updated = listOf(DUMMY_PRODUCT_1.copy(quantity = 1234)) - // then - val result = viewModel.products.getOrAwaitValue() - assertThat(result.products.first { it.productDetail.id == productId }.quantity).isEqualTo( - 100, - ) - } + coEvery { getCatalogProductsByProductIdsUseCase(any()) } returns updated + + setUpTestLiveData( + CatalogUiModel(catalogProducts = DUMMY_PRODUCTS_2), + "_uiModel", + viewModel, + ) + + viewModel.loadCartProductsByProductIds(listOf(DUMMY_PRODUCT_1.productDetail.id)) + advanceUntilIdle() + + val result = viewModel.uiModel.getOrAwaitValue() + assertThat(result.catalogProducts.products).containsExactlyElementsIn(updated) + } @Test - fun `특정 상품들의 정보를 불러와 상품 목록에 반영한다`() { - // given - setUpTestLiveData(DUMMY_CATALOG_PRODUCTS_2, "_catalogProducts", viewModel) + fun `최근 검색 상품 목록을 불러온다`() = + runTest { + coEvery { getSearchHistoryUseCase() } returns listOf(DUMMY_HISTORY_PRODUCT_1) - val updatedProducts = listOf(DUMMY_CATALOG_PRODUCT_1.copy(quantity = 6)) + viewModel.loadHistoryProducts() + advanceUntilIdle() - every { - getCatalogProductsByIdsUseCase.invoke(any(), any()) - } answers { - secondArg<(List) -> Unit>().invoke(updatedProducts) + val result = viewModel.uiModel.getOrAwaitValue() + assertThat(result.historyProducts).containsExactly(DUMMY_HISTORY_PRODUCT_1) } - // when - viewModel.loadCartProductsByIds(listOf(DUMMY_PRODUCT_Detail_1.id)) + @Test + fun `장바구니 상품 수량을 불러와서 상품 목록에 반영한다`() = + runTest { + coEvery { getCartProductsQuantityUseCase() } returns 10 - // then - val result = viewModel.products.getOrAwaitValue() - assertThat(result.products).containsExactlyElementsIn(updatedProducts) - } + viewModel.loadCartProductsQuantity() + advanceUntilIdle() + + val result = viewModel.uiModel.getOrAwaitValue() + assertThat(result.cartProductsQuantity).isEqualTo(10) + } @AfterEach fun tearDown() { diff --git a/app/src/test/java/woowacourse/shopping/ui/payment/PaymentViewModelTest.kt b/app/src/test/java/woowacourse/shopping/ui/payment/PaymentViewModelTest.kt new file mode 100644 index 0000000000..8d93e0e26c --- /dev/null +++ b/app/src/test/java/woowacourse/shopping/ui/payment/PaymentViewModelTest.kt @@ -0,0 +1,122 @@ +package woowacourse.shopping.ui.payment + +import android.util.Log +import com.google.common.truth.Truth.assertThat +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import woowacourse.shopping.domain.usecase.GetCatalogProductsByProductIdsUseCase +import woowacourse.shopping.domain.usecase.GetCouponsUseCase +import woowacourse.shopping.domain.usecase.OrderProductsUseCase +import woowacourse.shopping.model.DUMMY_COUPONS_1 +import woowacourse.shopping.model.DUMMY_LOCAL_DATE_TIME_1 +import woowacourse.shopping.model.DUMMY_PRODUCTS_1 +import woowacourse.shopping.model.DUMMY_PRODUCTS_3 +import woowacourse.shopping.util.CoroutinesTestExtension +import woowacourse.shopping.util.InstantTaskExecutorExtension +import woowacourse.shopping.util.getOrAwaitValue + +@OptIn(ExperimentalCoroutinesApi::class) +@ExtendWith(InstantTaskExecutorExtension::class) +@ExtendWith(CoroutinesTestExtension::class) +class PaymentViewModelTest { + private lateinit var getCatalogProductsByProductIdsUseCase: GetCatalogProductsByProductIdsUseCase + private lateinit var getCouponsUseCase: GetCouponsUseCase + private lateinit var orderProductsUseCase: OrderProductsUseCase + + private lateinit var viewModel: PaymentViewModel + + @BeforeEach + fun setUp() { + getCatalogProductsByProductIdsUseCase = mockk() + getCouponsUseCase = mockk() + orderProductsUseCase = mockk() + + viewModel = + PaymentViewModel( + getCatalogProductsByProductIdsUseCase, + getCouponsUseCase, + orderProductsUseCase, + ) + + mockkStatic(Log::class) + every { Log.e(any(), any()) } returns 0 + } + + @Test + fun `상품 ID 목록을 전달하면 해당 상품들과 쿠폰을 불러온다`() = + runTest { + coEvery { getCatalogProductsByProductIdsUseCase(any()) } returns DUMMY_PRODUCTS_3.products + coEvery { getCouponsUseCase() } returns DUMMY_COUPONS_1 + + viewModel.loadProducts(DUMMY_PRODUCTS_3.products.map { it.productDetail.id }) + advanceUntilIdle() + + viewModel.loadCoupons(DUMMY_PRODUCTS_3, DUMMY_LOCAL_DATE_TIME_1) + advanceUntilIdle() + + val state = viewModel.uiModel.getOrAwaitValue() + + val expectedProducts = DUMMY_PRODUCTS_3.products.map { it.copy(isSelected = true) } + + assertThat(state.products.products).containsExactlyElementsIn(expectedProducts) + assertThat(state.coupons.value).containsExactlyElementsIn(DUMMY_COUPONS_1.value) + assertThat(state.connectionErrorMessage).isNull() + } + + @Test + fun `쿠폰을 선택하면 선택된 쿠폰만 참이 된다`() = + runTest { + coEvery { getCatalogProductsByProductIdsUseCase(any()) } returns DUMMY_PRODUCTS_3.products + coEvery { getCouponsUseCase() } returns DUMMY_COUPONS_1 + + viewModel.loadProducts(DUMMY_PRODUCTS_3.products.map { it.productDetail.id }) + advanceUntilIdle() + + viewModel.loadCoupons(DUMMY_PRODUCTS_3, DUMMY_LOCAL_DATE_TIME_1) + advanceUntilIdle() + + viewModel.selectCoupon( + DUMMY_COUPONS_1.value + .first() + .detail.id, + DUMMY_LOCAL_DATE_TIME_1, + ) + + val state = viewModel.uiModel.getOrAwaitValue() + val selected = state.coupons.value.filter { it.isSelected } + assertThat(selected).hasSize(1) + assertThat(state.price.result).isGreaterThan(0) + } + + @Test + fun `주문 성공 시 isOrderSuccess가 참이다`() = + runTest { + coEvery { getCatalogProductsByProductIdsUseCase(any()) } returns DUMMY_PRODUCTS_1.products + coEvery { getCouponsUseCase() } returns DUMMY_COUPONS_1 + coEvery { orderProductsUseCase(any()) } returns Unit + + viewModel.loadProducts(DUMMY_PRODUCTS_1.products.map { it.productDetail.id }) + advanceUntilIdle() + + viewModel.orderProducts() + advanceUntilIdle() + + val state = viewModel.uiModel.getOrAwaitValue() + assertThat(state.isOrderSuccess).isTrue() + } + + @AfterEach + fun tearDown() { + unmockkAll() + } +} diff --git a/app/src/test/java/woowacourse/shopping/ui/productdetail/ProductDetailDetailViewModelTest.kt b/app/src/test/java/woowacourse/shopping/ui/productdetail/ProductDetailDetailViewModelTest.kt deleted file mode 100644 index 3c947fc443..0000000000 --- a/app/src/test/java/woowacourse/shopping/ui/productdetail/ProductDetailDetailViewModelTest.kt +++ /dev/null @@ -1,143 +0,0 @@ -package woowacourse.shopping.ui.productdetail - -import com.google.common.truth.Truth.assertThat -import io.mockk.every -import io.mockk.mockk -import io.mockk.unmockkAll -import io.mockk.verify -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.extension.ExtendWith -import woowacourse.shopping.domain.model.CartProduct -import woowacourse.shopping.domain.model.HistoryProduct -import woowacourse.shopping.domain.model.Product -import woowacourse.shopping.domain.usecase.AddSearchHistoryUseCase -import woowacourse.shopping.domain.usecase.GetRecentSearchHistoryUseCase -import woowacourse.shopping.domain.usecase.UpdateCartProductUseCase -import woowacourse.shopping.model.DUMMY_CATALOG_PRODUCT_1 -import woowacourse.shopping.model.DUMMY_HISTORY_PRODUCT_1 -import woowacourse.shopping.model.DUMMY_PRODUCT_Detail_1 -import woowacourse.shopping.util.InstantTaskExecutorExtension -import woowacourse.shopping.util.getOrAwaitValue -import woowacourse.shopping.util.setUpTestLiveData - -@ExtendWith(InstantTaskExecutorExtension::class) -class ProductDetailDetailViewModelTest { - private lateinit var getProductDetailUseCase: GetProductDetailUseCase - private lateinit var getRecentSearchHistoryUseCase: GetRecentSearchHistoryUseCase - private lateinit var addSearchHistoryUseCase: AddSearchHistoryUseCase - private lateinit var updateCartProductUseCase: UpdateCartProductUseCase - private lateinit var viewModel: ProductDetailViewModel - - @BeforeEach - fun setup() { - getProductDetailUseCase = mockk() - getRecentSearchHistoryUseCase = mockk() - addSearchHistoryUseCase = mockk(relaxed = true) - updateCartProductUseCase = mockk(relaxed = true) - - viewModel = - ProductDetailViewModel( - getProductDetailUseCase, - getRecentSearchHistoryUseCase, - addSearchHistoryUseCase, - updateCartProductUseCase, - ) - } - - @Test - fun `상품 상세 정보를 불러온다`() { - // given - val expected = DUMMY_CATALOG_PRODUCT_1 - - every { - getProductDetailUseCase.invoke(DUMMY_PRODUCT_Detail_1.id, any()) - } answers { - secondArg<(Product?) -> Unit>().invoke(expected) - } - - // when - viewModel.loadProductDetail(DUMMY_PRODUCT_Detail_1.id) - - // then - val actual = viewModel.product.getOrAwaitValue() - assertThat(actual).isEqualTo(expected) - } - - @Test - fun `가장 최근에 탐색한 상품을 불러온다`() { - // given - every { - getRecentSearchHistoryUseCase.invoke(any()) - } answers { - firstArg<(HistoryProduct?) -> Unit>().invoke(DUMMY_HISTORY_PRODUCT_1) - } - - // when - viewModel.loadLastHistoryProduct() - - // then - val actual = viewModel.lastHistoryProduct.getOrAwaitValue() - assertThat(actual).isEqualTo(DUMMY_HISTORY_PRODUCT_1) - } - - @Test - fun `최근 탐색한 상품 목록에 현재 상품을 추가한다`() { - // given - val productId = DUMMY_PRODUCT_Detail_1.id - - // when - viewModel.addHistoryProduct(productId) - - // then - verify { addSearchHistoryUseCase.invoke(productId) } - } - - @Test - fun `상품 수량을 증가시킨다`() { - // given - val initial = DUMMY_CATALOG_PRODUCT_1 - setUpTestLiveData(initial, "_catalogProduct", viewModel) - - // when - viewModel.increaseCartProductQuantity() - - // then - val updated = viewModel.product.getOrAwaitValue() - assertThat(updated.quantity).isEqualTo(6) - } - - @Test - fun `상품 수량을 감소시킨다`() { - // given - val initial = DUMMY_CATALOG_PRODUCT_1 - setUpTestLiveData(initial, "_catalogProduct", viewModel) - - // when - viewModel.decreaseCartProductQuantity() - - // then - val updated = viewModel.product.getOrAwaitValue() - assertThat(updated.quantity).isEqualTo(4) - } - - @Test - fun `장바구니에 변경된 상품 수량을 반영한다`() { - // given - val catalogProduct = DUMMY_CATALOG_PRODUCT_1 - setUpTestLiveData(catalogProduct, "_catalogProduct", viewModel) - - // when - viewModel.updateCartProduct() - - // then - verify { updateCartProductUseCase.invoke(CartProduct(DUMMY_PRODUCT_Detail_1, 5)) } - assertThat(viewModel.onCartProductAddSuccess.getValue()).isTrue() - } - - @AfterEach - fun tearDown() { - unmockkAll() - } -} diff --git a/app/src/test/java/woowacourse/shopping/ui/productdetail/ProductDetailViewModelTest.kt b/app/src/test/java/woowacourse/shopping/ui/productdetail/ProductDetailViewModelTest.kt new file mode 100644 index 0000000000..5716f8e1df --- /dev/null +++ b/app/src/test/java/woowacourse/shopping/ui/productdetail/ProductDetailViewModelTest.kt @@ -0,0 +1,138 @@ +package woowacourse.shopping.ui.productdetail + +import com.google.common.truth.Truth.assertThat +import io.mockk.coEvery +import io.mockk.mockk +import io.mockk.unmockkAll +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import woowacourse.shopping.domain.usecase.AddSearchHistoryUseCase +import woowacourse.shopping.domain.usecase.GetCatalogProductUseCase +import woowacourse.shopping.domain.usecase.GetRecentSearchHistoryUseCase +import woowacourse.shopping.domain.usecase.UpdateCartProductUseCase +import woowacourse.shopping.model.DUMMY_HISTORY_PRODUCT_1 +import woowacourse.shopping.model.DUMMY_PRODUCT_1 +import woowacourse.shopping.ui.model.ProductDetailUiModel +import woowacourse.shopping.util.CoroutinesTestExtension +import woowacourse.shopping.util.InstantTaskExecutorExtension +import woowacourse.shopping.util.getOrAwaitValue +import woowacourse.shopping.util.setUpTestLiveData + +@OptIn(ExperimentalCoroutinesApi::class) +@ExtendWith(InstantTaskExecutorExtension::class) +@ExtendWith(CoroutinesTestExtension::class) +class ProductDetailViewModelTest { + private lateinit var getCatalogProductUseCase: GetCatalogProductUseCase + private lateinit var getRecentSearchHistoryUseCase: GetRecentSearchHistoryUseCase + private lateinit var addSearchHistoryUseCase: AddSearchHistoryUseCase + private lateinit var updateCartProductUseCase: UpdateCartProductUseCase + + private lateinit var viewModel: ProductDetailViewModel + + @BeforeEach + fun setup() { + getCatalogProductUseCase = mockk() + getRecentSearchHistoryUseCase = mockk() + addSearchHistoryUseCase = mockk(relaxed = true) + updateCartProductUseCase = mockk() + + viewModel = + ProductDetailViewModel( + getCatalogProductUseCase, + getRecentSearchHistoryUseCase, + addSearchHistoryUseCase, + updateCartProductUseCase, + ) + } + + @Test + fun `상품 상세 정보를 불러온다`() = + runTest { + // given + val expected = DUMMY_PRODUCT_1 + coEvery { getCatalogProductUseCase(expected.productDetail.id) } returns expected + + // when + viewModel.loadProductDetail(expected.productDetail.id) + advanceUntilIdle() + + // then + val state = viewModel.uiModel.getOrAwaitValue() + assertThat(state.product).isEqualTo(expected) + assertThat(state.connectionErrorMessage).isNull() + } + + @Test + fun `최근 탐색한 상품을 불러온다`() = + runTest { + // given + val expected = DUMMY_HISTORY_PRODUCT_1 + coEvery { getRecentSearchHistoryUseCase() } returns expected + + // when + viewModel.loadLastHistoryProduct() + advanceUntilIdle() + + // then + val state = viewModel.uiModel.getOrAwaitValue() + assertThat(state.lastHistoryProduct).isEqualTo(expected) + } + + @Test + fun `장바구니 수량을 증가시키면 상품 수량이 1 증가한다`() { + // given + val original = DUMMY_PRODUCT_1 + setUpTestLiveData(ProductDetailUiModel(product = original), "_uiModel", viewModel) + + // when + viewModel.increaseCartProductQuantity() + + // then + val state = viewModel.uiModel.getOrAwaitValue() + assertThat(state.product.quantity).isEqualTo(original.quantity + 1) + } + + @Test + fun `장바구니 수량을 감소시키면 상품 수량이 1 감소한다`() { + // given + val original = DUMMY_PRODUCT_1 + setUpTestLiveData(ProductDetailUiModel(product = original), "_uiModel", viewModel) + + // when + viewModel.decreaseCartProductQuantity() + + // then + val state = viewModel.uiModel.getOrAwaitValue() + assertThat(state.product.quantity).isEqualTo(original.quantity - 1) + } + + @Test + fun `장바구니 수량 업데이트 성공 여부를 State에 반영한다`() = + runTest { + // given + val product = DUMMY_PRODUCT_1 + setUpTestLiveData(ProductDetailUiModel(product = product), "_uiModel", viewModel) + + coEvery { + updateCartProductUseCase(productId = product.productDetail.id, cartId = product.cartId, quantity = product.quantity) + } returns Unit + + // when + viewModel.updateCartProduct() + advanceUntilIdle() + + // then + val state = viewModel.uiModel.getOrAwaitValue() + assertThat(state.isCartProductUpdateSuccess).isTrue() + } + + @AfterEach + fun tearDown() { + unmockkAll() + } +} diff --git a/app/src/test/java/woowacourse/shopping/util/CoroutinesTestExtension.kt b/app/src/test/java/woowacourse/shopping/util/CoroutinesTestExtension.kt new file mode 100644 index 0000000000..298c34ef4d --- /dev/null +++ b/app/src/test/java/woowacourse/shopping/util/CoroutinesTestExtension.kt @@ -0,0 +1,25 @@ +package woowacourse.shopping.util + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.jupiter.api.extension.AfterEachCallback +import org.junit.jupiter.api.extension.BeforeEachCallback +import org.junit.jupiter.api.extension.ExtensionContext + +@ExperimentalCoroutinesApi +class CoroutinesTestExtension( + private val dispatcher: TestDispatcher = UnconfinedTestDispatcher(), +) : BeforeEachCallback, + AfterEachCallback { + override fun beforeEach(context: ExtensionContext?) { + Dispatchers.setMain(dispatcher) + } + + override fun afterEach(context: ExtensionContext?) { + Dispatchers.resetMain() + } +}