From ee2e43b4c90165d64ee3d1c75819870d3a72474d Mon Sep 17 00:00:00 2001 From: Taewan Park Date: Mon, 1 Apr 2024 22:13:00 +0900 Subject: [PATCH] v1.2.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 회원 탈퇴 기능 추가 - 슬라이더 디자인 변경 - 딥링크 지원 방식 변경 (11번가와 네이버 스마트스토어) - 인앱 업데이트 구현 - 상품 최대 등록 개수 제한 - 비밀번호 찾기, 이메일 인증 구현 Co-Authored-By: ootr47 <83055885+ootr47@users.noreply.github.com> Co-Authored-By: EunhoKang Co-Authored-By: ByeongIk Choi Co-Authored-By: 손문기 <39684860+Muungi@users.noreply.github.com> --- .github/workflows/firebase-deploy.yml | 4 +- README.md | 14 +- android/.idea/.gitignore | 2 + android/.idea/appInsightsSettings.xml | 45 ++ android/app/build.gradle.kts | 26 +- android/app/src/main/AndroidManifest.xml | 14 +- .../data/datastore/TokenDataSource.kt | 2 + .../data/datastore/TokenDataSourceImpl.kt | 14 + .../data/dto/add/ProductAddRequest.kt | 1 + .../dto/deleteaccount/DeleteAccountRequest.kt | 9 + .../deleteaccount/DeleteAccountResponse.kt | 9 + .../dto/isverified/IsEmailVerifiedResponse.kt | 10 + .../data/dto/password/ResetPasswordRequest.kt | 8 + .../dto/password/ResetPasswordResponse.kt | 9 + .../data/dto/patch/PricePatchRequest.kt | 1 + .../data/dto/signup/SignupRequest.kt | 1 + .../RequestVerificationCodeRequest.kt | 8 + .../RequestVerificationCodeResponse.kt | 9 + .../dto/verifyemail/VerifyEmailRequest.kt | 9 + .../dto/verifyemail/VerifyEmailResponse.kt | 10 + .../app/priceguard/data/network/AuthAPI.kt | 9 + .../app/priceguard/data/network/ProductAPI.kt | 19 +- .../app/priceguard/data/network/UserAPI.kt | 40 +- .../data/repository/auth/AuthErrorState.kt | 4 + .../data/repository/auth/AuthRepository.kt | 10 +- .../repository/auth/AuthRepositoryImpl.kt | 105 ++++- .../repository/product/ProductErrorState.kt | 1 + .../repository/product/ProductRepository.kt | 10 +- .../product/ProductRepositoryImpl.kt | 30 +- .../data/repository/token/TokenErrorState.kt | 2 + .../data/repository/token/TokenRepository.kt | 5 + .../repository/token/TokenRepositoryImpl.kt | 62 +++ .../java/app/priceguard/di/NetworkModule.kt | 4 +- .../PriceGuardFirebaseMessagingService.kt | 6 +- .../priceguard/service/UpdateAlarmWorker.kt | 20 +- .../priceguard/ui/additem/AddItemActivity.kt | 4 +- .../confirm/ConfirmItemLinkFragment.kt | 1 + .../additem/link/RegisterItemLinkFragment.kt | 1 + .../setprice/SetTargetPriceDialogFragment.kt | 123 ++++++ .../setprice/SetTargetPriceDialogViewModel.kt | 29 ++ .../setprice/SetTargetPriceFragment.kt | 132 +++--- .../setprice/SetTargetPriceViewModel.kt | 22 +- .../priceguard/ui/data/VerifyEmailResult.kt | 5 + .../priceguard/ui/detail/DetailActivity.kt | 82 +++- .../ui/detail/ProductDetailViewModel.kt | 17 +- .../app/priceguard/ui/home/HomeActivity.kt | 85 ++++ .../ui/home/ProductSummaryAdapter.kt | 25 +- .../ui/home/ProductSummaryClickListener.kt | 4 +- .../ui/home/list/ProductListFragment.kt | 25 +- .../ui/home/list/ProductListViewModel.kt | 61 +-- .../ui/home/mypage/DeleteAccountActivity.kt | 94 ++++ .../ui/home/mypage/DeleteAccountViewModel.kt | 82 ++++ .../ui/home/mypage/MyPageFragment.kt | 40 +- .../ui/home/mypage/MyPageSettingAdapter.kt | 7 + .../ui/home/mypage/MyPageViewModel.kt | 33 +- .../ui/home/mypage/SettingItemInfo.kt | 3 +- .../recommend/RecommendedProductFragment.kt | 11 +- .../recommend/RecommendedProductViewModel.kt | 44 +- .../app/priceguard/ui/login/LoginActivity.kt | 8 + .../findpassword/EmailVerificationFragment.kt | 167 +++++++ .../EmailVerificationViewModel.kt | 170 +++++++ .../findpassword/FindPasswordActivity.kt | 29 ++ .../findpassword/ResetPasswordFragment.kt | 97 ++++ .../findpassword/ResetPasswordViewModel.kt | 94 ++++ .../priceguard/ui/signup/SignupActivity.kt | 141 +++--- .../priceguard/ui/signup/SignupViewModel.kt | 8 +- .../app/priceguard/ui/slider/ConvertUtil.kt | 45 ++ .../app/priceguard/ui/slider/RoundSlider.kt | 413 ++++++++++++++++++ .../priceguard/ui/slider/RoundSliderState.kt | 7 + .../ui/slider/SliderValueChangeListener.kt | 3 + .../ui/splash/SplashScreenActivity.kt | 9 +- .../java/app/priceguard/ui/util/Dialog.kt | 23 +- .../ui/util/ImageViewBindingAdapter.kt | 17 + .../ui/util/TextInputLayoutBindingAdapter.kt | 9 + .../ui/util/ThrottleClickListener.kt | 36 ++ .../app/priceguard/ui/util/ViewExtensions.kt | 16 + .../ui/util/drawable/ProgressIndicator.kt | 4 +- .../src/main/res/drawable/ic_close_red.xml | 10 + .../src/main/res/drawable/ic_naver_logo.xml | 8 + .../app/src/main/res/drawable/ic_remove.xml | 10 + .../res/layout/activity_delete_account.xml | 151 +++++++ .../src/main/res/layout/activity_detail.xml | 5 +- .../res/layout/activity_find_password.xml | 27 ++ .../src/main/res/layout/activity_login.xml | 12 +- .../src/main/res/layout/activity_signup.xml | 66 ++- .../res/layout/fragment_confirm_item_link.xml | 2 +- .../layout/fragment_email_verification.xml | 150 +++++++ .../src/main/res/layout/fragment_my_page.xml | 23 +- .../main/res/layout/fragment_product_list.xml | 38 +- .../layout/fragment_recommended_product.xml | 37 +- .../res/layout/fragment_reset_password.xml | 130 ++++++ .../res/layout/fragment_set_target_price.xml | 68 +-- .../layout/fragment_target_price_dialog.xml | 48 ++ .../main/res/layout/item_product_summary.xml | 3 +- .../app/src/main/res/navigation/nav_graph.xml | 6 + .../navigation/nav_graph_find_password.xml | 31 ++ android/app/src/main/res/values/attrs.xml | 9 + android/app/src/main/res/values/colors.xml | 4 + android/app/src/main/res/values/strings.xml | 56 ++- android/app/src/main/res/values/styles.xml | 14 + android/release_notes.txt | 15 +- 101 files changed, 3296 insertions(+), 385 deletions(-) create mode 100644 android/.idea/appInsightsSettings.xml create mode 100644 android/app/src/main/java/app/priceguard/data/dto/deleteaccount/DeleteAccountRequest.kt create mode 100644 android/app/src/main/java/app/priceguard/data/dto/deleteaccount/DeleteAccountResponse.kt create mode 100644 android/app/src/main/java/app/priceguard/data/dto/isverified/IsEmailVerifiedResponse.kt create mode 100644 android/app/src/main/java/app/priceguard/data/dto/password/ResetPasswordRequest.kt create mode 100644 android/app/src/main/java/app/priceguard/data/dto/password/ResetPasswordResponse.kt create mode 100644 android/app/src/main/java/app/priceguard/data/dto/verifyemail/RequestVerificationCodeRequest.kt create mode 100644 android/app/src/main/java/app/priceguard/data/dto/verifyemail/RequestVerificationCodeResponse.kt create mode 100644 android/app/src/main/java/app/priceguard/data/dto/verifyemail/VerifyEmailRequest.kt create mode 100644 android/app/src/main/java/app/priceguard/data/dto/verifyemail/VerifyEmailResponse.kt create mode 100644 android/app/src/main/java/app/priceguard/ui/additem/setprice/SetTargetPriceDialogFragment.kt create mode 100644 android/app/src/main/java/app/priceguard/ui/additem/setprice/SetTargetPriceDialogViewModel.kt create mode 100644 android/app/src/main/java/app/priceguard/ui/data/VerifyEmailResult.kt create mode 100644 android/app/src/main/java/app/priceguard/ui/home/mypage/DeleteAccountActivity.kt create mode 100644 android/app/src/main/java/app/priceguard/ui/home/mypage/DeleteAccountViewModel.kt create mode 100644 android/app/src/main/java/app/priceguard/ui/login/findpassword/EmailVerificationFragment.kt create mode 100644 android/app/src/main/java/app/priceguard/ui/login/findpassword/EmailVerificationViewModel.kt create mode 100644 android/app/src/main/java/app/priceguard/ui/login/findpassword/FindPasswordActivity.kt create mode 100644 android/app/src/main/java/app/priceguard/ui/login/findpassword/ResetPasswordFragment.kt create mode 100644 android/app/src/main/java/app/priceguard/ui/login/findpassword/ResetPasswordViewModel.kt create mode 100644 android/app/src/main/java/app/priceguard/ui/slider/ConvertUtil.kt create mode 100644 android/app/src/main/java/app/priceguard/ui/slider/RoundSlider.kt create mode 100644 android/app/src/main/java/app/priceguard/ui/slider/RoundSliderState.kt create mode 100644 android/app/src/main/java/app/priceguard/ui/slider/SliderValueChangeListener.kt create mode 100644 android/app/src/main/java/app/priceguard/ui/util/ImageViewBindingAdapter.kt create mode 100644 android/app/src/main/java/app/priceguard/ui/util/TextInputLayoutBindingAdapter.kt create mode 100644 android/app/src/main/java/app/priceguard/ui/util/ThrottleClickListener.kt create mode 100644 android/app/src/main/java/app/priceguard/ui/util/ViewExtensions.kt create mode 100644 android/app/src/main/res/drawable/ic_close_red.xml create mode 100644 android/app/src/main/res/drawable/ic_naver_logo.xml create mode 100644 android/app/src/main/res/drawable/ic_remove.xml create mode 100644 android/app/src/main/res/layout/activity_delete_account.xml create mode 100644 android/app/src/main/res/layout/activity_find_password.xml create mode 100644 android/app/src/main/res/layout/fragment_email_verification.xml create mode 100644 android/app/src/main/res/layout/fragment_reset_password.xml create mode 100644 android/app/src/main/res/layout/fragment_target_price_dialog.xml create mode 100644 android/app/src/main/res/navigation/nav_graph_find_password.xml create mode 100644 android/app/src/main/res/values/attrs.xml diff --git a/.github/workflows/firebase-deploy.yml b/.github/workflows/firebase-deploy.yml index 85d055b..0e14bd3 100644 --- a/.github/workflows/firebase-deploy.yml +++ b/.github/workflows/firebase-deploy.yml @@ -7,7 +7,7 @@ on: jobs: deploy: - runs-on: ubuntu-latest + runs-on: macos-14 steps: - uses: actions/checkout@v4 with: @@ -41,4 +41,4 @@ jobs: serviceCredentialsFileContent: ${{ secrets.FIREBASE_APP_DISTRIBUTION }} groups: tester releaseNotesFile: android/release_notes.txt - file: android/app/build/outputs/apk/release/app-release.apk + file: android/app/build/outputs/bundle/release/app-release.aab diff --git a/README.md b/README.md index 551c683..55a4477 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,11 @@ PriceGuard는 국내 상거래 사이트들의 상품 가격을 추적합니다. 또한 앱 내에서 원하는 상품의 가격 변화를 그래프로 표현합니다. ``` -어플리케이션은 [링크](https://appdistribution.firebase.google.com/pub/i/b299ae01bd67c829)를 통해 받을 수 있습니다. +어플리케이션은 아래 버튼을 클릭하여 받을 수 있습니다. + + + 다운로드하기 Google Play + ## 🥅 기술적 도전 @@ -129,7 +133,7 @@ PriceGuard는 국내 상거래 사이트들의 상품 가격을 추적합니다. - + @@ -154,9 +158,9 @@ PriceGuard는 국내 상거래 사이트들의 상품 가격을 추적합니다. - + - + @@ -215,7 +219,7 @@ PriceGuard는 국내 상거래 사이트들의 상품 가격을 추적합니다. ## 📺︎ 작동 화면 | 로그인/회원가입 | 상품 추천/상품 상세 | 상품 추가 / 마이페이지 | 알람 확인 | | ----------- | --------------- | ----------------- | ------- | -| | | | | +| | | | | ## :memo: 기술 문서 - [Feature List](https://docs.google.com/spreadsheets/d/1e1Z9YpHPZxcBZN2XBPeoaz88hDby6WG5jmMz8xjqMrU/edit#gid=1955813262) diff --git a/android/.idea/.gitignore b/android/.idea/.gitignore index 26d3352..8f00030 100644 --- a/android/.idea/.gitignore +++ b/android/.idea/.gitignore @@ -1,3 +1,5 @@ # Default ignored files /shelf/ /workspace.xml +# GitHub Copilot persisted chat sessions +/copilot/chatSessions diff --git a/android/.idea/appInsightsSettings.xml b/android/.idea/appInsightsSettings.xml new file mode 100644 index 0000000..c3fa74b --- /dev/null +++ b/android/.idea/appInsightsSettings.xml @@ -0,0 +1,45 @@ + + + + + + \ No newline at end of file diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 0e0f3c6..0379919 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -19,8 +19,8 @@ android { applicationId = "app.priceguard" minSdk = 29 targetSdk = 34 - versionCode = 8 - versionName = "1.0.1" + versionCode = 10 + versionName = "1.2.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -58,7 +58,7 @@ android { dependencies { // Firebase - implementation(platform("com.google.firebase:firebase-bom:32.6.0")) + implementation(platform("com.google.firebase:firebase-bom:32.8.0")) implementation("com.google.firebase:firebase-analytics") implementation("com.google.firebase:firebase-crashlytics") implementation("com.google.firebase:firebase-perf") @@ -67,11 +67,9 @@ dependencies { // Android 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.1") + implementation("com.google.android.material:material:1.11.0") + implementation("androidx.activity:activity-ktx:1.8.2") 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") // Retrofit, Serialization implementation("com.squareup.retrofit2:retrofit:2.9.0") @@ -82,7 +80,7 @@ dependencies { implementation("androidx.datastore:datastore-preferences:1.0.0") // Hilt - val hiltVersion = "2.48.1" + val hiltVersion = "2.49" implementation("com.google.dagger:hilt-android:$hiltVersion") kapt("com.google.dagger:hilt-android-compiler:$hiltVersion") @@ -92,7 +90,7 @@ dependencies { androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") // Navigation - val navVersion = "2.7.5" + val navVersion = "2.7.7" implementation("androidx.navigation:navigation-fragment-ktx:$navVersion") implementation("androidx.navigation:navigation-ui-ktx:$navVersion") @@ -107,11 +105,15 @@ dependencies { // Worker implementation("androidx.work:work-runtime-ktx:2.9.0") - implementation("androidx.hilt:hilt-work:1.1.0") - kapt("androidx.hilt:hilt-compiler:1.1.0") + implementation("androidx.hilt:hilt-work:1.2.0") + kapt("androidx.hilt:hilt-compiler:1.2.0") // Material chart - implementation("app.priceguard:materialchart:0.2.1") + implementation("app.priceguard:materialchart:0.2.2") + + // In app update + implementation("com.google.android.play:app-update:2.1.0") + implementation("com.google.android.play:app-update-ktx:2.1.0") } kapt { diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index fd71dcf..1482162 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -21,10 +21,6 @@ - - @@ -78,6 +74,15 @@ + + + - \ No newline at end of file diff --git a/android/app/src/main/java/app/priceguard/data/datastore/TokenDataSource.kt b/android/app/src/main/java/app/priceguard/data/datastore/TokenDataSource.kt index 1eca9f5..4844b3f 100644 --- a/android/app/src/main/java/app/priceguard/data/datastore/TokenDataSource.kt +++ b/android/app/src/main/java/app/priceguard/data/datastore/TokenDataSource.kt @@ -2,7 +2,9 @@ package app.priceguard.data.datastore interface TokenDataSource { suspend fun saveTokens(accessToken: String, refreshToken: String) + suspend fun saveEmailVerified(isVerified: Boolean) suspend fun getAccessToken(): String? suspend fun getRefreshToken(): String? + suspend fun getIsEmailVerified(): Boolean? suspend fun clearTokens() } diff --git a/android/app/src/main/java/app/priceguard/data/datastore/TokenDataSourceImpl.kt b/android/app/src/main/java/app/priceguard/data/datastore/TokenDataSourceImpl.kt index 5a0932b..dc3da42 100644 --- a/android/app/src/main/java/app/priceguard/data/datastore/TokenDataSourceImpl.kt +++ b/android/app/src/main/java/app/priceguard/data/datastore/TokenDataSourceImpl.kt @@ -2,6 +2,7 @@ package app.priceguard.data.datastore import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import app.priceguard.di.TokensQualifier @@ -15,6 +16,7 @@ class TokenDataSourceImpl @Inject constructor( private val accessTokenKey = stringPreferencesKey("access_token") private val refreshTokenKey = stringPreferencesKey("refresh_token") + private val isEmailVerifiedKey = booleanPreferencesKey("is_email_verified") override suspend fun saveTokens(accessToken: String, refreshToken: String) { dataStore.edit { preferences -> @@ -23,6 +25,12 @@ class TokenDataSourceImpl @Inject constructor( } } + override suspend fun saveEmailVerified(isVerified: Boolean) { + dataStore.edit { preferences -> + preferences[isEmailVerifiedKey] = isVerified + } + } + override suspend fun getAccessToken(): String? { return dataStore.data.map { preferences -> preferences[accessTokenKey] @@ -35,6 +43,12 @@ class TokenDataSourceImpl @Inject constructor( }.first() } + override suspend fun getIsEmailVerified(): Boolean? { + return dataStore.data.map { preferences -> + preferences[isEmailVerifiedKey] + }.first() + } + override suspend fun clearTokens() { dataStore.edit { preferences -> preferences.clear() diff --git a/android/app/src/main/java/app/priceguard/data/dto/add/ProductAddRequest.kt b/android/app/src/main/java/app/priceguard/data/dto/add/ProductAddRequest.kt index c8d3513..121bb2c 100644 --- a/android/app/src/main/java/app/priceguard/data/dto/add/ProductAddRequest.kt +++ b/android/app/src/main/java/app/priceguard/data/dto/add/ProductAddRequest.kt @@ -4,6 +4,7 @@ import kotlinx.serialization.Serializable @Serializable data class ProductAddRequest( + val shop: String, val productCode: String, val targetPrice: Int ) diff --git a/android/app/src/main/java/app/priceguard/data/dto/deleteaccount/DeleteAccountRequest.kt b/android/app/src/main/java/app/priceguard/data/dto/deleteaccount/DeleteAccountRequest.kt new file mode 100644 index 0000000..7a372b1 --- /dev/null +++ b/android/app/src/main/java/app/priceguard/data/dto/deleteaccount/DeleteAccountRequest.kt @@ -0,0 +1,9 @@ +package app.priceguard.data.dto.deleteaccount + +import kotlinx.serialization.Serializable + +@Serializable +data class DeleteAccountRequest( + val email: String, + val password: String +) diff --git a/android/app/src/main/java/app/priceguard/data/dto/deleteaccount/DeleteAccountResponse.kt b/android/app/src/main/java/app/priceguard/data/dto/deleteaccount/DeleteAccountResponse.kt new file mode 100644 index 0000000..f4545da --- /dev/null +++ b/android/app/src/main/java/app/priceguard/data/dto/deleteaccount/DeleteAccountResponse.kt @@ -0,0 +1,9 @@ +package app.priceguard.data.dto.deleteaccount + +import kotlinx.serialization.Serializable + +@Serializable +data class DeleteAccountResponse( + val statusCode: Int, + val message: String +) diff --git a/android/app/src/main/java/app/priceguard/data/dto/isverified/IsEmailVerifiedResponse.kt b/android/app/src/main/java/app/priceguard/data/dto/isverified/IsEmailVerifiedResponse.kt new file mode 100644 index 0000000..cfb27ff --- /dev/null +++ b/android/app/src/main/java/app/priceguard/data/dto/isverified/IsEmailVerifiedResponse.kt @@ -0,0 +1,10 @@ +package app.priceguard.data.dto.isverified + +import kotlinx.serialization.Serializable + +@Serializable +data class IsEmailVerifiedResponse( + val statusCode: Int, + val message: String, + val verified: Boolean? = null +) diff --git a/android/app/src/main/java/app/priceguard/data/dto/password/ResetPasswordRequest.kt b/android/app/src/main/java/app/priceguard/data/dto/password/ResetPasswordRequest.kt new file mode 100644 index 0000000..e5edc96 --- /dev/null +++ b/android/app/src/main/java/app/priceguard/data/dto/password/ResetPasswordRequest.kt @@ -0,0 +1,8 @@ +package app.priceguard.data.dto.password + +import kotlinx.serialization.Serializable + +@Serializable +data class ResetPasswordRequest( + val password: String +) diff --git a/android/app/src/main/java/app/priceguard/data/dto/password/ResetPasswordResponse.kt b/android/app/src/main/java/app/priceguard/data/dto/password/ResetPasswordResponse.kt new file mode 100644 index 0000000..2df9724 --- /dev/null +++ b/android/app/src/main/java/app/priceguard/data/dto/password/ResetPasswordResponse.kt @@ -0,0 +1,9 @@ +package app.priceguard.data.dto.password + +import kotlinx.serialization.Serializable + +@Serializable +data class ResetPasswordResponse( + val statusCode: Int, + val message: String +) diff --git a/android/app/src/main/java/app/priceguard/data/dto/patch/PricePatchRequest.kt b/android/app/src/main/java/app/priceguard/data/dto/patch/PricePatchRequest.kt index 0d07b26..2d97a3d 100644 --- a/android/app/src/main/java/app/priceguard/data/dto/patch/PricePatchRequest.kt +++ b/android/app/src/main/java/app/priceguard/data/dto/patch/PricePatchRequest.kt @@ -4,6 +4,7 @@ import kotlinx.serialization.Serializable @Serializable data class PricePatchRequest( + val shop: String, val productCode: String, val targetPrice: Int ) diff --git a/android/app/src/main/java/app/priceguard/data/dto/signup/SignupRequest.kt b/android/app/src/main/java/app/priceguard/data/dto/signup/SignupRequest.kt index 6a28e66..218581c 100644 --- a/android/app/src/main/java/app/priceguard/data/dto/signup/SignupRequest.kt +++ b/android/app/src/main/java/app/priceguard/data/dto/signup/SignupRequest.kt @@ -6,5 +6,6 @@ import kotlinx.serialization.Serializable data class SignupRequest( val email: String, val userName: String, + val verificationCode: String, val password: String ) diff --git a/android/app/src/main/java/app/priceguard/data/dto/verifyemail/RequestVerificationCodeRequest.kt b/android/app/src/main/java/app/priceguard/data/dto/verifyemail/RequestVerificationCodeRequest.kt new file mode 100644 index 0000000..0636903 --- /dev/null +++ b/android/app/src/main/java/app/priceguard/data/dto/verifyemail/RequestVerificationCodeRequest.kt @@ -0,0 +1,8 @@ +package app.priceguard.data.dto.verifyemail + +import kotlinx.serialization.Serializable + +@Serializable +data class RequestVerificationCodeRequest( + val email: String +) diff --git a/android/app/src/main/java/app/priceguard/data/dto/verifyemail/RequestVerificationCodeResponse.kt b/android/app/src/main/java/app/priceguard/data/dto/verifyemail/RequestVerificationCodeResponse.kt new file mode 100644 index 0000000..3596c55 --- /dev/null +++ b/android/app/src/main/java/app/priceguard/data/dto/verifyemail/RequestVerificationCodeResponse.kt @@ -0,0 +1,9 @@ +package app.priceguard.data.dto.verifyemail + +import kotlinx.serialization.Serializable + +@Serializable +data class RequestVerificationCodeResponse( + val statusCode: Int, + val message: String +) diff --git a/android/app/src/main/java/app/priceguard/data/dto/verifyemail/VerifyEmailRequest.kt b/android/app/src/main/java/app/priceguard/data/dto/verifyemail/VerifyEmailRequest.kt new file mode 100644 index 0000000..fe7797a --- /dev/null +++ b/android/app/src/main/java/app/priceguard/data/dto/verifyemail/VerifyEmailRequest.kt @@ -0,0 +1,9 @@ +package app.priceguard.data.dto.verifyemail + +import kotlinx.serialization.Serializable + +@Serializable +data class VerifyEmailRequest( + val email: String, + val verificationCode: String +) diff --git a/android/app/src/main/java/app/priceguard/data/dto/verifyemail/VerifyEmailResponse.kt b/android/app/src/main/java/app/priceguard/data/dto/verifyemail/VerifyEmailResponse.kt new file mode 100644 index 0000000..93ff845 --- /dev/null +++ b/android/app/src/main/java/app/priceguard/data/dto/verifyemail/VerifyEmailResponse.kt @@ -0,0 +1,10 @@ +package app.priceguard.data.dto.verifyemail + +import kotlinx.serialization.Serializable + +@Serializable +data class VerifyEmailResponse( + val statusCode: Int, + val message: String, + val verifyToken: String? = null +) diff --git a/android/app/src/main/java/app/priceguard/data/network/AuthAPI.kt b/android/app/src/main/java/app/priceguard/data/network/AuthAPI.kt index b65be19..01acb91 100644 --- a/android/app/src/main/java/app/priceguard/data/network/AuthAPI.kt +++ b/android/app/src/main/java/app/priceguard/data/network/AuthAPI.kt @@ -1,9 +1,13 @@ package app.priceguard.data.network import app.priceguard.data.dto.renew.RenewResponse +import app.priceguard.data.dto.verifyemail.VerifyEmailRequest +import app.priceguard.data.dto.verifyemail.VerifyEmailResponse import retrofit2.Response +import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.Header +import retrofit2.http.POST interface AuthAPI { @@ -11,4 +15,9 @@ interface AuthAPI { suspend fun renewTokens( @Header("Authorization") authToken: String ): Response + + @POST("verify/email") + suspend fun verifyEmail( + @Body verifyEmailRequest: VerifyEmailRequest + ): Response } 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 1f62101..55f3748 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 @@ -21,39 +21,42 @@ import retrofit2.http.Path interface ProductAPI { - @POST("verify") + @POST("v1/product/verify") suspend fun verifyLink( @Body productUrl: ProductVerifyRequest ): Response - @POST(".") + @POST("v1/product") suspend fun addProduct( @Body productAddRequest: ProductAddRequest ): Response - @GET("tracking") + @GET("product/tracking") suspend fun getProductList(): Response - @GET("recommend") + @GET("product/recommend") suspend fun getRecommendedProductList(): Response - @GET("{productCode}") + @GET("v1/product/{shop}/{productCode}") suspend fun getProductDetail( + @Path("shop") shop: String, @Path("productCode") productCode: String ): Response - @DELETE("{productCode}") + @DELETE("v1/product/{shop}/{productCode}") suspend fun deleteProduct( + @Path("shop") shop: String, @Path("productCode") productCode: String ): Response - @PATCH("targetPrice") + @PATCH("v1/product/targetPrice") suspend fun updateTargetPrice( @Body pricePatchRequest: PricePatchRequest ): Response - @PATCH("alert/{productCode}") + @PATCH("v1/product/alert/{shop}/{productCode}") suspend fun updateAlert( + @Path("shop") shop: String, @Path("productCode") productCode: String ): Response } diff --git a/android/app/src/main/java/app/priceguard/data/network/UserAPI.kt b/android/app/src/main/java/app/priceguard/data/network/UserAPI.kt index 270b0a4..a0fc8b7 100644 --- a/android/app/src/main/java/app/priceguard/data/network/UserAPI.kt +++ b/android/app/src/main/java/app/priceguard/data/network/UserAPI.kt @@ -1,32 +1,66 @@ package app.priceguard.data.network +import app.priceguard.data.dto.deleteaccount.DeleteAccountRequest +import app.priceguard.data.dto.deleteaccount.DeleteAccountResponse import app.priceguard.data.dto.firebase.FirebaseTokenUpdateRequest import app.priceguard.data.dto.firebase.FirebaseTokenUpdateResponse +import app.priceguard.data.dto.isverified.IsEmailVerifiedResponse import app.priceguard.data.dto.login.LoginRequest import app.priceguard.data.dto.login.LoginResponse +import app.priceguard.data.dto.password.ResetPasswordRequest +import app.priceguard.data.dto.password.ResetPasswordResponse import app.priceguard.data.dto.signup.SignupRequest import app.priceguard.data.dto.signup.SignupResponse +import app.priceguard.data.dto.verifyemail.RequestVerificationCodeRequest +import app.priceguard.data.dto.verifyemail.RequestVerificationCodeResponse import retrofit2.Response import retrofit2.http.Body +import retrofit2.http.GET import retrofit2.http.Header import retrofit2.http.POST import retrofit2.http.PUT interface UserAPI { - @POST("login") + @POST("user/login") suspend fun login( @Body request: LoginRequest ): Response - @POST("register") + @POST("v1/user/register") suspend fun register( @Body request: SignupRequest ): Response - @PUT("firebase/token") + @POST("user/remove") + suspend fun deleteAccount( + @Body request: DeleteAccountRequest + ): Response + + @PUT("user/firebase/token") suspend fun updateFirebaseToken( @Header("Authorization") authToken: String, @Body request: FirebaseTokenUpdateRequest ): Response + + @GET("user/email/is-verified") + suspend fun updateIsEmailVerified( + @Header("Authorization") accessToken: String + ): Response + + @POST("user/email/verification") + suspend fun requestVerificationCode( + @Body requestVerificationCodeRequest: RequestVerificationCodeRequest + ): Response + + @POST("user/email/register-verification") + suspend fun requestRegisterVerificationCode( + @Body requestVerificationCodeRequest: RequestVerificationCodeRequest + ): Response + + @POST("user/password") + suspend fun resetPassword( + @Header("Authorization") token: String, + @Body resetPasswordRequest: ResetPasswordRequest + ): Response } diff --git a/android/app/src/main/java/app/priceguard/data/repository/auth/AuthErrorState.kt b/android/app/src/main/java/app/priceguard/data/repository/auth/AuthErrorState.kt index 391b82c..27e0c7e 100644 --- a/android/app/src/main/java/app/priceguard/data/repository/auth/AuthErrorState.kt +++ b/android/app/src/main/java/app/priceguard/data/repository/auth/AuthErrorState.kt @@ -2,6 +2,10 @@ package app.priceguard.data.repository.auth enum class AuthErrorState { INVALID_REQUEST, + UNAUTHORIZED, DUPLICATED_EMAIL, + NOT_FOUND, + EXPIRE, + OVER_LIMIT, UNDEFINED_ERROR } diff --git a/android/app/src/main/java/app/priceguard/data/repository/auth/AuthRepository.kt b/android/app/src/main/java/app/priceguard/data/repository/auth/AuthRepository.kt index f75b37b..6ac89de 100644 --- a/android/app/src/main/java/app/priceguard/data/repository/auth/AuthRepository.kt +++ b/android/app/src/main/java/app/priceguard/data/repository/auth/AuthRepository.kt @@ -6,7 +6,15 @@ import app.priceguard.ui.data.SignupResult interface AuthRepository { - suspend fun signUp(email: String, userName: String, password: String): RepositoryResult + suspend fun signUp(email: String, userName: String, password: String, verificationCode: String): RepositoryResult suspend fun login(email: String, password: String): RepositoryResult + + suspend fun deleteAccount(email: String, password: String): RepositoryResult + + suspend fun requestVerificationCode(email: String): RepositoryResult + + suspend fun requestRegisterVerificationCode(email: String): RepositoryResult + + suspend fun resetPassword(password: String, verifyToken: String): RepositoryResult } diff --git a/android/app/src/main/java/app/priceguard/data/repository/auth/AuthRepositoryImpl.kt b/android/app/src/main/java/app/priceguard/data/repository/auth/AuthRepositoryImpl.kt index d007435..e721b2f 100644 --- a/android/app/src/main/java/app/priceguard/data/repository/auth/AuthRepositoryImpl.kt +++ b/android/app/src/main/java/app/priceguard/data/repository/auth/AuthRepositoryImpl.kt @@ -1,7 +1,10 @@ package app.priceguard.data.repository.auth +import app.priceguard.data.dto.deleteaccount.DeleteAccountRequest import app.priceguard.data.dto.login.LoginRequest +import app.priceguard.data.dto.password.ResetPasswordRequest import app.priceguard.data.dto.signup.SignupRequest +import app.priceguard.data.dto.verifyemail.RequestVerificationCodeRequest import app.priceguard.data.network.UserAPI import app.priceguard.data.repository.APIResult import app.priceguard.data.repository.RepositoryResult @@ -20,10 +23,26 @@ class AuthRepositoryImpl @Inject constructor(private val userAPI: UserAPI) : Aut RepositoryResult.Error(AuthErrorState.INVALID_REQUEST) } + 401 -> { + RepositoryResult.Error(AuthErrorState.UNAUTHORIZED) + } + + 404 -> { + RepositoryResult.Error(AuthErrorState.NOT_FOUND) + } + 409 -> { RepositoryResult.Error(AuthErrorState.DUPLICATED_EMAIL) } + 410 -> { + RepositoryResult.Error(AuthErrorState.EXPIRE) + } + + 429 -> { + RepositoryResult.Error(AuthErrorState.OVER_LIMIT) + } + else -> { RepositoryResult.Error(AuthErrorState.UNDEFINED_ERROR) } @@ -33,10 +52,11 @@ class AuthRepositoryImpl @Inject constructor(private val userAPI: UserAPI) : Aut override suspend fun signUp( email: String, userName: String, - password: String + password: String, + verificationCode: String ): RepositoryResult { val response = getApiResult { - userAPI.register(SignupRequest(email, userName, password)) + userAPI.register(SignupRequest(email, userName, verificationCode, password)) } return when (response) { is APIResult.Success -> { @@ -54,7 +74,10 @@ class AuthRepositoryImpl @Inject constructor(private val userAPI: UserAPI) : Aut } } - override suspend fun login(email: String, password: String): RepositoryResult { + override suspend fun login( + email: String, + password: String + ): RepositoryResult { val response = getApiResult { userAPI.login(LoginRequest(email, password)) } @@ -73,4 +96,80 @@ class AuthRepositoryImpl @Inject constructor(private val userAPI: UserAPI) : Aut } } } + + override suspend fun deleteAccount( + email: String, + password: String + ): RepositoryResult { + val response = getApiResult { + userAPI.deleteAccount(DeleteAccountRequest(email, password)) + } + + return when (response) { + is APIResult.Success -> { + RepositoryResult.Success(true) + } + + is APIResult.Error -> { + handleError(response.code) + } + } + } + + override suspend fun requestVerificationCode(email: String): RepositoryResult { + return when ( + val response = + getApiResult { userAPI.requestVerificationCode(RequestVerificationCodeRequest(email)) } + ) { + is APIResult.Success -> { + RepositoryResult.Success(true) + } + + is APIResult.Error -> { + handleError(response.code) + } + } + } + + override suspend fun requestRegisterVerificationCode(email: String): RepositoryResult { + return when ( + val response = + getApiResult { + userAPI.requestRegisterVerificationCode( + RequestVerificationCodeRequest(email) + ) + } + ) { + is APIResult.Success -> { + RepositoryResult.Success(true) + } + + is APIResult.Error -> { + handleError(response.code) + } + } + } + + override suspend fun resetPassword( + password: String, + verifyToken: String + ): RepositoryResult { + return when ( + val response = + getApiResult { + userAPI.resetPassword( + "Bearer $verifyToken", + ResetPasswordRequest(password) + ) + } + ) { + is APIResult.Success -> { + RepositoryResult.Success(true) + } + + is APIResult.Error -> { + handleError(response.code) + } + } + } } diff --git a/android/app/src/main/java/app/priceguard/data/repository/product/ProductErrorState.kt b/android/app/src/main/java/app/priceguard/data/repository/product/ProductErrorState.kt index 8dd2c74..623172c 100644 --- a/android/app/src/main/java/app/priceguard/data/repository/product/ProductErrorState.kt +++ b/android/app/src/main/java/app/priceguard/data/repository/product/ProductErrorState.kt @@ -5,5 +5,6 @@ enum class ProductErrorState { INVALID_REQUEST, NOT_FOUND, EXIST, + FULL_STORAGE, UNDEFINED_ERROR } diff --git a/android/app/src/main/java/app/priceguard/data/repository/product/ProductRepository.kt b/android/app/src/main/java/app/priceguard/data/repository/product/ProductRepository.kt index b0a933a..cdfc9c9 100644 --- a/android/app/src/main/java/app/priceguard/data/repository/product/ProductRepository.kt +++ b/android/app/src/main/java/app/priceguard/data/repository/product/ProductRepository.kt @@ -12,17 +12,17 @@ interface ProductRepository { suspend fun verifyLink(productUrl: String, isRenewed: Boolean = false): RepositoryResult - suspend fun addProduct(productCode: String, targetPrice: Int, isRenewed: Boolean = false): RepositoryResult + suspend fun addProduct(shop: String, productCode: String, targetPrice: Int, isRenewed: Boolean = false): RepositoryResult suspend fun getProductList(isRenewed: Boolean = false): RepositoryResult, ProductErrorState> suspend fun getRecommendedProductList(isRenewed: Boolean = false): RepositoryResult, ProductErrorState> - suspend fun getProductDetail(productCode: String, isRenewed: Boolean = false): RepositoryResult + suspend fun getProductDetail(shop: String, productCode: String, isRenewed: Boolean = false): RepositoryResult - suspend fun deleteProduct(productCode: String, isRenewed: Boolean = false): RepositoryResult + suspend fun deleteProduct(shop: String, productCode: String, isRenewed: Boolean = false): RepositoryResult - suspend fun updateTargetPrice(productCode: String, targetPrice: Int, isRenewed: Boolean = false): RepositoryResult + suspend fun updateTargetPrice(shop: String, productCode: String, targetPrice: Int, isRenewed: Boolean = false): RepositoryResult - suspend fun switchAlert(productCode: String, isRenewed: Boolean = false): RepositoryResult + suspend fun switchAlert(shop: String, productCode: String, isRenewed: Boolean = false): RepositoryResult } diff --git a/android/app/src/main/java/app/priceguard/data/repository/product/ProductRepositoryImpl.kt b/android/app/src/main/java/app/priceguard/data/repository/product/ProductRepositoryImpl.kt index 88e32e0..0566ff1 100644 --- a/android/app/src/main/java/app/priceguard/data/repository/product/ProductRepositoryImpl.kt +++ b/android/app/src/main/java/app/priceguard/data/repository/product/ProductRepositoryImpl.kt @@ -50,6 +50,10 @@ class ProductRepositoryImpl @Inject constructor( RepositoryResult.Error(ProductErrorState.PERMISSION_DENIED) } + 403 -> { + RepositoryResult.Error(ProductErrorState.FULL_STORAGE) + } + 404 -> { RepositoryResult.Error(ProductErrorState.NOT_FOUND) } @@ -105,12 +109,13 @@ class ProductRepositoryImpl @Inject constructor( } override suspend fun addProduct( + shop: String, productCode: String, targetPrice: Int, isRenewed: Boolean ): RepositoryResult { val response = getApiResult { - productAPI.addProduct(ProductAddRequest(productCode, targetPrice)) + productAPI.addProduct(ProductAddRequest(shop, productCode, targetPrice)) } return when (response) { is APIResult.Success -> { @@ -124,7 +129,7 @@ class ProductRepositoryImpl @Inject constructor( is APIResult.Error -> { handleError(response.code, isRenewed) { - addProduct(productCode, targetPrice, true) + addProduct(shop, productCode, targetPrice, true) } } } @@ -190,11 +195,12 @@ class ProductRepositoryImpl @Inject constructor( } override suspend fun getProductDetail( + shop: String, productCode: String, isRenewed: Boolean ): RepositoryResult { val response = getApiResult { - productAPI.getProductDetail(productCode) + productAPI.getProductDetail(shop, productCode) } return when (response) { is APIResult.Success -> { @@ -216,36 +222,38 @@ class ProductRepositoryImpl @Inject constructor( is APIResult.Error -> { handleError(response.code, isRenewed) { - getProductDetail(productCode, true) + getProductDetail(shop, productCode, true) } } } } override suspend fun deleteProduct( + shop: String, productCode: String, isRenewed: Boolean ): RepositoryResult { - return when (val response = getApiResult { productAPI.deleteProduct(productCode) }) { + return when (val response = getApiResult { productAPI.deleteProduct(shop, productCode) }) { is APIResult.Success -> { RepositoryResult.Success(true) } is APIResult.Error -> { handleError(response.code, isRenewed) { - deleteProduct(productCode, true) + deleteProduct(shop, productCode, true) } } } } override suspend fun updateTargetPrice( + shop: String, productCode: String, targetPrice: Int, isRenewed: Boolean ): RepositoryResult { val response = getApiResult { - productAPI.updateTargetPrice(PricePatchRequest(productCode, targetPrice)) + productAPI.updateTargetPrice(PricePatchRequest(shop, productCode, targetPrice)) } return when (response) { is APIResult.Success -> { @@ -259,21 +267,21 @@ class ProductRepositoryImpl @Inject constructor( is APIResult.Error -> { handleError(response.code, isRenewed) { - updateTargetPrice(productCode, targetPrice, true) + updateTargetPrice(shop, productCode, targetPrice, true) } } } } - override suspend fun switchAlert(productCode: String, isRenewed: Boolean): RepositoryResult { - return when (val response = getApiResult { productAPI.updateAlert(productCode) }) { + override suspend fun switchAlert(shop: String, productCode: String, isRenewed: Boolean): RepositoryResult { + return when (val response = getApiResult { productAPI.updateAlert(shop, productCode) }) { is APIResult.Success -> { RepositoryResult.Success(true) } is APIResult.Error -> { handleError(response.code, isRenewed) { - deleteProduct(productCode, true) + deleteProduct(shop, productCode, true) } } } diff --git a/android/app/src/main/java/app/priceguard/data/repository/token/TokenErrorState.kt b/android/app/src/main/java/app/priceguard/data/repository/token/TokenErrorState.kt index 36506c4..e76803f 100644 --- a/android/app/src/main/java/app/priceguard/data/repository/token/TokenErrorState.kt +++ b/android/app/src/main/java/app/priceguard/data/repository/token/TokenErrorState.kt @@ -1,7 +1,9 @@ package app.priceguard.data.repository.token enum class TokenErrorState { + INVALID_REQUEST, UNAUTHORIZED, EXPIRED, + NOT_FOUND, UNDEFINED_ERROR } diff --git a/android/app/src/main/java/app/priceguard/data/repository/token/TokenRepository.kt b/android/app/src/main/java/app/priceguard/data/repository/token/TokenRepository.kt index dff5a21..eaa2c9a 100644 --- a/android/app/src/main/java/app/priceguard/data/repository/token/TokenRepository.kt +++ b/android/app/src/main/java/app/priceguard/data/repository/token/TokenRepository.kt @@ -2,6 +2,7 @@ package app.priceguard.data.repository.token import app.priceguard.data.repository.RepositoryResult import app.priceguard.ui.data.UserDataResult +import app.priceguard.ui.data.VerifyEmailResult interface TokenRepository { suspend fun storeTokens(accessToken: String, refreshToken: String) @@ -12,4 +13,8 @@ interface TokenRepository { suspend fun getUserData(): UserDataResult suspend fun renewTokens(refreshToken: String): RepositoryResult suspend fun clearTokens() + suspend fun updateIsEmailVerified() + suspend fun storeEmailVerified(isVerified: Boolean) + suspend fun getIsEmailVerified(): Boolean? + suspend fun verifyEmail(email: String, verificationCode: String): RepositoryResult } diff --git a/android/app/src/main/java/app/priceguard/data/repository/token/TokenRepositoryImpl.kt b/android/app/src/main/java/app/priceguard/data/repository/token/TokenRepositoryImpl.kt index 245322d..793b65d 100644 --- a/android/app/src/main/java/app/priceguard/data/repository/token/TokenRepositoryImpl.kt +++ b/android/app/src/main/java/app/priceguard/data/repository/token/TokenRepositoryImpl.kt @@ -3,12 +3,14 @@ package app.priceguard.data.repository.token import android.util.Log import app.priceguard.data.datastore.TokenDataSource import app.priceguard.data.dto.firebase.FirebaseTokenUpdateRequest +import app.priceguard.data.dto.verifyemail.VerifyEmailRequest import app.priceguard.data.network.AuthAPI import app.priceguard.data.network.UserAPI import app.priceguard.data.repository.APIResult import app.priceguard.data.repository.RepositoryResult import app.priceguard.data.repository.getApiResult import app.priceguard.ui.data.UserDataResult +import app.priceguard.ui.data.VerifyEmailResult import com.google.firebase.Firebase import com.google.firebase.messaging.FirebaseMessaging import com.google.firebase.messaging.messaging @@ -26,10 +28,18 @@ class TokenRepositoryImpl @Inject constructor( code: Int? ): RepositoryResult { return when (code) { + 400 -> { + RepositoryResult.Error(TokenErrorState.INVALID_REQUEST) + } + 401 -> { RepositoryResult.Error(TokenErrorState.UNAUTHORIZED) } + 404 -> { + RepositoryResult.Error(TokenErrorState.NOT_FOUND) + } + 410 -> { RepositoryResult.Error(TokenErrorState.EXPIRED) } @@ -108,4 +118,56 @@ class TokenRepositoryImpl @Inject constructor( Firebase.messaging.deleteToken() tokenDataSource.clearTokens() } + + override suspend fun updateIsEmailVerified() { + when ( + val response = + getApiResult { userAPI.updateIsEmailVerified("Bearer ${getAccessToken()}") } + ) { + is APIResult.Success -> { + storeEmailVerified(response.data.verified ?: false) + RepositoryResult.Success(response.data.verified ?: false) + } + + is APIResult.Error -> { + handleError(response.code) + } + } + } + + override suspend fun verifyEmail( + email: String, + verificationCode: String + ): RepositoryResult { + val response = getApiResult { + authAPI.verifyEmail(VerifyEmailRequest(email, verificationCode)) + } + + return when (response) { + is APIResult.Success -> { + RepositoryResult.Success( + VerifyEmailResult( + response.data.verifyToken ?: "" + ) + ) + } + + is APIResult.Error -> { + handleError(response.code) + } + } + } + + override suspend fun storeEmailVerified(isVerified: Boolean) { + tokenDataSource.saveEmailVerified(isVerified) + } + + override suspend fun getIsEmailVerified(): Boolean? { + var isEmailVerified = tokenDataSource.getIsEmailVerified() + if (isEmailVerified == null) { + updateIsEmailVerified() + isEmailVerified = tokenDataSource.getIsEmailVerified() + } + return isEmailVerified + } } diff --git a/android/app/src/main/java/app/priceguard/di/NetworkModule.kt b/android/app/src/main/java/app/priceguard/di/NetworkModule.kt index cbfd365..7c4fa74 100644 --- a/android/app/src/main/java/app/priceguard/di/NetworkModule.kt +++ b/android/app/src/main/java/app/priceguard/di/NetworkModule.kt @@ -25,7 +25,7 @@ object NetworkModule { @Provides @Singleton fun provideUserAPI(): UserAPI = Retrofit.Builder() - .baseUrl("${BASE_URL}/user/") + .baseUrl("${BASE_URL}/") .addConverterFactory(json.asConverterFactory(MediaType.parse("application/json")!!)) .build() .create(UserAPI::class.java) @@ -46,7 +46,7 @@ object NetworkModule { .build() return Retrofit.Builder() - .baseUrl("${BASE_URL}/product/") + .baseUrl("${BASE_URL}/") .addConverterFactory(json.asConverterFactory(MediaType.parse("application/json")!!)) .client(interceptorClient) .build() diff --git a/android/app/src/main/java/app/priceguard/service/PriceGuardFirebaseMessagingService.kt b/android/app/src/main/java/app/priceguard/service/PriceGuardFirebaseMessagingService.kt index 79e9fd7..ec0c893 100644 --- a/android/app/src/main/java/app/priceguard/service/PriceGuardFirebaseMessagingService.kt +++ b/android/app/src/main/java/app/priceguard/service/PriceGuardFirebaseMessagingService.kt @@ -33,15 +33,17 @@ class PriceGuardFirebaseMessagingService : FirebaseMessagingService() { it.title ?: return, it.body ?: return, it.imageUrl ?: return, + message.data["shop"] ?: return, message.data["productCode"] ?: return ) } } - private fun sendNotification(title: String, body: String, imageUrl: Uri, data: String) { + private fun sendNotification(title: String, body: String, imageUrl: Uri, shop: String, code: String) { val intent = Intent(this, DetailActivity::class.java) intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - intent.putExtra("productCode", data) + intent.putExtra("productShop", shop) + intent.putExtra("productCode", code) intent.putExtra("directed", true) val requestCode = getRequestCode() diff --git a/android/app/src/main/java/app/priceguard/service/UpdateAlarmWorker.kt b/android/app/src/main/java/app/priceguard/service/UpdateAlarmWorker.kt index f463b71..a2ac01a 100644 --- a/android/app/src/main/java/app/priceguard/service/UpdateAlarmWorker.kt +++ b/android/app/src/main/java/app/priceguard/service/UpdateAlarmWorker.kt @@ -22,14 +22,15 @@ class UpdateAlarmWorker @AssistedInject constructor( ) : CoroutineWorker(appContext, workerParams) { override suspend fun doWork(): Result { - val inputData = inputData.getString(ARGUMENT_KEY) ?: return Result.failure() + val productShop = inputData.getString(PRODUCT_SHOP) ?: return Result.failure() + val productCode = inputData.getString(PRODUCT_CODE) ?: return Result.failure() - return updateAlarm(inputData) + return updateAlarm(productShop, productCode) } - private suspend fun updateAlarm(productCode: String): Result { + private suspend fun updateAlarm(productShop: String, productCode: String): Result { return try { - when (productRepository.switchAlert(productCode)) { + when (productRepository.switchAlert(productShop, productCode)) { is RepositoryResult.Error -> { Result.failure() } @@ -45,9 +46,14 @@ class UpdateAlarmWorker @AssistedInject constructor( } companion object { - const val ARGUMENT_KEY = "productCode" - fun createWorkRequest(inputString: String): OneTimeWorkRequest { - val inputData = Data.Builder().putString(ARGUMENT_KEY, inputString).build() + const val PRODUCT_CODE = "productCode" + const val PRODUCT_SHOP = "productShop" + + fun createWorkRequest(inputString: Pair): OneTimeWorkRequest { + val inputData = Data.Builder() + .putString(PRODUCT_SHOP, inputString.first) + .putString(PRODUCT_CODE, inputString.second) + .build() val constraints = Constraints.Builder().build() return OneTimeWorkRequestBuilder() .setInputData(inputData) 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 f33794b..c43a20e 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 @@ -34,13 +34,15 @@ class AddItemActivity : AppCompatActivity() { bundle.putString("link", data) navController.navigate(R.id.registerItemLinkFragment, bundle) } - } else if (intent.hasExtra("productCode") && + } else if (intent.hasExtra("productShop") && + intent.hasExtra("productCode") && intent.hasExtra("productTitle") && intent.hasExtra("productPrice") && intent.hasExtra("isAdding") ) { val action = RegisterItemLinkFragmentDirections.actionRegisterItemLinkFragmentToSetTargetPriceFragment( + intent.getStringExtra("productShop") ?: "", intent.getStringExtra("productCode") ?: "", intent.getStringExtra("productTitle") ?: "", intent.getIntExtra("productPrice", 0), 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 8f285a4..7da805c 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 @@ -54,6 +54,7 @@ class ConfirmItemLinkFragment : Fragment() { btnConfirmItemNext.setOnClickListener { val action = ConfirmItemLinkFragmentDirections.actionConfirmItemLinkFragmentToSetTargetPriceFragment( + arguments.getString("productShop") ?: "", arguments.getString("productCode") ?: "", arguments.getString("productName") ?: "", arguments.getInt("productPrice"), 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 25b033a..cf669a5 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 @@ -98,6 +98,7 @@ class RegisterItemLinkFragment : Fragment() { is RegisterItemLinkViewModel.RegisterLinkEvent.SuccessVerification -> { val action = RegisterItemLinkFragmentDirections.actionRegisterItemLinkFragmentToConfirmItemLinkFragment( + event.product.shop, event.product.productCode, event.product.productPrice, event.product.shop, diff --git a/android/app/src/main/java/app/priceguard/ui/additem/setprice/SetTargetPriceDialogFragment.kt b/android/app/src/main/java/app/priceguard/ui/additem/setprice/SetTargetPriceDialogFragment.kt new file mode 100644 index 0000000..51bb991 --- /dev/null +++ b/android/app/src/main/java/app/priceguard/ui/additem/setprice/SetTargetPriceDialogFragment.kt @@ -0,0 +1,123 @@ +package app.priceguard.ui.additem.setprice + +import android.app.Dialog +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.widget.addTextChangedListener +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.viewModels +import app.priceguard.R +import app.priceguard.databinding.FragmentTargetPriceDialogBinding +import app.priceguard.ui.util.lifecycle.repeatOnStarted +import app.priceguard.ui.util.setTextColorWithEnabled +import com.google.android.material.dialog.MaterialAlertDialogBuilder + +class SetTargetPriceDialogFragment : DialogFragment() { + + private var _binding: FragmentTargetPriceDialogBinding? = null + private val binding get() = _binding!! + + private val viewModel: SetTargetPriceDialogViewModel by viewModels() + + private var resultListener: OnDialogResultListener? = null + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + _binding = FragmentTargetPriceDialogBinding.inflate(requireActivity().layoutInflater) + val view = binding.root + + val title = arguments?.getString("title") ?: "" + + val dialogBuilder = MaterialAlertDialogBuilder( + requireActivity(), + R.style.ThemeOverlay_App_MaterialAlertDialog + ).apply { + setTitle(title) + setView(view) + setNegativeButton(R.string.cancel) { _, _ -> dismiss() } + setPositiveButton(R.string.confirm) { _, _ -> + resultListener?.onDialogResult(viewModel.state.value.targetPrice.toInt()) + dismiss() + } + } + val dialog = dialogBuilder.create() + + repeatOnStarted { + viewModel.state.collect { state -> + viewModel.updateTextChangedEnabled(false) + binding.etTargetPriceDialog.setText( + getString(R.string.won, getString(R.string.comma_number, state.targetPrice)) + ) + val positiveButton = dialog.getButton(Dialog.BUTTON_POSITIVE) + positiveButton.isEnabled = !state.isErrorMessageVisible + positiveButton.setTextColorWithEnabled() + + viewModel.updateTextChangedEnabled(true) + } + } + + return dialog + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + super.onCreateView(inflater, container, savedInstanceState) + + binding.viewModel = viewModel + binding.lifecycleOwner = viewLifecycleOwner + + initListener() + + val price = arguments?.getInt("price") ?: 0 + viewModel.updateTargetPrice(price.toLong()) + + return binding.root + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + private fun initListener() { + binding.etTargetPriceDialog.addTextChangedListener { + binding.etTargetPriceDialog.setSelection(it.toString().length - 1) + + val price = extractAndConvertToInteger(it.toString()) + + if (viewModel.state.value.isTextChanged) { + if (price > MAX_TARGET_PRICE) { + viewModel.updateErrorMessageVisible(true) + } else { + viewModel.updateErrorMessageVisible(false) + } + viewModel.updateTargetPrice(price) + } + } + + binding.etTargetPriceDialog.setOnClickListener { + binding.etTargetPriceDialog.setSelection(binding.etTargetPriceDialog.text.toString().length - 1) + } + } + + private fun extractAndConvertToInteger(text: String): Long { + val digits = text.filter { it.isDigit() } + return (digits.toLongOrNull() ?: 0).coerceIn(0, MAX_TARGET_PRICE * 10 - 1) + } + + fun setOnDialogResultListener(listener: OnDialogResultListener) { + resultListener = listener + } + + interface OnDialogResultListener { + fun onDialogResult(result: Int) + } + + companion object { + const val MAX_TARGET_PRICE = 1_000_000_000L + } +} diff --git a/android/app/src/main/java/app/priceguard/ui/additem/setprice/SetTargetPriceDialogViewModel.kt b/android/app/src/main/java/app/priceguard/ui/additem/setprice/SetTargetPriceDialogViewModel.kt new file mode 100644 index 0000000..a7285e1 --- /dev/null +++ b/android/app/src/main/java/app/priceguard/ui/additem/setprice/SetTargetPriceDialogViewModel.kt @@ -0,0 +1,29 @@ +package app.priceguard.ui.additem.setprice + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +class SetTargetPriceDialogViewModel : ViewModel() { + + data class SetTargetPriceDialogState( + val targetPrice: Long = 0, + val isTextChanged: Boolean = false, + val isErrorMessageVisible: Boolean = false + ) + + private val _state = MutableStateFlow(SetTargetPriceDialogState()) + val state = _state.asStateFlow() + + fun updateTargetPrice(price: Long) { + _state.value = _state.value.copy(targetPrice = price) + } + + fun updateTextChangedEnabled(isEnabled: Boolean) { + _state.value = _state.value.copy(isTextChanged = isEnabled) + } + + fun updateErrorMessageVisible(isEnabled: Boolean) { + _state.value = _state.value.copy(isErrorMessageVisible = isEnabled) + } +} 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 6e76c08..96af838 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,12 +1,10 @@ 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 @@ -16,17 +14,16 @@ import app.priceguard.data.repository.token.TokenRepository import app.priceguard.databinding.FragmentSetTargetPriceBinding import app.priceguard.ui.additem.setprice.SetTargetPriceViewModel.SetTargetPriceEvent import app.priceguard.ui.data.DialogConfirmAction +import app.priceguard.ui.slider.RoundSliderState import app.priceguard.ui.util.lifecycle.repeatOnStarted import app.priceguard.ui.util.showDialogWithAction import app.priceguard.ui.util.showDialogWithLogout -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() { +class SetTargetPriceFragment : Fragment(), SetTargetPriceDialogFragment.OnDialogResultListener { @Inject lateinit var tokenRepository: TokenRepository @@ -53,6 +50,7 @@ class SetTargetPriceFragment : Fragment() { setBackPressedCallback() binding.initView() binding.initListener() + initCollector() handleEvent() } @@ -69,12 +67,13 @@ class SetTargetPriceFragment : Fragment() { private fun FragmentSetTargetPriceBinding.initView() { val arguments = requireArguments() + val productShop = arguments.getString("productShop") ?: "" val productCode = arguments.getString("productCode") ?: "" val title = arguments.getString("productTitle") ?: "" val price = arguments.getInt("productPrice") - var targetPrice = arguments.getInt("productTargetPrice") + val targetPrice = arguments.getInt("productTargetPrice") - setTargetPriceViewModel.updateTargetPrice(targetPrice) + setTargetPriceViewModel.setProductInfo(productShop, productCode, title, price, targetPrice) tvSetPriceCurrentPrice.text = String.format( @@ -84,10 +83,22 @@ class SetTargetPriceFragment : Fragment() { tvSetPriceCurrentPrice.contentDescription = getString(R.string.current_price_info, tvSetPriceCurrentPrice.text) - setTargetPriceViewModel.setProductInfo(productCode, title, price) - etTargetPrice.setText(targetPrice.toString()) + rsTargetPrice.setMaxPercentValue(MAX_PERCENT) + rsTargetPrice.setStepSize(STEP_SIZE) - updateSlideValueWithPrice(targetPrice.toFloat()) + btnTargetPriceDecrease.setOnClickListener { + val sliderValue = (rsTargetPrice.sliderValue - STEP_SIZE).coerceIn(0, MAX_PERCENT) + rsTargetPrice.setValue(sliderValue) + setTargetPriceViewModel.updateTargetPriceFromPercent(sliderValue) + } + + btnTargetPriceIncrease.setOnClickListener { + val sliderValue = (rsTargetPrice.sliderValue + STEP_SIZE).coerceIn(0, MAX_PERCENT) + rsTargetPrice.setValue(sliderValue) + setTargetPriceViewModel.updateTargetPriceFromPercent(sliderValue) + } + + calculatePercentAndSetSliderValue(price, targetPrice) } private fun FragmentSetTargetPriceBinding.initListener() { @@ -98,58 +109,39 @@ class SetTargetPriceFragment : Fragment() { findNavController().navigateUp() } } + btnConfirmItemNext.setOnClickListener { val isAdding = requireArguments().getBoolean("isAdding") if (isAdding) setTargetPriceViewModel.addProduct() else setTargetPriceViewModel.patchProduct() } - slTargetPrice.addOnChangeListener { _, value, _ -> - if (!etTargetPrice.isFocused) { - setTargetPriceAndPercent(value) + + rsTargetPrice.setSliderValueChangeListener { value -> + if (setTargetPriceViewModel.state.value.isEnabledSliderListener) { + setTargetPriceViewModel.updateTargetPriceFromPercent(value) } } - slTargetPrice.addOnSliderTouchListener(object : OnSliderTouchListener { - override fun onStartTrackingTouch(slider: Slider) { - etTargetPrice.clearFocus() - setTargetPriceAndPercent(slider.value) - } - override fun onStopTrackingTouch(slider: Slider) { - } - }) - etTargetPrice.addTextChangedListener { - updateTargetPriceUI(it) + tvTargetPriceContent.setOnClickListener { + showConfirmationDialogForResult() } } - private fun updateTargetPriceUI(it: Editable?) { - if (binding.etTargetPrice.isFocused) { - val targetPrice = if (it.toString().matches("^\\d{1,9}$".toRegex())) { - it.toString().toInt() - } else if (it.toString().isEmpty()) { - binding.etTargetPrice.setText(getString(R.string.min_price)) - 0 - } else { - binding.etTargetPrice.setText(getString(R.string.max_price)) - 999999999 + private fun initCollector() { + repeatOnStarted { + setTargetPriceViewModel.state.collect { state -> + if (state.targetPrice > state.productPrice) { + binding.rsTargetPrice.setSliderMode(RoundSliderState.ERROR) + } else { + binding.rsTargetPrice.setSliderMode(RoundSliderState.ACTIVE) + } } - - setTargetPriceViewModel.updateTargetPrice(targetPrice) - binding.updateSlideValueWithPrice(targetPrice.toFloat()) } } - private fun Int.roundAtFirstDigit(): Int { - return ((this + 5) / 10) * 10 - } - - private fun FragmentSetTargetPriceBinding.setTargetPriceAndPercent(value: Float) { - val targetPrice = ((setTargetPriceViewModel.state.value.productPrice) * value.toInt() / 100) - tvTargetPricePercent.text = - String.format(getString(R.string.current_price_percent), value.toInt()) - etTargetPrice.setText( - targetPrice.toString() - ) - setTargetPriceViewModel.updateTargetPrice(targetPrice) + private fun calculatePercentAndSetSliderValue(productPrice: Int, targetPrice: Int) { + setTargetPriceViewModel.setSliderChangeListenerEnabled(false) + binding.rsTargetPrice.setValue((targetPrice.toFloat() / productPrice.toFloat() * 100F).toInt()) + setTargetPriceViewModel.setSliderChangeListenerEnabled(true) } private fun handleEvent() { @@ -186,6 +178,14 @@ class SetTargetPriceFragment : Fragment() { showDialogWithLogout() } + ProductErrorState.FULL_STORAGE -> { + showDialogWithAction( + getString(R.string.error_add_product), + getString(R.string.error_maximum_count_exceeded), + DialogConfirmAction.HOME + ) + } + else -> { showDialogWithAction( getString(R.string.error), @@ -214,22 +214,17 @@ class SetTargetPriceFragment : Fragment() { } } - private fun FragmentSetTargetPriceBinding.updateSlideValueWithPrice(targetPrice: Float) { - val percent = - ((targetPrice / setTargetPriceViewModel.state.value.productPrice) * MAX_PERCENT).toInt() - val pricePercent = percent.coerceIn(MIN_PERCENT, MAX_PERCENT).roundAtFirstDigit() - if (targetPrice > setTargetPriceViewModel.state.value.productPrice) { - tvTargetPricePercent.text = getString(R.string.over_current_price) - } else { - tvTargetPricePercent.text = - String.format(getString(R.string.current_price_percent), percent) - } - binding.tvTargetPricePercent.contentDescription = getString( - R.string.target_price_percent_and_price, - binding.tvTargetPricePercent.text, - binding.tvSetPriceCurrentPrice.text - ) - slTargetPrice.value = pricePercent.toFloat() + private fun showConfirmationDialogForResult() { + val tag = "set_target_price_dialog_fragment_from_fragment" + if (requireActivity().supportFragmentManager.findFragmentByTag(tag) != null) return + + val dialogFragment = SetTargetPriceDialogFragment() + val bundle = Bundle() + bundle.putString("title", getString(R.string.set_target_price_dialog_title)) + bundle.putInt("price", setTargetPriceViewModel.state.value.targetPrice) + dialogFragment.setOnDialogResultListener(this) + dialogFragment.arguments = bundle + dialogFragment.show(requireActivity().supportFragmentManager, tag) } override fun onDestroyView() { @@ -237,8 +232,13 @@ class SetTargetPriceFragment : Fragment() { _binding = null } + override fun onDialogResult(result: Int) { + setTargetPriceViewModel.updateTargetPrice(result) + calculatePercentAndSetSliderValue(setTargetPriceViewModel.state.value.productPrice, result) + } + companion object { - const val MIN_PERCENT = 0 - const val MAX_PERCENT = 100 + const val STEP_SIZE = 10 + const val MAX_PERCENT = 200 } } 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 c40b3d0..256656a 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 @@ -18,10 +18,12 @@ class SetTargetPriceViewModel @Inject constructor(private val productRepository: ViewModel() { data class SetTargetPriceState( + val productShop: String = "", val productCode: String = "", val targetPrice: Int = 0, val productName: String = "", val productPrice: Int = 0, + val isEnabledSliderListener: Boolean = true, val isReady: Boolean = true ) @@ -42,6 +44,7 @@ class SetTargetPriceViewModel @Inject constructor(private val productRepository: viewModelScope.launch { _state.value = state.value.copy(isReady = false) val response = productRepository.addProduct( + _state.value.productShop, _state.value.productCode, _state.value.targetPrice ) @@ -60,7 +63,9 @@ class SetTargetPriceViewModel @Inject constructor(private val productRepository: fun patchProduct() { viewModelScope.launch { + _state.value = state.value.copy(isReady = false) val response = productRepository.updateTargetPrice( + _state.value.productShop, _state.value.productCode, _state.value.targetPrice ) @@ -73,6 +78,7 @@ class SetTargetPriceViewModel @Inject constructor(private val productRepository: _event.emit(SetTargetPriceEvent.FailurePriceUpdate(response.errorState)) } } + _state.value = state.value.copy(isReady = true) } } @@ -80,12 +86,24 @@ class SetTargetPriceViewModel @Inject constructor(private val productRepository: _state.value = state.value.copy(targetPrice = price) } - fun setProductInfo(productCode: String, name: String, price: Int) { + fun updateTargetPriceFromPercent(percent: Int) { + _state.value = state.value.copy( + targetPrice = (_state.value.productPrice.toFloat() / 100F * percent.toFloat()).toInt() + ) + } + + fun setProductInfo(productShop: String, productCode: String, name: String, price: Int, targetPrice: Int) { _state.value = state.value.copy( + productShop = productShop, productCode = productCode, productName = name, - productPrice = price + productPrice = price, + targetPrice = targetPrice ) } + + fun setSliderChangeListenerEnabled(isEnabled: Boolean) { + _state.value = state.value.copy(isEnabledSliderListener = isEnabled) + } } diff --git a/android/app/src/main/java/app/priceguard/ui/data/VerifyEmailResult.kt b/android/app/src/main/java/app/priceguard/ui/data/VerifyEmailResult.kt new file mode 100644 index 0000000..2d5a7eb --- /dev/null +++ b/android/app/src/main/java/app/priceguard/ui/data/VerifyEmailResult.kt @@ -0,0 +1,5 @@ +package app.priceguard.ui.data + +data class VerifyEmailResult( + val verifyToken: String +) 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 7fe0a3e..dfeef14 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 @@ -1,5 +1,6 @@ package app.priceguard.ui.detail +import android.content.ActivityNotFoundException import android.content.Intent import android.net.Uri import android.os.Bundle @@ -8,6 +9,7 @@ import androidx.activity.addCallback import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.content.res.AppCompatResources.getDrawable import app.priceguard.R import app.priceguard.data.graph.ProductChartDataset import app.priceguard.data.graph.ProductChartGridLine @@ -71,6 +73,7 @@ class DetailActivity : AppCompatActivity(), ConfirmDialogFragment.OnDialogResult private fun initListener() { binding.btnDetailTrack.setOnClickListener { val intent = Intent(this, AddItemActivity::class.java) + intent.putExtra("productShop", productDetailViewModel.state.value.shop) intent.putExtra("productCode", productDetailViewModel.productCode) intent.putExtra("productTitle", productDetailViewModel.state.value.productName) intent.putExtra("productPrice", productDetailViewModel.state.value.price) @@ -80,6 +83,7 @@ class DetailActivity : AppCompatActivity(), ConfirmDialogFragment.OnDialogResult binding.btnDetailEditPrice.setOnClickListener { val intent = Intent(this, AddItemActivity::class.java) + intent.putExtra("productShop", productDetailViewModel.state.value.shop) intent.putExtra("productCode", productDetailViewModel.productCode) intent.putExtra("productTitle", productDetailViewModel.state.value.productName) intent.putExtra("productPrice", productDetailViewModel.state.value.price) @@ -112,8 +116,11 @@ class DetailActivity : AppCompatActivity(), ConfirmDialogFragment.OnDialogResult binding.btnDetailShare.setOnClickListener { binding.btnDetailShare.isEnabled = false - val shareLink = - getString(R.string.share_link_template, productDetailViewModel.productCode) + val shareLink = if (productDetailViewModel.productShop == "11번가") { + getString(R.string.share_link_template, "11st", productDetailViewModel.productCode) + } else { + getString(R.string.share_link_template, "naver", productDetailViewModel.productCode) + } val sendIntent: Intent = Intent().apply { action = Intent.ACTION_SEND @@ -135,35 +142,39 @@ class DetailActivity : AppCompatActivity(), ConfirmDialogFragment.OnDialogResult } private fun checkProductCode(intent: Intent) { + val productShop = intent.getStringExtra("productShop") val productCode = intent.getStringExtra("productCode") val deepLink = intent.data + val productShopFromDeepLink = deepLink?.getQueryParameter("store") val productCodeFromDeepLink = deepLink?.getQueryParameter("code") - if (productCode == null && productCodeFromDeepLink == null) { - showConfirmDialog( - getString(R.string.error), - getString(R.string.invalid_access), - DialogConfirmAction.FINISH - ) + if (productShop != null && productCode != null) { + productDetailViewModel.productShop = productShop + productDetailViewModel.productCode = productCode + productDetailViewModel.getDetails(false) return } - productCode?.let { code -> - productDetailViewModel.productCode = code + if (productShopFromDeepLink != null && productCodeFromDeepLink != null) { + productDetailViewModel.productShop = productShopFromDeepLink + productDetailViewModel.productCode = productCodeFromDeepLink productDetailViewModel.getDetails(false) return } - productCodeFromDeepLink?.let { code -> - productDetailViewModel.productCode = code - productDetailViewModel.getDetails(false) - } + // 유효하지 않은 경우 + showConfirmDialog( + getString(R.string.error), + getString(R.string.invalid_access), + DialogConfirmAction.FINISH + ) } private fun observeEvent() { repeatOnStarted { productDetailViewModel.state.collect { state -> state.targetPrice ?: return@collect + state.shop ?: return@collect binding.chGraphDetail.dataset = ProductChartDataset( showXAxis = true, showYAxis = true, @@ -174,6 +185,7 @@ class DetailActivity : AppCompatActivity(), ConfirmDialogFragment.OnDialogResult data = state.chartData, gridLines = getGridLines(state.targetPrice.toFloat()) ) + binding.setShopLogoIcon(state.shop) } } repeatOnStarted { @@ -200,10 +212,7 @@ class DetailActivity : AppCompatActivity(), ConfirmDialogFragment.OnDialogResult } is ProductDetailViewModel.ProductDetailEvent.OpenShoppingMall -> { - val redirectUrl = - "https://11stapp.11st.co.kr/?domain=m.11st.co.kr&appLnkWyCd=02&goUrl=${event.url}" - val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(redirectUrl)) - startActivity(browserIntent) + launchShopApplication(event.url, event.shop) } ProductDetailViewModel.ProductDetailEvent.DeleteTracking -> { @@ -248,6 +257,43 @@ class DetailActivity : AppCompatActivity(), ConfirmDialogFragment.OnDialogResult } } + private fun ActivityDetailBinding.setShopLogoIcon(shop: String) { + val iconDrawable = when (shop) { + "11번가" -> { + getDrawable(this@DetailActivity, R.drawable.ic_11st_logo) + } + + "SmartStore", "BrandStore" -> { + getDrawable(this@DetailActivity, R.drawable.ic_naver_logo) + } + + else -> return + } + ivDetailShoppingMallIcon.setImageDrawable(iconDrawable) + } + + private fun launchShopApplication(url: String, shop: String) { + val redirectUrl: String = when (shop) { + "11번가" -> { + "elevenst://loadurl?domain=m.11st.co.kr&url=$url&appLnkWyCd=02&domain=m.11st.co.kr&trTypeCd=null" + } + + "SmartStore", "BrandStore" -> { + "naversearchapp://inappbrowser?url=$url&target=new&version=6" + } + + else -> return + } + try { + val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(redirectUrl)) + startActivity(browserIntent) + } catch (e: ActivityNotFoundException) { + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) + } catch (e: Exception) { + showToast(getString(R.string.failed_to_open_shop)) + } + } + private fun getGridLines(targetPrice: Float): List { return if (targetPrice < 0) { listOf() 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 a51de14..ec4e745 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 @@ -41,12 +41,13 @@ class ProductDetailViewModel @Inject constructor( val formattedPrice: String = "", val formattedTargetPrice: String = "", val formattedLowestPrice: String = "", + val isSoldOut: Boolean = false, val graphMode: GraphMode = GraphMode.DAY, val chartData: List = listOf() ) sealed class ProductDetailEvent { - data class OpenShoppingMall(val url: String) : ProductDetailEvent() + data class OpenShoppingMall(val url: String, val shop: String) : ProductDetailEvent() data object DeleteTracking : ProductDetailEvent() data object Logout : ProductDetailEvent() data object NotFound : ProductDetailEvent() @@ -55,6 +56,7 @@ class ProductDetailViewModel @Inject constructor( data class DeleteFailed(val errorType: ProductErrorState) : ProductDetailEvent() } + lateinit var productShop: String lateinit var productCode: String private var productGraphData: List = listOf() @@ -67,7 +69,7 @@ class ProductDetailViewModel @Inject constructor( fun deleteProductTracking() { viewModelScope.launch { - when (val result = productRepository.deleteProduct(productCode)) { + when (val result = productRepository.deleteProduct(productShop, productCode)) { is RepositoryResult.Success -> { _event.emit(ProductDetailEvent.DeleteSuccess) } @@ -81,7 +83,7 @@ class ProductDetailViewModel @Inject constructor( fun getDetails(isRefresh: Boolean) { viewModelScope.launch { - if (::productCode.isInitialized.not()) { + if (::productCode.isInitialized.not() && ::productShop.isInitialized.not()) { return@launch } @@ -89,7 +91,7 @@ class ProductDetailViewModel @Inject constructor( _state.value = _state.value.copy(isRefreshing = true) } - val result = productRepository.getProductDetail(productCode) + val result = productRepository.getProductDetail(productShop, productCode) _state.value = _state.value.copy(isRefreshing = false) @@ -108,6 +110,7 @@ class ProductDetailViewModel @Inject constructor( targetPrice = result.data.targetPrice, lowestPrice = result.data.lowestPrice, price = result.data.price, + isSoldOut = result.data.priceData.last().valid.not(), formattedPrice = formatPrice(result.data.price), formattedTargetPrice = if (result.data.targetPrice < 0) { "0" @@ -159,8 +162,10 @@ class ProductDetailViewModel @Inject constructor( fun sendBrowserEvent() { viewModelScope.launch { - val event = _state.value.shopUrl?.let { ProductDetailEvent.OpenShoppingMall(it) } - ?: return@launch + val event = _state.value.let { + if (it.shopUrl == null || it.shop == null) return@launch + ProductDetailEvent.OpenShoppingMall(it.shopUrl, it.shop) + } _event.emit(event) } } diff --git a/android/app/src/main/java/app/priceguard/ui/home/HomeActivity.kt b/android/app/src/main/java/app/priceguard/ui/home/HomeActivity.kt index 14a48b7..8b709e9 100644 --- a/android/app/src/main/java/app/priceguard/ui/home/HomeActivity.kt +++ b/android/app/src/main/java/app/priceguard/ui/home/HomeActivity.kt @@ -5,6 +5,10 @@ import android.content.pm.PackageManager import android.os.Build import android.os.Bundle import android.util.Log +import android.widget.Toast +import androidx.activity.result.ActivityResult +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.IntentSenderRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat @@ -16,12 +20,22 @@ import androidx.work.WorkManager import app.priceguard.R import app.priceguard.databinding.ActivityHomeBinding import app.priceguard.service.UpdateTokenWorker +import app.priceguard.ui.data.DialogConfirmAction import app.priceguard.ui.util.SystemNavigationColorState import app.priceguard.ui.util.applySystemNavigationBarColor import app.priceguard.ui.util.openNotificationSettings +import app.priceguard.ui.util.showConfirmDialog +import app.priceguard.ui.util.showDialogWithAction import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailability import com.google.android.material.snackbar.Snackbar +import com.google.android.play.core.appupdate.AppUpdateManager +import com.google.android.play.core.appupdate.AppUpdateManagerFactory +import com.google.android.play.core.appupdate.AppUpdateOptions +import com.google.android.play.core.install.InstallStateUpdatedListener +import com.google.android.play.core.install.model.AppUpdateType +import com.google.android.play.core.install.model.InstallStatus +import com.google.android.play.core.install.model.UpdateAvailability import dagger.hilt.android.AndroidEntryPoint import java.util.concurrent.TimeUnit @@ -31,17 +45,34 @@ class HomeActivity : AppCompatActivity() { private lateinit var binding: ActivityHomeBinding private lateinit var snackbar: Snackbar + private lateinit var appUpdateManager: AppUpdateManager + private lateinit var flexibleAppUpdateResultLauncher: ActivityResultLauncher + private lateinit var immediateAppUpdateResultLauncher: ActivityResultLauncher + + private val updateListener = InstallStateUpdatedListener { state -> + if (state.installStatus() == InstallStatus.DOWNLOADED) { + appUpdateManager.completeUpdate() + } + + if (state.installStatus() == InstallStatus.FAILED) { + Toast.makeText(this, getString(R.string.update_failed), Toast.LENGTH_LONG) + .show() + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) this.applySystemNavigationBarColor(SystemNavigationColorState.BOTTOM_NAVIGATION) binding = ActivityHomeBinding.inflate(layoutInflater) setContentView(binding.root) + setupAppUpdate() enqueueWorker() initSnackBar() checkForGooglePlayServices() setBottomNavigationBar() askNotificationPermission() + checkAppUpdates() } override fun onResume() { @@ -59,6 +90,11 @@ class HomeActivity : AppCompatActivity() { } } + override fun onDestroy() { + super.onDestroy() + appUpdateManager.unregisterListener(updateListener) + } + private fun enqueueWorker() { val saveRequest = PeriodicWorkRequestBuilder(730, TimeUnit.HOURS) @@ -128,6 +164,55 @@ class HomeActivity : AppCompatActivity() { } } + private fun setupAppUpdate() { + appUpdateManager = AppUpdateManagerFactory.create(this) + flexibleAppUpdateResultLauncher = + registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result: ActivityResult -> + when (result.resultCode) { + RESULT_CANCELED -> { + showConfirmDialog(getString(R.string.warning), getString(R.string.update_cancel_warning)) + } + + com.google.android.play.core.install.model.ActivityResult.RESULT_IN_APP_UPDATE_FAILED -> { + Toast.makeText(this, getString(R.string.update_failed), Toast.LENGTH_LONG) + .show() + } + } + } + immediateAppUpdateResultLauncher = + registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result: ActivityResult -> + when (result.resultCode) { + RESULT_CANCELED -> { + showDialogWithAction(getString(R.string.warning), getString(R.string.must_update), DialogConfirmAction.FINISH) + } + + com.google.android.play.core.install.model.ActivityResult.RESULT_IN_APP_UPDATE_FAILED -> { + showDialogWithAction(getString(R.string.error), getString(R.string.update_failed), DialogConfirmAction.FINISH) + } + } + } + appUpdateManager.registerListener(updateListener) + } + + private fun checkAppUpdates() { + val appUpdateInfoTask = appUpdateManager.appUpdateInfo + var appUpdateOptions = AppUpdateOptions.newBuilder(AppUpdateType.FLEXIBLE).build() + var activityResultLauncher = flexibleAppUpdateResultLauncher + + appUpdateInfoTask.addOnSuccessListener { info -> + when (info.updateAvailability()) { + UpdateAvailability.UPDATE_AVAILABLE, UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS -> { + if (info.isUpdateTypeAllowed(AppUpdateType.IMMEDIATE) && info.updatePriority() >= 3) { + activityResultLauncher = immediateAppUpdateResultLauncher + appUpdateOptions = AppUpdateOptions.newBuilder(AppUpdateType.IMMEDIATE).build() + } + appUpdateManager.startUpdateFlowForResult(info, activityResultLauncher, appUpdateOptions) + } + else -> {} + } + } + } + private fun showNotificationOffSnackbar() { if (snackbar.isShown) return snackbar.show() 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 cf282f0..d4f6159 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 @@ -4,6 +4,7 @@ import android.util.TypedValue import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.appcompat.content.res.AppCompatResources.getDrawable import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView @@ -49,8 +50,9 @@ class ProductSummaryAdapter( resetListener() summary = item setViewType(item) - setClickListener(item.productCode) + setClickListener(item.brandType, item.productCode) setGraph(item.priceData) + setShopLogoIcon(item.brandType) } } @@ -82,7 +84,7 @@ class ProductSummaryAdapter( updateThumbIcon(msProduct.isChecked) msProduct.setOnCheckedChangeListener { _, isChecked -> - productSummaryClickListener.onToggle(item.productCode, isChecked) + productSummaryClickListener.onToggle(item.brandType, item.productCode, isChecked) updateThumbIcon(isChecked) } msProduct.contentDescription = @@ -134,9 +136,9 @@ class ProductSummaryAdapter( ) } - private fun ItemProductSummaryBinding.setClickListener(code: String) { + private fun ItemProductSummaryBinding.setClickListener(shop: String, code: String) { cvProduct.setOnClickListener { - productSummaryClickListener.onClick(code) + productSummaryClickListener.onClick(shop, code) } } @@ -152,6 +154,15 @@ class ProductSummaryAdapter( gridLines = listOf() ) } + + private fun ItemProductSummaryBinding.setShopLogoIcon(shop: String) { + val iconDrawable = when (shop) { + "11번가" -> getDrawable(root.context, R.drawable.ic_11st_logo) + "SmartStore", "BrandStore" -> getDrawable(root.context, R.drawable.ic_naver_logo) + else -> return + } + ivItemIcon.setImageDrawable(iconDrawable) + } } companion object { @@ -160,6 +171,7 @@ class ProductSummaryAdapter( oldItem: ProductSummary.UserProductSummary, newItem: ProductSummary.UserProductSummary ) = oldItem.productCode == newItem.productCode && + oldItem.brandType == newItem.brandType && oldItem.price == newItem.price && oldItem.discountPercent == newItem.discountPercent && oldItem.title == newItem.title @@ -167,7 +179,8 @@ class ProductSummaryAdapter( override fun areItemsTheSame( oldItem: ProductSummary.UserProductSummary, newItem: ProductSummary.UserProductSummary - ) = oldItem.productCode == newItem.productCode + ) = oldItem.productCode == newItem.productCode && + oldItem.brandType == newItem.brandType } val diffUtil = @@ -176,7 +189,7 @@ class ProductSummaryAdapter( oldItem == newItem override fun areItemsTheSame(oldItem: ProductSummary, newItem: ProductSummary) = - oldItem.productCode == newItem.productCode + oldItem.productCode == newItem.productCode && oldItem.brandType == newItem.brandType } } } diff --git a/android/app/src/main/java/app/priceguard/ui/home/ProductSummaryClickListener.kt b/android/app/src/main/java/app/priceguard/ui/home/ProductSummaryClickListener.kt index 871d2a5..1eb6c51 100644 --- a/android/app/src/main/java/app/priceguard/ui/home/ProductSummaryClickListener.kt +++ b/android/app/src/main/java/app/priceguard/ui/home/ProductSummaryClickListener.kt @@ -1,7 +1,7 @@ package app.priceguard.ui.home interface ProductSummaryClickListener { - fun onClick(productCode: String) + fun onClick(productShop: String, productCode: String) - fun onToggle(productCode: String, checked: Boolean) + fun onToggle(productShop: String, productCode: String, checked: Boolean) } 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 bb08a5e..8f07cc0 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 @@ -33,7 +33,7 @@ class ProductListFragment : Fragment() { private val binding get() = _binding!! private val productListViewModel: ProductListViewModel by viewModels() - private var workRequestSet: MutableSet = mutableSetOf() + private var workRequestSet: MutableSet> = mutableSetOf() override fun onCreateView( inflater: LayoutInflater, @@ -60,18 +60,19 @@ class ProductListFragment : Fragment() { private fun FragmentProductListBinding.initSettingAdapter() { val listener = object : ProductSummaryClickListener { - override fun onClick(productCode: String) { + override fun onClick(productShop: String, productCode: String) { val intent = Intent(context, DetailActivity::class.java) + intent.putExtra("productShop", productShop) intent.putExtra("productCode", productCode) startActivity(intent) } - override fun onToggle(productCode: String, checked: Boolean) { - productListViewModel.updateProductAlarmToggle(productCode, checked) - if (workRequestSet.contains(productCode)) { - workRequestSet.remove(productCode) + override fun onToggle(productShop: String, productCode: String, checked: Boolean) { + productListViewModel.updateProductAlarmToggle(productShop, productCode, checked) + if (workRequestSet.contains(Pair(productShop, productCode))) { + workRequestSet.remove(Pair(productShop, productCode)) } else { - workRequestSet.add(productCode) + workRequestSet.add(Pair(productShop, productCode)) } } } @@ -79,8 +80,10 @@ class ProductListFragment : Fragment() { val adapter = ProductSummaryAdapter(listener, ProductSummaryAdapter.userDiffUtil) rvProductList.adapter = adapter this@ProductListFragment.repeatOnStarted { - productListViewModel.productList.collect { list -> - adapter.submitList(list) + productListViewModel.state.collect { state -> + if (state.productList.isNotEmpty()) { + adapter.submitList(state.productList) + } } } } @@ -135,9 +138,9 @@ class ProductListFragment : Fragment() { override fun onStop() { super.onStop() - workRequestSet.forEach { productCode -> + workRequestSet.forEach { requestData -> WorkManager.getInstance(requireContext()) - .enqueue(UpdateAlarmWorker.createWorkRequest(productCode)) + .enqueue(UpdateAlarmWorker.createWorkRequest(requestData)) } workRequestSet.clear() } 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 d1cdafc..6329820 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 @@ -26,11 +26,14 @@ class ProductListViewModel @Inject constructor( private val graphDataConverter: GraphDataConverter ) : ViewModel() { - private var _isRefreshing: MutableStateFlow = MutableStateFlow(false) - val isRefreshing: StateFlow = _isRefreshing.asStateFlow() + data class ProductListState( + val isRefreshing: Boolean = false, + val isUpdated: Boolean = false, + val productList: List = listOf() + ) - private var _productList = MutableStateFlow>(listOf()) - val productList: StateFlow> = _productList.asStateFlow() + private var _state: MutableStateFlow = MutableStateFlow(ProductListState()) + val state: StateFlow = _state.asStateFlow() private var _events = MutableSharedFlow() val events: SharedFlow = _events.asSharedFlow() @@ -38,12 +41,13 @@ class ProductListViewModel @Inject constructor( fun getProductList(isRefresh: Boolean) { viewModelScope.launch { if (isRefresh) { - _isRefreshing.value = true + _state.value = _state.value.copy(isRefreshing = true) + } else { + _state.value = _state.value.copy(isUpdated = false) } - val result = productRepository.getProductList() - _isRefreshing.value = false + _state.value = _state.value.copy(isRefreshing = false) when (result) { is RepositoryResult.Success -> { @@ -54,36 +58,41 @@ class ProductListViewModel @Inject constructor( _events.emit(result.errorState) } } + _state.value = _state.value.copy(isUpdated = true) } } private fun updateProductList(refresh: Boolean, fetched: List) { val productMap = mutableMapOf() - _productList.value.forEach { product -> + state.value.productList.forEach { product -> productMap[product.productCode] = product.isAlarmOn } - _productList.value = fetched.map { data -> - UserProductSummary( - data.shop, - data.productName, - data.price, - data.productCode, - graphDataConverter.packWithEdgeData(data.priceData, GraphMode.WEEK), - calculateDiscountRate(data.targetPrice, data.price), - if (refresh) productMap[data.productCode] ?: data.isAlert else data.isAlert - ) - } + _state.value = _state.value.copy( + productList = fetched.map { data -> + UserProductSummary( + data.shop, + data.productName, + data.price, + data.productCode, + graphDataConverter.packWithEdgeData(data.priceData, GraphMode.WEEK), + calculateDiscountRate(data.targetPrice, data.price), + if (refresh) productMap[data.productCode] ?: data.isAlert else data.isAlert + ) + } + ) } - fun updateProductAlarmToggle(productCode: String, checked: Boolean) { - _productList.value = productList.value.mapIndexed { _, product -> - if (product.productCode == productCode) { - product.copy(isAlarmOn = checked) - } else { - product + fun updateProductAlarmToggle(productShop: String, productCode: String, checked: Boolean) { + _state.value = _state.value.copy( + productList = state.value.productList.mapIndexed { _, product -> + if (product.productCode == productCode && product.brandType == productShop) { + product.copy(isAlarmOn = checked) + } else { + product + } } - } + ) } private fun calculateDiscountRate(targetPrice: Int, price: Int): Float { diff --git a/android/app/src/main/java/app/priceguard/ui/home/mypage/DeleteAccountActivity.kt b/android/app/src/main/java/app/priceguard/ui/home/mypage/DeleteAccountActivity.kt new file mode 100644 index 0000000..4a0c000 --- /dev/null +++ b/android/app/src/main/java/app/priceguard/ui/home/mypage/DeleteAccountActivity.kt @@ -0,0 +1,94 @@ +package app.priceguard.ui.home.mypage + +import android.content.Intent +import android.os.Bundle +import android.widget.Toast +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import app.priceguard.R +import app.priceguard.databinding.ActivityDeleteAccountBinding +import app.priceguard.ui.data.DialogConfirmAction +import app.priceguard.ui.login.LoginActivity +import app.priceguard.ui.util.SystemNavigationColorState +import app.priceguard.ui.util.applySystemNavigationBarColor +import app.priceguard.ui.util.lifecycle.repeatOnStarted +import app.priceguard.ui.util.onThrottleClick +import app.priceguard.ui.util.showConfirmDialog +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class DeleteAccountActivity : AppCompatActivity() { + + private lateinit var binding: ActivityDeleteAccountBinding + private val deleteAccountViewModel: DeleteAccountViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + this.applySystemNavigationBarColor(SystemNavigationColorState.SURFACE) + binding = ActivityDeleteAccountBinding.inflate(layoutInflater) + setContentView(binding.root) + + binding.viewModel = deleteAccountViewModel + binding.lifecycleOwner = this + + deleteAccountViewModel.updateEmail(intent.getStringExtra("email") ?: "") + + initView() + initCollector() + } + + private fun initView() { + binding.mtDeleteAccountTopbar.setNavigationOnClickListener { + finish() + } + + binding.btnDeleteAccount.onThrottleClick { + deleteAccountViewModel.deleteAccount() + } + } + + private fun initCollector() { + repeatOnStarted { + deleteAccountViewModel.state.collect { state -> + binding.btnDeleteAccount.isEnabled = state.isDeleteEnabled + } + } + + repeatOnStarted { + deleteAccountViewModel.event.collect { event -> + when (event) { + DeleteAccountViewModel.DeleteAccountEvent.Logout -> { + Toast.makeText( + this@DeleteAccountActivity, + getString(R.string.success_delete_account), + Toast.LENGTH_SHORT + ).show() + goBackToLoginActivity() + } + DeleteAccountViewModel.DeleteAccountEvent.WrongPassword -> { + showConfirmDialog( + getString(R.string.error_password), + getString(R.string.wrong_password), + DialogConfirmAction.NOTHING + ) + } + + DeleteAccountViewModel.DeleteAccountEvent.UndefinedError -> { + showConfirmDialog( + getString(R.string.error), + getString(R.string.undefined_error), + DialogConfirmAction.NOTHING + ) + } + } + } + } + } + + private fun goBackToLoginActivity() { + val intent = Intent(this, LoginActivity::class.java) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + startActivity(intent) + finish() + } +} diff --git a/android/app/src/main/java/app/priceguard/ui/home/mypage/DeleteAccountViewModel.kt b/android/app/src/main/java/app/priceguard/ui/home/mypage/DeleteAccountViewModel.kt new file mode 100644 index 0000000..781913d --- /dev/null +++ b/android/app/src/main/java/app/priceguard/ui/home/mypage/DeleteAccountViewModel.kt @@ -0,0 +1,82 @@ +package app.priceguard.ui.home.mypage + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.priceguard.data.repository.RepositoryResult +import app.priceguard.data.repository.auth.AuthErrorState +import app.priceguard.data.repository.auth.AuthRepository +import app.priceguard.data.repository.token.TokenRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +@HiltViewModel +class DeleteAccountViewModel @Inject constructor( + private val authRepository: AuthRepository, + private val tokenRepository: TokenRepository +) : ViewModel() { + + data class DeleteAccountState( + val email: String = "", + val password: String = "", + val isChecked: Boolean = false, + val isDeleteEnabled: Boolean = false + ) + + sealed class DeleteAccountEvent { + data object Logout : DeleteAccountEvent() + data object WrongPassword : DeleteAccountEvent() + data object UndefinedError : DeleteAccountEvent() + } + + private val _state = MutableStateFlow(DeleteAccountState()) + val state = _state.asStateFlow() + + private val _event = MutableSharedFlow() + val event = _event.asSharedFlow() + + fun deleteAccount() { + viewModelScope.launch { + when (val response = authRepository.deleteAccount(_state.value.email, _state.value.password)) { + is RepositoryResult.Error -> { + when (response.errorState) { + AuthErrorState.INVALID_REQUEST -> { + _event.emit(DeleteAccountEvent.WrongPassword) + } + + else -> { + _event.emit(DeleteAccountEvent.UndefinedError) + } + } + } + + is RepositoryResult.Success -> { + tokenRepository.clearTokens() + _event.emit(DeleteAccountEvent.Logout) + } + } + } + } + + fun updateEmail(email: String) { + _state.value = _state.value.copy(email = email) + } + + fun updatePassWord(password: String) { + _state.value = _state.value.copy(password = password) + updateDeleteEnabled() + } + + fun updateChecked(isChecked: Boolean) { + _state.value = _state.value.copy(isChecked = isChecked) + updateDeleteEnabled() + } + + private fun updateDeleteEnabled() { + _state.value = _state.value.copy(isDeleteEnabled = _state.value.password.isNotEmpty() && _state.value.isChecked) + } +} 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 77c0c7b..c16d8a3 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 @@ -5,6 +5,7 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.annotation.StringRes import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels @@ -15,6 +16,7 @@ import app.priceguard.databinding.FragmentMyPageBinding import app.priceguard.ui.data.DialogConfirmAction import app.priceguard.ui.home.mypage.MyPageViewModel.MyPageEvent import app.priceguard.ui.intro.IntroActivity +import app.priceguard.ui.login.findpassword.FindPasswordActivity import app.priceguard.ui.util.ConfirmDialogFragment import app.priceguard.ui.util.lifecycle.repeatOnStarted import app.priceguard.ui.util.openNotificationSettings @@ -52,11 +54,17 @@ class MyPageFragment : Fragment(), ConfirmDialogFragment.OnDialogResultListener myPageViewModel.event.collect { event -> when (event) { is MyPageEvent.StartIntroAndExitHome -> startIntroAndExitHome() + is MyPageEvent.StartVerifyEmail -> goToEmailVerification() } } } } + override fun onStart() { + super.onStart() + myPageViewModel.getIsEmailVerified() + } + private fun initSettingAdapter() { val settingItems = initSettingItem() binding.rvMyPageSetting.adapter = @@ -78,7 +86,17 @@ class MyPageFragment : Fragment(), ConfirmDialogFragment.OnDialogResultListener } Setting.LOGOUT -> { - showConfirmationDialogForResult() + showConfirmationDialogForResult( + R.string.logout_confirm_title, + R.string.logout_confirm_message + ) + } + + Setting.DELETE_ACCOUNT -> { + val intent = + Intent(requireActivity(), DeleteAccountActivity::class.java) + intent.putExtra("email", myPageViewModel.state.value.email) + startActivity(intent) } } } @@ -107,18 +125,32 @@ class MyPageFragment : Fragment(), ConfirmDialogFragment.OnDialogResultListener Setting.LOGOUT, ContextCompat.getDrawable(requireActivity(), R.drawable.ic_logout), getString(R.string.logout) + ), + SettingItemInfo( + Setting.DELETE_ACCOUNT, + ContextCompat.getDrawable(requireActivity(), R.drawable.ic_close_red), + getString(R.string.delete_account) ) ) } - private fun showConfirmationDialogForResult() { + private fun goToEmailVerification() { + val intent = Intent(requireActivity(), FindPasswordActivity::class.java) + intent.putExtra("isFindPassword", false) + startActivity(intent) + } + + private fun showConfirmationDialogForResult( + @StringRes title: Int, + @StringRes message: Int + ) { val tag = "confirm_dialog_fragment_from_activity" if (requireActivity().supportFragmentManager.findFragmentByTag(tag) != null) return val dialogFragment = ConfirmDialogFragment() val bundle = Bundle() - bundle.putString("title", getString(R.string.logout_confirm_title)) - bundle.putString("message", getString(R.string.logout_confirm_message)) + bundle.putString("title", getString(title)) + bundle.putString("message", getString(message)) bundle.putString("actionString", DialogConfirmAction.CUSTOM.name) dialogFragment.arguments = bundle dialogFragment.setOnDialogResultListener(this) diff --git a/android/app/src/main/java/app/priceguard/ui/home/mypage/MyPageSettingAdapter.kt b/android/app/src/main/java/app/priceguard/ui/home/mypage/MyPageSettingAdapter.kt index a182807..7972d8a 100644 --- a/android/app/src/main/java/app/priceguard/ui/home/mypage/MyPageSettingAdapter.kt +++ b/android/app/src/main/java/app/priceguard/ui/home/mypage/MyPageSettingAdapter.kt @@ -1,5 +1,6 @@ package app.priceguard.ui.home.mypage +import android.util.TypedValue import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView @@ -32,6 +33,12 @@ class MyPageSettingAdapter( with(binding) { settingItemInfo = item listener = clickListener + + if (item.id == Setting.DELETE_ACCOUNT) { + val typedValue = TypedValue() + root.context.theme.resolveAttribute(com.google.android.material.R.attr.colorError, typedValue, true) + tvMyPageItemTitle.setTextColor(typedValue.data) + } } } } diff --git a/android/app/src/main/java/app/priceguard/ui/home/mypage/MyPageViewModel.kt b/android/app/src/main/java/app/priceguard/ui/home/mypage/MyPageViewModel.kt index 12e49dc..eeca516 100644 --- a/android/app/src/main/java/app/priceguard/ui/home/mypage/MyPageViewModel.kt +++ b/android/app/src/main/java/app/priceguard/ui/home/mypage/MyPageViewModel.kt @@ -13,17 +13,19 @@ import kotlinx.coroutines.launch @HiltViewModel class MyPageViewModel @Inject constructor( - val tokenRepository: TokenRepository + private val tokenRepository: TokenRepository ) : ViewModel() { sealed class MyPageEvent { data object StartIntroAndExitHome : MyPageEvent() + data object StartVerifyEmail : MyPageEvent() } data class MyPageInfo( val name: String, val email: String, - val firstName: String + val firstName: String, + val isEmailVerified: Boolean? = null ) private val _state = MutableStateFlow(MyPageInfo("", "", "")) @@ -36,6 +38,26 @@ class MyPageViewModel @Inject constructor( setInfo() } + fun logout() { + viewModelScope.launch { + tokenRepository.clearTokens() + _event.emit(MyPageEvent.StartIntroAndExitHome) + } + } + + fun getIsEmailVerified() { + viewModelScope.launch { + val isEmailVerified = tokenRepository.getIsEmailVerified() + _state.value = _state.value.copy(isEmailVerified = isEmailVerified) + } + } + + fun startVerifyEmail() { + viewModelScope.launch { + _event.emit(MyPageEvent.StartVerifyEmail) + } + } + private fun setInfo() { viewModelScope.launch { val userData = tokenRepository.getUserData() @@ -48,13 +70,6 @@ class MyPageViewModel @Inject constructor( } } - fun logout() { - viewModelScope.launch { - tokenRepository.clearTokens() - _event.emit(MyPageEvent.StartIntroAndExitHome) - } - } - private fun getFirstName(name: String): String { name.forEach { if (it.isSurrogate().not() && it.toString().isNotBlank()) { diff --git a/android/app/src/main/java/app/priceguard/ui/home/mypage/SettingItemInfo.kt b/android/app/src/main/java/app/priceguard/ui/home/mypage/SettingItemInfo.kt index 3b53bb6..6462bc3 100644 --- a/android/app/src/main/java/app/priceguard/ui/home/mypage/SettingItemInfo.kt +++ b/android/app/src/main/java/app/priceguard/ui/home/mypage/SettingItemInfo.kt @@ -12,5 +12,6 @@ enum class Setting { NOTIFICATION, THEME, LICENSE, - LOGOUT + LOGOUT, + DELETE_ACCOUNT } 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 f6f5418..4908d18 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 @@ -55,13 +55,14 @@ class RecommendedProductFragment : Fragment() { private fun FragmentRecommendedProductBinding.initSettingAdapter() { val listener = object : ProductSummaryClickListener { - override fun onClick(productCode: String) { + override fun onClick(productShop: String, productCode: String) { val intent = Intent(context, DetailActivity::class.java) + intent.putExtra("productShop", productShop) intent.putExtra("productCode", productCode) startActivity(intent) } - override fun onToggle(productCode: String, checked: Boolean) { + override fun onToggle(productShop: String, productCode: String, checked: Boolean) { return } } @@ -69,8 +70,10 @@ class RecommendedProductFragment : Fragment() { val adapter = ProductSummaryAdapter(listener, ProductSummaryAdapter.diffUtil) rvRecommendedProduct.adapter = adapter this@RecommendedProductFragment.repeatOnStarted { - recommendedProductViewModel.recommendedProductList.collect { list -> - adapter.submitList(list) + recommendedProductViewModel.state.collect { state -> + if (state.recommendedList.isNotEmpty()) { + adapter.submitList(state.recommendedList) + } } } } 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 a3a9d34..5758579 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 @@ -24,13 +24,14 @@ class RecommendedProductViewModel @Inject constructor( private val graphDataConverter: GraphDataConverter ) : ViewModel() { - private var _isRefreshing = MutableStateFlow(false) - val isRefreshing: StateFlow = _isRefreshing.asStateFlow() + data class RecommendedProductState( + val isRefreshing: Boolean = false, + val isUpdated: Boolean = false, + val recommendedList: List = listOf() + ) - private var _recommendedProductList = - MutableStateFlow>(listOf()) - val recommendedProductList: StateFlow> = - _recommendedProductList.asStateFlow() + private var _state: MutableStateFlow = MutableStateFlow(RecommendedProductState()) + val state: StateFlow = _state.asStateFlow() private var _events = MutableSharedFlow() val events: SharedFlow = _events.asSharedFlow() @@ -38,30 +39,35 @@ class RecommendedProductViewModel @Inject constructor( fun getRecommendedProductList(isRefresh: Boolean) { viewModelScope.launch { if (isRefresh) { - _isRefreshing.value = true + _state.value = _state.value.copy(isRefreshing = true) + } else { + _state.value = _state.value.copy(isUpdated = false) } - val result = productRepository.getRecommendedProductList() - _isRefreshing.value = false + + _state.value = _state.value.copy(isRefreshing = false) when (result) { is RepositoryResult.Success -> { - _recommendedProductList.value = result.data.map { data -> - RecommendedProductSummary( - data.shop, - data.productName, - data.price, - data.productCode, - graphDataConverter.packWithEdgeData(data.priceData, GraphMode.WEEK), - data.rank - ) - } + _state.value = _state.value.copy( + recommendedList = result.data.map { data -> + RecommendedProductSummary( + data.shop, + data.productName, + data.price, + data.productCode, + graphDataConverter.packWithEdgeData(data.priceData, GraphMode.WEEK), + data.rank + ) + } + ) } is RepositoryResult.Error -> { _events.emit(result.errorState) } } + _state.value = _state.value.copy(isUpdated = true) } } } diff --git a/android/app/src/main/java/app/priceguard/ui/login/LoginActivity.kt b/android/app/src/main/java/app/priceguard/ui/login/LoginActivity.kt index c2dca38..4d3ffa9 100644 --- a/android/app/src/main/java/app/priceguard/ui/login/LoginActivity.kt +++ b/android/app/src/main/java/app/priceguard/ui/login/LoginActivity.kt @@ -9,6 +9,7 @@ import app.priceguard.R import app.priceguard.databinding.ActivityLoginBinding import app.priceguard.ui.home.HomeActivity import app.priceguard.ui.login.LoginViewModel.LoginEvent +import app.priceguard.ui.login.findpassword.FindPasswordActivity import app.priceguard.ui.signup.SignupActivity import app.priceguard.ui.util.SystemNavigationColorState import app.priceguard.ui.util.applySystemNavigationBarColor @@ -49,6 +50,9 @@ class LoginActivity : AppCompatActivity() { btnLoginSignup.setOnClickListener { gotoSignUp() } + tvFindPassword.setOnClickListener { + gotoFindPassword() + } } } @@ -112,6 +116,10 @@ class LoginActivity : AppCompatActivity() { startActivity(Intent(this, SignupActivity::class.java)) } + private fun gotoFindPassword() { + startActivity(Intent(this, FindPasswordActivity::class.java)) + } + private fun gotoHomeActivity() { val intent = Intent(this, HomeActivity::class.java) intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK diff --git a/android/app/src/main/java/app/priceguard/ui/login/findpassword/EmailVerificationFragment.kt b/android/app/src/main/java/app/priceguard/ui/login/findpassword/EmailVerificationFragment.kt new file mode 100644 index 0000000..8826d54 --- /dev/null +++ b/android/app/src/main/java/app/priceguard/ui/login/findpassword/EmailVerificationFragment.kt @@ -0,0 +1,167 @@ +package app.priceguard.ui.login.findpassword + +import android.os.Bundle +import android.os.CountDownTimer +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.fragment.findNavController +import app.priceguard.R +import app.priceguard.databinding.FragmentEmailVerificationBinding +import app.priceguard.ui.login.findpassword.EmailVerificationViewModel.EmailVerificationEvent +import app.priceguard.ui.util.lifecycle.repeatOnStarted +import app.priceguard.ui.util.safeNavigate +import app.priceguard.ui.util.showDialogWithAction +import dagger.hilt.android.AndroidEntryPoint +import java.util.concurrent.TimeUnit + +@AndroidEntryPoint +class EmailVerificationFragment : Fragment() { + + private var _binding: FragmentEmailVerificationBinding? = null + private val binding get() = _binding!! + + private val emailVerificationViewModel: EmailVerificationViewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentEmailVerificationBinding.inflate(layoutInflater, container, false) + + binding.lifecycleOwner = viewLifecycleOwner + binding.viewModel = emailVerificationViewModel + + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + initView() + initCollector() + } + + private fun initView() { + val isFindPassword = arguments?.getBoolean("isFindPassword") + if (isFindPassword != null) { + emailVerificationViewModel.updateType(isFindPassword) + } else { + Toast.makeText( + requireActivity(), + getString(R.string.undefined_error), + Toast.LENGTH_LONG + ).show() + requireActivity().finish() + } + + binding.btnEmailVerificationBack.setOnClickListener { + requireActivity().finish() + } + } + + private fun initCollector() { + viewLifecycleOwner.repeatOnStarted { + emailVerificationViewModel.event.collect { event -> + when (event) { + EmailVerificationEvent.SuccessRequestVerificationCode -> { + Toast.makeText( + requireActivity(), + getString(R.string.sent_verification_code), + Toast.LENGTH_LONG + ).show() + startTimer(180) + } + + is EmailVerificationEvent.SuccessVerify -> { + Toast.makeText( + requireActivity(), + getString(R.string.succes_email_verification), + Toast.LENGTH_LONG + ).show() + if (event.isFindPassword) { + goToResetPassword() + } else { + requireActivity().finish() + } + } + + EmailVerificationEvent.NotFoundEmail -> { + showDialogWithAction( + getString(R.string.error_request_verification_code), + getString(R.string.not_found_email) + ) + } + + EmailVerificationEvent.WrongVerificationCode -> { + showDialogWithAction( + getString(R.string.error_verify_email), + getString(R.string.match_error_verification_code) + ) + } + + EmailVerificationEvent.OverVerificationLimit -> { + showDialogWithAction( + getString(R.string.error_request_verification_code), + getString(R.string.request_verification_code_limit_max_5) + ) + } + + EmailVerificationEvent.ExpireToken -> { + showDialogWithAction( + getString(R.string.error_verify_email), + getString(R.string.expire_verification_code) + ) + } + + else -> { + showDialogWithAction( + getString(R.string.error), + getString(R.string.undefined_error) + ) + } + } + } + } + } + + private fun startTimer(totalTimeInSeconds: Int) { + val countDownTimer = object : CountDownTimer((totalTimeInSeconds * 1000).toLong(), 1000) { + override fun onTick(millisUntilFinished: Long) { + val timeLeft = millisUntilFinished / 1000 + val minutes = TimeUnit.SECONDS.toMinutes(timeLeft) + val seconds = timeLeft - TimeUnit.MINUTES.toSeconds(minutes) + + emailVerificationViewModel.updateTimer( + getString( + R.string.finish_send_verification_code, + String.format("%02d:%02d", minutes, seconds) + ) + ) + } + + override fun onFinish() { + emailVerificationViewModel.updateTimer(getString(R.string.expired)) + emailVerificationViewModel.updateRetryVerificationCodeEnabled(true) + } + } + countDownTimer.start() + } + + private fun goToResetPassword() { + val action = + EmailVerificationFragmentDirections.actionEmailVerificationFragmentToResetPasswordFragment( + verifyToken = emailVerificationViewModel.state.value.verifyToken + ) + findNavController().safeNavigate(action) + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/android/app/src/main/java/app/priceguard/ui/login/findpassword/EmailVerificationViewModel.kt b/android/app/src/main/java/app/priceguard/ui/login/findpassword/EmailVerificationViewModel.kt new file mode 100644 index 0000000..8cd1d43 --- /dev/null +++ b/android/app/src/main/java/app/priceguard/ui/login/findpassword/EmailVerificationViewModel.kt @@ -0,0 +1,170 @@ +package app.priceguard.ui.login.findpassword + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.priceguard.data.repository.RepositoryResult +import app.priceguard.data.repository.auth.AuthErrorState +import app.priceguard.data.repository.auth.AuthRepository +import app.priceguard.data.repository.token.TokenErrorState +import app.priceguard.data.repository.token.TokenRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +@HiltViewModel +class EmailVerificationViewModel @Inject constructor( + private val tokenRepository: TokenRepository, + private val authRepository: AuthRepository +) : ViewModel() { + + enum class EmailVerificationType { + REGISTER_VERIFICATION, + VERIFICATION + } + + data class EmailVerificationState( + val email: String = "", + val verificationCode: String = "", + val verifyToken: String = "", + val expirationTime: String = "", + val isMatchedEmailRegex: Boolean = false, + val isRequestedVerificationCode: Boolean = false, + val isFinishedRequestVerificationCode: Boolean = false, + val isNextEnabled: Boolean = false, + val isFindPassword: Boolean = true + ) + + sealed class EmailVerificationEvent { + data class SuccessVerify(val isFindPassword: Boolean) : EmailVerificationEvent() + data object SuccessRequestVerificationCode : EmailVerificationEvent() + data object NotFoundEmail : EmailVerificationEvent() + data object ExpireToken : EmailVerificationEvent() + data object WrongVerificationCode : EmailVerificationEvent() + data object DuplicatedEmail : EmailVerificationEvent() + data object OverVerificationLimit : EmailVerificationEvent() + data object UndefinedError : EmailVerificationEvent() + } + + private val _state = MutableStateFlow(EmailVerificationState()) + val state = _state.asStateFlow() + + private val _event = MutableSharedFlow() + val event = _event.asSharedFlow() + + fun requestVerificationCode(type: EmailVerificationType) { + _state.value = _state.value.copy(isRequestedVerificationCode = true) + + viewModelScope.launch { + val response = if (type == EmailVerificationType.VERIFICATION) { + authRepository.requestVerificationCode(_state.value.email) + } else { + authRepository.requestRegisterVerificationCode(_state.value.email) + } + when (response) { + is RepositoryResult.Error -> { + when (response.errorState) { + AuthErrorState.NOT_FOUND -> { + _event.emit(EmailVerificationEvent.NotFoundEmail) + } + + AuthErrorState.DUPLICATED_EMAIL -> { + _event.emit(EmailVerificationEvent.DuplicatedEmail) + } + + AuthErrorState.OVER_LIMIT -> { + _event.emit(EmailVerificationEvent.OverVerificationLimit) + } + + else -> _event.emit(EmailVerificationEvent.UndefinedError) + } + _state.value = _state.value.copy(isRequestedVerificationCode = false) + } + + is RepositoryResult.Success -> { + _event.emit(EmailVerificationEvent.SuccessRequestVerificationCode) + _state.value = _state.value.copy(isFinishedRequestVerificationCode = true) + checkNextEnabled() + } + } + } + } + + fun verifyEmail() { + viewModelScope.launch { + when ( + val response = + tokenRepository.verifyEmail(_state.value.email, _state.value.verificationCode) + ) { + is RepositoryResult.Error -> { + when (response.errorState) { + TokenErrorState.UNAUTHORIZED -> { + _event.emit(EmailVerificationEvent.WrongVerificationCode) + } + + TokenErrorState.NOT_FOUND -> { + _event.emit(EmailVerificationEvent.ExpireToken) + checkNextEnabled() + } + + else -> _event.emit(EmailVerificationEvent.UndefinedError) + } + } + + is RepositoryResult.Success -> { + tokenRepository.storeEmailVerified(true) + _state.value = _state.value.copy(verifyToken = response.data.verifyToken) + _event.emit(EmailVerificationEvent.SuccessVerify(_state.value.isFindPassword)) + } + } + } + } + + fun updateEmail(email: String) { + _state.value = _state.value.copy( + email = email + ) + checkEmailRegex(email) + } + + fun updateVerificationCode(verificationCode: String) { + _state.value = _state.value.copy( + verificationCode = verificationCode + ) + checkNextEnabled() + } + + fun updateTimer(timeString: String) { + _state.value = _state.value.copy(expirationTime = timeString) + } + + fun updateRetryVerificationCodeEnabled(enabled: Boolean) { + _state.value = _state.value.copy( + isRequestedVerificationCode = !enabled + ) + } + + fun updateType(isFindPassword: Boolean) { + _state.value = _state.value.copy( + isFindPassword = isFindPassword + ) + } + + private fun checkEmailRegex(email: String) { + val emailPattern = + """^[\w.+-]+@((?!-)[A-Za-z0-9-]{1,63}(?().navController + navController.setGraph(R.navigation.nav_graph_find_password, bundle) + } +} diff --git a/android/app/src/main/java/app/priceguard/ui/login/findpassword/ResetPasswordFragment.kt b/android/app/src/main/java/app/priceguard/ui/login/findpassword/ResetPasswordFragment.kt new file mode 100644 index 0000000..19eaae1 --- /dev/null +++ b/android/app/src/main/java/app/priceguard/ui/login/findpassword/ResetPasswordFragment.kt @@ -0,0 +1,97 @@ +package app.priceguard.ui.login.findpassword + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.addCallback +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import app.priceguard.R +import app.priceguard.databinding.FragmentResetPasswordBinding +import app.priceguard.ui.data.DialogConfirmAction +import app.priceguard.ui.util.lifecycle.repeatOnStarted +import app.priceguard.ui.util.showDialogWithAction +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class ResetPasswordFragment : Fragment() { + + private var _binding: FragmentResetPasswordBinding? = null + private val binding get() = _binding!! + + private val resetPasswordViewModel: ResetPasswordViewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentResetPasswordBinding.inflate(layoutInflater, container, false) + + binding.lifecycleOwner = viewLifecycleOwner + binding.viewModel = resetPasswordViewModel + + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner) { + findNavController().navigateUp() + } + initView() + initCollector() + setVerifyToken() + } + + private fun initView() { + binding.btnResetPasswordBack.setOnClickListener { + findNavController().navigateUp() + } + } + + private fun initCollector() { + viewLifecycleOwner.repeatOnStarted { + resetPasswordViewModel.event.collect { event -> + when (event) { + ResetPasswordViewModel.ResetPasswordEvent.SuccessResetPassword -> { + showDialogWithAction( + getString(R.string.finish_find_password), + getString(R.string.success_reset_password_retry_login), + DialogConfirmAction.FINISH + ) + } + + ResetPasswordViewModel.ResetPasswordEvent.ErrorVerifyToken -> { + showDialogWithAction( + getString(R.string.error), + getString(R.string.invalid_access), + DialogConfirmAction.FINISH + ) + } + + ResetPasswordViewModel.ResetPasswordEvent.UndefinedError -> { + showDialogWithAction( + getString(R.string.error), + getString(R.string.undefined_error) + ) + } + } + } + } + } + + private fun setVerifyToken() { + val arguments = requireArguments() + val verifyToken = arguments.getString("verifyToken") ?: "" + resetPasswordViewModel.updateVerifyToken(verifyToken) + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/android/app/src/main/java/app/priceguard/ui/login/findpassword/ResetPasswordViewModel.kt b/android/app/src/main/java/app/priceguard/ui/login/findpassword/ResetPasswordViewModel.kt new file mode 100644 index 0000000..950f59f --- /dev/null +++ b/android/app/src/main/java/app/priceguard/ui/login/findpassword/ResetPasswordViewModel.kt @@ -0,0 +1,94 @@ +package app.priceguard.ui.login.findpassword + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.priceguard.data.repository.RepositoryResult +import app.priceguard.data.repository.auth.AuthErrorState +import app.priceguard.data.repository.auth.AuthRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +@HiltViewModel +class ResetPasswordViewModel @Inject constructor( + private val authRepository: AuthRepository +) : ViewModel() { + + data class ResetPasswordState( + val password: String = "", + val passwordConfirm: String = "", + val verifyToken: String = "", + val isMatchedPasswordRegex: Boolean = false, + val isMatchedPasswordConfirm: Boolean = false + ) + + sealed class ResetPasswordEvent { + data object SuccessResetPassword : ResetPasswordEvent() + data object ErrorVerifyToken : ResetPasswordEvent() + data object UndefinedError : ResetPasswordEvent() + } + + private val _state = MutableStateFlow(ResetPasswordState()) + val state = _state.asStateFlow() + + private val _event = MutableSharedFlow() + val event = _event.asSharedFlow() + + fun resetPassword() { + viewModelScope.launch { + val response = + authRepository.resetPassword(_state.value.password, _state.value.verifyToken) + when (response) { + is RepositoryResult.Error -> { + when (response.errorState) { + AuthErrorState.UNAUTHORIZED, AuthErrorState.EXPIRE -> { + _event.emit(ResetPasswordEvent.ErrorVerifyToken) + } + + else -> { + _event.emit(ResetPasswordEvent.UndefinedError) + } + } + } + + is RepositoryResult.Success -> { + _event.emit(ResetPasswordEvent.SuccessResetPassword) + } + } + } + } + + fun updatePassword(password: String) { + _state.value = _state.value.copy(password = password) + checkPasswordRegex(password) + checkMatchPassword() + } + + fun updatePasswordConfirm(passwordConfirm: String) { + _state.value = _state.value.copy(passwordConfirm = passwordConfirm) + checkMatchPassword() + } + + fun updateVerifyToken(token: String) { + _state.value = _state.value.copy(verifyToken = token) + } + + private fun checkPasswordRegex(password: String) { + val passwordPattern = + """^(?=[A-Za-z\d!@#$%^&*()_+={};:'"~`,.?<>|\-\[\]\\/]*\d)(?=[A-Za-z\d!@#$%^&*()_+={};:'"~`,.?<>|\-\[\]\\/]*[a-z])(?=[A-Za-z\d!@#$%^&*()_+={};:'"~`,.?<>|\-\[\]\\/]*[A-Z])(?=[A-Za-z\d!@#$%^&*()_+={};:'"~`,.?<>|\-\[\]\\/]*[!@#$%^&*()_+={};:'"~`,.?<>|\-\[\]\\/])[A-Za-z\d!@#$%^&*()_+={};:'"~`,.?<>|\-\[\]\\/]{8,16}$""".toRegex() + + _state.value = _state.value.copy( + isMatchedPasswordRegex = password.matches(passwordPattern) + ) + } + + private fun checkMatchPassword() { + _state.value = _state.value.copy( + isMatchedPasswordConfirm = _state.value.password == _state.value.passwordConfirm + ) + } +} diff --git a/android/app/src/main/java/app/priceguard/ui/signup/SignupActivity.kt b/android/app/src/main/java/app/priceguard/ui/signup/SignupActivity.kt index 1fe5212..55301bc 100644 --- a/android/app/src/main/java/app/priceguard/ui/signup/SignupActivity.kt +++ b/android/app/src/main/java/app/priceguard/ui/signup/SignupActivity.kt @@ -2,6 +2,7 @@ package app.priceguard.ui.signup import android.content.Intent import android.os.Bundle +import android.os.CountDownTimer import android.widget.Toast import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity @@ -11,25 +12,28 @@ import androidx.databinding.DataBindingUtil import app.priceguard.R import app.priceguard.databinding.ActivitySignupBinding import app.priceguard.ui.home.HomeActivity +import app.priceguard.ui.login.findpassword.EmailVerificationViewModel import app.priceguard.ui.signup.SignupViewModel.SignupEvent -import app.priceguard.ui.signup.SignupViewModel.SignupUIState import app.priceguard.ui.util.SystemNavigationColorState import app.priceguard.ui.util.applySystemNavigationBarColor import app.priceguard.ui.util.drawable.getCircularProgressIndicatorDrawable import app.priceguard.ui.util.lifecycle.repeatOnStarted import app.priceguard.ui.util.showConfirmDialog +import app.priceguard.ui.util.showDialogWithAction import com.google.android.material.appbar.AppBarLayout import com.google.android.material.appbar.AppBarLayout.Behavior.DragCallback import com.google.android.material.button.MaterialButton import com.google.android.material.progressindicator.CircularProgressIndicatorSpec import com.google.android.material.progressindicator.IndeterminateDrawable import dagger.hilt.android.AndroidEntryPoint +import java.util.concurrent.TimeUnit @AndroidEntryPoint class SignupActivity : AppCompatActivity() { private lateinit var binding: ActivitySignupBinding private val signupViewModel: SignupViewModel by viewModels() + private val emailVerificationViewModel: EmailVerificationViewModel by viewModels() private lateinit var circularProgressIndicator: IndeterminateDrawable override fun onCreate(savedInstanceState: Bundle?) { @@ -37,6 +41,7 @@ class SignupActivity : AppCompatActivity() { this.applySystemNavigationBarColor(SystemNavigationColorState.SURFACE) binding = DataBindingUtil.setContentView(this, R.layout.activity_signup) binding.vm = signupViewModel + binding.verificationViewModel = emailVerificationViewModel binding.lifecycleOwner = this circularProgressIndicator = getCircularProgressIndicatorDrawable(this@SignupActivity) setNavigationButton() @@ -89,15 +94,24 @@ class SignupActivity : AppCompatActivity() { } SignupEvent.DuplicatedEmail -> { - showConfirmDialog(getString(R.string.error), getString(R.string.duplicate_email)) + showConfirmDialog( + getString(R.string.error), + getString(R.string.duplicate_email) + ) } SignupEvent.InvalidRequest -> { - showConfirmDialog(getString(R.string.error), getString(R.string.invalid_parameter)) + showConfirmDialog( + getString(R.string.error), + getString(R.string.invalid_parameter) + ) } SignupEvent.UndefinedError -> { - showConfirmDialog(getString(R.string.error), getString(R.string.undefined_error)) + showConfirmDialog( + getString(R.string.error), + getString(R.string.undefined_error) + ) } SignupEvent.TokenUpdateError, SignupEvent.FirebaseError -> { @@ -128,85 +142,82 @@ class SignupActivity : AppCompatActivity() { } private fun observeState() { - repeatOnStarted { - signupViewModel.state.collect { state -> - updateNameTextFieldUI(state) - updateEmailTextFieldUI(state) - updatePasswordTextFieldUI(state) - updateRetypePasswordTextFieldUI(state) - } - } - repeatOnStarted { signupViewModel.eventFlow.collect { event -> handleSignupEvent(event) } } - } - - private fun updateNameTextFieldUI(state: SignupUIState) { - when (state.isNameError) { - true -> { - binding.tilSignupName.error = getString(R.string.invalid_name) - } - - else -> { - binding.tilSignupName.error = null - } - } - } - - private fun updateEmailTextFieldUI(state: SignupUIState) { - when (state.isEmailError) { - null -> { - binding.tilSignupEmail.error = null - binding.tilSignupEmail.helperText = " " - } + repeatOnStarted { + emailVerificationViewModel.event.collect { event -> + when (event) { + EmailVerificationViewModel.EmailVerificationEvent.SuccessRequestVerificationCode -> { + Toast.makeText( + this@SignupActivity, + getString(R.string.sent_verification_code), + Toast.LENGTH_LONG + ).show() + startTimer(180) + } - true -> { - binding.tilSignupEmail.error = getString(R.string.invalid_email) - } + EmailVerificationViewModel.EmailVerificationEvent.DuplicatedEmail -> { + showDialogWithAction( + getString(R.string.error_email), + getString(R.string.duplicate_email) + ) + } - false -> { - binding.tilSignupEmail.error = null - binding.tilSignupEmail.helperText = getString(R.string.valid_email) - } - } - } + EmailVerificationViewModel.EmailVerificationEvent.WrongVerificationCode -> { + showDialogWithAction( + getString(R.string.error_verify_email), + getString(R.string.match_error_verification_code) + ) + } - private fun updatePasswordTextFieldUI(state: SignupUIState) { - when (state.isPasswordError) { - null -> { - binding.tilSignupPassword.error = null - binding.tilSignupPassword.helperText = " " - } + EmailVerificationViewModel.EmailVerificationEvent.OverVerificationLimit -> { + showDialogWithAction( + getString(R.string.error_request_verification_code), + getString(R.string.request_verification_code_limit_max_5) + ) + } - true -> { - binding.tilSignupPassword.error = getString(R.string.invalid_password) - } + EmailVerificationViewModel.EmailVerificationEvent.ExpireToken -> { + showDialogWithAction( + getString(R.string.error_verify_email), + getString(R.string.expire_verification_code) + ) + } - false -> { - binding.tilSignupPassword.error = null - binding.tilSignupPassword.helperText = getString(R.string.valid_password) + else -> { + showDialogWithAction( + getString(R.string.error), + getString(R.string.undefined_error) + ) + } + } } } } - private fun updateRetypePasswordTextFieldUI(state: SignupUIState) { - when (state.isRetypePasswordError) { - null -> { - binding.tilSignupRetypePassword.error = null - binding.tilSignupRetypePassword.helperText = " " - } + private fun startTimer(totalTimeInSeconds: Int) { + val countDownTimer = object : CountDownTimer((totalTimeInSeconds * 1000).toLong(), 1000) { + override fun onTick(millisUntilFinished: Long) { + val timeLeft = millisUntilFinished / 1000 + val minutes = TimeUnit.SECONDS.toMinutes(timeLeft) + val seconds = timeLeft - TimeUnit.MINUTES.toSeconds(minutes) - true -> { - binding.tilSignupRetypePassword.error = getString(R.string.password_mismatch) + emailVerificationViewModel.updateTimer( + getString( + R.string.finish_send_verification_code, + String.format("%02d:%02d", minutes, seconds) + ) + ) } - false -> { - binding.tilSignupRetypePassword.error = null - binding.tilSignupRetypePassword.helperText = getString(R.string.password_match) + override fun onFinish() { + emailVerificationViewModel.updateTimer(getString(R.string.expired)) + emailVerificationViewModel.updateRetryVerificationCodeEnabled(true) } } + countDownTimer.start() } } diff --git a/android/app/src/main/java/app/priceguard/ui/signup/SignupViewModel.kt b/android/app/src/main/java/app/priceguard/ui/signup/SignupViewModel.kt index 987b3be..3f57e6d 100644 --- a/android/app/src/main/java/app/priceguard/ui/signup/SignupViewModel.kt +++ b/android/app/src/main/java/app/priceguard/ui/signup/SignupViewModel.kt @@ -55,10 +55,10 @@ class SignupViewModel @Inject constructor( private val _state: MutableStateFlow = MutableStateFlow(SignupUIState()) val state: StateFlow = _state.asStateFlow() - private val _eventFlow: MutableSharedFlow = MutableSharedFlow(replay = 0) + private val _eventFlow: MutableSharedFlow = MutableSharedFlow() val eventFlow: SharedFlow = _eventFlow.asSharedFlow() - fun signup() { + fun signup(verificationCode: String) { if (_state.value.isSignupStarted || _state.value.isSignupFinished) { Log.d("Signup", "Signup already requested. Skipping") return @@ -70,7 +70,7 @@ class SignupViewModel @Inject constructor( Log.d("ViewModel", "Event Start Sent") val result = - authRepository.signUp(_state.value.email, _state.value.name, _state.value.password) + authRepository.signUp(_state.value.email, _state.value.name, _state.value.password, verificationCode) when (result) { is RepositoryResult.Success -> { @@ -100,7 +100,7 @@ class SignupViewModel @Inject constructor( SignupEvent.DuplicatedEmail } - AuthErrorState.UNDEFINED_ERROR -> { + else -> { SignupEvent.UndefinedError } } diff --git a/android/app/src/main/java/app/priceguard/ui/slider/ConvertUtil.kt b/android/app/src/main/java/app/priceguard/ui/slider/ConvertUtil.kt new file mode 100644 index 0000000..e33a84d --- /dev/null +++ b/android/app/src/main/java/app/priceguard/ui/slider/ConvertUtil.kt @@ -0,0 +1,45 @@ +package app.priceguard.ui.slider + +import android.content.Context +import android.util.TypedValue + +data class Px(val value: Float) + +data class Dp(val value: Float) + +data class Sp(val value: Float) + +fun Dp.toPx(context: Context): Px { + val density = context.resources.displayMetrics.density + return Px(value * density) +} + +fun Sp.toPx(context: Context): Px { + return Px( + TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_SP, + value, + context.resources.displayMetrics + ) + ) +} + +fun Double.toRadian(): Double { + return this * Math.PI / 180 +} + +fun Float.toRadian(): Float { + return this * Math.PI.toFloat() / 180F +} + +fun Int.toRadian(): Double { + return this * Math.PI / 180 +} + +fun Double.toDegree(): Double { + return this * 180 / Math.PI +} + +fun Float.toDegree(): Float { + return this * 180F / Math.PI.toFloat() +} diff --git a/android/app/src/main/java/app/priceguard/ui/slider/RoundSlider.kt b/android/app/src/main/java/app/priceguard/ui/slider/RoundSlider.kt new file mode 100644 index 0000000..b4bd91f --- /dev/null +++ b/android/app/src/main/java/app/priceguard/ui/slider/RoundSlider.kt @@ -0,0 +1,413 @@ +package app.priceguard.ui.slider + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Rect +import android.graphics.RectF +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View +import app.priceguard.R +import kotlin.math.abs +import kotlin.math.atan +import kotlin.math.cos +import kotlin.math.min +import kotlin.math.pow +import kotlin.math.roundToInt +import kotlin.math.sin +import kotlin.math.sqrt + +class RoundSlider @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, + defStyleRes: Int = 0 +) : View(context, attrs, defStyleAttr, defStyleRes) { + + private var width = 0F + private var height = 0F + + private val slideBarPaint = Paint() + private val activeSlideBarPaint = Paint() + private val controllerPaint = Paint() + private val sliderValuePaint = Paint() + private val axisCirclePaint = Paint() + + // 슬라이더 컨트롤러 좌표 + private var controllerPointX = 0F + private var controllerPointY = 0F + + // 슬라이더 바의 중심 좌표 (호의 중점) + private var slideBarPointX = 0F + private var slideBarPointY = 0F + + // 슬라이더 틱 간격 + private var sliderValueStepSize = 0 + + private var sliderValueChangeListener: SliderValueChangeListener? = null + + // 드래그 중인지, 드래그 중인 좌표가 슬라이더 뷰 안에 있는지 확인 + private var isDraggingOnSlider = false + + // 모드에 따른 슬라이더 바 및 활성화 색상 변경 + private var sliderMode = RoundSliderState.ACTIVE + + private val pi = Math.PI + + var sliderValue = 0 + private set(value) { // 슬라이더 value가 변경되면 리스너에 변경된 값과 함께 이벤트 보내기 + if (field != value) { + field = value + handleValueChangeEvent(value) + } + } + + private var slideBarStrokeWidth = Dp(8F).toPx(context).value + + private var slideBarRadius = 0F + private var controllerRadius = Dp(12F).toPx(context).value + + private var slideBarMargin = Dp(12F).toPx(context).value + controllerRadius + + private var maxPercentValue = 100 + + // 하이라이트된 슬라이더 바를 그리기 위한 시작과 끝 각도 + private var startDegree = 0F + private var endDegree = 0F + + private var textValueSize = Sp(32F) + + private val touchSizeMargin = Dp(8F).toPx(context).value + + private var isTouchedOnSlideBar = false + + // 현재 sliderValue값으로 위치해야하는 컨트롤러 좌표에 대한 라디안 값 + private var currentRad = pi + set(value) { + field = value.coerceIn(0.0..pi) + updateValueWithRadian(field) + updateControllerPointWithRadian(field) + invalidate() + } + + private var colorPrimary: Int + private var colorOnPrimaryContainer: Int + private var colorSurfaceVariant: Int + private var colorError: Int + + init { + val typedArray = context.obtainStyledAttributes( + attrs, + R.styleable.RoundSlider, + defStyleAttr, + 0 + ) + + colorPrimary = typedArray.getColor( + R.styleable.RoundSlider_colorPrimary, + Color.BLUE + ) + + colorOnPrimaryContainer = typedArray.getColor( + R.styleable.RoundSlider_colorOnPrimaryContainer, + Color.BLACK + ) + + colorSurfaceVariant = typedArray.getColor( + R.styleable.RoundSlider_colorSurfaceVariant, + Color.GRAY + ) + + colorError = typedArray.getColor( + R.styleable.RoundSlider_colorError, + Color.RED + ) + + typedArray.recycle() + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + + // wrap_content인지 match_parent인지 고정된 값인지 확인 + val viewWidthMode = MeasureSpec.getMode(widthMeasureSpec) + val viewHeightMode = MeasureSpec.getMode(heightMeasureSpec) + + // 값이 있으면 설정된 값 할당 없으면 최대 값 할당 + val viewWidthSize = MeasureSpec.getSize(widthMeasureSpec) + val viewHeightSize = MeasureSpec.getSize(heightMeasureSpec) + + width = if (viewWidthMode == MeasureSpec.EXACTLY) { + viewWidthSize.toFloat() + } else if (viewHeightMode == MeasureSpec.EXACTLY) { // height만 크기 값이 설정된 경우 height에 맞게 width 설정 + (viewHeightSize.toFloat() - controllerRadius - slideBarMargin) * 2 + } else { // width, height 모두 wrap_content와 같이 설정된 크기 값이 없을 경우 700으로 고정 + 700F + } + // width의 크기가 최대 크기보다 작거나 같아야 함 + width = min(width, viewWidthSize.toFloat()) + + height = if (viewHeightMode == MeasureSpec.EXACTLY) { + viewHeightSize.toFloat() + } else { + width / 2 + slideBarMargin + } + height = min(height, viewHeightSize.toFloat()) + + setMeasuredDimension(width.toInt(), height.toInt()) + + // 높이가 필요한 크기보다 클 경우 크기 줄이기 (중앙 정렬을 위함) + if (viewHeightMode == MeasureSpec.EXACTLY) { + val temp = width / 2 + slideBarMargin + if (height > temp) { + height -= (height - temp) / 2 + } + } + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + + slideBarPointX = width / 2 + slideBarPointY = height - slideBarMargin + + slideBarRadius = if (isHeightEnough()) { // 높이가 충분히 높을 경우 너비에 맞게 슬라이더 크기 설정 + width / 2 - slideBarMargin + } else { // 높이가 충분히 높지 않을 경우 높이에 맞게 슬라이더 크기 설정 + height - slideBarMargin * 2 + } + + // 슬라이더 value에 맞게 라디안 설정 + currentRad = valueToDegree(sliderValue).toRadian() + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + + drawSlideBar(canvas) + drawSliderBarTick(canvas) + drawController(canvas) + drawSlideValueText(canvas) + } + + override fun onTouchEvent(event: MotionEvent?): Boolean { + if (event != null) { + when (event.action) { + MotionEvent.ACTION_DOWN -> { + isTouchedOnSlideBar = isTouchOnSlideBar(event.x, event.y) + if (!isTouchedOnSlideBar) { + parent.requestDisallowInterceptTouchEvent(false) + } else { + parent.requestDisallowInterceptTouchEvent(true) + } + } + + MotionEvent.ACTION_MOVE -> { + if (!isTouchedOnSlideBar) return true + + if (event.y > slideBarPointY) { // 드래그 좌표가 슬라이더를 벗어난 경우 value를 x좌표에 맞게 최소 or 최대 값으로 설정 + if (isDraggingOnSlider) { + isDraggingOnSlider = false + currentRad = if (event.x >= slideBarPointX) 0.0 else pi + } + } else { + isDraggingOnSlider = true + updateRadianWithTouchPoint(event.x, event.y) + } + } + + MotionEvent.ACTION_UP -> { + parent.requestDisallowInterceptTouchEvent(false) + isDraggingOnSlider = false + } + } + } + return true + } + + fun setValue(value: Int) { + currentRad = (180 / maxPercentValue.toDouble() * (maxPercentValue - value)).toRadian() + sliderValue = value + } + + fun setMaxPercentValue(value: Int) { + maxPercentValue = value + invalidate() + } + + fun setHighlightRange(startValue: Int, endValue: Int) { + startDegree = (180F / maxPercentValue * startValue) + 180 + endDegree = (180F / maxPercentValue * endValue) + 180 + invalidate() + } + + fun setSliderValueChangeListener(listener: SliderValueChangeListener) { + sliderValueChangeListener = listener + } + + fun setSliderMode(mode: RoundSliderState) { + sliderMode = mode + } + + fun setStepSize(step: Int) { + if (maxPercentValue.mod(step) != 0) return + sliderValueStepSize = step + invalidate() + } + + private fun drawSlideBar(canvas: Canvas) { + slideBarPaint.style = Paint.Style.STROKE + slideBarPaint.strokeWidth = slideBarStrokeWidth + slideBarPaint.color = colorSurfaceVariant + + activeSlideBarPaint.style = Paint.Style.STROKE + activeSlideBarPaint.strokeWidth = slideBarStrokeWidth + activeSlideBarPaint.color = when (sliderMode) { + RoundSliderState.ACTIVE -> colorPrimary + RoundSliderState.INACTIVE -> colorSurfaceVariant + RoundSliderState.ERROR -> colorError + } + + val oval = RectF() + oval.set( + slideBarPointX - slideBarRadius, + slideBarPointY - slideBarRadius, + slideBarPointX + slideBarRadius, + slideBarPointY + slideBarRadius + ) + + canvas.drawArc(oval, 180F, 180F, false, slideBarPaint) + drawHighlightSlider(canvas) + canvas.drawArc( + oval, + 180F, + 180 - currentRad.toDegree().toFloat(), + false, + activeSlideBarPaint + ) + } + + private fun drawHighlightSlider(canvas: Canvas) { + slideBarPaint.color = Color.parseColor("#FFD7F3") + slideBarPaint.alpha + + val oval = RectF() + oval.set( + slideBarPointX - slideBarRadius, + slideBarPointY - slideBarRadius, + slideBarPointX + slideBarRadius, + slideBarPointY + slideBarRadius + ) + + canvas.drawArc(oval, startDegree, endDegree - startDegree, false, slideBarPaint) + } + + private fun drawSliderBarTick(canvas: Canvas) { + axisCirclePaint.color = colorOnPrimaryContainer + + // stepSize에 맞게 틱 그리기 + for (i in sliderValueStepSize until maxPercentValue step sliderValueStepSize) { + val rad = valueToDegree(i).toRadian() + val tickPointX = slideBarPointX + cos(rad).toFloat() * slideBarRadius + val tickPointY = slideBarPointY - sin(rad).toFloat() * slideBarRadius + + canvas.drawCircle(tickPointX, tickPointY, Dp(2F).toPx(context).value, axisCirclePaint) + } + } + + private fun drawController(canvas: Canvas) { + controllerPaint.style = Paint.Style.FILL + controllerPaint.color = when (sliderMode) { + RoundSliderState.ACTIVE -> colorPrimary + RoundSliderState.INACTIVE -> colorSurfaceVariant + RoundSliderState.ERROR -> colorError + } + + canvas.drawCircle(controllerPointX, controllerPointY, controllerRadius, controllerPaint) + } + + private fun drawSlideValueText(canvas: Canvas) { + sliderValuePaint.textSize = textValueSize.toPx(context).value + sliderValuePaint.color = when (sliderMode) { + RoundSliderState.ACTIVE -> colorOnPrimaryContainer + RoundSliderState.INACTIVE -> colorSurfaceVariant + RoundSliderState.ERROR -> colorError + } + val bounds = Rect() + val textString = "$sliderValue%" + sliderValuePaint.getTextBounds(textString, 0, textString.length, bounds) + val textWidth = bounds.width() + val textHeight = bounds.height() + + canvas.drawText( + textString, + slideBarPointX - textWidth / 2, + slideBarPointY - slideBarRadius / 2 + textHeight, + sliderValuePaint + ) + } + + private fun isTouchOnSlideBar(x: Float, y: Float): Boolean { + val deltaX = abs(slideBarPointX - x) + val deltaY = abs(slideBarPointY - y) + + val distance = sqrt(deltaX.pow(2) + deltaY.pow(2)) + + val minDistance = slideBarRadius - slideBarStrokeWidth - touchSizeMargin + val maxDistance = slideBarRadius + slideBarStrokeWidth + touchSizeMargin + + return distance in minDistance..maxDistance + } + + private fun updateControllerPointWithRadian(rad: Double) { + controllerPointX = slideBarPointX + cos(rad).toFloat() * slideBarRadius + controllerPointY = slideBarPointY - sin(rad).toFloat() * slideBarRadius + } + + private fun updateValueWithRadian(rad: Double) { + sliderValue = degreeToValue(rad.toDegree()) + } + + private fun calculateRadianWithPoint(x: Float, y: Float): Double { + val arcTanRadian = atan((x - slideBarPointX) / (y - slideBarPointY)) + return (pi / 2) + arcTanRadian + } + + private fun updateRadianWithTouchPoint(x: Float, y: Float) { + val rad = calculateRadianWithPoint(x, y) + currentRad = findCloseStepValueRadian(degreeToValue(rad.toDegree())) + } + + private fun isHeightEnough() = height >= width / 2 + slideBarMargin + + private fun degreeToValue(degree: Double) = + (((180F - degree) * maxPercentValue / 180F)).roundToInt() + + private fun valueToDegree(value: Int) = + (180 - ((180 * value.toDouble()) / maxPercentValue)) + + private fun findCloseStepValueRadian(currentValue: Int): Double { + var minDifference = maxPercentValue + var prevDifference = maxPercentValue + + for (value in 0..maxPercentValue step sliderValueStepSize) { + val difference = abs(currentValue - value) + + if (difference > prevDifference) { // difference 증가할 경우 이전 radian 반환 + return valueToDegree(value - sliderValueStepSize).toRadian() + } + + if (difference < minDifference) { + minDifference = difference + } + prevDifference = difference + } + return 0.0 + } + + private fun handleValueChangeEvent(value: Int) { + sliderValueChangeListener?.invoke(value) + } +} diff --git a/android/app/src/main/java/app/priceguard/ui/slider/RoundSliderState.kt b/android/app/src/main/java/app/priceguard/ui/slider/RoundSliderState.kt new file mode 100644 index 0000000..1d18dc5 --- /dev/null +++ b/android/app/src/main/java/app/priceguard/ui/slider/RoundSliderState.kt @@ -0,0 +1,7 @@ +package app.priceguard.ui.slider + +enum class RoundSliderState { + ACTIVE, + INACTIVE, + ERROR +} diff --git a/android/app/src/main/java/app/priceguard/ui/slider/SliderValueChangeListener.kt b/android/app/src/main/java/app/priceguard/ui/slider/SliderValueChangeListener.kt new file mode 100644 index 0000000..a41ba6f --- /dev/null +++ b/android/app/src/main/java/app/priceguard/ui/slider/SliderValueChangeListener.kt @@ -0,0 +1,3 @@ +package app.priceguard.ui.slider + +internal typealias SliderValueChangeListener = (value: Int) -> Unit diff --git a/android/app/src/main/java/app/priceguard/ui/splash/SplashScreenActivity.kt b/android/app/src/main/java/app/priceguard/ui/splash/SplashScreenActivity.kt index 15d756a..507bd4e 100644 --- a/android/app/src/main/java/app/priceguard/ui/splash/SplashScreenActivity.kt +++ b/android/app/src/main/java/app/priceguard/ui/splash/SplashScreenActivity.kt @@ -49,9 +49,10 @@ class SplashScreenActivity : AppCompatActivity() { splashViewModel.event.collect { event -> when (event) { SplashScreenViewModel.SplashEvent.OpenHome -> { + val productShop = intent.getStringExtra("shop") val productCode = intent.getStringExtra("productCode") - if (productCode != null) { - receivePushAlarm() + if (productShop != null && productCode != null) { + receivePushAlarm(productShop, productCode) } else { launchActivityAndExit( this@SplashScreenActivity, @@ -77,9 +78,9 @@ class SplashScreenActivity : AppCompatActivity() { content.viewTreeObserver.addOnPreDrawListener(onPreDrawListener) } - private fun receivePushAlarm() { - val productCode = intent.getStringExtra("productCode") ?: return + private fun receivePushAlarm(productShop: String, productCode: String) { val intent = Intent(this, DetailActivity::class.java) + intent.putExtra("productShop", productShop) intent.putExtra("productCode", productCode) intent.putExtra("directed", true) startActivity(intent) diff --git a/android/app/src/main/java/app/priceguard/ui/util/Dialog.kt b/android/app/src/main/java/app/priceguard/ui/util/Dialog.kt index b1f3e18..5bf53dc 100644 --- a/android/app/src/main/java/app/priceguard/ui/util/Dialog.kt +++ b/android/app/src/main/java/app/priceguard/ui/util/Dialog.kt @@ -27,7 +27,7 @@ fun Fragment.showDialogWithAction( message: String, action: DialogConfirmAction = DialogConfirmAction.NOTHING ) { - val tag = "confirm_dialog_fragment_from_activity" + val tag = "confirm_dialog_fragment_from_fragment" if (requireActivity().supportFragmentManager.findFragmentByTag(tag) != null) return val dialogFragment = ConfirmDialogFragment() @@ -39,12 +39,29 @@ fun Fragment.showDialogWithAction( dialogFragment.show(requireActivity().supportFragmentManager, tag) } +fun AppCompatActivity.showDialogWithAction( + title: String, + message: String, + action: DialogConfirmAction = DialogConfirmAction.NOTHING +) { + val tag = "confirm_dialog_fragment_with_action_from_activity" + if (supportFragmentManager.findFragmentByTag(tag) != null) return + + val dialogFragment = ConfirmDialogFragment() + val bundle = Bundle() + bundle.putString("title", title) + bundle.putString("message", message) + bundle.putString("actionString", action.name) + dialogFragment.arguments = bundle + dialogFragment.show(supportFragmentManager, tag) +} + fun AppCompatActivity.showDialogWithLogout() { - val tag = "error_dialog_fragment_from_fragment" + val tag = "error_dialog_fragment_from_activity" if (supportFragmentManager.findFragmentByTag(tag) != null) return val dialogFragment = ErrorDialogFragment() - dialogFragment.show(supportFragmentManager, "error_dialog_fragment_from_activity") + dialogFragment.show(supportFragmentManager, tag) } fun Fragment.showDialogWithLogout() { diff --git a/android/app/src/main/java/app/priceguard/ui/util/ImageViewBindingAdapter.kt b/android/app/src/main/java/app/priceguard/ui/util/ImageViewBindingAdapter.kt new file mode 100644 index 0000000..d077905 --- /dev/null +++ b/android/app/src/main/java/app/priceguard/ui/util/ImageViewBindingAdapter.kt @@ -0,0 +1,17 @@ +package app.priceguard.ui.util + +import android.widget.ImageView +import androidx.databinding.BindingAdapter +import com.bumptech.glide.Glide +import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions + +@BindingAdapter("imageFromUrl") +fun bindImageFromUrl(view: ImageView, imageUrl: String?) { + if (!imageUrl.isNullOrEmpty()) { + Glide.with(view.context) + .load(imageUrl) + .centerCrop() + .transition(DrawableTransitionOptions.withCrossFade()) + .into(view) + } +} diff --git a/android/app/src/main/java/app/priceguard/ui/util/TextInputLayoutBindingAdapter.kt b/android/app/src/main/java/app/priceguard/ui/util/TextInputLayoutBindingAdapter.kt new file mode 100644 index 0000000..23b9ef9 --- /dev/null +++ b/android/app/src/main/java/app/priceguard/ui/util/TextInputLayoutBindingAdapter.kt @@ -0,0 +1,9 @@ +package app.priceguard.ui.util + +import androidx.databinding.BindingAdapter +import com.google.android.material.textfield.TextInputLayout + +@BindingAdapter("textInputLayoutError") +fun TextInputLayout.bindTextInputLayoutError(errorMessage: String?) { + this.error = errorMessage +} diff --git a/android/app/src/main/java/app/priceguard/ui/util/ThrottleClickListener.kt b/android/app/src/main/java/app/priceguard/ui/util/ThrottleClickListener.kt new file mode 100644 index 0000000..bc8b162 --- /dev/null +++ b/android/app/src/main/java/app/priceguard/ui/util/ThrottleClickListener.kt @@ -0,0 +1,36 @@ +package app.priceguard.ui.util + +import android.view.View +import android.widget.Button +import androidx.databinding.BindingAdapter + +class OnThrottleClickListener( + private val onClickListener: View.OnClickListener, + private val interval: Long = 500L +) : View.OnClickListener { + + private var clickable = true + + override fun onClick(v: View?) { + if (clickable) { + clickable = false + v?.run { + postDelayed({ + clickable = true + }, interval) + onClickListener.onClick(v) + } + } + } +} + +fun View.onThrottleClick(action: (v: View) -> Unit) { + val listener = View.OnClickListener { action(it) } + setOnClickListener(OnThrottleClickListener(listener)) +} + +@BindingAdapter("onThrottleClick") +fun Button.bindThrottleClick(action: () -> Unit) { + val listener = View.OnClickListener { action() } + setOnClickListener(OnThrottleClickListener(listener)) +} diff --git a/android/app/src/main/java/app/priceguard/ui/util/ViewExtensions.kt b/android/app/src/main/java/app/priceguard/ui/util/ViewExtensions.kt new file mode 100644 index 0000000..4083979 --- /dev/null +++ b/android/app/src/main/java/app/priceguard/ui/util/ViewExtensions.kt @@ -0,0 +1,16 @@ +package app.priceguard.ui.util + +import android.util.TypedValue +import android.widget.Button + +fun Button.setTextColorWithEnabled() { + val value = TypedValue() + + val color = if (!isEnabled) { + com.google.android.material.R.attr.colorOutline + } else { + com.google.android.material.R.attr.colorPrimary + } + context?.theme?.resolveAttribute(color, value, true) + setTextColor(value.data) +} diff --git a/android/app/src/main/java/app/priceguard/ui/util/drawable/ProgressIndicator.kt b/android/app/src/main/java/app/priceguard/ui/util/drawable/ProgressIndicator.kt index af168d8..5a1dc86 100644 --- a/android/app/src/main/java/app/priceguard/ui/util/drawable/ProgressIndicator.kt +++ b/android/app/src/main/java/app/priceguard/ui/util/drawable/ProgressIndicator.kt @@ -5,12 +5,12 @@ import app.priceguard.R import com.google.android.material.progressindicator.CircularProgressIndicatorSpec import com.google.android.material.progressindicator.IndeterminateDrawable -fun getCircularProgressIndicatorDrawable(context: Context): IndeterminateDrawable { +fun getCircularProgressIndicatorDrawable(context: Context, style: Int = R.style.Theme_PriceGuard_CircularProgressIndicator): IndeterminateDrawable { val spec = CircularProgressIndicatorSpec( context, null, 0, - R.style.Theme_PriceGuard_CircularProgressIndicator + style ) return IndeterminateDrawable.createCircularDrawable(context, spec) diff --git a/android/app/src/main/res/drawable/ic_close_red.xml b/android/app/src/main/res/drawable/ic_close_red.xml new file mode 100644 index 0000000..ea5e3a4 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_close_red.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_naver_logo.xml b/android/app/src/main/res/drawable/ic_naver_logo.xml new file mode 100644 index 0000000..b39eb8a --- /dev/null +++ b/android/app/src/main/res/drawable/ic_naver_logo.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_remove.xml b/android/app/src/main/res/drawable/ic_remove.xml new file mode 100644 index 0000000..46c12d3 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_remove.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/app/src/main/res/layout/activity_delete_account.xml b/android/app/src/main/res/layout/activity_delete_account.xml new file mode 100644 index 0000000..33003a5 --- /dev/null +++ b/android/app/src/main/res/layout/activity_delete_account.xml @@ -0,0 +1,151 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +