diff --git a/app/src/main/java/com/daedan/festabook/presentation/common/component/FestabookSwitch.kt b/app/src/main/java/com/daedan/festabook/presentation/common/component/FestabookSwitch.kt new file mode 100644 index 0000000..dd80e51 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/common/component/FestabookSwitch.kt @@ -0,0 +1,33 @@ +package com.daedan.festabook.presentation.common.component + +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import com.daedan.festabook.presentation.theme.FestabookColor + +@Composable +fun FestabookSwitch( + enabled: Boolean, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + Switch( + enabled = enabled, + modifier = modifier.wrapContentSize(), + checked = checked, + onCheckedChange = onCheckedChange, + colors = + SwitchDefaults.colors().copy( + checkedBorderColor = Color.Transparent, + uncheckedBorderColor = Color.Transparent, + disabledCheckedTrackColor = FestabookColor.black, + disabledUncheckedTrackColor = FestabookColor.gray200, + checkedTrackColor = FestabookColor.black, + uncheckedTrackColor = FestabookColor.gray200, + ), + ) +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/common/component/ObserveAsEvents.kt b/app/src/main/java/com/daedan/festabook/presentation/common/component/ObserveAsEvents.kt new file mode 100644 index 0000000..86fffb3 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/common/component/ObserveAsEvents.kt @@ -0,0 +1,27 @@ +package com.daedan.festabook.presentation.common.component + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext + +// MVI 리팩토링 PR에도 동일한 코드가 사용되어 +// 머지 시 해당 부분 제거하여 충돌을 해결하겠습니다. +@Composable +fun ObserveAsEvents( + flow: Flow, + onEvent: suspend (T) -> Unit, +) { + val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(flow, lifecycleOwner.lifecycle) { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + withContext(Dispatchers.Main.immediate) { + flow.collect(onEvent) + } + } + } +} diff --git a/app/src/main/java/com/daedan/festabook/presentation/setting/SettingFragment.kt b/app/src/main/java/com/daedan/festabook/presentation/setting/SettingFragment.kt index 783a4a7..9fce8f9 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/setting/SettingFragment.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/setting/SettingFragment.kt @@ -2,13 +2,20 @@ package com.daedan.festabook.presentation.setting import android.content.Intent import android.os.Bundle +import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.net.toUri import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.daedan.festabook.BuildConfig import com.daedan.festabook.R import com.daedan.festabook.databinding.FragmentSettingBinding @@ -16,10 +23,12 @@ import com.daedan.festabook.di.fragment.FragmentKey import com.daedan.festabook.presentation.NotificationPermissionManager import com.daedan.festabook.presentation.NotificationPermissionRequester import com.daedan.festabook.presentation.common.BaseFragment +import com.daedan.festabook.presentation.common.component.ObserveAsEvents import com.daedan.festabook.presentation.common.showErrorSnackBar import com.daedan.festabook.presentation.common.showNotificationDeniedSnackbar import com.daedan.festabook.presentation.common.showSnackBar import com.daedan.festabook.presentation.home.HomeViewModel +import com.daedan.festabook.presentation.setting.component.SettingScreen import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject @@ -58,7 +67,7 @@ class SettingFragment( onPermissionGranted() } else { Timber.d("Notification permission denied") - showNotificationDeniedSnackbar(binding.root, requireContext()) + showNotificationDeniedSnackbar(requireView(), requireContext()) onPermissionDenied() } } @@ -69,87 +78,60 @@ class SettingFragment( override fun onPermissionDenied() = Unit - override fun onViewCreated( - view: View, + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) - setupBindings() - - setupNoticeAllowButtonClickListener() - setupServicePolicyClickListener() - setupContactUsButtonClickListener() - setupObservers() - } - - private fun setupBindings() { - val versionName = BuildConfig.VERSION_NAME - binding.tvSettingAppVersionName.text = versionName - } - - override fun shouldShowPermissionRationale(permission: String): Boolean = shouldShowRequestPermissionRationale(permission) - - private fun setupObservers() { - settingViewModel.permissionCheckEvent.observe(viewLifecycleOwner) { - notificationPermissionManager.requestNotificationPermission( - requireContext(), - ) - } - settingViewModel.isAllowed.observe(viewLifecycleOwner) { - binding.btnNoticeAllow.isChecked = it - } - settingViewModel.success.observe(viewLifecycleOwner) { - requireActivity().showSnackBar(getString(R.string.setting_notice_enabled)) - } - settingViewModel.error.observe(viewLifecycleOwner) { throwable -> - showErrorSnackBar(throwable) - } - settingViewModel.isLoading.observe(viewLifecycleOwner) { loading -> - binding.btnNoticeAllow.isEnabled = !loading - } - -// homeViewModel.festivalUiState.observe(viewLifecycleOwner) { state -> -// when (state) { -// is FestivalUiState.Error -> { -// showErrorSnackBar(state.throwable) -// Timber.w( -// state.throwable, -// "${this::class.simpleName}: ${state.throwable.message}", -// ) -// } -// -// FestivalUiState.Loading -> { -// binding.tvSettingCurrentUniversityNotice.text = "" -// } -// -// is FestivalUiState.Success -> { -// binding.tvSettingCurrentUniversity.text = state.organization.universityName -// } -// } -// } - } - - private fun setupServicePolicyClickListener() { - binding.tvSettingServicePolicy.setOnClickListener { - val intent = Intent(Intent.ACTION_VIEW, POLICY_URL.toUri()) - startActivity(intent) - } - } - - private fun setupContactUsButtonClickListener() { - binding.tvSettingContactUs.setOnClickListener { - val intent = Intent(Intent.ACTION_VIEW, CONTACT_US_URL.toUri()) - startActivity(intent) + ): View = + ComposeView(requireContext()).apply { + super.onCreateView(inflater, container, savedInstanceState) + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + val festival by homeViewModel.festivalUiState.collectAsStateWithLifecycle() + val isUniversitySubscribed by settingViewModel.isAllowed.collectAsStateWithLifecycle() + val isSubscribedLoading by settingViewModel.isLoading.collectAsStateWithLifecycle() + val context = LocalContext.current + + ObserveAsEvents(flow = settingViewModel.permissionCheckEvent) { + notificationPermissionManager.requestNotificationPermission(context) + } + + ObserveAsEvents(flow = settingViewModel.successFlow) { + requireActivity().showSnackBar(getString(R.string.setting_notice_enabled)) + } + + ObserveAsEvents(flow = settingViewModel.error) { + showErrorSnackBar(it) + } + + SettingScreen( + festivalUiState = festival, + isUniversitySubscribed = isUniversitySubscribed, + appVersion = BuildConfig.VERSION_NAME, + isSubscribeEnabled = !isSubscribedLoading, + onSubscribeClick = { + settingViewModel.notificationAllowClick() + }, + onPolicyClick = { + val intent = Intent(Intent.ACTION_VIEW, POLICY_URL.toUri()) + startActivity(intent) + }, + onContactUsClick = { + val intent = Intent(Intent.ACTION_VIEW, CONTACT_US_URL.toUri()) + startActivity(intent) + }, + onError = { + showErrorSnackBar(it.throwable) + Timber.w( + it.throwable, + "${this::class.simpleName}: ${it.throwable.message}", + ) + }, + ) + } } - } - private fun setupNoticeAllowButtonClickListener() { - binding.btnNoticeAllow.setOnClickListener { - // 기본적으로 클릭했을 때 checked되는 기능 무효화 - binding.btnNoticeAllow.isChecked = !binding.btnNoticeAllow.isChecked - settingViewModel.notificationAllowClick() - } - } + override fun shouldShowPermissionRationale(permission: String): Boolean = shouldShowRequestPermissionRationale(permission) companion object { private const val POLICY_URL: String = diff --git a/app/src/main/java/com/daedan/festabook/presentation/setting/SettingViewModel.kt b/app/src/main/java/com/daedan/festabook/presentation/setting/SettingViewModel.kt index 917ad46..3bf2061 100644 --- a/app/src/main/java/com/daedan/festabook/presentation/setting/SettingViewModel.kt +++ b/app/src/main/java/com/daedan/festabook/presentation/setting/SettingViewModel.kt @@ -1,15 +1,20 @@ package com.daedan.festabook.presentation.setting import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.asLiveData import androidx.lifecycle.viewModelScope import com.daedan.festabook.di.viewmodel.ViewModelKey import com.daedan.festabook.domain.repository.FestivalNotificationRepository -import com.daedan.festabook.presentation.common.SingleLiveData import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoMap import dev.zacsweers.metro.Inject +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import timber.log.Timber @@ -19,27 +24,33 @@ import timber.log.Timber class SettingViewModel( private val festivalNotificationRepository: FestivalNotificationRepository, ) : ViewModel() { - private val _permissionCheckEvent: SingleLiveData = SingleLiveData() - val permissionCheckEvent: LiveData get() = _permissionCheckEvent + private val _permissionCheckEvent: MutableSharedFlow = + MutableSharedFlow() + val permissionCheckEvent: SharedFlow = _permissionCheckEvent.asSharedFlow() private val _isAllowed = - MutableLiveData( + MutableStateFlow( festivalNotificationRepository.getFestivalNotificationIsAllow(), ) - val isAllowed: LiveData get() = _isAllowed + val isAllowed: StateFlow = _isAllowed.asStateFlow() - private val _error: SingleLiveData = SingleLiveData() - val error: LiveData get() = _error + private val _error: MutableSharedFlow = + MutableSharedFlow() + val error: SharedFlow = _error.asSharedFlow() - private val _isLoading: MutableLiveData = MutableLiveData(false) - val isLoading: LiveData get() = _isLoading + private val _isLoading: MutableStateFlow = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() - private val _success: SingleLiveData = SingleLiveData() - val success: LiveData get() = _success + private val _success: MutableSharedFlow = + MutableSharedFlow() + val success: LiveData = _success.asLiveData() + val successFlow = _success.asSharedFlow() fun notificationAllowClick() { - if (_isAllowed.value == false) { - _permissionCheckEvent.setValue(Unit) + if (!_isAllowed.value) { + viewModelScope.launch { + _permissionCheckEvent.emit(Unit) + } } else { deleteNotificationId() } @@ -54,21 +65,22 @@ class SettingViewModel( } fun saveNotificationId() { - if (_isLoading.value == true) return + if (_isLoading.value) return _isLoading.value = true // Optimistic UI 적용, 요청 실패 시 원복 saveNotificationIsAllowed(true) updateNotificationIsAllowed(true) - _success.setValue(Unit) viewModelScope.launch { + _success.emit(Unit) + val result = festivalNotificationRepository.saveFestivalNotification() result .onFailure { - _error.setValue(it) + _error.emit(it) saveNotificationIsAllowed(false) updateNotificationIsAllowed(false) Timber.e(it, "${this::class.java.simpleName} NotificationId 저장 실패") @@ -79,7 +91,7 @@ class SettingViewModel( } private fun deleteNotificationId() { - if (_isLoading.value == true) return + if (_isLoading.value) return _isLoading.value = true // Optimistic UI 적용, 요청 실패 시 원복 @@ -92,7 +104,7 @@ class SettingViewModel( result .onFailure { - _error.setValue(it) + _error.emit(it) saveNotificationIsAllowed(true) updateNotificationIsAllowed(true) Timber.e(it, "${this::class.java.simpleName} NotificationId 삭제 실패") diff --git a/app/src/main/java/com/daedan/festabook/presentation/setting/component/SettingScreen.kt b/app/src/main/java/com/daedan/festabook/presentation/setting/component/SettingScreen.kt new file mode 100644 index 0000000..202f312 --- /dev/null +++ b/app/src/main/java/com/daedan/festabook/presentation/setting/component/SettingScreen.kt @@ -0,0 +1,296 @@ +package com.daedan.festabook.presentation.setting.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.daedan.festabook.R +import com.daedan.festabook.domain.model.Festival +import com.daedan.festabook.domain.model.Organization +import com.daedan.festabook.presentation.common.component.FestabookSwitch +import com.daedan.festabook.presentation.common.component.FestabookTopAppBar +import com.daedan.festabook.presentation.home.adapter.FestivalUiState +import com.daedan.festabook.presentation.theme.FestabookColor +import com.daedan.festabook.presentation.theme.FestabookTheme +import com.daedan.festabook.presentation.theme.FestabookTypography +import com.daedan.festabook.presentation.theme.festabookSpacing +import java.time.LocalDate + +@Composable +fun SettingScreen( + festivalUiState: FestivalUiState, + isUniversitySubscribed: Boolean, + appVersion: String, + isSubscribeEnabled: Boolean, + modifier: Modifier = Modifier, + onSubscribeClick: (Boolean) -> Unit = {}, + onPolicyClick: () -> Unit = {}, + onContactUsClick: () -> Unit = {}, + onError: (FestivalUiState.Error) -> Unit = {}, +) { + val windowInfo = LocalWindowInfo.current + val density = LocalDensity.current + val screenWidthDp = + remember { + with(density) { + windowInfo.containerSize.width.toDp() + } + } + + val currentOnError by rememberUpdatedState(onError) + + LaunchedEffect(festivalUiState) { + when (festivalUiState) { + is FestivalUiState.Error -> currentOnError(festivalUiState) + else -> Unit + } + } + + Scaffold( + topBar = { + FestabookTopAppBar( + title = stringResource(R.string.setting_title), + ) + }, + modifier = modifier, + ) { innerPadding -> + Column( + modifier = + Modifier + .fillMaxSize() + .background(color = FestabookColor.white) + .padding(horizontal = festabookSpacing.paddingScreenGutter) + .padding(innerPadding), + ) { + when (festivalUiState) { + is FestivalUiState.Success -> { + SubscriptionContent( + universityName = festivalUiState.organization.universityName, + isUniversitySubscribed = isUniversitySubscribed, + onSubscribeClick = onSubscribeClick, + isSubscribeEnabled = isSubscribeEnabled, + ) + } + + else -> Unit + } + + HorizontalDivider( + modifier = + Modifier + .requiredWidth(screenWidthDp) + .padding(vertical = 20.dp), + color = FestabookColor.gray100, + thickness = festabookSpacing.paddingBody2, + ) + + AppInfoContent( + appVersion = appVersion, + onPolicyClick = onPolicyClick, + onContactUsClick = onContactUsClick, + ) + } + } +} + +@Composable +private fun SubscriptionContent( + universityName: String, + isUniversitySubscribed: Boolean, + isSubscribeEnabled: Boolean, + onSubscribeClick: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Text( + text = stringResource(R.string.setting_notice_title), + style = FestabookTypography.bodyMedium, + modifier = Modifier.padding(top = 20.dp), + ) + + Row( + modifier = Modifier.wrapContentSize(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = stringResource(R.string.setting_current_university_notice), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = FestabookTypography.titleMedium, + modifier = + Modifier.padding( + top = festabookSpacing.paddingBody3, + ), + ) + + Text( + text = universityName, + style = FestabookTypography.bodyMedium, + modifier = Modifier.padding(vertical = festabookSpacing.paddingBody1), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = FestabookColor.gray500, + ) + } + + FestabookSwitch( + enabled = isSubscribeEnabled, + checked = isUniversitySubscribed, + onCheckedChange = onSubscribeClick, + ) + } + } +} + +@Composable +private fun AppInfoContent( + appVersion: String, + onPolicyClick: () -> Unit, + onContactUsClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Text( + text = stringResource(R.string.setting_app_info_title), + modifier = Modifier.padding(vertical = festabookSpacing.paddingBody3), + style = FestabookTypography.bodyMedium, + ) + + AppVersionInfo( + appVersion = appVersion, + ) + + AppInfoButton( + text = stringResource(R.string.setting_service_policy), + onClick = onPolicyClick, + ) + AppInfoButton( + text = stringResource(R.string.setting_contact_us), + onClick = onContactUsClick, + ) + } +} + +@Composable +private fun AppVersionInfo( + appVersion: String, + modifier: Modifier = Modifier, +) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = + modifier + .fillMaxWidth() + .padding(vertical = festabookSpacing.paddingBody3), + ) { + Text( + text = stringResource(R.string.setting_app_version), + style = FestabookTypography.titleMedium, + ) + + Text( + text = appVersion, + style = FestabookTypography.bodyMedium, + ) + } +} + +@Composable +private fun AppInfoButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val windowInfo = LocalWindowInfo.current + val density = LocalDensity.current + val screenWidthDp = + remember { + with(density) { + windowInfo.containerSize.width.toDp() + } + } + + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = + modifier + .requiredWidth(screenWidthDp) + .clickable { + onClick() + }.padding( + horizontal = festabookSpacing.paddingScreenGutter, + vertical = festabookSpacing.paddingBody3, + ), + ) { + Text( + text = text, + style = FestabookTypography.titleMedium, + ) + + Icon( + painter = painterResource(R.drawable.ic_arrow_forward_right), + contentDescription = stringResource(R.string.move), + tint = Color.Unspecified, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun SettingScreenPreview() { + FestabookTheme { + var isSubscribed by remember { mutableStateOf(false) } + SettingScreen( + festivalUiState = + FestivalUiState.Success( + Organization( + id = 1, + universityName = "성균관대학교 인문사회과학철학문학자연캠퍼스 인문사회과학철학문학자연캠퍼스", + festival = + Festival( + festivalName = "성균관대학교 축제축제축제축제축제축제축제축제축제축제축제축제", + festivalImages = listOf(), + startDate = LocalDate.of(2026, 1, 1), + endDate = LocalDate.of(2026, 2, 1), + ), + ), + ), + isUniversitySubscribed = isSubscribed, + onSubscribeClick = { isSubscribed = !isSubscribed }, + appVersion = "v1.0.0", + isSubscribeEnabled = true, + ) + } +} diff --git a/app/src/test/java/com/daedan/festabook/FlowExtension.kt b/app/src/test/java/com/daedan/festabook/FlowExtension.kt new file mode 100644 index 0000000..16f9d7b --- /dev/null +++ b/app/src/test/java/com/daedan/festabook/FlowExtension.kt @@ -0,0 +1,41 @@ +package com.daedan.festabook + +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.timeout +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.withTimeout +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +fun TestScope.observeEvent(flow: Flow): Deferred { + val event = + backgroundScope.async { + withTimeout(3000) { + flow.first() + } + } + advanceUntilIdle() + return event +} + +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +fun TestScope.observeMultipleEvent( + flow: Flow, + result: MutableList, +) { + backgroundScope.launch(UnconfinedTestDispatcher()) { + flow + .timeout(3.seconds) + .collect { + result.add(it) + } + } +} diff --git a/app/src/test/java/com/daedan/festabook/setting/SettingViewModelTest.kt b/app/src/test/java/com/daedan/festabook/setting/SettingViewModelTest.kt index f8d2889..fdec631 100644 --- a/app/src/test/java/com/daedan/festabook/setting/SettingViewModelTest.kt +++ b/app/src/test/java/com/daedan/festabook/setting/SettingViewModelTest.kt @@ -1,8 +1,7 @@ package com.daedan.festabook.setting -import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.daedan.festabook.domain.repository.FestivalNotificationRepository -import com.daedan.festabook.getOrAwaitValue +import com.daedan.festabook.observeEvent import com.daedan.festabook.presentation.setting.SettingViewModel import io.mockk.coEvery import io.mockk.coVerify @@ -17,14 +16,11 @@ import kotlinx.coroutines.test.setMain import org.assertj.core.api.Assertions.assertThat import org.junit.After import org.junit.Before -import org.junit.Rule import org.junit.Test +import org.junit.jupiter.api.Assertions.assertAll @OptIn(ExperimentalCoroutinesApi::class) class SettingViewModelTest { - @get:Rule - val instantTaskExecutorRule = InstantTaskExecutorRule() - private val testDispatcher = StandardTestDispatcher() private lateinit var settingViewModel: SettingViewModel @@ -48,16 +44,17 @@ class SettingViewModelTest { runTest { // given coEvery { festivalNotificationRepository.getFestivalNotificationIsAllow() } returns false - val expected = Unit + settingViewModel = SettingViewModel(festivalNotificationRepository) // 먼저 생성 + val event = observeEvent(settingViewModel.permissionCheckEvent) // when - settingViewModel = SettingViewModel(festivalNotificationRepository) settingViewModel.notificationAllowClick() advanceUntilIdle() // then - val actual = settingViewModel.permissionCheckEvent.value - assertThat(actual).isEqualTo(expected) + val actual = event.await() + advanceUntilIdle() + assertThat(actual).isEqualTo(Unit) } @Test @@ -72,10 +69,12 @@ class SettingViewModelTest { advanceUntilIdle() // then - val result = settingViewModel.isAllowed.getOrAwaitValue() - coVerify { festivalNotificationRepository.setFestivalNotificationIsAllow(false) } - coVerify { festivalNotificationRepository.deleteFestivalNotification() } - assertThat(result).isFalse() + val result = settingViewModel.isAllowed.value + assertAll( + { coVerify { festivalNotificationRepository.setFestivalNotificationIsAllow(false) } }, + { coVerify { festivalNotificationRepository.deleteFestivalNotification() } }, + { assertThat(result).isFalse() }, + ) } @Test @@ -83,7 +82,10 @@ class SettingViewModelTest { runTest { // given coEvery { festivalNotificationRepository.getFestivalNotificationIsAllow() } returns true - coEvery { festivalNotificationRepository.deleteFestivalNotification() } returns Result.failure(Throwable()) + coEvery { festivalNotificationRepository.deleteFestivalNotification() } returns + Result.failure( + Throwable(), + ) // when settingViewModel = SettingViewModel(festivalNotificationRepository) @@ -91,24 +93,31 @@ class SettingViewModelTest { advanceUntilIdle() // then - val result = settingViewModel.isAllowed.getOrAwaitValue() - coVerify { festivalNotificationRepository.setFestivalNotificationIsAllow(true) } - assertThat(result).isTrue() + val result = settingViewModel.isAllowed.value + assertAll( + { coVerify { festivalNotificationRepository.setFestivalNotificationIsAllow(true) } }, + { assertThat(result).isTrue() }, + ) } @Test fun `알림을 허용했을 때 서버에 알림 정보 삭제에 실패하면 이전 상태로 원복한다`() = runTest { // given - coEvery { festivalNotificationRepository.saveFestivalNotification() } returns Result.failure(Throwable()) + coEvery { festivalNotificationRepository.saveFestivalNotification() } returns + Result.failure( + Throwable(), + ) // when settingViewModel.saveNotificationId() advanceUntilIdle() // then - val result = settingViewModel.isAllowed.getOrAwaitValue() - coVerify { festivalNotificationRepository.setFestivalNotificationIsAllow(false) } - assertThat(result).isFalse() + val result = settingViewModel.isAllowed.value + assertAll( + { coVerify { festivalNotificationRepository.setFestivalNotificationIsAllow(false) } }, + { assertThat(result).isFalse() }, + ) } }