diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e43c8252..98b1101a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -25,8 +25,8 @@ android { applicationId = "com.sopt.clody" minSdk = 28 targetSdk = 35 - versionCode = 27 - versionName = "1.3.0" + versionCode = 28 + versionName = "1.4.0" val kakaoApiKey: String = properties.getProperty("kakao.api.key") val amplitudeApiKey: String = properties.getProperty("amplitude.api.key") val googleAdmobAppId: String = properties.getProperty("GOOGLE_ADMOB_APP_ID", "") diff --git a/app/src/main/java/com/sopt/clody/data/remote/appupdate/AppUpdateCheckerImpl.kt b/app/src/main/java/com/sopt/clody/data/remote/appupdate/AppUpdateCheckerImpl.kt index ef137767..d41f2a0b 100644 --- a/app/src/main/java/com/sopt/clody/data/remote/appupdate/AppUpdateCheckerImpl.kt +++ b/app/src/main/java/com/sopt/clody/data/remote/appupdate/AppUpdateCheckerImpl.kt @@ -4,6 +4,9 @@ import com.sopt.clody.data.remote.datasource.RemoteConfigDataSource import com.sopt.clody.domain.appupdate.AppUpdateChecker import com.sopt.clody.domain.model.AppUpdateState import com.sopt.clody.domain.util.VersionComparator +import java.time.LocalDateTime +import java.time.format.TextStyle +import java.util.Locale import javax.inject.Inject class AppUpdateCheckerImpl @Inject constructor( @@ -30,4 +33,30 @@ class AppUpdateCheckerImpl @Inject constructor( } } } + + override suspend fun isUnderInspection(): Boolean { + val start = remoteConfigDataSource.getInspectionStart() ?: return false + val end = remoteConfigDataSource.getInspectionEnd() ?: return false + val now = LocalDateTime.now() + return now.isAfter(start) && now.isBefore(end) + } + + override fun getInspectionTimeText(): String? { + val start = remoteConfigDataSource.getInspectionStart() + val end = remoteConfigDataSource.getInspectionEnd() + if (start == null || end == null) return null + + val startText = formatDateTimeWithDayOfWeek(start) + val endText = formatDateTimeWithDayOfWeek(end) + return "$startText ~ $endText" + } + + private fun formatDateTimeWithDayOfWeek(dateTime: LocalDateTime): String { + val dayOfWeek = dateTime.dayOfWeek.getDisplayName(TextStyle.SHORT, Locale.KOREAN) + val month = dateTime.monthValue + val day = dateTime.dayOfMonth + val hour = dateTime.hour.toString().padStart(2, '0') + + return "$month/$day($dayOfWeek) ${hour}시" + } } diff --git a/app/src/main/java/com/sopt/clody/data/remote/datasource/RemoteConfigDataSource.kt b/app/src/main/java/com/sopt/clody/data/remote/datasource/RemoteConfigDataSource.kt index 8561623a..42e12ba6 100644 --- a/app/src/main/java/com/sopt/clody/data/remote/datasource/RemoteConfigDataSource.kt +++ b/app/src/main/java/com/sopt/clody/data/remote/datasource/RemoteConfigDataSource.kt @@ -3,6 +3,8 @@ package com.sopt.clody.data.remote.datasource import com.google.firebase.remoteconfig.FirebaseRemoteConfig import com.sopt.clody.BuildConfig import kotlinx.coroutines.tasks.await +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter import javax.inject.Inject import javax.inject.Singleton @@ -20,8 +22,22 @@ class RemoteConfigDataSource @Inject constructor( fun getMinimumVersion(): String = remoteConfig.getString(KEY_MINIMUM_VERSION).ifEmpty { BuildConfig.VERSION_NAME } + fun getInspectionStart(): LocalDateTime? = + remoteConfig.getString(KEY_INSPECTION_START).takeIf { it.isNotBlank() }?.let { + runCatching { LocalDateTime.parse(it, formatter) }.getOrNull() + } + + fun getInspectionEnd(): LocalDateTime? = + remoteConfig.getString(KEY_INSPECTION_END).takeIf { it.isNotBlank() }?.let { + runCatching { LocalDateTime.parse(it, formatter) }.getOrNull() + } + companion object { private const val KEY_LATEST_VERSION = "latest_version" private const val KEY_MINIMUM_VERSION = "min_required_version" + private const val KEY_INSPECTION_START = "inspection_start_android" + private const val KEY_INSPECTION_END = "inspection_end_android" + + private val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss") } } diff --git a/app/src/main/java/com/sopt/clody/di/AppUpdateModule.kt b/app/src/main/java/com/sopt/clody/di/AppUpdateModule.kt index 98fd28c3..dcb8ea68 100644 --- a/app/src/main/java/com/sopt/clody/di/AppUpdateModule.kt +++ b/app/src/main/java/com/sopt/clody/di/AppUpdateModule.kt @@ -20,7 +20,7 @@ object AppUpdateModule { fun provideFirebaseRemoteConfig(): FirebaseRemoteConfig { val remoteConfig = FirebaseRemoteConfig.getInstance() val configSettings = FirebaseRemoteConfigSettings.Builder() - .setMinimumFetchIntervalInSeconds(600L) + .setMinimumFetchIntervalInSeconds(0) .build() remoteConfig.setConfigSettingsAsync(configSettings) return remoteConfig diff --git a/app/src/main/java/com/sopt/clody/domain/appupdate/AppUpdateChecker.kt b/app/src/main/java/com/sopt/clody/domain/appupdate/AppUpdateChecker.kt index e8f3e7d6..de3f683f 100644 --- a/app/src/main/java/com/sopt/clody/domain/appupdate/AppUpdateChecker.kt +++ b/app/src/main/java/com/sopt/clody/domain/appupdate/AppUpdateChecker.kt @@ -4,4 +4,6 @@ import com.sopt.clody.domain.model.AppUpdateState interface AppUpdateChecker { suspend fun getAppUpdateState(currentVersion: String): AppUpdateState + suspend fun isUnderInspection(): Boolean + fun getInspectionTimeText(): String? } diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/component/dialog/InspectionDialog.kt b/app/src/main/java/com/sopt/clody/presentation/ui/component/dialog/InspectionDialog.kt new file mode 100644 index 00000000..fb885bf5 --- /dev/null +++ b/app/src/main/java/com/sopt/clody/presentation/ui/component/dialog/InspectionDialog.kt @@ -0,0 +1,107 @@ +package com.sopt.clody.presentation.ui.component.dialog + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.sopt.clody.R +import com.sopt.clody.presentation.utils.base.BasePreview +import com.sopt.clody.presentation.utils.base.ClodyPreview +import com.sopt.clody.ui.theme.ClodyTheme + +@Composable +fun InspectionDialog( + inspectionTime: String, + onDismiss: () -> Unit, +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + dismissOnClickOutside = false, + dismissOnBackPress = false, + usePlatformDefaultWidth = false, + ), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.5f)) + .padding(horizontal = 24.dp), + contentAlignment = Alignment.Center, + ) { + Card( + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = ClodyTheme.colors.white), + ) { + Column( + modifier = Modifier.padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + painter = painterResource(id = R.drawable.img_inspection_dialog), + contentDescription = null, + ) + Spacer(modifier = Modifier.height(20.dp)) + Text( + text = "보다 안정적인 클로디 서비스를 위해\n시스템 점검 중이에요. 곧 다시 만나요!", + color = ClodyTheme.colors.gray03, + textAlign = TextAlign.Center, + style = ClodyTheme.typography.body3Medium, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "점검시간 : $inspectionTime", + color = ClodyTheme.colors.gray04, + textAlign = TextAlign.Center, + style = ClodyTheme.typography.body3Medium, + ) + Spacer(modifier = Modifier.height(24.dp)) + Button( + onClick = onDismiss, + modifier = Modifier + .fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + colors = ButtonDefaults.buttonColors(ClodyTheme.colors.mainYellow), + ) { + Text( + text = "확인", + color = ClodyTheme.colors.gray02, + style = ClodyTheme.typography.body3SemiBold, + ) + } + } + } + } + } +} + +@ClodyPreview +@Composable +private fun PreviewInspectionDialog() { + BasePreview { + InspectionDialog( + inspectionTime = "", + onDismiss = {}, + ) + } +} diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/splash/SplashContract.kt b/app/src/main/java/com/sopt/clody/presentation/ui/splash/SplashContract.kt index 15ce091f..c120e0c0 100644 --- a/app/src/main/java/com/sopt/clody/presentation/ui/splash/SplashContract.kt +++ b/app/src/main/java/com/sopt/clody/presentation/ui/splash/SplashContract.kt @@ -5,10 +5,11 @@ import com.airbnb.mvrx.MavericksState import com.sopt.clody.domain.model.AppUpdateState class SplashContract { - data class SplashState( val isUserLoggedIn: Boolean? = null, val updateState: AppUpdateState? = null, + val showInspectionDialog: Boolean = false, + val inspectionTimeText: String? = null, ) : MavericksState sealed class SplashIntent { @@ -16,6 +17,7 @@ class SplashContract { data class HandleHardUpdate(val isConfirm: Boolean) : SplashIntent() data object HandleSoftUpdateConfirm : SplashIntent() data object ClearUpdateState : SplashIntent() + data object DismissInspectionDialog : SplashIntent() } sealed interface SplashSideEffect { diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/splash/SplashScreen.kt b/app/src/main/java/com/sopt/clody/presentation/ui/splash/SplashScreen.kt index a14ff51f..71d5c7ce 100644 --- a/app/src/main/java/com/sopt/clody/presentation/ui/splash/SplashScreen.kt +++ b/app/src/main/java/com/sopt/clody/presentation/ui/splash/SplashScreen.kt @@ -25,6 +25,7 @@ import com.airbnb.mvrx.compose.collectAsState import com.airbnb.mvrx.compose.mavericksViewModel import com.sopt.clody.R import com.sopt.clody.domain.model.AppUpdateState +import com.sopt.clody.presentation.ui.component.dialog.InspectionDialog import com.sopt.clody.presentation.utils.appupdate.AppUpdateUtils import com.sopt.clody.presentation.utils.base.BasePreview import com.sopt.clody.presentation.utils.base.ClodyPreview @@ -93,6 +94,14 @@ fun SplashRoute( else -> {} } + if (state.showInspectionDialog) { + InspectionDialog( + inspectionTime = state.inspectionTimeText.orEmpty(), + onDismiss = { + viewModel.postIntent(SplashContract.SplashIntent.DismissInspectionDialog) + }, + ) + } SplashScreen() } diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/splash/SplashViewModel.kt b/app/src/main/java/com/sopt/clody/presentation/ui/splash/SplashViewModel.kt index 327544d4..fe86c62a 100644 --- a/app/src/main/java/com/sopt/clody/presentation/ui/splash/SplashViewModel.kt +++ b/app/src/main/java/com/sopt/clody/presentation/ui/splash/SplashViewModel.kt @@ -49,15 +49,31 @@ class SplashViewModel @AssistedInject constructor( is SplashContract.SplashIntent.HandleHardUpdate -> handleHardUpdate(intent) is SplashContract.SplashIntent.HandleSoftUpdateConfirm -> handleSoftUpdateConfirm() is SplashContract.SplashIntent.ClearUpdateState -> clearUpdateState() + is SplashContract.SplashIntent.DismissInspectionDialog -> handleDismissInspection() } } - private fun handleInitSplash(intent: SplashContract.SplashIntent.InitSplash) { + private suspend fun handleInitSplash(intent: SplashContract.SplashIntent.InitSplash) { if (intent.startIntent.hasExtra("google.message_id")) { AmplitudeUtils.trackEvent(AmplitudeConstraints.ALARM) } - attemptAutoLogin() + if (checkInspectionAndHandle()) return checkVersionAndNavigate() + attemptAutoLogin() + } + + private suspend fun checkInspectionAndHandle(): Boolean { + if (appUpdateChecker.isUnderInspection()) { + val inspectionText = appUpdateChecker.getInspectionTimeText() + setState { + copy( + showInspectionDialog = true, + inspectionTimeText = inspectionText, + ) + } + return true + } + return false } private fun attemptAutoLogin() { @@ -66,20 +82,18 @@ class SplashViewModel @AssistedInject constructor( setState { copy(isUserLoggedIn = isLoggedIn) } } - private fun checkVersionAndNavigate() { - viewModelScope.launch { - val updateState = appUpdateChecker.getAppUpdateState(BuildConfig.VERSION_NAME) - setState { copy(updateState = updateState) } + private suspend fun checkVersionAndNavigate() { + val updateState = appUpdateChecker.getAppUpdateState(BuildConfig.VERSION_NAME) + setState { copy(updateState = updateState) } - if (updateState == AppUpdateState.Latest) { - delay(1000) - val isLoggedIn = withState(this@SplashViewModel) { it.isUserLoggedIn } + if (updateState == AppUpdateState.Latest) { + delay(1000) + val isLoggedIn = withState(this@SplashViewModel) { it.isUserLoggedIn } - if (isLoggedIn == true) { - _sideEffects.send(SplashContract.SplashSideEffect.NavigateToHome) - } else { - _sideEffects.send(SplashContract.SplashSideEffect.NavigateToLogin) - } + if (isLoggedIn == true) { + _sideEffects.send(SplashContract.SplashSideEffect.NavigateToHome) + } else { + _sideEffects.send(SplashContract.SplashSideEffect.NavigateToLogin) } } } @@ -100,6 +114,11 @@ class SplashViewModel @AssistedInject constructor( setState { copy(updateState = AppUpdateState.Latest) } } + private suspend fun handleDismissInspection() { + setState { copy(showInspectionDialog = false) } + _sideEffects.send(SplashContract.SplashSideEffect.FinishApp) + } + @AssistedFactory interface Factory : AssistedViewModelFactory { override fun create(state: SplashContract.SplashState): SplashViewModel diff --git a/app/src/main/res/drawable/img_inspection_dialog.png b/app/src/main/res/drawable/img_inspection_dialog.png new file mode 100644 index 00000000..892de40a Binary files /dev/null and b/app/src/main/res/drawable/img_inspection_dialog.png differ