From 7e620107911cca942becc3334b3e3500ee53edd0 Mon Sep 17 00:00:00 2001 From: Taewan Park Date: Fri, 1 Dec 2023 03:06:14 +0900 Subject: [PATCH] v0.2.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새로고침 레이아웃 추가 - 가격 수정 기능 구현 - 상세페이지 트래킹 목록에 추가 기능 구현 - 서브모듈 연결 - 차트 ui 추가 - Ping API 구현 - Logger 미들웨어 추가 - 최신 refreshToken 무효화 - MongoDB 연동 - 상품 정보 Cache, 기간별 상품 가격 변동 조회 - 주기적으로 상품 조회, DB에 최신 정보 반영 - 상품 인기 순위 섞이는 버그 수정 - 상품 목록 API 응답 내용 수정 - MongoDB Schema 수정 - 추천/추적 상품 목록 조회 API에서 가격 그래프 데이터 및 최신 가격 정보 동기화 차트 레포 주소 : https://github.com/Taewan-P/material-android-chart Co-Authored-By: EunhoKang Co-Authored-By: ootr47 <83055885+ootr47@users.noreply.github.com> Co-Authored-By: 손문기 <39684860+Muungi@users.noreply.github.com> Co-Authored-By: ByeongIk Choi --- .github/workflows/firebase-deploy.yml | 3 + .gitmodules | 4 + android/.idea/gradle.xml | 1 + android/app/build.gradle.kts | 10 +- .../app/priceguard/data/dto/PriceDataDTO.kt | 10 + .../priceguard/data/dto/PricePatchRequest.kt | 9 + .../priceguard/data/dto/PricePatchResponse.kt | 9 + .../app/priceguard/data/dto/ProductDTO.kt | 18 +- .../data/dto/ProductDeleteResponse.kt | 8 - .../priceguard/data/dto/ProductErrorState.kt | 9 + .../data/dto/ProductListResponse.kt | 14 +- .../priceguard/data/dto/ProductResponse.kt | 31 +- .../priceguard/data/dto/ProductVerifyDTO.kt | 10 +- .../data/dto/ProductVerifyResponse.kt | 10 +- .../data/dto/RecommendProductDTO.kt | 17 +- .../data/dto/RecommendProductResponse.kt | 15 +- .../data/graph/GraphDataConverter.kt | 24 + .../priceguard/data/graph/ProductChartData.kt | 9 + .../data/graph/ProductChartDataset.kt | 15 + .../data/graph/ProductChartGridLine.kt | 8 + .../app/priceguard/data/network/ProductAPI.kt | 6 +- .../data/network/ProductRepositoryResult.kt | 10 + .../data/repository/ProductRepository.kt | 26 +- .../data/repository/ProductRepositoryImpl.kt | 338 +++++------ .../priceguard/di/ProductRepositoryModule.kt | 9 +- .../priceguard/ui/additem/AddItemActivity.kt | 21 + .../confirm/ConfirmItemLinkFragment.kt | 3 +- .../additem/link/RegisterItemLinkFragment.kt | 58 +- .../additem/link/RegisterItemLinkViewModel.kt | 53 +- .../setprice/SetTargetPriceFragment.kt | 154 +++++- .../setprice/SetTargetPriceViewModel.kt | 43 +- .../priceguard/ui/detail/DetailActivity.kt | 112 +++- .../ui/detail/ProductDetailViewModel.kt | 120 ++-- .../app/priceguard/ui/home/ProductSummary.kt | 15 +- .../ui/home/ProductSummaryAdapter.kt | 41 ++ .../ui/home/list/ProductListFragment.kt | 49 +- .../ui/home/list/ProductListViewModel.kt | 61 +- .../ui/home/mypage/MyPageFragment.kt | 2 +- .../recommend/RecommendedProductFragment.kt | 46 +- .../recommend/RecommendedProductViewModel.kt | 52 +- .../priceguard/ui/util/ui/NetworkDialog.kt | 13 + .../src/main/res/drawable/bg_round_corner.xml | 5 + .../res/drawable/bg_round_corner_error.xml | 13 + .../src/main/res/layout/activity_detail.xml | 523 +++++++++--------- .../main/res/layout/fragment_product_list.xml | 88 +-- .../layout/fragment_recommended_product.xml | 76 +-- .../layout/fragment_register_item_link.xml | 17 +- .../res/layout/fragment_set_target_price.xml | 8 +- .../main/res/layout/item_product_summary.xml | 15 +- .../app/src/main/res/navigation/nav_graph.xml | 6 + android/app/src/main/res/values/strings.xml | 17 + android/app/src/main/res/values/themes.xml | 18 + android/release_notes.txt | 17 +- android/settings.gradle | 1 + backend/package-lock.json | 323 ++++++++++- backend/package.json | 3 + backend/src/app.controller.spec.ts | 2 +- backend/src/app.controller.ts | 27 +- backend/src/app.module.ts | 18 +- backend/src/app.service.ts | 2 +- backend/src/auth/jwt/jwt.service.ts | 13 +- backend/src/auth/jwt/jwt.strategy.ts | 2 +- backend/src/constants.ts | 6 + backend/src/dto/auth.swagger.dto.ts | 10 + backend/src/dto/price.data.dto.ts | 5 + backend/src/dto/product.details.dto.ts | 3 + backend/src/dto/product.price.dto.ts | 5 + backend/src/dto/product.recommend.dto.ts | 10 + backend/src/dto/product.swagger.dto.ts | 100 ++-- backend/src/dto/product.tracking.dto.ts | 4 +- backend/src/dto/user.swagger.dto.ts | 4 +- .../src/exceptions/http.exception.filter.ts | 7 +- backend/src/middlewares/logger.middleware.ts | 17 + backend/src/product/product.controller.ts | 16 +- backend/src/product/product.module.ts | 7 +- backend/src/product/product.service.ts | 124 ++++- .../src/product/trackingProduct.repository.ts | 6 +- backend/src/schema/product.schema.ts | 26 + backend/src/utils/openapi.11st.ts | 8 +- 79 files changed, 2073 insertions(+), 945 deletions(-) create mode 100644 .gitmodules create mode 100644 android/app/src/main/java/app/priceguard/data/dto/PriceDataDTO.kt create mode 100644 android/app/src/main/java/app/priceguard/data/dto/PricePatchRequest.kt create mode 100644 android/app/src/main/java/app/priceguard/data/dto/PricePatchResponse.kt create mode 100644 android/app/src/main/java/app/priceguard/data/dto/ProductErrorState.kt create mode 100644 android/app/src/main/java/app/priceguard/data/graph/GraphDataConverter.kt create mode 100644 android/app/src/main/java/app/priceguard/data/graph/ProductChartData.kt create mode 100644 android/app/src/main/java/app/priceguard/data/graph/ProductChartDataset.kt create mode 100644 android/app/src/main/java/app/priceguard/data/graph/ProductChartGridLine.kt create mode 100644 android/app/src/main/java/app/priceguard/data/network/ProductRepositoryResult.kt create mode 100644 android/app/src/main/res/drawable/bg_round_corner_error.xml create mode 100644 backend/src/dto/price.data.dto.ts create mode 100644 backend/src/dto/product.price.dto.ts create mode 100644 backend/src/dto/product.recommend.dto.ts create mode 100644 backend/src/middlewares/logger.middleware.ts create mode 100644 backend/src/schema/product.schema.ts diff --git a/.github/workflows/firebase-deploy.yml b/.github/workflows/firebase-deploy.yml index fae2345..85d055b 100644 --- a/.github/workflows/firebase-deploy.yml +++ b/.github/workflows/firebase-deploy.yml @@ -10,6 +10,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: recursive - name: Setup JDK uses: actions/setup-java@v3 with: diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..70ad252 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "android/materialchart"] + path = android/materialchart + url = https://github.com/Taewan-P/material-android-chart + branch = release diff --git a/android/.idea/gradle.xml b/android/.idea/gradle.xml index ae388c2..266f9cb 100644 --- a/android/.idea/gradle.xml +++ b/android/.idea/gradle.xml @@ -12,6 +12,7 @@ diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 56669c7..edeeff2 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -21,6 +21,8 @@ android { targetSdk = 34 versionCode = 2 versionName = "0.1.1" + versionCode = 3 + versionName = "0.2.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -67,7 +69,7 @@ dependencies { implementation("androidx.core:core-ktx:1.12.0") implementation("androidx.appcompat:appcompat:1.6.1") implementation("com.google.android.material:material:1.10.0") - implementation("androidx.activity:activity-ktx:1.8.0") + implementation("androidx.activity:activity-ktx:1.8.1") implementation("androidx.constraintlayout:constraintlayout:2.1.4") implementation("androidx.navigation:navigation-fragment-ktx:2.7.5") implementation("androidx.navigation:navigation-ui-ktx:2.7.5") @@ -100,6 +102,12 @@ dependencies { // Glide implementation("com.github.bumptech.glide:glide:4.16.0") + + // Pull to Refresh + implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0") + + // Material chart + implementation(project(":materialchart")) } kapt { diff --git a/android/app/src/main/java/app/priceguard/data/dto/PriceDataDTO.kt b/android/app/src/main/java/app/priceguard/data/dto/PriceDataDTO.kt new file mode 100644 index 0000000..a80ed75 --- /dev/null +++ b/android/app/src/main/java/app/priceguard/data/dto/PriceDataDTO.kt @@ -0,0 +1,10 @@ +package app.priceguard.data.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class PriceDataDTO( + val time: Float? = null, + val price: Int? = null, + val isSoldOut: Boolean? = null +) diff --git a/android/app/src/main/java/app/priceguard/data/dto/PricePatchRequest.kt b/android/app/src/main/java/app/priceguard/data/dto/PricePatchRequest.kt new file mode 100644 index 0000000..7765575 --- /dev/null +++ b/android/app/src/main/java/app/priceguard/data/dto/PricePatchRequest.kt @@ -0,0 +1,9 @@ +package app.priceguard.data.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class PricePatchRequest( + val productCode: String, + val targetPrice: Int +) diff --git a/android/app/src/main/java/app/priceguard/data/dto/PricePatchResponse.kt b/android/app/src/main/java/app/priceguard/data/dto/PricePatchResponse.kt new file mode 100644 index 0000000..dc162bf --- /dev/null +++ b/android/app/src/main/java/app/priceguard/data/dto/PricePatchResponse.kt @@ -0,0 +1,9 @@ +package app.priceguard.data.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class PricePatchResponse( + val statusCode: Int, + val message: String +) diff --git a/android/app/src/main/java/app/priceguard/data/dto/ProductDTO.kt b/android/app/src/main/java/app/priceguard/data/dto/ProductDTO.kt index ba52d09..3931a8e 100644 --- a/android/app/src/main/java/app/priceguard/data/dto/ProductDTO.kt +++ b/android/app/src/main/java/app/priceguard/data/dto/ProductDTO.kt @@ -1,16 +1,17 @@ package app.priceguard.data.dto +import app.priceguard.data.graph.ProductChartData import kotlinx.serialization.Serializable @Serializable data class ProductDTO( - val productName: String?, - val productCode: String?, - val shop: String?, - val shopUrl: String?, - val imageUrl: String?, - val targetPrice: Int?, - val price: Int? + val productName: String? = null, + val productCode: String? = null, + val shop: String? = null, + val imageUrl: String? = null, + val targetPrice: Int? = null, + val price: Int? = null, + val priceData: List? = null ) data class ProductData( @@ -19,5 +20,6 @@ data class ProductData( val shop: String, val imageUrl: String, val targetPrice: Int, - val price: Int + val price: Int, + val priceData: List ) diff --git a/android/app/src/main/java/app/priceguard/data/dto/ProductDeleteResponse.kt b/android/app/src/main/java/app/priceguard/data/dto/ProductDeleteResponse.kt index 913c504..89281b8 100644 --- a/android/app/src/main/java/app/priceguard/data/dto/ProductDeleteResponse.kt +++ b/android/app/src/main/java/app/priceguard/data/dto/ProductDeleteResponse.kt @@ -7,11 +7,3 @@ data class ProductDeleteResponse( val statusCode: Int, val message: String ) - -enum class ProductDeleteState { - SUCCESS, - NOT_FOUND, - INVALID_REQUEST, - UNAUTHORIZED, - UNDEFINED_ERROR -} diff --git a/android/app/src/main/java/app/priceguard/data/dto/ProductErrorState.kt b/android/app/src/main/java/app/priceguard/data/dto/ProductErrorState.kt new file mode 100644 index 0000000..05d582b --- /dev/null +++ b/android/app/src/main/java/app/priceguard/data/dto/ProductErrorState.kt @@ -0,0 +1,9 @@ +package app.priceguard.data.dto + +enum class ProductErrorState { + PERMISSION_DENIED, + INVALID_REQUEST, + NOT_FOUND, + EXIST, + UNDEFINED_ERROR +} diff --git a/android/app/src/main/java/app/priceguard/data/dto/ProductListResponse.kt b/android/app/src/main/java/app/priceguard/data/dto/ProductListResponse.kt index 9c2043f..54be311 100644 --- a/android/app/src/main/java/app/priceguard/data/dto/ProductListResponse.kt +++ b/android/app/src/main/java/app/priceguard/data/dto/ProductListResponse.kt @@ -6,17 +6,5 @@ import kotlinx.serialization.Serializable data class ProductListResponse( val statusCode: Int, val message: String, - val trackingList: List? + val trackingList: List? = null ) - -data class ProductListResult( - val productListState: ProductListState, - val trackingList: List -) - -enum class ProductListState { - SUCCESS, - PERMISSION_DENIED, - NOT_FOUND, - UNDEFINED_ERROR -} diff --git a/android/app/src/main/java/app/priceguard/data/dto/ProductResponse.kt b/android/app/src/main/java/app/priceguard/data/dto/ProductResponse.kt index a033a0f..f46006d 100644 --- a/android/app/src/main/java/app/priceguard/data/dto/ProductResponse.kt +++ b/android/app/src/main/java/app/priceguard/data/dto/ProductResponse.kt @@ -1,5 +1,6 @@ package app.priceguard.data.dto +import app.priceguard.data.graph.ProductChartData import kotlinx.serialization.Serializable @Serializable @@ -14,25 +15,19 @@ data class ProductResponse( val shopUrl: String? = null, val targetPrice: Int? = null, val lowestPrice: Int? = null, - val price: Int? = null + val price: Int? = null, + val priceData: List? = null ) data class ProductDetailResult( - val state: ProductDetailState, - val productName: String? = null, - val productCode: String? = null, - val shop: String? = null, - val imageUrl: String? = null, - val rank: Int? = null, - val shopUrl: String? = null, - val targetPrice: Int? = null, - val lowestPrice: Int? = null, - val price: Int? = null + val productName: String, + val productCode: String, + val shop: String, + val imageUrl: String, + val rank: Int, + val shopUrl: String, + val targetPrice: Int, + val lowestPrice: Int, + val price: Int, + val priceData: List ) - -enum class ProductDetailState { - SUCCESS, - PERMISSION_DENIED, - NOT_FOUND, - UNDEFINED_ERROR -} diff --git a/android/app/src/main/java/app/priceguard/data/dto/ProductVerifyDTO.kt b/android/app/src/main/java/app/priceguard/data/dto/ProductVerifyDTO.kt index dc19070..12c02ef 100644 --- a/android/app/src/main/java/app/priceguard/data/dto/ProductVerifyDTO.kt +++ b/android/app/src/main/java/app/priceguard/data/dto/ProductVerifyDTO.kt @@ -4,9 +4,9 @@ import kotlinx.serialization.Serializable @Serializable data class ProductVerifyDTO( - val productName: String?, - val productCode: String?, - val productPrice: Int?, - val shop: String?, - val imageUrl: String? + val productName: String? = null, + val productCode: String? = null, + val productPrice: Int? = null, + val shop: String? = null, + val imageUrl: String? = null ) diff --git a/android/app/src/main/java/app/priceguard/data/dto/ProductVerifyResponse.kt b/android/app/src/main/java/app/priceguard/data/dto/ProductVerifyResponse.kt index aa531c6..5eb5e51 100644 --- a/android/app/src/main/java/app/priceguard/data/dto/ProductVerifyResponse.kt +++ b/android/app/src/main/java/app/priceguard/data/dto/ProductVerifyResponse.kt @@ -6,9 +6,9 @@ import kotlinx.serialization.Serializable data class ProductVerifyResponse( val statusCode: Int, val message: String, - val productName: String?, - val productCode: String?, - val productPrice: Int?, - val shop: String?, - val imageUrl: String? + val productName: String? = null, + val productCode: String? = null, + val productPrice: Int? = null, + val shop: String? = null, + val imageUrl: String? = null ) diff --git a/android/app/src/main/java/app/priceguard/data/dto/RecommendProductDTO.kt b/android/app/src/main/java/app/priceguard/data/dto/RecommendProductDTO.kt index be027dd..9b4ac22 100644 --- a/android/app/src/main/java/app/priceguard/data/dto/RecommendProductDTO.kt +++ b/android/app/src/main/java/app/priceguard/data/dto/RecommendProductDTO.kt @@ -1,15 +1,17 @@ package app.priceguard.data.dto +import app.priceguard.data.graph.ProductChartData import kotlinx.serialization.Serializable @Serializable data class RecommendProductDTO( - val productName: String?, - val productCode: String?, - val shop: String?, - val imageUrl: String?, - val price: Int?, - val rank: Int? + val productName: String? = null, + val productCode: String? = null, + val shop: String? = null, + val imageUrl: String? = null, + val price: Int? = null, + val rank: Int? = null, + val priceData: List? = null ) data class RecommendProductData( @@ -18,5 +20,6 @@ data class RecommendProductData( val shop: String, val imageUrl: String, val price: Int, - val rank: Int + val rank: Int, + val priceData: List ) diff --git a/android/app/src/main/java/app/priceguard/data/dto/RecommendProductResponse.kt b/android/app/src/main/java/app/priceguard/data/dto/RecommendProductResponse.kt index ac91dd5..063af2b 100644 --- a/android/app/src/main/java/app/priceguard/data/dto/RecommendProductResponse.kt +++ b/android/app/src/main/java/app/priceguard/data/dto/RecommendProductResponse.kt @@ -6,18 +6,5 @@ import kotlinx.serialization.Serializable data class RecommendProductResponse( val statusCode: Int, val message: String, - val recommendList: List? + val recommendList: List? = null ) - -data class RecommendProductResult( - val productListState: RecommendProductState, - val recommendList: List -) - -enum class RecommendProductState { - SUCCESS, - WRONG_REQUEST, - PERMISSION_DENIED, - NOT_FOUND, - UNDEFINED_ERROR -} diff --git a/android/app/src/main/java/app/priceguard/data/graph/GraphDataConverter.kt b/android/app/src/main/java/app/priceguard/data/graph/GraphDataConverter.kt new file mode 100644 index 0000000..125c39d --- /dev/null +++ b/android/app/src/main/java/app/priceguard/data/graph/GraphDataConverter.kt @@ -0,0 +1,24 @@ +package app.priceguard.data.graph + +import app.priceguard.data.dto.PriceDataDTO + +class GraphDataConverter { + + // TODO: 기간별로 데이터 필터링을 통해 해당 기간에 발생한 가격 변동만 추적하도록 구조 변경. + fun toDataset(priceData: List?): List { + priceData ?: return listOf() + if (priceData.isEmpty()) { + return listOf() + } + val dataList = mutableListOf() + priceData.forEach { dto -> + dto.time ?: return@forEach + dto.price ?: return@forEach + dto.isSoldOut ?: return@forEach + dataList.add(ProductChartData(dto.time / 1000, dto.price.toFloat(), dto.isSoldOut.not())) + } + val currentTime = (System.currentTimeMillis() / 1000).toFloat() + dataList.add(ProductChartData(currentTime, dataList.last().y, dataList.last().valid)) + return dataList.toList().sortedBy { it.x }.filter { it.x <= currentTime } + } +} diff --git a/android/app/src/main/java/app/priceguard/data/graph/ProductChartData.kt b/android/app/src/main/java/app/priceguard/data/graph/ProductChartData.kt new file mode 100644 index 0000000..5c8e4a1 --- /dev/null +++ b/android/app/src/main/java/app/priceguard/data/graph/ProductChartData.kt @@ -0,0 +1,9 @@ +package app.priceguard.data.graph + +import app.priceguard.materialchart.data.ChartData + +data class ProductChartData( + override val x: Float, + override val y: Float, + override val valid: Boolean +) : ChartData diff --git a/android/app/src/main/java/app/priceguard/data/graph/ProductChartDataset.kt b/android/app/src/main/java/app/priceguard/data/graph/ProductChartDataset.kt new file mode 100644 index 0000000..b8411f9 --- /dev/null +++ b/android/app/src/main/java/app/priceguard/data/graph/ProductChartDataset.kt @@ -0,0 +1,15 @@ +package app.priceguard.data.graph + +import app.priceguard.materialchart.data.ChartData +import app.priceguard.materialchart.data.ChartDataset +import app.priceguard.materialchart.data.GraphMode +import app.priceguard.materialchart.data.GridLine + +data class ProductChartDataset( + override val showXAxis: Boolean, + override val showYAxis: Boolean, + override val isInteractive: Boolean, + override val graphMode: GraphMode, + override val data: List, + override val gridLines: List +) : ChartDataset diff --git a/android/app/src/main/java/app/priceguard/data/graph/ProductChartGridLine.kt b/android/app/src/main/java/app/priceguard/data/graph/ProductChartGridLine.kt new file mode 100644 index 0000000..c60e02f --- /dev/null +++ b/android/app/src/main/java/app/priceguard/data/graph/ProductChartGridLine.kt @@ -0,0 +1,8 @@ +package app.priceguard.data.graph + +import app.priceguard.materialchart.data.GridLine + +data class ProductChartGridLine( + override val name: String, + override val value: Float +) : GridLine diff --git a/android/app/src/main/java/app/priceguard/data/network/ProductAPI.kt b/android/app/src/main/java/app/priceguard/data/network/ProductAPI.kt index 4d48c95..31459ef 100644 --- a/android/app/src/main/java/app/priceguard/data/network/ProductAPI.kt +++ b/android/app/src/main/java/app/priceguard/data/network/ProductAPI.kt @@ -1,5 +1,7 @@ package app.priceguard.data.network +import app.priceguard.data.dto.PricePatchRequest +import app.priceguard.data.dto.PricePatchResponse import app.priceguard.data.dto.ProductAddRequest import app.priceguard.data.dto.ProductAddResponse import app.priceguard.data.dto.ProductDeleteResponse @@ -46,6 +48,6 @@ interface ProductAPI { @PATCH("targetPrice") suspend fun updateTargetPrice( - @Body productAddRequest: ProductAddRequest - ): Response + @Body pricePatchRequest: PricePatchRequest + ): Response } diff --git a/android/app/src/main/java/app/priceguard/data/network/ProductRepositoryResult.kt b/android/app/src/main/java/app/priceguard/data/network/ProductRepositoryResult.kt new file mode 100644 index 0000000..60710f5 --- /dev/null +++ b/android/app/src/main/java/app/priceguard/data/network/ProductRepositoryResult.kt @@ -0,0 +1,10 @@ +package app.priceguard.data.network + +import app.priceguard.data.dto.ProductErrorState + +sealed class ProductRepositoryResult { + + data class Success(val data: T) : ProductRepositoryResult() + + data class Error(val productErrorState: ProductErrorState) : ProductRepositoryResult() +} diff --git a/android/app/src/main/java/app/priceguard/data/repository/ProductRepository.kt b/android/app/src/main/java/app/priceguard/data/repository/ProductRepository.kt index 931843a..7078197 100644 --- a/android/app/src/main/java/app/priceguard/data/repository/ProductRepository.kt +++ b/android/app/src/main/java/app/priceguard/data/repository/ProductRepository.kt @@ -1,29 +1,29 @@ package app.priceguard.data.repository +import app.priceguard.data.dto.PricePatchRequest +import app.priceguard.data.dto.PricePatchResponse import app.priceguard.data.dto.ProductAddRequest import app.priceguard.data.dto.ProductAddResponse -import app.priceguard.data.dto.ProductDeleteState +import app.priceguard.data.dto.ProductData import app.priceguard.data.dto.ProductDetailResult -import app.priceguard.data.dto.ProductListResult -import app.priceguard.data.dto.ProductResponse +import app.priceguard.data.dto.ProductVerifyDTO import app.priceguard.data.dto.ProductVerifyRequest -import app.priceguard.data.dto.ProductVerifyResponse -import app.priceguard.data.dto.RecommendProductResult -import app.priceguard.data.network.APIResult +import app.priceguard.data.dto.RecommendProductData +import app.priceguard.data.network.ProductRepositoryResult interface ProductRepository { - suspend fun verifyLink(productUrl: ProductVerifyRequest): APIResult + suspend fun verifyLink(productUrl: ProductVerifyRequest, isRenewed: Boolean = false): ProductRepositoryResult - suspend fun addProduct(productAddRequest: ProductAddRequest): APIResult + suspend fun addProduct(productAddRequest: ProductAddRequest, isRenewed: Boolean = false): ProductRepositoryResult - suspend fun getProductList(afterRenew: Boolean = false): ProductListResult + suspend fun getProductList(isRenewed: Boolean = false): ProductRepositoryResult> - suspend fun getRecommendedProductList(afterRenew: Boolean = false): RecommendProductResult + suspend fun getRecommendedProductList(isRenewed: Boolean = false): ProductRepositoryResult> - suspend fun getProductDetail(productCode: String, renewed: Boolean): ProductDetailResult + suspend fun getProductDetail(productCode: String, isRenewed: Boolean = false): ProductRepositoryResult - suspend fun deleteProduct(productCode: String, renewed: Boolean): ProductDeleteState + suspend fun deleteProduct(productCode: String, isRenewed: Boolean = false): ProductRepositoryResult - suspend fun updateTargetPrice(productAddRequest: ProductAddRequest): ProductResponse + suspend fun updateTargetPrice(pricePatchRequest: PricePatchRequest, isRenewed: Boolean = false): ProductRepositoryResult } diff --git a/android/app/src/main/java/app/priceguard/data/repository/ProductRepositoryImpl.kt b/android/app/src/main/java/app/priceguard/data/repository/ProductRepositoryImpl.kt index eb824d3..0b269c4 100644 --- a/android/app/src/main/java/app/priceguard/data/repository/ProductRepositoryImpl.kt +++ b/android/app/src/main/java/app/priceguard/data/repository/ProductRepositoryImpl.kt @@ -1,92 +1,138 @@ package app.priceguard.data.repository +import app.priceguard.data.dto.PricePatchRequest +import app.priceguard.data.dto.PricePatchResponse import app.priceguard.data.dto.ProductAddRequest import app.priceguard.data.dto.ProductAddResponse import app.priceguard.data.dto.ProductData -import app.priceguard.data.dto.ProductDeleteState import app.priceguard.data.dto.ProductDetailResult -import app.priceguard.data.dto.ProductDetailState -import app.priceguard.data.dto.ProductListResult -import app.priceguard.data.dto.ProductListState -import app.priceguard.data.dto.ProductResponse +import app.priceguard.data.dto.ProductErrorState +import app.priceguard.data.dto.ProductVerifyDTO import app.priceguard.data.dto.ProductVerifyRequest -import app.priceguard.data.dto.ProductVerifyResponse import app.priceguard.data.dto.RecommendProductData -import app.priceguard.data.dto.RecommendProductResult -import app.priceguard.data.dto.RecommendProductState import app.priceguard.data.dto.RenewResult +import app.priceguard.data.graph.GraphDataConverter import app.priceguard.data.network.APIResult import app.priceguard.data.network.ProductAPI +import app.priceguard.data.network.ProductRepositoryResult import app.priceguard.data.network.getApiResult import javax.inject.Inject class ProductRepositoryImpl @Inject constructor( private val productAPI: ProductAPI, - private val tokenRepository: TokenRepository + private val tokenRepository: TokenRepository, + private val graphDataConverter: GraphDataConverter ) : ProductRepository { - override suspend fun verifyLink(productUrl: ProductVerifyRequest): APIResult { - val response = getApiResult { - productAPI.verifyLink(productUrl) + private suspend fun renew(): Boolean { + val refreshToken = tokenRepository.getRefreshToken() ?: return false + val renewResult = tokenRepository.renewTokens(refreshToken) + if (renewResult != RenewResult.SUCCESS) { + return false } - when (response) { - is APIResult.Success -> { - return response + return true + } + + private suspend fun handleError( + code: Int?, + isRenewed: Boolean, + repoFun: suspend () -> ProductRepositoryResult + ): ProductRepositoryResult { + return when (code) { + 400 -> { + ProductRepositoryResult.Error(ProductErrorState.INVALID_REQUEST) } - is APIResult.Error -> { - return when (response.code) { - 400 -> { - response - } + 401 -> { + ProductRepositoryResult.Error(ProductErrorState.PERMISSION_DENIED) + } - 401 -> { - response - } + 404 -> { + ProductRepositoryResult.Error(ProductErrorState.NOT_FOUND) + } - else -> { - response + 409 -> { + ProductRepositoryResult.Error(ProductErrorState.EXIST) + } + + 410 -> { + if (isRenewed) { + ProductRepositoryResult.Error(ProductErrorState.PERMISSION_DENIED) + } else { + if (renew()) { + repoFun.invoke() + } else { + ProductRepositoryResult.Error(ProductErrorState.PERMISSION_DENIED) } } } + + else -> { + ProductRepositoryResult.Error(ProductErrorState.UNDEFINED_ERROR) + } } } - override suspend fun addProduct(productAddRequest: ProductAddRequest): APIResult { + override suspend fun verifyLink( + productUrl: ProductVerifyRequest, + isRenewed: Boolean + ): ProductRepositoryResult { val response = getApiResult { - productAPI.addProduct(productAddRequest) + productAPI.verifyLink(productUrl) } - when (response) { + return when (response) { is APIResult.Success -> { - return response + ProductRepositoryResult.Success( + ProductVerifyDTO( + response.data.productName, + response.data.productCode, + response.data.productPrice, + response.data.shop, + response.data.imageUrl + ) + ) } is APIResult.Error -> { - return when (response.code) { - 400 -> { - response - } + handleError(response.code, isRenewed) { + verifyLink(productUrl, true) + } + } + } + } - 401 -> { - response - } + override suspend fun addProduct( + productAddRequest: ProductAddRequest, + isRenewed: Boolean + ): ProductRepositoryResult { + val response = getApiResult { + productAPI.addProduct(productAddRequest) + } + return when (response) { + is APIResult.Success -> { + ProductRepositoryResult.Success( + ProductAddResponse( + response.data.statusCode, + response.data.message + ) + ) + } - else -> { - response - } + is APIResult.Error -> { + handleError(response.code, isRenewed) { + addProduct(productAddRequest, true) } } } } - override suspend fun getProductList(afterRenew: Boolean): ProductListResult { + override suspend fun getProductList(isRenewed: Boolean): ProductRepositoryResult> { val response = getApiResult { productAPI.getProductList() } - when (response) { + return when (response) { is APIResult.Success -> { - return ProductListResult( - ProductListState.SUCCESS, + ProductRepositoryResult.Success( response.data.trackingList?.map { dto -> ProductData( dto.productName ?: "", @@ -94,54 +140,28 @@ class ProductRepositoryImpl @Inject constructor( dto.shop ?: "", dto.imageUrl ?: "", dto.targetPrice ?: 0, - dto.price ?: 0 + dto.price ?: 0, + GraphDataConverter().toDataset(dto.priceData) ) } ?: listOf() ) } is APIResult.Error -> { - when (response.code) { - 401 -> { - if (afterRenew) { - return ProductListResult(ProductListState.PERMISSION_DENIED, listOf()) - } else { - val refreshToken = - tokenRepository.getRefreshToken() ?: return ProductListResult( - ProductListState.PERMISSION_DENIED, - listOf() - ) - val renewResult = tokenRepository.renewTokens(refreshToken) - if (renewResult != RenewResult.SUCCESS) { - return ProductListResult( - ProductListState.PERMISSION_DENIED, - listOf() - ) - } - return getProductList(afterRenew = true) - } - } - - 404 -> { - return ProductListResult(ProductListState.NOT_FOUND, listOf()) - } - - else -> { - return ProductListResult(ProductListState.UNDEFINED_ERROR, listOf()) - } + handleError(response.code, isRenewed) { + getProductList(true) } } } } - override suspend fun getRecommendedProductList(afterRenew: Boolean): RecommendProductResult { + override suspend fun getRecommendedProductList(isRenewed: Boolean): ProductRepositoryResult> { val response = getApiResult { productAPI.getRecommendedProductList() } - when (response) { + return when (response) { is APIResult.Success -> { - return RecommendProductResult( - RecommendProductState.SUCCESS, + ProductRepositoryResult.Success( response.data.recommendList?.map { dto -> RecommendProductData( dto.productName ?: "", @@ -149,48 +169,16 @@ class ProductRepositoryImpl @Inject constructor( dto.shop ?: "", dto.imageUrl ?: "", dto.price ?: 0, - dto.rank ?: 0 + dto.rank ?: 0, + GraphDataConverter().toDataset(dto.priceData) ) } ?: listOf() ) } is APIResult.Error -> { - return when (response.code) { - 400 -> { - RecommendProductResult(RecommendProductState.WRONG_REQUEST, listOf()) - } - - 401 -> { - if (afterRenew) { - return RecommendProductResult( - RecommendProductState.PERMISSION_DENIED, - listOf() - ) - } else { - val refreshToken = - tokenRepository.getRefreshToken() ?: return RecommendProductResult( - RecommendProductState.PERMISSION_DENIED, - listOf() - ) - val renewResult = tokenRepository.renewTokens(refreshToken) - if (renewResult != RenewResult.SUCCESS) { - return RecommendProductResult( - RecommendProductState.PERMISSION_DENIED, - listOf() - ) - } - return getRecommendedProductList(afterRenew = true) - } - } - - 404 -> { - RecommendProductResult(RecommendProductState.NOT_FOUND, listOf()) - } - - else -> { - RecommendProductResult(RecommendProductState.UNDEFINED_ERROR, listOf()) - } + handleError(response.code, isRenewed) { + getRecommendedProductList(true) } } } @@ -198,98 +186,76 @@ class ProductRepositoryImpl @Inject constructor( override suspend fun getProductDetail( productCode: String, - renewed: Boolean - ): ProductDetailResult { - when (val response = getApiResult { productAPI.getProductDetail(productCode) }) { + isRenewed: Boolean + ): ProductRepositoryResult { + val response = getApiResult { + productAPI.getProductDetail(productCode) + } + return when (response) { is APIResult.Success -> { - return ProductDetailResult( - ProductDetailState.SUCCESS, - productName = response.data.productName ?: "", - productCode = response.data.productCode, - shop = response.data.shop, - imageUrl = response.data.imageUrl, - rank = response.data.rank, - shopUrl = response.data.shopUrl, - targetPrice = response.data.targetPrice, - lowestPrice = response.data.lowestPrice, - price = response.data.price + ProductRepositoryResult.Success( + ProductDetailResult( + productName = response.data.productName ?: "", + productCode = response.data.productCode ?: "", + shop = response.data.shop ?: "", + imageUrl = response.data.imageUrl ?: "", + rank = response.data.rank ?: -1, + shopUrl = response.data.shopUrl ?: "", + targetPrice = response.data.targetPrice ?: -1, + lowestPrice = response.data.lowestPrice ?: -1, + price = response.data.price ?: -1, + priceData = graphDataConverter.toDataset(response.data.priceData) + ) ) } is APIResult.Error -> { - when (response.code) { - 401 -> { - if (renewed) { - return ProductDetailResult(ProductDetailState.PERMISSION_DENIED) - } - - val refreshToken = - tokenRepository.getRefreshToken() ?: return ProductDetailResult( - ProductDetailState.PERMISSION_DENIED - ) - - val renewResult = tokenRepository.renewTokens(refreshToken) - - if (renewResult != RenewResult.SUCCESS) { - return ProductDetailResult(ProductDetailState.PERMISSION_DENIED) - } - - return getProductDetail(productCode, true) - } - - 404 -> { - return ProductDetailResult(ProductDetailState.NOT_FOUND) - } - - else -> { - return ProductDetailResult(ProductDetailState.UNDEFINED_ERROR) - } + handleError(response.code, isRenewed) { + getProductDetail(productCode, true) } } } } - override suspend fun deleteProduct(productCode: String, renewed: Boolean): ProductDeleteState { - when (val response = getApiResult { productAPI.deleteProduct(productCode) }) { + override suspend fun deleteProduct( + productCode: String, + isRenewed: Boolean + ): ProductRepositoryResult { + return when (val response = getApiResult { productAPI.deleteProduct(productCode) }) { is APIResult.Success -> { - return ProductDeleteState.SUCCESS + ProductRepositoryResult.Success(true) } is APIResult.Error -> { - when (response.code) { - 400 -> { - return ProductDeleteState.INVALID_REQUEST - } - - 401 -> { - if (renewed) { - return ProductDeleteState.UNAUTHORIZED - } - - val refreshToken = tokenRepository.getRefreshToken() - ?: return ProductDeleteState.UNAUTHORIZED - val renewResult = tokenRepository.renewTokens(refreshToken) - - if (renewResult != RenewResult.SUCCESS) { - return ProductDeleteState.UNAUTHORIZED - } - - return deleteProduct(productCode, true) - } - - 404 -> { - return ProductDeleteState.NOT_FOUND - } - - else -> { - return ProductDeleteState.UNDEFINED_ERROR - } + handleError(response.code, isRenewed) { + deleteProduct(productCode, true) } } } } - override suspend fun updateTargetPrice(productAddRequest: ProductAddRequest): ProductResponse { - TODO("Not yet implemented") + override suspend fun updateTargetPrice( + pricePatchRequest: PricePatchRequest, + isRenewed: Boolean + ): ProductRepositoryResult { + val response = getApiResult { + productAPI.updateTargetPrice(pricePatchRequest) + } + return when (response) { + is APIResult.Success -> { + ProductRepositoryResult.Success( + PricePatchResponse( + response.data.statusCode, + response.data.message + ) + ) + } + + is APIResult.Error -> { + handleError(response.code, isRenewed) { + updateTargetPrice(pricePatchRequest, true) + } + } + } } } diff --git a/android/app/src/main/java/app/priceguard/di/ProductRepositoryModule.kt b/android/app/src/main/java/app/priceguard/di/ProductRepositoryModule.kt index e2beb30..a835a08 100644 --- a/android/app/src/main/java/app/priceguard/di/ProductRepositoryModule.kt +++ b/android/app/src/main/java/app/priceguard/di/ProductRepositoryModule.kt @@ -1,5 +1,6 @@ package app.priceguard.di +import app.priceguard.data.graph.GraphDataConverter import app.priceguard.data.network.ProductAPI import app.priceguard.data.repository.ProductRepository import app.priceguard.data.repository.ProductRepositoryImpl @@ -16,6 +17,10 @@ object ProductRepositoryModule { @Provides @Singleton - fun provideProductRepository(productAPI: ProductAPI, tokenRepository: TokenRepository): ProductRepository = - ProductRepositoryImpl(productAPI, tokenRepository) + fun provideProductRepository(productAPI: ProductAPI, tokenRepository: TokenRepository, graphDataConverter: GraphDataConverter): ProductRepository = + ProductRepositoryImpl(productAPI, tokenRepository, graphDataConverter) + + @Provides + @Singleton + fun provideGraphDataConverter(): GraphDataConverter = GraphDataConverter() } diff --git a/android/app/src/main/java/app/priceguard/ui/additem/AddItemActivity.kt b/android/app/src/main/java/app/priceguard/ui/additem/AddItemActivity.kt index dc18c73..fdfba1a 100644 --- a/android/app/src/main/java/app/priceguard/ui/additem/AddItemActivity.kt +++ b/android/app/src/main/java/app/priceguard/ui/additem/AddItemActivity.kt @@ -2,7 +2,9 @@ package app.priceguard.ui.additem import android.os.Bundle import androidx.appcompat.app.AppCompatActivity +import androidx.navigation.fragment.NavHostFragment import app.priceguard.databinding.ActivityAddItemBinding +import app.priceguard.ui.additem.link.RegisterItemLinkFragmentDirections import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint @@ -14,6 +16,25 @@ class AddItemActivity : AppCompatActivity() { super.onCreate(savedInstanceState) binding = ActivityAddItemBinding.inflate(layoutInflater) + setStartDestination() setContentView(binding.root) } + + private fun setStartDestination() { + if (intent.hasExtra("productCode") && + intent.hasExtra("productTitle") && + intent.hasExtra("productPrice") && + intent.hasExtra("isAdding") + ) { + val navController = binding.fcvAddItem.getFragment().navController + val action = + RegisterItemLinkFragmentDirections.actionRegisterItemLinkFragmentToSetTargetPriceFragment( + intent.getStringExtra("productCode") ?: "", + intent.getStringExtra("productTitle") ?: "", + intent.getIntExtra("productPrice", 0), + intent.getBooleanExtra("isAdding", true) + ) + navController.navigate(action) + } + } } diff --git a/android/app/src/main/java/app/priceguard/ui/additem/confirm/ConfirmItemLinkFragment.kt b/android/app/src/main/java/app/priceguard/ui/additem/confirm/ConfirmItemLinkFragment.kt index 955c6de..8af63f0 100644 --- a/android/app/src/main/java/app/priceguard/ui/additem/confirm/ConfirmItemLinkFragment.kt +++ b/android/app/src/main/java/app/priceguard/ui/additem/confirm/ConfirmItemLinkFragment.kt @@ -60,7 +60,8 @@ class ConfirmItemLinkFragment : Fragment() { ConfirmItemLinkFragmentDirections.actionConfirmItemLinkFragmentToSetTargetPriceFragment( productInfo.productCode ?: "", productInfo.productName ?: "", - productInfo.productPrice ?: 0 + productInfo.productPrice ?: 0, + true ) findNavController().navigate(action) } diff --git a/android/app/src/main/java/app/priceguard/ui/additem/link/RegisterItemLinkFragment.kt b/android/app/src/main/java/app/priceguard/ui/additem/link/RegisterItemLinkFragment.kt index 5307aa0..e9024e8 100644 --- a/android/app/src/main/java/app/priceguard/ui/additem/link/RegisterItemLinkFragment.kt +++ b/android/app/src/main/java/app/priceguard/ui/additem/link/RegisterItemLinkFragment.kt @@ -4,22 +4,28 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.Toast import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.navigation.NavController import androidx.navigation.NavDirections import androidx.navigation.fragment.findNavController import app.priceguard.R +import app.priceguard.data.dto.ProductErrorState +import app.priceguard.data.repository.TokenRepository import app.priceguard.databinding.FragmentRegisterItemLinkBinding import app.priceguard.ui.util.lifecycle.repeatOnStarted +import app.priceguard.ui.util.ui.showPermissionDeniedDialog import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @AndroidEntryPoint class RegisterItemLinkFragment : Fragment() { + @Inject + lateinit var tokenRepository: TokenRepository + private var _binding: FragmentRegisterItemLinkBinding? = null private val binding get() = _binding!! private val viewModel: RegisterItemLinkViewModel by viewModels() @@ -37,23 +43,23 @@ class RegisterItemLinkFragment : Fragment() { super.onViewCreated(view, savedInstanceState) binding.lifecycleOwner = viewLifecycleOwner binding.viewModel = viewModel - setCollector() + initCollector() + initEvent() } - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } - - private fun setCollector() { + private fun initCollector() { repeatOnStarted { viewModel.state.collect { state -> - if (state.product != null) { - // TODO: 링크 정규식 검사에 맞지 않을 시 발생하는 ui 업데이트 로직 + if (state.isLinkError) { + updateLinkError(getString(R.string.not_link)) + } else { + updateLinkError("") } } } + } + private fun initEvent() { repeatOnStarted { viewModel.event.collect { event -> when (event) { @@ -66,27 +72,35 @@ class RegisterItemLinkFragment : Fragment() { } is RegisterItemLinkViewModel.RegisterLinkEvent.FailureVerification -> { - Toast.makeText( - requireActivity(), - getString(R.string.not_link), - Toast.LENGTH_SHORT - ).show() + when (event.errorType) { + ProductErrorState.PERMISSION_DENIED -> { + requireActivity().showPermissionDeniedDialog(tokenRepository) + } + + ProductErrorState.INVALID_REQUEST -> { + updateLinkError(getString(R.string.not_product_link)) + } + + else -> { + updateLinkError(getString(R.string.undefined_error)) + } + } } } } } } - private fun NavController.safeNavigate(direction: NavDirections) { - currentDestination?.getAction(direction.actionId)?.run { navigate(direction) } + private fun updateLinkError(message: String) { + binding.tvRegisterItemError.text = message } - private fun updateLinkFieldUI() { - } - - private fun updateNextBtnUI() { + private fun NavController.safeNavigate(direction: NavDirections) { + currentDestination?.getAction(direction.actionId)?.run { navigate(direction) } } - private fun update() { + override fun onDestroyView() { + super.onDestroyView() + _binding = null } } diff --git a/android/app/src/main/java/app/priceguard/ui/additem/link/RegisterItemLinkViewModel.kt b/android/app/src/main/java/app/priceguard/ui/additem/link/RegisterItemLinkViewModel.kt index f66c03b..e933a86 100644 --- a/android/app/src/main/java/app/priceguard/ui/additem/link/RegisterItemLinkViewModel.kt +++ b/android/app/src/main/java/app/priceguard/ui/additem/link/RegisterItemLinkViewModel.kt @@ -1,11 +1,11 @@ package app.priceguard.ui.additem.link -import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import app.priceguard.data.dto.ProductErrorState import app.priceguard.data.dto.ProductVerifyDTO import app.priceguard.data.dto.ProductVerifyRequest -import app.priceguard.data.network.APIResult +import app.priceguard.data.network.ProductRepositoryResult import app.priceguard.data.repository.ProductRepository import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @@ -22,14 +22,13 @@ class RegisterItemLinkViewModel data class RegisterLinkUIState( val link: String = "", val product: ProductVerifyDTO? = null, - val isNextReady: Boolean = false, - val isLinkError: Boolean? = null, - val isVerificationFinished: Boolean = true + val isNextReady: Boolean = true, + val isLinkError: Boolean = false ) sealed class RegisterLinkEvent { data class SuccessVerification(val product: ProductVerifyDTO) : RegisterLinkEvent() - data object FailureVerification : RegisterLinkEvent() + data class FailureVerification(val errorType: ProductErrorState) : RegisterLinkEvent() } private val _state = MutableStateFlow(RegisterLinkUIState()) @@ -39,45 +38,39 @@ class RegisterItemLinkViewModel val event = _event.asSharedFlow() private fun isUrlValid(url: String): Boolean { - val urlPattern = """^(https?):\\/\\/(-\.)?([^\s\\/?\.#]+\.?)+(\\/\S*)?$""".toRegex() + val urlPattern = + """(https?:\/\/)?(www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)""".toRegex() return urlPattern.matches(url) } fun verifyLink() { - _state.value = state.value.copy(isVerificationFinished = false) + if (!isUrlValid(state.value.link)) { + _state.value = + state.value.copy(isLinkError = !isUrlValid(state.value.link), isNextReady = false) + return + } + _state.value = state.value.copy( + isNextReady = false, + isLinkError = false + ) viewModelScope.launch { val response = productRepository.verifyLink(ProductVerifyRequest(state.value.link)) - Log.d("responseProduct", response.toString()) when (response) { - is APIResult.Success -> { - val product = ProductVerifyDTO( - response.data.productName, - response.data.productCode, - response.data.productPrice, - response.data.shop, - response.data.imageUrl - ) - _state.value = state.value.copy( - product = product - ) - _event.emit(RegisterLinkEvent.SuccessVerification(product)) + is ProductRepositoryResult.Success -> { + _state.value = state.value.copy(isNextReady = true, product = response.data) + _event.emit(RegisterLinkEvent.SuccessVerification(response.data)) } - is APIResult.Error -> { - when (response.code) { - 400 -> { - _state.value = state.value.copy(isLinkError = true) - } - } - _event.emit(RegisterLinkEvent.FailureVerification) + is ProductRepositoryResult.Error -> { + _state.value = state.value.copy(isLinkError = true, isNextReady = false) + _event.emit(RegisterLinkEvent.FailureVerification(response.productErrorState)) } } - _state.value = state.value.copy(isVerificationFinished = true) } } fun updateLink(link: String) { - _state.value = state.value.copy(isLinkError = isUrlValid(link), link = link) + _state.value = state.value.copy(isLinkError = false, isNextReady = true, link = link) } } diff --git a/android/app/src/main/java/app/priceguard/ui/additem/setprice/SetTargetPriceFragment.kt b/android/app/src/main/java/app/priceguard/ui/additem/setprice/SetTargetPriceFragment.kt index dd90be8..51167ba 100644 --- a/android/app/src/main/java/app/priceguard/ui/additem/setprice/SetTargetPriceFragment.kt +++ b/android/app/src/main/java/app/priceguard/ui/additem/setprice/SetTargetPriceFragment.kt @@ -1,24 +1,35 @@ package app.priceguard.ui.additem.setprice import android.os.Bundle +import android.text.Editable import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.activity.addCallback import androidx.core.widget.addTextChangedListener import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController import app.priceguard.R +import app.priceguard.data.dto.ProductErrorState +import app.priceguard.data.repository.TokenRepository import app.priceguard.databinding.FragmentSetTargetPriceBinding +import app.priceguard.ui.additem.setprice.SetTargetPriceViewModel.SetTargetPriceEvent import app.priceguard.ui.util.lifecycle.repeatOnStarted +import app.priceguard.ui.util.ui.showPermissionDeniedDialog +import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.slider.Slider import com.google.android.material.slider.Slider.OnSliderTouchListener import dagger.hilt.android.AndroidEntryPoint import java.text.NumberFormat +import javax.inject.Inject @AndroidEntryPoint class SetTargetPriceFragment : Fragment() { + @Inject + lateinit var tokenRepository: TokenRepository + private var _binding: FragmentSetTargetPriceBinding? = null private val binding get() = _binding!! private val viewModel: SetTargetPriceViewModel by viewModels() @@ -35,13 +46,23 @@ class SetTargetPriceFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - binding.viewModel = viewModel + binding.vm = viewModel binding.lifecycleOwner = viewLifecycleOwner + val callback = requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner) { + if (requireActivity().intent.hasExtra("isAdding")) { + requireActivity().finish() + } else { + findNavController().navigateUp() + } + } + val productCode = requireArguments().getString("productCode") ?: "" val title = requireArguments().getString("productTitle") ?: "" val price = requireArguments().getInt("productPrice") + viewModel.updateTargetPrice((price * 0.8).toInt()) + binding.tvSetPriceCurrentPrice.text = String.format( resources.getString(R.string.won), @@ -57,7 +78,15 @@ class SetTargetPriceFragment : Fragment() { private fun FragmentSetTargetPriceBinding.initListener() { btnConfirmItemBack.setOnClickListener { - findNavController().navigateUp() + if (requireActivity().intent.hasExtra("isAdding")) { + requireActivity().finish() + } else { + findNavController().navigateUp() + } + } + btnConfirmItemNext.setOnClickListener { + val isAdding = requireArguments().getBoolean("isAdding") + if (isAdding) viewModel.addProduct() else viewModel.patchProduct() } slTargetPrice.addOnChangeListener { _, value, _ -> if (!etTargetPrice.isFocused) { @@ -74,52 +103,131 @@ class SetTargetPriceFragment : Fragment() { } }) etTargetPrice.addTextChangedListener { - if (etTargetPrice.isFocused) { - if (it.toString().matches("^\\d+\$".toRegex())) { - val targetPrice = it.toString().toFloat() - var percent = - ((targetPrice / (viewModel?.state?.value?.productPrice ?: 0)) * 100).toInt() - tvTargetPricePercent.text = - String.format(getString(R.string.current_price_percent), percent) - - percent = 10 * ((percent + 5) / 10) - if (targetPrice > (viewModel?.state?.value?.productPrice ?: 0)) { - tvTargetPricePercent.text = getString(R.string.over_current_price) - percent = 100 - } else if (percent < 1) { - percent = 0 - } - slTargetPrice.value = percent.toFloat() - } + updateTargetPriceUI(it) + } + } + + private fun updateTargetPriceUI(it: Editable?) { + if (binding.etTargetPrice.isFocused) { + val targetPrice = if (it.toString().matches("^\\d+\$".toRegex())) { + it.toString().toFloat() + } else { + 0F } + + viewModel.updateTargetPrice(targetPrice.toInt()) + + val percent = + ((targetPrice / viewModel.state.value.productPrice) * MAX_PERCENT).toInt() + + binding.tvTargetPricePercent.text = + String.format(getString(R.string.current_price_percent), percent) + + binding.updateSlideValueWithPrice(targetPrice, percent.roundAtFirstDigit()) } } + private fun Int.roundAtFirstDigit(): Int { + return ((this + 5) / 10) * 10 + } + private fun FragmentSetTargetPriceBinding.setTargetPriceAndPercent(value: Float) { + val targetPrice = ((viewModel.state.value.productPrice) * value.toInt() / 100) tvTargetPricePercent.text = String.format(getString(R.string.current_price_percent), value.toInt()) etTargetPrice.setText( - ((viewModel?.state?.value?.productPrice ?: 0) * value.toInt() / 100).toString() + targetPrice.toString() ) + viewModel.updateTargetPrice(targetPrice) } private fun handleEvent() { repeatOnStarted { viewModel.event.collect { event -> when (event) { - SetTargetPriceViewModel.SetTargetPriceEvent.FailureProductAdd -> { + is SetTargetPriceEvent.SuccessProductAdd -> { + showActivityFinishDialog( + getString(R.string.success_add), + getString(R.string.success_add_message) + ) + } + + is SetTargetPriceEvent.SuccessPriceUpdate -> { + showActivityFinishDialog( + getString(R.string.success_update), + getString(R.string.success_update_message) + ) + } + + is SetTargetPriceEvent.FailurePriceAdd -> { + when (event.errorType) { + ProductErrorState.EXIST -> { + showActivityFinishDialog( + getString(R.string.error_add_product), + getString(R.string.exist_product) + ) + } + + ProductErrorState.PERMISSION_DENIED -> { + requireActivity().showPermissionDeniedDialog(tokenRepository) + } + + else -> { + showActivityFinishDialog( + getString(R.string.error), + getString(R.string.retry) + ) + } + } } - SetTargetPriceViewModel.SetTargetPriceEvent.SuccessProductAdd -> { - activity?.finish() + is SetTargetPriceEvent.FailurePriceUpdate -> { + when (event.errorType) { + ProductErrorState.PERMISSION_DENIED -> { + requireActivity().showPermissionDeniedDialog(tokenRepository) + } + + else -> { + showActivityFinishDialog( + getString(R.string.error_patch_price), + getString(R.string.retry) + ) + } + } } } } } } + private fun showActivityFinishDialog(title: String, message: String) { + MaterialAlertDialogBuilder(requireActivity(), R.style.ThemeOverlay_App_MaterialAlertDialog) + .setTitle(title) + .setMessage(message) + .setPositiveButton(R.string.confirm) { _, _ -> requireActivity().finish() } + .setOnDismissListener { requireActivity().finish() } + .create() + .show() + } + + private fun FragmentSetTargetPriceBinding.updateSlideValueWithPrice( + targetPrice: Float, + percent: Int + ) { + val pricePercent = percent.coerceIn(MIN_PERCENT, MAX_PERCENT) + if (targetPrice > viewModel.state.value.productPrice) { + tvTargetPricePercent.text = getString(R.string.over_current_price) + } + slTargetPrice.value = pricePercent.toFloat() + } + override fun onDestroyView() { super.onDestroyView() _binding = null } + + companion object { + const val MIN_PERCENT = 0 + const val MAX_PERCENT = 100 + } } diff --git a/android/app/src/main/java/app/priceguard/ui/additem/setprice/SetTargetPriceViewModel.kt b/android/app/src/main/java/app/priceguard/ui/additem/setprice/SetTargetPriceViewModel.kt index b7c054e..7a95341 100644 --- a/android/app/src/main/java/app/priceguard/ui/additem/setprice/SetTargetPriceViewModel.kt +++ b/android/app/src/main/java/app/priceguard/ui/additem/setprice/SetTargetPriceViewModel.kt @@ -1,10 +1,11 @@ package app.priceguard.ui.additem.setprice -import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import app.priceguard.data.dto.PricePatchRequest import app.priceguard.data.dto.ProductAddRequest -import app.priceguard.data.network.APIResult +import app.priceguard.data.dto.ProductErrorState +import app.priceguard.data.network.ProductRepositoryResult import app.priceguard.data.repository.ProductRepository import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @@ -27,7 +28,9 @@ class SetTargetPriceViewModel @Inject constructor(private val productRepository: sealed class SetTargetPriceEvent { data object SuccessProductAdd : SetTargetPriceEvent() - data object FailureProductAdd : SetTargetPriceEvent() + data class FailurePriceAdd(val errorType: ProductErrorState) : SetTargetPriceEvent() + data object SuccessPriceUpdate : SetTargetPriceEvent() + data class FailurePriceUpdate(val errorType: ProductErrorState) : SetTargetPriceEvent() } private val _state = MutableStateFlow(SetTargetPriceState()) @@ -37,7 +40,6 @@ class SetTargetPriceViewModel @Inject constructor(private val productRepository: val event = _event.asSharedFlow() fun addProduct() { - Log.d("responseSetTargetPrice", "1") viewModelScope.launch { val response = productRepository.addProduct( ProductAddRequest( @@ -45,25 +47,42 @@ class SetTargetPriceViewModel @Inject constructor(private val productRepository: _state.value.targetPrice ) ) - Log.d("responseSetTargetPrice", response.toString()) when (response) { - is APIResult.Error -> { - _event.emit(SetTargetPriceEvent.FailureProductAdd) + is ProductRepositoryResult.Success -> { + _event.emit(SetTargetPriceEvent.SuccessProductAdd) } - is APIResult.Success -> { - _event.emit(SetTargetPriceEvent.SuccessProductAdd) + is ProductRepositoryResult.Error -> { + _event.emit(SetTargetPriceEvent.FailurePriceAdd(response.productErrorState)) } } } } - fun updateTargetPrice(price: String) { - if (price.toIntOrNull() != null) { - _state.value = state.value.copy(targetPrice = price.toInt()) + fun patchProduct() { + viewModelScope.launch { + val response = productRepository.updateTargetPrice( + PricePatchRequest( + _state.value.productCode, + _state.value.targetPrice + ) + ) + when (response) { + is ProductRepositoryResult.Success -> { + _event.emit(SetTargetPriceEvent.SuccessPriceUpdate) + } + + is ProductRepositoryResult.Error -> { + _event.emit(SetTargetPriceEvent.FailurePriceUpdate(response.productErrorState)) + } + } } } + fun updateTargetPrice(price: Int) { + _state.value = state.value.copy(targetPrice = price) + } + fun setProductInfo(productCode: String, name: String, price: Int) { _state.value = state.value.copy( diff --git a/android/app/src/main/java/app/priceguard/ui/detail/DetailActivity.kt b/android/app/src/main/java/app/priceguard/ui/detail/DetailActivity.kt index de84cd1..189c358 100644 --- a/android/app/src/main/java/app/priceguard/ui/detail/DetailActivity.kt +++ b/android/app/src/main/java/app/priceguard/ui/detail/DetailActivity.kt @@ -8,15 +8,24 @@ import android.widget.Toast import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import app.priceguard.R -import app.priceguard.data.dto.ProductDeleteState +import app.priceguard.data.dto.ProductErrorState +import app.priceguard.data.graph.ProductChartGridLine +import app.priceguard.data.repository.TokenRepository import app.priceguard.databinding.ActivityDetailBinding +import app.priceguard.materialchart.data.GraphMode +import app.priceguard.ui.additem.AddItemActivity import app.priceguard.ui.util.lifecycle.repeatOnStarted +import app.priceguard.ui.util.ui.showConfirmationDialog +import app.priceguard.ui.util.ui.showPermissionDeniedDialog import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject @AndroidEntryPoint class DetailActivity : AppCompatActivity() { + @Inject + lateinit var tokenRepository: TokenRepository private lateinit var binding: ActivityDetailBinding private val productDetailViewModel: ProductDetailViewModel by viewModels() @@ -27,11 +36,62 @@ class DetailActivity : AppCompatActivity() { binding.viewModel = productDetailViewModel setContentView(binding.root) + initListener() setNavigationButton() + setGraph() checkProductCode() observeEvent() } + override fun onStart() { + super.onStart() + if (productDetailViewModel.state.value.isReady) { + productDetailViewModel.getDetails(true) + } + } + + private fun initListener() { + binding.btnDetailTrack.setOnClickListener { + val intent = Intent(this, AddItemActivity::class.java) + intent.putExtra("productCode", productDetailViewModel.productCode) + intent.putExtra("productTitle", productDetailViewModel.state.value.productName) + intent.putExtra("productPrice", productDetailViewModel.state.value.price) + intent.putExtra("isAdding", true) + this@DetailActivity.startActivity(intent) + } + + binding.btnDetailEditPrice.setOnClickListener { + val intent = Intent(this, AddItemActivity::class.java) + intent.putExtra("productCode", productDetailViewModel.productCode) + intent.putExtra("productTitle", productDetailViewModel.state.value.productName) + intent.putExtra("productPrice", productDetailViewModel.state.value.price) + intent.putExtra("isAdding", false) + this@DetailActivity.startActivity(intent) + } + + binding.mbtgGraphPeriod.addOnButtonCheckedListener { _, checkedId, isChecked -> + if (!isChecked) return@addOnButtonCheckedListener + + when (checkedId) { + R.id.btn_period_day -> { + productDetailViewModel.changePeriod(GraphMode.DAY) + } + + R.id.btn_period_week -> { + productDetailViewModel.changePeriod(GraphMode.WEEK) + } + + R.id.btn_period_month -> { + productDetailViewModel.changePeriod(GraphMode.MONTH) + } + + R.id.btn_period_quarter -> { + productDetailViewModel.changePeriod(GraphMode.QUARTER) + } + } + } + } + private fun checkProductCode() { val productCode = intent.getStringExtra("productCode") if (productCode == null) { @@ -39,11 +99,27 @@ class DetailActivity : AppCompatActivity() { showDialogAndExit(getString(R.string.error), getString(R.string.invalid_access)) } else { productDetailViewModel.productCode = productCode - productDetailViewModel.getDetails() + productDetailViewModel.getDetails(false) } } private fun observeEvent() { + repeatOnStarted { + productDetailViewModel.state.collect { state -> + binding.chGraphDetail.dataset = state.chartData?.copy( + gridLines = listOf( + ProductChartGridLine( + resources.getString(R.string.target_price), + state.targetPrice?.toFloat() ?: 0F + ), + ProductChartGridLine( + resources.getString(R.string.lowest_price), + state.lowestPrice?.toFloat() ?: 0F + ) + ) + ) + } + } repeatOnStarted { productDetailViewModel.event.collect { event -> when (event) { @@ -81,24 +157,30 @@ class DetailActivity : AppCompatActivity() { is ProductDetailViewModel.ProductDetailEvent.DeleteFailed -> { when (event.errorType) { - ProductDeleteState.NOT_FOUND -> { - showToast(getString(R.string.product_not_found)) + ProductErrorState.NOT_FOUND -> { + showConfirmationDialog( + getString(R.string.delete_product_failed), + getString(R.string.product_not_found) + ) } - ProductDeleteState.INVALID_REQUEST -> { - showToast(getString(R.string.invalid_request)) + ProductErrorState.INVALID_REQUEST -> { + showConfirmationDialog( + getString(R.string.delete_product_failed), + getString(R.string.invalid_request) + ) } - ProductDeleteState.UNAUTHORIZED -> { - showToast(getString(R.string.logged_out)) - finish() + ProductErrorState.PERMISSION_DENIED -> { + showPermissionDeniedDialog(tokenRepository) } - ProductDeleteState.UNDEFINED_ERROR -> { - showToast(getString(R.string.undefined_error)) + else -> { + showConfirmationDialog( + getString(R.string.delete_product_failed), + getString(R.string.undefined_error) + ) } - - else -> {} } } @@ -117,6 +199,10 @@ class DetailActivity : AppCompatActivity() { } } + private fun setGraph() { + binding.chGraphDetail.setXAxisMargin(48F) + } + private fun showConfirmationDialog( title: String, message: String, diff --git a/android/app/src/main/java/app/priceguard/ui/detail/ProductDetailViewModel.kt b/android/app/src/main/java/app/priceguard/ui/detail/ProductDetailViewModel.kt index 4670ba4..d420144 100644 --- a/android/app/src/main/java/app/priceguard/ui/detail/ProductDetailViewModel.kt +++ b/android/app/src/main/java/app/priceguard/ui/detail/ProductDetailViewModel.kt @@ -2,9 +2,11 @@ package app.priceguard.ui.detail import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import app.priceguard.data.dto.ProductDeleteState -import app.priceguard.data.dto.ProductDetailState +import app.priceguard.data.dto.ProductErrorState +import app.priceguard.data.graph.ProductChartDataset +import app.priceguard.data.network.ProductRepositoryResult import app.priceguard.data.repository.ProductRepository +import app.priceguard.materialchart.data.GraphMode import dagger.hilt.android.lifecycle.HiltViewModel import java.text.NumberFormat import javax.inject.Inject @@ -24,6 +26,7 @@ class ProductDetailViewModel @Inject constructor(val productRepository: ProductR data class ProductDetailUIState( val isTracking: Boolean = false, val isReady: Boolean = false, + val isRefreshing: Boolean = false, val productName: String? = null, val shop: String? = null, val imageUrl: String? = null, @@ -34,7 +37,9 @@ class ProductDetailViewModel @Inject constructor(val productRepository: ProductR val price: Int? = null, val formattedPrice: String = "", val formattedTargetPrice: String = "", - val formattedLowestPrice: String = "" + val formattedLowestPrice: String = "", + val chartPeriod: GraphMode = GraphMode.DAY, + val chartData: ProductChartDataset? = null ) sealed class ProductDetailEvent { @@ -44,7 +49,7 @@ class ProductDetailViewModel @Inject constructor(val productRepository: ProductR data object NotFound : ProductDetailEvent() data object UnknownError : ProductDetailEvent() data object DeleteSuccess : ProductDetailEvent() - data class DeleteFailed(val errorType: ProductDeleteState) : ProductDetailEvent() + data class DeleteFailed(val errorType: ProductErrorState) : ProductDetailEvent() } lateinit var productCode: String @@ -58,82 +63,103 @@ class ProductDetailViewModel @Inject constructor(val productRepository: ProductR fun deleteProductTracking() { viewModelScope.launch { - when (val result = productRepository.deleteProduct(productCode, false)) { - ProductDeleteState.SUCCESS -> { + when (val result = productRepository.deleteProduct(productCode)) { + is ProductRepositoryResult.Success -> { _event.emit(ProductDetailEvent.DeleteSuccess) } - else -> { - _event.emit(ProductDetailEvent.DeleteFailed(result)) + is ProductRepositoryResult.Error -> { + _event.emit(ProductDetailEvent.DeleteFailed(result.productErrorState)) } } } } - fun getDetails() { + fun getDetails(isRefresh: Boolean) { viewModelScope.launch { if (::productCode.isInitialized.not()) { return@launch } - val result = productRepository.getProductDetail(productCode, false) - - when (result.state) { - ProductDetailState.SUCCESS -> { - if (result.productName == null || - result.productCode == null || - result.shop == null || - result.imageUrl == null || - result.rank == null || - result.shopUrl == null || - result.targetPrice == null || - result.lowestPrice == null || - result.price == null - ) { - _event.emit(ProductDetailEvent.UnknownError) - return@launch - } + if (isRefresh) { + _state.value = _state.value.copy(isRefreshing = true) + } + + val result = productRepository.getProductDetail(productCode) + _state.value = _state.value.copy(isRefreshing = false) + + when (result) { + is ProductRepositoryResult.Success -> { _state.update { it.copy( isReady = true, - isTracking = result.targetPrice >= 0, - productName = result.productName, - shop = result.shop, - imageUrl = result.imageUrl, - rank = result.rank, - shopUrl = result.shopUrl, - targetPrice = result.targetPrice, - lowestPrice = result.lowestPrice, - price = result.price, - formattedPrice = formatPrice(result.price), - formattedTargetPrice = if (result.targetPrice < 0) { + isTracking = result.data.targetPrice >= 0, + productName = result.data.productName, + shop = result.data.shop, + imageUrl = result.data.imageUrl, + rank = result.data.rank, + shopUrl = result.data.shopUrl, + targetPrice = result.data.targetPrice, + lowestPrice = result.data.lowestPrice, + price = result.data.price, + formattedPrice = formatPrice(result.data.price), + formattedTargetPrice = if (result.data.targetPrice < 0) { "0" } else { formatPrice( - result.targetPrice + result.data.targetPrice ) }, - formattedLowestPrice = formatPrice(result.lowestPrice) + formattedLowestPrice = formatPrice(result.data.lowestPrice), + chartPeriod = GraphMode.DAY, + chartData = ProductChartDataset( + showXAxis = true, + showYAxis = true, + isInteractive = true, + graphMode = GraphMode.DAY, + data = result.data.priceData, + gridLines = listOf() + ) ) } } - ProductDetailState.PERMISSION_DENIED -> { - _event.emit(ProductDetailEvent.Logout) - } + is ProductRepositoryResult.Error -> { + when (result.productErrorState) { + ProductErrorState.PERMISSION_DENIED -> { + _event.emit(ProductDetailEvent.Logout) + } - ProductDetailState.NOT_FOUND -> { - _event.emit(ProductDetailEvent.NotFound) - } + ProductErrorState.NOT_FOUND -> { + _event.emit(ProductDetailEvent.NotFound) + } - ProductDetailState.UNDEFINED_ERROR -> { - _event.emit(ProductDetailEvent.UnknownError) + else -> { + _event.emit(ProductDetailEvent.UnknownError) + } + } } } } } + fun changePeriod(period: GraphMode) { + _state.update { + it.copy( + chartPeriod = period, + chartData = ProductChartDataset( + showXAxis = true, + showYAxis = true, + isInteractive = true, + graphMode = period, + data = it.chartData?.data ?: listOf(), + gridLines = listOf() + ) + ) + } + } + fun sendBrowserEvent() { viewModelScope.launch { val event = _state.value.shopUrl?.let { ProductDetailEvent.OpenShoppingMall(it) } diff --git a/android/app/src/main/java/app/priceguard/ui/home/ProductSummary.kt b/android/app/src/main/java/app/priceguard/ui/home/ProductSummary.kt index 2ff1f7e..3d6294f 100644 --- a/android/app/src/main/java/app/priceguard/ui/home/ProductSummary.kt +++ b/android/app/src/main/java/app/priceguard/ui/home/ProductSummary.kt @@ -1,27 +1,30 @@ package app.priceguard.ui.home +import app.priceguard.data.graph.ProductChartData + sealed interface ProductSummary { val brandType: String val title: String - val price: String - val discountPercent: String + val price: Int val productCode: String + val priceData: List data class UserProductSummary( override val brandType: String, override val title: String, - override val price: String, - override val discountPercent: String, + override val price: Int, override val productCode: String, + override val priceData: List, + val discountPercent: Float, val isAlarmOn: Boolean ) : ProductSummary data class RecommendedProductSummary( override val brandType: String, override val title: String, - override val price: String, - override val discountPercent: String, + override val price: Int, override val productCode: String, + override val priceData: List, val recommendRank: Int ) : ProductSummary } diff --git a/android/app/src/main/java/app/priceguard/ui/home/ProductSummaryAdapter.kt b/android/app/src/main/java/app/priceguard/ui/home/ProductSummaryAdapter.kt index dda071b..ac02f9d 100644 --- a/android/app/src/main/java/app/priceguard/ui/home/ProductSummaryAdapter.kt +++ b/android/app/src/main/java/app/priceguard/ui/home/ProductSummaryAdapter.kt @@ -1,6 +1,7 @@ package app.priceguard.ui.home import android.content.Intent +import android.util.TypedValue import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -8,7 +9,10 @@ import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import app.priceguard.R +import app.priceguard.data.graph.ProductChartData +import app.priceguard.data.graph.ProductChartDataset import app.priceguard.databinding.ItemProductSummaryBinding +import app.priceguard.materialchart.data.GraphMode import app.priceguard.ui.detail.DetailActivity class ProductSummaryAdapter : @@ -33,6 +37,7 @@ class ProductSummaryAdapter : summary = item setViewType(item) setClickListener(item.productCode) + setGraph(item.priceData) } } @@ -41,12 +46,15 @@ class ProductSummaryAdapter : is ProductSummary.RecommendedProductSummary -> { tvProductRecommendRank.visibility = View.VISIBLE msProduct.visibility = View.GONE + tvProductDiscountPercent.visibility = View.GONE setRecommendRank(item) } is ProductSummary.UserProductSummary -> { tvProductRecommendRank.visibility = View.GONE msProduct.visibility = View.VISIBLE + tvProductDiscountPercent.visibility = View.VISIBLE + setDisCount(item.discountPercent) setSwitchListener() } } @@ -67,6 +75,28 @@ class ProductSummaryAdapter : } } + private fun ItemProductSummaryBinding.setDisCount(discount: Float) { + tvProductDiscountPercent.text = + if (discount > 0) { + tvProductDiscountPercent.context.getString( + R.string.add_plus, + tvProductDiscountPercent.context.getString(R.string.percent, discount) + ) + } else { + tvProductDiscountPercent.context.getString( + R.string.percent, + discount + ) + } + val value = TypedValue() + tvProductDiscountPercent.context.theme.resolveAttribute( + if (discount > 0) android.R.attr.colorPrimary else android.R.attr.colorError, + value, + true + ) + tvProductDiscountPercent.setTextColor(value.data) + } + private fun ItemProductSummaryBinding.setRecommendRank(item: ProductSummary.RecommendedProductSummary) { tvProductRecommendRank.text = tvProductRecommendRank.context.getString( R.string.recommand_rank, item.recommendRank @@ -80,6 +110,17 @@ class ProductSummaryAdapter : binding.root.context.startActivity(intent) } } + + private fun ItemProductSummaryBinding.setGraph(data: List) { + chGraph.dataset = ProductChartDataset( + showXAxis = false, + showYAxis = false, + isInteractive = false, + graphMode = GraphMode.DAY, + data = data, + gridLines = listOf() + ) + } } companion object { diff --git a/android/app/src/main/java/app/priceguard/ui/home/list/ProductListFragment.kt b/android/app/src/main/java/app/priceguard/ui/home/list/ProductListFragment.kt index ed2e30b..f1e0abb 100644 --- a/android/app/src/main/java/app/priceguard/ui/home/list/ProductListFragment.kt +++ b/android/app/src/main/java/app/priceguard/ui/home/list/ProductListFragment.kt @@ -8,19 +8,18 @@ import android.view.ViewGroup import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels -import androidx.lifecycle.lifecycleScope import app.priceguard.R +import app.priceguard.data.dto.ProductErrorState import app.priceguard.data.repository.TokenRepository import app.priceguard.databinding.FragmentProductListBinding import app.priceguard.ui.additem.AddItemActivity import app.priceguard.ui.home.ProductSummaryAdapter -import app.priceguard.ui.home.list.ProductListViewModel.ProductListEvent import app.priceguard.ui.util.lifecycle.repeatOnStarted import app.priceguard.ui.util.ui.disableAppBarRecyclerView +import app.priceguard.ui.util.ui.showConfirmationDialog import app.priceguard.ui.util.ui.showPermissionDeniedDialog import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject -import kotlinx.coroutines.launch @AndroidEntryPoint class ProductListFragment : Fragment() { @@ -54,6 +53,11 @@ class ProductListFragment : Fragment() { ) } + override fun onStart() { + super.onStart() + productListViewModel.getProductList(false) + } + private fun FragmentProductListBinding.initSettingAdapter() { val adapter = ProductSummaryAdapter() rvProductList.adapter = adapter @@ -65,17 +69,13 @@ class ProductListFragment : Fragment() { } private fun FragmentProductListBinding.initListener() { - mtbProductList.setOnMenuItemClickListener { menuItem -> - if (menuItem.itemId == R.id.refresh) { - lifecycleScope.launch { - productListViewModel.getProductList() - } - } - true - } fabProductList.setOnClickListener { gotoProductAddActivity() } + + ablProductList.addOnOffsetChangedListener { _, verticalOffset -> + srlProductList.isEnabled = verticalOffset == 0 + } } private fun gotoProductAddActivity() { @@ -86,8 +86,31 @@ class ProductListFragment : Fragment() { private fun collectEvent() { repeatOnStarted { productListViewModel.events.collect { event -> - if (event is ProductListEvent.PermissionDenied) { - activity?.showPermissionDeniedDialog(tokenRepository) + when (event) { + ProductErrorState.PERMISSION_DENIED -> { + requireActivity().showPermissionDeniedDialog(tokenRepository) + } + + ProductErrorState.INVALID_REQUEST -> { + requireActivity().showConfirmationDialog( + getString(R.string.product_list_failed), + getString(R.string.invalid_request) + ) + } + + ProductErrorState.NOT_FOUND -> { + requireActivity().showConfirmationDialog( + getString(R.string.product_list_failed), + getString(R.string.not_found) + ) + } + + else -> { + requireActivity().showConfirmationDialog( + getString(R.string.product_list_failed), + getString(R.string.undefined_error) + ) + } } } } diff --git a/android/app/src/main/java/app/priceguard/ui/home/list/ProductListViewModel.kt b/android/app/src/main/java/app/priceguard/ui/home/list/ProductListViewModel.kt index 503f7b8..194e045 100644 --- a/android/app/src/main/java/app/priceguard/ui/home/list/ProductListViewModel.kt +++ b/android/app/src/main/java/app/priceguard/ui/home/list/ProductListViewModel.kt @@ -2,11 +2,13 @@ package app.priceguard.ui.home.list import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import app.priceguard.data.dto.ProductListState +import app.priceguard.data.dto.ProductErrorState +import app.priceguard.data.network.ProductRepositoryResult import app.priceguard.data.repository.ProductRepository import app.priceguard.ui.home.ProductSummary.UserProductSummary import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import kotlin.math.round import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -20,37 +22,48 @@ class ProductListViewModel @Inject constructor( private val productRepository: ProductRepository ) : ViewModel() { - sealed class ProductListEvent { - data object PermissionDenied : ProductListEvent() - } + private var _isRefreshing: MutableStateFlow = MutableStateFlow(false) + val isRefreshing: StateFlow = _isRefreshing.asStateFlow() private var _productList = MutableStateFlow>(listOf()) val productList: StateFlow> = _productList.asStateFlow() - private var _events = MutableSharedFlow() - val events: SharedFlow = _events.asSharedFlow() + private var _events = MutableSharedFlow() + val events: SharedFlow = _events.asSharedFlow() - init { + fun getProductList(isRefresh: Boolean) { viewModelScope.launch { - getProductList() - } - } + if (isRefresh) { + _isRefreshing.value = true + } + + val result = productRepository.getProductList() - suspend fun getProductList() { - val result = productRepository.getProductList() - if (result.productListState == ProductListState.PERMISSION_DENIED) { - _events.emit(ProductListEvent.PermissionDenied) - } else { - _productList.value = result.trackingList.map { data -> - UserProductSummary( - data.shop, - data.productName, - data.price.toString(), - "-15.0%", - data.productCode, - true - ) + _isRefreshing.value = false + + when (result) { + is ProductRepositoryResult.Success -> { + _productList.value = result.data.map { data -> + UserProductSummary( + data.shop, + data.productName, + data.price, + data.productCode, + data.priceData, + calculateDiscountRate(data.targetPrice, data.price), + true + ) + } + } + + is ProductRepositoryResult.Error -> { + _events.emit(result.productErrorState) + } } } } + + private fun calculateDiscountRate(targetPrice: Int, price: Int): Float { + return round((price - targetPrice).toFloat() / (if (targetPrice == 0) 1 else targetPrice) * 1000) / 10 + } } diff --git a/android/app/src/main/java/app/priceguard/ui/home/mypage/MyPageFragment.kt b/android/app/src/main/java/app/priceguard/ui/home/mypage/MyPageFragment.kt index 5406092..2392312 100644 --- a/android/app/src/main/java/app/priceguard/ui/home/mypage/MyPageFragment.kt +++ b/android/app/src/main/java/app/priceguard/ui/home/mypage/MyPageFragment.kt @@ -119,7 +119,7 @@ class MyPageFragment : Fragment() { private fun startIntroAndExitHome() { val intent = Intent(requireActivity(), IntroActivity::class.java) startActivity(intent) - activity?.finish() + requireActivity().finish() } override fun onDestroyView() { diff --git a/android/app/src/main/java/app/priceguard/ui/home/recommend/RecommendedProductFragment.kt b/android/app/src/main/java/app/priceguard/ui/home/recommend/RecommendedProductFragment.kt index 4a5ca2c..fbbc963 100644 --- a/android/app/src/main/java/app/priceguard/ui/home/recommend/RecommendedProductFragment.kt +++ b/android/app/src/main/java/app/priceguard/ui/home/recommend/RecommendedProductFragment.kt @@ -7,18 +7,17 @@ import android.view.ViewGroup import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels -import androidx.lifecycle.lifecycleScope import app.priceguard.R +import app.priceguard.data.dto.ProductErrorState import app.priceguard.data.repository.TokenRepository import app.priceguard.databinding.FragmentRecommendedProductBinding import app.priceguard.ui.home.ProductSummaryAdapter -import app.priceguard.ui.home.recommend.RecommendedProductViewModel.RecommendedProductEvent import app.priceguard.ui.util.lifecycle.repeatOnStarted import app.priceguard.ui.util.ui.disableAppBarRecyclerView +import app.priceguard.ui.util.ui.showConfirmationDialog import app.priceguard.ui.util.ui.showPermissionDeniedDialog import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject -import kotlinx.coroutines.launch @AndroidEntryPoint class RecommendedProductFragment : Fragment() { @@ -52,6 +51,11 @@ class RecommendedProductFragment : Fragment() { ) } + override fun onStart() { + super.onStart() + recommendedProductViewModel.getRecommendedProductList(false) + } + private fun FragmentRecommendedProductBinding.initSettingAdapter() { val adapter = ProductSummaryAdapter() rvRecommendedProduct.adapter = adapter @@ -63,21 +67,39 @@ class RecommendedProductFragment : Fragment() { } private fun FragmentRecommendedProductBinding.initListener() { - mtbRecommendedProduct.setOnMenuItemClickListener { menuItem -> - if (menuItem.itemId == R.id.refresh) { - lifecycleScope.launch { - recommendedProductViewModel.getProductList() - } - } - true + ablRecommendedProduct.addOnOffsetChangedListener { _, verticalOffset -> + srlRecommendedProduct.isEnabled = verticalOffset == 0 } } private fun collectEvent() { repeatOnStarted { recommendedProductViewModel.events.collect { event -> - if (event is RecommendedProductEvent.PermissionDenied) { - activity?.showPermissionDeniedDialog(tokenRepository) + when (event) { + ProductErrorState.PERMISSION_DENIED -> { + requireActivity().showPermissionDeniedDialog(tokenRepository) + } + + ProductErrorState.INVALID_REQUEST -> { + requireActivity().showConfirmationDialog( + getString(R.string.recommended_product_failed), + getString(R.string.invalid_request) + ) + } + + ProductErrorState.NOT_FOUND -> { + requireActivity().showConfirmationDialog( + getString(R.string.recommended_product_failed), + getString(R.string.not_found) + ) + } + + else -> { + requireActivity().showConfirmationDialog( + getString(R.string.recommended_product_failed), + getString(R.string.undefined_error) + ) + } } } } diff --git a/android/app/src/main/java/app/priceguard/ui/home/recommend/RecommendedProductViewModel.kt b/android/app/src/main/java/app/priceguard/ui/home/recommend/RecommendedProductViewModel.kt index 47a095d..49d81b5 100644 --- a/android/app/src/main/java/app/priceguard/ui/home/recommend/RecommendedProductViewModel.kt +++ b/android/app/src/main/java/app/priceguard/ui/home/recommend/RecommendedProductViewModel.kt @@ -2,7 +2,8 @@ package app.priceguard.ui.home.recommend import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import app.priceguard.data.dto.RecommendProductState +import app.priceguard.data.dto.ProductErrorState +import app.priceguard.data.network.ProductRepositoryResult import app.priceguard.data.repository.ProductRepository import app.priceguard.ui.home.ProductSummary.RecommendedProductSummary import dagger.hilt.android.lifecycle.HiltViewModel @@ -24,34 +25,43 @@ class RecommendedProductViewModel @Inject constructor( data object PermissionDenied : RecommendedProductEvent() } + private var _isRefreshing = MutableStateFlow(false) + val isRefreshing: StateFlow = _isRefreshing.asStateFlow() + private var _recommendedProductList = MutableStateFlow>(listOf()) val recommendedProductList: StateFlow> = _recommendedProductList.asStateFlow() - private var _events = MutableSharedFlow() - val events: SharedFlow = _events.asSharedFlow() + private var _events = MutableSharedFlow() + val events: SharedFlow = _events.asSharedFlow() - init { + fun getRecommendedProductList(isRefresh: Boolean) { viewModelScope.launch { - getProductList() - } - } + if (isRefresh) { + _isRefreshing.value = true + } + + val result = productRepository.getRecommendedProductList() + _isRefreshing.value = false + + when (result) { + is ProductRepositoryResult.Success -> { + _recommendedProductList.value = result.data.map { data -> + RecommendedProductSummary( + data.shop, + data.productName, + data.price, + data.productCode, + data.priceData, + data.rank + ) + } + } - suspend fun getProductList() { - val result = productRepository.getRecommendedProductList() - if (result.productListState == RecommendProductState.PERMISSION_DENIED) { - _events.emit(RecommendedProductEvent.PermissionDenied) - } else { - _recommendedProductList.value = result.recommendList.map { data -> - RecommendedProductSummary( - data.shop, - data.productName, - data.price.toString(), - "-15.3%", - data.productCode, - data.rank - ) + is ProductRepositoryResult.Error -> { + _events.emit(result.productErrorState) + } } } } diff --git a/android/app/src/main/java/app/priceguard/ui/util/ui/NetworkDialog.kt b/android/app/src/main/java/app/priceguard/ui/util/ui/NetworkDialog.kt index 0babe06..f9eaa17 100644 --- a/android/app/src/main/java/app/priceguard/ui/util/ui/NetworkDialog.kt +++ b/android/app/src/main/java/app/priceguard/ui/util/ui/NetworkDialog.kt @@ -27,3 +27,16 @@ fun Activity.goBackToLoginActivity(tokenRepository: TokenRepository) { startActivity(intent) finish() } + +fun Activity.showConfirmationDialog( + title: String, + message: String +) { + MaterialAlertDialogBuilder(this, R.style.ThemeOverlay_App_MaterialAlertDialog) + .setTitle(title) + .setMessage(message) + .setPositiveButton(getString(R.string.confirm)) { _, _ -> } + .setNegativeButton(getString(R.string.cancel)) { _, _ -> } + .create() + .show() +} diff --git a/android/app/src/main/res/drawable/bg_round_corner.xml b/android/app/src/main/res/drawable/bg_round_corner.xml index 1c9349a..a68b3a8 100644 --- a/android/app/src/main/res/drawable/bg_round_corner.xml +++ b/android/app/src/main/res/drawable/bg_round_corner.xml @@ -3,6 +3,11 @@ xmlns:android="http://schemas.android.com/apk/res/android"> + + + \ No newline at end of file diff --git a/android/app/src/main/res/drawable/bg_round_corner_error.xml b/android/app/src/main/res/drawable/bg_round_corner_error.xml new file mode 100644 index 0000000..17766cd --- /dev/null +++ b/android/app/src/main/res/drawable/bg_round_corner_error.xml @@ -0,0 +1,13 @@ + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/layout/activity_detail.xml b/android/app/src/main/res/layout/activity_detail.xml index 18e59e1..f14e721 100644 --- a/android/app/src/main/res/layout/activity_detail.xml +++ b/android/app/src/main/res/layout/activity_detail.xml @@ -34,286 +34,299 @@ - + app:layout_behavior="@string/appbar_scrolling_view_behavior" + app:onRefreshListener="@{() -> viewModel.getDetails(true)}" + app:refreshing="@{viewModel.state.isRefreshing}"> - - - - - - - - - - - - - - - - - - -