diff --git a/build-logic/src/main/kotlin/AndroidApplicationConventionPlugin.kt b/build-logic/src/main/kotlin/AndroidApplicationConventionPlugin.kt index 9d71f0f6..d31a051b 100644 --- a/build-logic/src/main/kotlin/AndroidApplicationConventionPlugin.kt +++ b/build-logic/src/main/kotlin/AndroidApplicationConventionPlugin.kt @@ -3,6 +3,7 @@ import com.ninecraft.booket.convention.ApplicationConstants import com.ninecraft.booket.convention.Plugins import com.ninecraft.booket.convention.applyPlugins import com.ninecraft.booket.convention.configureAndroid +import com.ninecraft.booket.convention.libs import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.kotlin.dsl.configure @@ -19,9 +20,9 @@ internal class AndroidApplicationConventionPlugin : Plugin { configureAndroid(this) defaultConfig { - targetSdk = ApplicationConstants.TARGET_SDK - versionName = ApplicationConstants.VERSION_NAME - versionCode = ApplicationConstants.VERSION_CODE + targetSdk = libs.versions.targetSdk.get().toInt() + versionName = libs.versions.versionName.get() + versionCode = libs.versions.versionCode.get().toInt() } } } diff --git a/build-logic/src/main/kotlin/AndroidLibraryConventionPlugin.kt b/build-logic/src/main/kotlin/AndroidLibraryConventionPlugin.kt index 39888c94..b430d6b9 100644 --- a/build-logic/src/main/kotlin/AndroidLibraryConventionPlugin.kt +++ b/build-logic/src/main/kotlin/AndroidLibraryConventionPlugin.kt @@ -2,10 +2,10 @@ import com.android.build.gradle.LibraryExtension import com.ninecraft.booket.convention.Plugins import com.ninecraft.booket.convention.applyPlugins import com.ninecraft.booket.convention.configureAndroid +import com.ninecraft.booket.convention.libs import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.kotlin.dsl.configure -import com.ninecraft.booket.convention.ApplicationConstants internal class AndroidLibraryConventionPlugin : Plugin { override fun apply(target: Project) { @@ -19,7 +19,7 @@ internal class AndroidLibraryConventionPlugin : Plugin { configureAndroid(this) defaultConfig.apply { - targetSdk = ApplicationConstants.TARGET_SDK + targetSdk = libs.versions.targetSdk.get().toInt() } } } diff --git a/build-logic/src/main/kotlin/com/ninecraft/booket/convention/Android.kt b/build-logic/src/main/kotlin/com/ninecraft/booket/convention/Android.kt index 75b05af8..b02d0b99 100644 --- a/build-logic/src/main/kotlin/com/ninecraft/booket/convention/Android.kt +++ b/build-logic/src/main/kotlin/com/ninecraft/booket/convention/Android.kt @@ -8,10 +8,10 @@ import org.jetbrains.kotlin.gradle.dsl.KotlinProjectExtension internal fun Project.configureAndroid(extension: CommonExtension<*, *, *, *, *, *>) { extension.apply { - compileSdk = ApplicationConstants.COMPILE_SDK + compileSdk = libs.versions.compileSdk.get().toInt() defaultConfig { - minSdk = ApplicationConstants.MIN_SDK + minSdk = libs.versions.minSdk.get().toInt() } compileOptions { diff --git a/build-logic/src/main/kotlin/com/ninecraft/booket/convention/ApplicationConstants.kt b/build-logic/src/main/kotlin/com/ninecraft/booket/convention/ApplicationConstants.kt index 7dd72860..fc25dc55 100644 --- a/build-logic/src/main/kotlin/com/ninecraft/booket/convention/ApplicationConstants.kt +++ b/build-logic/src/main/kotlin/com/ninecraft/booket/convention/ApplicationConstants.kt @@ -3,11 +3,6 @@ package com.ninecraft.booket.convention import org.gradle.api.JavaVersion internal object ApplicationConstants { - const val MIN_SDK = 28 - const val TARGET_SDK = 35 - const val COMPILE_SDK = 35 - const val VERSION_CODE = 3 - const val VERSION_NAME = "1.0.0" const val JAVA_VERSION_INT = 17 val javaVersion = JavaVersion.VERSION_17 } diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts index 35666a1b..c5624301 100644 --- a/core/common/build.gradle.kts +++ b/core/common/build.gradle.kts @@ -9,6 +9,14 @@ plugins { android { namespace = "com.ninecraft.booket.core.common" + + buildFeatures { + buildConfig = true + } + + defaultConfig { + buildConfigField("String", "PACKAGE_NAME", "\"${libs.versions.packageName.get()}\"") + } } dependencies { diff --git a/core/common/src/main/kotlin/com/ninecraft/booket/core/common/extensions/Context.kt b/core/common/src/main/kotlin/com/ninecraft/booket/core/common/extensions/Context.kt index a922695c..d1fa560b 100644 --- a/core/common/src/main/kotlin/com/ninecraft/booket/core/common/extensions/Context.kt +++ b/core/common/src/main/kotlin/com/ninecraft/booket/core/common/extensions/Context.kt @@ -2,6 +2,9 @@ package com.ninecraft.booket.core.common.extensions import android.content.ContentValues import android.content.Context +import android.content.Intent +import androidx.core.net.toUri +import com.ninecraft.booket.core.common.BuildConfig import android.graphics.Bitmap import android.os.Build import android.os.Environment @@ -41,7 +44,8 @@ fun Context.saveImageToGallery(bitmap: ImageBitmap) { } } - val imageUri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues) + val imageUri = + contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues) imageUri?.let { uri -> contentResolver.openOutputStream(uri)?.use { outputStream -> bitmap.asAndroidBitmap().compress(Bitmap.CompressFormat.PNG, 100, outputStream) @@ -57,3 +61,9 @@ fun Context.saveImageToGallery(bitmap: ImageBitmap) { Logger.e("Failed to save image to gallery: ${e.message}") } } + +fun Context.openPlayStore() { + val intent = + Intent(Intent.ACTION_VIEW, "market://details?id=${BuildConfig.PACKAGE_NAME}".toUri()) + startActivity(intent) +} diff --git a/core/common/src/main/kotlin/com/ninecraft/booket/core/common/util/VersionUtils.kt b/core/common/src/main/kotlin/com/ninecraft/booket/core/common/util/VersionUtils.kt new file mode 100644 index 00000000..c91a6811 --- /dev/null +++ b/core/common/src/main/kotlin/com/ninecraft/booket/core/common/util/VersionUtils.kt @@ -0,0 +1,43 @@ +package com.ninecraft.booket.core.common.util + +import com.orhanobut.logger.Logger + +/** + * 두 버전을 비교하는 함수 + * + * @param version1 첫 번째 버전 (예: "1.2.3") + * @param version2 두 번째 버전 (예: "1.1.0") + * @return 양수면 version1 > version2, 음수면 version1 < version2, 0이면 같음 + * + * 버전 형식: "메이저.마이너.패치" (예: 1.2.3) + * 비교 순서: 메이저 → 마이너 → 패치 버전 순으로 비교 + */ +fun compareVersions(version1: String, version2: String): Int { + Logger.d("compareVersions: version1: $version1, version2: $version2") + + if (!Regex("""^\d+\.\d+\.\d+$""").matches(version1)) return 0 + if (!Regex("""^\d+\.\d+\.\d+$""").matches(version2)) return 0 + + val v1 = version1.split('.').map { it.toInt() } + val v2 = version2.split('.').map { it.toInt() } + + // 메이저 버전 비교 + if (v1[0] != v2[0]) return v1[0] - v2[0] + + // 마이너 버전 비교 + if (v1[1] != v2[1]) return v1[1] - v2[1] + + // 패치 버전 비교 + return v1[2] - v2[2] +} + +/** + * 현재 앱 버전이 최소 요구 버전보다 낮은지 확인하는 함수 + * + * @param currentVersion 현재 앱의 버전 (예: "1.0.0") + * @param minVersion 최소 요구 버전 (Firebase Remote Config에서 가져온 값) + * @return true면 강제 업데이트 필요 (현재 버전 < 최소 요구 버전), false면 업데이트 불필요 + */ +fun isUpdateRequired(currentVersion: String, minVersion: String): Boolean { + return compareVersions(currentVersion, minVersion) < 0 +} diff --git a/core/common/src/main/kotlin/com/ninecraft/booket/core/common/utils/HandleException.kt b/core/common/src/main/kotlin/com/ninecraft/booket/core/common/utils/HandleException.kt index fb2850a2..ed84d6e8 100644 --- a/core/common/src/main/kotlin/com/ninecraft/booket/core/common/utils/HandleException.kt +++ b/core/common/src/main/kotlin/com/ninecraft/booket/core/common/utils/HandleException.kt @@ -19,7 +19,7 @@ import java.net.UnknownHostException fun handleException( exception: Throwable, onError: (String) -> Unit, - onLoginRequired: () -> Unit, + onLoginRequired: () -> Unit = {}, ) { when { exception is HttpException && exception.code() == 401 -> { diff --git a/core/data/api/src/main/kotlin/com/ninecraft/booket/core/data/api/repository/RemoteConfigRepository.kt b/core/data/api/src/main/kotlin/com/ninecraft/booket/core/data/api/repository/RemoteConfigRepository.kt new file mode 100644 index 00000000..7a9dba2e --- /dev/null +++ b/core/data/api/src/main/kotlin/com/ninecraft/booket/core/data/api/repository/RemoteConfigRepository.kt @@ -0,0 +1,6 @@ +package com.ninecraft.booket.core.data.api.repository + +interface RemoteConfigRepository { + suspend fun getLatestVersion(): Result + suspend fun shouldUpdate(): Result +} diff --git a/core/data/impl/build.gradle.kts b/core/data/impl/build.gradle.kts index cd5c5777..f894b8ed 100644 --- a/core/data/impl/build.gradle.kts +++ b/core/data/impl/build.gradle.kts @@ -8,6 +8,14 @@ plugins { android { namespace = "com.ninecraft.booket.core.data.impl" + + buildFeatures { + buildConfig = true + } + + defaultConfig { + buildConfigField("String", "APP_VERSION", "\"${libs.versions.versionName.get()}\"") + } } dependencies { @@ -18,6 +26,8 @@ dependencies { projects.core.model, projects.core.network, + platform(libs.firebase.bom), + libs.firebase.remote.config, libs.logger, ) } diff --git a/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/di/FirebaseModule.kt b/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/di/FirebaseModule.kt new file mode 100644 index 00000000..2a6f28a5 --- /dev/null +++ b/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/di/FirebaseModule.kt @@ -0,0 +1,29 @@ +package com.ninecraft.booket.core.data.impl.di + +import com.google.firebase.Firebase +import com.google.firebase.remoteconfig.FirebaseRemoteConfig +import com.google.firebase.remoteconfig.remoteConfig +import com.google.firebase.remoteconfig.remoteConfigSettings +import com.ninecraft.booket.core.data.impl.BuildConfig +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +internal object FirebaseModule { + @Singleton + @Provides + fun provideRemoteConfig(): FirebaseRemoteConfig { + return Firebase.remoteConfig.apply { + val configSettings by lazy { + remoteConfigSettings { + minimumFetchIntervalInSeconds = if (BuildConfig.DEBUG) 0 else 60 + } + } + setConfigSettingsAsync(configSettings) + } + } +} diff --git a/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/di/RepositoryModule.kt b/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/di/RepositoryModule.kt index 900bbf73..013d61de 100644 --- a/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/di/RepositoryModule.kt +++ b/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/di/RepositoryModule.kt @@ -3,10 +3,12 @@ package com.ninecraft.booket.core.data.impl.di import com.ninecraft.booket.core.data.api.repository.AuthRepository import com.ninecraft.booket.core.data.api.repository.BookRepository import com.ninecraft.booket.core.data.api.repository.RecordRepository +import com.ninecraft.booket.core.data.api.repository.RemoteConfigRepository import com.ninecraft.booket.core.data.api.repository.UserRepository import com.ninecraft.booket.core.data.impl.repository.DefaultAuthRepository import com.ninecraft.booket.core.data.impl.repository.DefaultBookRepository import com.ninecraft.booket.core.data.impl.repository.DefaultRecordRepository +import com.ninecraft.booket.core.data.impl.repository.DefaultRemoteConfigRepository import com.ninecraft.booket.core.data.impl.repository.DefaultUserRepository import dagger.Binds import dagger.Module @@ -33,4 +35,8 @@ internal abstract class RepositoryModule { @Binds @Singleton abstract fun bindRecordRepository(defaultRecordRepository: DefaultRecordRepository): RecordRepository + + @Binds + @Singleton + abstract fun bindRemoteConfigRepository(defaultRemoteConfigRepository: DefaultRemoteConfigRepository): RemoteConfigRepository } diff --git a/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/repository/DefaultRemoteConfigRepository.kt b/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/repository/DefaultRemoteConfigRepository.kt new file mode 100644 index 00000000..ea9733f6 --- /dev/null +++ b/core/data/impl/src/main/kotlin/com/ninecraft/booket/core/data/impl/repository/DefaultRemoteConfigRepository.kt @@ -0,0 +1,46 @@ +package com.ninecraft.booket.core.data.impl.repository + +import com.google.firebase.remoteconfig.FirebaseRemoteConfig +import com.google.firebase.remoteconfig.get +import com.ninecraft.booket.core.common.util.isUpdateRequired +import com.ninecraft.booket.core.data.api.repository.RemoteConfigRepository +import com.ninecraft.booket.core.data.impl.BuildConfig +import com.orhanobut.logger.Logger +import kotlinx.coroutines.suspendCancellableCoroutine +import javax.inject.Inject +import kotlin.coroutines.resume + +class DefaultRemoteConfigRepository @Inject constructor( + private val remoteConfig: FirebaseRemoteConfig, +) : RemoteConfigRepository { + override suspend fun getLatestVersion(): Result = suspendCancellableCoroutine { continuation -> + remoteConfig.fetchAndActivate().addOnCompleteListener { task -> + if (task.isSuccessful) { + val latestVersion = remoteConfig[KEY_LATEST_VERSION].asString() + Logger.d("LatestVersion: $latestVersion") + continuation.resume(Result.success(latestVersion)) + } else { + Logger.e(task.exception, "getLatestVersion failed") + continuation.resume(Result.failure(task.exception ?: Exception("Unknown error"))) + } + } + } + + override suspend fun shouldUpdate(): Result = suspendCancellableCoroutine { continuation -> + remoteConfig.fetchAndActivate().addOnCompleteListener { task -> + if (task.isSuccessful) { + val minVersion = remoteConfig[KEY_MIN_VERSION].asString() + val currentVersion = BuildConfig.APP_VERSION + continuation.resume(Result.success(isUpdateRequired(currentVersion, minVersion))) + } else { + Logger.e(task.exception, "shouldUpdate: getMinVersion failed") + continuation.resume(Result.failure(task.exception ?: Exception("Unknown error"))) + } + } + } + + companion object { + private const val KEY_LATEST_VERSION = "LatestVersion" + private const val KEY_MIN_VERSION = "MinVersion" + } +} diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml index 67994c9b..ff80b470 100644 --- a/core/ui/src/main/res/values/strings.xml +++ b/core/ui/src/main/res/values/strings.xml @@ -1,7 +1,7 @@ 더 이상 결과가 없습니다 - 다시 시도 + 다시 시도하기 네트워크 연결이 불안정합니다.\n인터넷 연결을 확인해주세요 알 수 없는 문제가 발생했어요.\n다시 시도해주세요 diff --git a/feature/home/src/main/res/values/strings.xml b/feature/home/src/main/res/values/strings.xml index cc15f77a..58420e85 100644 --- a/feature/home/src/main/res/values/strings.xml +++ b/feature/home/src/main/res/values/strings.xml @@ -10,5 +10,4 @@ 기록하기 책 정보를 가져오는데 실패했어요 - 다시 시도 diff --git a/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/HandlingSettingsSideEffect.kt b/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/HandlingSettingsSideEffect.kt index 3f046e66..7b255355 100644 --- a/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/HandlingSettingsSideEffect.kt +++ b/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/HandlingSettingsSideEffect.kt @@ -3,11 +3,13 @@ package com.ninecraft.booket.feature.settings import android.widget.Toast import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext +import com.ninecraft.booket.core.common.extensions.openPlayStore import com.skydoves.compose.effects.RememberedEffect @Composable internal fun HandleSettingsSideEffects( state: SettingsUiState, + eventSink: (SettingsUiEvent) -> Unit, ) { val context = LocalContext.current @@ -16,7 +18,16 @@ internal fun HandleSettingsSideEffects( is SettingsSideEffect.ShowToast -> { Toast.makeText(context, state.sideEffect.message, Toast.LENGTH_SHORT).show() } - null -> {} + + is SettingsSideEffect.NavigateToPlayStore -> { + context.openPlayStore() + } + + else -> {} + } + + if (state.sideEffect != null) { + eventSink(SettingsUiEvent.InitSideEffect) } } } diff --git a/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/SettingsPresenter.kt b/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/SettingsPresenter.kt index f9715e19..e7b37d22 100644 --- a/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/SettingsPresenter.kt +++ b/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/SettingsPresenter.kt @@ -8,11 +8,13 @@ import androidx.compose.runtime.setValue import com.ninecraft.booket.core.common.constants.WebViewConstants import com.ninecraft.booket.core.common.utils.handleException import com.ninecraft.booket.core.data.api.repository.AuthRepository +import com.ninecraft.booket.core.data.api.repository.RemoteConfigRepository import com.ninecraft.booket.feature.screens.LoginScreen import com.ninecraft.booket.feature.screens.OssLicensesScreen import com.ninecraft.booket.feature.screens.SettingsScreen import com.ninecraft.booket.feature.screens.WebViewScreen import com.orhanobut.logger.Logger +import com.skydoves.compose.effects.RememberedEffect import com.slack.circuit.codegen.annotations.CircuitInject import com.slack.circuit.retained.rememberRetained import com.slack.circuit.runtime.Navigator @@ -26,19 +28,109 @@ import kotlinx.coroutines.launch class SettingsPresenter @AssistedInject constructor( @Assisted val navigator: Navigator, private val authRepository: AuthRepository, + private val remoteConfigRepository: RemoteConfigRepository, ) : Presenter { @Composable override fun present(): SettingsUiState { val scope = rememberCoroutineScope() var isLoading by rememberRetained { mutableStateOf(false) } - var sideEffect by rememberRetained { mutableStateOf(null) } var isLogoutDialogVisible by rememberRetained { mutableStateOf(false) } var isWithdrawBottomSheetVisible by rememberRetained { mutableStateOf(false) } var isWithdrawConfirmed by rememberRetained { mutableStateOf(false) } + var latestVersion by rememberRetained { mutableStateOf("") } + var isOptionalUpdateDialogVisible by rememberRetained { mutableStateOf(false) } + var sideEffect by rememberRetained { mutableStateOf(null) } + + fun logout() { + scope.launch { + try { + isLoading = true + authRepository.logout() + .onSuccess { + navigator.resetRoot(LoginScreen) + } + .onFailure { exception -> + val handleErrorMessage = { message: String -> + Logger.e(message) + sideEffect = SettingsSideEffect.ShowToast(message) + } + + handleException( + exception = exception, + onError = handleErrorMessage, + onLoginRequired = { + navigator.resetRoot(LoginScreen) + }, + ) + } + } finally { + isLoading = false + isLogoutDialogVisible = false + } + } + } + + fun withdraw() { + scope.launch { + try { + isLoading = true + authRepository.withdraw() + .onSuccess { + navigator.resetRoot(LoginScreen) + } + .onFailure { exception -> + val handleErrorMessage = { message: String -> + Logger.e(message) + sideEffect = SettingsSideEffect.ShowToast(message) + } + + handleException( + exception = exception, + onError = handleErrorMessage, + onLoginRequired = { + navigator.resetRoot(LoginScreen) + }, + ) + } + } finally { + isLoading = false + isWithdrawBottomSheetVisible = false + } + } + } + + fun getLatestVersion() { + scope.launch { + try { + isLoading = true + remoteConfigRepository.getLatestVersion() + .onSuccess { version -> + latestVersion = version + } + .onFailure { exception -> + val handleErrorMessage = { message: String -> + Logger.e(message) + sideEffect = SettingsSideEffect.ShowToast(message) + } + + handleException( + exception = exception, + onError = handleErrorMessage, + ) + } + } finally { + isLoading = false + } + } + } fun handleEvent(event: SettingsUiEvent) { when (event) { + is SettingsUiEvent.InitSideEffect -> { + sideEffect = null + } + is SettingsUiEvent.OnBackClick -> { navigator.pop() } @@ -76,69 +168,38 @@ class SettingsPresenter @AssistedInject constructor( } is SettingsUiEvent.Logout -> { - scope.launch { - try { - isLoading = true - authRepository.logout() - .onSuccess { - navigator.resetRoot(LoginScreen) - } - .onFailure { exception -> - val handleErrorMessage = { message: String -> - Logger.e(message) - sideEffect = SettingsSideEffect.ShowToast(message) - } - - handleException( - exception = exception, - onError = handleErrorMessage, - onLoginRequired = { - navigator.resetRoot(LoginScreen) - }, - ) - } - } finally { - isLoading = false - } - } - isLogoutDialogVisible = false + logout() } is SettingsUiEvent.Withdraw -> { - scope.launch { - try { - isLoading = true - authRepository.withdraw() - .onSuccess { - navigator.resetRoot(LoginScreen) - } - .onFailure { exception -> - val handleErrorMessage = { message: String -> - Logger.e(message) - sideEffect = SettingsSideEffect.ShowToast(message) - } - - handleException( - exception = exception, - onError = handleErrorMessage, - onLoginRequired = { - navigator.resetRoot(LoginScreen) - }, - ) - } - } finally { - isLoading = false - } - } - isWithdrawBottomSheetVisible = false + withdraw() + } + + is SettingsUiEvent.OnVersionClick -> { + isOptionalUpdateDialogVisible = true + } + + is SettingsUiEvent.OnOptionalUpdateDialogDismiss -> { + isOptionalUpdateDialogVisible = false + } + + is SettingsUiEvent.OnUpdateButtonClick -> { + sideEffect = SettingsSideEffect.NavigateToPlayStore } } } + + RememberedEffect(Unit) { + getLatestVersion() + } + return SettingsUiState( isLoading = isLoading, isLogoutDialogVisible = isLogoutDialogVisible, isWithdrawBottomSheetVisible = isWithdrawBottomSheetVisible, isWithdrawConfirmed = isWithdrawConfirmed, + latestVersion = latestVersion, + isOptionalUpdateDialogVisible = isOptionalUpdateDialogVisible, sideEffect = sideEffect, eventSink = ::handleEvent, ) diff --git a/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/SettingsUi.kt b/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/SettingsUi.kt index e6dcfc0a..33595b10 100644 --- a/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/SettingsUi.kt +++ b/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/SettingsUi.kt @@ -5,9 +5,9 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api @@ -24,7 +24,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource -import com.ninecraft.booket.core.common.extensions.clickableSingle +import com.ninecraft.booket.core.common.util.compareVersions import com.ninecraft.booket.core.designsystem.DevicePreview import com.ninecraft.booket.core.designsystem.component.ReedDivider import com.ninecraft.booket.core.designsystem.theme.ReedTheme @@ -34,10 +34,12 @@ import com.ninecraft.booket.core.ui.component.ReedBackTopAppBar import com.ninecraft.booket.core.ui.component.ReedDialog import com.ninecraft.booket.core.ui.component.ReedLoadingIndicator import com.ninecraft.booket.feature.screens.SettingsScreen +import com.ninecraft.booket.feature.settings.component.SettingItem import com.ninecraft.booket.feature.settings.component.WithdrawConfirmationBottomSheet import com.slack.circuit.codegen.annotations.CircuitInject import dagger.hilt.android.components.ActivityRetainedComponent import kotlinx.coroutines.launch +import com.ninecraft.booket.core.designsystem.R as designR @OptIn(ExperimentalMaterial3Api::class) @CircuitInject(SettingsScreen::class, ActivityRetainedComponent::class) @@ -46,7 +48,10 @@ internal fun SettingsUi( state: SettingsUiState, modifier: Modifier = Modifier, ) { - HandleSettingsSideEffects(state = state) + HandleSettingsSideEffects( + state = state, + eventSink = state.eventSink, + ) val withDrawSheetState = rememberModalBottomSheetState() val coroutineScope = rememberCoroutineScope() @@ -58,6 +63,10 @@ internal fun SettingsUi( }.getOrNull() ?: "Unknown" } + val isUpdateAvailable = remember(appVersion, state.latestVersion) { + compareVersions(state.latestVersion, appVersion) > 0 + } + ReedScaffold( modifier = modifier .fillMaxSize() @@ -84,7 +93,7 @@ internal fun SettingsUi( }, action = { Icon( - imageVector = ImageVector.vectorResource(id = com.ninecraft.booket.core.designsystem.R.drawable.ic_chevron_right), + imageVector = ImageVector.vectorResource(id = designR.drawable.ic_chevron_right), contentDescription = "Right Chevron Icon", tint = Color.Unspecified, ) @@ -97,7 +106,7 @@ internal fun SettingsUi( }, action = { Icon( - imageVector = ImageVector.vectorResource(id = com.ninecraft.booket.core.designsystem.R.drawable.ic_chevron_right), + imageVector = ImageVector.vectorResource(id = designR.drawable.ic_chevron_right), contentDescription = "Right Chevron Icon", tint = Color.Unspecified, ) @@ -110,7 +119,7 @@ internal fun SettingsUi( }, action = { Icon( - imageVector = ImageVector.vectorResource(id = com.ninecraft.booket.core.designsystem.R.drawable.ic_chevron_right), + imageVector = ImageVector.vectorResource(id = designR.drawable.ic_chevron_right), contentDescription = "Right Chevron Icon", tint = Color.Unspecified, ) @@ -118,12 +127,34 @@ internal fun SettingsUi( ) SettingItem( title = stringResource(R.string.settings_app_version), - isClickable = false, + isClickable = isUpdateAvailable, + onItemClick = { + state.eventSink(SettingsUiEvent.OnVersionClick) + }, action = { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = appVersion, + style = ReedTheme.typography.body1Medium, + color = ReedTheme.colors.contentBrand, + ) + if (isUpdateAvailable) { + Spacer(modifier = Modifier.width(ReedTheme.spacing.spacing2)) + Icon( + imageVector = ImageVector.vectorResource(id = designR.drawable.ic_chevron_right), + contentDescription = "Right Chevron Icon", + tint = Color.Unspecified, + ) + } + } + }, + description = { Text( - text = appVersion, - style = ReedTheme.typography.body1Medium, - color = ReedTheme.colors.contentSecondary, + text = stringResource(R.string.latest_version, state.latestVersion), + color = ReedTheme.colors.contentTertiary, + style = ReedTheme.typography.label1Medium, ) }, ) @@ -181,40 +212,20 @@ internal fun SettingsUi( }, ) } - } -} - -@Composable -private fun SettingItem( - title: String, - modifier: Modifier = Modifier, - isClickable: Boolean = true, - onItemClick: () -> Unit = {}, - action: @Composable () -> Unit = {}, -) { - val combinedModifier = if (isClickable) { - modifier - .fillMaxWidth() - .clickableSingle { onItemClick() } - } else { - modifier.fillMaxWidth() - } - Row( - modifier = combinedModifier - .padding( - horizontal = ReedTheme.spacing.spacing5, - vertical = ReedTheme.spacing.spacing4, - ), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - modifier = Modifier.weight(1f), - text = title, - style = ReedTheme.typography.body1Medium, - color = ReedTheme.colors.contentPrimary, - ) - action() + if (state.isOptionalUpdateDialogVisible) { + ReedDialog( + onDismissRequest = { + state.eventSink(SettingsUiEvent.OnOptionalUpdateDialogDismiss) + }, + title = stringResource(R.string.settings_optional_update_title), + description = stringResource(R.string.settings_optional_update_message), + confirmButtonText = stringResource(R.string.settings_optional_update_button_text), + onConfirmRequest = { + state.eventSink(SettingsUiEvent.OnUpdateButtonClick) + }, + ) + } } } diff --git a/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/SettingsUiState.kt b/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/SettingsUiState.kt index 21d5a76b..e318b370 100644 --- a/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/SettingsUiState.kt +++ b/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/SettingsUiState.kt @@ -10,6 +10,9 @@ data class SettingsUiState( val isLogoutDialogVisible: Boolean = false, val isWithdrawBottomSheetVisible: Boolean = false, val isWithdrawConfirmed: Boolean = false, + val latestVersion: String = "", + val isUpdateAvailable: Boolean = false, + val isOptionalUpdateDialogVisible: Boolean = false, val sideEffect: SettingsSideEffect? = null, val eventSink: (SettingsUiEvent) -> Unit, ) : CircuitUiState @@ -20,9 +23,12 @@ sealed interface SettingsSideEffect { val message: String, private val key: String = UUID.randomUUID().toString(), ) : SettingsSideEffect + + data object NavigateToPlayStore : SettingsSideEffect } sealed interface SettingsUiEvent : CircuitUiEvent { + data object InitSideEffect : SettingsUiEvent data object OnBackClick : SettingsUiEvent data object OnPolicyClick : SettingsUiEvent data object OnTermClick : SettingsUiEvent @@ -33,4 +39,7 @@ sealed interface SettingsUiEvent : CircuitUiEvent { data object OnWithdrawConfirmationToggled : SettingsUiEvent data object Logout : SettingsUiEvent data object Withdraw : SettingsUiEvent + data object OnVersionClick : SettingsUiEvent + data object OnOptionalUpdateDialogDismiss : SettingsUiEvent + data object OnUpdateButtonClick : SettingsUiEvent } diff --git a/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/component/SettingItem.kt b/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/component/SettingItem.kt new file mode 100644 index 00000000..d794bef3 --- /dev/null +++ b/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/component/SettingItem.kt @@ -0,0 +1,64 @@ +package com.ninecraft.booket.feature.settings.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.ninecraft.booket.core.common.extensions.clickableSingle +import com.ninecraft.booket.core.designsystem.ComponentPreview +import com.ninecraft.booket.core.designsystem.theme.ReedTheme + +@Composable +internal fun SettingItem( + title: String, + modifier: Modifier = Modifier, + isClickable: Boolean = true, + onItemClick: () -> Unit = {}, + action: @Composable () -> Unit = {}, + description: @Composable () -> Unit = {}, +) { + val combinedModifier = if (isClickable) { + modifier + .fillMaxWidth() + .clickableSingle { onItemClick() } + } else { + modifier.fillMaxWidth() + } + + Column { + Row( + modifier = combinedModifier + .padding( + horizontal = ReedTheme.spacing.spacing5, + vertical = ReedTheme.spacing.spacing4, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = title, + style = ReedTheme.typography.body1Medium, + color = ReedTheme.colors.contentPrimary, + ) + description() + } + action() + } + } +} + +@ComponentPreview +@Composable +private fun SettingItemPreview() { + ReedTheme { + SettingItem( + title = "로그아웃", + ) + } +} diff --git a/feature/settings/src/main/res/values/strings.xml b/feature/settings/src/main/res/values/strings.xml index 01bbd5d5..fa897943 100644 --- a/feature/settings/src/main/res/values/strings.xml +++ b/feature/settings/src/main/res/values/strings.xml @@ -14,4 +14,8 @@ 취소 탈퇴하기 오픈소스 라이선스 + 최신 버전 %1$s + 최신 버전이 출시되었습니다. + 최적의 사용 환경을 위해 업데이트해주세요. + 업데이트하기 diff --git a/feature/splash/src/main/kotlin/com/ninecraft/booket/splash/HandleSplashSideEffects.kt b/feature/splash/src/main/kotlin/com/ninecraft/booket/splash/HandleSplashSideEffects.kt new file mode 100644 index 00000000..736d6df6 --- /dev/null +++ b/feature/splash/src/main/kotlin/com/ninecraft/booket/splash/HandleSplashSideEffects.kt @@ -0,0 +1,27 @@ +package com.ninecraft.booket.splash + +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import com.ninecraft.booket.core.common.extensions.openPlayStore +import com.skydoves.compose.effects.RememberedEffect + +@Composable +internal fun HandleSplashSideEffects( + state: SplashUiState, + eventSink: (SplashUiEvent) -> Unit, +) { + val context = LocalContext.current + + RememberedEffect(state.sideEffect) { + when (state.sideEffect) { + is SplashSideEffect.NavigateToPlayStore -> { + context.openPlayStore() + } + null -> {} + } + + if (state.sideEffect != null) { + eventSink(SplashUiEvent.InitSideEffect) + } + } +} diff --git a/feature/splash/src/main/kotlin/com/ninecraft/booket/splash/SplashPresenter.kt b/feature/splash/src/main/kotlin/com/ninecraft/booket/splash/SplashPresenter.kt index b8715ef5..3942dc54 100644 --- a/feature/splash/src/main/kotlin/com/ninecraft/booket/splash/SplashPresenter.kt +++ b/feature/splash/src/main/kotlin/com/ninecraft/booket/splash/SplashPresenter.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.setValue import com.ninecraft.booket.core.common.constants.ErrorScope import com.ninecraft.booket.core.common.utils.postErrorDialog import com.ninecraft.booket.core.data.api.repository.AuthRepository +import com.ninecraft.booket.core.data.api.repository.RemoteConfigRepository import com.ninecraft.booket.core.data.api.repository.UserRepository import com.ninecraft.booket.core.model.AutoLoginState import com.ninecraft.booket.core.model.OnboardingState @@ -17,7 +18,7 @@ import com.ninecraft.booket.feature.screens.HomeScreen import com.ninecraft.booket.feature.screens.LoginScreen import com.ninecraft.booket.feature.screens.OnboardingScreen import com.ninecraft.booket.feature.screens.SplashScreen -import com.skydoves.compose.effects.RememberedEffect +import com.orhanobut.logger.Logger import com.slack.circuit.codegen.annotations.CircuitInject import com.slack.circuit.retained.collectAsRetainedState import com.slack.circuit.retained.rememberRetained @@ -34,6 +35,7 @@ class SplashPresenter @AssistedInject constructor( @Assisted private val navigator: Navigator, private val userRepository: UserRepository, private val authRepository: AuthRepository, + private val remoteConfigRepository: RemoteConfigRepository, ) : Presenter { @Composable @@ -41,7 +43,8 @@ class SplashPresenter @AssistedInject constructor( val scope = rememberCoroutineScope() val onboardingState by userRepository.onboardingState.collectAsRetainedState(initial = OnboardingState.IDLE) val autoLoginState by authRepository.autoLoginState.collectAsRetainedState(initial = AutoLoginState.IDLE) - var isSplashTimeCompleted by rememberRetained { mutableStateOf(false) } + var isForceUpdateDialogVisible by rememberRetained { mutableStateOf(false) } + var sideEffect by rememberRetained { mutableStateOf(null) } fun checkTermsAgreement() { scope.launch { @@ -64,14 +67,7 @@ class SplashPresenter @AssistedInject constructor( } } - LaunchedEffect(Unit) { - delay(1000L) - isSplashTimeCompleted = true - } - - RememberedEffect(onboardingState, autoLoginState, isSplashTimeCompleted) { - if (!isSplashTimeCompleted) return@RememberedEffect - + fun proceedToNextScreen() { when (onboardingState) { OnboardingState.NOT_COMPLETED -> { navigator.resetRoot(OnboardingScreen) @@ -99,7 +95,50 @@ class SplashPresenter @AssistedInject constructor( } } - return SplashUiState + fun checkForceUpdate() { + scope.launch { + remoteConfigRepository.shouldUpdate() + .onSuccess { shouldUpdate -> + if (shouldUpdate) { + isForceUpdateDialogVisible = true + } else { + proceedToNextScreen() + } + } + .onFailure { exception -> + Logger.e("${exception.message}") + proceedToNextScreen() + } + } + } + + fun handleEvent(event: SplashUiEvent) { + when (event) { + SplashUiEvent.OnUpdateButtonClick -> { + sideEffect = SplashSideEffect.NavigateToPlayStore + } + + SplashUiEvent.InitSideEffect -> { + sideEffect = null + } + } + } + + LaunchedEffect(onboardingState, autoLoginState) { + delay(1000L) + + if (onboardingState == OnboardingState.IDLE || autoLoginState == AutoLoginState.IDLE) { + return@LaunchedEffect + } + + checkForceUpdate() + } + + return SplashUiState( + isForceUpdateDialogVisible = isForceUpdateDialogVisible, + sideEffect = sideEffect, + eventSink = ::handleEvent, + ) } @CircuitInject(SplashScreen::class, ActivityRetainedComponent::class) diff --git a/feature/splash/src/main/kotlin/com/ninecraft/booket/splash/SplashUi.kt b/feature/splash/src/main/kotlin/com/ninecraft/booket/splash/SplashUi.kt index 536e66ff..dd7f9d80 100644 --- a/feature/splash/src/main/kotlin/com/ninecraft/booket/splash/SplashUi.kt +++ b/feature/splash/src/main/kotlin/com/ninecraft/booket/splash/SplashUi.kt @@ -1,5 +1,6 @@ package com.ninecraft.booket.splash +import android.R.attr.description import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -21,6 +22,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.ninecraft.booket.core.designsystem.DevicePreview import com.ninecraft.booket.core.designsystem.theme.ReedTheme +import com.ninecraft.booket.core.ui.component.ReedDialog import com.ninecraft.booket.feature.screens.SplashScreen import com.ninecraft.booket.feature.splash.R import com.slack.circuit.codegen.annotations.CircuitInject @@ -30,6 +32,7 @@ import tech.thdev.compose.exteions.system.ui.controller.rememberSystemUiControll @CircuitInject(SplashScreen::class, ActivityRetainedComponent::class) @Composable fun SplashUi( + state: SplashUiState, modifier: Modifier = Modifier, ) { val systemUiController = rememberSystemUiController() @@ -50,6 +53,11 @@ fun SplashUi( } } + HandleSplashSideEffects( + state = state, + eventSink = state.eventSink, + ) + Box( modifier = modifier .fillMaxSize() @@ -74,6 +82,17 @@ fun SplashUi( ) Spacer(Modifier.height(ReedTheme.spacing.spacing8)) } + + if (state.isForceUpdateDialogVisible) { + ReedDialog( + title = stringResource(R.string.splash_force_update_title), + description = stringResource(R.string.splash_force_update_message), + confirmButtonText = stringResource(R.string.splash_force_update_button_text), + onConfirmRequest = { + state.eventSink(SplashUiEvent.OnUpdateButtonClick) + }, + ) + } } } @@ -81,6 +100,10 @@ fun SplashUi( @Composable private fun SplashPreview() { ReedTheme { - SplashUi() + SplashUi( + state = SplashUiState( + eventSink = {}, + ), + ) } } diff --git a/feature/splash/src/main/kotlin/com/ninecraft/booket/splash/SplashUiState.kt b/feature/splash/src/main/kotlin/com/ninecraft/booket/splash/SplashUiState.kt index d1ed996e..7f8be888 100644 --- a/feature/splash/src/main/kotlin/com/ninecraft/booket/splash/SplashUiState.kt +++ b/feature/splash/src/main/kotlin/com/ninecraft/booket/splash/SplashUiState.kt @@ -1,5 +1,21 @@ package com.ninecraft.booket.splash +import androidx.compose.runtime.Immutable +import com.slack.circuit.runtime.CircuitUiEvent import com.slack.circuit.runtime.CircuitUiState -object SplashUiState : CircuitUiState +data class SplashUiState( + val isForceUpdateDialogVisible: Boolean = false, + val sideEffect: SplashSideEffect? = null, + val eventSink: (SplashUiEvent) -> Unit, +) : CircuitUiState + +@Immutable +sealed interface SplashSideEffect { + data object NavigateToPlayStore : SplashSideEffect +} + +sealed interface SplashUiEvent : CircuitUiEvent { + data object InitSideEffect : SplashUiEvent + data object OnUpdateButtonClick : SplashUiEvent +} diff --git a/feature/splash/src/main/res/values/strings.xml b/feature/splash/src/main/res/values/strings.xml index 3c81ec5c..bde1e36c 100644 --- a/feature/splash/src/main/res/values/strings.xml +++ b/feature/splash/src/main/res/values/strings.xml @@ -1,4 +1,7 @@ 책 덮기 전 한 문장을 기록해보세요 + 최신 버전이 출시되었습니다. + 최적의 사용 환경을 위해 업데이트해주세요. + 업데이트 하기 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 896dd695..33cf0675 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -75,6 +75,14 @@ google-service = "4.4.3" firebase-bom = "33.16.0" firebase-crashlytics = "3.0.4" +## App Configuration +minSdk = "28" +targetSdk = "35" +compileSdk = "35" +versionName = "1.0.0" +versionCode = "3" +packageName = "com.ninecraft.booket" + [libraries] android-gradle-plugin = { group = "com.android.tools.build", name = "gradle", version.ref = "android-gradle-plugin" } kotlin-gradle-plugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } @@ -150,6 +158,7 @@ androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebase-bom" } firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics-ktx" } firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics-ktx" } +firebase-remote-config = { group = "com.google.firebase", name = "firebase-config-ktx" } [plugins] gradle-dependency-handler-extensions = { id = "land.sungbin.dependency.handler.extensions", version.ref = "gradle-dependency-handler-extensions" }