From 15d6beecfc7b73eedd515d9951acb34787d2272e Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Thu, 2 Apr 2026 19:30:33 +0900 Subject: [PATCH 01/12] =?UTF-8?q?feat/#83:=20Firebase=20Remote=20Config=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- core/data/build.gradle.kts | 3 +++ gradle/libs.versions.toml | 1 + 2 files changed, 4 insertions(+) diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index 68b527a0..cba7150e 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -18,6 +18,9 @@ dependencies { implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.android) + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.config) + implementation(libs.hilt.android) ksp(libs.hilt.compiler) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ca320995..820336d0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -123,6 +123,7 @@ androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging" } firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics" } +firebase-config = { group = "com.google.firebase", name = "firebase-config" } lottie-compose = { group = "com.airbnb.android", name = "lottie-compose", version.ref = "lottie" } From 66df47c665f5a59a384076c0174dd7d1ed8c7c57 Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Thu, 2 Apr 2026 19:31:27 +0900 Subject: [PATCH 02/12] =?UTF-8?q?feat/#83:=20=EC=95=B1=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EB=AA=A8=EB=8D=B8=20=EB=B0=8F=20Repository=20=EC=9D=B8?= =?UTF-8?q?=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../buyornot/domain/model/AppUpdateInfo.kt | 13 +++++++++++++ .../domain/repository/AppUpdateRepository.kt | 7 +++++++ 2 files changed, 20 insertions(+) create mode 100644 domain/src/main/java/com/sseotdabwa/buyornot/domain/model/AppUpdateInfo.kt create mode 100644 domain/src/main/java/com/sseotdabwa/buyornot/domain/repository/AppUpdateRepository.kt diff --git a/domain/src/main/java/com/sseotdabwa/buyornot/domain/model/AppUpdateInfo.kt b/domain/src/main/java/com/sseotdabwa/buyornot/domain/model/AppUpdateInfo.kt new file mode 100644 index 00000000..51a5d112 --- /dev/null +++ b/domain/src/main/java/com/sseotdabwa/buyornot/domain/model/AppUpdateInfo.kt @@ -0,0 +1,13 @@ +package com.sseotdabwa.buyornot.domain.model + +data class AppUpdateInfo( + val latestVersion: Int, + val minimumVersion: Int, + val updateStrategy: UpdateStrategy, +) + +enum class UpdateStrategy { + NONE, + SOFT, + FORCE, +} diff --git a/domain/src/main/java/com/sseotdabwa/buyornot/domain/repository/AppUpdateRepository.kt b/domain/src/main/java/com/sseotdabwa/buyornot/domain/repository/AppUpdateRepository.kt new file mode 100644 index 00000000..a2386f1b --- /dev/null +++ b/domain/src/main/java/com/sseotdabwa/buyornot/domain/repository/AppUpdateRepository.kt @@ -0,0 +1,7 @@ +package com.sseotdabwa.buyornot.domain.repository + +import com.sseotdabwa.buyornot.domain.model.AppUpdateInfo + +interface AppUpdateRepository { + suspend fun getAppUpdateInfo(): AppUpdateInfo +} From 24d5e2148480254cb65a17827ff899bc8b72054d Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Thu, 2 Apr 2026 19:32:34 +0900 Subject: [PATCH 03/12] =?UTF-8?q?feat/#83:=20Remote=20Config=20DataSource?= =?UTF-8?q?=20=EB=B0=8F=20AppUpdateRepository=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- core/data/build.gradle.kts | 1 + .../data/datasource/RemoteConfigDataSource.kt | 7 ++ .../datasource/RemoteConfigDataSourceImpl.kt | 57 ++++++++++++++++ .../buyornot/core/data/di/DataModule.kt | 5 ++ .../core/data/di/RemoteConfigModule.kt | 15 +++++ .../repository/AppUpdateRepositoryImpl.kt | 14 ++++ docs/PR-bugfix-65-qa-2.md | 66 +++++++++++++++++++ gradle/libs.versions.toml | 1 + 8 files changed, 166 insertions(+) create mode 100644 core/data/src/main/java/com/sseotdabwa/buyornot/core/data/datasource/RemoteConfigDataSource.kt create mode 100644 core/data/src/main/java/com/sseotdabwa/buyornot/core/data/datasource/RemoteConfigDataSourceImpl.kt create mode 100644 core/data/src/main/java/com/sseotdabwa/buyornot/core/data/di/RemoteConfigModule.kt create mode 100644 core/data/src/main/java/com/sseotdabwa/buyornot/core/data/repository/AppUpdateRepositoryImpl.kt create mode 100644 docs/PR-bugfix-65-qa-2.md diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index cba7150e..7b3919fc 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -17,6 +17,7 @@ dependencies { implementation(libs.okhttp) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.coroutines.play.services) implementation(platform(libs.firebase.bom)) implementation(libs.firebase.config) diff --git a/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/datasource/RemoteConfigDataSource.kt b/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/datasource/RemoteConfigDataSource.kt new file mode 100644 index 00000000..7e2b7bcd --- /dev/null +++ b/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/datasource/RemoteConfigDataSource.kt @@ -0,0 +1,7 @@ +package com.sseotdabwa.buyornot.core.data.datasource + +import com.sseotdabwa.buyornot.domain.model.AppUpdateInfo + +interface RemoteConfigDataSource { + suspend fun fetchAppUpdateConfig(): AppUpdateInfo +} diff --git a/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/datasource/RemoteConfigDataSourceImpl.kt b/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/datasource/RemoteConfigDataSourceImpl.kt new file mode 100644 index 00000000..6fc3fb39 --- /dev/null +++ b/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/datasource/RemoteConfigDataSourceImpl.kt @@ -0,0 +1,57 @@ +package com.sseotdabwa.buyornot.core.data.datasource + +import com.google.firebase.remoteconfig.FirebaseRemoteConfig +import com.google.firebase.remoteconfig.FirebaseRemoteConfigSettings +import com.sseotdabwa.buyornot.domain.model.AppUpdateInfo +import com.sseotdabwa.buyornot.domain.model.UpdateStrategy +import kotlinx.coroutines.tasks.await +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class RemoteConfigDataSourceImpl + @Inject + constructor() : RemoteConfigDataSource { + private val remoteConfig: FirebaseRemoteConfig = FirebaseRemoteConfig.getInstance() + + init { + val settings = + FirebaseRemoteConfigSettings + .Builder() + .setMinimumFetchIntervalInSeconds(FETCH_INTERVAL_SECONDS) + .build() + + remoteConfig.setConfigSettingsAsync(settings) + remoteConfig.setDefaultsAsync( + mapOf( + KEY_LATEST_VERSION to DEFAULT_VERSION, + KEY_MINIMUM_VERSION to DEFAULT_VERSION, + KEY_UPDATE_STRATEGY to UpdateStrategy.NONE.name, + ), + ) + } + + override suspend fun fetchAppUpdateConfig(): AppUpdateInfo { + remoteConfig.fetchAndActivate().await() + + val latestVersion = remoteConfig.getLong(KEY_LATEST_VERSION).toInt() + val minimumVersion = remoteConfig.getLong(KEY_MINIMUM_VERSION).toInt() + val strategyRaw = remoteConfig.getString(KEY_UPDATE_STRATEGY) + val updateStrategy = + runCatching { UpdateStrategy.valueOf(strategyRaw) }.getOrDefault(UpdateStrategy.NONE) + + return AppUpdateInfo( + latestVersion = latestVersion, + minimumVersion = minimumVersion, + updateStrategy = updateStrategy, + ) + } + + companion object { + private const val KEY_LATEST_VERSION = "android_latest_version" + private const val KEY_MINIMUM_VERSION = "android_minimum_version" + private const val KEY_UPDATE_STRATEGY = "android_update_strategy" + private const val DEFAULT_VERSION = 1L + private const val FETCH_INTERVAL_SECONDS = 3600L + } + } diff --git a/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/di/DataModule.kt b/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/di/DataModule.kt index f536bf09..c2a66c53 100644 --- a/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/di/DataModule.kt +++ b/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/di/DataModule.kt @@ -1,12 +1,14 @@ package com.sseotdabwa.buyornot.core.data.di import com.sseotdabwa.buyornot.core.data.repository.AppPreferencesRepositoryImpl +import com.sseotdabwa.buyornot.core.data.repository.AppUpdateRepositoryImpl import com.sseotdabwa.buyornot.core.data.repository.AuthRepositoryImpl import com.sseotdabwa.buyornot.core.data.repository.FeedRepositoryImpl import com.sseotdabwa.buyornot.core.data.repository.NotificationRepositoryImpl import com.sseotdabwa.buyornot.core.data.repository.UserPreferencesRepositoryImpl import com.sseotdabwa.buyornot.core.data.repository.UserRepositoryImpl import com.sseotdabwa.buyornot.domain.repository.AppPreferencesRepository +import com.sseotdabwa.buyornot.domain.repository.AppUpdateRepository import com.sseotdabwa.buyornot.domain.repository.AuthRepository import com.sseotdabwa.buyornot.domain.repository.FeedRepository import com.sseotdabwa.buyornot.domain.repository.NotificationRepository @@ -37,4 +39,7 @@ internal abstract class DataModule { @Binds abstract fun bindAppPreferencesRepository(impl: AppPreferencesRepositoryImpl): AppPreferencesRepository + + @Binds + abstract fun bindAppUpdateRepository(impl: AppUpdateRepositoryImpl): AppUpdateRepository } diff --git a/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/di/RemoteConfigModule.kt b/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/di/RemoteConfigModule.kt new file mode 100644 index 00000000..d9e380e1 --- /dev/null +++ b/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/di/RemoteConfigModule.kt @@ -0,0 +1,15 @@ +package com.sseotdabwa.buyornot.core.data.di + +import com.sseotdabwa.buyornot.core.data.datasource.RemoteConfigDataSource +import com.sseotdabwa.buyornot.core.data.datasource.RemoteConfigDataSourceImpl +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class RemoteConfigModule { + @Binds + abstract fun bindRemoteConfigDataSource(impl: RemoteConfigDataSourceImpl): RemoteConfigDataSource +} diff --git a/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/repository/AppUpdateRepositoryImpl.kt b/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/repository/AppUpdateRepositoryImpl.kt new file mode 100644 index 00000000..ec9ec7b4 --- /dev/null +++ b/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/repository/AppUpdateRepositoryImpl.kt @@ -0,0 +1,14 @@ +package com.sseotdabwa.buyornot.core.data.repository + +import com.sseotdabwa.buyornot.core.data.datasource.RemoteConfigDataSource +import com.sseotdabwa.buyornot.domain.model.AppUpdateInfo +import com.sseotdabwa.buyornot.domain.repository.AppUpdateRepository +import javax.inject.Inject + +class AppUpdateRepositoryImpl + @Inject + constructor( + private val remoteConfigDataSource: RemoteConfigDataSource, + ) : AppUpdateRepository { + override suspend fun getAppUpdateInfo(): AppUpdateInfo = remoteConfigDataSource.fetchAppUpdateConfig() + } diff --git a/docs/PR-bugfix-65-qa-2.md b/docs/PR-bugfix-65-qa-2.md new file mode 100644 index 00000000..3c9c6d06 --- /dev/null +++ b/docs/PR-bugfix-65-qa-2.md @@ -0,0 +1,66 @@ +## ๐Ÿ›  Related issue +closed #65 + +์–ด๋–ค ๋ณ€๊ฒฝ์‚ฌํ•ญ์ด ์žˆ์—ˆ๋‚˜์š”? +- [x] ๐Ÿž BugFix Something isn't working +- [ ] ๐ŸŽจ Design Markup & styling +- [ ] ๐Ÿ“ƒ Docs Documentation writing and editing (README.md, etc.) +- [x] โœจ Feature Feature +- [x] ๐Ÿ”จ Refactor Code refactoring +- [ ] โš™๏ธ Setting Development environment setup +- [ ] โœ… Test Test related (Junit, etc.) + +## โœ… CheckPoint +PR์ด ๋‹ค์Œ ์š”๊ตฌ ์‚ฌํ•ญ์„ ์ถฉ์กฑํ•˜๋Š”์ง€ ํ™•์ธํ•˜์„ธ์š”. + +- [x] PR ์ปจ๋ฒค์…˜์— ๋งž๊ฒŒ ์ž‘์„ฑํ–ˆ์Šต๋‹ˆ๋‹ค. (ํ•„์ˆ˜) +- [x] mergeํ•  ๋ธŒ๋žœ์น˜์˜ ์œ„์น˜๋ฅผ ํ™•์ธํ•ด ์ฃผ์„ธ์š”(mainโŒ/developโญ•) (ํ•„์ˆ˜) +- [x] Approve๋œ PR์€ assigner๊ฐ€ ๋จธ์ง€ํ•˜๊ณ , ์ˆ˜์ • ์š”์ฒญ์ด ์˜จ ๊ฒฝ์šฐ ์ˆ˜์ • ํ›„ ๋‹ค์‹œ push๋ฅผ ํ•ฉ๋‹ˆ๋‹ค. (ํ•„์ˆ˜) +- [x] BugFix์˜ ๊ฒฝ์šฐ, ๋ฒ„๊ทธ์˜ ์›์ธ์„ ํŒŒ์•…ํ•˜์˜€์Šต๋‹ˆ๋‹ค. (์„ ํƒ) + +## โœ๏ธ Work Description + +### ๐Ÿž BugFix + +- **๋กœ๊ทธ์ธ ์„ฑ๊ณต ์‹œ ๋ฐฑ์Šคํƒ ๋ฏธ์ œ๊ฑฐ** + - ์›์ธ: `onLoginSuccess`์—์„œ `popUpTo(SPLASH_ROUTE)`๋ฅผ ์‚ฌ์šฉํ–ˆ์œผ๋‚˜, `navigateToLogin()` ํ˜ธ์ถœ ์‹œ์ ์— ์ด๋ฏธ SPLASH_ROUTE๊ฐ€ ๋ฐฑ์Šคํƒ์—์„œ ์ œ๊ฑฐ๋œ ์ƒํƒœ๋ผ AUTH_ROUTE๊ฐ€ ์ž”๋ฅ˜ + - ์ˆ˜์ •: `popUpTo(AUTH_ROUTE) { inclusive = true }`๋กœ ๋ณ€๊ฒฝ (`BuyOrNotNavHost.kt`) + +- **ํˆฌํ‘œ ํ”ผ๋“œ ์ค‘๋ณต ์—…๋กœ๋“œ** + - ์›์ธ: ์—…๋กœ๋“œ ๋ฒ„ํŠผ ํ™œ์„ฑํ™” ์กฐ๊ฑด์— `isLoading` ์ฒดํฌ ๋ˆ„๋ฝ + - ์ˆ˜์ •: `uiState.isLoading` ์ค‘ ์—…๋กœ๋“œ ๋ฒ„ํŠผ ๋น„ํ™œ์„ฑํ™” ๋ฐ ViewModel์—์„œ ์ด์ค‘ ํ˜ธ์ถœ ๋ฐฉ์–ด (`UploadScreen.kt`, `UploadViewModel.kt`) + +- **ํšŒ์›ํƒˆํ‡ด ๋‹ค์ด์–ผ๋กœ๊ทธ ๋ฏธ๋…ธ์ถœ** + - ์›์ธ: `WithdrawalScreen`์—์„œ `onShowWithdrawalDialog` / `onDismissWithdrawalDialog` ์ฝœ๋ฐฑ์ด ์ „๋‹ฌ๋˜์ง€ ์•Š์Œ + - ์ˆ˜์ •: ๋ˆ„๋ฝ๋œ ์ฝœ๋ฐฑ ์—ฐ๊ฒฐ (`WithdrawalScreen.kt`) + +- **ํˆฌํ‘œ ๋‚™๊ด€์  ์—…๋ฐ์ดํŠธ ์‹œ ๋“ํ‘œ์œจ ๋…ธ์ถœ** + - ์›์ธ: ๋‚™๊ด€์  ์—…๋ฐ์ดํŠธ๋กœ ํˆฌํ‘œ ์ˆ˜๊ฐ€ ์ž„์‹œ ๋ณ€๊ฒฝ๋  ๋•Œ ํˆฌํ‘œ ๊ฒฐ๊ณผ(๋“ํ‘œ์œจ ๋ฐ”)๊ฐ€ ๋…ธ์ถœ๋จ + - ์ˆ˜์ •: API ์‘๋‹ต ํ™•์ • ์ „๊นŒ์ง€ UI์— ๊ฒฐ๊ณผ๋ฅผ ๋ฐ˜์˜ํ•˜์ง€ ์•Š๋„๋ก ์ฒ˜๋ฆฌ (`HomeViewModel.kt`) + +### โœจ Feature + +- **์Šค๋‚ต๋ฐ” ์ตœ์‹  ๋ฉ”์‹œ์ง€ ์ฆ‰์‹œ ๊ต์ฒด** + - ๊ธฐ์กด: `Mutex`๋กœ ์ด์ „ ์Šค๋‚ต๋ฐ” ์ข…๋ฃŒ ๋Œ€๊ธฐ ํ›„ ๋‹ค์Œ ๋ฉ”์‹œ์ง€ ํ‘œ์‹œ + - ๋ณ€๊ฒฝ: `currentSnackbarData?.dismiss()` ํ˜ธ์ถœ๋กœ ํ˜„์žฌ ์Šค๋‚ต๋ฐ”๋ฅผ ์ฆ‰์‹œ ๋‹ซ๊ณ  ์ƒˆ ๋ฉ”์‹œ์ง€ ๋…ธ์ถœ + - ์• ๋‹ˆ๋ฉ”์ด์…˜๋„ ์ผ€์ด์Šค ๋ถ„๋ฆฌ: ๊ต์ฒด(push down) / ์ตœ์ดˆ ๋“ฑ์žฅ(slide up) / dismiss(slide down) (`SnackBar.kt`) + +### ๐Ÿ”จ Refactor + +- **HomeScreen ํ—ค๋” ๊ตฌํ˜„ ๋‹จ์ˆœํ™”** + - ๊ธฐ์กด: `SubcomposeLayout`์œผ๋กœ TopBarยทTab ๋†’์ด๋ฅผ ๋ณ„๋„ ์ธก์ • โ†’ `NestedScrollConnection` + `offset`์œผ๋กœ ์ˆ˜๋™ ์ œ์–ด + - ๋ณ€๊ฒฝ: TopBar๋ฅผ LazyColumn์˜ ์ผ๋ฐ˜ `item`์œผ๋กœ, Tab์„ `stickyHeader`๋กœ ๋ฐฐ์น˜ + - ์ œ๊ฑฐ: `SubcomposeLayout`, `HomeHeader` composable, `NestedScrollConnection`, `topBarHeightPx`/`tabHeightPx` state (`HomeScreen.kt`) + +## ๐Ÿ˜… Uncompleted Tasks +- N/A + +## ๐Ÿ“ข To Reviewers + +- ์Šค๋‚ต๋ฐ” mutex ์ œ๊ฑฐ ํ›„ ๋น ๋ฅด๊ฒŒ ์—ฐ์† ๋ฉ”์‹œ์ง€๊ฐ€ ์˜ค๋Š” ์ผ€์ด์Šค์—์„œ `SnackbarHostState` ๋‚ด๋ถ€ mutex๊ฐ€ race condition ์—†์ด ์ฒ˜๋ฆฌํ•˜๋Š”์ง€ ํ™•์ธ ๋ถ€ํƒ๋“œ๋ฆฝ๋‹ˆ๋‹ค. +- HomeScreen ๋ฆฌํŒฉํ† ๋ง์—์„œ `stickyHeader`๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ์Šคํฌ๋กค ์‹œ TopBar๋Š” ์˜ฌ๋ผ๊ฐ€๊ณ  Tab๋งŒ ๊ณ ์ •๋˜๋Š” ๋™์ž‘์ด LazyColumn ๊ธฐ๋ณธ ๋™์ž‘์œผ๋กœ ์ž์—ฐ์Šค๋Ÿฝ๊ฒŒ ์ง€์›๋ฉ๋‹ˆ๋‹ค. + +## ๐Ÿ“ƒ RCA ๋ฃฐ +- R: ๊ผญ ๋ฐ˜์˜ํ•ด ์ฃผ์„ธ์š”. ์ ๊ทน์ ์œผ๋กœ ๊ณ ๋ คํ•ด ์ฃผ์„ธ์š”. (Request changes) +- C: ์›ฌ๋งŒํ•˜๋ฉด ๋ฐ˜์˜ํ•ด ์ฃผ์„ธ์š”. (Comment) +- A: ๋ฐ˜์˜ํ•ด๋„ ์ข‹๊ณ  ๋„˜์–ด๊ฐ€๋„ ์ข‹์Šต๋‹ˆ๋‹ค. ๊ทธ๋ƒฅ ์‚ฌ์†Œํ•œ ์˜๊ฒฌ์ž…๋‹ˆ๋‹ค. (Approve) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 820336d0..dfd88eb5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -96,6 +96,7 @@ googleid = { group = "com.google.android.libraries.identity.googleid", name = "g kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" } kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-coroutines-guava = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-guava", version.ref = "coroutines" } +kotlinx-coroutines-play-services = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-play-services", version.ref = "coroutines" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } # Lifecycle From 49a1da1d3038a3c258df36455de4779c80fb6510 Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Thu, 2 Apr 2026 19:33:41 +0900 Subject: [PATCH 04/12] =?UTF-8?q?feat/#83:=20=EC=86=8C=ED=94=84=ED=8A=B8?= =?UTF-8?q?=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EB=85=B8=EC=B6=9C=20?= =?UTF-8?q?=EB=B9=88=EB=8F=84=20=EC=A0=9C=ED=95=9C=EC=9D=84=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20DataStore=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../data/repository/AppPreferencesRepositoryImpl.kt | 7 +++++++ .../core/datastore/AppPreferencesDataSource.kt | 10 ++++++++++ .../core/datastore/AppPreferencesDataSourceImpl.kt | 13 +++++++++++++ .../domain/repository/AppPreferencesRepository.kt | 10 ++++++++++ 4 files changed, 40 insertions(+) diff --git a/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/repository/AppPreferencesRepositoryImpl.kt b/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/repository/AppPreferencesRepositoryImpl.kt index 3f2de7bb..e6fcdded 100644 --- a/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/repository/AppPreferencesRepositoryImpl.kt +++ b/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/repository/AppPreferencesRepositoryImpl.kt @@ -18,6 +18,9 @@ class AppPreferencesRepositoryImpl @Inject constructor( override val isFirstRun: Flow = appPreferencesDataSource.isFirstRun + override val lastSoftUpdateShownTime: Flow = + appPreferencesDataSource.lastSoftUpdateShownTime + override suspend fun updateNotificationPermissionRequested(requested: Boolean) { appPreferencesDataSource.updateNotificationPermissionRequested(requested) } @@ -25,4 +28,8 @@ class AppPreferencesRepositoryImpl @Inject constructor( override suspend fun updateIsFirstRun(isFirstRun: Boolean) { appPreferencesDataSource.updateIsFirstRun(isFirstRun) } + + override suspend fun updateLastSoftUpdateShownTime(timeMillis: Long) { + appPreferencesDataSource.updateLastSoftUpdateShownTime(timeMillis) + } } diff --git a/core/datastore/src/main/java/com/sseotdabwa/buyornot/core/datastore/AppPreferencesDataSource.kt b/core/datastore/src/main/java/com/sseotdabwa/buyornot/core/datastore/AppPreferencesDataSource.kt index 71d72c13..f4b66df2 100644 --- a/core/datastore/src/main/java/com/sseotdabwa/buyornot/core/datastore/AppPreferencesDataSource.kt +++ b/core/datastore/src/main/java/com/sseotdabwa/buyornot/core/datastore/AppPreferencesDataSource.kt @@ -23,6 +23,11 @@ interface AppPreferencesDataSource { */ val isFirstRun: Flow + /** + * ์†Œํ”„ํŠธ ์—…๋ฐ์ดํŠธ ๋‹ค์ด์–ผ๋กœ๊ทธ ๋งˆ์ง€๋ง‰ ๋…ธ์ถœ ์‹œ๊ฐ (epoch millis)์„ Flow๋กœ ์ œ๊ณต + */ + val lastSoftUpdateShownTime: Flow + /** * ์•Œ๋ฆผ ๊ถŒํ•œ ์š”์ฒญ ์ด๋ ฅ ์—…๋ฐ์ดํŠธ */ @@ -32,4 +37,9 @@ interface AppPreferencesDataSource { * ์ตœ์ดˆ ์‹คํ–‰ ์—ฌ๋ถ€ ์—…๋ฐ์ดํŠธ */ suspend fun updateIsFirstRun(isFirstRun: Boolean) + + /** + * ์†Œํ”„ํŠธ ์—…๋ฐ์ดํŠธ ๋‹ค์ด์–ผ๋กœ๊ทธ ๋งˆ์ง€๋ง‰ ๋…ธ์ถœ ์‹œ๊ฐ ์—…๋ฐ์ดํŠธ + */ + suspend fun updateLastSoftUpdateShownTime(timeMillis: Long) } diff --git a/core/datastore/src/main/java/com/sseotdabwa/buyornot/core/datastore/AppPreferencesDataSourceImpl.kt b/core/datastore/src/main/java/com/sseotdabwa/buyornot/core/datastore/AppPreferencesDataSourceImpl.kt index 0b04c0d4..754c9178 100644 --- a/core/datastore/src/main/java/com/sseotdabwa/buyornot/core/datastore/AppPreferencesDataSourceImpl.kt +++ b/core/datastore/src/main/java/com/sseotdabwa/buyornot/core/datastore/AppPreferencesDataSourceImpl.kt @@ -3,6 +3,7 @@ package com.sseotdabwa.buyornot.core.datastore import android.content.Context import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.preferencesDataStore import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.Flow @@ -26,6 +27,7 @@ class AppPreferencesDataSourceImpl private object Keys { val HAS_REQUESTED_NOTIFICATION_PERMISSION = booleanPreferencesKey("has_requested_notification_permission") val IS_FIRST_RUN = booleanPreferencesKey("is_first_run") + val LAST_SOFT_UPDATE_SHOWN_TIME = longPreferencesKey("last_soft_update_shown_time") } override val preferences: Flow = @@ -46,6 +48,11 @@ class AppPreferencesDataSourceImpl prefs[Keys.IS_FIRST_RUN] ?: true } + override val lastSoftUpdateShownTime: Flow = + context.appPreferencesDataStore.data.map { prefs -> + prefs[Keys.LAST_SOFT_UPDATE_SHOWN_TIME] ?: 0L + } + override suspend fun updateNotificationPermissionRequested(requested: Boolean) { context.appPreferencesDataStore.edit { prefs -> prefs[Keys.HAS_REQUESTED_NOTIFICATION_PERMISSION] = requested @@ -57,4 +64,10 @@ class AppPreferencesDataSourceImpl prefs[Keys.IS_FIRST_RUN] = isFirstRun } } + + override suspend fun updateLastSoftUpdateShownTime(timeMillis: Long) { + context.appPreferencesDataStore.edit { prefs -> + prefs[Keys.LAST_SOFT_UPDATE_SHOWN_TIME] = timeMillis + } + } } diff --git a/domain/src/main/java/com/sseotdabwa/buyornot/domain/repository/AppPreferencesRepository.kt b/domain/src/main/java/com/sseotdabwa/buyornot/domain/repository/AppPreferencesRepository.kt index 2b0195b5..6b800ac8 100644 --- a/domain/src/main/java/com/sseotdabwa/buyornot/domain/repository/AppPreferencesRepository.kt +++ b/domain/src/main/java/com/sseotdabwa/buyornot/domain/repository/AppPreferencesRepository.kt @@ -18,6 +18,11 @@ interface AppPreferencesRepository { */ val isFirstRun: Flow + /** + * ์†Œํ”„ํŠธ ์—…๋ฐ์ดํŠธ ๋‹ค์ด์–ผ๋กœ๊ทธ ๋งˆ์ง€๋ง‰ ๋…ธ์ถœ ์‹œ๊ฐ (epoch millis)์„ Flow๋กœ ์ œ๊ณต + */ + val lastSoftUpdateShownTime: Flow + /** * ์•Œ๋ฆผ ๊ถŒํ•œ ์š”์ฒญ ์ด๋ ฅ ์—…๋ฐ์ดํŠธ */ @@ -27,4 +32,9 @@ interface AppPreferencesRepository { * ์ตœ์ดˆ ์‹คํ–‰ ์—ฌ๋ถ€ ์—…๋ฐ์ดํŠธ */ suspend fun updateIsFirstRun(isFirstRun: Boolean) + + /** + * ์†Œํ”„ํŠธ ์—…๋ฐ์ดํŠธ ๋‹ค์ด์–ผ๋กœ๊ทธ ๋งˆ์ง€๋ง‰ ๋…ธ์ถœ ์‹œ๊ฐ ์—…๋ฐ์ดํŠธ + */ + suspend fun updateLastSoftUpdateShownTime(timeMillis: Long) } From 33bad107edabe413400d98d629c1672dc1456e06 Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Thu, 2 Apr 2026 19:36:06 +0900 Subject: [PATCH 05/12] =?UTF-8?q?feat/#83:=20=EC=95=B1=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EB=8B=A4=EC=9D=B4=EC=96=BC?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20UI=20=EB=B0=8F=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../com/sseotdabwa/buyornot/MainActivity.kt | 1 + .../com/sseotdabwa/buyornot/ui/BuyOrNotApp.kt | 52 +++++++++++ .../buyornot/ui/BuyOrNotViewModel.kt | 92 ++++++++++++++++--- 3 files changed, 130 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/sseotdabwa/buyornot/MainActivity.kt b/app/src/main/java/com/sseotdabwa/buyornot/MainActivity.kt index c7021069..4fff2a3f 100644 --- a/app/src/main/java/com/sseotdabwa/buyornot/MainActivity.kt +++ b/app/src/main/java/com/sseotdabwa/buyornot/MainActivity.kt @@ -36,6 +36,7 @@ class MainActivity : ComponentActivity() { BuyOrNotApp( authEventBus = authEventBus, onBackPressed = { finish() }, + onFinish = { finishAffinity() }, ) } } diff --git a/app/src/main/java/com/sseotdabwa/buyornot/ui/BuyOrNotApp.kt b/app/src/main/java/com/sseotdabwa/buyornot/ui/BuyOrNotApp.kt index 778acf35..dc841acb 100644 --- a/app/src/main/java/com/sseotdabwa/buyornot/ui/BuyOrNotApp.kt +++ b/app/src/main/java/com/sseotdabwa/buyornot/ui/BuyOrNotApp.kt @@ -1,5 +1,9 @@ package com.sseotdabwa.buyornot.ui +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.consumeWindowInsets @@ -10,11 +14,13 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavDestination import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController +import com.sseotdabwa.buyornot.core.designsystem.components.BuyOrNotAlertDialog import com.sseotdabwa.buyornot.core.designsystem.components.BuyOrNotSnackBarHost import com.sseotdabwa.buyornot.core.designsystem.theme.BuyOrNotTheme import com.sseotdabwa.buyornot.core.network.AuthEventBus @@ -39,12 +45,14 @@ import com.sseotdabwa.buyornot.navigation.BuyOrNotNavHost * * @param authEventBus ์ธ์ฆ ๊ด€๋ จ ์ด๋ฒคํŠธ ๋ฒ„์Šค * @param onBackPressed ํ™ˆ ํ™”๋ฉด์—์„œ ๋’ค๋กœ๊ฐ€๊ธฐ ์‹œ ์•ฑ ์ข…๋ฃŒ๋ฅผ ์œ„ํ•œ ์ฝœ๋ฐฑ + * @param onFinish ์•ฑ ๊ฐ•์ œ ์ข…๋ฃŒ๋ฅผ ์œ„ํ•œ ์ฝœ๋ฐฑ (๊ฐ•์ œ ์—…๋ฐ์ดํŠธ ์‹œ "์ข…๋ฃŒ" ๋ฒ„ํŠผ) * @param viewModel ์•ฑ ๊ณตํ†ต ViewModel */ @Composable fun BuyOrNotApp( authEventBus: AuthEventBus, onBackPressed: () -> Unit = {}, + onFinish: () -> Unit = {}, viewModel: BuyOrNotViewModel = hiltViewModel(), ) { val navController = rememberNavController() @@ -53,6 +61,8 @@ fun BuyOrNotApp( val currentDestination = navBackStackEntry?.destination val isFirstRun by viewModel.isFirstRun.collectAsStateWithLifecycle() + val updateDialogType by viewModel.updateDialogType.collectAsStateWithLifecycle() + val context = LocalContext.current // ํ™ˆ ํ™”๋ฉด์—์„œ ๋’ค๋กœ๊ฐ€๊ธฐ ์‹œ ์•ฑ ์ข…๋ฃŒ BackHandler(enabled = currentDestination?.route == HOME_ROUTE) { @@ -71,6 +81,32 @@ fun BuyOrNotApp( } } + when (updateDialogType) { + UpdateDialogType.Force -> { + BuyOrNotAlertDialog( + onDismissRequest = { }, + title = "์—…๋ฐ์ดํŠธ๊ฐ€ ํ•„์š”ํ•ด์š”", + subText = "์•ฑ์„ ๊ณ„์† ์‚ฌ์šฉํ•˜๋ ค๋ฉด ์ตœ์‹  ๋ฒ„์ „์œผ๋กœ ์—…๋ฐ์ดํŠธํ•ด ์ฃผ์„ธ์š”.", + confirmText = "์—…๋ฐ์ดํŠธ", + dismissText = "์ข…๋ฃŒ", + onConfirm = { openPlayStore(context) }, + onDismiss = { onFinish() }, + ) + } + UpdateDialogType.Soft -> { + BuyOrNotAlertDialog( + onDismissRequest = { viewModel.dismissSoftUpdate() }, + title = "์ƒˆ๋กœ์šด ๋ฒ„์ „์ด ์žˆ์–ด์š”", + subText = "๋” ๋‚˜์€ ๊ฒฝํ—˜์„ ์œ„ํ•ด ์—…๋ฐ์ดํŠธ๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.", + confirmText = "์—…๋ฐ์ดํŠธ", + dismissText = "๋‚˜์ค‘์—", + onConfirm = { openPlayStore(context) }, + onDismiss = { viewModel.dismissSoftUpdate() }, + ) + } + UpdateDialogType.None -> Unit + } + CompositionLocalProvider(LocalSnackbarState provides snackbarState) { Scaffold( containerColor = BuyOrNotTheme.colors.gray0, @@ -88,6 +124,22 @@ fun BuyOrNotApp( } } +private fun openPlayStore(context: Context) { + val packageName = context.packageName + try { + context.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=$packageName")), + ) + } catch (e: ActivityNotFoundException) { + context.startActivity( + Intent( + Intent.ACTION_VIEW, + Uri.parse("https://play.google.com/store/apps/details?id=$packageName"), + ), + ) + } +} + /** * ํŠน์ • ํ™”๋ฉด(์Šคํ”Œ๋ž˜์‹œ, ๋กœ๊ทธ์ธ)์—์„œ๋Š” ์‹œ์Šคํ…œ ํŒจ๋”ฉ์„ ์ œ๊ฑฐํ•˜๋Š” ํ™•์žฅ ํ•จ์ˆ˜ * diff --git a/app/src/main/java/com/sseotdabwa/buyornot/ui/BuyOrNotViewModel.kt b/app/src/main/java/com/sseotdabwa/buyornot/ui/BuyOrNotViewModel.kt index 3035439a..9563f127 100644 --- a/app/src/main/java/com/sseotdabwa/buyornot/ui/BuyOrNotViewModel.kt +++ b/app/src/main/java/com/sseotdabwa/buyornot/ui/BuyOrNotViewModel.kt @@ -2,28 +2,90 @@ package com.sseotdabwa.buyornot.ui import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.sseotdabwa.buyornot.BuildConfig +import com.sseotdabwa.buyornot.domain.model.UpdateStrategy import com.sseotdabwa.buyornot.domain.repository.AppPreferencesRepository +import com.sseotdabwa.buyornot.domain.repository.AppUpdateRepository import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject +sealed interface UpdateDialogType { + data object None : UpdateDialogType + data object Soft : UpdateDialogType + data object Force : UpdateDialogType +} + @HiltViewModel -class BuyOrNotViewModel @Inject constructor( - private val appPreferencesRepository: AppPreferencesRepository, -) : ViewModel() { - val isFirstRun = - appPreferencesRepository.isFirstRun - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5000), - initialValue = false, - ) - - fun updateIsFirstRun(isFirstRun: Boolean) { - viewModelScope.launch { - appPreferencesRepository.updateIsFirstRun(isFirstRun) +class BuyOrNotViewModel + @Inject + constructor( + private val appPreferencesRepository: AppPreferencesRepository, + private val appUpdateRepository: AppUpdateRepository, + ) : ViewModel() { + val isFirstRun = + appPreferencesRepository.isFirstRun + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = false, + ) + + private val _updateDialogType = MutableStateFlow(UpdateDialogType.None) + val updateDialogType: StateFlow = _updateDialogType.asStateFlow() + + init { + checkAppUpdate() + } + + private fun checkAppUpdate() { + viewModelScope.launch { + runCatching { + val updateInfo = appUpdateRepository.getAppUpdateInfo() + val currentVersion = BuildConfig.VERSION_CODE + + val dialogType = + when { + currentVersion < updateInfo.minimumVersion -> UpdateDialogType.Force + updateInfo.updateStrategy == UpdateStrategy.FORCE -> UpdateDialogType.Force + updateInfo.updateStrategy == UpdateStrategy.SOFT && + currentVersion < updateInfo.latestVersion -> { + val lastShown = appPreferencesRepository.lastSoftUpdateShownTime.first() + val now = System.currentTimeMillis() + if (now - lastShown >= SOFT_UPDATE_INTERVAL_MILLIS) { + UpdateDialogType.Soft + } else { + UpdateDialogType.None + } + } + else -> UpdateDialogType.None + } + + _updateDialogType.value = dialogType + } + } + } + + fun updateIsFirstRun(isFirstRun: Boolean) { + viewModelScope.launch { + appPreferencesRepository.updateIsFirstRun(isFirstRun) + } + } + + fun dismissSoftUpdate() { + viewModelScope.launch { + appPreferencesRepository.updateLastSoftUpdateShownTime(System.currentTimeMillis()) + _updateDialogType.value = UpdateDialogType.None + } + } + + companion object { + private const val SOFT_UPDATE_INTERVAL_MILLIS = 24 * 60 * 60 * 1000L } } -} From 4e236ca663a739d0362ca9b78e883bd13080edfa Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Thu, 2 Apr 2026 19:45:22 +0900 Subject: [PATCH 06/12] =?UTF-8?q?feat/#83:=20Remote=20Config=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95/=EA=B8=B0=EB=B3=B8=EA=B0=92=20=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=ED=99=94=EB=A5=BC=20suspend=EC=97=90=EC=84=9C=20await=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../datasource/RemoteConfigDataSourceImpl.kt | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/datasource/RemoteConfigDataSourceImpl.kt b/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/datasource/RemoteConfigDataSourceImpl.kt index 6fc3fb39..8927053e 100644 --- a/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/datasource/RemoteConfigDataSourceImpl.kt +++ b/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/datasource/RemoteConfigDataSourceImpl.kt @@ -14,24 +14,23 @@ class RemoteConfigDataSourceImpl constructor() : RemoteConfigDataSource { private val remoteConfig: FirebaseRemoteConfig = FirebaseRemoteConfig.getInstance() - init { + override suspend fun fetchAppUpdateConfig(): AppUpdateInfo { val settings = FirebaseRemoteConfigSettings .Builder() .setMinimumFetchIntervalInSeconds(FETCH_INTERVAL_SECONDS) .build() - remoteConfig.setConfigSettingsAsync(settings) - remoteConfig.setDefaultsAsync( - mapOf( - KEY_LATEST_VERSION to DEFAULT_VERSION, - KEY_MINIMUM_VERSION to DEFAULT_VERSION, - KEY_UPDATE_STRATEGY to UpdateStrategy.NONE.name, - ), - ) - } + remoteConfig.setConfigSettingsAsync(settings).await() + remoteConfig + .setDefaultsAsync( + mapOf( + KEY_LATEST_VERSION to DEFAULT_VERSION, + KEY_MINIMUM_VERSION to DEFAULT_VERSION, + KEY_UPDATE_STRATEGY to UpdateStrategy.NONE.name, + ), + ).await() - override suspend fun fetchAppUpdateConfig(): AppUpdateInfo { remoteConfig.fetchAndActivate().await() val latestVersion = remoteConfig.getLong(KEY_LATEST_VERSION).toInt() From a72f0e34dc1b5230cbc31adaaff4763750729f81 Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Thu, 2 Apr 2026 20:07:25 +0900 Subject: [PATCH 07/12] =?UTF-8?q?feat/#83:=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=ED=8C=9D=EC=97=85=EC=9D=84=20=EC=8A=A4=ED=94=8C?= =?UTF-8?q?=EB=9E=98=EC=8B=9C=EC=97=90=EC=84=9C=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SplashViewModel์—์„œ ํ† ํฐ ์ฒดํฌ + Remote Config fetch ๋ณ‘๋ ฌ ์‹คํ–‰ - ํŒ์—…์ด ๋‹ซํžˆ๊ธฐ ์ „๊นŒ์ง€ ๋„ค๋น„๊ฒŒ์ด์…˜ ์ฐจ๋‹จ (uiState.first) - BuyOrNotViewModel์—์„œ ์—…๋ฐ์ดํŠธ ๋กœ์ง ์ œ๊ฑฐ Co-Authored-By: Claude Sonnet 4.6 --- .../buyornot/navigation/BuyOrNotNavHost.kt | 2 + .../com/sseotdabwa/buyornot/ui/BuyOrNotApp.kt | 55 +------- .../buyornot/ui/BuyOrNotViewModel.kt | 60 --------- .../datasource/RemoteConfigDataSourceImpl.kt | 14 +- .../feature/auth/navigation/AuthNavigation.kt | 2 + .../feature/auth/ui/SplashContract.kt | 19 +-- .../buyornot/feature/auth/ui/SplashScreen.kt | 79 +++++++++-- .../feature/auth/ui/SplashViewModel.kt | 124 +++++++++++++----- 8 files changed, 191 insertions(+), 164 deletions(-) diff --git a/app/src/main/java/com/sseotdabwa/buyornot/navigation/BuyOrNotNavHost.kt b/app/src/main/java/com/sseotdabwa/buyornot/navigation/BuyOrNotNavHost.kt index b1c78c96..3180f675 100644 --- a/app/src/main/java/com/sseotdabwa/buyornot/navigation/BuyOrNotNavHost.kt +++ b/app/src/main/java/com/sseotdabwa/buyornot/navigation/BuyOrNotNavHost.kt @@ -42,6 +42,7 @@ import com.sseotdabwa.buyornot.feature.upload.navigation.uploadScreen fun BuyOrNotNavHost( navController: NavHostController, authEventBus: AuthEventBus, + onFinish: () -> Unit, modifier: Modifier = Modifier, ) { val snackbarState = LocalSnackbarState.current @@ -74,6 +75,7 @@ fun BuyOrNotNavHost( }, ) }, + onFinish = onFinish, ) authScreen( diff --git a/app/src/main/java/com/sseotdabwa/buyornot/ui/BuyOrNotApp.kt b/app/src/main/java/com/sseotdabwa/buyornot/ui/BuyOrNotApp.kt index dc841acb..be76d0ac 100644 --- a/app/src/main/java/com/sseotdabwa/buyornot/ui/BuyOrNotApp.kt +++ b/app/src/main/java/com/sseotdabwa/buyornot/ui/BuyOrNotApp.kt @@ -1,9 +1,5 @@ package com.sseotdabwa.buyornot.ui -import android.content.ActivityNotFoundException -import android.content.Context -import android.content.Intent -import android.net.Uri import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.consumeWindowInsets @@ -14,13 +10,11 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavDestination import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController -import com.sseotdabwa.buyornot.core.designsystem.components.BuyOrNotAlertDialog import com.sseotdabwa.buyornot.core.designsystem.components.BuyOrNotSnackBarHost import com.sseotdabwa.buyornot.core.designsystem.theme.BuyOrNotTheme import com.sseotdabwa.buyornot.core.network.AuthEventBus @@ -38,14 +32,12 @@ import com.sseotdabwa.buyornot.navigation.BuyOrNotNavHost * ๋„ค๋น„๊ฒŒ์ด์…˜๊ณผ ํ•˜๋‹จ ๋„ค๋น„๊ฒŒ์ด์…˜ ๋ฐ”๋ฅผ ํฌํ•จํ•œ ์•ฑ์˜ ์ „์ฒด ๊ตฌ์กฐ๋ฅผ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค. * ์Šคํ”Œ๋ž˜์‹œ ๋ฐ ๋กœ๊ทธ์ธ ํ™”๋ฉด์—์„œ๋Š” ํ•˜๋‹จ ๋ฐ”๊ฐ€ ํ‘œ์‹œ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. * - * * ์ „์ฒด ํ™”๋ฉด์ด ํ•„์š”ํ•˜๋ฉด โ†’ bottomBarPadding() ํ•จ์ˆ˜์˜ ๋ฆฌ์ŠคํŠธ์— ๋ผ์šฐํŠธ ์ถ”๊ฐ€ - * * ์ผ๋ฐ˜ ํ™”๋ฉด์ด๋ฉด โ†’ ์•„๋ฌด ๊ฒƒ๋„ ํ•˜์ง€ ์•Š์•„๋„ ์ž๋™์œผ๋กœ ํŒจ๋”ฉ ์ ์šฉ * * @param authEventBus ์ธ์ฆ ๊ด€๋ จ ์ด๋ฒคํŠธ ๋ฒ„์Šค * @param onBackPressed ํ™ˆ ํ™”๋ฉด์—์„œ ๋’ค๋กœ๊ฐ€๊ธฐ ์‹œ ์•ฑ ์ข…๋ฃŒ๋ฅผ ์œ„ํ•œ ์ฝœ๋ฐฑ - * @param onFinish ์•ฑ ๊ฐ•์ œ ์ข…๋ฃŒ๋ฅผ ์œ„ํ•œ ์ฝœ๋ฐฑ (๊ฐ•์ œ ์—…๋ฐ์ดํŠธ ์‹œ "์ข…๋ฃŒ" ๋ฒ„ํŠผ) + * @param onFinish ์•ฑ ์ข…๋ฃŒ ์ฝœ๋ฐฑ (๊ฐ•์ œ ์—…๋ฐ์ดํŠธ ์‹œ "์ข…๋ฃŒ" ๋ฒ„ํŠผ) * @param viewModel ์•ฑ ๊ณตํ†ต ViewModel */ @Composable @@ -61,8 +53,6 @@ fun BuyOrNotApp( val currentDestination = navBackStackEntry?.destination val isFirstRun by viewModel.isFirstRun.collectAsStateWithLifecycle() - val updateDialogType by viewModel.updateDialogType.collectAsStateWithLifecycle() - val context = LocalContext.current // ํ™ˆ ํ™”๋ฉด์—์„œ ๋’ค๋กœ๊ฐ€๊ธฐ ์‹œ ์•ฑ ์ข…๋ฃŒ BackHandler(enabled = currentDestination?.route == HOME_ROUTE) { @@ -81,32 +71,6 @@ fun BuyOrNotApp( } } - when (updateDialogType) { - UpdateDialogType.Force -> { - BuyOrNotAlertDialog( - onDismissRequest = { }, - title = "์—…๋ฐ์ดํŠธ๊ฐ€ ํ•„์š”ํ•ด์š”", - subText = "์•ฑ์„ ๊ณ„์† ์‚ฌ์šฉํ•˜๋ ค๋ฉด ์ตœ์‹  ๋ฒ„์ „์œผ๋กœ ์—…๋ฐ์ดํŠธํ•ด ์ฃผ์„ธ์š”.", - confirmText = "์—…๋ฐ์ดํŠธ", - dismissText = "์ข…๋ฃŒ", - onConfirm = { openPlayStore(context) }, - onDismiss = { onFinish() }, - ) - } - UpdateDialogType.Soft -> { - BuyOrNotAlertDialog( - onDismissRequest = { viewModel.dismissSoftUpdate() }, - title = "์ƒˆ๋กœ์šด ๋ฒ„์ „์ด ์žˆ์–ด์š”", - subText = "๋” ๋‚˜์€ ๊ฒฝํ—˜์„ ์œ„ํ•ด ์—…๋ฐ์ดํŠธ๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.", - confirmText = "์—…๋ฐ์ดํŠธ", - dismissText = "๋‚˜์ค‘์—", - onConfirm = { openPlayStore(context) }, - onDismiss = { viewModel.dismissSoftUpdate() }, - ) - } - UpdateDialogType.None -> Unit - } - CompositionLocalProvider(LocalSnackbarState provides snackbarState) { Scaffold( containerColor = BuyOrNotTheme.colors.gray0, @@ -115,6 +79,7 @@ fun BuyOrNotApp( BuyOrNotNavHost( navController = navController, authEventBus = authEventBus, + onFinish = onFinish, modifier = Modifier .consumeWindowInsets(innerPadding) @@ -124,22 +89,6 @@ fun BuyOrNotApp( } } -private fun openPlayStore(context: Context) { - val packageName = context.packageName - try { - context.startActivity( - Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=$packageName")), - ) - } catch (e: ActivityNotFoundException) { - context.startActivity( - Intent( - Intent.ACTION_VIEW, - Uri.parse("https://play.google.com/store/apps/details?id=$packageName"), - ), - ) - } -} - /** * ํŠน์ • ํ™”๋ฉด(์Šคํ”Œ๋ž˜์‹œ, ๋กœ๊ทธ์ธ)์—์„œ๋Š” ์‹œ์Šคํ…œ ํŒจ๋”ฉ์„ ์ œ๊ฑฐํ•˜๋Š” ํ™•์žฅ ํ•จ์ˆ˜ * diff --git a/app/src/main/java/com/sseotdabwa/buyornot/ui/BuyOrNotViewModel.kt b/app/src/main/java/com/sseotdabwa/buyornot/ui/BuyOrNotViewModel.kt index 9563f127..79f6f012 100644 --- a/app/src/main/java/com/sseotdabwa/buyornot/ui/BuyOrNotViewModel.kt +++ b/app/src/main/java/com/sseotdabwa/buyornot/ui/BuyOrNotViewModel.kt @@ -2,32 +2,18 @@ package com.sseotdabwa.buyornot.ui import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.sseotdabwa.buyornot.BuildConfig -import com.sseotdabwa.buyornot.domain.model.UpdateStrategy import com.sseotdabwa.buyornot.domain.repository.AppPreferencesRepository -import com.sseotdabwa.buyornot.domain.repository.AppUpdateRepository import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject -sealed interface UpdateDialogType { - data object None : UpdateDialogType - data object Soft : UpdateDialogType - data object Force : UpdateDialogType -} - @HiltViewModel class BuyOrNotViewModel @Inject constructor( private val appPreferencesRepository: AppPreferencesRepository, - private val appUpdateRepository: AppUpdateRepository, ) : ViewModel() { val isFirstRun = appPreferencesRepository.isFirstRun @@ -37,55 +23,9 @@ class BuyOrNotViewModel initialValue = false, ) - private val _updateDialogType = MutableStateFlow(UpdateDialogType.None) - val updateDialogType: StateFlow = _updateDialogType.asStateFlow() - - init { - checkAppUpdate() - } - - private fun checkAppUpdate() { - viewModelScope.launch { - runCatching { - val updateInfo = appUpdateRepository.getAppUpdateInfo() - val currentVersion = BuildConfig.VERSION_CODE - - val dialogType = - when { - currentVersion < updateInfo.minimumVersion -> UpdateDialogType.Force - updateInfo.updateStrategy == UpdateStrategy.FORCE -> UpdateDialogType.Force - updateInfo.updateStrategy == UpdateStrategy.SOFT && - currentVersion < updateInfo.latestVersion -> { - val lastShown = appPreferencesRepository.lastSoftUpdateShownTime.first() - val now = System.currentTimeMillis() - if (now - lastShown >= SOFT_UPDATE_INTERVAL_MILLIS) { - UpdateDialogType.Soft - } else { - UpdateDialogType.None - } - } - else -> UpdateDialogType.None - } - - _updateDialogType.value = dialogType - } - } - } - fun updateIsFirstRun(isFirstRun: Boolean) { viewModelScope.launch { appPreferencesRepository.updateIsFirstRun(isFirstRun) } } - - fun dismissSoftUpdate() { - viewModelScope.launch { - appPreferencesRepository.updateLastSoftUpdateShownTime(System.currentTimeMillis()) - _updateDialogType.value = UpdateDialogType.None - } - } - - companion object { - private const val SOFT_UPDATE_INTERVAL_MILLIS = 24 * 60 * 60 * 1000L - } } diff --git a/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/datasource/RemoteConfigDataSourceImpl.kt b/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/datasource/RemoteConfigDataSourceImpl.kt index 8927053e..eb453b9d 100644 --- a/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/datasource/RemoteConfigDataSourceImpl.kt +++ b/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/datasource/RemoteConfigDataSourceImpl.kt @@ -1,5 +1,6 @@ package com.sseotdabwa.buyornot.core.data.datasource +import android.util.Log import com.google.firebase.remoteconfig.FirebaseRemoteConfig import com.google.firebase.remoteconfig.FirebaseRemoteConfigSettings import com.sseotdabwa.buyornot.domain.model.AppUpdateInfo @@ -31,7 +32,8 @@ class RemoteConfigDataSourceImpl ), ).await() - remoteConfig.fetchAndActivate().await() + val activated = remoteConfig.fetchAndActivate().await() + Log.d(TAG, "fetchAndActivate: activated=$activated") val latestVersion = remoteConfig.getLong(KEY_LATEST_VERSION).toInt() val minimumVersion = remoteConfig.getLong(KEY_MINIMUM_VERSION).toInt() @@ -39,6 +41,15 @@ class RemoteConfigDataSourceImpl val updateStrategy = runCatching { UpdateStrategy.valueOf(strategyRaw) }.getOrDefault(UpdateStrategy.NONE) + Log.d( + TAG, + "Remote Config values โ€” " + + "latestVersion=$latestVersion, " + + "minimumVersion=$minimumVersion, " + + "strategyRaw=$strategyRaw, " + + "resolvedStrategy=$updateStrategy", + ) + return AppUpdateInfo( latestVersion = latestVersion, minimumVersion = minimumVersion, @@ -47,6 +58,7 @@ class RemoteConfigDataSourceImpl } companion object { + private const val TAG = "RemoteConfig" private const val KEY_LATEST_VERSION = "android_latest_version" private const val KEY_MINIMUM_VERSION = "android_minimum_version" private const val KEY_UPDATE_STRATEGY = "android_update_strategy" diff --git a/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/navigation/AuthNavigation.kt b/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/navigation/AuthNavigation.kt index 298fccdf..455378a8 100644 --- a/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/navigation/AuthNavigation.kt +++ b/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/navigation/AuthNavigation.kt @@ -30,11 +30,13 @@ const val AUTH_ROUTE = "auth" fun NavGraphBuilder.splashScreen( onNavigateToLogin: () -> Unit, onNavigateToHome: () -> Unit, + onFinish: () -> Unit, ) { composable(route = SPLASH_ROUTE) { SplashRoute( onNavigateToLogin = onNavigateToLogin, onNavigateToHome = onNavigateToHome, + onFinish = onFinish, ) } } diff --git a/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashContract.kt b/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashContract.kt index 8a66c366..faa51972 100644 --- a/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashContract.kt +++ b/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashContract.kt @@ -2,33 +2,36 @@ package com.sseotdabwa.buyornot.feature.auth.ui import androidx.compose.runtime.Immutable +sealed interface UpdateDialogType { + data object None : UpdateDialogType + data object Soft : UpdateDialogType + data object Force : UpdateDialogType +} + /** * ์Šคํ”Œ๋ž˜์‹œ ํ™”๋ฉด์˜ UI ์ƒํƒœ (MVI State) * * @property isLoading ์ดˆ๊ธฐ ๋กœ๋”ฉ ์ค‘ ์—ฌ๋ถ€ + * @property updateDialogType ํ‘œ์‹œํ•  ์—…๋ฐ์ดํŠธ ๋‹ค์ด์–ผ๋กœ๊ทธ ํƒ€์ž… */ @Immutable data class SplashUiState( val isLoading: Boolean = true, + val updateDialogType: UpdateDialogType = UpdateDialogType.None, ) /** * ์Šคํ”Œ๋ž˜์‹œ ํ™”๋ฉด์—์„œ ๋ฐœ์ƒํ•˜๋Š” ์‚ฌ์šฉ์ž ์•ก์…˜ (MVI Intent) - * ์Šคํ”Œ๋ž˜์‹œ ํ™”๋ฉด์€ ์ž๋™์œผ๋กœ ์ง„ํ–‰๋˜๋ฏ€๋กœ ์‚ฌ์šฉ์ž Intent๋Š” ์—†์Œ */ -sealed interface SplashIntent +sealed interface SplashIntent { + data object DismissSoftUpdate : SplashIntent +} /** * ์Šคํ”Œ๋ž˜์‹œ ํ™”๋ฉด์˜ ์ผํšŒ์„ฑ ์ด๋ฒคํŠธ (MVI SideEffect) */ sealed interface SplashSideEffect { - /** - * ๋กœ๊ทธ์ธ ํ™”๋ฉด์œผ๋กœ ์ด๋™ - */ data object NavigateToLogin : SplashSideEffect - /** - * ํ™ˆ ํ™”๋ฉด์œผ๋กœ ์ด๋™ - */ data object NavigateToHome : SplashSideEffect } diff --git a/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashScreen.kt b/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashScreen.kt index 3d075c9e..09bf7600 100644 --- a/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashScreen.kt +++ b/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashScreen.kt @@ -1,5 +1,9 @@ package com.sseotdabwa.buyornot.feature.auth.ui +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -13,12 +17,15 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.airbnb.lottie.compose.LottieAnimation import com.airbnb.lottie.compose.LottieCompositionSpec import com.airbnb.lottie.compose.rememberLottieComposition +import com.sseotdabwa.buyornot.core.designsystem.components.BuyOrNotAlertDialog import com.sseotdabwa.buyornot.core.designsystem.icon.BuyOrNotIcons import com.sseotdabwa.buyornot.core.designsystem.icon.BuyOrNotLotties import com.sseotdabwa.buyornot.core.designsystem.icon.asImageVector @@ -28,21 +35,24 @@ import com.sseotdabwa.buyornot.core.designsystem.theme.BuyOrNotTheme * ์Šคํ”Œ๋ž˜์‹œ ํ™”๋ฉด์˜ ๋„ค๋น„๊ฒŒ์ด์…˜ ์ง„์ž…์  * * ์•ฑ ์ตœ์ดˆ ์ง„์ž… ์‹œ ํ‘œ์‹œ๋˜๋Š” ์Šคํ”Œ๋ž˜์‹œ ํ™”๋ฉด์ž…๋‹ˆ๋‹ค. - * ์ง€์ •๋œ ์‹œ๊ฐ„(2.3์ดˆ) ํ›„ ์ž๋™์œผ๋กœ ๋กœ๊ทธ์ธ ์ƒํƒœ๋ฅผ ํ™•์ธํ•˜์—ฌ: - * - ๋กœ๊ทธ์ธ ์ƒํƒœ(ํ† ํฐ ์žˆ์Œ) โ†’ ํ™ˆ ํ™”๋ฉด์œผ๋กœ ์ด๋™ - * - ๋น„๋กœ๊ทธ์ธ ์ƒํƒœ โ†’ ๋กœ๊ทธ์ธ ํ™”๋ฉด์œผ๋กœ ์ด๋™ + * ํ† ํฐ ์ฒดํฌ + ์—…๋ฐ์ดํŠธ ์ฒดํฌ๋ฅผ ๋ณ‘๋ ฌ๋กœ ์‹คํ–‰ํ•˜๋ฉฐ, + * ์—…๋ฐ์ดํŠธ ํŒ์—…์ด ํ‘œ์‹œ ์ค‘์ด๋ฉด ๋‹ค๋ฅธ ํ™”๋ฉด์œผ๋กœ ์ด๋™ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. * * @param onNavigateToLogin ๋กœ๊ทธ์ธ ํ™”๋ฉด์œผ๋กœ ์ด๋™ํ•˜๋Š” ์ฝœ๋ฐฑ * @param onNavigateToHome ํ™ˆ ํ™”๋ฉด์œผ๋กœ ์ด๋™ํ•˜๋Š” ์ฝœ๋ฐฑ + * @param onFinish ์•ฑ ์ข…๋ฃŒ ์ฝœ๋ฐฑ (๊ฐ•์ œ ์—…๋ฐ์ดํŠธ ์‹œ "์ข…๋ฃŒ" ๋ฒ„ํŠผ) * @param viewModel SplashViewModel (Hilt ์ฃผ์ž…) */ @Composable fun SplashRoute( onNavigateToLogin: () -> Unit, onNavigateToHome: () -> Unit, + onFinish: () -> Unit, viewModel: SplashViewModel = hiltViewModel(), ) { - // SideEffect ์ฒ˜๋ฆฌ (ViewModel์—์„œ ํ† ํฐ ์ฒดํฌ ํ›„ ๋„ค๋น„๊ฒŒ์ด์…˜ ์ด๋ฒคํŠธ ๋ฐฉ์ถœ) + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + LaunchedEffect(Unit) { viewModel.sideEffect.collect { sideEffect -> when (sideEffect) { @@ -52,16 +62,24 @@ fun SplashRoute( } } - SplashScreen() + SplashScreen( + updateDialogType = uiState.updateDialogType, + onDismissSoftUpdate = { viewModel.handleIntent(SplashIntent.DismissSoftUpdate) }, + onUpdateClick = { openPlayStore(context) }, + onFinish = onFinish, + ) } /** * ์Šคํ”Œ๋ž˜์‹œ ํ™”๋ฉด UI - * - * ์•ฑ ๋กœ๊ณ ๋ฅผ ์ค‘์•™์— ํ‘œ์‹œํ•ฉ๋‹ˆ๋‹ค. */ @Composable -private fun SplashScreen() { +private fun SplashScreen( + updateDialogType: UpdateDialogType = UpdateDialogType.None, + onDismissSoftUpdate: () -> Unit = {}, + onUpdateClick: () -> Unit = {}, + onFinish: () -> Unit = {}, +) { Box( modifier = Modifier @@ -89,11 +107,50 @@ private fun SplashScreen() { } } } + + when (updateDialogType) { + UpdateDialogType.Force -> { + BuyOrNotAlertDialog( + onDismissRequest = { }, + title = "ํ•„์ˆ˜ ์—…๋ฐ์ดํŠธ๊ฐ€ ์žˆ์–ด์š”", + subText = "์„œ๋น„์Šค ์ด์šฉ์„ ์œ„ํ•ด ์—…๋ฐ์ดํŠธ๊ฐ€ ํ•„์š”ํ•ด์š”.", + confirmText = "์—…๋ฐ์ดํŠธ", + dismissText = "์ข…๋ฃŒ", + onConfirm = onUpdateClick, + onDismiss = onFinish, + ) + } + UpdateDialogType.Soft -> { + BuyOrNotAlertDialog( + onDismissRequest = onDismissSoftUpdate, + title = "์ƒˆ ๋ฒ„์ „์ด ์ถœ์‹œ๋์–ด์š”", + subText = "๋” ๋‚˜์€ ๊ฒฝํ—˜์„ ์œ„ํ•ด ์—…๋ฐ์ดํŠธ๋ฅผ ๊ถŒ์žฅํ•ด์š”.", + confirmText = "์—…๋ฐ์ดํŠธ", + dismissText = "๋‚˜์ค‘์—", + onConfirm = onUpdateClick, + onDismiss = onDismissSoftUpdate, + ) + } + UpdateDialogType.None -> Unit + } +} + +private fun openPlayStore(context: Context) { + val packageName = context.packageName + try { + context.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=$packageName")), + ) + } catch (e: ActivityNotFoundException) { + context.startActivity( + Intent( + Intent.ACTION_VIEW, + Uri.parse("https://play.google.com/store/apps/details?id=$packageName"), + ), + ) + } } -/** - * ์Šคํ”Œ๋ž˜์‹œ ํ™”๋ฉด ํ”„๋ฆฌ๋ทฐ - */ @Preview(name = "SplashScreen - Pixel 5", device = "id:pixel_5", showBackground = true) @Composable private fun SplashScreenPreview() { diff --git a/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashViewModel.kt b/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashViewModel.kt index d5aa65de..de7ba96d 100644 --- a/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashViewModel.kt +++ b/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashViewModel.kt @@ -1,10 +1,18 @@ package com.sseotdabwa.buyornot.feature.auth.ui +import android.content.Context +import android.util.Log +import androidx.core.content.pm.PackageInfoCompat import androidx.lifecycle.viewModelScope import com.sseotdabwa.buyornot.core.ui.base.BaseViewModel +import com.sseotdabwa.buyornot.domain.model.UpdateStrategy import com.sseotdabwa.buyornot.domain.model.UserType +import com.sseotdabwa.buyornot.domain.repository.AppPreferencesRepository +import com.sseotdabwa.buyornot.domain.repository.AppUpdateRepository import com.sseotdabwa.buyornot.domain.repository.UserPreferencesRepository import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch @@ -12,49 +20,103 @@ import javax.inject.Inject import kotlin.coroutines.cancellation.CancellationException private const val SPLASH_TIMEOUT_MILLIS = 2300L +private const val SOFT_UPDATE_INTERVAL_MILLIS = 24 * 60 * 60 * 1000L +private const val TAG = "SplashUpdate" /** * ์Šคํ”Œ๋ž˜์‹œ ํ™”๋ฉด์„ ์œ„ํ•œ ViewModel * - * ํ† ํฐ ์กด์žฌ ์—ฌ๋ถ€๋ฅผ ํ™•์ธํ•˜๊ณ , 2.3์ดˆ ํ›„ ์ž๋™์œผ๋กœ ๋„ค๋น„๊ฒŒ์ด์…˜ SideEffect๋ฅผ ๋ฐฉ์ถœํ•ฉ๋‹ˆ๋‹ค. + * ํ† ํฐ ์กด์žฌ ์—ฌ๋ถ€์™€ ์•ฑ ์—…๋ฐ์ดํŠธ ํ•„์š” ์—ฌ๋ถ€๋ฅผ ๋ณ‘๋ ฌ๋กœ ํ™•์ธํ•˜๊ณ , + * ์—…๋ฐ์ดํŠธ ํŒ์—…์ด ํ‘œ์‹œ ์ค‘์ด๋ฉด ๋„ค๋น„๊ฒŒ์ด์…˜์„ ์ฐจ๋‹จํ•ฉ๋‹ˆ๋‹ค. */ @HiltViewModel -class SplashViewModel @Inject constructor( - private val userPreferencesRepository: UserPreferencesRepository, -) : BaseViewModel(SplashUiState()) { - init { - checkTokenAndNavigate() - } +class SplashViewModel + @Inject + constructor( + @ApplicationContext private val context: Context, + private val userPreferencesRepository: UserPreferencesRepository, + private val appUpdateRepository: AppUpdateRepository, + private val appPreferencesRepository: AppPreferencesRepository, + ) : BaseViewModel(SplashUiState()) { + init { + checkTokenAndNavigate() + } - override fun handleIntent(intent: SplashIntent) { - // ์Šคํ”Œ๋ž˜์‹œ ํ™”๋ฉด์€ ์‚ฌ์šฉ์ž ์•ก์…˜์ด ์—†์œผ๋ฏ€๋กœ ๋น„์–ด์žˆ์Œ - } + override fun handleIntent(intent: SplashIntent) { + when (intent) { + SplashIntent.DismissSoftUpdate -> dismissSoftUpdate() + } + } - /** - * ํ† ํฐ ์กด์žฌ ์—ฌ๋ถ€๋ฅผ ํ™•์ธํ•˜๊ณ  ์ ์ ˆํ•œ ํ™”๋ฉด์œผ๋กœ ์ด๋™ - */ - private fun checkTokenAndNavigate() { - viewModelScope.launch { - val hasValidToken = - try { - val userType = userPreferencesRepository.userType.first() - userType != UserType.GUEST - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - false // DataStore ์˜ค๋ฅ˜ ์‹œ ๋น„๋กœ๊ทธ์ธ ํ™”๋ฉด์œผ๋กœ ํด๋ฐฑ + private fun checkTokenAndNavigate() { + viewModelScope.launch { + // ํ† ํฐ ์ฒดํฌ + ์—…๋ฐ์ดํŠธ ์ฒดํฌ ๋ณ‘๋ ฌ ์‹คํ–‰ + val updateInfoDeferred = async { + runCatching { appUpdateRepository.getAppUpdateInfo() }.getOrNull() } - delay(SPLASH_TIMEOUT_MILLIS) + val hasValidToken = + try { + userPreferencesRepository.userType.first() != UserType.GUEST + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + false + } + + delay(SPLASH_TIMEOUT_MILLIS) + + // ์—…๋ฐ์ดํŠธ ๋‹ค์ด์–ผ๋กœ๊ทธ ํƒ€์ž… ๊ฒฐ์ • + val updateInfo = updateInfoDeferred.await() + val currentVersion = PackageInfoCompat + .getLongVersionCode(context.packageManager.getPackageInfo(context.packageName, 0)) + .toInt() + val dialogType = determineDialogType(currentVersion, updateInfo) + + Log.d(TAG, "currentVersion=$currentVersion, dialogType=$dialogType, updateInfo=$updateInfo") + + if (dialogType != UpdateDialogType.None) { + updateState { it.copy(updateDialogType = dialogType) } + // ํŒ์—…์ด ๋‹ซํž ๋•Œ๊นŒ์ง€ ๋„ค๋น„๊ฒŒ์ด์…˜ ์ฐจ๋‹จ + uiState.first { it.updateDialogType == UpdateDialogType.None } + } - // ํ† ํฐ ์œ ํšจ์„ฑ์— ๋”ฐ๋ผ SideEffect ๋ฐฉ์ถœ - if (hasValidToken) { - sendSideEffect(SplashSideEffect.NavigateToHome) - } else { - sendSideEffect(SplashSideEffect.NavigateToLogin) + if (hasValidToken) { + sendSideEffect(SplashSideEffect.NavigateToHome) + } else { + sendSideEffect(SplashSideEffect.NavigateToLogin) + } + + updateState { it.copy(isLoading = false) } } + } - updateState { it.copy(isLoading = false) } + private suspend fun determineDialogType( + currentVersion: Int, + updateInfo: com.sseotdabwa.buyornot.domain.model.AppUpdateInfo?, + ): UpdateDialogType { + if (updateInfo == null) return UpdateDialogType.None + + return when { + currentVersion < updateInfo.minimumVersion -> UpdateDialogType.Force + updateInfo.updateStrategy == UpdateStrategy.FORCE -> UpdateDialogType.Force + updateInfo.updateStrategy == UpdateStrategy.SOFT && + currentVersion < updateInfo.latestVersion -> { + val lastShown = appPreferencesRepository.lastSoftUpdateShownTime.first() + if (System.currentTimeMillis() - lastShown >= SOFT_UPDATE_INTERVAL_MILLIS) { + UpdateDialogType.Soft + } else { + UpdateDialogType.None + } + } + else -> UpdateDialogType.None + } + } + + private fun dismissSoftUpdate() { + viewModelScope.launch { + appPreferencesRepository.updateLastSoftUpdateShownTime(System.currentTimeMillis()) + updateState { it.copy(updateDialogType = UpdateDialogType.None) } + } } } -} From 155fecd8efab86bf0f795d8018cda97f1f44466b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 2 Apr 2026 11:16:28 +0000 Subject: [PATCH 08/12] =?UTF-8?q?chore/#83:=20ktlint=20=ED=8F=AC=EB=A7=B7?= =?UTF-8?q?=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../buyornot/feature/auth/ui/SplashContract.kt | 2 ++ .../buyornot/feature/auth/ui/SplashViewModel.kt | 14 ++++++++------ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashContract.kt b/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashContract.kt index faa51972..a13a0a5c 100644 --- a/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashContract.kt +++ b/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashContract.kt @@ -4,7 +4,9 @@ import androidx.compose.runtime.Immutable sealed interface UpdateDialogType { data object None : UpdateDialogType + data object Soft : UpdateDialogType + data object Force : UpdateDialogType } diff --git a/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashViewModel.kt b/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashViewModel.kt index de7ba96d..c564cdda 100644 --- a/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashViewModel.kt +++ b/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashViewModel.kt @@ -51,9 +51,10 @@ class SplashViewModel private fun checkTokenAndNavigate() { viewModelScope.launch { // ํ† ํฐ ์ฒดํฌ + ์—…๋ฐ์ดํŠธ ์ฒดํฌ ๋ณ‘๋ ฌ ์‹คํ–‰ - val updateInfoDeferred = async { - runCatching { appUpdateRepository.getAppUpdateInfo() }.getOrNull() - } + val updateInfoDeferred = + async { + runCatching { appUpdateRepository.getAppUpdateInfo() }.getOrNull() + } val hasValidToken = try { @@ -68,9 +69,10 @@ class SplashViewModel // ์—…๋ฐ์ดํŠธ ๋‹ค์ด์–ผ๋กœ๊ทธ ํƒ€์ž… ๊ฒฐ์ • val updateInfo = updateInfoDeferred.await() - val currentVersion = PackageInfoCompat - .getLongVersionCode(context.packageManager.getPackageInfo(context.packageName, 0)) - .toInt() + val currentVersion = + PackageInfoCompat + .getLongVersionCode(context.packageManager.getPackageInfo(context.packageName, 0)) + .toInt() val dialogType = determineDialogType(currentVersion, updateInfo) Log.d(TAG, "currentVersion=$currentVersion, dialogType=$dialogType, updateInfo=$updateInfo") From 75616704c4171f782e52a1c3aba644c1f6a4d121 Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Thu, 2 Apr 2026 23:59:17 +0900 Subject: [PATCH 09/12] =?UTF-8?q?chore/#83:=20docs=20=ED=8F=B4=EB=8D=94=20?= =?UTF-8?q?md=20=ED=8C=8C=EC=9D=BC=20git=20=ED=8A=B8=EB=9E=98=ED=82=B9=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- docs/CommitConvention.md | 39 ------------------ docs/Modularization.md | 71 --------------------------------- docs/PR-bugfix-65-qa-2.md | 66 ------------------------------ docs/SnackbarStateManagement.md | 51 ----------------------- 4 files changed, 227 deletions(-) delete mode 100644 docs/CommitConvention.md delete mode 100644 docs/Modularization.md delete mode 100644 docs/PR-bugfix-65-qa-2.md delete mode 100644 docs/SnackbarStateManagement.md diff --git a/docs/CommitConvention.md b/docs/CommitConvention.md deleted file mode 100644 index 91cf2eba..00000000 --- a/docs/CommitConvention.md +++ /dev/null @@ -1,39 +0,0 @@ -# Commit Convention - -## Commit Message Structure - -``` -/#: -``` - -### Type - -| Type | Description | -| ---------- | ------------------------------------------------------------ | -| `feat` | ์ƒˆ๋กœ์šด ๊ธฐ๋Šฅ ์ถ”๊ฐ€ | -| `fix` | ๋ฒ„๊ทธ ์ˆ˜์ • | -| `docs` | ๋ฌธ์„œ ์ˆ˜์ • | -| `style` | ์ฝ”๋“œ ํฌ๋งทํŒ…, ์„ธ๋ฏธ์ฝœ๋ก  ๋ˆ„๋ฝ, ์ฝ”๋“œ ๋ณ€๊ฒฝ์ด ์—†๋Š” ๊ฒฝ์šฐ | -| `refactor` | ์ฝ”๋“œ ๋ฆฌํŒฉํ† ๋ง | -| `test` | ํ…Œ์ŠคํŠธ ์ฝ”๋“œ, ๋ฆฌํŒฉํ† ๋ง ํ…Œ์ŠคํŠธ ์ฝ”๋“œ ์ถ”๊ฐ€ | -| `chore` | ๋นŒ๋“œ ์—…๋ฌด ์ˆ˜์ •, ํŒจํ‚ค์ง€ ๋งค๋‹ˆ์ € ์ˆ˜์ • | -| `add` | ์—์…‹ ์ถ”๊ฐ€ ๋“ฑ | -| `ci` | CI ๊ด€๋ จ ์„ค์ • | -| `build` | ๋นŒ๋“œ ๊ด€๋ จ ํŒŒ์ผ ์ˆ˜์ • | -| `perf` | ์„ฑ๋Šฅ ๊ฐœ์„  | - - -### Subject - -- ์ œ๋ชฉ์€ 50์ž๋ฅผ ๋„˜๊ธฐ์ง€ ์•Š๋„๋ก ํ•ฉ๋‹ˆ๋‹ค. -- ์ฒซ ๊ธ€์ž๋Š” ๋Œ€๋ฌธ์ž๋กœ ์ž‘์„ฑํ•ฉ๋‹ˆ๋‹ค. -- ๋งˆ์นจํ‘œ ๋ฐ ํŠน์ˆ˜๊ธฐํ˜ธ๋Š” ์‚ฌ์šฉํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. -- ๊ณผ๊ฑฐ์‹œ์ œ๊ฐ€ ์•„๋‹Œ ํ˜„์žฌ์‹œ์ œ๋กœ ๋ช…๋ น๋ฌธ์œผ๋กœ ์ž‘์„ฑํ•ฉ๋‹ˆ๋‹ค. - - - -### Example - -``` -feat/#10: ๋„คํŠธ์›Œํฌ ์—๋Ÿฌ ๊ณตํ†ต Error View ์ปดํฌ๋„ŒํŠธ ๊ตฌํ˜„ -``` diff --git a/docs/Modularization.md b/docs/Modularization.md deleted file mode 100644 index 6bf158e0..00000000 --- a/docs/Modularization.md +++ /dev/null @@ -1,71 +0,0 @@ -ํ”„๋กœ์ ํŠธ๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๋ชจ๋“ˆ ๊ตฌ์กฐ๋ฅผ ๋”ฐ๋ฅธ๋‹ค. - -```text -app -domain -core:data -core:network -core:datastore -core:ui -core:designsystem -feature:auth -feature:home -feature:upload -feature:mypage -build-logic -``` - -## ๊ฐ ๋ชจ๋“ˆ์˜ ์—ญํ•  ์ •์˜ - -### app ๋ชจ๋“ˆ - -- ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์ง„์ž…์  -- Hilt ์—”ํŠธ๋ฆฌํฌ์ธํŠธ ์ •์˜ -- ์ „์—ญ Navigation ๊ทธ๋ž˜ํ”„ ๊ด€๋ฆฌ (Navigation3) -- feature ๋ชจ๋“ˆ ๋ฐ”์ธ๋”ฉ - -app ๋ชจ๋“ˆ์€ **๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์„ ํฌํ•จํ•˜์ง€ ์•Š๋Š”๋‹ค.** - -### feature ๋ชจ๋“ˆ - -๊ฐ feature ๋ชจ๋“ˆ์€ **ํ™”๋ฉด ๋ฐ ์‚ฌ์šฉ์ž ํ”Œ๋กœ์šฐ ๋‹จ์œ„**๋กœ ๋ถ„๋ฆฌ๋œ๋‹ค. -๋ชจ๋“  feature ๋ชจ๋“ˆ์€ ๋‹ค์Œ ์š”์†Œ๋ฅผ ํฌํ•จํ•œ๋‹ค. - -- Compose UI -- ViewModel -- Navigation Route ์ •์˜ - -### domain ๋ชจ๋“ˆ - -- ์ˆœ์ˆ˜ Kotlin ๋ชจ๋“ˆ -- ๋น„์ฆˆ๋‹ˆ์Šค ๋ชจ๋ธ ๋ฐ Repository ์ธํ„ฐํŽ˜์ด์Šค ์ •์˜ -- Android ํ”„๋ ˆ์ž„์›Œํฌ ์˜์กด์„ฑ ์—†์Œ - -#### ํฌํ•จ ์š”์†Œ -- Domain Model -- Repository ์ธํ„ฐํŽ˜์ด์Šค -- ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ - -### core:data ๋ชจ๋“ˆ - -- ์™ธ๋ถ€ ๋ฐ์ดํ„ฐ ์†Œ์Šค ์ ‘๊ทผ ์ฑ…์ž„ -- Retrofit2 + OkHttp ๊ธฐ๋ฐ˜ ๋„คํŠธ์›Œํฌ ํ†ต์‹  -- Repository ๊ตฌํ˜„์ฒด ์ œ๊ณต - -domain ๊ณ„์ธต๊ณผ feature ๊ณ„์ธต์—๋Š” ์˜ํ–ฅ์„ ์ฃผ์ง€ ์•Š๋Š”๋‹ค. - -### core:ui ๋ชจ๋“ˆ - -- ๊ณต์šฉ UI ์ปดํฌ๋„ŒํŠธ ์ •์˜ -- ๋ฒ„ํŠผ, ๋ฆฌ์ŠคํŠธ ์•„์ดํ…œ, ๋กœ๋”ฉ UI ๋“ฑ ์žฌ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ Composable ํฌํ•จ - -### core:designsystem ๋ชจ๋“ˆ - -- ์ƒ‰์ƒ, ํƒ€์ดํฌ๊ทธ๋ž˜ํ”ผ, spacing ๋“ฑ ๋””์ž์ธ ํ† ํฐ ์ •์˜ -- feature ๋ชจ๋“ˆ์—์„œ๋Š” ๋””์ž์ธ ๊ฐ’์„ ์ง์ ‘ ์ •์˜ํ•˜์ง€ ์•Š๋Š”๋‹ค. - -## ์˜์กด์„ฑ ๊ด€๋ฆฌ - -- ๋ชจ๋“  ๋ชจ๋“ˆ์€ **build-logic ๊ธฐ๋ฐ˜ ์ปค์Šคํ…€ ์ปจ๋ฒค์…˜ ํ”Œ๋Ÿฌ๊ทธ์ธ**์„ ์‚ฌ์šฉํ•˜์—ฌ ์„ค์ • -- ๋ชจ๋“ˆ๋ณ„ ์˜์กด์„ฑ ์ค‘๋ณต ์ œ๊ฑฐ -- Gradle ์„ค์ •์˜ ์ผ๊ด€์„ฑ ์œ ์ง€ diff --git a/docs/PR-bugfix-65-qa-2.md b/docs/PR-bugfix-65-qa-2.md deleted file mode 100644 index 3c9c6d06..00000000 --- a/docs/PR-bugfix-65-qa-2.md +++ /dev/null @@ -1,66 +0,0 @@ -## ๐Ÿ›  Related issue -closed #65 - -์–ด๋–ค ๋ณ€๊ฒฝ์‚ฌํ•ญ์ด ์žˆ์—ˆ๋‚˜์š”? -- [x] ๐Ÿž BugFix Something isn't working -- [ ] ๐ŸŽจ Design Markup & styling -- [ ] ๐Ÿ“ƒ Docs Documentation writing and editing (README.md, etc.) -- [x] โœจ Feature Feature -- [x] ๐Ÿ”จ Refactor Code refactoring -- [ ] โš™๏ธ Setting Development environment setup -- [ ] โœ… Test Test related (Junit, etc.) - -## โœ… CheckPoint -PR์ด ๋‹ค์Œ ์š”๊ตฌ ์‚ฌํ•ญ์„ ์ถฉ์กฑํ•˜๋Š”์ง€ ํ™•์ธํ•˜์„ธ์š”. - -- [x] PR ์ปจ๋ฒค์…˜์— ๋งž๊ฒŒ ์ž‘์„ฑํ–ˆ์Šต๋‹ˆ๋‹ค. (ํ•„์ˆ˜) -- [x] mergeํ•  ๋ธŒ๋žœ์น˜์˜ ์œ„์น˜๋ฅผ ํ™•์ธํ•ด ์ฃผ์„ธ์š”(mainโŒ/developโญ•) (ํ•„์ˆ˜) -- [x] Approve๋œ PR์€ assigner๊ฐ€ ๋จธ์ง€ํ•˜๊ณ , ์ˆ˜์ • ์š”์ฒญ์ด ์˜จ ๊ฒฝ์šฐ ์ˆ˜์ • ํ›„ ๋‹ค์‹œ push๋ฅผ ํ•ฉ๋‹ˆ๋‹ค. (ํ•„์ˆ˜) -- [x] BugFix์˜ ๊ฒฝ์šฐ, ๋ฒ„๊ทธ์˜ ์›์ธ์„ ํŒŒ์•…ํ•˜์˜€์Šต๋‹ˆ๋‹ค. (์„ ํƒ) - -## โœ๏ธ Work Description - -### ๐Ÿž BugFix - -- **๋กœ๊ทธ์ธ ์„ฑ๊ณต ์‹œ ๋ฐฑ์Šคํƒ ๋ฏธ์ œ๊ฑฐ** - - ์›์ธ: `onLoginSuccess`์—์„œ `popUpTo(SPLASH_ROUTE)`๋ฅผ ์‚ฌ์šฉํ–ˆ์œผ๋‚˜, `navigateToLogin()` ํ˜ธ์ถœ ์‹œ์ ์— ์ด๋ฏธ SPLASH_ROUTE๊ฐ€ ๋ฐฑ์Šคํƒ์—์„œ ์ œ๊ฑฐ๋œ ์ƒํƒœ๋ผ AUTH_ROUTE๊ฐ€ ์ž”๋ฅ˜ - - ์ˆ˜์ •: `popUpTo(AUTH_ROUTE) { inclusive = true }`๋กœ ๋ณ€๊ฒฝ (`BuyOrNotNavHost.kt`) - -- **ํˆฌํ‘œ ํ”ผ๋“œ ์ค‘๋ณต ์—…๋กœ๋“œ** - - ์›์ธ: ์—…๋กœ๋“œ ๋ฒ„ํŠผ ํ™œ์„ฑํ™” ์กฐ๊ฑด์— `isLoading` ์ฒดํฌ ๋ˆ„๋ฝ - - ์ˆ˜์ •: `uiState.isLoading` ์ค‘ ์—…๋กœ๋“œ ๋ฒ„ํŠผ ๋น„ํ™œ์„ฑํ™” ๋ฐ ViewModel์—์„œ ์ด์ค‘ ํ˜ธ์ถœ ๋ฐฉ์–ด (`UploadScreen.kt`, `UploadViewModel.kt`) - -- **ํšŒ์›ํƒˆํ‡ด ๋‹ค์ด์–ผ๋กœ๊ทธ ๋ฏธ๋…ธ์ถœ** - - ์›์ธ: `WithdrawalScreen`์—์„œ `onShowWithdrawalDialog` / `onDismissWithdrawalDialog` ์ฝœ๋ฐฑ์ด ์ „๋‹ฌ๋˜์ง€ ์•Š์Œ - - ์ˆ˜์ •: ๋ˆ„๋ฝ๋œ ์ฝœ๋ฐฑ ์—ฐ๊ฒฐ (`WithdrawalScreen.kt`) - -- **ํˆฌํ‘œ ๋‚™๊ด€์  ์—…๋ฐ์ดํŠธ ์‹œ ๋“ํ‘œ์œจ ๋…ธ์ถœ** - - ์›์ธ: ๋‚™๊ด€์  ์—…๋ฐ์ดํŠธ๋กœ ํˆฌํ‘œ ์ˆ˜๊ฐ€ ์ž„์‹œ ๋ณ€๊ฒฝ๋  ๋•Œ ํˆฌํ‘œ ๊ฒฐ๊ณผ(๋“ํ‘œ์œจ ๋ฐ”)๊ฐ€ ๋…ธ์ถœ๋จ - - ์ˆ˜์ •: API ์‘๋‹ต ํ™•์ • ์ „๊นŒ์ง€ UI์— ๊ฒฐ๊ณผ๋ฅผ ๋ฐ˜์˜ํ•˜์ง€ ์•Š๋„๋ก ์ฒ˜๋ฆฌ (`HomeViewModel.kt`) - -### โœจ Feature - -- **์Šค๋‚ต๋ฐ” ์ตœ์‹  ๋ฉ”์‹œ์ง€ ์ฆ‰์‹œ ๊ต์ฒด** - - ๊ธฐ์กด: `Mutex`๋กœ ์ด์ „ ์Šค๋‚ต๋ฐ” ์ข…๋ฃŒ ๋Œ€๊ธฐ ํ›„ ๋‹ค์Œ ๋ฉ”์‹œ์ง€ ํ‘œ์‹œ - - ๋ณ€๊ฒฝ: `currentSnackbarData?.dismiss()` ํ˜ธ์ถœ๋กœ ํ˜„์žฌ ์Šค๋‚ต๋ฐ”๋ฅผ ์ฆ‰์‹œ ๋‹ซ๊ณ  ์ƒˆ ๋ฉ”์‹œ์ง€ ๋…ธ์ถœ - - ์• ๋‹ˆ๋ฉ”์ด์…˜๋„ ์ผ€์ด์Šค ๋ถ„๋ฆฌ: ๊ต์ฒด(push down) / ์ตœ์ดˆ ๋“ฑ์žฅ(slide up) / dismiss(slide down) (`SnackBar.kt`) - -### ๐Ÿ”จ Refactor - -- **HomeScreen ํ—ค๋” ๊ตฌํ˜„ ๋‹จ์ˆœํ™”** - - ๊ธฐ์กด: `SubcomposeLayout`์œผ๋กœ TopBarยทTab ๋†’์ด๋ฅผ ๋ณ„๋„ ์ธก์ • โ†’ `NestedScrollConnection` + `offset`์œผ๋กœ ์ˆ˜๋™ ์ œ์–ด - - ๋ณ€๊ฒฝ: TopBar๋ฅผ LazyColumn์˜ ์ผ๋ฐ˜ `item`์œผ๋กœ, Tab์„ `stickyHeader`๋กœ ๋ฐฐ์น˜ - - ์ œ๊ฑฐ: `SubcomposeLayout`, `HomeHeader` composable, `NestedScrollConnection`, `topBarHeightPx`/`tabHeightPx` state (`HomeScreen.kt`) - -## ๐Ÿ˜… Uncompleted Tasks -- N/A - -## ๐Ÿ“ข To Reviewers - -- ์Šค๋‚ต๋ฐ” mutex ์ œ๊ฑฐ ํ›„ ๋น ๋ฅด๊ฒŒ ์—ฐ์† ๋ฉ”์‹œ์ง€๊ฐ€ ์˜ค๋Š” ์ผ€์ด์Šค์—์„œ `SnackbarHostState` ๋‚ด๋ถ€ mutex๊ฐ€ race condition ์—†์ด ์ฒ˜๋ฆฌํ•˜๋Š”์ง€ ํ™•์ธ ๋ถ€ํƒ๋“œ๋ฆฝ๋‹ˆ๋‹ค. -- HomeScreen ๋ฆฌํŒฉํ† ๋ง์—์„œ `stickyHeader`๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ์Šคํฌ๋กค ์‹œ TopBar๋Š” ์˜ฌ๋ผ๊ฐ€๊ณ  Tab๋งŒ ๊ณ ์ •๋˜๋Š” ๋™์ž‘์ด LazyColumn ๊ธฐ๋ณธ ๋™์ž‘์œผ๋กœ ์ž์—ฐ์Šค๋Ÿฝ๊ฒŒ ์ง€์›๋ฉ๋‹ˆ๋‹ค. - -## ๐Ÿ“ƒ RCA ๋ฃฐ -- R: ๊ผญ ๋ฐ˜์˜ํ•ด ์ฃผ์„ธ์š”. ์ ๊ทน์ ์œผ๋กœ ๊ณ ๋ คํ•ด ์ฃผ์„ธ์š”. (Request changes) -- C: ์›ฌ๋งŒํ•˜๋ฉด ๋ฐ˜์˜ํ•ด ์ฃผ์„ธ์š”. (Comment) -- A: ๋ฐ˜์˜ํ•ด๋„ ์ข‹๊ณ  ๋„˜์–ด๊ฐ€๋„ ์ข‹์Šต๋‹ˆ๋‹ค. ๊ทธ๋ƒฅ ์‚ฌ์†Œํ•œ ์˜๊ฒฌ์ž…๋‹ˆ๋‹ค. (Approve) diff --git a/docs/SnackbarStateManagement.md b/docs/SnackbarStateManagement.md deleted file mode 100644 index a29cc7f6..00000000 --- a/docs/SnackbarStateManagement.md +++ /dev/null @@ -1,51 +0,0 @@ -### ์ค‘์•™ ์Šค๋‚ต๋ฐ” ๊ด€๋ฆฌ ์ „๋žต (์ƒํƒœ ํ˜ธ์ด์ŠคํŒ… ๋ฐฉ์‹) - -**1. ๋ชฉํ‘œ (Goal)** -- `BuyOrNotApp.kt`์˜ ์ตœ์ƒ๋‹จ `Scaffold`์—์„œ ์Šค๋‚ต๋ฐ”๋ฅผ ์ค‘์•™ ๊ด€๋ฆฌํ•œ๋‹ค. -- `core:common`๊ณผ `core:designsystem` ๊ฐ„์˜ ๋ถ€์ ์ ˆํ•œ ์˜์กด์„ฑ ๋ฌธ์ œ๋ฅผ ํšŒํ”ผํ•œ๋‹ค. -- Compose์˜ ์ƒํƒœ ํ˜ธ์ด์ŠคํŒ…(State Hoisting)๊ณผ `CompositionLocal` ํŒจํ„ด์„ ํ™œ์šฉํ•œ๋‹ค. - -**2. ํ•ต์‹ฌ ์•„์ด๋””์–ด (Core Idea)** -- ์Šค๋‚ต๋ฐ” ํ‘œ์‹œ์— ํ•„์š”ํ•œ ์ƒํƒœ(`SnackbarHostState`)์™€ ๋กœ์ง(`showBuyOrNotSnackBar`)์„ ํฌํ•จํ•˜๋Š” `BuyOrNotSnackbarState` ํด๋ž˜์Šค๋ฅผ UI ๊ณ„์ธต(`app` ๋ชจ๋“ˆ)์— ์ƒ์„ฑํ•œ๋‹ค. -- ์ด ์ƒํƒœ ๊ฐ์ฒด๋ฅผ `BuyOrNotApp`์—์„œ `remember`๋กœ ์ƒ์„ฑํ•˜๊ณ , `CompositionLocal`์„ ํ†ตํ•ด ์•ฑ์˜ ์ „์ฒด UI ํŠธ๋ฆฌ์— ์ œ๊ณตํ•œ๋‹ค. -- ๊ฐ ํ™”๋ฉด์˜ `ViewModel`์€ ์ด์ „์ฒ˜๋Ÿผ `ShowSnackbar` `SideEffect`๋ฅผ ๋ฐœ์ƒ์‹œํ‚จ๋‹ค. -- ๊ฐ ํ™”๋ฉด์˜ `...Route` ์ปดํฌ์ €๋ธ”์€ `CompositionLocal`๋กœ๋ถ€ํ„ฐ `BuyOrNotSnackbarState`๋ฅผ ์–ป๊ณ , `SideEffect`๋ฅผ ๊ตฌ๋…ํ•˜์—ฌ `snackbarState.show(...)`๋ฅผ ํ˜ธ์ถœํ•œ๋‹ค. - -**3. ๊ตฌํ˜„ ๋‹จ๊ณ„ (Implementation Steps)** - -- **`BuyOrNotSnackbarState` ํด๋ž˜์Šค ๋ฐ `CompositionLocal` ์ •์˜:** - - **๊ฒฝ๋กœ**: `core/ui/src/main/java/com/sseotdabwa/buyornot/core/ui/BuyOrNotSnackbarState.kt` - - **`BuyOrNotSnackbarState` ๋‚ด์šฉ**: - - `SnackbarHostState`์™€ `CoroutineScope`๋ฅผ ๋‚ด๋ถ€ ํ”„๋กœํผํ‹ฐ๋กœ ๊ฐ–๋Š”๋‹ค. - - `showBuyOrNotSnackBar`๋ฅผ ํ˜ธ์ถœํ•˜๋Š” `fun show(message: String, icon: IconResource? = null, iconTint: SnackBarIconTint = SnackBarIconTint.Success)` ๋ฉ”์„œ๋“œ๋ฅผ ๋…ธ์ถœํ•œ๋‹ค. - - **`remember...` ํ•จ์ˆ˜ ๋‚ด์šฉ**: `rememberBuyOrNotSnackbarState` ๋ผ๋Š” `@Composable` ํŒฉํ† ๋ฆฌ ํ•จ์ˆ˜๋ฅผ ๋งŒ๋“ค์–ด `remember` ๋กœ์ง์„ ์บก์Аํ™”ํ•œ๋‹ค. - - **`CompositionLocal` ๋‚ด์šฉ**: `val LocalSnackbarState = compositionLocalOf { error("SnackbarState not provided") }` ์™€ ๊ฐ™์ด `CompositionLocal`์„ ํ•จ๊ป˜ ์ •์˜ํ•œ๋‹ค. - -- **`BuyOrNotApp.kt` ์ˆ˜์ •:** - - `rememberBuyOrNotSnackbarState()`๋ฅผ ํ˜ธ์ถœํ•˜์—ฌ `snackbarState` ์ธ์Šคํ„ด์Šค๋ฅผ ์ƒ์„ฑํ•œ๋‹ค. - - `CompositionLocalProvider(LocalSnackbarState provides snackbarState)`๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ `BuyOrNotNavHost`๋ฅผ ๊ฐ์‹ผ๋‹ค. - - ์ตœ์ƒ๋‹จ `Scaffold`์˜ `snackbarHost` ํŒŒ๋ผ๋ฏธํ„ฐ์— `BuyOrNotSnackBarHost(snackbarState.snackbarHostState)`๋ฅผ ์„ค์ •ํ•œ๋‹ค. - -- **`SideEffect` ์ˆ˜์ •:** - - ์Šค๋‚ต๋ฐ”์— ์•„์ด์ฝ˜์„ ํ‘œ์‹œํ•  ์ˆ˜ ์žˆ๋„๋ก `ShowSnackbar` `SideEffect` ๋ฐ์ดํ„ฐ ํด๋ž˜์Šค์— `icon: IconResource? = null` ๋ฐ `iconTint: SnackBarIconTint = SnackBarIconTint.Success` ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ์ถ”๊ฐ€ํ•œ๋‹ค. (๋Œ€์ƒ: `LoginSideEffect.kt`, `MyPageSideEffect.kt` ๋“ฑ) - -- **๊ฐœ๋ณ„ ํ™”๋ฉด UI (`...Route`) ์ˆ˜์ •:** - - **๋Œ€์ƒ ํŒŒ์ผ**: `LoginScreen.kt`, `MyPageScreen.kt`, `AccountSettingScreen.kt`, `WithdrawalScreen.kt` - - ๊ฐ ํŒŒ์ผ์˜ `...Route` ์ปดํฌ์ €๋ธ”์—์„œ ๋กœ์ปฌ `Scaffold`์™€ `SnackbarHostState`๋ฅผ **์‚ญ์ œ**ํ•œ๋‹ค. - - `LocalSnackbarState.current`๋ฅผ ํ†ตํ•ด ์ค‘์•™ `snackbarState`์— ์ ‘๊ทผํ•œ๋‹ค. - - `LaunchedEffect`์—์„œ `ViewModel`์˜ `ShowSnackbar` `SideEffect`๋ฅผ `collect`ํ•˜๊ณ , `snackbarState.show(sideEffect.message, sideEffect.icon, sideEffect.iconTint)`๋ฅผ ํ˜ธ์ถœํ•œ๋‹ค. - -- **ViewModel ์ˆ˜์ •:** - - `SnackbarManager`๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๋Œ€์‹ , ๊ธฐ์กด์ฒ˜๋Ÿผ `ShowSnackbar` `SideEffect`๋ฅผ ๋ฐœ์ƒ์‹œํ‚ค๋„๋ก ์œ ์ง€ํ•˜๊ฑฐ๋‚˜ ๋ณต์›ํ•œ๋‹ค. (์ด ์ „๋žต์—์„œ๋Š” ViewModel์„ ์ˆ˜์ •ํ•  ํ•„์š”๊ฐ€ ์—†์Œ) - - -**4. ์žฅ๋‹จ์  (Pros and Cons)** - -- **์žฅ์ **: - - **๋ชจ๋“ˆ ์˜์กด์„ฑ ํ•ด๊ฒฐ**: `core:common`์ด `core:designsystem`์— ์˜์กดํ•  ํ•„์š”๊ฐ€ ์—†์–ด์ง„๋‹ค. ๋ชจ๋“  UI ๊ด€๋ จ ๋กœ์ง์ด UI ๊ณ„์ธต์— ๋จธ๋ฌด๋ฅธ๋‹ค. - - **Compose ์นœํ™”์ **: `CompositionLocal`์„ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์€ Compose UI ํŠธ๋ฆฌ ๋‚ด์—์„œ ์ƒํƒœ๋ฅผ ๊ณต์œ ํ•˜๋Š” ์ž์—ฐ์Šค๋Ÿฌ์šด ๋ฐฉ๋ฒ•์ด๋‹ค. - -- **๋‹จ์ **: - - **ํ…Œ์ŠคํŠธ ๋ณต์žก์„ฑ**: `...Route` ์ปดํฌ์ €๋ธ”์„ UI ํ…Œ์ŠคํŠธํ•˜๋ ค๋ฉด `CompositionLocalProvider`๋ฅผ ํ†ตํ•ด `BuyOrNotSnackbarState`์˜ Mock ๊ฐ์ฒด๋ฅผ ์ œ๊ณตํ•ด์•ผ ํ•˜๋Š” ๋“ฑ ํ…Œ์ŠคํŠธ ์„ค์ •์ด ๋” ๋ณต์žกํ•ด์ง„๋‹ค. - - **๋ณด์ผ๋Ÿฌํ”Œ๋ ˆ์ดํŠธ**: ๊ฐ `...Route` ์ปดํฌ์ €๋ธ”๋งˆ๋‹ค `LocalSnackbarState.current`๋ฅผ ํ˜ธ์ถœํ•˜๊ณ  `LaunchedEffect`๋ฅผ ์„ค์ •ํ•˜๋Š” ์ฝ”๋“œ๊ฐ€ ๋ฐ˜๋ณต๋œ๋‹ค. (`SnackbarManager` ๋ฐฉ์‹์—์„œ๋Š” ์ด ๋กœ์ง์ด `BuyOrNotApp`์— ๋‹จ ํ•œ ๋ฒˆ๋งŒ ์กด์žฌํ–ˆ๋‹ค.) - - **๋‚ฎ์€ ์œ ์—ฐ์„ฑ**: ์Šค๋‚ต๋ฐ”๊ฐ€ ์•„๋‹Œ ๋‹ค๋ฅธ ๋ฐฉ์‹(์˜ˆ: ํ‘ธ์‹œ ์•Œ๋ฆผ)์œผ๋กœ ๋ฉ”์‹œ์ง€๋ฅผ ๋ณด๋‚ด๊ณ  ์‹ถ์„ ๊ฒฝ์šฐ, UI ์ƒํƒœ์— ์˜์กด์ ์ธ ํ˜„์žฌ ๊ตฌ์กฐ๋Š” ํ™•์žฅ์ด ๋” ์–ด๋ ต๋‹ค. From e03f969c06a158d30b4b795608236e6aca6231e5 Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Fri, 3 Apr 2026 00:00:50 +0900 Subject: [PATCH 10/12] =?UTF-8?q?fix/#83:=20=EC=86=8C=ED=94=84=ED=8A=B8=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20=EC=8A=A4=ED=94=8C=EB=9E=98?= =?UTF-8?q?=EC=8B=9C=20=EC=98=81=EA=B5=AC=20=EB=8C=80=EA=B8=B0=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../sseotdabwa/buyornot/feature/auth/ui/SplashViewModel.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashViewModel.kt b/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashViewModel.kt index c564cdda..3da8cc40 100644 --- a/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashViewModel.kt +++ b/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashViewModel.kt @@ -117,8 +117,11 @@ class SplashViewModel private fun dismissSoftUpdate() { viewModelScope.launch { - appPreferencesRepository.updateLastSoftUpdateShownTime(System.currentTimeMillis()) - updateState { it.copy(updateDialogType = UpdateDialogType.None) } + try { + appPreferencesRepository.updateLastSoftUpdateShownTime(System.currentTimeMillis()) + } finally { + updateState { it.copy(updateDialogType = UpdateDialogType.None) } + } } } } From 9f7619d7b6cf9464d0923b98fe8cd3ff47fa4c88 Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Fri, 3 Apr 2026 00:02:40 +0900 Subject: [PATCH 11/12] =?UTF-8?q?fix/#83:=20fetchAndActivate=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EC=8B=9C=20=EC=BA=90=EC=8B=9C/=EA=B8=B0=EB=B3=B8?= =?UTF-8?q?=EA=B0=92=EC=9C=BC=EB=A1=9C=20=ED=8F=B4=EB=B0=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../core/data/datasource/RemoteConfigDataSourceImpl.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/datasource/RemoteConfigDataSourceImpl.kt b/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/datasource/RemoteConfigDataSourceImpl.kt index eb453b9d..19b3c784 100644 --- a/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/datasource/RemoteConfigDataSourceImpl.kt +++ b/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/datasource/RemoteConfigDataSourceImpl.kt @@ -32,7 +32,10 @@ class RemoteConfigDataSourceImpl ), ).await() - val activated = remoteConfig.fetchAndActivate().await() + val activated = + runCatching { remoteConfig.fetchAndActivate().await() } + .onFailure { Log.w(TAG, "fetchAndActivate failed, using cached/default values", it) } + .getOrDefault(false) Log.d(TAG, "fetchAndActivate: activated=$activated") val latestVersion = remoteConfig.getLong(KEY_LATEST_VERSION).toInt() From 12c0dc691f4faece8a4d5de2382bad8084a8e1ee Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Fri, 3 Apr 2026 00:08:35 +0900 Subject: [PATCH 12/12] =?UTF-8?q?chore/#83:=20@Inject=20constructor=20?= =?UTF-8?q?=ED=95=9C=20=EC=A4=84=20=EC=84=A0=EC=96=B8=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../buyornot/ui/BuyOrNotViewModel.kt | 30 ++-- .../datasource/RemoteConfigDataSourceImpl.kt | 100 ++++++------ .../repository/AppUpdateRepositoryImpl.kt | 12 +- .../datastore/AppPreferencesDataSourceImpl.kt | 80 +++++----- .../UserPreferencesDataSourceImpl.kt | 134 ++++++++-------- .../buyornot/core/network/AuthEventBus.kt | 14 +- .../feature/auth/ui/SplashViewModel.kt | 148 +++++++++--------- 7 files changed, 252 insertions(+), 266 deletions(-) diff --git a/app/src/main/java/com/sseotdabwa/buyornot/ui/BuyOrNotViewModel.kt b/app/src/main/java/com/sseotdabwa/buyornot/ui/BuyOrNotViewModel.kt index 79f6f012..3035439a 100644 --- a/app/src/main/java/com/sseotdabwa/buyornot/ui/BuyOrNotViewModel.kt +++ b/app/src/main/java/com/sseotdabwa/buyornot/ui/BuyOrNotViewModel.kt @@ -10,22 +10,20 @@ import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel -class BuyOrNotViewModel - @Inject - constructor( - private val appPreferencesRepository: AppPreferencesRepository, - ) : ViewModel() { - val isFirstRun = - appPreferencesRepository.isFirstRun - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5000), - initialValue = false, - ) +class BuyOrNotViewModel @Inject constructor( + private val appPreferencesRepository: AppPreferencesRepository, +) : ViewModel() { + val isFirstRun = + appPreferencesRepository.isFirstRun + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = false, + ) - fun updateIsFirstRun(isFirstRun: Boolean) { - viewModelScope.launch { - appPreferencesRepository.updateIsFirstRun(isFirstRun) - } + fun updateIsFirstRun(isFirstRun: Boolean) { + viewModelScope.launch { + appPreferencesRepository.updateIsFirstRun(isFirstRun) } } +} diff --git a/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/datasource/RemoteConfigDataSourceImpl.kt b/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/datasource/RemoteConfigDataSourceImpl.kt index 19b3c784..7205fac0 100644 --- a/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/datasource/RemoteConfigDataSourceImpl.kt +++ b/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/datasource/RemoteConfigDataSourceImpl.kt @@ -10,62 +10,60 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class RemoteConfigDataSourceImpl - @Inject - constructor() : RemoteConfigDataSource { - private val remoteConfig: FirebaseRemoteConfig = FirebaseRemoteConfig.getInstance() +class RemoteConfigDataSourceImpl @Inject constructor() : RemoteConfigDataSource { + private val remoteConfig: FirebaseRemoteConfig = FirebaseRemoteConfig.getInstance() - override suspend fun fetchAppUpdateConfig(): AppUpdateInfo { - val settings = - FirebaseRemoteConfigSettings - .Builder() - .setMinimumFetchIntervalInSeconds(FETCH_INTERVAL_SECONDS) - .build() + override suspend fun fetchAppUpdateConfig(): AppUpdateInfo { + val settings = + FirebaseRemoteConfigSettings + .Builder() + .setMinimumFetchIntervalInSeconds(FETCH_INTERVAL_SECONDS) + .build() - remoteConfig.setConfigSettingsAsync(settings).await() - remoteConfig - .setDefaultsAsync( - mapOf( - KEY_LATEST_VERSION to DEFAULT_VERSION, - KEY_MINIMUM_VERSION to DEFAULT_VERSION, - KEY_UPDATE_STRATEGY to UpdateStrategy.NONE.name, - ), - ).await() + remoteConfig.setConfigSettingsAsync(settings).await() + remoteConfig + .setDefaultsAsync( + mapOf( + KEY_LATEST_VERSION to DEFAULT_VERSION, + KEY_MINIMUM_VERSION to DEFAULT_VERSION, + KEY_UPDATE_STRATEGY to UpdateStrategy.NONE.name, + ), + ).await() - val activated = - runCatching { remoteConfig.fetchAndActivate().await() } - .onFailure { Log.w(TAG, "fetchAndActivate failed, using cached/default values", it) } - .getOrDefault(false) - Log.d(TAG, "fetchAndActivate: activated=$activated") + val activated = + runCatching { remoteConfig.fetchAndActivate().await() } + .onFailure { Log.w(TAG, "fetchAndActivate failed, using cached/default values", it) } + .getOrDefault(false) + Log.d(TAG, "fetchAndActivate: activated=$activated") - val latestVersion = remoteConfig.getLong(KEY_LATEST_VERSION).toInt() - val minimumVersion = remoteConfig.getLong(KEY_MINIMUM_VERSION).toInt() - val strategyRaw = remoteConfig.getString(KEY_UPDATE_STRATEGY) - val updateStrategy = - runCatching { UpdateStrategy.valueOf(strategyRaw) }.getOrDefault(UpdateStrategy.NONE) + val latestVersion = remoteConfig.getLong(KEY_LATEST_VERSION).toInt() + val minimumVersion = remoteConfig.getLong(KEY_MINIMUM_VERSION).toInt() + val strategyRaw = remoteConfig.getString(KEY_UPDATE_STRATEGY) + val updateStrategy = + runCatching { UpdateStrategy.valueOf(strategyRaw) }.getOrDefault(UpdateStrategy.NONE) - Log.d( - TAG, - "Remote Config values โ€” " + - "latestVersion=$latestVersion, " + - "minimumVersion=$minimumVersion, " + - "strategyRaw=$strategyRaw, " + - "resolvedStrategy=$updateStrategy", - ) + Log.d( + TAG, + "Remote Config values โ€” " + + "latestVersion=$latestVersion, " + + "minimumVersion=$minimumVersion, " + + "strategyRaw=$strategyRaw, " + + "resolvedStrategy=$updateStrategy", + ) - return AppUpdateInfo( - latestVersion = latestVersion, - minimumVersion = minimumVersion, - updateStrategy = updateStrategy, - ) - } + return AppUpdateInfo( + latestVersion = latestVersion, + minimumVersion = minimumVersion, + updateStrategy = updateStrategy, + ) + } - companion object { - private const val TAG = "RemoteConfig" - private const val KEY_LATEST_VERSION = "android_latest_version" - private const val KEY_MINIMUM_VERSION = "android_minimum_version" - private const val KEY_UPDATE_STRATEGY = "android_update_strategy" - private const val DEFAULT_VERSION = 1L - private const val FETCH_INTERVAL_SECONDS = 3600L - } + companion object { + private const val TAG = "RemoteConfig" + private const val KEY_LATEST_VERSION = "android_latest_version" + private const val KEY_MINIMUM_VERSION = "android_minimum_version" + private const val KEY_UPDATE_STRATEGY = "android_update_strategy" + private const val DEFAULT_VERSION = 1L + private const val FETCH_INTERVAL_SECONDS = 3600L } +} diff --git a/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/repository/AppUpdateRepositoryImpl.kt b/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/repository/AppUpdateRepositoryImpl.kt index ec9ec7b4..b3723344 100644 --- a/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/repository/AppUpdateRepositoryImpl.kt +++ b/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/repository/AppUpdateRepositoryImpl.kt @@ -5,10 +5,8 @@ import com.sseotdabwa.buyornot.domain.model.AppUpdateInfo import com.sseotdabwa.buyornot.domain.repository.AppUpdateRepository import javax.inject.Inject -class AppUpdateRepositoryImpl - @Inject - constructor( - private val remoteConfigDataSource: RemoteConfigDataSource, - ) : AppUpdateRepository { - override suspend fun getAppUpdateInfo(): AppUpdateInfo = remoteConfigDataSource.fetchAppUpdateConfig() - } +class AppUpdateRepositoryImpl @Inject constructor( + private val remoteConfigDataSource: RemoteConfigDataSource, +) : AppUpdateRepository { + override suspend fun getAppUpdateInfo(): AppUpdateInfo = remoteConfigDataSource.fetchAppUpdateConfig() +} diff --git a/core/datastore/src/main/java/com/sseotdabwa/buyornot/core/datastore/AppPreferencesDataSourceImpl.kt b/core/datastore/src/main/java/com/sseotdabwa/buyornot/core/datastore/AppPreferencesDataSourceImpl.kt index 754c9178..b9075d90 100644 --- a/core/datastore/src/main/java/com/sseotdabwa/buyornot/core/datastore/AppPreferencesDataSourceImpl.kt +++ b/core/datastore/src/main/java/com/sseotdabwa/buyornot/core/datastore/AppPreferencesDataSourceImpl.kt @@ -19,55 +19,53 @@ private val Context.appPreferencesDataStore by preferencesDataStore(name = "app_ * ์‚ฌ์šฉ์ž ์ •๋ณด์™€ ๋ฌด๊ด€ํ•œ ์•ฑ ๋ ˆ๋ฒจ์˜ ์„ค์ •(์•Œ๋ฆผ ๊ถŒํ•œ ๋“ฑ)์„ DataStore๋กœ ๊ด€๋ฆฌํ•ฉ๋‹ˆ๋‹ค. */ @Singleton -class AppPreferencesDataSourceImpl - @Inject - constructor( - @ApplicationContext private val context: Context, - ) : AppPreferencesDataSource { - private object Keys { - val HAS_REQUESTED_NOTIFICATION_PERMISSION = booleanPreferencesKey("has_requested_notification_permission") - val IS_FIRST_RUN = booleanPreferencesKey("is_first_run") - val LAST_SOFT_UPDATE_SHOWN_TIME = longPreferencesKey("last_soft_update_shown_time") - } +class AppPreferencesDataSourceImpl @Inject constructor( + @ApplicationContext private val context: Context, +) : AppPreferencesDataSource { + private object Keys { + val HAS_REQUESTED_NOTIFICATION_PERMISSION = booleanPreferencesKey("has_requested_notification_permission") + val IS_FIRST_RUN = booleanPreferencesKey("is_first_run") + val LAST_SOFT_UPDATE_SHOWN_TIME = longPreferencesKey("last_soft_update_shown_time") + } - override val preferences: Flow = - context.appPreferencesDataStore.data.map { prefs -> - AppPreferences( - hasRequestedNotificationPermission = prefs[Keys.HAS_REQUESTED_NOTIFICATION_PERMISSION] ?: false, - isFirstRun = prefs[Keys.IS_FIRST_RUN] ?: false, - ) - } + override val preferences: Flow = + context.appPreferencesDataStore.data.map { prefs -> + AppPreferences( + hasRequestedNotificationPermission = prefs[Keys.HAS_REQUESTED_NOTIFICATION_PERMISSION] ?: false, + isFirstRun = prefs[Keys.IS_FIRST_RUN] ?: false, + ) + } - override val hasRequestedNotificationPermission: Flow = - context.appPreferencesDataStore.data.map { prefs -> - prefs[Keys.HAS_REQUESTED_NOTIFICATION_PERMISSION] ?: false - } + override val hasRequestedNotificationPermission: Flow = + context.appPreferencesDataStore.data.map { prefs -> + prefs[Keys.HAS_REQUESTED_NOTIFICATION_PERMISSION] ?: false + } - override val isFirstRun: Flow = - context.appPreferencesDataStore.data.map { prefs -> - prefs[Keys.IS_FIRST_RUN] ?: true - } + override val isFirstRun: Flow = + context.appPreferencesDataStore.data.map { prefs -> + prefs[Keys.IS_FIRST_RUN] ?: true + } - override val lastSoftUpdateShownTime: Flow = - context.appPreferencesDataStore.data.map { prefs -> - prefs[Keys.LAST_SOFT_UPDATE_SHOWN_TIME] ?: 0L - } + override val lastSoftUpdateShownTime: Flow = + context.appPreferencesDataStore.data.map { prefs -> + prefs[Keys.LAST_SOFT_UPDATE_SHOWN_TIME] ?: 0L + } - override suspend fun updateNotificationPermissionRequested(requested: Boolean) { - context.appPreferencesDataStore.edit { prefs -> - prefs[Keys.HAS_REQUESTED_NOTIFICATION_PERMISSION] = requested - } + override suspend fun updateNotificationPermissionRequested(requested: Boolean) { + context.appPreferencesDataStore.edit { prefs -> + prefs[Keys.HAS_REQUESTED_NOTIFICATION_PERMISSION] = requested } + } - override suspend fun updateIsFirstRun(isFirstRun: Boolean) { - context.appPreferencesDataStore.edit { prefs -> - prefs[Keys.IS_FIRST_RUN] = isFirstRun - } + override suspend fun updateIsFirstRun(isFirstRun: Boolean) { + context.appPreferencesDataStore.edit { prefs -> + prefs[Keys.IS_FIRST_RUN] = isFirstRun } + } - override suspend fun updateLastSoftUpdateShownTime(timeMillis: Long) { - context.appPreferencesDataStore.edit { prefs -> - prefs[Keys.LAST_SOFT_UPDATE_SHOWN_TIME] = timeMillis - } + override suspend fun updateLastSoftUpdateShownTime(timeMillis: Long) { + context.appPreferencesDataStore.edit { prefs -> + prefs[Keys.LAST_SOFT_UPDATE_SHOWN_TIME] = timeMillis } } +} diff --git a/core/datastore/src/main/java/com/sseotdabwa/buyornot/core/datastore/UserPreferencesDataSourceImpl.kt b/core/datastore/src/main/java/com/sseotdabwa/buyornot/core/datastore/UserPreferencesDataSourceImpl.kt index 3aa9899f..0b909d40 100644 --- a/core/datastore/src/main/java/com/sseotdabwa/buyornot/core/datastore/UserPreferencesDataSourceImpl.kt +++ b/core/datastore/src/main/java/com/sseotdabwa/buyornot/core/datastore/UserPreferencesDataSourceImpl.kt @@ -18,85 +18,83 @@ private val Context.userPreferencesDataStore by preferencesDataStore(name = "use * ์‚ฌ์šฉ์ž ํ”„๋กœํ•„ ๋ฐ ์ธ์ฆ ํ† ํฐ์„ DataStore๋กœ ๊ด€๋ฆฌํ•ฉ๋‹ˆ๋‹ค. */ @Singleton -class UserPreferencesDataSourceImpl - @Inject - constructor( - @ApplicationContext private val context: Context, - ) : UserPreferencesDataSource { - private object Keys { - val DISPLAY_NAME = stringPreferencesKey("display_name") - val PROFILE_IMAGE_URL = stringPreferencesKey("profile_image_url") - val ACCESS_TOKEN = stringPreferencesKey("access_token") - val REFRESH_TOKEN = stringPreferencesKey("refresh_token") - val USER_TYPE = stringPreferencesKey("user_type") - } +class UserPreferencesDataSourceImpl @Inject constructor( + @ApplicationContext private val context: Context, +) : UserPreferencesDataSource { + private object Keys { + val DISPLAY_NAME = stringPreferencesKey("display_name") + val PROFILE_IMAGE_URL = stringPreferencesKey("profile_image_url") + val ACCESS_TOKEN = stringPreferencesKey("access_token") + val REFRESH_TOKEN = stringPreferencesKey("refresh_token") + val USER_TYPE = stringPreferencesKey("user_type") + } - override val preferences: Flow = - context.userPreferencesDataStore.data.map { prefs -> - UserPreferences( - displayName = prefs[Keys.DISPLAY_NAME] ?: UserPreferences().displayName, - profileImageUrl = prefs[Keys.PROFILE_IMAGE_URL] ?: UserPreferences().profileImageUrl, - accessToken = prefs[Keys.ACCESS_TOKEN] ?: UserPreferences().accessToken, - refreshToken = prefs[Keys.REFRESH_TOKEN] ?: UserPreferences().refreshToken, - userType = - prefs[Keys.USER_TYPE]?.let { - try { - UserType.valueOf(it) - } catch (e: IllegalArgumentException) { - UserPreferences().userType - } - } ?: UserPreferences().userType, - ) - } + override val preferences: Flow = + context.userPreferencesDataStore.data.map { prefs -> + UserPreferences( + displayName = prefs[Keys.DISPLAY_NAME] ?: UserPreferences().displayName, + profileImageUrl = prefs[Keys.PROFILE_IMAGE_URL] ?: UserPreferences().profileImageUrl, + accessToken = prefs[Keys.ACCESS_TOKEN] ?: UserPreferences().accessToken, + refreshToken = prefs[Keys.REFRESH_TOKEN] ?: UserPreferences().refreshToken, + userType = + prefs[Keys.USER_TYPE]?.let { + try { + UserType.valueOf(it) + } catch (e: IllegalArgumentException) { + UserPreferences().userType + } + } ?: UserPreferences().userType, + ) + } - override val accessToken: Flow = context.userPreferencesDataStore.data.map { it[Keys.ACCESS_TOKEN] ?: "" } + override val accessToken: Flow = context.userPreferencesDataStore.data.map { it[Keys.ACCESS_TOKEN] ?: "" } - override val userType: Flow = - context.userPreferencesDataStore.data.map { prefs -> - prefs[Keys.USER_TYPE]?.let { - try { - UserType.valueOf(it) - } catch (e: IllegalArgumentException) { - UserType.GUEST - } - } ?: UserType.GUEST - } + override val userType: Flow = + context.userPreferencesDataStore.data.map { prefs -> + prefs[Keys.USER_TYPE]?.let { + try { + UserType.valueOf(it) + } catch (e: IllegalArgumentException) { + UserType.GUEST + } + } ?: UserType.GUEST + } - override suspend fun updateDisplayName(newName: String) { - context.userPreferencesDataStore.edit { prefs -> - prefs[Keys.DISPLAY_NAME] = newName - } + override suspend fun updateDisplayName(newName: String) { + context.userPreferencesDataStore.edit { prefs -> + prefs[Keys.DISPLAY_NAME] = newName } + } - override suspend fun updateProfileImageUrl(newUrl: String) { - context.userPreferencesDataStore.edit { prefs -> - prefs[Keys.PROFILE_IMAGE_URL] = newUrl - } + override suspend fun updateProfileImageUrl(newUrl: String) { + context.userPreferencesDataStore.edit { prefs -> + prefs[Keys.PROFILE_IMAGE_URL] = newUrl } + } - override suspend fun updateTokens( - accessToken: String, - refreshToken: String, - ) { - context.userPreferencesDataStore.edit { prefs -> - prefs[Keys.ACCESS_TOKEN] = accessToken - prefs[Keys.REFRESH_TOKEN] = refreshToken - } + override suspend fun updateTokens( + accessToken: String, + refreshToken: String, + ) { + context.userPreferencesDataStore.edit { prefs -> + prefs[Keys.ACCESS_TOKEN] = accessToken + prefs[Keys.REFRESH_TOKEN] = refreshToken } + } - override suspend fun updateUserType(userType: UserType) { - context.userPreferencesDataStore.edit { prefs -> - prefs[Keys.USER_TYPE] = userType.name - } + override suspend fun updateUserType(userType: UserType) { + context.userPreferencesDataStore.edit { prefs -> + prefs[Keys.USER_TYPE] = userType.name } + } - override suspend fun clearUserInfo() { - context.userPreferencesDataStore.edit { prefs -> - prefs.remove(Keys.ACCESS_TOKEN) - prefs.remove(Keys.REFRESH_TOKEN) - prefs.remove(Keys.PROFILE_IMAGE_URL) - prefs.remove(Keys.DISPLAY_NAME) - prefs[Keys.USER_TYPE] = UserType.GUEST.name - } + override suspend fun clearUserInfo() { + context.userPreferencesDataStore.edit { prefs -> + prefs.remove(Keys.ACCESS_TOKEN) + prefs.remove(Keys.REFRESH_TOKEN) + prefs.remove(Keys.PROFILE_IMAGE_URL) + prefs.remove(Keys.DISPLAY_NAME) + prefs[Keys.USER_TYPE] = UserType.GUEST.name } } +} diff --git a/core/network/src/main/java/com/sseotdabwa/buyornot/core/network/AuthEventBus.kt b/core/network/src/main/java/com/sseotdabwa/buyornot/core/network/AuthEventBus.kt index 6b8477be..ebd8efe8 100644 --- a/core/network/src/main/java/com/sseotdabwa/buyornot/core/network/AuthEventBus.kt +++ b/core/network/src/main/java/com/sseotdabwa/buyornot/core/network/AuthEventBus.kt @@ -10,13 +10,11 @@ enum class AuthEvent { } @Singleton -class AuthEventBus - @Inject - constructor() { - private val _events = MutableSharedFlow() - val events = _events.asSharedFlow() +class AuthEventBus @Inject constructor() { + private val _events = MutableSharedFlow() + val events = _events.asSharedFlow() - suspend fun emit(event: AuthEvent) { - _events.emit(event) - } + suspend fun emit(event: AuthEvent) { + _events.emit(event) } +} diff --git a/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashViewModel.kt b/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashViewModel.kt index 3da8cc40..8d908e24 100644 --- a/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashViewModel.kt +++ b/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashViewModel.kt @@ -30,98 +30,96 @@ private const val TAG = "SplashUpdate" * ์—…๋ฐ์ดํŠธ ํŒ์—…์ด ํ‘œ์‹œ ์ค‘์ด๋ฉด ๋„ค๋น„๊ฒŒ์ด์…˜์„ ์ฐจ๋‹จํ•ฉ๋‹ˆ๋‹ค. */ @HiltViewModel -class SplashViewModel - @Inject - constructor( - @ApplicationContext private val context: Context, - private val userPreferencesRepository: UserPreferencesRepository, - private val appUpdateRepository: AppUpdateRepository, - private val appPreferencesRepository: AppPreferencesRepository, - ) : BaseViewModel(SplashUiState()) { - init { - checkTokenAndNavigate() - } +class SplashViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val userPreferencesRepository: UserPreferencesRepository, + private val appUpdateRepository: AppUpdateRepository, + private val appPreferencesRepository: AppPreferencesRepository, +) : BaseViewModel(SplashUiState()) { + init { + checkTokenAndNavigate() + } - override fun handleIntent(intent: SplashIntent) { - when (intent) { - SplashIntent.DismissSoftUpdate -> dismissSoftUpdate() - } + override fun handleIntent(intent: SplashIntent) { + when (intent) { + SplashIntent.DismissSoftUpdate -> dismissSoftUpdate() } + } - private fun checkTokenAndNavigate() { - viewModelScope.launch { - // ํ† ํฐ ์ฒดํฌ + ์—…๋ฐ์ดํŠธ ์ฒดํฌ ๋ณ‘๋ ฌ ์‹คํ–‰ - val updateInfoDeferred = - async { - runCatching { appUpdateRepository.getAppUpdateInfo() }.getOrNull() - } - - val hasValidToken = - try { - userPreferencesRepository.userType.first() != UserType.GUEST - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - false - } + private fun checkTokenAndNavigate() { + viewModelScope.launch { + // ํ† ํฐ ์ฒดํฌ + ์—…๋ฐ์ดํŠธ ์ฒดํฌ ๋ณ‘๋ ฌ ์‹คํ–‰ + val updateInfoDeferred = + async { + runCatching { appUpdateRepository.getAppUpdateInfo() }.getOrNull() + } - delay(SPLASH_TIMEOUT_MILLIS) + val hasValidToken = + try { + userPreferencesRepository.userType.first() != UserType.GUEST + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + false + } - // ์—…๋ฐ์ดํŠธ ๋‹ค์ด์–ผ๋กœ๊ทธ ํƒ€์ž… ๊ฒฐ์ • - val updateInfo = updateInfoDeferred.await() - val currentVersion = - PackageInfoCompat - .getLongVersionCode(context.packageManager.getPackageInfo(context.packageName, 0)) - .toInt() - val dialogType = determineDialogType(currentVersion, updateInfo) + delay(SPLASH_TIMEOUT_MILLIS) - Log.d(TAG, "currentVersion=$currentVersion, dialogType=$dialogType, updateInfo=$updateInfo") + // ์—…๋ฐ์ดํŠธ ๋‹ค์ด์–ผ๋กœ๊ทธ ํƒ€์ž… ๊ฒฐ์ • + val updateInfo = updateInfoDeferred.await() + val currentVersion = + PackageInfoCompat + .getLongVersionCode(context.packageManager.getPackageInfo(context.packageName, 0)) + .toInt() + val dialogType = determineDialogType(currentVersion, updateInfo) - if (dialogType != UpdateDialogType.None) { - updateState { it.copy(updateDialogType = dialogType) } - // ํŒ์—…์ด ๋‹ซํž ๋•Œ๊นŒ์ง€ ๋„ค๋น„๊ฒŒ์ด์…˜ ์ฐจ๋‹จ - uiState.first { it.updateDialogType == UpdateDialogType.None } - } + Log.d(TAG, "currentVersion=$currentVersion, dialogType=$dialogType, updateInfo=$updateInfo") - if (hasValidToken) { - sendSideEffect(SplashSideEffect.NavigateToHome) - } else { - sendSideEffect(SplashSideEffect.NavigateToLogin) - } + if (dialogType != UpdateDialogType.None) { + updateState { it.copy(updateDialogType = dialogType) } + // ํŒ์—…์ด ๋‹ซํž ๋•Œ๊นŒ์ง€ ๋„ค๋น„๊ฒŒ์ด์…˜ ์ฐจ๋‹จ + uiState.first { it.updateDialogType == UpdateDialogType.None } + } - updateState { it.copy(isLoading = false) } + if (hasValidToken) { + sendSideEffect(SplashSideEffect.NavigateToHome) + } else { + sendSideEffect(SplashSideEffect.NavigateToLogin) } + + updateState { it.copy(isLoading = false) } } + } - private suspend fun determineDialogType( - currentVersion: Int, - updateInfo: com.sseotdabwa.buyornot.domain.model.AppUpdateInfo?, - ): UpdateDialogType { - if (updateInfo == null) return UpdateDialogType.None + private suspend fun determineDialogType( + currentVersion: Int, + updateInfo: com.sseotdabwa.buyornot.domain.model.AppUpdateInfo?, + ): UpdateDialogType { + if (updateInfo == null) return UpdateDialogType.None - return when { - currentVersion < updateInfo.minimumVersion -> UpdateDialogType.Force - updateInfo.updateStrategy == UpdateStrategy.FORCE -> UpdateDialogType.Force - updateInfo.updateStrategy == UpdateStrategy.SOFT && - currentVersion < updateInfo.latestVersion -> { - val lastShown = appPreferencesRepository.lastSoftUpdateShownTime.first() - if (System.currentTimeMillis() - lastShown >= SOFT_UPDATE_INTERVAL_MILLIS) { - UpdateDialogType.Soft - } else { - UpdateDialogType.None - } + return when { + currentVersion < updateInfo.minimumVersion -> UpdateDialogType.Force + updateInfo.updateStrategy == UpdateStrategy.FORCE -> UpdateDialogType.Force + updateInfo.updateStrategy == UpdateStrategy.SOFT && + currentVersion < updateInfo.latestVersion -> { + val lastShown = appPreferencesRepository.lastSoftUpdateShownTime.first() + if (System.currentTimeMillis() - lastShown >= SOFT_UPDATE_INTERVAL_MILLIS) { + UpdateDialogType.Soft + } else { + UpdateDialogType.None } - else -> UpdateDialogType.None } + else -> UpdateDialogType.None } + } - private fun dismissSoftUpdate() { - viewModelScope.launch { - try { - appPreferencesRepository.updateLastSoftUpdateShownTime(System.currentTimeMillis()) - } finally { - updateState { it.copy(updateDialogType = UpdateDialogType.None) } - } + private fun dismissSoftUpdate() { + viewModelScope.launch { + try { + appPreferencesRepository.updateLastSoftUpdateShownTime(System.currentTimeMillis()) + } finally { + updateState { it.copy(updateDialogType = UpdateDialogType.None) } } } } +}