diff --git a/.github/workflows/firebase-deploy.yml b/.github/workflows/firebase-deploy.yml
index 85d055b..12392fb 100644
--- a/.github/workflows/firebase-deploy.yml
+++ b/.github/workflows/firebase-deploy.yml
@@ -4,41 +4,42 @@ on:
push:
branches:
- release # Only in release branch
-
+
jobs:
deploy:
- runs-on: ubuntu-latest
+ runs-on: macos-14
steps:
- - uses: actions/checkout@v4
- with:
- fetch-depth: 0
- submodules: recursive
- - name: Setup JDK
- uses: actions/setup-java@v3
- with:
- distribution: 'oracle'
- java-version: '17'
- cache: 'gradle'
- - name: Generate Keystore
- env:
- KEYSTORE_B64: ${{ secrets.APP_KEYSTORE }}
- run: |
- echo $KEYSTORE_B64 > keystore_b64.txt
- base64 --decode --ignore-garbage keystore_b64.txt > keystore.jks
- working-directory: ./android/app
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ submodules: recursive
+ - name: Setup JDK
+ uses: actions/setup-java@v3
+ with:
+ distribution: "oracle"
+ java-version: "17"
+ cache: "gradle"
+ - name: Generate Keystore
+ env:
+ KEYSTORE_B64: ${{ secrets.APP_KEYSTORE }}
+ run: |
+ echo $KEYSTORE_B64 \
+ | sed 's/[^A-Za-z0-9+/=]//g' \
+ | base64 -d > keystore.jks
+ working-directory: ./android/app
+
+ - name: Build Release APK
+ env:
+ SIGNING_KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
+ SIGNING_PASSWORD: ${{ secrets.KEY_PASSWORD }}
+ run: ./gradlew assembleRelease
+ working-directory: ./android
- - name: Build Release APK
- env:
- SIGNING_KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
- SIGNING_PASSWORD: ${{ secrets.KEY_PASSWORD }}
- run: ./gradlew assembleRelease
- working-directory: ./android
-
- - name: Upload to Firebase App Distribution
- uses: wzieba/Firebase-Distribution-Github-Action@v1
- with:
- appId: ${{ secrets.FIREBASE_APP_ID }}
- serviceCredentialsFileContent: ${{ secrets.FIREBASE_APP_DISTRIBUTION }}
- groups: tester
- releaseNotesFile: android/release_notes.txt
- file: android/app/build/outputs/apk/release/app-release.apk
+ - name: Upload to Firebase App Distribution
+ uses: wzieba/Firebase-Distribution-Github-Action@v1
+ with:
+ appId: ${{ secrets.FIREBASE_APP_ID }}
+ serviceCredentialsFileContent: ${{ secrets.FIREBASE_APP_DISTRIBUTION }}
+ groups: tester
+ releaseNotesFile: android/release_notes.txt
+ 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)를 통해 받을 수 있습니다.
+어플리케이션은 아래 버튼을 클릭하여 받을 수 있습니다.
+
+
+
+
## 🥅 기술적 도전
@@ -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..7902253 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")
@@ -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/ImageViewAdapter.kt b/android/app/src/main/java/app/priceguard/ui/util/ImageViewBindingAdapter.kt
similarity index 100%
rename from android/app/src/main/java/app/priceguard/ui/util/ImageViewAdapter.kt
rename to android/app/src/main/java/app/priceguard/ui/util/ImageViewBindingAdapter.kt
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/layout/activity_detail.xml b/android/app/src/main/res/layout/activity_detail.xml
index f38f68a..720bdaf 100644
--- a/android/app/src/main/res/layout/activity_detail.xml
+++ b/android/app/src/main/res/layout/activity_detail.xml
@@ -85,7 +85,6 @@
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:contentDescription="@string/shopping_mall_logo"
- android:src="@drawable/ic_11st_logo"
app:layout_constraintStart_toEndOf="@+id/gl_vertical_start_nested"
app:layout_constraintTop_toBottomOf="@id/iv_detail_product" />
@@ -122,8 +121,8 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
- android:contentDescription="@{@string/current_price_info(@string/price(viewModel.state.formattedPrice))}"
- android:text="@{@string/price(viewModel.state.formattedPrice)}"
+ android:contentDescription="@{viewModel.state.isSoldOut ? @string/sold_out : @string/current_price_info(@string/price(viewModel.state.formattedPrice))}"
+ android:text="@{viewModel.state.isSoldOut ? @string/sold_out : @string/price(viewModel.state.formattedPrice)}"
android:textAppearance="?attr/textAppearanceTitleMedium"
app:layout_constraintStart_toEndOf="@id/gl_vertical_start_nested"
app:layout_constraintTop_toBottomOf="@id/tv_detail_product_name"
diff --git a/android/app/src/main/res/layout/activity_find_password.xml b/android/app/src/main/res/layout/activity_find_password.xml
new file mode 100644
index 0000000..68a99be
--- /dev/null
+++ b/android/app/src/main/res/layout/activity_find_password.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/layout/activity_login.xml b/android/app/src/main/res/layout/activity_login.xml
index ee1b040..480e786 100644
--- a/android/app/src/main/res/layout/activity_login.xml
+++ b/android/app/src/main/res/layout/activity_login.xml
@@ -134,6 +134,15 @@
app:layout_constraintStart_toEndOf="@+id/gl_vertical_start"
app:layout_constraintTop_toBottomOf="@+id/til_login_password" />
+
+