Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions docs/convention/mvi.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,27 +171,30 @@ ViewModel 테스트는 작업 결과 신호와 소비 후 초기화를, 화면

## 강제

`MviContractKonsistTest` 가 셋을 본다.
`MviContractKonsistTest` 가 넷을 본다.

| 규칙 | 내용 |
| --- | --- |
| A | `MviViewModel` 상속체는 `MutableStateFlow`·`MutableSharedFlow`·`Channel` 을 직접 선언하지 않는다 |
| B | `feature/*/presentation` 의 ViewModel 은 `MviViewModel` 을 상속한다 |
| C | `MviIntent`·`ReducerEvent` 를 직접 구현하는 타입은 `sealed interface` 다 |
| D | 아직 전환하지 않은 `feature/*/presentation` ViewModel 도 이벤트용 `Channel`·`MutableSharedFlow` 를 새로 선언하지 않는다 (#1502) |

규칙 B 는 아직 전환하지 않은 ViewModel 을 `PENDING_MVI_MIGRATION` 예외로 둔다(가드 도입 시점 49개). 전환할 때마다 목록에서 빼고, **목록이 비면 예외 자체를 지운다.** `app` 의 ViewModel 은 규칙 B 의 범위 밖이다.

세 규칙은 **프로덕션 소스만** 본다. 테스트 더블이 `MviViewModel` 을 상속하며 보조 상태 홀더를 드는 것은
규칙 D 는 규칙 B 의 예외에 든 ViewModel 이 전환 전까지 이벤트 스트림을 늘리지 못하게 한다. 규칙 A 는 전환한 ViewModel 만 보기 때문이다. 가드를 세울 때 이미 있던 선언은 `LEGACY_EVENT_STREAMS` 기준선으로 인정하고, 그 ViewModel 이 전환되거나 지워지면 기준선에서도 뺀다. 전환 전 상태 홀더인 `MutableStateFlow` 는 대상이 아니다.

네 규칙은 **프로덕션 소스만** 본다. 테스트 더블이 `MviViewModel` 을 상속하며 보조 상태 홀더를 드는 것은
규칙 A 의 대상이 아니다 — 더블은 계약을 지키는 대상이 아니라 계약을 흉내 내는 도구다.

규칙 A·B 의 상속 판정은 **중간 추상 베이스를 낀 사슬까지** 따라간다. Konsist 의
규칙 A·B·D 의 상속 판정은 **중간 추상 베이스를 낀 사슬까지** 따라간다. Konsist 의
`parents(indirectParents = true)` 로는 안 되고(0.17.3 에서 이 스코프 구성으로는 직계와 같은 목록을
돌려준다 — 실측), 스캔한 파일에서 이름 색인을 만들어 직접 걷는다.

### 가드가 «안» 보는 것

- **`onIntent` 밖의 public 진입점.** 상속체가 `fun refreshOnReturn()` 같은 public 함수를 노출해도
세 규칙 어디도 막지 않는다. 화면이 ViewModel 을 직접 부르는 통로가 `onIntent` 하나여야 한다는 것은
네 규칙 어디도 막지 않는다. 화면이 ViewModel 을 직접 부르는 통로가 `onIntent` 하나여야 한다는 것은
규약이지 CI 계약이 아니다. 리뷰에서 본다.
- **`reduce` 밖의 전이.** 규칙 A 는 «상태 홀더 선언» 을 막을 뿐, `dispatch` 를 거치지 않는 다른 경로를
전수로 잡지는 못한다.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.afternote.feature.setting.presentation.account

import com.afternote.core.ui.mvi.MviIntent
import com.afternote.core.ui.mvi.ReducerEvent

internal sealed interface ConnectedAccountsIntent : MviIntent {
data class Toggle(
val provider: String,
val enabled: Boolean,
) : ConnectedAccountsIntent

/** 화면이 플랫폼 인증으로 받아낸 [accessToken] 으로 [provider] 를 연결한다. */
data class Link(
val provider: String,
val accessToken: String,
) : ConnectedAccountsIntent

/** 화면이 처리한 연결 요청 [provider] 를 되돌려준다. 그사이 다른 제공자 요청이 올라왔으면 그 요청은 남는다. */
data class ConsumeLinkRequest(
val provider: String,
) : ConnectedAccountsIntent
}

internal sealed interface ConnectedAccountsReducerEvent : ReducerEvent {
data class Loaded(
val accounts: List<SocialAccountState>,
) : ConnectedAccountsReducerEvent

data class LoadFailed(
val message: String,
) : ConnectedAccountsReducerEvent

data class AccountsChanged(
val accounts: List<SocialAccountState>,
) : ConnectedAccountsReducerEvent

data class ChangeFailed(
val message: String,
) : ConnectedAccountsReducerEvent

data class LinkRequested(
val provider: String,
) : ConnectedAccountsReducerEvent

data class LinkRequestConsumed(
val provider: String,
) : ConnectedAccountsReducerEvent
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ package com.afternote.feature.setting.presentation.account
import android.app.Activity
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
Expand All @@ -15,11 +15,15 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.afternote.core.common.auth.requestKakaoAccessToken
import com.afternote.core.domain.error.CoreAuthFailure
import com.afternote.core.ui.findActivity
import com.afternote.core.ui.mvi.ObserveSignal
import com.afternote.feature.setting.presentation.BuildConfig
import com.afternote.feature.setting.presentation.R
import com.afternote.feature.setting.presentation.account.social.KakaoAuthResult
import com.afternote.feature.setting.presentation.account.social.requestGoogleIdToken
import com.afternote.feature.setting.presentation.account.social.toKakaoAuthResult
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock

@Composable
internal fun ConnectedAccountsScreen(
Expand All @@ -33,16 +37,20 @@ internal fun ConnectedAccountsScreen(
val snackbarHostState = remember { SnackbarHostState() }
val kakaoAccountLinkFailedMessage = stringResource(R.string.setting_kakao_account_link_failed)
val googleAccountLinkFailedMessage = stringResource(R.string.setting_google_account_link_failed)
// 소비가 ObserveSignal 의 effect 를 다시 시작시켜도 인증·스낵바는 끝까지 간다. 예전 이벤트 수집처럼
// 요청을 하나씩 처리하도록 화면 코루틴에서 잠금으로 줄을 세운다.
val linkScope = rememberCoroutineScope()
val linkMutex = remember { Mutex() }

LaunchedEffect(Unit) {
viewModel.events.collect { event ->
when (event) {
is ConnectedAccountsEvent.ShowError -> {
snackbarHostState.showSnackbar(event.message)
}

is ConnectedAccountsEvent.RequestLink -> {
when (event.provider) {
uiState.pendingLinkProvider?.let { pendingProvider ->
ObserveSignal(
signal = pendingProvider,
consumed = ConnectedAccountsIntent.ConsumeLinkRequest(pendingProvider),
onIntent = viewModel::onIntent,
) { provider ->
linkScope.launch {
linkMutex.withLock {
when (provider) {
"kakao" -> {
val activity = context.findActivity<Activity>()
val authResult =
Expand All @@ -51,15 +59,13 @@ internal fun ConnectedAccountsScreen(
?: KakaoAuthResult.Failure
when (authResult) {
is KakaoAuthResult.Success -> {
viewModel.link("kakao", authResult.accessToken)
viewModel.onIntent(ConnectedAccountsIntent.Link("kakao", authResult.accessToken))
}

KakaoAuthResult.Cancelled -> {}

KakaoAuthResult.Failure -> {
snackbarHostState.showSnackbar(
kakaoAccountLinkFailedMessage,
)
snackbarHostState.showSnackbar(kakaoAccountLinkFailedMessage)
}
}
}
Expand All @@ -69,14 +75,13 @@ internal fun ConnectedAccountsScreen(
context = context,
credentialManager = credentialManager,
serverClientId = BuildConfig.GOOGLE_WEB_CLIENT_ID,
).onSuccess { token -> viewModel.link("google", token) }
.onFailure { e ->
if (e !is CoreAuthFailure.UserCancelledAuth) {
viewModel.notifyLinkError(
googleAccountLinkFailedMessage,
)
}
).onSuccess { token ->
viewModel.onIntent(ConnectedAccountsIntent.Link("google", token))
}.onFailure { e ->
if (e !is CoreAuthFailure.UserCancelledAuth) {
snackbarHostState.showSnackbar(googleAccountLinkFailedMessage)
}
}
}
}
}
Expand All @@ -88,7 +93,7 @@ internal fun ConnectedAccountsScreen(
uiState = uiState,
snackbarHostState = snackbarHostState,
onBack = onBack,
onToggle = viewModel::onToggle,
onToggle = { provider, enabled -> viewModel.onIntent(ConnectedAccountsIntent.Toggle(provider, enabled)) },
modifier = modifier,
)
}
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
package com.afternote.feature.setting.presentation.account

import com.afternote.core.ui.mvi.UiState

data class ConnectedAccountsUiState(
val isLoading: Boolean = false,
val accounts: List<SocialAccountState> = emptyList(),
val errorMessage: String? = null,
)

sealed interface ConnectedAccountsEvent {
data class RequestLink(
val provider: String,
) : ConnectedAccountsEvent

data class ShowError(
val message: String,
) : ConnectedAccountsEvent
}
/**
* 화면이 플랫폼 인증(카카오 SDK·Credential Manager)을 시작해야 하는 제공자 신호다 (#1502).
*
* ViewModel 은 Activity·Context 에 닿지 않으므로 토큰을 직접 받지 못한다. 화면이 이 신호를 받아
* 인증을 돌린 뒤 토큰만 [ConnectedAccountsIntent.Link] 로 돌려보내고, 받은 즉시
* [ConnectedAccountsIntent.ConsumeLinkRequest] 로 되돌린다.
*/
val pendingLinkProvider: String? = null,
) : UiState
Original file line number Diff line number Diff line change
@@ -1,17 +1,12 @@
package com.afternote.feature.setting.presentation.account

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.afternote.core.domain.result.runCatchingCancellable
import com.afternote.core.model.user.UserConnectedAccount
import com.afternote.core.ui.mvi.MviViewModel
import com.afternote.feature.setting.domain.SettingAccountRepository
import com.afternote.feature.setting.presentation.R
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject

Expand All @@ -20,59 +15,100 @@ internal class ConnectedAccountsViewModel
@Inject
constructor(
private val accountRepository: SettingAccountRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(ConnectedAccountsUiState(isLoading = true))
val uiState = _uiState.asStateFlow()

private val _events = Channel<ConnectedAccountsEvent>(Channel.BUFFERED)
val events = _events.receiveAsFlow()

) : MviViewModel<ConnectedAccountsIntent, ConnectedAccountsUiState, ConnectedAccountsReducerEvent>(
ConnectedAccountsUiState(isLoading = true),
) {
init {
loadConnectedAccounts()
}

override fun onIntent(intent: ConnectedAccountsIntent) {
when (intent) {
is ConnectedAccountsIntent.Toggle -> {
toggle(intent.provider, intent.enabled)
}

is ConnectedAccountsIntent.Link -> {
link(intent.provider, intent.accessToken)
}

is ConnectedAccountsIntent.ConsumeLinkRequest -> {
dispatch(
ConnectedAccountsReducerEvent.LinkRequestConsumed(intent.provider),
)
}
}
}

override fun reduce(
state: ConnectedAccountsUiState,
event: ConnectedAccountsReducerEvent,
): ConnectedAccountsUiState =
when (event) {
is ConnectedAccountsReducerEvent.Loaded -> {
state.copy(isLoading = false, accounts = event.accounts)
}

is ConnectedAccountsReducerEvent.LoadFailed -> {
state.copy(isLoading = false, errorMessage = event.message)
}

is ConnectedAccountsReducerEvent.AccountsChanged -> {
state.copy(accounts = event.accounts)
}

is ConnectedAccountsReducerEvent.ChangeFailed -> {
state.copy(errorMessage = event.message)
}

is ConnectedAccountsReducerEvent.LinkRequested -> {
state.copy(pendingLinkProvider = event.provider)
}

// 늦게 도착한 소비가 그사이 올라온 다른 제공자 요청을 지우지 않게 한다.
is ConnectedAccountsReducerEvent.LinkRequestConsumed -> {
if (state.pendingLinkProvider == event.provider) state.copy(pendingLinkProvider = null) else state
}
}

private fun loadConnectedAccounts() {
viewModelScope.launch {
runCatchingCancellable { accountRepository.getConnectedAccounts() }
.onSuccess { accounts ->
_uiState.update { it.copy(isLoading = false, accounts = accounts.toStateList()) }
dispatch(ConnectedAccountsReducerEvent.Loaded(accounts.toStateList()))
}.onFailure {
_uiState.update { it.copy(isLoading = false, errorMessage = "계정 정보를 불러올 수 없습니다.") }
dispatch(ConnectedAccountsReducerEvent.LoadFailed("계정 정보를 불러올 수 없습니다."))
}
}
}

fun onToggle(
private fun toggle(
provider: String,
enabled: Boolean,
) {
if (enabled) {
viewModelScope.launch { _events.send(ConnectedAccountsEvent.RequestLink(provider)) }
dispatch(ConnectedAccountsReducerEvent.LinkRequested(provider))
} else {
unlink(provider)
}
}

fun notifyLinkError(message: String) {
viewModelScope.launch { _events.send(ConnectedAccountsEvent.ShowError(message)) }
}

fun link(
private fun link(
provider: String,
accessToken: String,
) {
viewModelScope.launch {
runCatchingCancellable { accountRepository.linkConnectedAccount(provider, accessToken) }
.onSuccess { accounts -> _uiState.update { it.copy(accounts = accounts.toStateList()) } }
.onFailure { _uiState.update { it.copy(errorMessage = "계정 연결에 실패했습니다.") } }
.onSuccess { accounts -> dispatch(ConnectedAccountsReducerEvent.AccountsChanged(accounts.toStateList())) }
.onFailure { dispatch(ConnectedAccountsReducerEvent.ChangeFailed("계정 연결에 실패했습니다.")) }
}
}

private fun unlink(provider: String) {
viewModelScope.launch {
runCatchingCancellable { accountRepository.unlinkConnectedAccount(provider) }
.onSuccess { accounts -> _uiState.update { it.copy(accounts = accounts.toStateList()) } }
.onFailure { _uiState.update { it.copy(errorMessage = "계정 연결 해제에 실패했습니다.") } }
.onSuccess { accounts -> dispatch(ConnectedAccountsReducerEvent.AccountsChanged(accounts.toStateList())) }
.onFailure { dispatch(ConnectedAccountsReducerEvent.ChangeFailed("계정 연결 해제에 실패했습니다.")) }
}
}

Expand Down
Loading
Loading