From a0776108c64445b63fff96c77fabc557bc3680c1 Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Wed, 9 Jul 2025 13:32:19 +0900 Subject: [PATCH 01/16] =?UTF-8?q?[REFACTOR/#231]=20SplashViewModel?= =?UTF-8?q?=EC=9D=84=20ContainerHost=20=EA=B8=B0=EB=B0=98=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/yapp/splash/SplashScreen.kt | 57 +++++++++--------- .../java/com/yapp/splash/SplashViewModel.kt | 59 ++++++++++--------- 2 files changed, 61 insertions(+), 55 deletions(-) diff --git a/feature/splash/src/main/java/com/yapp/splash/SplashScreen.kt b/feature/splash/src/main/java/com/yapp/splash/SplashScreen.kt index 272dea8a..6ba67dee 100644 --- a/feature/splash/src/main/java/com/yapp/splash/SplashScreen.kt +++ b/feature/splash/src/main/java/com/yapp/splash/SplashScreen.kt @@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -23,7 +22,7 @@ import androidx.navigation.navOptions import com.yapp.common.navigation.OrbitNavigator import com.yapp.common.navigation.route.SplashRoute import com.yapp.designsystem.theme.OrbitTheme -import kotlinx.coroutines.flow.collectLatest +import org.orbitmvi.orbit.compose.collectSideEffect @Composable fun SplashRoute( @@ -31,32 +30,9 @@ fun SplashRoute( viewModel: SplashViewModel = hiltViewModel(), ) { val state by viewModel.container.stateFlow.collectAsStateWithLifecycle() - val sideEffect = viewModel.container.sideEffectFlow - LaunchedEffect(sideEffect) { - sideEffect.collectLatest { effect -> - when (effect) { - is SplashContract.SideEffect.NavigateToOnboarding -> { - navigator.navigateToOnboarding( - navOptions = navOptions { - popUpTo(SplashRoute) { - inclusive = true - } - }, - ) - } - - is SplashContract.SideEffect.NavigateToHome -> { - navigator.navigateToHome( - navOptions = navOptions { - popUpTo(SplashRoute) { - inclusive = true - } - }, - ) - } - } - } + viewModel.collectSideEffect { + handleSideEffects(it, navigator) } SplashScreen(state = state) @@ -88,3 +64,30 @@ fun SplashScreen( ) } } + +private fun handleSideEffects( + sideEffect: SplashContract.SideEffect, + navigator: OrbitNavigator, +) { + when (sideEffect) { + is SplashContract.SideEffect.NavigateToOnboarding -> { + navigator.navigateToOnboarding( + navOptions = navOptions { + popUpTo(SplashRoute) { + inclusive = true + } + }, + ) + } + + is SplashContract.SideEffect.NavigateToHome -> { + navigator.navigateToHome( + navOptions = navOptions { + popUpTo(SplashRoute) { + inclusive = true + } + }, + ) + } + } +} diff --git a/feature/splash/src/main/java/com/yapp/splash/SplashViewModel.kt b/feature/splash/src/main/java/com/yapp/splash/SplashViewModel.kt index 49e4ccdb..e14bd222 100644 --- a/feature/splash/src/main/java/com/yapp/splash/SplashViewModel.kt +++ b/feature/splash/src/main/java/com/yapp/splash/SplashViewModel.kt @@ -1,49 +1,52 @@ package com.yapp.splash -import androidx.lifecycle.viewModelScope +import androidx.lifecycle.ViewModel import com.yapp.domain.repository.UserInfoRepository -import com.yapp.ui.base.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.first +import org.orbitmvi.orbit.Container +import org.orbitmvi.orbit.ContainerHost +import org.orbitmvi.orbit.syntax.simple.intent +import org.orbitmvi.orbit.syntax.simple.postSideEffect +import org.orbitmvi.orbit.syntax.simple.reduce +import org.orbitmvi.orbit.viewmodel.container import javax.inject.Inject @HiltViewModel class SplashViewModel @Inject constructor( private val userInfoRepository: UserInfoRepository, -) : BaseViewModel( - initialState = SplashContract.State(), -) { - init { +) : ViewModel(), ContainerHost { + + override val container: Container = container( + initialState = SplashContract.State(), + ) { startSplashAnimation() } - private fun startSplashAnimation() { - viewModelScope.launch { - updateState { copy(isVisible = true) } - delay(1500) - updateState { copy(isVisible = false) } - delay(1000) + private fun startSplashAnimation() = intent { + reduce { state.copy(isVisible = true) } + delay(1500) + reduce { state.copy(isVisible = false) } + delay(1000) - checkUserState() - } + checkUserState() } - private fun checkUserState() { - viewModelScope.launch { - combine( - userInfoRepository.userIdFlow, - userInfoRepository.onboardingCompletedFlow, - ) { userId, onboardingCompleted -> - Pair(userId, onboardingCompleted) - }.collect { (userId, onboardingCompleted) -> - if (userId != null && onboardingCompleted) { - emitSideEffect(SplashContract.SideEffect.NavigateToHome) - } else { - emitSideEffect(SplashContract.SideEffect.NavigateToOnboarding) - } + private fun checkUserState() = intent { + combine( + userInfoRepository.userIdFlow, + userInfoRepository.onboardingCompletedFlow, + ) { userId, onboardingCompleted -> + Pair(userId, onboardingCompleted) + }.first { (userId, onboardingCompleted) -> + if (userId != null && onboardingCompleted) { + postSideEffect(SplashContract.SideEffect.NavigateToHome) + } else { + postSideEffect(SplashContract.SideEffect.NavigateToOnboarding) } + true } } } From 0f297714788a13b32924e22f3c8b9db9fbc4153c Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Wed, 9 Jul 2025 13:57:52 +0900 Subject: [PATCH 02/16] =?UTF-8?q?[REFACTOR/#231]=20OnboardingViewModel?= =?UTF-8?q?=EC=9D=84=20ContainerHost=20=EA=B8=B0=EB=B0=98=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/yapp/onboarding/OnboardingNavGraph.kt | 57 ++--- .../yapp/onboarding/OnboardingViewModel.kt | 217 +++++++++--------- 2 files changed, 127 insertions(+), 147 deletions(-) diff --git a/feature/onboarding/src/main/java/com/yapp/onboarding/OnboardingNavGraph.kt b/feature/onboarding/src/main/java/com/yapp/onboarding/OnboardingNavGraph.kt index ee57af2e..7b0677fa 100644 --- a/feature/onboarding/src/main/java/com/yapp/onboarding/OnboardingNavGraph.kt +++ b/feature/onboarding/src/main/java/com/yapp/onboarding/OnboardingNavGraph.kt @@ -1,7 +1,6 @@ package com.yapp.onboarding import android.net.Uri -import androidx.compose.runtime.LaunchedEffect import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable import androidx.navigation.navOptions @@ -10,7 +9,7 @@ import com.yapp.common.navigation.OrbitNavigator import com.yapp.common.navigation.extensions.sharedHiltViewModel import com.yapp.common.navigation.route.OnboardingBaseRoute import com.yapp.common.navigation.route.OnboardingDestination -import kotlinx.coroutines.flow.collectLatest +import org.orbitmvi.orbit.compose.collectSideEffect fun NavGraphBuilder.onboardingNavGraph( navigator: OrbitNavigator, @@ -18,90 +17,72 @@ fun NavGraphBuilder.onboardingNavGraph( navigation(startDestination = OnboardingDestination.Explain) { composable { val viewModel = it.sharedHiltViewModel(navigator.navController) - LaunchedEffect(viewModel) { - viewModel.container.sideEffectFlow.collectLatest { sideEffect -> - handleSideEffect(sideEffect, navigator, viewModel) - } + viewModel.collectSideEffect { + handleSideEffect(it, navigator, viewModel) } OnboardingExplainRoute(viewModel) } composable { val viewModel = it.sharedHiltViewModel(navigator.navController) - LaunchedEffect(viewModel) { - viewModel.container.sideEffectFlow.collectLatest { sideEffect -> - handleSideEffect(sideEffect, navigator, viewModel) - } + viewModel.collectSideEffect { + handleSideEffect(it, navigator, viewModel) } OnboardingAlarmTimeSelectionRoute(viewModel) } composable { val viewModel = it.sharedHiltViewModel(navigator.navController) - LaunchedEffect(viewModel) { - viewModel.container.sideEffectFlow.collectLatest { sideEffect -> - handleSideEffect(sideEffect, navigator, viewModel) - } + viewModel.collectSideEffect { + handleSideEffect(it, navigator, viewModel) } OnboardingBirthdayRoute(viewModel) } composable { val viewModel = it.sharedHiltViewModel(navigator.navController) - LaunchedEffect(viewModel) { - viewModel.container.sideEffectFlow.collectLatest { sideEffect -> - handleSideEffect(sideEffect, navigator, viewModel) - } + viewModel.collectSideEffect { + handleSideEffect(it, navigator, viewModel) } OnboardingTimeOfBirthRoute(viewModel) } composable { val viewModel = it.sharedHiltViewModel(navigator.navController) - LaunchedEffect(viewModel) { - viewModel.container.sideEffectFlow.collectLatest { sideEffect -> - handleSideEffect(sideEffect, navigator, viewModel) - } + viewModel.collectSideEffect { + handleSideEffect(it, navigator, viewModel) } OnboardingNameRoute(viewModel) } composable { val viewModel = it.sharedHiltViewModel(navigator.navController) - LaunchedEffect(viewModel) { - viewModel.container.sideEffectFlow.collectLatest { sideEffect -> - handleSideEffect(sideEffect, navigator, viewModel) - } + viewModel.collectSideEffect { + handleSideEffect(it, navigator, viewModel) } OnboardingGenderRoute(viewModel) } composable { val viewModel = it.sharedHiltViewModel(navigator.navController) - LaunchedEffect(viewModel) { - viewModel.container.sideEffectFlow.collectLatest { sideEffect -> - handleSideEffect(sideEffect, navigator, viewModel) - } + viewModel.collectSideEffect { + handleSideEffect(it, navigator, viewModel) } OnboardingAccessRoute(viewModel) } composable { val viewModel = it.sharedHiltViewModel(navigator.navController) - LaunchedEffect(viewModel) { - viewModel.container.sideEffectFlow.collectLatest { sideEffect -> - handleSideEffect(sideEffect, navigator, viewModel) - } + viewModel.collectSideEffect { + handleSideEffect(it, navigator, viewModel) } OnboardingCompleteRoute(viewModel) } composable { val viewModel = it.sharedHiltViewModel(navigator.navController) - LaunchedEffect(viewModel) { - viewModel.container.sideEffectFlow.collectLatest { sideEffect -> - handleSideEffect(sideEffect, navigator, viewModel) - } + viewModel.collectSideEffect { + handleSideEffect(it, navigator, viewModel) } OnboardingCompleteRoute2(viewModel) } diff --git a/feature/onboarding/src/main/java/com/yapp/onboarding/OnboardingViewModel.kt b/feature/onboarding/src/main/java/com/yapp/onboarding/OnboardingViewModel.kt index d92bec81..bbf65913 100644 --- a/feature/onboarding/src/main/java/com/yapp/onboarding/OnboardingViewModel.kt +++ b/feature/onboarding/src/main/java/com/yapp/onboarding/OnboardingViewModel.kt @@ -2,7 +2,7 @@ package com.yapp.onboarding import android.util.Log import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.viewModelScope +import androidx.lifecycle.ViewModel import com.yapp.analytics.AnalyticsEvent import com.yapp.analytics.AnalyticsHelper import com.yapp.common.navigation.route.OnboardingDestination @@ -14,9 +14,13 @@ import com.yapp.domain.repository.UserInfoRepository import com.yapp.domain.usecase.AlarmUseCase import com.yapp.media.haptic.HapticFeedbackManager import com.yapp.media.haptic.HapticType -import com.yapp.ui.base.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.launch +import org.orbitmvi.orbit.Container +import org.orbitmvi.orbit.ContainerHost +import org.orbitmvi.orbit.syntax.simple.intent +import org.orbitmvi.orbit.syntax.simple.postSideEffect +import org.orbitmvi.orbit.syntax.simple.reduce +import org.orbitmvi.orbit.viewmodel.container import javax.inject.Inject import kotlin.reflect.KClass @@ -28,15 +32,18 @@ class OnboardingViewModel @Inject constructor( private val alarmUseCase: AlarmUseCase, private val hapticFeedbackManager: HapticFeedbackManager, private val savedStateHandle: SavedStateHandle, -) : BaseViewModel( - initialState = OnboardingContract.State( - currentStep = savedStateHandle["currentStep"] ?: 1, - birthDate = savedStateHandle["birthDate"] ?: "", - birthType = savedStateHandle["birthType"] ?: "양력", - ), -) { +) : ViewModel(), ContainerHost { + + override val container: Container = container( + initialState = OnboardingContract.State( + currentStep = savedStateHandle["currentStep"] ?: 1, + birthDate = savedStateHandle["birthDate"] ?: "", + birthType = savedStateHandle["birthType"] ?: "양력", + ), + ) + private val currentRoute: KClass? - get() = OnboardingDestination.routes.getOrNull(currentState.currentStep) + get() = OnboardingDestination.routes.getOrNull(container.stateFlow.value.currentStep) fun processAction(action: OnboardingContract.Action) { when (action) { @@ -57,122 +64,116 @@ class OnboardingViewModel @Inject constructor( } } - private fun submitUserInfo() { - viewModelScope.launch { - val state = container.stateFlow.value - - val result = signUpRepository.postSignUp( - name = state.userName, - calendarType = state.birthType, - birthDate = state.birthDate, - birthTime = state.birthTime, - gender = state.selectedGender ?: "", - ) + private fun submitUserInfo() = intent { + val result = signUpRepository.postSignUp( + name = state.userName, + calendarType = state.birthType, + birthDate = state.birthDate, + birthTime = state.birthTime, + gender = state.selectedGender ?: "", + ) - if (result.isSuccess) { - val userId = result.getOrNull() ?: return@launch - val userName = state.userName - userInfoRepository.saveUserId(userId) - userInfoRepository.saveUserName(userName) - - analyticsHelper.setUserId(userId) - analyticsHelper.logEvent( - AnalyticsEvent( - type = "onboarding_complete", - properties = mapOf( - AnalyticsEvent.OnboardingPropertiesKeys.STEP to "환영2", - ), + if (result.isSuccess) { + val userId = result.getOrNull() ?: return@intent + val userName = state.userName + userInfoRepository.saveUserId(userId) + userInfoRepository.saveUserName(userName) + + analyticsHelper.setUserId(userId) + analyticsHelper.logEvent( + AnalyticsEvent( + type = "onboarding_complete", + properties = mapOf( + AnalyticsEvent.OnboardingPropertiesKeys.STEP to "환영2", ), - ) + ), + ) - updateState { copy(isBottomSheetOpen = false) } - moveToNextStep() - } else { - processAction(OnboardingContract.Action.ShowWarningDialog) - } + reduce { state.copy(isBottomSheetOpen = false) } + moveToNextStep() + } else { + processAction(OnboardingContract.Action.ShowWarningDialog) } } - private fun moveToNextStep() { - val currentStep = container.stateFlow.value.currentStep + private fun moveToNextStep() = intent { + val currentStep = state.currentStep val nextStep = currentStep + 1 val nextRoute = OnboardingDestination.getNextRouteForStep(currentStep) - savedStateHandle["birthDate"] = currentState.birthDate - savedStateHandle["birthType"] = currentState.birthType + savedStateHandle["birthDate"] = state.birthDate + savedStateHandle["birthType"] = state.birthType if (nextRoute != null) { savedStateHandle["currentStep"] = nextStep - updateState { copy(currentStep = nextStep) } - emitSideEffect(OnboardingContract.SideEffect.NavigateToNextStep(currentStep)) + reduce { state.copy(currentStep = nextStep) } + postSideEffect(OnboardingContract.SideEffect.NavigateToNextStep(currentStep)) } else { - emitSideEffect(OnboardingContract.SideEffect.OnboardingCompleted) + postSideEffect(OnboardingContract.SideEffect.OnboardingCompleted) } } - private fun moveToPreviousStep() { - val currentStep = container.stateFlow.value.currentStep + private fun moveToPreviousStep() = intent { + val currentStep = state.currentStep if (currentStep > 1) { val previousStep = currentStep - 1 savedStateHandle["currentStep"] = previousStep - updateState { copy(currentStep = previousStep) } - emitSideEffect(OnboardingContract.SideEffect.NavigateBack) + reduce { state.copy(currentStep = previousStep) } + postSideEffect(OnboardingContract.SideEffect.NavigateBack) } } - private fun setAlarmTime(amPm: String, hour: Int, minute: Int) { + private fun setAlarmTime(amPm: String, hour: Int, minute: Int) = intent { hapticFeedbackManager.performHapticFeedback(HapticType.LIGHT_TICK) - val newTimeState = currentState.timeState.copy( + val newTimeState = state.timeState.copy( selectedAmPm = amPm, selectedHour = hour, selectedMinute = minute, ) - updateState { - copy( + reduce { + state.copy( timeState = newTimeState, ) } } - private fun createAlarm() { - viewModelScope.launch { - alarmUseCase.getAlarmSounds().onSuccess { sounds -> - val defaultSoundIndex = sounds.indexOfFirst { it.title == "Homecoming" }.takeIf { it >= 0 } ?: 0 - val defaultSoundUri = sounds[defaultSoundIndex] - - val newAlarm = Alarm( - isAm = currentState.timeState.selectedAmPm == "오전", - hour = currentState.timeState.selectedHour, - minute = currentState.timeState.selectedMinute, - repeatDays = setOf(AlarmDay.MON, AlarmDay.TUE, AlarmDay.WED, AlarmDay.THU, AlarmDay.FRI).toRepeatDays(), - isSnoozeEnabled = true, - snoozeInterval = 5, - snoozeCount = 5, - soundUri = "${defaultSoundUri.uri}", - ) - - alarmUseCase.insertAlarm( - alarm = newAlarm, - ).onSuccess { - emitSideEffect(OnboardingContract.SideEffect.OnboardingCompleted) - }.onFailure { - Log.e("OnboardingViewModel", "Failed to create alarm", it) - } + private fun createAlarm() = intent { + alarmUseCase.getAlarmSounds().onSuccess { sounds -> + val defaultSoundIndex = sounds.indexOfFirst { it.title == "Homecoming" }.takeIf { it >= 0 } ?: 0 + val defaultSoundUri = sounds[defaultSoundIndex] + + val newAlarm = Alarm( + isAm = state.timeState.selectedAmPm == "오전", + hour = state.timeState.selectedHour, + minute = state.timeState.selectedMinute, + repeatDays = setOf(AlarmDay.MON, AlarmDay.TUE, AlarmDay.WED, AlarmDay.THU, AlarmDay.FRI).toRepeatDays(), + isSnoozeEnabled = true, + snoozeInterval = 5, + snoozeCount = 5, + soundUri = "${defaultSoundUri.uri}", + ) + + alarmUseCase.insertAlarm( + alarm = newAlarm, + ).onSuccess { + postSideEffect(OnboardingContract.SideEffect.OnboardingCompleted) }.onFailure { - Log.e("OnboardingViewModel", "Failed to get alarm sounds", it) + Log.e("OnboardingViewModel", "Failed to create alarm", it) } + }.onFailure { + Log.e("OnboardingViewModel", "Failed to get alarm sounds", it) } } - private fun updateField(value: String, fieldType: OnboardingContract.FieldType) { + private fun updateField(value: String, fieldType: OnboardingContract.FieldType) = intent { when (fieldType) { OnboardingContract.FieldType.TIME -> { val isComplete = value.length == 5 val isValid = isComplete && value.matches(fieldType.validationRegex) - updateState { - copy( + reduce { + state.copy( textFieldValue = value, birthTime = if (isValid) value else "", showWarning = isComplete && !isValid, @@ -187,8 +188,8 @@ class OnboardingViewModel @Inject constructor( val truncatedValue = OnboardingContract.truncateTextToLimit(value) val isValid = truncatedValue.matches(fieldType.validationRegex) - updateState { - copy( + reduce { + state.copy( textFieldValue = truncatedValue, userName = truncatedValue, showWarning = !isValid, @@ -200,8 +201,8 @@ class OnboardingViewModel @Inject constructor( } } - private fun updateBirthDate(lunar: String, year: Int, month: Int, day: Int) { - if (currentRoute != OnboardingDestination.Birthday::class) return + private fun updateBirthDate(lunar: String, year: Int, month: Int, day: Int) = intent { + if (currentRoute != OnboardingDestination.Birthday::class) return@intent val formattedDate = "$year-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}" @@ -209,8 +210,8 @@ class OnboardingViewModel @Inject constructor( savedStateHandle["birthDate"] = formattedDate savedStateHandle["birthType"] = lunar - updateState { - copy( + reduce { + state.copy( birthDate = formattedDate, birthType = lunar, isBirthDateValid = true, @@ -218,9 +219,9 @@ class OnboardingViewModel @Inject constructor( } } - private fun resetFields() { - updateState { - copy( + private fun resetFields() = intent { + reduce { + state.copy( textFieldValue = "", showWarning = false, isButtonEnabled = false, @@ -229,31 +230,29 @@ class OnboardingViewModel @Inject constructor( } } - private fun updateGender(gender: String) { - updateState { copy(selectedGender = gender, isButtonEnabled = true) } + private fun updateGender(gender: String) = intent { + reduce { state.copy(selectedGender = gender, isButtonEnabled = true) } } - private fun toggleBottomSheet() { - val isCurrentlyOpen = container.stateFlow.value.isBottomSheetOpen - updateState { copy(isBottomSheetOpen = !isCurrentlyOpen) } + private fun toggleBottomSheet() = intent { + val isCurrentlyOpen = state.isBottomSheetOpen + reduce { state.copy(isBottomSheetOpen = !isCurrentlyOpen) } } - private fun completeOnboarding() { - viewModelScope.launch { - userInfoRepository.setOnboardingCompleted() - emitSideEffect(OnboardingContract.SideEffect.OnboardingCompleted) - } + private fun completeOnboarding() = intent { + userInfoRepository.setOnboardingCompleted() + postSideEffect(OnboardingContract.SideEffect.OnboardingCompleted) } - private fun openWebView(url: String) { - emitSideEffect(OnboardingContract.SideEffect.OpenWebView(url)) + private fun openWebView(url: String) = intent { + postSideEffect(OnboardingContract.SideEffect.OpenWebView(url)) } - private fun showWarningDialog() { - updateState { copy(isShowWarningDialog = true) } + private fun showWarningDialog() = intent { + reduce { state.copy(isShowWarningDialog = true) } } - private fun hideWarningDialog() { - updateState { copy(isShowWarningDialog = false) } + private fun hideWarningDialog() = intent { + reduce { state.copy(isShowWarningDialog = false) } } } From 1c3239f5878d7d00a373f454e61ede2d8ef25efe Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Wed, 9 Jul 2025 14:22:28 +0900 Subject: [PATCH 03/16] =?UTF-8?q?[REFACTOR/#231]=20AlarmAddEditViewModel?= =?UTF-8?q?=EC=9D=84=20ContainerHost=20=EA=B8=B0=EB=B0=98=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yapp/alarm/addedit/AlarmAddEditScreen.kt | 96 +++--- .../alarm/addedit/AlarmAddEditViewModel.kt | 319 +++++++++--------- 2 files changed, 219 insertions(+), 196 deletions(-) diff --git a/feature/home/src/main/java/com/yapp/alarm/addedit/AlarmAddEditScreen.kt b/feature/home/src/main/java/com/yapp/alarm/addedit/AlarmAddEditScreen.kt index d479e3f6..5f4e9fb8 100644 --- a/feature/home/src/main/java/com/yapp/alarm/addedit/AlarmAddEditScreen.kt +++ b/feature/home/src/main/java/com/yapp/alarm/addedit/AlarmAddEditScreen.kt @@ -32,7 +32,6 @@ import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -68,8 +67,9 @@ import com.yapp.ui.component.snackbar.showCustomSnackBar import com.yapp.ui.component.switch.OrbitSwitch import com.yapp.ui.component.timepicker.OrbitPicker import feature.home.R -import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import org.orbitmvi.orbit.compose.collectSideEffect @Composable fun AlarmAddEditRoute( @@ -78,52 +78,11 @@ fun AlarmAddEditRoute( snackBarHostState: SnackbarHostState, ) { val state by viewModel.container.stateFlow.collectAsStateWithLifecycle() - val sideEffect = viewModel.container.sideEffectFlow val coroutineScope = rememberCoroutineScope() - LaunchedEffect(sideEffect) { - sideEffect.collectLatest { effect -> - when (effect) { - is AlarmAddEditContract.SideEffect.NavigateBack -> { - navigator.navigateBack() - } - is AlarmAddEditContract.SideEffect.SaveAlarm -> { - navigator.navController.previousBackStackEntry - ?.savedStateHandle - ?.set(ADD_ALARM_RESULT_KEY, effect.id) - navigator.navController.popBackStack() - } - is AlarmAddEditContract.SideEffect.UpdateAlarm -> { - navigator.navController.previousBackStackEntry - ?.savedStateHandle - ?.set(UPDATE_ALARM_RESULT_KEY, effect.id) - navigator.navigateBack() - } - is AlarmAddEditContract.SideEffect.DeleteAlarm -> { - navigator.navController.previousBackStackEntry - ?.savedStateHandle - ?.set(DELETE_ALARM_RESULT_KEY, effect.id) - navigator.navigateBack() - } - is AlarmAddEditContract.SideEffect.ShowSnackBar -> { - val result = showCustomSnackBar( - scope = coroutineScope, - snackBarHostState = snackBarHostState, - message = effect.message, - actionLabel = effect.label, - iconRes = effect.iconRes, - bottomPadding = effect.bottomPadding, - durationMillis = effect.durationMillis, - ) - - when (result) { - SnackbarResult.ActionPerformed -> effect.onAction() - SnackbarResult.Dismissed -> effect.onDismiss() - } - } - } - } + viewModel.collectSideEffect { + handleSideEffect(it, navigator, snackBarHostState, coroutineScope) } AlarmAddEditScreen( @@ -638,6 +597,53 @@ private fun AlarmAddEditDisableHolidaySwitch( } } +private suspend fun handleSideEffect( + effect: AlarmAddEditContract.SideEffect, + navigator: OrbitNavigator, + snackBarHostState: SnackbarHostState, + coroutineScope: CoroutineScope, +) { + when (effect) { + is AlarmAddEditContract.SideEffect.NavigateBack -> { + navigator.navigateBack() + } + is AlarmAddEditContract.SideEffect.SaveAlarm -> { + navigator.navController.previousBackStackEntry + ?.savedStateHandle + ?.set(ADD_ALARM_RESULT_KEY, effect.id) + navigator.navController.popBackStack() + } + is AlarmAddEditContract.SideEffect.UpdateAlarm -> { + navigator.navController.previousBackStackEntry + ?.savedStateHandle + ?.set(UPDATE_ALARM_RESULT_KEY, effect.id) + navigator.navigateBack() + } + is AlarmAddEditContract.SideEffect.DeleteAlarm -> { + navigator.navController.previousBackStackEntry + ?.savedStateHandle + ?.set(DELETE_ALARM_RESULT_KEY, effect.id) + navigator.navigateBack() + } + is AlarmAddEditContract.SideEffect.ShowSnackBar -> { + val result = showCustomSnackBar( + scope = coroutineScope, + snackBarHostState = snackBarHostState, + message = effect.message, + actionLabel = effect.label, + iconRes = effect.iconRes, + bottomPadding = effect.bottomPadding, + durationMillis = effect.durationMillis, + ) + + when (result) { + SnackbarResult.ActionPerformed -> effect.onAction() + SnackbarResult.Dismissed -> effect.onDismiss() + } + } + } +} + @Preview @Composable fun AlarmAddEditSettingsSectionPreview() { diff --git a/feature/home/src/main/java/com/yapp/alarm/addedit/AlarmAddEditViewModel.kt b/feature/home/src/main/java/com/yapp/alarm/addedit/AlarmAddEditViewModel.kt index 3634ff21..4690da7a 100644 --- a/feature/home/src/main/java/com/yapp/alarm/addedit/AlarmAddEditViewModel.kt +++ b/feature/home/src/main/java/com/yapp/alarm/addedit/AlarmAddEditViewModel.kt @@ -3,7 +3,7 @@ package com.yapp.alarm.addedit import android.util.Log import androidx.compose.ui.unit.dp import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.viewModelScope +import androidx.lifecycle.ViewModel import com.yapp.analytics.AnalyticsEvent import com.yapp.analytics.AnalyticsHelper import com.yapp.common.util.ResourceProvider @@ -18,11 +18,15 @@ import com.yapp.domain.scheduler.AlarmScheduler import com.yapp.domain.usecase.AlarmUseCase import com.yapp.media.haptic.HapticFeedbackManager import com.yapp.media.haptic.HapticType -import com.yapp.ui.base.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel import feature.home.R import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch +import org.orbitmvi.orbit.Container +import org.orbitmvi.orbit.ContainerHost +import org.orbitmvi.orbit.syntax.simple.intent +import org.orbitmvi.orbit.syntax.simple.postSideEffect +import org.orbitmvi.orbit.syntax.simple.reduce +import org.orbitmvi.orbit.viewmodel.container import java.time.LocalTime import javax.inject.Inject @@ -34,17 +38,18 @@ class AlarmAddEditViewModel @Inject constructor( private val hapticFeedbackManager: HapticFeedbackManager, private val alarmScheduler: AlarmScheduler, savedStateHandle: SavedStateHandle, -) : BaseViewModel( - initialState = AlarmAddEditContract.State(), -) { - private val alarmId: Long = savedStateHandle.get("alarmId") ?: -1 +) : ViewModel(), ContainerHost { - init { - updateState { copy(mode = if (alarmId == -1L) AlarmAddEditContract.EditMode.ADD else AlarmAddEditContract.EditMode.EDIT) } - initializeAlarmScreen() + override val container: Container = container(initialState = AlarmAddEditContract.State()) { + intent { + reduce { state.copy(mode = if (alarmId == -1L) AlarmAddEditContract.EditMode.ADD else AlarmAddEditContract.EditMode.EDIT) } + initializeAlarmScreen() + } } - private fun initializeAlarmScreen() = viewModelScope.launch { + private val alarmId: Long = savedStateHandle.get("alarmId") ?: -1 + + private fun initializeAlarmScreen() = intent { alarmUseCase.getAlarmSounds().onSuccess { sounds -> if (alarmId == -1L) { setupNewAlarmScreen(sounds) @@ -56,7 +61,7 @@ class AlarmAddEditViewModel @Inject constructor( } } - private fun setupNewAlarmScreen(sounds: List) { + private fun setupNewAlarmScreen(sounds: List) = intent { val defaultSoundIndex = sounds.indexOfFirst { it.title == "Homecoming" }.takeIf { it >= 0 } ?: 0 val defaultSound = sounds[defaultSoundIndex] @@ -67,10 +72,10 @@ class AlarmAddEditViewModel @Inject constructor( val initialHour = if (now.hour == 0 || now.hour == 12) 12 else now.hour % 12 val initialMinute = now.minute - updateState { - copy( + reduce { + state.copy( initialLoading = false, - timeState = timeState.copy( + timeState = state.timeState.copy( initialAmPm = initialAmPm, initialHour = "$initialHour", initialMinute = initialMinute.toString().padStart(2, '0'), @@ -79,12 +84,12 @@ class AlarmAddEditViewModel @Inject constructor( currentMinute = initialMinute, alarmMessage = getAlarmMessage(initialAmPm, initialHour, initialMinute, emptySet()), ), - soundState = soundState.copy(sounds = sounds, soundIndex = defaultSoundIndex), + soundState = state.soundState.copy(sounds = sounds, soundIndex = defaultSoundIndex), ) } } - private suspend fun loadExistingAlarm(sounds: List) { + private fun loadExistingAlarm(sounds: List) = intent { alarmUseCase.getAlarm(alarmId).onSuccess { alarm -> val repeatDays = alarm.repeatDays.toAlarmDays() val isAM = alarm.isAm @@ -94,10 +99,10 @@ class AlarmAddEditViewModel @Inject constructor( alarmUseCase.initializeSoundPlayer(selectedSound.uri) - updateState { - copy( + reduce { + state.copy( initialLoading = false, - timeState = timeState.copy( + timeState = state.timeState.copy( initialAmPm = if (isAM) "오전" else "오후", initialHour = "$hour", initialMinute = alarm.minute.toString().padStart(2, '0'), @@ -106,13 +111,13 @@ class AlarmAddEditViewModel @Inject constructor( currentMinute = alarm.minute, alarmMessage = getAlarmMessage(if (isAM) "오전" else "오후", hour, alarm.minute, repeatDays), ), - daySelectionState = setupDaySelectionState(repeatDays), - holidayState = holidayState.copy( + daySelectionState = setupDaySelectionState(repeatDays, state), + holidayState = state.holidayState.copy( isDisableHolidayEnabled = repeatDays.isNotEmpty(), isDisableHolidayChecked = alarm.isHolidayAlarmOff, ), - snoozeState = setupSnoozeState(alarm), - soundState = soundState.copy( + snoozeState = setupSnoozeState(alarm, state), + soundState = state.soundState.copy( isVibrationEnabled = alarm.isVibrationEnabled, isSoundEnabled = alarm.isSoundEnabled, soundVolume = alarm.soundVolume, @@ -124,17 +129,27 @@ class AlarmAddEditViewModel @Inject constructor( } } - private fun setupDaySelectionState(repeatDays: Set) = currentState.daySelectionState.copy( - selectedDays = repeatDays, - isWeekdaysChecked = repeatDays.containsAll(setOf(AlarmDay.MON, AlarmDay.TUE, AlarmDay.WED, AlarmDay.THU, AlarmDay.FRI)), - isWeekendsChecked = repeatDays.containsAll(setOf(AlarmDay.SAT, AlarmDay.SUN)), - ) + private fun setupDaySelectionState( + repeatDays: Set, + currentState: AlarmAddEditContract.State, + ): AlarmAddEditContract.AlarmDaySelectionState { + return currentState.daySelectionState.copy( + selectedDays = repeatDays, + isWeekdaysChecked = repeatDays.containsAll(setOf(AlarmDay.MON, AlarmDay.TUE, AlarmDay.WED, AlarmDay.THU, AlarmDay.FRI)), + isWeekendsChecked = repeatDays.containsAll(setOf(AlarmDay.SAT, AlarmDay.SUN)), + ) + } - private fun setupSnoozeState(alarm: Alarm) = currentState.snoozeState.copy( - isSnoozeEnabled = alarm.isSnoozeEnabled, - snoozeIntervalIndex = findSnoozeIndex(alarm.snoozeInterval, currentState.snoozeState.snoozeIntervals), - snoozeCountIndex = findSnoozeIndex(alarm.snoozeCount, currentState.snoozeState.snoozeCounts), - ) + private fun setupSnoozeState( + alarm: Alarm, + currentState: AlarmAddEditContract.State, + ): AlarmAddEditContract.AlarmSnoozeState { + return currentState.snoozeState.copy( + isSnoozeEnabled = alarm.isSnoozeEnabled, + snoozeIntervalIndex = findSnoozeIndex(alarm.snoozeInterval, currentState.snoozeState.snoozeIntervals), + snoozeCountIndex = findSnoozeIndex(alarm.snoozeCount, currentState.snoozeState.snoozeCounts), + ) + } private fun findSnoozeIndex(value: Int, list: List): Int { return list.indexOfFirst { @@ -173,39 +188,35 @@ class AlarmAddEditViewModel @Inject constructor( } } - private fun checkUnsavedChangesBeforeExit() { - if (currentState.mode == AlarmAddEditContract.EditMode.ADD) { + private fun checkUnsavedChangesBeforeExit() = intent { + if (state.mode == AlarmAddEditContract.EditMode.ADD) { navigateBack() } else { - val updatedAlarm = currentState.toAlarm() - viewModelScope.launch { - alarmUseCase.getAlarm(alarmId).onSuccess { existingAlarm -> - if (updatedAlarm.copy(id = alarmId) != existingAlarm) { - showUnsavedChangesDialog() - } else { - emitSideEffect(AlarmAddEditContract.SideEffect.NavigateBack) - } + val updatedAlarm = state.toAlarm() + alarmUseCase.getAlarm(alarmId).onSuccess { existingAlarm -> + if (updatedAlarm.copy(id = alarmId) != existingAlarm) { + showUnsavedChangesDialog() + } else { + postSideEffect(AlarmAddEditContract.SideEffect.NavigateBack) } } } } - private fun navigateBack() { - emitSideEffect(AlarmAddEditContract.SideEffect.NavigateBack) + private fun navigateBack() = intent { + postSideEffect(AlarmAddEditContract.SideEffect.NavigateBack) } - private fun saveAlarm() { - val newAlarm = currentState.toAlarm() + private fun saveAlarm() = intent { + val newAlarm = state.toAlarm() - viewModelScope.launch { - when (currentState.mode) { - AlarmAddEditContract.EditMode.EDIT -> updateExistingAlarm(newAlarm) - AlarmAddEditContract.EditMode.ADD -> checkAndCreateAlarm(newAlarm) - } + when (state.mode) { + AlarmAddEditContract.EditMode.EDIT -> updateExistingAlarm(newAlarm) + AlarmAddEditContract.EditMode.ADD -> checkAndCreateAlarm(newAlarm) } } - private suspend fun updateExistingAlarm(alarm: Alarm) { + private fun updateExistingAlarm(alarm: Alarm) = intent { val updatedAlarm = alarm.copy(id = alarmId) alarmUseCase.getAlarm(alarmId).onSuccess { oldAlarm -> @@ -215,7 +226,7 @@ class AlarmAddEditViewModel @Inject constructor( alarmUseCase.updateAlarm(updatedAlarm) .onSuccess { alarmScheduler.scheduleAlarm(updatedAlarm) - emitSideEffect(AlarmAddEditContract.SideEffect.UpdateAlarm(it.id)) + postSideEffect(AlarmAddEditContract.SideEffect.UpdateAlarm(it.id)) } .onFailure { Log.e("AlarmAddEditViewModel", "Failed to update alarm", it) @@ -243,8 +254,8 @@ class AlarmAddEditViewModel @Inject constructor( } } - private fun showAlarmAlreadySetWarning() { - emitSideEffect( + private fun showAlarmAlreadySetWarning() = intent { + postSideEffect( AlarmAddEditContract.SideEffect.ShowSnackBar( message = resourceProvider.getString(R.string.alarm_already_set), iconRes = resourceProvider.getDrawable(core.designsystem.R.drawable.ic_alert), @@ -255,7 +266,7 @@ class AlarmAddEditViewModel @Inject constructor( ) } - private suspend fun createNewAlarm(alarm: Alarm) { + private fun createNewAlarm(alarm: Alarm) = intent { alarmUseCase.insertAlarm(alarm) .onSuccess { analyticsHelper.logEvent( @@ -269,139 +280,139 @@ class AlarmAddEditViewModel @Inject constructor( ), ) alarmScheduler.scheduleAlarm(it) - emitSideEffect(AlarmAddEditContract.SideEffect.SaveAlarm(it.id)) + postSideEffect(AlarmAddEditContract.SideEffect.SaveAlarm(it.id)) } .onFailure { Log.e("AlarmAddEditViewModel", "Failed to insert alarm", it) } } - private fun setAlarmTime(amPm: String, hour: Int, minute: Int) { - val newTimeState = currentState.timeState.copy( + private fun setAlarmTime(amPm: String, hour: Int, minute: Int) = intent { + val newTimeState = state.timeState.copy( currentAmPm = amPm, currentHour = hour, currentMinute = minute, - alarmMessage = getAlarmMessage(amPm, hour, minute, currentState.daySelectionState.selectedDays), + alarmMessage = getAlarmMessage(amPm, hour, minute, state.daySelectionState.selectedDays), ) hapticFeedbackManager.performHapticFeedback(HapticType.LIGHT_TICK) - updateState { - copy(timeState = newTimeState) + reduce { + state.copy(timeState = newTimeState) } } - private fun showDeleteDialog() { - updateState { copy(isDeleteDialogVisible = true) } + private fun showDeleteDialog() = intent { + reduce { state.copy(isDeleteDialogVisible = true) } } - private fun hideDeleteDialog() { - updateState { copy(isDeleteDialogVisible = false) } + private fun hideDeleteDialog() = intent { + reduce { state.copy(isDeleteDialogVisible = false) } } - private fun showUnsavedChangesDialog() { - updateState { copy(isUnsavedChangesDialogVisible = true) } + private fun showUnsavedChangesDialog() = intent { + reduce { state.copy(isUnsavedChangesDialogVisible = true) } } - private fun hideUnsavedChangesDialog() { - updateState { copy(isUnsavedChangesDialogVisible = false) } + private fun hideUnsavedChangesDialog() = intent { + reduce { state.copy(isUnsavedChangesDialogVisible = false) } } - private fun deleteAlarm() { - emitSideEffect(AlarmAddEditContract.SideEffect.DeleteAlarm(alarmId)) + private fun deleteAlarm() = intent { + postSideEffect(AlarmAddEditContract.SideEffect.DeleteAlarm(alarmId)) } - private fun toggleWeekdaysSelection() { + private fun toggleWeekdaysSelection() = intent { val weekdays = setOf(AlarmDay.MON, AlarmDay.TUE, AlarmDay.WED, AlarmDay.THU, AlarmDay.FRI) - val isChecked = !currentState.daySelectionState.isWeekdaysChecked + val isChecked = !state.daySelectionState.isWeekdaysChecked val updatedDays = if (isChecked) { - currentState.daySelectionState.selectedDays + weekdays + state.daySelectionState.selectedDays + weekdays } else { - currentState.daySelectionState.selectedDays - weekdays + state.daySelectionState.selectedDays - weekdays } - val newDayState = currentState.daySelectionState.copy( + val newDayState = state.daySelectionState.copy( isWeekdaysChecked = isChecked, selectedDays = updatedDays, ) - updateState { - copy( - timeState = timeState.copy( - alarmMessage = getAlarmMessage(timeState.currentAmPm, timeState.currentHour, timeState.currentMinute, newDayState.selectedDays), + reduce { + state.copy( + timeState = state.timeState.copy( + alarmMessage = getAlarmMessage(state.timeState.currentAmPm, state.timeState.currentHour, state.timeState.currentMinute, newDayState.selectedDays), ), daySelectionState = newDayState, - holidayState = holidayState.copy( + holidayState = state.holidayState.copy( isDisableHolidayEnabled = newDayState.selectedDays.isNotEmpty(), - isDisableHolidayChecked = if (newDayState.selectedDays.isEmpty()) false else holidayState.isDisableHolidayChecked, + isDisableHolidayChecked = if (newDayState.selectedDays.isEmpty()) false else state.holidayState.isDisableHolidayChecked, ), ) } } - private fun toggleWeekendsSelection() { + private fun toggleWeekendsSelection() = intent { val weekends = setOf(AlarmDay.SAT, AlarmDay.SUN) - val isChecked = !currentState.daySelectionState.isWeekendsChecked + val isChecked = !state.daySelectionState.isWeekendsChecked val updatedDays = if (isChecked) { - currentState.daySelectionState.selectedDays + weekends + state.daySelectionState.selectedDays + weekends } else { - currentState.daySelectionState.selectedDays - weekends + state.daySelectionState.selectedDays - weekends } - val newDayState = currentState.daySelectionState.copy( + val newDayState = state.daySelectionState.copy( isWeekendsChecked = isChecked, selectedDays = updatedDays, ) - updateState { - copy( - timeState = timeState.copy( - alarmMessage = getAlarmMessage(timeState.currentAmPm, timeState.currentHour, timeState.currentMinute, newDayState.selectedDays), + reduce { + state.copy( + timeState = state.timeState.copy( + alarmMessage = getAlarmMessage(state.timeState.currentAmPm, state.timeState.currentHour, state.timeState.currentMinute, newDayState.selectedDays), ), daySelectionState = newDayState, - holidayState = holidayState.copy( + holidayState = state.holidayState.copy( isDisableHolidayEnabled = newDayState.selectedDays.isNotEmpty(), - isDisableHolidayChecked = if (newDayState.selectedDays.isEmpty()) false else holidayState.isDisableHolidayChecked, + isDisableHolidayChecked = if (newDayState.selectedDays.isEmpty()) false else state.holidayState.isDisableHolidayChecked, ), ) } } - private fun toggleSpecificDaySelection(day: AlarmDay) { - val updatedDays = if (day in currentState.daySelectionState.selectedDays) { - currentState.daySelectionState.selectedDays - day + private fun toggleSpecificDaySelection(day: AlarmDay) = intent { + val updatedDays = if (day in state.daySelectionState.selectedDays) { + state.daySelectionState.selectedDays - day } else { - currentState.daySelectionState.selectedDays + day + state.daySelectionState.selectedDays + day } val weekdays = setOf(AlarmDay.MON, AlarmDay.TUE, AlarmDay.WED, AlarmDay.THU, AlarmDay.FRI) val weekends = setOf(AlarmDay.SAT, AlarmDay.SUN) - val newDayState = currentState.daySelectionState.copy( + val newDayState = state.daySelectionState.copy( selectedDays = updatedDays, isWeekdaysChecked = updatedDays.containsAll(weekdays), isWeekendsChecked = updatedDays.containsAll(weekends), ) - updateState { - copy( - timeState = timeState.copy( - alarmMessage = getAlarmMessage(timeState.currentAmPm, timeState.currentHour, timeState.currentMinute, newDayState.selectedDays), + reduce { + state.copy( + timeState = state.timeState.copy( + alarmMessage = getAlarmMessage(state.timeState.currentAmPm, state.timeState.currentHour, state.timeState.currentMinute, newDayState.selectedDays), ), daySelectionState = newDayState, - holidayState = holidayState.copy( + holidayState = state.holidayState.copy( isDisableHolidayEnabled = newDayState.selectedDays.isNotEmpty(), - isDisableHolidayChecked = if (newDayState.selectedDays.isEmpty()) false else holidayState.isDisableHolidayChecked, + isDisableHolidayChecked = if (newDayState.selectedDays.isEmpty()) false else state.holidayState.isDisableHolidayChecked, ), ) } } - private fun toggleHolidaySkipOption() { - val newHolidayState = currentState.holidayState.copy( - isDisableHolidayChecked = !currentState.holidayState.isDisableHolidayChecked, + private fun toggleHolidaySkipOption() = intent { + val newHolidayState = state.holidayState.copy( + isDisableHolidayChecked = !state.holidayState.isDisableHolidayChecked, ) - updateState { - copy(holidayState = newHolidayState) + reduce { + state.copy(holidayState = newHolidayState) } if (newHolidayState.isDisableHolidayChecked) { - emitSideEffect( + postSideEffect( AlarmAddEditContract.SideEffect.ShowSnackBar( message = resourceProvider.getString(R.string.alarm_disabled_warning), label = resourceProvider.getString(R.string.alarm_delete_dialog_btn_cancel), @@ -409,8 +420,14 @@ class AlarmAddEditViewModel @Inject constructor( bottomPadding = 78.dp, onDismiss = { }, onAction = { - updateState { - copy(holidayState = holidayState.copy(isDisableHolidayChecked = false)) + intent { + reduce { + state.copy( + holidayState = state.holidayState.copy( + isDisableHolidayChecked = false, + ), + ) + } } }, ), @@ -418,80 +435,80 @@ class AlarmAddEditViewModel @Inject constructor( } } - private fun toggleSnoozeOption() { - val newSnoozeState = currentState.snoozeState.copy( - isSnoozeEnabled = !currentState.snoozeState.isSnoozeEnabled, + private fun toggleSnoozeOption() = intent { + val newSnoozeState = state.snoozeState.copy( + isSnoozeEnabled = !state.snoozeState.isSnoozeEnabled, ) - updateState { - copy(snoozeState = newSnoozeState) + reduce { + state.copy(snoozeState = newSnoozeState) } } - private fun setSnoozeInterval(index: Int) { - val newSnoozeState = currentState.snoozeState.copy(snoozeIntervalIndex = index) - updateState { - copy(snoozeState = newSnoozeState) + private fun setSnoozeInterval(index: Int) = intent { + val newSnoozeState = state.snoozeState.copy(snoozeIntervalIndex = index) + reduce { + state.copy(snoozeState = newSnoozeState) } } - private fun setSnoozeRepeatCount(index: Int) { - val newSnoozeState = currentState.snoozeState.copy(snoozeCountIndex = index) - updateState { - copy(snoozeState = newSnoozeState) + private fun setSnoozeRepeatCount(index: Int) = intent { + val newSnoozeState = state.snoozeState.copy(snoozeCountIndex = index) + reduce { + state.copy(snoozeState = newSnoozeState) } } - private fun toggleVibrationOption() { - val newSoundState = currentState.soundState.copy(isVibrationEnabled = !currentState.soundState.isVibrationEnabled) + private fun toggleVibrationOption() = intent { + val newSoundState = state.soundState.copy(isVibrationEnabled = !state.soundState.isVibrationEnabled) if (newSoundState.isVibrationEnabled) { hapticFeedbackManager.performHapticFeedback(HapticType.SUCCESS) } - updateState { - copy(soundState = newSoundState) + reduce { + state.copy(soundState = newSoundState) } } - private fun toggleSoundOption() { - val newSoundState = currentState.soundState.copy(isSoundEnabled = !currentState.soundState.isSoundEnabled) + private fun toggleSoundOption() = intent { + val newSoundState = state.soundState.copy(isSoundEnabled = !state.soundState.isSoundEnabled) if (!newSoundState.isSoundEnabled) { alarmUseCase.stopAlarmSound() } - updateState { - copy(soundState = newSoundState) + reduce { + state.copy(soundState = newSoundState) } } - private fun adjustSoundVolume(volume: Int) { - val newSoundState = currentState.soundState.copy(soundVolume = volume) + private fun adjustSoundVolume(volume: Int) = intent { + val newSoundState = state.soundState.copy(soundVolume = volume) alarmUseCase.updateAlarmVolume(volume) - updateState { - copy(soundState = newSoundState) + reduce { + state.copy(soundState = newSoundState) } } - private fun selectAlarmSound(index: Int) { - val newSoundState = currentState.soundState.copy(soundIndex = index) - updateState { - copy(soundState = newSoundState) + private fun selectAlarmSound(index: Int) = intent { + val newSoundState = state.soundState.copy(soundIndex = index) + reduce { + state.copy(soundState = newSoundState) } - val selectedSound = currentState.soundState.sounds[index] + val selectedSound = state.soundState.sounds[index] alarmUseCase.initializeSoundPlayer(selectedSound.uri) - alarmUseCase.playAlarmSound(currentState.soundState.soundVolume) + alarmUseCase.playAlarmSound(state.soundState.soundVolume) } - private fun toggleBottomSheet(sheetType: AlarmAddEditContract.BottomSheetType) { - val newBottomSheetState = if (currentState.bottomSheetState == sheetType) { - if (currentState.bottomSheetState == AlarmAddEditContract.BottomSheetType.SoundSetting) { + private fun toggleBottomSheet(sheetType: AlarmAddEditContract.BottomSheetType) = intent { + val newBottomSheetState = if (state.bottomSheetState == sheetType) { + if (state.bottomSheetState == AlarmAddEditContract.BottomSheetType.SoundSetting) { alarmUseCase.stopAlarmSound() } null } else { sheetType } - updateState { - copy(bottomSheetState = newBottomSheetState) + reduce { + state.copy(bottomSheetState = newBottomSheetState) } } From ca8f1435292d2ee8b86bf0482ae2de3c6bcf9e0d Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Wed, 9 Jul 2025 14:42:08 +0900 Subject: [PATCH 04/16] =?UTF-8?q?[REFACTOR/#231]=20handleSideEffect=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=EB=A5=BC=20Route=20=EB=B0=94=EB=A1=9C=20?= =?UTF-8?q?=EC=95=84=EB=9E=98=EB=A1=9C=20=EC=9D=B4=EB=8F=99=ED=95=98?= =?UTF-8?q?=EC=97=AC=20=EA=B5=AC=EC=A1=B0=20=EA=B0=80=EB=8F=85=EC=84=B1=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yapp/alarm/addedit/AlarmAddEditScreen.kt | 94 +++++++++---------- .../main/java/com/yapp/splash/SplashScreen.kt | 54 +++++------ 2 files changed, 74 insertions(+), 74 deletions(-) diff --git a/feature/home/src/main/java/com/yapp/alarm/addedit/AlarmAddEditScreen.kt b/feature/home/src/main/java/com/yapp/alarm/addedit/AlarmAddEditScreen.kt index 5f4e9fb8..ebcaec50 100644 --- a/feature/home/src/main/java/com/yapp/alarm/addedit/AlarmAddEditScreen.kt +++ b/feature/home/src/main/java/com/yapp/alarm/addedit/AlarmAddEditScreen.kt @@ -91,6 +91,53 @@ fun AlarmAddEditRoute( ) } +private suspend fun handleSideEffect( + sideEffect: AlarmAddEditContract.SideEffect, + navigator: OrbitNavigator, + snackBarHostState: SnackbarHostState, + coroutineScope: CoroutineScope, +) { + when (sideEffect) { + is AlarmAddEditContract.SideEffect.NavigateBack -> { + navigator.navigateBack() + } + is AlarmAddEditContract.SideEffect.SaveAlarm -> { + navigator.navController.previousBackStackEntry + ?.savedStateHandle + ?.set(ADD_ALARM_RESULT_KEY, sideEffect.id) + navigator.navController.popBackStack() + } + is AlarmAddEditContract.SideEffect.UpdateAlarm -> { + navigator.navController.previousBackStackEntry + ?.savedStateHandle + ?.set(UPDATE_ALARM_RESULT_KEY, sideEffect.id) + navigator.navigateBack() + } + is AlarmAddEditContract.SideEffect.DeleteAlarm -> { + navigator.navController.previousBackStackEntry + ?.savedStateHandle + ?.set(DELETE_ALARM_RESULT_KEY, sideEffect.id) + navigator.navigateBack() + } + is AlarmAddEditContract.SideEffect.ShowSnackBar -> { + val result = showCustomSnackBar( + scope = coroutineScope, + snackBarHostState = snackBarHostState, + message = sideEffect.message, + actionLabel = sideEffect.label, + iconRes = sideEffect.iconRes, + bottomPadding = sideEffect.bottomPadding, + durationMillis = sideEffect.durationMillis, + ) + + when (result) { + SnackbarResult.ActionPerformed -> sideEffect.onAction() + SnackbarResult.Dismissed -> sideEffect.onDismiss() + } + } + } +} + @Composable fun AlarmAddEditScreen( stateProvider: () -> AlarmAddEditContract.State, @@ -597,53 +644,6 @@ private fun AlarmAddEditDisableHolidaySwitch( } } -private suspend fun handleSideEffect( - effect: AlarmAddEditContract.SideEffect, - navigator: OrbitNavigator, - snackBarHostState: SnackbarHostState, - coroutineScope: CoroutineScope, -) { - when (effect) { - is AlarmAddEditContract.SideEffect.NavigateBack -> { - navigator.navigateBack() - } - is AlarmAddEditContract.SideEffect.SaveAlarm -> { - navigator.navController.previousBackStackEntry - ?.savedStateHandle - ?.set(ADD_ALARM_RESULT_KEY, effect.id) - navigator.navController.popBackStack() - } - is AlarmAddEditContract.SideEffect.UpdateAlarm -> { - navigator.navController.previousBackStackEntry - ?.savedStateHandle - ?.set(UPDATE_ALARM_RESULT_KEY, effect.id) - navigator.navigateBack() - } - is AlarmAddEditContract.SideEffect.DeleteAlarm -> { - navigator.navController.previousBackStackEntry - ?.savedStateHandle - ?.set(DELETE_ALARM_RESULT_KEY, effect.id) - navigator.navigateBack() - } - is AlarmAddEditContract.SideEffect.ShowSnackBar -> { - val result = showCustomSnackBar( - scope = coroutineScope, - snackBarHostState = snackBarHostState, - message = effect.message, - actionLabel = effect.label, - iconRes = effect.iconRes, - bottomPadding = effect.bottomPadding, - durationMillis = effect.durationMillis, - ) - - when (result) { - SnackbarResult.ActionPerformed -> effect.onAction() - SnackbarResult.Dismissed -> effect.onDismiss() - } - } - } -} - @Preview @Composable fun AlarmAddEditSettingsSectionPreview() { diff --git a/feature/splash/src/main/java/com/yapp/splash/SplashScreen.kt b/feature/splash/src/main/java/com/yapp/splash/SplashScreen.kt index 6ba67dee..6068cd1b 100644 --- a/feature/splash/src/main/java/com/yapp/splash/SplashScreen.kt +++ b/feature/splash/src/main/java/com/yapp/splash/SplashScreen.kt @@ -38,33 +38,6 @@ fun SplashRoute( SplashScreen(state = state) } -@Composable -fun SplashScreen( - state: SplashContract.State, -) { - val alpha by animateFloatAsState( - targetValue = if (state.isVisible) 1f else 0f, - animationSpec = tween(durationMillis = 1000, easing = FastOutSlowInEasing), - label = "logoFade", - ) - - Column( - modifier = Modifier - .fillMaxSize() - .background(OrbitTheme.colors.gray_900), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Image( - painter = painterResource(id = core.designsystem.R.drawable.ic_splash_logo), - contentDescription = "Splash Logo", - modifier = Modifier - .size(120.dp) - .graphicsLayer(alpha = alpha), - ) - } -} - private fun handleSideEffects( sideEffect: SplashContract.SideEffect, navigator: OrbitNavigator, @@ -91,3 +64,30 @@ private fun handleSideEffects( } } } + +@Composable +fun SplashScreen( + state: SplashContract.State, +) { + val alpha by animateFloatAsState( + targetValue = if (state.isVisible) 1f else 0f, + animationSpec = tween(durationMillis = 1000, easing = FastOutSlowInEasing), + label = "logoFade", + ) + + Column( + modifier = Modifier + .fillMaxSize() + .background(OrbitTheme.colors.gray_900), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Image( + painter = painterResource(id = core.designsystem.R.drawable.ic_splash_logo), + contentDescription = "Splash Logo", + modifier = Modifier + .size(120.dp) + .graphicsLayer(alpha = alpha), + ) + } +} From ad83ccd3782a0585f7d33cfc1ac32ab5b31c699e Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Wed, 9 Jul 2025 14:48:12 +0900 Subject: [PATCH 05/16] =?UTF-8?q?[REFACTOR/#231]=20HomeViewModel=EC=9D=84?= =?UTF-8?q?=20ContainerHost=20=EA=B8=B0=EB=B0=98=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/yapp/home/HomeScreen.kt | 81 ++-- .../main/java/com/yapp/home/HomeViewModel.kt | 388 +++++++++--------- 2 files changed, 236 insertions(+), 233 deletions(-) diff --git a/feature/home/src/main/java/com/yapp/home/HomeScreen.kt b/feature/home/src/main/java/com/yapp/home/HomeScreen.kt index b8a4dc46..f65e1061 100644 --- a/feature/home/src/main/java/com/yapp/home/HomeScreen.kt +++ b/feature/home/src/main/java/com/yapp/home/HomeScreen.kt @@ -77,7 +77,8 @@ import com.yapp.ui.component.tooltip.OrbitToolTip import com.yapp.ui.utils.heightForScreenPercentage import com.yapp.ui.utils.toPx import feature.home.R -import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.CoroutineScope +import org.orbitmvi.orbit.compose.collectSideEffect @Composable fun HomeRoute( @@ -86,7 +87,6 @@ fun HomeRoute( snackBarHostState: SnackbarHostState, ) { val state by viewModel.container.stateFlow.collectAsStateWithLifecycle() - val sideEffect = viewModel.container.sideEffectFlow val coroutineScope = rememberCoroutineScope() @@ -120,49 +120,56 @@ fun HomeRoute( } } - LaunchedEffect(sideEffect) { - sideEffect.collectLatest { effect -> - when (effect) { - is HomeContract.SideEffect.NavigateToAddAlarm -> { - navigator.navigateToAddAlarm() - } + viewModel.collectSideEffect { + handleSideEffect(it, navigator, snackBarHostState, coroutineScope) + } - is HomeContract.SideEffect.NavigateToEditAlarm -> { - navigator.navigateToEditAlarm(effect.alarmId) - } + HomeScreen( + stateProvider = { state }, + eventDispatcher = viewModel::processAction, + ) +} - is HomeContract.SideEffect.NavigateToFortune -> { - navigator.navigateToFortune() - } +private suspend fun handleSideEffect( + sideEffect: HomeContract.SideEffect, + navigator: OrbitNavigator, + snackBarHostState: SnackbarHostState, + coroutineScope: CoroutineScope, +) { + when (sideEffect) { + is HomeContract.SideEffect.NavigateToAddAlarm -> { + navigator.navigateToAddAlarm() + } - is HomeContract.SideEffect.NavigateToSetting -> { - navigator.navigateToSetting() - } + is HomeContract.SideEffect.NavigateToEditAlarm -> { + navigator.navigateToEditAlarm(sideEffect.alarmId) + } - is HomeContract.SideEffect.ShowSnackBar -> { - val result = showCustomSnackBar( - scope = coroutineScope, - snackBarHostState = snackBarHostState, - message = effect.message, - actionLabel = effect.label, - iconRes = effect.iconRes, - bottomPadding = effect.bottomPadding, - durationMillis = effect.durationMillis, - ) + is HomeContract.SideEffect.NavigateToFortune -> { + navigator.navigateToFortune() + } - when (result) { - SnackbarResult.ActionPerformed -> effect.onAction() - SnackbarResult.Dismissed -> effect.onDismiss() - } - } + is HomeContract.SideEffect.NavigateToSetting -> { + navigator.navigateToSetting() + } + + is HomeContract.SideEffect.ShowSnackBar -> { + val result = showCustomSnackBar( + scope = coroutineScope, + snackBarHostState = snackBarHostState, + message = sideEffect.message, + actionLabel = sideEffect.label, + iconRes = sideEffect.iconRes, + bottomPadding = sideEffect.bottomPadding, + durationMillis = sideEffect.durationMillis, + ) + + when (result) { + SnackbarResult.ActionPerformed -> sideEffect.onAction() + SnackbarResult.Dismissed -> sideEffect.onDismiss() } } } - - HomeScreen( - stateProvider = { state }, - eventDispatcher = viewModel::processAction, - ) } @Composable diff --git a/feature/home/src/main/java/com/yapp/home/HomeViewModel.kt b/feature/home/src/main/java/com/yapp/home/HomeViewModel.kt index df6b4558..04944efc 100644 --- a/feature/home/src/main/java/com/yapp/home/HomeViewModel.kt +++ b/feature/home/src/main/java/com/yapp/home/HomeViewModel.kt @@ -1,7 +1,7 @@ package com.yapp.home import android.util.Log -import androidx.lifecycle.viewModelScope +import androidx.lifecycle.ViewModel import com.yapp.common.util.ResourceProvider import com.yapp.domain.model.Alarm import com.yapp.domain.model.toAlarmDays @@ -10,12 +10,18 @@ import com.yapp.domain.repository.FortuneRepository import com.yapp.domain.repository.UserInfoRepository import com.yapp.domain.scheduler.AlarmScheduler import com.yapp.domain.usecase.AlarmUseCase -import com.yapp.ui.base.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel import feature.home.R import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.launch +import org.orbitmvi.orbit.Container +import org.orbitmvi.orbit.ContainerHost +import org.orbitmvi.orbit.syntax.simple.intent +import org.orbitmvi.orbit.syntax.simple.postSideEffect +import org.orbitmvi.orbit.syntax.simple.reduce +import org.orbitmvi.orbit.syntax.simple.repeatOnSubscription +import org.orbitmvi.orbit.viewmodel.container import java.time.LocalDate import java.time.LocalDateTime import java.time.LocalTime @@ -29,13 +35,18 @@ class HomeViewModel @Inject constructor( private val alarmScheduler: AlarmScheduler, private val fortuneRepository: FortuneRepository, private val userInfoRepository: UserInfoRepository, -) : BaseViewModel( - initialState = HomeContract.State(), -) { - init { - loadAllAlarms() - loadDailyFortuneState() - loadUserName() +) : ViewModel(), ContainerHost { + + override val container: Container = container( + initialState = HomeContract.State(), + ) { + intent { + repeatOnSubscription { + loadAllAlarms() + loadDailyFortuneState() + loadUserName() + } + } } fun processAction(action: HomeContract.Action) { @@ -70,17 +81,17 @@ class HomeViewModel @Inject constructor( } } - fun scrollToAddedAlarm(id: Long) { - val newAlarmIndex = currentState.alarms.indexOfFirst { it.id == id } - if (newAlarmIndex == -1) return + fun scrollToAddedAlarm(id: Long) = intent { + val newAlarmIndex = state.alarms.indexOfFirst { it.id == id } + if (newAlarmIndex == -1) return@intent - updateState { - copy( + reduce { + state.copy( lastAddedAlarmIndex = newAlarmIndex, ) } - emitSideEffect( + postSideEffect( HomeContract.SideEffect.ShowSnackBar( message = resourceProvider.getString(R.string.alarm_added), iconRes = resourceProvider.getDrawable(core.designsystem.R.drawable.ic_check_green), @@ -90,164 +101,160 @@ class HomeViewModel @Inject constructor( ) } - fun scrollToUpdatedAlarm(id: Long) { - val updatedAlarmIndex = currentState.alarms.indexOfFirst { it.id == id } - if (updatedAlarmIndex == -1) return + fun scrollToUpdatedAlarm(id: Long) = intent { + val updatedAlarmIndex = state.alarms.indexOfFirst { it.id == id } + if (updatedAlarmIndex == -1) return@intent - updateState { - copy( + reduce { + state.copy( lastAddedAlarmIndex = updatedAlarmIndex, ) } } - private fun navigateToAlarmCreation() { - emitSideEffect(HomeContract.SideEffect.NavigateToAddAlarm) + private fun navigateToAlarmCreation() = intent { + postSideEffect(HomeContract.SideEffect.NavigateToAddAlarm) } - private fun toggleMultiSelectionMode() { - updateState { - copy( - isSelectionMode = !currentState.isSelectionMode, + private fun toggleMultiSelectionMode() = intent { + reduce { + state.copy( + isSelectionMode = !state.isSelectionMode, selectedAlarmIds = emptySet(), dropdownMenuExpanded = false, ) } } - private fun showDropDownMenu() { - updateState { copy(dropdownMenuExpanded = true) } + private fun showDropDownMenu() = intent { + reduce { state.copy(dropdownMenuExpanded = true) } } - private fun showSortDropDownMenu() { - updateState { - copy( + private fun showSortDropDownMenu() = intent { + reduce { + state.copy( dropdownMenuExpanded = false, sortDropDownMenuExpanded = true, ) } } - private fun hideDropDownMenu() { - updateState { - copy( + private fun hideDropDownMenu() = intent { + reduce { + state.copy( dropdownMenuExpanded = false, sortDropDownMenuExpanded = false, ) } } - private fun toggleAlarmSelection(alarmId: Long) { - updateState { - val updatedSelection = currentState.selectedAlarmIds.toMutableSet().apply { + private fun toggleAlarmSelection(alarmId: Long) = intent { + reduce { + val updatedSelection = state.selectedAlarmIds.toMutableSet().apply { if (contains(alarmId)) remove(alarmId) else add(alarmId) } - copy(selectedAlarmIds = updatedSelection) + state.copy(selectedAlarmIds = updatedSelection) } } - private fun toggleAllAlarmSelection() { - updateState { - val allIds = currentState.alarms.map { it.id }.toSet() - val updatedSelection = if (currentState.selectedAlarmIds == allIds) emptySet() else allIds - copy(selectedAlarmIds = updatedSelection) + private fun toggleAllAlarmSelection() = intent { + reduce { + val allIds = state.alarms.map { it.id }.toSet() + val updatedSelection = if (state.selectedAlarmIds == allIds) emptySet() else allIds + state.copy(selectedAlarmIds = updatedSelection) } } - private fun toggleAlarmActivation(alarmId: Long) { - viewModelScope.launch { - val currentIndex = currentState.alarms.indexOfFirst { it.id == alarmId } - if (currentIndex == -1) return@launch + private fun toggleAlarmActivation(alarmId: Long) = intent { + val currentIndex = state.alarms.indexOfFirst { it.id == alarmId } + if (currentIndex == -1) return@intent - val currentAlarm = currentState.alarms[currentIndex] - val previousState = currentAlarm.isAlarmActive // 기존 상태 저장 - val updatedAlarm = currentAlarm.copy(isAlarmActive = !currentAlarm.isAlarmActive) + val currentAlarm = state.alarms[currentIndex] + val previousState = currentAlarm.isAlarmActive // 기존 상태 저장 + val updatedAlarm = currentAlarm.copy(isAlarmActive = !currentAlarm.isAlarmActive) - alarmUseCase.updateAlarmActive(alarmId, updatedAlarm.isAlarmActive).onSuccess { - val updatedAlarms = currentState.alarms.toMutableList() - updatedAlarms[currentIndex] = updatedAlarm + alarmUseCase.updateAlarmActive(alarmId, updatedAlarm.isAlarmActive).onSuccess { + val updatedAlarms = state.alarms.toMutableList() + updatedAlarms[currentIndex] = updatedAlarm - val hasActivatedAlarm = updatedAlarms.any { it.isAlarmActive } - updateState { - copy( - alarms = updatedAlarms, - isNoActivatedAlarmDialogVisible = !hasActivatedAlarm, - pendingAlarmToggle = if (!hasActivatedAlarm) alarmId to previousState else null, - ) - } - - if (updatedAlarm.isAlarmActive) { - alarmScheduler.scheduleAlarm(updatedAlarm) - } else { - alarmScheduler.unScheduleAlarm(updatedAlarm) - } - }.onFailure { error -> - Log.e("HomeViewModel", "Failed to update alarm state", error) + val hasActivatedAlarm = updatedAlarms.any { it.isAlarmActive } + reduce { + state.copy( + alarms = updatedAlarms, + isNoActivatedAlarmDialogVisible = !hasActivatedAlarm, + pendingAlarmToggle = if (!hasActivatedAlarm) alarmId to previousState else null, + ) } + + if (updatedAlarm.isAlarmActive) { + alarmScheduler.scheduleAlarm(updatedAlarm) + } else { + alarmScheduler.unScheduleAlarm(updatedAlarm) + } + }.onFailure { error -> + Log.e("HomeViewModel", "Failed to update alarm state", error) } } - private fun showDeleteDialog() { - updateState { copy(isDeleteDialogVisible = true) } + private fun showDeleteDialog() = intent { + reduce { state.copy(isDeleteDialogVisible = true) } } - private fun hideDeleteDialog() { - updateState { copy(isDeleteDialogVisible = false) } + private fun hideDeleteDialog() = intent { + reduce { state.copy(isDeleteDialogVisible = false) } } - private fun confirmDeletion() { - deleteAlarms(currentState.selectedAlarmIds) - updateState { - copy( + private fun confirmDeletion() = intent { + deleteAlarms(state.selectedAlarmIds) + reduce { + state.copy( selectedAlarmIds = emptySet(), isDeleteDialogVisible = false, ) } } - private fun showNoActivatedAlarmDialog() { - updateState { copy(isNoActivatedAlarmDialogVisible = true) } + private fun showNoActivatedAlarmDialog() = intent { + reduce { state.copy(isNoActivatedAlarmDialogVisible = true) } } - private fun hideNoActivatedAlarmDialog() { - updateState { - copy( + private fun hideNoActivatedAlarmDialog() = intent { + reduce { + state.copy( isNoActivatedAlarmDialogVisible = false, pendingAlarmToggle = null, ) } } - private fun rollbackAlarmActivation() { - val pendingAlarm = currentState.pendingAlarmToggle ?: return + private fun rollbackAlarmActivation() = intent { + val pendingAlarm = state.pendingAlarmToggle ?: return@intent val (alarmId, previousState) = pendingAlarm - viewModelScope.launch { - val currentIndex = currentState.alarms.indexOfFirst { it.id == alarmId } - if (currentIndex == -1) return@launch - - val currentAlarm = currentState.alarms[currentIndex] - val restoredAlarm = currentAlarm.copy(isAlarmActive = previousState) - - alarmUseCase.updateAlarm(restoredAlarm).onSuccess { updatedAlarm -> - val updatedAlarms = currentState.alarms.toMutableList() - updatedAlarms[currentIndex] = updatedAlarm - updateState { - copy( - alarms = updatedAlarms, - pendingAlarmToggle = null, - isNoActivatedAlarmDialogVisible = false, - ) - } - - if (updatedAlarm.isAlarmActive) { - alarmScheduler.scheduleAlarm(updatedAlarm) - } else { - alarmScheduler.unScheduleAlarm(updatedAlarm) - } - }.onFailure { error -> - Log.e("HomeViewModel", "Failed to rollback alarm state", error) + val currentIndex = state.alarms.indexOfFirst { it.id == alarmId } + if (currentIndex == -1) return@intent + + val currentAlarm = state.alarms[currentIndex] + val restoredAlarm = currentAlarm.copy(isAlarmActive = previousState) + + alarmUseCase.updateAlarm(restoredAlarm).onSuccess { updatedAlarm -> + val updatedAlarms = state.alarms.toMutableList() + updatedAlarms[currentIndex] = updatedAlarm + reduce { + state.copy( + alarms = updatedAlarms, + pendingAlarmToggle = null, + isNoActivatedAlarmDialogVisible = false, + ) } + + if (updatedAlarm.isAlarmActive) { + alarmScheduler.scheduleAlarm(updatedAlarm) + } else { + alarmScheduler.unScheduleAlarm(updatedAlarm) + } + }.onFailure { error -> + Log.e("HomeViewModel", "Failed to rollback alarm state", error) } } @@ -255,24 +262,22 @@ class HomeViewModel @Inject constructor( deleteAlarms(setOf(alarmId)) } - private fun deleteAlarms(alarmIds: Set) { - if (alarmIds.isEmpty()) return + private fun deleteAlarms(alarmIds: Set) = intent { + if (alarmIds.isEmpty()) return@intent - val alarmsToDelete = currentState.alarms + val alarmsToDelete = state.alarms .filter { it.id in alarmIds } - viewModelScope.launch { - alarmsToDelete.forEach { alarm -> - alarmUseCase.deleteAlarm(alarm.id) - alarmScheduler.unScheduleAlarm(alarm) - } + alarmsToDelete.forEach { alarm -> + alarmUseCase.deleteAlarm(alarm.id) + alarmScheduler.unScheduleAlarm(alarm) } - if (currentState.activeItemMenu != null) { + if (state.activeItemMenu != null) { hideItemMenu() } - emitSideEffect( + postSideEffect( HomeContract.SideEffect.ShowSnackBar( message = resourceProvider.getString(R.string.alarm_deleted), label = resourceProvider.getString(R.string.alarm_delete_dialog_btn_cancel), @@ -285,40 +290,36 @@ class HomeViewModel @Inject constructor( ) } - private fun restoreDeletedAlarms(alarmsWithIndex: List) { - viewModelScope.launch { - alarmsWithIndex.forEach { alarm -> - alarmUseCase.insertAlarm(alarm) - alarmScheduler.scheduleAlarm(alarm) - } + private fun restoreDeletedAlarms(alarmsWithIndex: List) = intent { + alarmsWithIndex.forEach { alarm -> + alarmUseCase.insertAlarm(alarm) + alarmScheduler.scheduleAlarm(alarm) } } - private fun restLastAddedAlarmIndex() { - updateState { copy(lastAddedAlarmIndex = null) } + private fun restLastAddedAlarmIndex() = intent { + reduce { state.copy(lastAddedAlarmIndex = null) } } - private fun loadAllAlarms() { - updateState { copy(initialLoading = true) } + private fun loadAllAlarms() = intent { + reduce { state.copy(initialLoading = true) } - viewModelScope.launch { - alarmUseCase.getAllAlarms().collect { - updateState { - copy( - alarms = it, - initialLoading = false, - ) - } - updateDeliveryTime(it) + alarmUseCase.getAllAlarms().collect { + reduce { + state.copy( + alarms = it, + initialLoading = false, + ) } + updateDeliveryTime(it) } } - private fun editAlarm(alarmId: Long) { - emitSideEffect(HomeContract.SideEffect.NavigateToEditAlarm(alarmId)) + private fun editAlarm(alarmId: Long) = intent { + postSideEffect(HomeContract.SideEffect.NavigateToEditAlarm(alarmId)) } - private fun updateDeliveryTime(alarms: List) { + private fun updateDeliveryTime(alarms: List) = intent { val earliestAlarm = alarms .filter { it.isAlarmActive } .minByOrNull { alarm -> @@ -330,7 +331,7 @@ class HomeViewModel @Inject constructor( alarmDateTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm")) } ?: "NONE" - updateState { copy(deliveryTime = formatDeliveryTime(deliveryTime)) } + reduce { state.copy(deliveryTime = formatDeliveryTime(deliveryTime)) } } private fun getNextAlarmDateWithTime(isAm: Boolean, hour: Int, minute: Int, repeatDays: Int): LocalDateTime { @@ -392,91 +393,86 @@ class HomeViewModel @Inject constructor( } } - private fun loadDailyFortune() { - viewModelScope.launch { - val fortuneDate = fortuneRepository.fortuneDateFlow.firstOrNull() - val todayDate = LocalDate.now().format(DateTimeFormatter.ISO_DATE) + private fun loadDailyFortune() = intent { + val fortuneDate = fortuneRepository.fortuneDateFlow.firstOrNull() + val todayDate = LocalDate.now().format(DateTimeFormatter.ISO_DATE) - Log.d("HomeViewModel", "fortuneDate: $fortuneDate, todayDate: $todayDate") + Log.d("HomeViewModel", "fortuneDate: $fortuneDate, todayDate: $todayDate") - if (fortuneDate != todayDate) { - processAction(HomeContract.Action.ShowNoDailyFortuneDialog) - } else { - fortuneRepository.markFortuneAsChecked() - emitSideEffect(HomeContract.SideEffect.NavigateToFortune) - } + if (fortuneDate != todayDate) { + processAction(HomeContract.Action.ShowNoDailyFortuneDialog) + } else { + fortuneRepository.markFortuneAsChecked() + postSideEffect(HomeContract.SideEffect.NavigateToFortune) } } - private fun loadDailyFortuneState() { - viewModelScope.launch { - val todayDate = LocalDate.now().format(DateTimeFormatter.ISO_DATE) - - combine( - fortuneRepository.fortuneDateFlow, - fortuneRepository.fortuneScoreFlow, - fortuneRepository.hasNewFortuneFlow, - ) { fortuneDate, fortuneScore, hasNewFortune -> - val isTodayFortuneAvailable = fortuneDate == todayDate - val finalFortuneScore = if (isTodayFortuneAvailable) fortuneScore ?: -1 else -1 - - Pair(finalFortuneScore, hasNewFortune) - }.collect { (finalFortuneScore, hasNewFortune) -> - updateState { - copy( - lastFortuneScore = finalFortuneScore, - hasNewFortune = hasNewFortune, - isToolTipVisible = hasNewFortune, - ) - } + private fun loadDailyFortuneState() = intent { + val todayDate = LocalDate.now().format(DateTimeFormatter.ISO_DATE) + + combine( + fortuneRepository.fortuneDateFlow, + fortuneRepository.fortuneScoreFlow, + fortuneRepository.hasNewFortuneFlow, + ) { fortuneDate, fortuneScore, hasNewFortune -> + val isTodayFortuneAvailable = fortuneDate == todayDate + val finalFortuneScore = if (isTodayFortuneAvailable) fortuneScore ?: -1 else -1 + + Pair(finalFortuneScore, hasNewFortune) + }.collect { (finalFortuneScore, hasNewFortune) -> + reduce { + state.copy( + lastFortuneScore = finalFortuneScore, + hasNewFortune = hasNewFortune, + isToolTipVisible = hasNewFortune, + ) } } } - private fun loadUserName() { - viewModelScope.launch { - userInfoRepository.userNameFlow.collect { userName -> - updateState { copy(name = userName ?: "") } - } + private fun loadUserName() = intent { + userInfoRepository.userNameFlow.first { userName -> + reduce { state.copy(name = userName ?: "") } + true } } - private fun showNoDailyFortuneDialog() { - updateState { copy(isNoDailyFortuneDialogVisible = true) } + private fun showNoDailyFortuneDialog() = intent { + reduce { state.copy(isNoDailyFortuneDialogVisible = true) } } - private fun hideNoDailyFortuneDialog() { - updateState { copy(isNoDailyFortuneDialogVisible = false) } + private fun hideNoDailyFortuneDialog() = intent { + reduce { state.copy(isNoDailyFortuneDialogVisible = false) } } - private fun hideToolTip() { - updateState { copy(isToolTipVisible = false) } + private fun hideToolTip() = intent { + reduce { state.copy(isToolTipVisible = false) } } - private fun navigateToSetting() { - emitSideEffect(HomeContract.SideEffect.NavigateToSetting) + private fun navigateToSetting() = intent { + postSideEffect(HomeContract.SideEffect.NavigateToSetting) } - private fun showItemMenu(alarmId: Long, x: Float, y: Float) { - updateState { - copy( + private fun showItemMenu(alarmId: Long, x: Float, y: Float) = intent { + reduce { + state.copy( activeItemMenu = alarmId, activeItemMenuPosition = x to y, ) } } - private fun hideItemMenu() { - updateState { - copy( + private fun hideItemMenu() = intent { + reduce { + state.copy( activeItemMenu = null, activeItemMenuPosition = null, ) } } - private fun setSortOrder(sortOrder: HomeContract.AlarmSortOrder) { - updateState { copy(sortOrder = sortOrder) } + private fun setSortOrder(sortOrder: HomeContract.AlarmSortOrder) = intent { + reduce { state.copy(sortOrder = sortOrder) } hideDropDownMenu() } } From 61c6bf7da8c953e5ff78320617983a61cd173609 Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Wed, 9 Jul 2025 15:15:06 +0900 Subject: [PATCH 06/16] =?UTF-8?q?[REFACTOR/#231]=20SettingViewModel?= =?UTF-8?q?=EC=9D=84=20ContainerHost=20=EA=B8=B0=EB=B0=98=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/yapp/setting/SettingNavGraph.kt | 21 ++--- .../java/com/yapp/setting/SettingScreen.kt | 4 - .../java/com/yapp/setting/SettingViewModel.kt | 81 +++++++++++-------- 3 files changed, 54 insertions(+), 52 deletions(-) diff --git a/feature/setting/src/main/java/com/yapp/setting/SettingNavGraph.kt b/feature/setting/src/main/java/com/yapp/setting/SettingNavGraph.kt index 054c523b..0114df93 100644 --- a/feature/setting/src/main/java/com/yapp/setting/SettingNavGraph.kt +++ b/feature/setting/src/main/java/com/yapp/setting/SettingNavGraph.kt @@ -5,7 +5,6 @@ import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.tween import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically -import androidx.compose.runtime.LaunchedEffect import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable import androidx.navigation.navOptions @@ -14,7 +13,7 @@ import com.yapp.common.navigation.OrbitNavigator import com.yapp.common.navigation.extensions.sharedHiltViewModel import com.yapp.common.navigation.route.SettingBaseRoute import com.yapp.common.navigation.route.SettingDestination -import kotlinx.coroutines.flow.collectLatest +import org.orbitmvi.orbit.compose.collectSideEffect fun NavGraphBuilder.settingNavGraph( navigator: OrbitNavigator, @@ -25,10 +24,8 @@ fun NavGraphBuilder.settingNavGraph( composable { val viewModel = it.sharedHiltViewModel(navigator.navController) - LaunchedEffect(viewModel) { - viewModel.container.sideEffectFlow.collectLatest { sideEffect -> - handleSideEffect(sideEffect, navigator) - } + viewModel.collectSideEffect { + handleSideEffect(it, navigator) } SettingRoute(viewModel) @@ -37,10 +34,8 @@ fun NavGraphBuilder.settingNavGraph( composable { val viewModel = it.sharedHiltViewModel(navigator.navController) - LaunchedEffect(viewModel) { - viewModel.container.sideEffectFlow.collectLatest { sideEffect -> - handleSideEffect(sideEffect, navigator) - } + viewModel.collectSideEffect { + handleSideEffect(it, navigator) } EditProfileRoute(viewModel) @@ -86,10 +81,8 @@ fun NavGraphBuilder.settingNavGraph( ) { val viewModel = it.sharedHiltViewModel(navigator.navController) - LaunchedEffect(viewModel) { - viewModel.container.sideEffectFlow.collectLatest { sideEffect -> - handleSideEffect(sideEffect, navigator) - } + viewModel.collectSideEffect { + handleSideEffect(it, navigator) } EditBirthdayRoute(viewModel) diff --git a/feature/setting/src/main/java/com/yapp/setting/SettingScreen.kt b/feature/setting/src/main/java/com/yapp/setting/SettingScreen.kt index 03f79fd0..da81f5d9 100644 --- a/feature/setting/src/main/java/com/yapp/setting/SettingScreen.kt +++ b/feature/setting/src/main/java/com/yapp/setting/SettingScreen.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -40,9 +39,6 @@ fun SettingRoute( val state by viewModel.container.stateFlow.collectAsStateWithLifecycle() val context = LocalContext.current - LaunchedEffect(key1 = Unit) { - viewModel.onAction(SettingContract.Action.RefreshUserInfo) - } SettingScreen( state = state, onNavigateToEditProfile = { diff --git a/feature/setting/src/main/java/com/yapp/setting/SettingViewModel.kt b/feature/setting/src/main/java/com/yapp/setting/SettingViewModel.kt index 132f2047..eada6f8a 100644 --- a/feature/setting/src/main/java/com/yapp/setting/SettingViewModel.kt +++ b/feature/setting/src/main/java/com/yapp/setting/SettingViewModel.kt @@ -1,24 +1,37 @@ package com.yapp.setting import android.util.Log -import androidx.lifecycle.viewModelScope +import androidx.lifecycle.ViewModel import com.yapp.domain.repository.UserInfoRepository -import com.yapp.ui.base.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.launch +import org.orbitmvi.orbit.Container +import org.orbitmvi.orbit.ContainerHost import org.orbitmvi.orbit.syntax.simple.intent +import org.orbitmvi.orbit.syntax.simple.postSideEffect +import org.orbitmvi.orbit.syntax.simple.reduce +import org.orbitmvi.orbit.syntax.simple.repeatOnSubscription +import org.orbitmvi.orbit.viewmodel.container import javax.inject.Inject @HiltViewModel class SettingViewModel @Inject constructor( private val userInfoRepository: UserInfoRepository, -) : BaseViewModel( - SettingContract.State(), -) { +) : ViewModel(), ContainerHost { + + override val container: Container = container( + initialState = SettingContract.State(), + ) { + intent { + repeatOnSubscription { + refreshUserInfo() + } + } + } + fun onAction(action: SettingContract.Action) = intent { when (action) { - SettingContract.Action.PreviousStep -> emitSideEffect(SettingContract.SideEffect.NavigateBack) + SettingContract.Action.PreviousStep -> navigateBack() SettingContract.Action.NavigateToEditProfile -> navigateToEditProfile() is SettingContract.Action.OpenWebView -> openWebView(action.url) SettingContract.Action.RefreshUserInfo -> refreshUserInfo() @@ -26,40 +39,40 @@ class SettingViewModel @Inject constructor( } } - private fun fetchUserInfo(userId: Long) { - viewModelScope.launch { - userInfoRepository.getUserInfo(userId) - .onSuccess { user -> - updateState { - copy( - initialLoading = false, - name = user.name, - birthDate = user.birthDate, - selectedGender = user.gender, - timeOfBirth = user.birthTime.toString(), - ) - } - } - .onFailure { error -> - Log.e("SettingViewModel", "사용자 정보 가져오기 실패: ${error.message}") + private fun fetchUserInfo(userId: Long) = intent { + userInfoRepository.getUserInfo(userId) + .onSuccess { user -> + reduce { + state.copy( + initialLoading = false, + name = user.name, + birthDate = user.birthDate, + selectedGender = user.gender, + timeOfBirth = user.birthTime.toString(), + ) } - } + } + .onFailure { error -> + Log.e("SettingViewModel", "사용자 정보 가져오기 실패: ${error.message}") + } } - private fun navigateToEditProfile() { - emitSideEffect(SettingContract.SideEffect.NavigateToEditProfile) + private fun navigateBack() = intent { + postSideEffect(SettingContract.SideEffect.NavigateBack) } - private fun openWebView(url: String) { - emitSideEffect(SettingContract.SideEffect.OpenWebView(url)) + private fun navigateToEditProfile() = intent { + postSideEffect(SettingContract.SideEffect.NavigateToEditProfile) } - private fun refreshUserInfo() { - viewModelScope.launch { - val userId = userInfoRepository.userIdFlow.firstOrNull() - if (userId != null) { - fetchUserInfo(userId) - } + private fun openWebView(url: String) = intent { + postSideEffect(SettingContract.SideEffect.OpenWebView(url)) + } + + private fun refreshUserInfo() = intent { + val userId = userInfoRepository.userIdFlow.firstOrNull() + if (userId != null) { + fetchUserInfo(userId) } } } From 53034c654493642a05fda8bf4af79469ca324f95 Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Wed, 9 Jul 2025 15:15:23 +0900 Subject: [PATCH 07/16] =?UTF-8?q?[REFACTOR/#231]=20EditProfileViewModel?= =?UTF-8?q?=EC=9D=84=20ContainerHost=20=EA=B8=B0=EB=B0=98=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/yapp/setting/EditBirthdayScreen.kt | 12 +- .../com/yapp/setting/EditProfileScreen.kt | 22 +- .../com/yapp/setting/EditProfileViewModel.kt | 201 ++++++++++-------- 3 files changed, 131 insertions(+), 104 deletions(-) diff --git a/feature/setting/src/main/java/com/yapp/setting/EditBirthdayScreen.kt b/feature/setting/src/main/java/com/yapp/setting/EditBirthdayScreen.kt index 1870ceea..b8e2116b 100644 --- a/feature/setting/src/main/java/com/yapp/setting/EditBirthdayScreen.kt +++ b/feature/setting/src/main/java/com/yapp/setting/EditBirthdayScreen.kt @@ -32,14 +32,14 @@ fun EditBirthdayRoute( EditBirthdayScreen( state = state, - onBack = { viewModel.onAction(SettingContract.Action.PreviousStep) }, + onBack = { viewModel.processAction(SettingContract.Action.PreviousStep) }, onConfirmExit = { - viewModel.onAction(SettingContract.Action.HideDialog) - viewModel.onAction(SettingContract.Action.PreviousStep) + viewModel.processAction(SettingContract.Action.HideDialog) + viewModel.processAction(SettingContract.Action.PreviousStep) }, - onCancelDialog = { viewModel.onAction(SettingContract.Action.HideDialog) }, + onCancelDialog = { viewModel.processAction(SettingContract.Action.HideDialog) }, onUpdateBirthDate = { lunar, year, month, day -> - viewModel.onAction( + viewModel.processAction( SettingContract.Action.UpdateBirthDate( lunar, year, @@ -48,7 +48,7 @@ fun EditBirthdayRoute( ), ) }, - onConfirm = { viewModel.onAction(SettingContract.Action.ConfirmAndNavigateBack) }, + onConfirm = { viewModel.processAction(SettingContract.Action.ConfirmAndNavigateBack) }, ) } diff --git a/feature/setting/src/main/java/com/yapp/setting/EditProfileScreen.kt b/feature/setting/src/main/java/com/yapp/setting/EditProfileScreen.kt index 6df4257a..f65daca3 100644 --- a/feature/setting/src/main/java/com/yapp/setting/EditProfileScreen.kt +++ b/feature/setting/src/main/java/com/yapp/setting/EditProfileScreen.kt @@ -55,36 +55,36 @@ fun EditProfileRoute( LaunchedEffect(state.shouldFetchUserInfo) { if (state.shouldFetchUserInfo) { - viewModel.onAction(SettingContract.Action.RefreshUserInfo) + viewModel.processAction(SettingContract.Action.RefreshUserInfo) } } EditProfileScreen( state = state, - onBack = { viewModel.onAction(SettingContract.Action.ShowDialog) }, - onUpdateName = { name -> viewModel.onAction(SettingContract.Action.UpdateName(name)) }, - onToggleGender = { isMale -> viewModel.onAction(SettingContract.Action.ToggleGender(isMale)) }, + onBack = { viewModel.processAction(SettingContract.Action.ShowDialog) }, + onUpdateName = { name -> viewModel.processAction(SettingContract.Action.UpdateName(name)) }, + onToggleGender = { isMale -> viewModel.processAction(SettingContract.Action.ToggleGender(isMale)) }, onToggleTimeUnknown = { isChecked -> - viewModel.onAction( + viewModel.processAction( SettingContract.Action.ToggleTimeUnknown( isChecked, ), ) }, onUpdateTimeOfBirth = { time -> - viewModel.onAction( + viewModel.processAction( SettingContract.Action.UpdateTimeOfBirth( time, ), ) }, - onNavigateToEditBirthday = { viewModel.onAction(SettingContract.Action.NavigateToEditBirthday) }, + onNavigateToEditBirthday = { viewModel.processAction(SettingContract.Action.NavigateToEditBirthday) }, onConfirmExit = { - viewModel.onAction(SettingContract.Action.HideDialog) - viewModel.onAction(SettingContract.Action.PreviousStep) + viewModel.processAction(SettingContract.Action.HideDialog) + viewModel.processAction(SettingContract.Action.PreviousStep) }, - onCancelDialog = { viewModel.onAction(SettingContract.Action.HideDialog) }, - onSaveUserInfo = { viewModel.onAction(SettingContract.Action.SubmitUserInfo) }, + onCancelDialog = { viewModel.processAction(SettingContract.Action.HideDialog) }, + onSaveUserInfo = { viewModel.processAction(SettingContract.Action.SubmitUserInfo) }, ) } diff --git a/feature/setting/src/main/java/com/yapp/setting/EditProfileViewModel.kt b/feature/setting/src/main/java/com/yapp/setting/EditProfileViewModel.kt index 5427db4f..3dfdcaf2 100644 --- a/feature/setting/src/main/java/com/yapp/setting/EditProfileViewModel.kt +++ b/feature/setting/src/main/java/com/yapp/setting/EditProfileViewModel.kt @@ -1,23 +1,29 @@ package com.yapp.setting import android.util.Log -import androidx.lifecycle.viewModelScope +import androidx.lifecycle.ViewModel import com.yapp.domain.model.EditUser import com.yapp.domain.repository.UserInfoRepository -import com.yapp.ui.base.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.launch +import org.orbitmvi.orbit.Container +import org.orbitmvi.orbit.ContainerHost import org.orbitmvi.orbit.syntax.simple.intent +import org.orbitmvi.orbit.syntax.simple.postSideEffect +import org.orbitmvi.orbit.syntax.simple.reduce +import org.orbitmvi.orbit.viewmodel.container import javax.inject.Inject @HiltViewModel class EditProfileViewModel @Inject constructor( private val userInfoRepository: UserInfoRepository, -) : BaseViewModel( - SettingContract.State(), -) { - fun onAction(action: SettingContract.Action) = intent { +) : ViewModel(), ContainerHost { + + override val container: Container = container( + initialState = SettingContract.State(), + ) + + fun processAction(action: SettingContract.Action) { when (action) { is SettingContract.Action.UpdateName -> updateName(action.name) is SettingContract.Action.UpdateBirthDate -> updateBirthDate(action) @@ -26,63 +32,73 @@ class EditProfileViewModel @Inject constructor( is SettingContract.Action.ToggleGender -> toggleGender(action.isMale) is SettingContract.Action.ToggleTimeUnknown -> toggleTimeUnknown(action.isChecked) is SettingContract.Action.UpdateTimeOfBirth -> updateTimeOfBirth(action.time) - is SettingContract.Action.ConfirmAndNavigateBack -> emitSideEffect(SettingContract.SideEffect.NavigateBack) - is SettingContract.Action.Reset -> updateState { SettingContract.State() } - SettingContract.Action.ShowDialog -> updateState { copy(isDialogVisible = true) } - SettingContract.Action.HideDialog -> updateState { copy(isDialogVisible = false) } + is SettingContract.Action.ConfirmAndNavigateBack -> navigateBack() + is SettingContract.Action.Reset -> resetState() + SettingContract.Action.ShowDialog -> showDialog() + SettingContract.Action.HideDialog -> hideDialog() SettingContract.Action.PreviousStep -> previousStep() SettingContract.Action.SubmitUserInfo -> submitUserInfo() is SettingContract.Action.NavigateToEditBirthday -> navigateToEditBirthday() - is SettingContract.Action.RefreshUserInfo -> { - if (currentState.shouldFetchUserInfo) { - refreshUserInfo() - } - } + is SettingContract.Action.RefreshUserInfo -> refreshUserInfo() else -> {} } } - private fun updateName(name: String) = updateState { - copy(name = name, isNameValid = validateName(name)) + private fun updateName(name: String) = intent { + reduce { + state.copy(name = name, isNameValid = validateName(name)) + } } private fun validateName(name: String): Boolean { return SettingContract.FieldType.NAME.validationRegex.matches(name) } - private fun updateBirthDate(action: SettingContract.Action.UpdateBirthDate) = updateState { - val formattedDate = "${action.year}-${action.month.toString().padStart(2, '0')}-${ - action.day.toString().padStart(2, '0') - }" - copy(birthDate = formattedDate) + private fun updateBirthDate(action: SettingContract.Action.UpdateBirthDate) = intent { + reduce { + val formattedDate = "${action.year}-${action.month.toString().padStart(2, '0')}-${ + action.day.toString().padStart(2, '0') + }" + state.copy(birthDate = formattedDate) + } } - private fun updateCalendarType(calendarType: String) = updateState { - copy(birthType = calendarType) + private fun updateCalendarType(calendarType: String) = intent { + reduce { + state.copy(birthType = calendarType) + } } - private fun updateGender(gender: String) = updateState { - copy(selectedGender = gender) + private fun updateGender(gender: String) = intent { + reduce { + state.copy(selectedGender = gender) + } } - private fun toggleGender(isMale: Boolean) = updateState { - copy( - isMaleSelected = isMale, - isFemaleSelected = !isMale, - selectedGender = if (isMale) "남성" else "여성", - ) + private fun toggleGender(isMale: Boolean) = intent { + reduce { + state.copy( + isMaleSelected = isMale, + isFemaleSelected = !isMale, + selectedGender = if (isMale) "남성" else "여성", + ) + } } - private fun toggleTimeUnknown(isChecked: Boolean) = updateState { - val newState = copy( - isTimeUnknown = isChecked, - timeOfBirth = if (isChecked) "시간모름" else "", - ) - newState.copy(isTimeValid = validateTimeOfBirth(newState.timeOfBirth, isChecked)) + private fun toggleTimeUnknown(isChecked: Boolean) = intent { + reduce { + val newState = state.copy( + isTimeUnknown = isChecked, + timeOfBirth = if (isChecked) "시간모름" else "", + ) + newState.copy(isTimeValid = validateTimeOfBirth(newState.timeOfBirth, isChecked)) + } } - private fun updateTimeOfBirth(time: String) = updateState { - copy(timeOfBirth = time, isTimeValid = validateTimeOfBirth(time, isTimeUnknown)) + private fun updateTimeOfBirth(time: String) = intent { + reduce { + state.copy(timeOfBirth = time, isTimeValid = validateTimeOfBirth(time, state.isTimeUnknown)) + } } private fun validateTimeOfBirth(time: String, isTimeUnknown: Boolean): Boolean { @@ -93,47 +109,44 @@ class EditProfileViewModel @Inject constructor( } } - private fun fetchUserInfo(userId: Long) { - viewModelScope.launch { - userInfoRepository.getUserInfo(userId) - .onSuccess { user -> - val (initialYear, initialMonth, initialDay) = user.birthDate.split("-") - - updateState { - copy( - name = user.name, - isNameValid = validateName(user.name), - initialYear = initialYear, - initialMonth = initialMonth, - initialDay = initialDay, - birthType = user.calendarType, - birthDate = user.birthDate, - selectedGender = user.gender, - timeOfBirth = user.birthTime ?: "99:99", - isTimeUnknown = user.birthTime == "시간모름", - isTimeValid = validateTimeOfBirth( - user.birthTime ?: "", - user.birthTime == "시간모름", - ), - isMaleSelected = user.gender == "남성", - isFemaleSelected = user.gender == "여성", - ) - } + private fun fetchUserInfo(userId: Long) = intent { + userInfoRepository.getUserInfo(userId) + .onSuccess { user -> + val (initialYear, initialMonth, initialDay) = user.birthDate.split("-") + + reduce { + state.copy( + name = user.name, + isNameValid = validateName(user.name), + initialYear = initialYear, + initialMonth = initialMonth, + initialDay = initialDay, + birthType = user.calendarType, + birthDate = user.birthDate, + selectedGender = user.gender, + timeOfBirth = user.birthTime ?: "99:99", + isTimeUnknown = user.birthTime == "시간모름", + isTimeValid = validateTimeOfBirth( + user.birthTime ?: "", + user.birthTime == "시간모름", + ), + isMaleSelected = user.gender == "남성", + isFemaleSelected = user.gender == "여성", + ) } - .onFailure { error -> - Log.e("EditProfileViewModel", "사용자 정보 가져오기 실패: ${error.message}") - } - } + } + .onFailure { error -> + Log.e("EditProfileViewModel", "사용자 정보 가져오기 실패: ${error.message}") + } } - private fun previousStep() { - updateState { copy(shouldFetchUserInfo = true) } - emitSideEffect(SettingContract.SideEffect.NavigateBack) + private fun previousStep() = intent { + reduce { state.copy(shouldFetchUserInfo = true) } + postSideEffect(SettingContract.SideEffect.NavigateBack) } - private fun submitUserInfo() = viewModelScope.launch { - val userId = userInfoRepository.userIdFlow.firstOrNull() ?: return@launch - val state = container.stateFlow.value + private fun submitUserInfo() = intent { + val userId = userInfoRepository.userIdFlow.firstOrNull() ?: return@intent val updatedUser = EditUser( name = state.name, @@ -147,7 +160,7 @@ class EditProfileViewModel @Inject constructor( if (result.isSuccess) { userInfoRepository.saveUserName(state.name) - emitSideEffect(SettingContract.SideEffect.NavigateToSettingRoute) + postSideEffect(SettingContract.SideEffect.NavigateToSettingRoute) } else { Log.e("EditProfileViewModel", "사용자 정보 수정 실패") } @@ -157,17 +170,31 @@ class EditProfileViewModel @Inject constructor( return formattedDate.replace(Regex("[^0-9-]"), "") } - private fun navigateToEditBirthday() { - updateState { copy(shouldFetchUserInfo = false) } - emitSideEffect(SettingContract.SideEffect.NavigateToEditBirthday) + private fun navigateBack() = intent { + postSideEffect(SettingContract.SideEffect.NavigateBack) } - private fun refreshUserInfo() { - viewModelScope.launch { - val userId = userInfoRepository.userIdFlow.firstOrNull() - if (userId != null) { - fetchUserInfo(userId) - } + private fun resetState() = intent { + reduce { SettingContract.State() } + } + + private fun showDialog() = intent { + reduce { state.copy(isDialogVisible = true) } + } + + private fun hideDialog() = intent { + reduce { state.copy(isDialogVisible = false) } + } + + private fun refreshUserInfo() = intent { + val userId = userInfoRepository.userIdFlow.firstOrNull() + if (userId != null) { + fetchUserInfo(userId) } } + + private fun navigateToEditBirthday() = intent { + reduce { state.copy(shouldFetchUserInfo = false) } + postSideEffect(SettingContract.SideEffect.NavigateToEditBirthday) + } } From 8686872a1690a5e5775271b3f8e865069897593a Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Wed, 9 Jul 2025 16:09:17 +0900 Subject: [PATCH 08/16] =?UTF-8?q?[REFACTOR/#231]=20AlarmActionViewModel?= =?UTF-8?q?=EC=9D=84=20ContainerHost=20=EA=B8=B0=EB=B0=98=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interaction/action/AlarmActionScreen.kt | 24 ++-- .../action/AlarmActionViewModel.kt | 105 +++++++++--------- 2 files changed, 68 insertions(+), 61 deletions(-) diff --git a/feature/alarm-interaction/src/main/java/com/yapp/alarm/interaction/action/AlarmActionScreen.kt b/feature/alarm-interaction/src/main/java/com/yapp/alarm/interaction/action/AlarmActionScreen.kt index 4db7ed7b..c8fb991a 100644 --- a/feature/alarm-interaction/src/main/java/com/yapp/alarm/interaction/action/AlarmActionScreen.kt +++ b/feature/alarm-interaction/src/main/java/com/yapp/alarm/interaction/action/AlarmActionScreen.kt @@ -17,7 +17,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -36,6 +35,7 @@ import com.yapp.ui.component.button.OrbitButton import com.yapp.ui.component.lottie.LottieAnimation import com.yapp.ui.utils.heightForScreenPercentage import feature.alarm.interaction.R +import org.orbitmvi.orbit.compose.collectSideEffect import java.util.Locale @Composable @@ -44,16 +44,9 @@ internal fun AlarmActionRoute( navigator: OrbitNavigator, ) { val state by viewModel.container.stateFlow.collectAsStateWithLifecycle() - val sideEffect = viewModel.container.sideEffectFlow - LaunchedEffect(sideEffect) { - sideEffect.collect { action -> - when (action) { - is AlarmActionContract.SideEffect.NavigateToAlarmSnooze -> { - navigator.navigateToAlarmSnoozeTimer(action.alarm) - } - } - } + viewModel.collectSideEffect { + handleSideEffect(it, navigator) } AlarmActionScreen( @@ -62,6 +55,17 @@ internal fun AlarmActionRoute( ) } +private fun handleSideEffect( + sideEffect: AlarmActionContract.SideEffect, + navigator: OrbitNavigator, +) { + when (sideEffect) { + is AlarmActionContract.SideEffect.NavigateToAlarmSnooze -> { + navigator.navigateToAlarmSnoozeTimer(sideEffect.alarm) + } + } +} + @Composable internal fun AlarmActionScreen( stateProvider: () -> AlarmActionContract.State, diff --git a/feature/alarm-interaction/src/main/java/com/yapp/alarm/interaction/action/AlarmActionViewModel.kt b/feature/alarm-interaction/src/main/java/com/yapp/alarm/interaction/action/AlarmActionViewModel.kt index d1d4c011..30d95dfe 100644 --- a/feature/alarm-interaction/src/main/java/com/yapp/alarm/interaction/action/AlarmActionViewModel.kt +++ b/feature/alarm-interaction/src/main/java/com/yapp/alarm/interaction/action/AlarmActionViewModel.kt @@ -2,17 +2,20 @@ package com.yapp.alarm.interaction.action import android.app.Application import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.viewModelScope +import androidx.lifecycle.ViewModel import com.yapp.alarm.pendingIntent.interaction.createAlarmDismissIntent import com.yapp.alarm.pendingIntent.interaction.createAlarmSnoozeIntent import com.yapp.domain.model.Alarm import com.yapp.domain.repository.FortuneRepository -import com.yapp.ui.base.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch +import org.orbitmvi.orbit.Container +import org.orbitmvi.orbit.ContainerHost +import org.orbitmvi.orbit.syntax.simple.intent +import org.orbitmvi.orbit.syntax.simple.postSideEffect +import org.orbitmvi.orbit.syntax.simple.reduce +import org.orbitmvi.orbit.viewmodel.container import java.time.LocalDate import java.time.LocalTime import java.time.format.DateTimeFormatter @@ -25,79 +28,79 @@ class AlarmActionViewModel @Inject constructor( private val app: Application, private val fortuneRepository: FortuneRepository, savedStateHandle: SavedStateHandle, -) : BaseViewModel( - AlarmActionContract.State(), -) { +) : ViewModel(), ContainerHost { + + override val container: Container = container( + initialState = AlarmActionContract.State(), + ) { + fetchIsFirstMission() + initializeAlarmState() + startClock() + } + private val alarmJson: String? = savedStateHandle.get("alarm") private val alarm: Alarm? = alarmJson?.let { Alarm.fromJson(it) } - init { - fetchIsFirstMission() - updateState { - copy( + fun processAction(action: AlarmActionContract.Action) { + when (action) { + is AlarmActionContract.Action.Snooze -> snooze() + is AlarmActionContract.Action.Dismiss -> dismiss() + } + } + + private fun initializeAlarmState() = intent { + reduce { + state.copy( snoozeEnabled = alarm?.isSnoozeEnabled ?: false, snoozeCount = alarm?.snoozeCount ?: 5, snoozeInterval = alarm?.snoozeInterval ?: 5, ) } - - startClock() } - private fun fetchIsFirstMission() { - viewModelScope.launch { - val fortuneDate = fortuneRepository.fortuneDateFlow.firstOrNull() - val todayDate = LocalDate.now().format(DateTimeFormatter.ISO_DATE) - val isFirstMission = fortuneDate != todayDate + private fun fetchIsFirstMission() = intent { + val fortuneDate = fortuneRepository.fortuneDateFlow.firstOrNull() + val todayDate = LocalDate.now().format(DateTimeFormatter.ISO_DATE) + val isFirstMission = fortuneDate != todayDate - updateState { - copy(isFirstMission = isFirstMission) - } + reduce { + state.copy(isFirstMission = isFirstMission) } } - private fun startClock() { - viewModelScope.launch { - while (isActive) { - val now = LocalTime.now() - val today = LocalDate.now() - val dayOfWeek = today.dayOfWeek.getDisplayName(TextStyle.FULL, Locale.KOREAN) - - updateState { - copy( - isAm = now.hour < 12, - hour = if (now.hour % 12 == 0) 12 else now.hour % 12, - minute = now.minute, - todayDate = "${today.monthValue}월 ${today.dayOfMonth}일 $dayOfWeek", - initialLoading = false, - ) - } + private fun startClock() = intent { + while (true) { + val now = LocalTime.now() + val today = LocalDate.now() + val dayOfWeek = today.dayOfWeek.getDisplayName(TextStyle.FULL, Locale.KOREAN) - delay(1000L) + reduce { + state.copy( + isAm = now.hour < 12, + hour = if (now.hour % 12 == 0) 12 else now.hour % 12, + minute = now.minute, + todayDate = "${today.monthValue}월 ${today.dayOfMonth}일 $dayOfWeek", + initialLoading = false, + ) } - } - } - fun processAction(action: AlarmActionContract.Action) { - when (action) { - is AlarmActionContract.Action.Snooze -> snooze() - is AlarmActionContract.Action.Dismiss -> dismiss() + delay(1000L) } } - private fun snooze() { + private fun snooze() = intent { sendAlarmSnoozeEventToAlarmReceiver() - updateState { - copy( - snoozeCount = if (currentState.snoozeCount == -1) { - currentState.snoozeCount + reduce { + state.copy( + snoozeCount = if (state.snoozeCount == -1) { + state.snoozeCount } else { - currentState.snoozeCount - 1 + state.snoozeCount - 1 }, ) } alarm?.let { - emitSideEffect(AlarmActionContract.SideEffect.NavigateToAlarmSnooze(it)) + postSideEffect(AlarmActionContract.SideEffect.NavigateToAlarmSnooze(it)) } } From 742d645bf81d004a4b0d596dbaaae62bfc2cb39b Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Wed, 9 Jul 2025 16:09:28 +0900 Subject: [PATCH 09/16] =?UTF-8?q?[REFACTOR/#231]=20AlarmSnoozeTimerViewMod?= =?UTF-8?q?el=EC=9D=84=20ContainerHost=20=EA=B8=B0=EB=B0=98=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interaction/AlarmInteractionNavGraph.kt | 4 +- .../snooze/AlarmSnoozeTimerScreen.kt | 8 -- .../snooze/AlarmSnoozeTimerViewModel.kt | 97 +++++++++---------- 3 files changed, 49 insertions(+), 60 deletions(-) diff --git a/feature/alarm-interaction/src/main/java/com/yapp/alarm/interaction/AlarmInteractionNavGraph.kt b/feature/alarm-interaction/src/main/java/com/yapp/alarm/interaction/AlarmInteractionNavGraph.kt index bc8d0035..d5ee04d9 100644 --- a/feature/alarm-interaction/src/main/java/com/yapp/alarm/interaction/AlarmInteractionNavGraph.kt +++ b/feature/alarm-interaction/src/main/java/com/yapp/alarm/interaction/AlarmInteractionNavGraph.kt @@ -71,9 +71,7 @@ fun NavGraphBuilder.alarmInteractionNavGraph( composable( typeMap = mapOf(typeOf() to AlarmArgType), ) { - AlarmSnoozeTimerRoute( - navigator = navigator, - ) + AlarmSnoozeTimerRoute() } } } diff --git a/feature/alarm-interaction/src/main/java/com/yapp/alarm/interaction/snooze/AlarmSnoozeTimerScreen.kt b/feature/alarm-interaction/src/main/java/com/yapp/alarm/interaction/snooze/AlarmSnoozeTimerScreen.kt index e914f506..0cb9b177 100644 --- a/feature/alarm-interaction/src/main/java/com/yapp/alarm/interaction/snooze/AlarmSnoozeTimerScreen.kt +++ b/feature/alarm-interaction/src/main/java/com/yapp/alarm/interaction/snooze/AlarmSnoozeTimerScreen.kt @@ -19,7 +19,6 @@ import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -42,7 +41,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.yapp.common.navigation.OrbitNavigator import com.yapp.designsystem.theme.OrbitTheme import com.yapp.ui.component.lottie.LottieAnimation import com.yapp.ui.utils.heightForScreenPercentage @@ -51,14 +49,8 @@ import feature.alarm.interaction.R @Composable internal fun AlarmSnoozeTimerRoute( viewModel: AlarmSnoozeTimerViewModel = hiltViewModel(), - navigator: OrbitNavigator, ) { val state by viewModel.container.stateFlow.collectAsStateWithLifecycle() - val sideEffect = viewModel.container.sideEffectFlow - - LaunchedEffect(sideEffect) { - sideEffect.collect { } - } AlarmSnoozeTimerScreen( stateProvider = { state }, diff --git a/feature/alarm-interaction/src/main/java/com/yapp/alarm/interaction/snooze/AlarmSnoozeTimerViewModel.kt b/feature/alarm-interaction/src/main/java/com/yapp/alarm/interaction/snooze/AlarmSnoozeTimerViewModel.kt index e8bb4277..2d362032 100644 --- a/feature/alarm-interaction/src/main/java/com/yapp/alarm/interaction/snooze/AlarmSnoozeTimerViewModel.kt +++ b/feature/alarm-interaction/src/main/java/com/yapp/alarm/interaction/snooze/AlarmSnoozeTimerViewModel.kt @@ -2,16 +2,18 @@ package com.yapp.alarm.interaction.snooze import android.app.Application import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.viewModelScope +import androidx.lifecycle.ViewModel import com.yapp.alarm.pendingIntent.interaction.createAlarmDismissIntent import com.yapp.domain.model.Alarm import com.yapp.domain.repository.FortuneRepository -import com.yapp.ui.base.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch +import org.orbitmvi.orbit.Container +import org.orbitmvi.orbit.ContainerHost +import org.orbitmvi.orbit.syntax.simple.intent +import org.orbitmvi.orbit.syntax.simple.reduce +import org.orbitmvi.orbit.viewmodel.container import java.time.LocalDate import java.time.LocalDateTime import java.time.ZoneId @@ -24,66 +26,63 @@ class AlarmSnoozeTimerViewModel @Inject constructor( private val app: Application, savedStateHandle: SavedStateHandle, private val fortuneRepository: FortuneRepository, -) : BaseViewModel( - AlarmSnoozeTimerContract.State(), -) { - private val alarmJson: String? = savedStateHandle.get("alarm") - private val alarm: Alarm? = alarmJson?.let { Alarm.fromJson(it) } +) : ViewModel(), ContainerHost { - init { + override val container: Container = container( + initialState = AlarmSnoozeTimerContract.State(), + ) { fetchIsFirstMission() startClock() } - private fun fetchIsFirstMission() { - viewModelScope.launch { - val fortuneDate = fortuneRepository.fortuneDateFlow.firstOrNull() - val todayDate = LocalDate.now().format(DateTimeFormatter.ISO_DATE) - val isFirstMission = fortuneDate != todayDate + private val alarmJson: String? = savedStateHandle.get("alarm") + private val alarm: Alarm? = alarmJson?.let { Alarm.fromJson(it) } - updateState { - copy(isFirstMission = isFirstMission) - } + fun processAction(action: AlarmSnoozeTimerContract.Action) { + when (action) { + is AlarmSnoozeTimerContract.Action.Dismiss -> dismiss() } } - private fun startClock() { - viewModelScope.launch { - val nowMillis = System.currentTimeMillis() - val nextSnoozeTimeMillis = alarm?.let { getNextSnoozeAlarmTimeMillis(it.snoozeInterval) } ?: nowMillis - val remainingMillis = max(0, nextSnoozeTimeMillis - nowMillis) - val remainingSeconds = (remainingMillis / 1000).toInt() - - updateState { - copy( - remainingSeconds = remainingSeconds, - totalSeconds = remainingSeconds, - alarmTimeStamp = nextSnoozeTimeMillis / 1000, - initialLoading = true, - ) - }.join() + private fun fetchIsFirstMission() = intent { + val fortuneDate = fortuneRepository.fortuneDateFlow.firstOrNull() + val todayDate = LocalDate.now().format(DateTimeFormatter.ISO_DATE) + val isFirstMission = fortuneDate != todayDate - while (isActive) { - val currentTime = System.currentTimeMillis() / 1000 - val remaining = max(0, currentState.alarmTimeStamp - currentTime) + reduce { + state.copy(isFirstMission = isFirstMission) + } + } - updateState { - copy( - remainingSeconds = remaining.toInt(), - initialLoading = false, - ) - } + private fun startClock() = intent { + val nowMillis = System.currentTimeMillis() + val nextSnoozeTimeMillis = alarm?.let { getNextSnoozeAlarmTimeMillis(it.snoozeInterval) } ?: nowMillis + val remainingMillis = max(0, nextSnoozeTimeMillis - nowMillis) + val remainingSeconds = (remainingMillis / 1000).toInt() + + reduce { + state.copy( + remainingSeconds = remainingSeconds, + totalSeconds = remainingSeconds, + alarmTimeStamp = nextSnoozeTimeMillis / 1000, + initialLoading = true, + ) + } - if (remaining.toInt() == 0) break + while (true) { + val currentTime = System.currentTimeMillis() / 1000 + val remaining = max(0, state.alarmTimeStamp - currentTime) - delay(1000L) + reduce { + state.copy( + remainingSeconds = remaining.toInt(), + initialLoading = false, + ) } - } - } - fun processAction(action: AlarmSnoozeTimerContract.Action) { - when (action) { - is AlarmSnoozeTimerContract.Action.Dismiss -> dismiss() + if (remaining.toInt() == 0) break + + delay(1000L) } } From 99194ccd416e54b0c86f809be26524c31e0adaeb Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Wed, 9 Jul 2025 16:09:41 +0900 Subject: [PATCH 10/16] =?UTF-8?q?[REFACTOR/#231]=20FortuneViewModel?= =?UTF-8?q?=EC=9D=84=20ContainerHost=20=EA=B8=B0=EB=B0=98=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/yapp/fortune/FortuneRewardScreen.kt | 6 +- .../java/com/yapp/fortune/FortuneScreen.kt | 8 +- .../java/com/yapp/fortune/FortuneViewModel.kt | 94 +++++++++++-------- 3 files changed, 60 insertions(+), 48 deletions(-) diff --git a/feature/fortune/src/main/java/com/yapp/fortune/FortuneRewardScreen.kt b/feature/fortune/src/main/java/com/yapp/fortune/FortuneRewardScreen.kt index 868464e8..778a3726 100644 --- a/feature/fortune/src/main/java/com/yapp/fortune/FortuneRewardScreen.kt +++ b/feature/fortune/src/main/java/com/yapp/fortune/FortuneRewardScreen.kt @@ -55,15 +55,15 @@ fun FortuneRewardRoute( FortuneRewardScreen( state = state, - onCloseClick = { viewModel.onAction(FortuneContract.Action.NavigateToHome) }, - onCompleteClick = { viewModel.onAction(FortuneContract.Action.NavigateToHome) }, + onCloseClick = { viewModel.processAction(FortuneContract.Action.NavigateToHome) }, + onCompleteClick = { viewModel.processAction(FortuneContract.Action.NavigateToHome) }, onSaveImage = { analyticsHelper.logEvent( AnalyticsEvent( type = "fortune_talisman_save", ), ) - viewModel.onAction(FortuneContract.Action.SaveImage(it)) + viewModel.processAction(FortuneContract.Action.SaveImage(it)) }, ) } diff --git a/feature/fortune/src/main/java/com/yapp/fortune/FortuneScreen.kt b/feature/fortune/src/main/java/com/yapp/fortune/FortuneScreen.kt index 0b7357bc..f5109a3b 100644 --- a/feature/fortune/src/main/java/com/yapp/fortune/FortuneScreen.kt +++ b/feature/fortune/src/main/java/com/yapp/fortune/FortuneScreen.kt @@ -105,15 +105,15 @@ fun FortuneRoute( } if (state.currentStep != pagerState.currentPage) { - viewModel.onAction(FortuneContract.Action.UpdateStep(pagerState.currentPage)) + viewModel.processAction(FortuneContract.Action.UpdateStep(pagerState.currentPage)) } } FortuneScreen( state = state, pagerState = pagerState, - onNextStep = { viewModel.onAction(FortuneContract.Action.NextStep) }, - onNavigateToHome = { viewModel.onAction(FortuneContract.Action.NavigateToHome) }, + onNextStep = { viewModel.processAction(FortuneContract.Action.NextStep) }, + onNavigateToHome = { viewModel.processAction(FortuneContract.Action.NavigateToHome) }, onCloseClick = { analyticsHelper.logEvent( AnalyticsEvent( @@ -123,7 +123,7 @@ fun FortuneRoute( ), ), ) - viewModel.onAction(FortuneContract.Action.NavigateToHome) + viewModel.processAction(FortuneContract.Action.NavigateToHome) }, ) } diff --git a/feature/fortune/src/main/java/com/yapp/fortune/FortuneViewModel.kt b/feature/fortune/src/main/java/com/yapp/fortune/FortuneViewModel.kt index 7759dbd1..034c1590 100644 --- a/feature/fortune/src/main/java/com/yapp/fortune/FortuneViewModel.kt +++ b/feature/fortune/src/main/java/com/yapp/fortune/FortuneViewModel.kt @@ -3,18 +3,19 @@ package com.yapp.fortune import android.app.Application import android.util.Log import androidx.annotation.DrawableRes -import androidx.lifecycle.viewModelScope +import androidx.lifecycle.ViewModel import com.yapp.domain.repository.FortuneRepository import com.yapp.fortune.page.toFortunePages import com.yapp.media.decoder.ImageUtils import com.yapp.media.storage.ImageSaver -import com.yapp.ui.base.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.launch +import org.orbitmvi.orbit.Container +import org.orbitmvi.orbit.ContainerHost import org.orbitmvi.orbit.syntax.simple.intent import org.orbitmvi.orbit.syntax.simple.postSideEffect import org.orbitmvi.orbit.syntax.simple.reduce +import org.orbitmvi.orbit.viewmodel.container import java.time.LocalDate import java.time.format.DateTimeFormatter import javax.inject.Inject @@ -24,20 +25,40 @@ class FortuneViewModel @Inject constructor( private val application: Application, private val fortuneRepository: FortuneRepository, private val imageSaver: ImageSaver, -) : BaseViewModel( - FortuneContract.State(), -) { - - init { - viewModelScope.launch { - val fortuneId = fortuneRepository.fortuneIdFlow.firstOrNull() - val firstDismissedAlarmId = fortuneRepository.firstDismissedAlarmIdFlow.firstOrNull() - val fortuneDate = fortuneRepository.fortuneDateFlow.firstOrNull() - fortuneId?.let { getFortune(it, firstDismissedAlarmId, fortuneDate) } +) : ViewModel(), ContainerHost { + + override val container: Container = container( + initialState = FortuneContract.State(), + ) { + loadFortune() + } + + fun processAction(action: FortuneContract.Action) { + when (action) { + is FortuneContract.Action.NextStep -> { + moveToNextStep() + } + is FortuneContract.Action.UpdateStep -> { + updateStep(action.step) + } + is FortuneContract.Action.NavigateToHome -> { + navigateToHome() + } + is FortuneContract.Action.SaveImage -> { + saveImage(action.resId) + } } } - private fun getFortune(fortuneId: Long, firstDismissedAlarmId: Long?, fortuneDate: String?) = intent { - updateState { copy(isLoading = true) } + + private fun loadFortune() = intent { + val fortuneId = fortuneRepository.fortuneIdFlow.firstOrNull() + val firstDismissedAlarmId = fortuneRepository.firstDismissedAlarmIdFlow.firstOrNull() + val fortuneDate = fortuneRepository.fortuneDateFlow.firstOrNull() + fortuneId?.let { fetchAndUpdateFortune(it, firstDismissedAlarmId, fortuneDate) } + } + + private fun fetchAndUpdateFortune(fortuneId: Long, firstDismissedAlarmId: Long?, fortuneDate: String?) = intent { + reduce { state.copy(isLoading = true) } fortuneRepository.getFortune(fortuneId).onSuccess { fortune -> val savedImageId = fortuneRepository.fortuneImageIdFlow.firstOrNull() @@ -46,8 +67,8 @@ class FortuneViewModel @Inject constructor( val formattedTitle = fortune.dailyFortuneTitle.replace(",", ",\n").trim() val todayDate = LocalDate.now().format(DateTimeFormatter.ISO_DATE) val hasReward = (fortuneDate == todayDate) && (firstDismissedAlarmId != null) - updateState { - copy( + reduce { + state.copy( isLoading = false, dailyFortuneTitle = formattedTitle, dailyFortuneDescription = fortune.dailyFortuneDescription, @@ -59,50 +80,41 @@ class FortuneViewModel @Inject constructor( } }.onFailure { error -> Log.e("FortuneViewModel", "운세 데이터 요청 실패: ${error.message}") - updateState { copy(isLoading = false) } + reduce { state.copy(isLoading = false) } } } - fun saveFortuneImageIdIfNeeded(imageId: Int) = viewModelScope.launch { + fun saveFortuneImageIdIfNeeded(imageId: Int) = intent { val savedImageId = fortuneRepository.fortuneImageIdFlow.firstOrNull() if (savedImageId == null || savedImageId != imageId) { fortuneRepository.saveFortuneImageId(imageId) } } - fun onAction(action: FortuneContract.Action) = intent { - when (action) { - is FortuneContract.Action.NextStep -> { - if (state.hasReward) { - postSideEffect(FortuneContract.SideEffect.NavigateToFortuneReward) - } else { - reduce { state.copy(currentStep = (state.currentStep + 1).coerceAtMost(5)) } - } - } - is FortuneContract.Action.UpdateStep -> { - reduce { state.copy(currentStep = action.step) } - } - is FortuneContract.Action.NavigateToHome -> { - navigateToHome() - } - is FortuneContract.Action.SaveImage -> { - saveImage(action.resId) - } + private fun moveToNextStep() = intent { + if (state.hasReward) { + postSideEffect(FortuneContract.SideEffect.NavigateToFortuneReward) + } else { + reduce { state.copy(currentStep = (state.currentStep + 1).coerceAtMost(5)) } } } - private fun navigateToHome() { - emitSideEffect(FortuneContract.SideEffect.NavigateToHome) + private fun updateStep(step: Int) = intent { + reduce { state.copy(currentStep = step) } + } + + private fun navigateToHome() = intent { + postSideEffect(FortuneContract.SideEffect.NavigateToHome) } - private fun saveImage(@DrawableRes resId: Int) = viewModelScope.launch { + private fun saveImage(@DrawableRes resId: Int) = intent { val bitmap = ImageUtils.getBitmapFromResource(application, resId) val byteArray = ImageUtils.bitmapToByteArray(bitmap) val isSuccess = imageSaver.saveImage(byteArray, "fortune_${System.currentTimeMillis()}.png") if (isSuccess) { - emitSideEffect( + postSideEffect( FortuneContract.SideEffect.ShowSnackBar( message = "앨범에 저장되었습니다.", iconRes = core.designsystem.R.drawable.ic_check_green, From 9659795c4496a95dc6ea7cb072c1398e01f2df82 Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Wed, 9 Jul 2025 16:09:52 +0900 Subject: [PATCH 11/16] =?UTF-8?q?[REFACTOR/#231]=20MissionViewModel?= =?UTF-8?q?=EC=9D=84=20ContainerHost=20=EA=B8=B0=EB=B0=98=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/yapp/mission/MissionNavGraph.kt | 39 +++-- .../java/com/yapp/mission/MissionViewModel.kt | 146 +++++++++--------- 2 files changed, 94 insertions(+), 91 deletions(-) diff --git a/feature/mission/src/main/java/com/yapp/mission/MissionNavGraph.kt b/feature/mission/src/main/java/com/yapp/mission/MissionNavGraph.kt index a24f0545..5c2812f1 100644 --- a/feature/mission/src/main/java/com/yapp/mission/MissionNavGraph.kt +++ b/feature/mission/src/main/java/com/yapp/mission/MissionNavGraph.kt @@ -1,6 +1,5 @@ package com.yapp.mission -import androidx.compose.runtime.LaunchedEffect import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable import androidx.navigation.navDeepLink @@ -8,6 +7,7 @@ import androidx.navigation.navOptions import com.yapp.common.navigation.OrbitNavigator import com.yapp.common.navigation.extensions.sharedHiltViewModel import com.yapp.common.navigation.route.MissionRoute +import org.orbitmvi.orbit.compose.collectSideEffect fun NavGraphBuilder.missionScreen( navigator: OrbitNavigator, @@ -21,24 +21,29 @@ fun NavGraphBuilder.missionScreen( ) { val viewModel = it.sharedHiltViewModel(navigator.navController) - LaunchedEffect(viewModel) { - viewModel.container.sideEffectFlow.collect { sideEffect -> - when (sideEffect) { - MissionContract.SideEffect.NavigateToFortune -> { - navigator.navigateToFortune( - navOptions = navOptions { - popUpTo(MissionRoute) { - inclusive = true - } - }, - ) - } - - MissionContract.SideEffect.NavigateBack -> navigator.navigateBack() - } - } + viewModel.collectSideEffect { + handleSideEffect(it, navigator) } MissionRoute(viewModel) } } + +private fun handleSideEffect( + sideEffect: MissionContract.SideEffect, + navigator: OrbitNavigator, +) { + when (sideEffect) { + MissionContract.SideEffect.NavigateToFortune -> { + navigator.navigateToFortune( + navOptions = navOptions { + popUpTo(MissionRoute) { + inclusive = true + } + }, + ) + } + + MissionContract.SideEffect.NavigateBack -> navigator.navigateBack() + } +} diff --git a/feature/mission/src/main/java/com/yapp/mission/MissionViewModel.kt b/feature/mission/src/main/java/com/yapp/mission/MissionViewModel.kt index 85dc3e6d..1fddee54 100644 --- a/feature/mission/src/main/java/com/yapp/mission/MissionViewModel.kt +++ b/feature/mission/src/main/java/com/yapp/mission/MissionViewModel.kt @@ -3,7 +3,7 @@ package com.yapp.mission import android.app.Application import android.util.Log import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.viewModelScope +import androidx.lifecycle.ViewModel import com.yapp.alarm.pendingIntent.interaction.createAlarmDismissIntent import com.yapp.analytics.AnalyticsEvent import com.yapp.analytics.AnalyticsHelper @@ -13,13 +13,17 @@ import com.yapp.domain.repository.UserInfoRepository import com.yapp.domain.usecase.GetMissionTypeUseCase import com.yapp.media.haptic.HapticFeedbackManager import com.yapp.media.haptic.HapticType -import com.yapp.ui.base.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.orbitmvi.orbit.Container +import org.orbitmvi.orbit.ContainerHost +import org.orbitmvi.orbit.syntax.simple.intent +import org.orbitmvi.orbit.syntax.simple.postSideEffect +import org.orbitmvi.orbit.syntax.simple.reduce +import org.orbitmvi.orbit.viewmodel.container import javax.inject.Inject @HiltViewModel @@ -31,28 +35,17 @@ class MissionViewModel @Inject constructor( private val getMissionTypeUseCase: GetMissionTypeUseCase, private val app: Application, savedStateHandle: SavedStateHandle, -) : BaseViewModel( - MissionContract.State(), -) { - init { +) : ViewModel(), ContainerHost { + + override val container: Container = container( + initialState = MissionContract.State(), + ) { savedStateHandle.get("notificationId")?.toLong()?.let { sendAlarmDismissIntent(it) } loadRemoteMissionType() } - private fun loadRemoteMissionType() { - viewModelScope.launch { - val missionType = getMissionTypeUseCase.execute() - updateState { - copy( - missionType = missionType, - isMissionTypeLoading = false, - ) - } - } - } - fun processAction(action: MissionContract.Action) { when (action) { is MissionContract.Action.ShakeCard -> handleShake() @@ -63,25 +56,35 @@ class MissionViewModel @Inject constructor( } } - private fun showExitDialog() { - updateState { copy(showExitDialog = true) } + private fun loadRemoteMissionType() = intent { + val missionType = getMissionTypeUseCase.execute() + reduce { + state.copy( + missionType = missionType, + isMissionTypeLoading = false, + ) + } + } + + private fun showExitDialog() = intent { + reduce { state.copy(showExitDialog = true) } } - private fun hideExitDialog() { - updateState { copy(showExitDialog = false) } + private fun hideExitDialog() = intent { + reduce { state.copy(showExitDialog = false) } } - private fun handleShake() = viewModelScope.launch { - if (currentState.missionType != MissionType.SHAKE) return@launch + private fun handleShake() = intent { + if (state.missionType != MissionType.SHAKE) return@intent - val currentCount = currentState.shakeCount + val currentCount = state.shakeCount if (currentCount < 9) { performHapticSuccess() - updateState { copy(shakeCount = currentCount + 1) } - } else if (!currentState.isFlipped) { + reduce { state.copy(shakeCount = currentCount + 1) } + } else if (!state.isFlipped) { completeMission(type = "shake") - updateState { - copy( + reduce { + state.copy( isMissionCompleted = true, shakeCount = 10, isFlipped = true, @@ -91,70 +94,65 @@ class MissionViewModel @Inject constructor( } } - private fun handleClick() = viewModelScope.launch { - if (currentState.missionType != MissionType.TAP) return@launch + private fun handleClick() = intent { + if (state.missionType != MissionType.TAP) return@intent - val currentCount = currentState.clickCount + val currentCount = state.clickCount if (currentCount < 9) { performHapticSuccess() - logMissionSuccess("click") - updateState { copy(clickCount = currentCount + 1, playWhenClick = true) } + reduce { state.copy(clickCount = currentCount + 1, playWhenClick = true) } delay(500) - updateState { copy(playWhenClick = false) } + reduce { state.copy(playWhenClick = false) } } else { - updateState { - copy( + completeMission("click") + reduce { + state.copy( + isMissionCompleted = true, clickCount = 10, showFinalAnimation = true, ) } - postFortune() delay(500) - updateState { copy(isMissionCompleted = true) } } } - private fun postFortune() { - viewModelScope.launch { - val userId = userInfoRepository.userIdFlow.firstOrNull() ?: return@launch - val result = runCatching { - withContext(Dispatchers.IO) { - fortuneRepository.postFortune(userId) - } + private fun postFortune() = intent { + val userId = userInfoRepository.userIdFlow.firstOrNull() ?: return@intent + val result = runCatching { + withContext(Dispatchers.IO) { + fortuneRepository.postFortune(userId) } + } - result.onSuccess { - val data = it.getOrThrow() - fortuneRepository.saveFortuneId(data.id) - fortuneRepository.saveFortuneScore(data.avgFortuneScore) + result.onSuccess { + val data = it.getOrThrow() + fortuneRepository.saveFortuneId(data.id) + fortuneRepository.saveFortuneScore(data.avgFortuneScore) - emitSideEffect(MissionContract.SideEffect.NavigateToFortune) - }.onFailure { error -> - Log.e("MissionViewModel", "운세 데이터 요청 실패: ${error.message}") - updateState { copy(errorMessage = error.message) } - } + postSideEffect(MissionContract.SideEffect.NavigateToFortune) + }.onFailure { error -> + Log.e("MissionViewModel", "운세 데이터 요청 실패: ${error.message}") + reduce { state.copy(errorMessage = error.message) } } } - private fun retryPostFortune() { - viewModelScope.launch { - val userId = userInfoRepository.userIdFlow.firstOrNull() ?: return@launch - val result = runCatching { - withContext(Dispatchers.IO) { - fortuneRepository.postFortune(userId) - } + private fun retryPostFortune() = intent { + val userId = userInfoRepository.userIdFlow.firstOrNull() ?: return@intent + val result = runCatching { + withContext(Dispatchers.IO) { + fortuneRepository.postFortune(userId) } + } - result.onSuccess { - val data = it.getOrThrow() - fortuneRepository.saveFortuneId(data.id) - fortuneRepository.saveFortuneScore(data.avgFortuneScore) + result.onSuccess { + val data = it.getOrThrow() + fortuneRepository.saveFortuneId(data.id) + fortuneRepository.saveFortuneScore(data.avgFortuneScore) - emitSideEffect(MissionContract.SideEffect.NavigateToFortune) - }.onFailure { - Log.e("MissionViewModel", "운세 재요청 실패: ${it.message}") - navigateToHome() - } + postSideEffect(MissionContract.SideEffect.NavigateToFortune) + }.onFailure { + Log.e("MissionViewModel", "운세 재요청 실패: ${it.message}") + navigateToHome() } } @@ -179,8 +177,8 @@ class MissionViewModel @Inject constructor( ) } - private fun navigateToHome() { - emitSideEffect(MissionContract.SideEffect.NavigateToFortune) + private fun navigateToHome() = intent { + postSideEffect(MissionContract.SideEffect.NavigateToFortune) } private fun sendAlarmDismissIntent(id: Long) { From 92e1decdf21dc47d1bea148c5909c52375e56178 Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Wed, 9 Jul 2025 16:13:33 +0900 Subject: [PATCH 12/16] =?UTF-8?q?[REMOVE/#231]=20BaseViewModel=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/yapp/ui/base/BaseViewModel.kt | 72 ------------------- 1 file changed, 72 deletions(-) delete mode 100644 core/ui/src/main/java/com/yapp/ui/base/BaseViewModel.kt diff --git a/core/ui/src/main/java/com/yapp/ui/base/BaseViewModel.kt b/core/ui/src/main/java/com/yapp/ui/base/BaseViewModel.kt deleted file mode 100644 index 52bf9a30..00000000 --- a/core/ui/src/main/java/com/yapp/ui/base/BaseViewModel.kt +++ /dev/null @@ -1,72 +0,0 @@ -package com.yapp.ui.base - -import androidx.lifecycle.ViewModel -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.catch -import org.orbitmvi.orbit.ContainerHost -import org.orbitmvi.orbit.syntax.simple.intent -import org.orbitmvi.orbit.syntax.simple.postSideEffect -import org.orbitmvi.orbit.syntax.simple.reduce -import org.orbitmvi.orbit.viewmodel.container - -abstract class BaseViewModel( - initialState: UI_STATE, -) : ViewModel(), ContainerHost { - - override val container = container(initialState) - val currentState: UI_STATE - get() = container.stateFlow.value - - /** - * UI 상태 업데이트 - * @param reducer 현재 상태를 수정하는 람다식 - */ - protected fun updateState(reducer: UI_STATE.() -> UI_STATE) = intent { - reduce { reducer(state) } - } - - /** - * 단일 부수 효과 전달 - * @param effect 전달할 부수 효과 - */ - protected fun emitSideEffect(effect: SIDE_EFFECT) = intent { - postSideEffect(effect) - } - - /** - * 여러 부수 효과 전달 - * @param effects 전달할 부수 효과 리스트 - */ - protected fun emitSideEffects(vararg effects: SIDE_EFFECT) = intent { - effects.forEach { postSideEffect(it) } - } - - /** - * Flow 구독하고 상태 업데이트 or 부수 효과 처리 - * @param flow 구독할 Flow - * @param onEach 각 데이터 처리 로직 - * @param onError 에러 처리 로직 - */ - protected fun collectFlow( - flow: Flow, - onEach: (T) -> Unit, - onError: ((Throwable) -> Unit)? = null, - ) = intent { - flow.catch { onError?.invoke(it) } - .collect { onEach(it) } - } - - /** - * 비동기 작업 수행하고 상태 업데이트 or 부수 효과 처리 - * @param block 실행할 suspend 블록 - * @param onError 에러 처리 로직 (옵션) - */ - protected fun launchWithErrorHandler( - block: suspend () -> Unit, - onError: ((Throwable) -> Unit)? = null, - ) = intent { - kotlin.runCatching { - block() - }.onFailure { onError?.invoke(it) } - } -} From 4fbdeb222f9e6fd79bf885a1f215aa42106cc514 Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Sat, 12 Jul 2025 13:23:02 +0900 Subject: [PATCH 13/16] [RENAME/#231] onAction -> processAction --- .../src/main/java/com/yapp/setting/SettingScreen.kt | 10 +++++----- .../src/main/java/com/yapp/setting/SettingViewModel.kt | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/feature/setting/src/main/java/com/yapp/setting/SettingScreen.kt b/feature/setting/src/main/java/com/yapp/setting/SettingScreen.kt index da81f5d9..9f4eca47 100644 --- a/feature/setting/src/main/java/com/yapp/setting/SettingScreen.kt +++ b/feature/setting/src/main/java/com/yapp/setting/SettingScreen.kt @@ -42,9 +42,9 @@ fun SettingRoute( SettingScreen( state = state, onNavigateToEditProfile = { - viewModel.onAction(SettingContract.Action.NavigateToEditProfile) + viewModel.processAction(SettingContract.Action.NavigateToEditProfile) }, - onBackClick = { viewModel.onAction(SettingContract.Action.PreviousStep) }, + onBackClick = { viewModel.processAction(SettingContract.Action.PreviousStep) }, onInquiryClick = { val kakaoUrl = "http://pf.kakao.com/_ykqxjn" val kakaoSchemeUrl = "kakaoplus://plusfriend/home/_ykqxjn" @@ -54,18 +54,18 @@ fun SettingRoute( try { context.startActivity(kakaoIntent) // 카카오톡 앱으로 이동 } catch (e: Exception) { - viewModel.onAction( + viewModel.processAction( SettingContract.Action.OpenWebView(kakaoUrl), // 앱이 없으면 웹뷰로 열기 ) } }, onTermsClick = { - viewModel.onAction( + viewModel.processAction( SettingContract.Action.OpenWebView("https://www.orbitalarm.net/terms.html"), ) }, onPrivacyPolicyClick = { - viewModel.onAction( + viewModel.processAction( SettingContract.Action.OpenWebView("https://www.orbitalarm.net/privacy.html"), ) }, diff --git a/feature/setting/src/main/java/com/yapp/setting/SettingViewModel.kt b/feature/setting/src/main/java/com/yapp/setting/SettingViewModel.kt index eada6f8a..c72828d6 100644 --- a/feature/setting/src/main/java/com/yapp/setting/SettingViewModel.kt +++ b/feature/setting/src/main/java/com/yapp/setting/SettingViewModel.kt @@ -29,7 +29,7 @@ class SettingViewModel @Inject constructor( } } - fun onAction(action: SettingContract.Action) = intent { + fun processAction(action: SettingContract.Action) = intent { when (action) { SettingContract.Action.PreviousStep -> navigateBack() SettingContract.Action.NavigateToEditProfile -> navigateToEditProfile() From deb5b4d485e2e2561e6e8d802d8e15bd5a185e18 Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Sat, 12 Jul 2025 13:32:12 +0900 Subject: [PATCH 14/16] =?UTF-8?q?[FIX/#231]=20=EC=9A=B4=EC=84=B8=20?= =?UTF-8?q?=EC=9E=AC=EC=9A=94=EC=B2=AD=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20?= =?UTF-8?q?=ED=99=88=ED=99=94=EB=A9=B4=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/yapp/mission/MissionContract.kt | 2 +- .../src/main/java/com/yapp/mission/MissionNavGraph.kt | 10 ++++++++++ .../src/main/java/com/yapp/mission/MissionViewModel.kt | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/feature/mission/src/main/java/com/yapp/mission/MissionContract.kt b/feature/mission/src/main/java/com/yapp/mission/MissionContract.kt index a2af97ef..a07bc97d 100644 --- a/feature/mission/src/main/java/com/yapp/mission/MissionContract.kt +++ b/feature/mission/src/main/java/com/yapp/mission/MissionContract.kt @@ -29,7 +29,7 @@ sealed class MissionContract { sealed class SideEffect : com.yapp.ui.base.SideEffect { data object NavigateToFortune : SideEffect() - + data object NavigateToHome : SideEffect() data object NavigateBack : SideEffect() } } diff --git a/feature/mission/src/main/java/com/yapp/mission/MissionNavGraph.kt b/feature/mission/src/main/java/com/yapp/mission/MissionNavGraph.kt index 5c2812f1..3b35a3c8 100644 --- a/feature/mission/src/main/java/com/yapp/mission/MissionNavGraph.kt +++ b/feature/mission/src/main/java/com/yapp/mission/MissionNavGraph.kt @@ -44,6 +44,16 @@ private fun handleSideEffect( ) } + MissionContract.SideEffect.NavigateToHome -> { + navigator.navigateToHome( + navOptions = navOptions { + popUpTo(MissionRoute) { + inclusive = true + } + }, + ) + } + MissionContract.SideEffect.NavigateBack -> navigator.navigateBack() } } diff --git a/feature/mission/src/main/java/com/yapp/mission/MissionViewModel.kt b/feature/mission/src/main/java/com/yapp/mission/MissionViewModel.kt index 1fddee54..bdb376da 100644 --- a/feature/mission/src/main/java/com/yapp/mission/MissionViewModel.kt +++ b/feature/mission/src/main/java/com/yapp/mission/MissionViewModel.kt @@ -178,7 +178,7 @@ class MissionViewModel @Inject constructor( } private fun navigateToHome() = intent { - postSideEffect(MissionContract.SideEffect.NavigateToFortune) + postSideEffect(MissionContract.SideEffect.NavigateToHome) } private fun sendAlarmDismissIntent(id: Long) { From c5ff1960f604ff80472e1f78676c739149a9789a Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Sat, 12 Jul 2025 13:55:56 +0900 Subject: [PATCH 15/16] =?UTF-8?q?[REFACTOR/#231]=20=EC=A4=91=EC=B2=A9=20ru?= =?UTF-8?q?nCatching=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20=EC=A4=91=EB=B3=B5?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repositoryimpl/FortuneRepositoryImpl.kt | 1 + .../java/com/yapp/mission/MissionViewModel.kt | 40 ++++++------------- 2 files changed, 14 insertions(+), 27 deletions(-) diff --git a/data/src/main/java/com/yapp/data/repositoryimpl/FortuneRepositoryImpl.kt b/data/src/main/java/com/yapp/data/repositoryimpl/FortuneRepositoryImpl.kt index 31521351..d3abb0e9 100644 --- a/data/src/main/java/com/yapp/data/repositoryimpl/FortuneRepositoryImpl.kt +++ b/data/src/main/java/com/yapp/data/repositoryimpl/FortuneRepositoryImpl.kt @@ -33,6 +33,7 @@ class FortuneRepositoryImpl @Inject constructor( fortuneResponse.toDomain() } } + override suspend fun getFortune(fortuneId: Long): Result { return fortuneRemoteDataSource.getFortune(fortuneId) .mapCatching { fortuneResponse -> diff --git a/feature/mission/src/main/java/com/yapp/mission/MissionViewModel.kt b/feature/mission/src/main/java/com/yapp/mission/MissionViewModel.kt index bdb376da..8c578194 100644 --- a/feature/mission/src/main/java/com/yapp/mission/MissionViewModel.kt +++ b/feature/mission/src/main/java/com/yapp/mission/MissionViewModel.kt @@ -116,44 +116,30 @@ class MissionViewModel @Inject constructor( } } - private fun postFortune() = intent { + private fun postFortune(isRetry: Boolean = false) = intent { val userId = userInfoRepository.userIdFlow.firstOrNull() ?: return@intent - val result = runCatching { - withContext(Dispatchers.IO) { - fortuneRepository.postFortune(userId) - } + + val result = withContext(Dispatchers.IO) { + fortuneRepository.postFortune(userId) } - result.onSuccess { - val data = it.getOrThrow() + result.onSuccess { data -> fortuneRepository.saveFortuneId(data.id) fortuneRepository.saveFortuneScore(data.avgFortuneScore) postSideEffect(MissionContract.SideEffect.NavigateToFortune) }.onFailure { error -> - Log.e("MissionViewModel", "운세 데이터 요청 실패: ${error.message}") - reduce { state.copy(errorMessage = error.message) } - } - } - - private fun retryPostFortune() = intent { - val userId = userInfoRepository.userIdFlow.firstOrNull() ?: return@intent - val result = runCatching { - withContext(Dispatchers.IO) { - fortuneRepository.postFortune(userId) + Log.e("MissionViewModel", "운세 ${if (isRetry) "재요청" else "요청"} 실패: ${error.message}") + if (isRetry) { + navigateToHome() + } else { + reduce { state.copy(errorMessage = error.message) } } } + } - result.onSuccess { - val data = it.getOrThrow() - fortuneRepository.saveFortuneId(data.id) - fortuneRepository.saveFortuneScore(data.avgFortuneScore) - - postSideEffect(MissionContract.SideEffect.NavigateToFortune) - }.onFailure { - Log.e("MissionViewModel", "운세 재요청 실패: ${it.message}") - navigateToHome() - } + fun retryPostFortune() { + postFortune(isRetry = true) } private fun completeMission(type: String) { From 04fb29d5f1aa75b82a300256b084783a4c2cfa28 Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Sat, 12 Jul 2025 14:09:32 +0900 Subject: [PATCH 16/16] =?UTF-8?q?[REFACTOR/#231]=20intent=20=EB=82=B4?= =?UTF-8?q?=EC=97=90=EC=84=9C=20processAction=20=ED=98=B8=EC=B6=9C=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 --- .../src/main/java/com/yapp/onboarding/OnboardingViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feature/onboarding/src/main/java/com/yapp/onboarding/OnboardingViewModel.kt b/feature/onboarding/src/main/java/com/yapp/onboarding/OnboardingViewModel.kt index bbf65913..fcfb105b 100644 --- a/feature/onboarding/src/main/java/com/yapp/onboarding/OnboardingViewModel.kt +++ b/feature/onboarding/src/main/java/com/yapp/onboarding/OnboardingViewModel.kt @@ -92,7 +92,7 @@ class OnboardingViewModel @Inject constructor( reduce { state.copy(isBottomSheetOpen = false) } moveToNextStep() } else { - processAction(OnboardingContract.Action.ShowWarningDialog) + showWarningDialog() } }