diff --git a/core/data/src/main/java/com/puzzle/data/repository/ConfigureRepositoryImpl.kt b/core/data/src/main/java/com/puzzle/data/repository/ConfigureRepositoryImpl.kt index a6b0bdfad..49709b59b 100644 --- a/core/data/src/main/java/com/puzzle/data/repository/ConfigureRepositoryImpl.kt +++ b/core/data/src/main/java/com/puzzle/data/repository/ConfigureRepositoryImpl.kt @@ -1,8 +1,10 @@ package com.puzzle.data.repository import com.puzzle.domain.model.configure.ForceUpdate +import com.puzzle.domain.model.configure.MaintenancePeriod import com.puzzle.domain.repository.ConfigureRepository import com.puzzle.network.model.configure.GetForceUpdateInfoResponse +import com.puzzle.network.model.configure.GetMaintenancePeriodInfoResponse import com.puzzle.network.source.configure.ConfigDataSource import com.puzzle.network.source.configure.ConfigDataSource.Key import javax.inject.Inject @@ -10,10 +12,13 @@ import javax.inject.Inject class ConfigureRepositoryImpl @Inject constructor( private val configureDataSource: ConfigDataSource, ) : ConfigureRepository { - override suspend fun getUpdateInfo(): ForceUpdate { - return configureDataSource.getReferenceType( - key = Key.getKey(ConfigDataSource.FORCE_UPDATE), - defaultValue = GetForceUpdateInfoResponse(), - ).toDomain() - } + override suspend fun getUpdateInfo(): ForceUpdate = configureDataSource.getReferenceType( + key = Key.getKey(ConfigDataSource.FORCE_UPDATE), + defaultValue = GetForceUpdateInfoResponse(), + ).toDomain() + + override suspend fun getMaintenancePeriod(): MaintenancePeriod = configureDataSource.getReferenceType( + key = Key.getKey(ConfigDataSource.MAINTENANCE_PERIOD), + defaultValue = GetMaintenancePeriodInfoResponse(), + ).toDomain() } diff --git a/core/designsystem/src/main/java/com/puzzle/designsystem/component/Dialog.kt b/core/designsystem/src/main/java/com/puzzle/designsystem/component/Dialog.kt index 6b8c3dfc4..65d18a3aa 100644 --- a/core/designsystem/src/main/java/com/puzzle/designsystem/component/Dialog.kt +++ b/core/designsystem/src/main/java/com/puzzle/designsystem/component/Dialog.kt @@ -61,6 +61,7 @@ fun PieceDialog( fun PieceDialogDefaultTop( title: AnnotatedString, subText: String, + description: AnnotatedString? = null ) { Column( verticalArrangement = Arrangement.spacedBy(8.dp), @@ -80,6 +81,27 @@ fun PieceDialogDefaultTop( textAlign = TextAlign.Center, style = PieceTheme.typography.bodySM, ) + + description?.let { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .padding(top = 4.dp) + .fillMaxWidth() + .background(PieceTheme.colors.light3) + .padding(vertical = 12.dp), + ) { + Text( + text = it, + color = PieceTheme.colors.dark3, + style = PieceTheme.typography.bodySM, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .background(PieceTheme.colors.light3) + ) + } + } } } @@ -87,6 +109,7 @@ fun PieceDialogDefaultTop( fun PieceDialogDefaultTop( title: AnnotatedString, subText: AnnotatedString, + description: AnnotatedString? = null ) { Column( verticalArrangement = Arrangement.spacedBy(8.dp), @@ -106,6 +129,27 @@ fun PieceDialogDefaultTop( textAlign = TextAlign.Center, style = PieceTheme.typography.bodySM, ) + + description?.let { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .padding(top = 4.dp) + .fillMaxWidth() + .background(PieceTheme.colors.light3) + .padding(vertical = 12.dp), + ) { + Text( + text = it, + color = PieceTheme.colors.dark3, + style = PieceTheme.typography.bodySM, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .background(PieceTheme.colors.light3) + ) + } + } } } @@ -113,6 +157,7 @@ fun PieceDialogDefaultTop( fun PieceDialogDefaultTop( title: String, subText: AnnotatedString, + description: AnnotatedString? = null ) { Column( verticalArrangement = Arrangement.spacedBy(8.dp), @@ -132,6 +177,27 @@ fun PieceDialogDefaultTop( textAlign = TextAlign.Center, style = PieceTheme.typography.bodySM, ) + + description?.let { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .padding(top = 4.dp) + .fillMaxWidth() + .background(PieceTheme.colors.light3) + .padding(vertical = 12.dp), + ) { + Text( + text = it, + color = PieceTheme.colors.dark3, + style = PieceTheme.typography.bodySM, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .background(PieceTheme.colors.light3) + ) + } + } } } @@ -139,6 +205,7 @@ fun PieceDialogDefaultTop( fun PieceDialogDefaultTop( title: String, subText: String, + description: AnnotatedString? = null ) { Column( verticalArrangement = Arrangement.spacedBy(8.dp), @@ -158,6 +225,27 @@ fun PieceDialogDefaultTop( style = PieceTheme.typography.bodySM, textAlign = TextAlign.Center, ) + + description?.let { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .padding(top = 4.dp) + .fillMaxWidth() + .background(PieceTheme.colors.light3) + .padding(vertical = 12.dp), + ) { + Text( + text = it, + color = PieceTheme.colors.dark3, + style = PieceTheme.typography.bodySM, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .background(PieceTheme.colors.light3) + ) + } + } } } @@ -346,7 +434,8 @@ fun PreviewPieceDialogDefault() { dialogTop = { PieceDialogDefaultTop( title = AnnotatedString("Default Title"), - subText = "This is a default subtitle" + subText = "This is a default subtitle", + description = AnnotatedString("This is a default description") ) }, dialogBottom = { diff --git a/core/designsystem/src/main/res/values/strings.xml b/core/designsystem/src/main/res/values/strings.xml index 0f3e1cb10..bcd996070 100644 --- a/core/designsystem/src/main/res/values/strings.xml +++ b/core/designsystem/src/main/res/values/strings.xml @@ -317,6 +317,13 @@ Piece가 새로운 버전으로\n업데이트되었어요! 여러분의 의견을 반영하여 사용성을 개선했습니다.\n지금 바로 업데이트해 보세요! + + Piece가 잠시 쉬어가요! + 대규모 업데이트 작업을 진행하고 있어요.\n새롭게 출시될 기능들을 기대해주세요! + 일시 중단 시간: + 닫기 + + 가입이 승인되었어요! 매일 밤 10시, 설레는 인연을 만나보세요✨ diff --git a/core/domain/src/main/java/com/puzzle/domain/model/configure/MaintenancePeriod.kt b/core/domain/src/main/java/com/puzzle/domain/model/configure/MaintenancePeriod.kt new file mode 100644 index 000000000..078805c6a --- /dev/null +++ b/core/domain/src/main/java/com/puzzle/domain/model/configure/MaintenancePeriod.kt @@ -0,0 +1,5 @@ +package com.puzzle.domain.model.configure + +data class MaintenancePeriod ( + val maintenancePeriod: String +) \ No newline at end of file diff --git a/core/domain/src/main/java/com/puzzle/domain/repository/ConfigureRepository.kt b/core/domain/src/main/java/com/puzzle/domain/repository/ConfigureRepository.kt index ead6165aa..dc7485131 100644 --- a/core/domain/src/main/java/com/puzzle/domain/repository/ConfigureRepository.kt +++ b/core/domain/src/main/java/com/puzzle/domain/repository/ConfigureRepository.kt @@ -1,7 +1,9 @@ package com.puzzle.domain.repository import com.puzzle.domain.model.configure.ForceUpdate +import com.puzzle.domain.model.configure.MaintenancePeriod interface ConfigureRepository { suspend fun getUpdateInfo(): ForceUpdate + suspend fun getMaintenancePeriod(): MaintenancePeriod } diff --git a/core/network/src/main/java/com/puzzle/network/model/configure/GetMaintenancePeriodInfoResponse.kt b/core/network/src/main/java/com/puzzle/network/model/configure/GetMaintenancePeriodInfoResponse.kt new file mode 100644 index 000000000..704a4f5f9 --- /dev/null +++ b/core/network/src/main/java/com/puzzle/network/model/configure/GetMaintenancePeriodInfoResponse.kt @@ -0,0 +1,13 @@ +package com.puzzle.network.model.configure + +import com.puzzle.domain.model.configure.MaintenancePeriod +import kotlinx.serialization.Serializable + +@Serializable +data class GetMaintenancePeriodInfoResponse( + val maintenancePeriod: String = "" +) { + fun toDomain() = MaintenancePeriod( + maintenancePeriod = maintenancePeriod + ) +} diff --git a/core/network/src/main/java/com/puzzle/network/source/configure/ConfigureDataSource.kt b/core/network/src/main/java/com/puzzle/network/source/configure/ConfigureDataSource.kt index 72a8f4eaf..2353d4807 100644 --- a/core/network/src/main/java/com/puzzle/network/source/configure/ConfigureDataSource.kt +++ b/core/network/src/main/java/com/puzzle/network/source/configure/ConfigureDataSource.kt @@ -1,8 +1,13 @@ package com.puzzle.network.source.configure +import android.util.Log +import com.google.firebase.remoteconfig.ConfigUpdate +import com.google.firebase.remoteconfig.ConfigUpdateListener import com.google.firebase.remoteconfig.FirebaseRemoteConfig +import com.google.firebase.remoteconfig.FirebaseRemoteConfigException import com.google.firebase.remoteconfig.FirebaseRemoteConfigValue import com.google.firebase.remoteconfig.get +import com.google.firebase.remoteconfig.remoteConfigSettings import com.puzzle.network.BuildConfig import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.serialization.json.Json @@ -16,6 +21,22 @@ class ConfigDataSource @Inject constructor( private val remoteConfig: FirebaseRemoteConfig, val json: Json, ) { + + init { + val configSettings = remoteConfigSettings { + minimumFetchIntervalInSeconds = 0 + } + remoteConfig.setConfigSettingsAsync(configSettings) + remoteConfig.addOnConfigUpdateListener(object : ConfigUpdateListener { + override fun onUpdate(configUpdate: ConfigUpdate) { + remoteConfig.activate() + } + override fun onError(error: FirebaseRemoteConfigException) { + Log.e("ConfigDataSource", "RemoteConfig Update Error: ${error.message}", error) + } + }) + } + suspend inline fun getReferenceType(key: String, defaultValue: T): T { return getReferenceType(key) ?: defaultValue } @@ -44,6 +65,7 @@ class ConfigDataSource @Inject constructor( companion object Key { const val FORCE_UPDATE = "force_update" + const val MAINTENANCE_PERIOD = "maintenance_period" fun getKey(key: String): String = "${key}_AN_${BuildConfig.BUILD_TYPE}" } } diff --git a/presentation/src/main/java/com/puzzle/presentation/MainActivity.kt b/presentation/src/main/java/com/puzzle/presentation/MainActivity.kt index 789351680..f22b56b2b 100644 --- a/presentation/src/main/java/com/puzzle/presentation/MainActivity.kt +++ b/presentation/src/main/java/com/puzzle/presentation/MainActivity.kt @@ -96,6 +96,7 @@ class MainActivity : ComponentActivity() { CompositionLocalProvider(LocalAnalyticsHelper provides analyticsHelper) { val scope = rememberCoroutineScope() val forceUpdate by viewModel.forceUpdate.collectAsStateWithLifecycle() + val maintenancePeriod by viewModel.maintenancePeriod.collectAsStateWithLifecycle() val userRole by viewModel.userRole.collectAsStateWithLifecycle() val snackBarHostState = remember { SnackbarHostState() } val appState = rememberPieceAppState(networkMonitor = networkMonitor) @@ -104,6 +105,7 @@ class MainActivity : ComponentActivity() { PieceApp( appState = appState, forceUpdate = forceUpdate, + maintenancePeriod = maintenancePeriod, snackBarHostState = snackBarHostState, navigateToBottomNaviDestination = { bottomBarDestination -> if (isPendingUser(bottomBarDestination, userRole)) { @@ -150,6 +152,13 @@ class MainActivity : ComponentActivity() { handleNotificationIntent(intent) } + override fun onResume() { + super.onResume() + lifecycleScope.launch { + viewModel.refreshAppStatus() + } + } + private fun handleNotificationIntent(intent: Intent?) { intent?.getStringExtra(NOTIFCATION_ID)?.let { id -> viewModel.readNotification(id.toIntOrNull() ?: return@let) diff --git a/presentation/src/main/java/com/puzzle/presentation/MainViewModel.kt b/presentation/src/main/java/com/puzzle/presentation/MainViewModel.kt index c41c75175..731838f40 100644 --- a/presentation/src/main/java/com/puzzle/presentation/MainViewModel.kt +++ b/presentation/src/main/java/com/puzzle/presentation/MainViewModel.kt @@ -8,6 +8,7 @@ import com.puzzle.common.event.PieceEvent import com.puzzle.common.suspendRunCatching import com.puzzle.common.ui.SnackBarState import com.puzzle.domain.model.configure.ForceUpdate +import com.puzzle.domain.model.configure.MaintenancePeriod import com.puzzle.domain.model.error.ErrorHelper import com.puzzle.domain.model.error.HttpResponseException import com.puzzle.domain.model.error.HttpResponseStatus @@ -50,6 +51,9 @@ class MainViewModel @Inject constructor( private val _forceUpdate = MutableStateFlow(null) val forceUpdate = _forceUpdate.asStateFlow() + private val _maintenancePeriod = MutableStateFlow(null) + val maintenancePeriod = _maintenancePeriod.asStateFlow() + val userRole = userRepository.getUserRole() .stateIn( scope = viewModelScope, @@ -89,15 +93,16 @@ class MainViewModel @Inject constructor( } internal suspend fun initConfigure() = coroutineScope { - val forceUpdateJob = launch { checkMinVersion() } - val loadTermsJob = launch { loadTerms() } - val loadValuePicksJob = launch { loadValuePicks() } - val loadValueTalksJob = launch { loadValueTalks() } - - forceUpdateJob.join() - loadTermsJob.join() - loadValuePicksJob.join() - loadValueTalksJob.join() + launch { checkMinVersion() } + launch { checkMaintenancePeriod() } + launch { loadTerms() } + launch { loadValuePicks() } + launch { loadValueTalks() } + } + + internal suspend fun refreshAppStatus() = coroutineScope { + launch { checkMinVersion() } + launch { checkMaintenancePeriod() } } private suspend fun checkMinVersion() { @@ -105,7 +110,15 @@ class MainViewModel @Inject constructor( configureRepository.getUpdateInfo() }.onSuccess { _forceUpdate.value = it - }.onFailure { errorHelper.sendError(it) } + }.onFailure { errorHelper.recordError(it) } + } + + private suspend fun checkMaintenancePeriod() { + suspendRunCatching { + configureRepository.getMaintenancePeriod() + }.onSuccess { + _maintenancePeriod.value = it + }.onFailure { errorHelper.recordError(it) } } private suspend fun loadTerms() { diff --git a/presentation/src/main/java/com/puzzle/presentation/maintenance/MaintenanceDialog.kt b/presentation/src/main/java/com/puzzle/presentation/maintenance/MaintenanceDialog.kt new file mode 100644 index 000000000..e64d12d7d --- /dev/null +++ b/presentation/src/main/java/com/puzzle/presentation/maintenance/MaintenanceDialog.kt @@ -0,0 +1,55 @@ +package com.puzzle.presentation.maintenance + +import android.app.Activity +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import com.puzzle.designsystem.R +import com.puzzle.designsystem.component.PieceDialog +import com.puzzle.designsystem.component.PieceDialogDefaultTop +import com.puzzle.designsystem.component.PieceSolidButton +import com.puzzle.designsystem.foundation.PieceTheme + +@Composable +internal fun MaintenanceDialog(maintenancePeriodString: String) { + + val context = LocalContext.current + val activity = context as? Activity + + PieceDialog( + onDismissRequest = {}, + dialogTop = { + PieceDialogDefaultTop( + title = stringResource(id = R.string.maintenance_title), + subText = stringResource(id = R.string.maintenance_subtext), + description = buildAnnotatedString { + append(stringResource(id = R.string.maintenance_description_prefix)) + append(" ") + withStyle( + style = SpanStyle(color = PieceTheme.colors.subDefault) + ) { + append(maintenancePeriodString) + } + } + ) + }, + dialogBottom = { + PieceSolidButton( + label = stringResource(id = R.string.maintenance_label), + onClick = { + activity?.finishAffinity() + }, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 20.dp, top = 12.dp), + ) + } + ) +} \ No newline at end of file diff --git a/presentation/src/main/java/com/puzzle/presentation/ui/PieceApp.kt b/presentation/src/main/java/com/puzzle/presentation/ui/PieceApp.kt index 3f74b7262..c2e50d390 100644 --- a/presentation/src/main/java/com/puzzle/presentation/ui/PieceApp.kt +++ b/presentation/src/main/java/com/puzzle/presentation/ui/PieceApp.kt @@ -19,16 +19,21 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.puzzle.common.ui.LocalSystemBarState import com.puzzle.common.ui.NavigationBarColor import com.puzzle.common.ui.PieceBottomBarAnimation import com.puzzle.common.ui.StatusBarColor +import com.puzzle.common.ui.blur import com.puzzle.debug.DebugDrawer import com.puzzle.designsystem.R import com.puzzle.designsystem.component.PieceModalBottomSheet @@ -37,26 +42,36 @@ import com.puzzle.designsystem.component.PieceSnackBarHost import com.puzzle.designsystem.component.PieceTopShadowFab import com.puzzle.designsystem.foundation.PieceTheme import com.puzzle.domain.model.configure.ForceUpdate +import com.puzzle.domain.model.configure.MaintenancePeriod import com.puzzle.navigation.MatchingGraphBaseRoute import com.puzzle.navigation.Route +import com.puzzle.presentation.maintenance.MaintenanceDialog import com.puzzle.presentation.navigation.AppBottomBar import com.puzzle.presentation.navigation.AppNavHost import com.puzzle.presentation.network.NetworkScreen import com.puzzle.presentation.update.ForceUpdateDialog +import com.puzzle.presentation.update.isShowForceUpdateDialog import kotlinx.coroutines.launch @Composable internal fun PieceApp( appState: PieceAppState, forceUpdate: ForceUpdate?, + maintenancePeriod: MaintenancePeriod?, snackBarHostState: SnackbarHostState, navigateToBottomNaviDestination: (Route) -> Unit, ) { val scope = rememberCoroutineScope() + val context = LocalContext.current + + val maintenanceString = maintenancePeriod?.maintenancePeriod?.takeIf { it.isNotBlank() } + val isUpdateNeeded = maintenanceString == null && isShowForceUpdateDialog(context, forceUpdate) + val isShowingPopup = maintenanceString != null || isUpdateNeeded DebugDrawer(navController = appState.navController) { PieceModalBottomSheet(bottomSheetState = appState.bottomSheetState) { Scaffold( + modifier = Modifier.blur(isShowingPopup), containerColor = PieceTheme.colors.white, bottomBar = { PieceBottomBarAnimation( @@ -111,7 +126,13 @@ internal fun PieceApp( ) NetworkScreen(appState.networkState) - ForceUpdateDialog(forceUpdate) + + if (maintenanceString != null) { + MaintenanceDialog(maintenancePeriodString = maintenanceString) + } else if (isUpdateNeeded) { + ForceUpdateDialog() + } + SetSystemBarColor(appState) } } diff --git a/presentation/src/main/java/com/puzzle/presentation/update/ForceUpdateDialog.kt b/presentation/src/main/java/com/puzzle/presentation/update/ForceUpdateDialog.kt index cee9044ea..37b0c9604 100644 --- a/presentation/src/main/java/com/puzzle/presentation/update/ForceUpdateDialog.kt +++ b/presentation/src/main/java/com/puzzle/presentation/update/ForceUpdateDialog.kt @@ -5,11 +5,6 @@ import android.content.Intent import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -23,51 +18,44 @@ import com.puzzle.domain.model.configure.ForceUpdate import com.puzzle.presentation.BuildConfig @Composable -internal fun ForceUpdateDialog(forceUpdate: ForceUpdate?) { +internal fun ForceUpdateDialog() { val context = LocalContext.current - var isDialogVisible by remember { mutableStateOf(false) } - LaunchedEffect(forceUpdate) { - isDialogVisible = isShowForceUpdateDialog(context, forceUpdate) - } - - if (isDialogVisible) { - PieceDialog( - onDismissRequest = {}, - dialogTop = { - PieceDialogDefaultTop( - title = stringResource(R.string.update_title), - subText = stringResource(R.string.update_subtext), - ) - }, - dialogBottom = { - PieceSolidButton( - label = stringResource(R.string.update_app), - onClick = { - val intent = Intent( - Intent.ACTION_VIEW, - BuildConfig.PIECE_MARKET_URL.toUri() - ) - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - context.startActivity(intent) - }, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 20.dp, top = 12.dp), - ) - }, - ) - } + PieceDialog( + onDismissRequest = {}, + dialogTop = { + PieceDialogDefaultTop( + title = stringResource(R.string.update_title), + subText = stringResource(R.string.update_subtext), + ) + }, + dialogBottom = { + PieceSolidButton( + label = stringResource(R.string.update_app), + onClick = { + val intent = Intent( + Intent.ACTION_VIEW, + BuildConfig.PIECE_MARKET_URL.toUri() + ) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + }, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 20.dp, top = 12.dp), + ) + }, + ) } -private fun isShowForceUpdateDialog( +internal fun isShowForceUpdateDialog( context: Context, info: ForceUpdate?, ): Boolean { if (info == null) return false - val currentVersion = context.packageManager.getPackageInfo(context.packageName, 0).versionName - return checkShouldUpdate(currentVersion!!, info.minVersion) + val currentVersion = context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: return false + return checkShouldUpdate(currentVersion, info.minVersion) } /**