diff --git a/core/ui/src/main/kotlin/com/afternote/core/ui/mvi/ObserveSignal.kt b/core/ui/src/main/kotlin/com/afternote/core/ui/mvi/ObserveSignal.kt index a4366d04be..b433e14ff2 100644 --- a/core/ui/src/main/kotlin/com/afternote/core/ui/mvi/ObserveSignal.kt +++ b/core/ui/src/main/kotlin/com/afternote/core/ui/mvi/ObserveSignal.kt @@ -56,3 +56,35 @@ fun ObserveSignal( currentOnIntent(consumed) } } + +/** + * `Boolean` 플래그로 든 일회성 신호를 소비한다 (#1802 파일럿에서 확정). + * + * [ObserveSignal] 과 같은 계약이고, 신호가 `nullable 값` 이 아니라 「올라갔다/내려갔다」 로만 + * 표현되는 경우다 — `isLoggedIn` · `shouldNavigateToXxx` 처럼 실어 나를 값이 없는 신호가 + * 이 저장소에 이미 여러 개 있다. + * + * ```kotlin + * ObserveFlag( + * raised = state.isLoggedIn, + * consumed = LoginIntent.ConsumeLoggedIn, + * onIntent = onIntent, + * onRaised = onLoginSuccess, + * ) + * ``` + */ +@Composable +fun ObserveFlag( + raised: Boolean, + consumed: I, + onIntent: (I) -> Unit, + onRaised: () -> Unit, +) { + // Unit? 로 접어 ObserveSignal 하나로 모은다 — 두 벌로 두면 소비 규약이 갈린다. + ObserveSignal( + signal = Unit.takeIf { raised }, + consumed = consumed, + onIntent = onIntent, + onSignal = { onRaised() }, + ) +} diff --git a/core/ui/src/test/kotlin/com/afternote/core/ui/mvi/ObserveSignalTest.kt b/core/ui/src/test/kotlin/com/afternote/core/ui/mvi/ObserveSignalTest.kt index d0f73a07cc..332e679751 100644 --- a/core/ui/src/test/kotlin/com/afternote/core/ui/mvi/ObserveSignalTest.kt +++ b/core/ui/src/test/kotlin/com/afternote/core/ui/mvi/ObserveSignalTest.kt @@ -90,6 +90,51 @@ class ObserveSignalTest { assertEquals(emptyList(), observed) assertEquals(emptyList(), intents) } + + @Test + fun `플래그가 올라가면 한 번 부르고 소비 Intent 를 되쏜다`() { + var raisedCount = 0 + val intents = mutableListOf() + var raised by mutableStateOf(false) + + composeRule.setContent { + ObserveFlag( + raised = raised, + consumed = SignalIntent.ConsumeError, + onIntent = { intent -> + intents += intent + raised = false + }, + onRaised = { raisedCount++ }, + ) + } + + composeRule.runOnIdle { raised = true } + composeRule.waitForIdle() + composeRule.runOnIdle { raised = true } + composeRule.waitForIdle() + + assertEquals(2, raisedCount) + assertEquals(listOf(SignalIntent.ConsumeError, SignalIntent.ConsumeError), intents) + } + + @Test + fun `플래그가 내려가 있으면 아무것도 부르지 않는다`() { + var raisedCount = 0 + + composeRule.setContent { + ObserveFlag( + raised = false, + consumed = SignalIntent.ConsumeError, + onIntent = {}, + onRaised = { raisedCount++ }, + ) + } + + composeRule.waitForIdle() + + assertEquals(0, raisedCount) + } } private sealed interface SignalIntent : MviIntent { diff --git a/docs/convention/mvi.md b/docs/convention/mvi.md index 6cd527170c..acb9f13dbd 100644 --- a/docs/convention/mvi.md +++ b/docs/convention/mvi.md @@ -75,7 +75,7 @@ private fun requestCode() { ```kotlin @Composable -fun FindIdScreen(modifier: Modifier = Modifier, viewModel: FindIdViewModel = hiltViewModel()) { +internal fun FindIdScreen(viewModel: FindIdViewModel, modifier: Modifier = Modifier) { val state by viewModel.uiState.collectAsStateWithLifecycle() FindIdContent(state = state, onIntent = viewModel::onIntent, modifier = modifier) } @@ -85,7 +85,15 @@ internal fun FindIdContent(state: FindIdUiState, onIntent: (FindIdIntent) -> Uni ``` - `Screen` 은 stateful 이고 ViewModel 을 주입받는다. -- `Content` 는 `internal` stateless 이고, **프리뷰 · screenshotTest · Robolectric 의 진입점**이다. 화면을 그리려고 ViewModel 을 조립하지 않는다. +- `Content` 는 상태와 콜백으로 화면을 그린다. 별도 프로덕션 `Screen` 파일에서 사용하는 렌더 계약은 `internal` 이며, 프리뷰 · screenshotTest · Robolectric 도 같은 계약을 소비한다. + +### 파일럿에서 확정한 것 (#1802) + +- **ViewModel 을 화면이 만들지 않는 경우가 있다.** onboarding 은 `Route.Onboarding` 그래프 스코프로 여러 화면이 한 인스턴스를 공유하므로, `Screen` 이 `hiltViewModel()` 을 부르지 않고 **VM 을 파라미터로 받는다.** 화면이 스스로 만들면 공유가 끊긴다. 공유가 없는 화면은 `viewModel: XxxViewModel = hiltViewModel()` 로 둔다. +- **수명·소비와 렌더 책임을 가른다.** onboarding 파일럿은 `FindIdScreen.kt` ↔ `FindIdContent.kt` 처럼 `Screen` 이 ViewModel 상태 수집과 신호 소비를 맡고, 별도 `Content` 파일이 렌더를 맡는다(#1829). 같은 파일 안에만 사용하는 렌더 helper 는 `private` 로 둔다. +- **플랫폼에 매인 콜백은 `Content` 의 파라미터로 남는다.** 카카오 SDK·Credential Manager 는 Activity·Context 의존이라 stateful 층이 토큰을 받아낸 뒤 문자열만 Intent 로 보낸다. ViewModel 은 플랫폼 독립을 유지한다. +- **공개 범위는 프로덕션 소비처로 정한다.** 같은 파일에서만 호출하면 `private`, 같은 모듈의 다른 파일이 호출하면 `internal`, 다른 모듈이 실제 소비할 때만 `public` 이다. 테스트 접근을 위해 넓히거나 visibility baseline 에 남기지 않는다. 파일럿의 다섯 `Content` 는 별도 `Screen` 파일의 실제 호출로 `internal` 이고, 이전 예외 baseline 은 제거했다(#1829). +- **파생값을 화면에 넘기지 않는다.** `isNextEnabled` 같은 값은 `UiState` 의 계산 프로퍼티로 두고 `Content` 가 `state` 에서 읽는다. 호출부가 따로 계산해 넘기면 화면마다 판정이 갈린다. ## 일회성 신호 — `UiState` 흡수 + `Intent.ConsumeXxx` @@ -103,6 +111,17 @@ ObserveSignal( - **소비가 신호를 null 로 되돌리므로 같은 값이 연속으로 와도 두 번 소비된다** (`A → null → A`). reset 없이 같은 값을 다시 쓰면 두 번째는 조용히 묻힌다. - `onSignal` 안에서 suspend 를 직접 기다리지 않는다. 소비 직후의 상태 변화가 `LaunchedEffect` 를 재시작시켜 이전 코루틴을 취소한다 — 스낵바처럼 시간이 걸리는 표출은 `rememberCoroutineScope()` 에 launch 한다. +신호가 **값 없이 「올라갔다/내려갔다」 로만** 표현되면 `ObserveFlag` 를 쓴다 — `isLoggedIn` · `shouldNavigateToXxx` 처럼 나를 값이 없는 신호다. 안에서 `ObserveSignal` 로 접히므로 소비 규약은 하나다. + +```kotlin +ObserveFlag( + raised = state.isLoggedIn, + consumed = LoginIntent.ConsumeLoggedIn, + onIntent = onIntent, + onRaised = onLoginSuccess, +) +``` + `Effect` 타입 파라미터는 베이스에 없다. MVI 가 요구하는 것은 「일회성 효과를 상태 전이에서 분리한다」 까지고, 전달 수단은 아키텍처 계약 밖이다. ## `composable-callback-defaults.md` (#1388) 와의 관계 @@ -123,7 +142,7 @@ ObserveSignal( | B | `feature/*/presentation` 의 ViewModel 은 `MviViewModel` 을 상속한다 | | C | `MviIntent`·`ReducerEvent` 를 직접 구현하는 타입은 `sealed interface` 다 | -규칙 B 는 전환 전 ViewModel 49개를 `PENDING_MVI_MIGRATION` 예외로 둔다. 모듈 전환 이슈가 닫힐 때마다 목록에서 빼고, **목록이 비면 예외 자체를 지운다.** `app` 의 ViewModel 2개는 규칙 B 의 대상이 아니다 — #1809 가 처리한다. +규칙 B 는 아직 전환하지 않은 ViewModel 을 `PENDING_MVI_MIGRATION` 예외로 둔다(가드 도입 시점 49개, onboarding 파일럿 이후 46개). 모듈 전환 이슈가 닫힐 때마다 목록에서 빼고, **목록이 비면 예외 자체를 지운다.** `app` 의 ViewModel 2개는 규칙 B 의 대상이 아니다 — #1809 가 처리한다. 세 규칙은 **프로덕션 소스만** 본다. 테스트 더블이 `MviViewModel` 을 상속하며 보조 상태 홀더를 드는 것은 규칙 A 의 대상이 아니다 — 더블은 계약을 지키는 대상이 아니라 계약을 흉내 내는 도구다. diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/OnboardingProfileContent.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/OnboardingProfileContent.kt new file mode 100644 index 0000000000..0dc2360398 --- /dev/null +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/OnboardingProfileContent.kt @@ -0,0 +1,157 @@ +package com.afternote.feature.onboarding.presentation + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import com.afternote.core.ui.AfternoteTextField +import com.afternote.core.ui.ProfileImagePicker +import com.afternote.core.ui.button.AfternoteButton +import com.afternote.core.ui.button.AfternoteButtonType +import com.afternote.core.ui.modifierextention.addFocusCleaner +import com.afternote.core.ui.theme.AfternoteDesign +import com.afternote.core.ui.topbar.DetailTopBar +import com.afternote.feature.onboarding.presentation.signup.SignUpIntent +import com.afternote.feature.onboarding.presentation.signup.SignUpUiState + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun OnboardingProfileContent( + state: SignUpUiState, + onIntent: (SignUpIntent) -> Unit, + snackbarHostState: SnackbarHostState, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val focusManager = LocalFocusManager.current + // VM 은 Android Framework 의존 제거를 위해 String 으로 보관한다. Uri 변환은 UI 층 몫이다. + val displayImageUri = state.profileImageUri?.toUri() + val nameState = rememberTextFieldState(state.name) + + LaunchedEffect(nameState) { + snapshotFlow { nameState.text.toString() }.collect { onIntent(SignUpIntent.UpdateName(it)) } + } + + val photoPickerLauncher = + rememberLauncherForActivityResult( + contract = PickVisualMedia(), + onResult = { uri -> + handleProfileImagePickerResult(uri) { picked -> onIntent(SignUpIntent.PickProfileImage(picked.toString())) } + }, + ) + + Scaffold( + modifier = modifier, + topBar = { + DetailTopBar( + title = stringResource(R.string.onboarding_profile_top_bar_title), + onBackClick = { + focusManager.clearFocus() + onBackClick() + }, + ) + }, + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + containerColor = Color.Transparent, + ) { innerPadding -> + Column( + modifier = + Modifier + .padding(innerPadding) + .consumeWindowInsets(innerPadding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .imePadding() + .addFocusCleaner(focusManager) + .padding(horizontal = 20.dp), + ) { + Spacer(modifier = Modifier.height(39.dp)) + Column( + verticalArrangement = Arrangement.spacedBy(56.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.onboarding_profile_headline), + modifier = Modifier.fillMaxWidth(), + style = AfternoteDesign.typography.h1, + color = AfternoteDesign.colors.black, + textAlign = TextAlign.Start, + ) + ProfileImagePicker( + onPickClick = { + photoPickerLauncher.launch( + PickVisualMediaRequest(PickVisualMedia.ImageOnly), + ) + }, + displayImageUri = displayImageUri?.toString(), + ) + + val isNameProvided = + nameState.text + .toString() + .trim() + .isNotEmpty() + + // 제출 중에는 버튼과 IME 두 경로 모두 잠근다. 한쪽만 막으면 다른 쪽으로 중복 제출된다. + val isCompleteEnabled = isNameProvided && !state.isLoading + + AfternoteTextField( + state = nameState, + placeholder = stringResource(R.string.onboarding_profile_name_placeholder), + imeAction = ImeAction.Done, + onImeAction = { + focusManager.clearFocus() + if (isCompleteEnabled) onIntent(SignUpIntent.SubmitSignUp) + }, + ) + + AfternoteButton( + text = stringResource(R.string.onboarding_profile_complete), + onClick = { + focusManager.clearFocus() + if (isCompleteEnabled) onIntent(SignUpIntent.SubmitSignUp) + }, + type = if (isCompleteEnabled) AfternoteButtonType.Default else AfternoteButtonType.Un, + isLoading = state.isLoading, + ) + } + } + } +} + +/** 포토 피커 취소 결과(null)는 선택 변경이 아니므로 기존 프로필 이미지를 그대로 둔다. */ +private fun handleProfileImagePickerResult( + uri: Uri?, + onProfileImagePick: (Uri) -> Unit, +) { + uri?.let(onProfileImagePick) +} diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/OnboardingProfileEntry.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/OnboardingProfileEntry.kt deleted file mode 100644 index 30e140b047..0000000000 --- a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/OnboardingProfileEntry.kt +++ /dev/null @@ -1,85 +0,0 @@ -package com.afternote.feature.onboarding.presentation - -import androidx.compose.material3.SnackbarDuration -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.res.stringResource -import androidx.core.net.toUri -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.afternote.core.ui.asString -import com.afternote.feature.onboarding.presentation.signup.SignUpUiState -import com.afternote.feature.onboarding.presentation.signup.SignUpViewModel -import kotlinx.coroutines.launch - -/** - * 프로필 설정 Entry. - * - * Graph-scoped [SignUpViewModel] 의 [SignUpUiState] 단발성 신호 (isSignedUp · - * errorMessage · isNameRequired) 를 LaunchedEffect 로 소비. 소비 후 VM 의 `onXxxConsumed()` - * 호출로 reset. - */ -@Composable -internal fun OnboardingProfileEntry( - viewModel: SignUpViewModel, - onOnboardingComplete: () -> Unit, - onBackClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val snackbarHostState = remember { SnackbarHostState() } - val coroutineScope = rememberCoroutineScope() - - val nameRequiredMessage = stringResource(R.string.onboarding_signup_name_required) - - val showSnackbar: (String) -> Unit = { message -> - coroutineScope.launch { - snackbarHostState.showSnackbar( - message = message, - duration = SnackbarDuration.Short, - ) - } - } - - LaunchedEffect(uiState.isSignedUp) { - if (uiState.isSignedUp) { - onOnboardingComplete() - viewModel.onSignedUpConsumed() - } - } - LaunchedEffect(uiState.isNameRequired) { - if (uiState.isNameRequired) { - showSnackbar(nameRequiredMessage) - viewModel.onNameRequiredConsumed() - } - } - // VM 이 UiText 로 폴백까지 확정해 두므로 빈 문구가 도달하지 않는다. - val snackbarMessage = - uiState.failure - .toDisplay() - .snackbarMessage - ?.asString() - LaunchedEffect(snackbarMessage) { - if (snackbarMessage != null) { - showSnackbar(snackbarMessage) - viewModel.onErrorConsumed() - } - } - - // VM 은 Android Framework 의존 제거를 위해 String 으로 보관. UI 레이어에서 Uri 와 변환. - OnboardingProfileScreen( - initialName = uiState.name, - displayImageUri = uiState.profileImageUri?.toUri(), - snackbarHostState = snackbarHostState, - onNameChange = viewModel::updateName, - onProfileImagePick = { uri -> viewModel.onProfileImagePicked(uri.toString()) }, - onCompleteClick = viewModel::submitSignUp, - onBackClick = onBackClick, - modifier = modifier, - isSubmitting = uiState.isLoading, - ) -} diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/OnboardingProfileScreen.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/OnboardingProfileScreen.kt index 55cae8ab41..97b0b6b0d2 100644 --- a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/OnboardingProfileScreen.kt +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/OnboardingProfileScreen.kt @@ -1,154 +1,44 @@ package com.afternote.feature.onboarding.presentation -import android.net.Uri -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.PickVisualMediaRequest -import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.input.rememberTextFieldState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Alignment +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import com.afternote.core.ui.AfternoteTextField -import com.afternote.core.ui.ProfileImagePicker -import com.afternote.core.ui.button.AfternoteButton -import com.afternote.core.ui.button.AfternoteButtonType -import com.afternote.core.ui.modifierextention.addFocusCleaner -import com.afternote.core.ui.theme.AfternoteDesign -import com.afternote.core.ui.topbar.DetailTopBar - -@OptIn(ExperimentalMaterial3Api::class) +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.afternote.core.ui.mvi.ObserveFlag +import com.afternote.feature.onboarding.presentation.signup.SignUpIntent +import com.afternote.feature.onboarding.presentation.signup.SignUpUiState +import com.afternote.feature.onboarding.presentation.signup.SignUpViewModel +import com.afternote.feature.onboarding.presentation.signup.rememberSignUpSnackbarHost + +/** + * 프로필 설정 — stateful 층. + * + * 그래프 스코프 [SignUpViewModel] 의 일회성 신호를 소비한다. 이름 미입력·실패 문구는 Step 화면과 + * 같은 관용구([rememberSignUpSnackbarHost])를 쓰고, 완료 신호([SignUpUiState.isSignedUp])만 + * 이 화면이 직접 소비한다. + */ @Composable -fun OnboardingProfileScreen( - initialName: String, - displayImageUri: Uri?, - snackbarHostState: SnackbarHostState, - onNameChange: (String) -> Unit, - onProfileImagePick: (Uri) -> Unit, +internal fun OnboardingProfileScreen( + viewModel: SignUpViewModel, + onOnboardingComplete: () -> Unit, onBackClick: () -> Unit, - onCompleteClick: () -> Unit, modifier: Modifier = Modifier, - isSubmitting: Boolean = false, ) { - val focusManager = LocalFocusManager.current - val nameState = rememberTextFieldState(initialName) - - LaunchedEffect(nameState) { - snapshotFlow { nameState.text.toString() }.collect(onNameChange) - } - - val photoPickerLauncher = - rememberLauncherForActivityResult( - contract = PickVisualMedia(), - onResult = { uri -> handleProfileImagePickerResult(uri, onProfileImagePick) }, - ) - - Scaffold( + val state by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = rememberSignUpSnackbarHost(state, viewModel::onIntent) + + ObserveFlag( + raised = state.isSignedUp, + consumed = SignUpIntent.ConsumeSignedUp, + onIntent = viewModel::onIntent, + onRaised = onOnboardingComplete, + ) + + OnboardingProfileContent( + state = state, + onIntent = viewModel::onIntent, + snackbarHostState = snackbarHostState, + onBackClick = onBackClick, modifier = modifier, - topBar = { - DetailTopBar( - title = stringResource(R.string.onboarding_profile_top_bar_title), - onBackClick = { - focusManager.clearFocus() - onBackClick() - }, - ) - }, - snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, - containerColor = Color.Transparent, - ) { innerPadding -> - Column( - modifier = - Modifier - .padding(innerPadding) - .consumeWindowInsets(innerPadding) - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .imePadding() - .addFocusCleaner(focusManager) - .padding(horizontal = 20.dp), - ) { - Spacer(modifier = Modifier.height(39.dp)) - Column( - verticalArrangement = Arrangement.spacedBy(56.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = stringResource(R.string.onboarding_profile_headline), - modifier = Modifier.fillMaxWidth(), - style = AfternoteDesign.typography.h1, - color = AfternoteDesign.colors.black, - textAlign = TextAlign.Start, - ) - ProfileImagePicker( - onPickClick = { - photoPickerLauncher.launch( - PickVisualMediaRequest(PickVisualMedia.ImageOnly), - ) - }, - displayImageUri = displayImageUri?.toString(), - ) - - val isNameProvided = - nameState.text - .toString() - .trim() - .isNotEmpty() - - // 제출 중에는 버튼과 IME 두 경로 모두 잠근다. 한쪽만 막으면 다른 쪽으로 중복 제출된다. - val isCompleteEnabled = isNameProvided && !isSubmitting - - AfternoteTextField( - state = nameState, - placeholder = stringResource(R.string.onboarding_profile_name_placeholder), - imeAction = ImeAction.Done, - onImeAction = { - focusManager.clearFocus() - if (isCompleteEnabled) onCompleteClick() - }, - ) - - AfternoteButton( - text = stringResource(R.string.onboarding_profile_complete), - onClick = { - focusManager.clearFocus() - if (isCompleteEnabled) onCompleteClick() - }, - type = if (isCompleteEnabled) AfternoteButtonType.Default else AfternoteButtonType.Un, - isLoading = isSubmitting, - ) - } - } - } -} - -/** 포토 피커 취소 결과(null)는 선택 변경이 아니므로 기존 프로필 이미지를 그대로 둔다. */ -private fun handleProfileImagePickerResult( - uri: Uri?, - onProfileImagePick: (Uri) -> Unit, -) { - uri?.let(onProfileImagePick) + ) } diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindIdContent.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindIdContent.kt new file mode 100644 index 0000000000..53a2472ea6 --- /dev/null +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindIdContent.kt @@ -0,0 +1,155 @@ +package com.afternote.feature.onboarding.presentation.findaccount + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.afternote.core.ui.AfternoteTextField +import com.afternote.core.ui.TextFieldType +import com.afternote.core.ui.scaffold.FlowStepScaffold +import com.afternote.core.ui.theme.AfternoteDesign +import com.afternote.feature.onboarding.presentation.OnboardingFailureDisplay +import com.afternote.feature.onboarding.presentation.R +import com.afternote.feature.onboarding.presentation.toDisplay + +private val HeaderSpacing = 8.dp + +/** 상태를 그리는 렌더 계약. 별도 Screen 파일이 수명과 신호 소비를 담당하므로 모듈 내부에 공개한다. */ +@Composable +internal fun FindIdContent( + state: FindIdUiState, + onIntent: (FindIdIntent) -> Unit, + snackbarHostState: SnackbarHostState, + onNextClick: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val emailState = rememberTextFieldState(state.email) + val certificateCodeState = rememberTextFieldState(state.certificateCode) + + // "바뀔 때마다 UpdateEmail Intent 를 보낸다"를 수행하는 장치가 이 블록이다. TextFieldState 에는 콜백이 없고, + // LaunchedEffect 의 key(emailState) 는 객체 동일성 비교라 내용(.text) 변이에는 반응하지 않는다 + // (rememberTextFieldState 인스턴스는 화면 수명 내내 동일 → key 만으론 최초 1회 실행 후 침묵). + // snapshotFlow 가 스냅샷 읽기 추적으로 내용 변이를 리컴포지션 없이 구독해 값이 달라질 때만 흘리고, + // LaunchedEffect 는 그 수집 코루틴의 수명(화면 이탈 시 자동 해제)만 맡는다. (SignUpScreen 선례) + // key=emailState 는 재시작 트리거가 아니라 의존성 선언 — remember 덕에 지금은 인스턴스가 안 갈리지만, + // 갈리는 리팩터링이 오면 옛 구독을 취소하고 새 인스턴스로 갈아타게 하는 보험이다 (Unit 이면 유령 구독). + LaunchedEffect(emailState) { + snapshotFlow { emailState.text.toString() }.collect { onIntent(FindIdIntent.UpdateEmail(it)) } + } + LaunchedEffect(certificateCodeState) { + snapshotFlow { certificateCodeState.text.toString() }.collect { onIntent(FindIdIntent.UpdateCertificateCode(it)) } + } + + val requestCodeText = + when { + state.isSendingCode -> { + stringResource(R.string.onboarding_find_account_code_requesting) + } + + state.resendCooldownSeconds > 0 -> { + stringResource( + R.string.onboarding_find_account_code_resend_cooldown, + state.resendCooldownSeconds, + ) + } + + state.isVerificationSent -> { + stringResource(R.string.onboarding_find_account_code_resend) + } + + else -> { + stringResource(R.string.onboarding_find_account_code_request) + } + } + + FlowStepScaffold( + topBarTitle = stringResource(R.string.onboarding_find_id_title), + actionButtonText = stringResource(R.string.onboarding_find_account_next), + onBackClick = onBackClick, + onActionClick = onNextClick, + modifier = modifier, + isActionEnabled = state.isNextEnabled, + snackbarHostState = snackbarHostState, + ) { + Spacer(modifier = Modifier.height(HeaderSpacing)) + Column( + modifier = Modifier.imePadding(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResource(R.string.onboarding_find_account_verify_email_title), + style = AfternoteDesign.typography.h1, + color = AfternoteDesign.colors.gray9, + ) + Text( + text = stringResource(R.string.onboarding_find_account_verify_email_description), + // 시안 텍스트 스펙 = NanumBarunGothic Regular 12 / 행간 18 → captionLargeR (bodySmallB 는 Bold 14/20 로 불일치) + style = AfternoteDesign.typography.captionLargeR, + color = AfternoteDesign.colors.gray5, + ) + + AfternoteTextField( + state = emailState, + type = + TextFieldType.Variant7( + text = requestCodeText, + onClick = { onIntent(FindIdIntent.RequestVerificationCode) }, + enabled = state.isSendCodeEnabled, + ), + placeholder = stringResource(R.string.onboarding_find_account_email_placeholder), + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Next, + ) + + // 인증번호 필드의 "확인" 은 발송 이력이 있을 때만 노출 (시안 초기 상태엔 없음). + AfternoteTextField( + state = certificateCodeState, + type = + if (state.isVerificationSent) { + TextFieldType.Variant7( + text = stringResource(R.string.onboarding_find_account_code_confirm), + onClick = { onIntent(FindIdIntent.VerifyCode) }, + enabled = state.isVerifyEnabled, + ) + } else { + TextFieldType.Basic + }, + placeholder = stringResource(R.string.onboarding_find_account_code_placeholder), + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done, + onImeAction = { + if (state.isVerifyEnabled) onIntent(FindIdIntent.VerifyCode) + }, + ) + + // 인증번호 불일치는 인라인 에러로, 그 외 실패는 스낵바로 나뉜다 (시안 2431-14204). + if ((state.failure.toDisplay() == OnboardingFailureDisplay.VerificationInline)) { + Text( + text = stringResource(R.string.onboarding_find_account_code_mismatch), + style = AfternoteDesign.typography.captionLargeB, + color = AfternoteDesign.colors.error, + ) + } else if (state.isVerificationSent) { + Text( + text = stringResource(R.string.onboarding_find_account_code_sent), + style = AfternoteDesign.typography.captionLargeB, + color = AfternoteDesign.colors.b1, + ) + } + } + } +} diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindIdContract.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindIdContract.kt new file mode 100644 index 0000000000..3ac984fa1b --- /dev/null +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindIdContract.kt @@ -0,0 +1,67 @@ +package com.afternote.feature.onboarding.presentation.findaccount + +import com.afternote.core.model.FoundAccount +import com.afternote.core.ui.UiText +import com.afternote.core.ui.mvi.MviIntent +import com.afternote.core.ui.mvi.ReducerEvent + +/** 아이디 찾기 화면이 [FindIdViewModel] 에 보내는 것 — 사용자가 하려는 것. */ +internal sealed interface FindIdIntent : MviIntent { + data class UpdateEmail( + val value: String, + ) : FindIdIntent + + data class UpdateCertificateCode( + val value: String, + ) : FindIdIntent + + data object RequestVerificationCode : FindIdIntent + + data object VerifyCode : FindIdIntent + + /** 스낵바로 표시한 [FindIdUiState.failure] 를 되돌린다. */ + data object ConsumeError : FindIdIntent +} + +/** 상태가 겪은 것. [FindIdViewModel] 만 만든다. */ +internal sealed interface FindIdReducerEvent : ReducerEvent { + data class EmailChanged( + val value: String, + ) : FindIdReducerEvent + + data class CertificateCodeChanged( + val value: String, + ) : FindIdReducerEvent + + data object CodeSendStarted : FindIdReducerEvent + + data object CodeSent : FindIdReducerEvent + + data class CodeSendFailed( + val message: UiText, + ) : FindIdReducerEvent + + data object CodeSendFinished : FindIdReducerEvent + + /** 쿨다운을 30초로 재장전한다. */ + data object CooldownReloaded : FindIdReducerEvent + + data object CooldownTicked : FindIdReducerEvent + + data object VerifyStarted : FindIdReducerEvent + + data class AccountFound( + val account: FoundAccount, + ) : FindIdReducerEvent + + /** 인증번호 무효 — 인라인 문구로 알린다. */ + data object VerificationRejected : FindIdReducerEvent + + data class VerifyFailed( + val message: UiText, + ) : FindIdReducerEvent + + data object VerifyFinished : FindIdReducerEvent + + data object ErrorConsumed : FindIdReducerEvent +} diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindIdScreen.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindIdScreen.kt index 85f46830c7..4bf80d13a9 100644 --- a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindIdScreen.kt +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindIdScreen.kt @@ -1,169 +1,61 @@ package com.afternote.feature.onboarding.presentation.findaccount -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.unit.dp -import com.afternote.core.ui.AfternoteTextField -import com.afternote.core.ui.TextFieldType -import com.afternote.core.ui.scaffold.FlowStepScaffold -import com.afternote.core.ui.theme.AfternoteDesign -import com.afternote.feature.onboarding.presentation.R - -private val HeaderSpacing = 8.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.afternote.core.ui.asString +import com.afternote.core.ui.mvi.ObserveSignal +import com.afternote.feature.onboarding.presentation.snackbarMessage +import com.afternote.feature.onboarding.presentation.toDisplay +import kotlinx.coroutines.launch /** - * 아이디 찾기 1단계 — 이메일 인증. + * 아이디 찾기 1단계 — 이메일 인증. stateful 층. + * + * ViewModel 은 `Route.Onboarding` 그래프 스코프 공유라 여기서 만들지 않고 받는다 + * (결과 화면·비밀번호 찾기가 같은 인스턴스를 이어받는다). * - * 인증번호 "확인" 이 곧 조회라(`auth/email/find` 가 인증번호를 함께 받는다) 확인에 성공해야 - * "다음" 이 열리고, 결과 화면은 VM 에 담긴 계정을 읽는다. + * 인증번호 무효는 시안상 인라인 문구라 [FindIdUiState.failure] 로 화면이 직접 그리고, + * 그 밖의 실패([FindIdUiState.failure])만 스낵바로 나른다. */ @Composable -fun FindIdScreen( - initialEmail: String, - initialCertificateCode: String, - isSendingCode: Boolean, - isVerificationSent: Boolean, - isSendCodeEnabled: Boolean, - isVerifyEnabled: Boolean, - isNextEnabled: Boolean, - resendCooldownSeconds: Int, - hasVerificationError: Boolean, - snackbarHostState: SnackbarHostState, - onEmailChange: (String) -> Unit, - onCertificateCodeChange: (String) -> Unit, - onRequestCode: () -> Unit, - onVerifyCode: () -> Unit, +internal fun FindIdScreen( + viewModel: FindIdViewModel, onNextClick: () -> Unit, onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { - val emailState = rememberTextFieldState(initialEmail) - val certificateCodeState = rememberTextFieldState(initialCertificateCode) - - // "바뀔 때마다 onEmailChange 호출"을 수행하는 장치가 이 블록이다. TextFieldState 에는 콜백이 없고, - // LaunchedEffect 의 key(emailState) 는 객체 동일성 비교라 내용(.text) 변이에는 반응하지 않는다 - // (rememberTextFieldState 인스턴스는 화면 수명 내내 동일 → key 만으론 최초 1회 실행 후 침묵). - // snapshotFlow 가 스냅샷 읽기 추적으로 내용 변이를 리컴포지션 없이 구독해 값이 달라질 때만 흘리고, - // LaunchedEffect 는 그 수집 코루틴의 수명(화면 이탈 시 자동 해제)만 맡는다. (SignUpScreen 선례) - // key=emailState 는 재시작 트리거가 아니라 의존성 선언 — remember 덕에 지금은 인스턴스가 안 갈리지만, - // 갈리는 리팩터링이 오면 옛 구독을 취소하고 새 인스턴스로 갈아타게 하는 보험이다 (Unit 이면 유령 구독). - LaunchedEffect(emailState) { - snapshotFlow { emailState.text.toString() }.collect(onEmailChange) - } - LaunchedEffect(certificateCodeState) { - snapshotFlow { certificateCodeState.text.toString() }.collect(onCertificateCodeChange) - } - - val requestCodeText = - when { - isSendingCode -> { - stringResource(R.string.onboarding_find_account_code_requesting) - } - - resendCooldownSeconds > 0 -> { - stringResource( - R.string.onboarding_find_account_code_resend_cooldown, - resendCooldownSeconds, - ) - } - - isVerificationSent -> { - stringResource(R.string.onboarding_find_account_code_resend) - } - - else -> { - stringResource(R.string.onboarding_find_account_code_request) - } + val state by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + + ObserveSignal( + // VM 이 UiText 로 폴백까지 확정해 두므로 빈 문구가 도달하지 않는다. + signal = + state.failure + .toDisplay() + .snackbarMessage + ?.asString(), + consumed = FindIdIntent.ConsumeError, + onIntent = viewModel::onIntent, + ) { message -> + // 소비가 곧바로 신호를 되돌려 effect 를 재시작시키므로, 표출은 effect 밖 스코프에 맡긴다. + scope.launch { + snackbarHostState.showSnackbar(message = message, duration = SnackbarDuration.Short) } + } - FlowStepScaffold( - topBarTitle = stringResource(R.string.onboarding_find_id_title), - actionButtonText = stringResource(R.string.onboarding_find_account_next), + FindIdContent( + state = state, + onIntent = viewModel::onIntent, + snackbarHostState = snackbarHostState, + onNextClick = onNextClick, onBackClick = onBackClick, - onActionClick = onNextClick, modifier = modifier, - isActionEnabled = isNextEnabled, - snackbarHostState = snackbarHostState, - ) { - Spacer(modifier = Modifier.height(HeaderSpacing)) - Column( - modifier = Modifier.imePadding(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = stringResource(R.string.onboarding_find_account_verify_email_title), - style = AfternoteDesign.typography.h1, - color = AfternoteDesign.colors.gray9, - ) - Text( - text = stringResource(R.string.onboarding_find_account_verify_email_description), - // 시안 텍스트 스펙 = NanumBarunGothic Regular 12 / 행간 18 → captionLargeR (bodySmallB 는 Bold 14/20 로 불일치) - style = AfternoteDesign.typography.captionLargeR, - color = AfternoteDesign.colors.gray5, - ) - - AfternoteTextField( - state = emailState, - type = - TextFieldType.Variant7( - text = requestCodeText, - onClick = onRequestCode, - enabled = isSendCodeEnabled, - ), - placeholder = stringResource(R.string.onboarding_find_account_email_placeholder), - keyboardType = KeyboardType.Email, - imeAction = ImeAction.Next, - ) - - // 인증번호 필드의 "확인" 은 발송 이력이 있을 때만 노출 (시안 초기 상태엔 없음). - AfternoteTextField( - state = certificateCodeState, - type = - if (isVerificationSent) { - TextFieldType.Variant7( - text = stringResource(R.string.onboarding_find_account_code_confirm), - onClick = onVerifyCode, - enabled = isVerifyEnabled, - ) - } else { - TextFieldType.Basic - }, - placeholder = stringResource(R.string.onboarding_find_account_code_placeholder), - keyboardType = KeyboardType.Number, - imeAction = ImeAction.Done, - onImeAction = { - if (isVerifyEnabled) onVerifyCode() - }, - ) - - // 인증번호 불일치는 인라인 에러로, 그 외 실패는 스낵바로 나뉜다 (시안 2431-14204). - if (hasVerificationError) { - Text( - text = stringResource(R.string.onboarding_find_account_code_mismatch), - style = AfternoteDesign.typography.captionLargeB, - color = AfternoteDesign.colors.error, - ) - } else if (isVerificationSent) { - Text( - text = stringResource(R.string.onboarding_find_account_code_sent), - style = AfternoteDesign.typography.captionLargeB, - color = AfternoteDesign.colors.b1, - ) - } - } - } + ) } diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindIdUiState.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindIdUiState.kt index 0278235f41..49db060175 100644 --- a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindIdUiState.kt +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindIdUiState.kt @@ -2,6 +2,7 @@ package com.afternote.feature.onboarding.presentation.findaccount import android.util.Patterns import com.afternote.core.model.FoundAccount +import com.afternote.core.ui.mvi.UiState import com.afternote.feature.onboarding.presentation.OnboardingFailure /** @@ -25,7 +26,7 @@ internal data class FindIdUiState( val resendCooldownSeconds: Int = 0, val foundAccount: FoundAccount? = null, val failure: OnboardingFailure? = null, -) { +) : UiState { /** * 이메일 형식 검사. [Patterns.EMAIL_ADDRESS] 는 컴파일된 정규식(`Pattern`) 상수라 * `matcher(입력)` 으로 그 문자열 전용 실행기를 만든 뒤 `matches()`(**전체 일치** — 부분 검색 diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindIdViewModel.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindIdViewModel.kt index 154ea5e048..b1c6b9c383 100644 --- a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindIdViewModel.kt +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/findaccount/FindIdViewModel.kt @@ -1,10 +1,10 @@ package com.afternote.feature.onboarding.presentation.findaccount -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.domain.error.CoreAuthFailure import com.afternote.core.domain.repository.account.AccountRepository +import com.afternote.core.ui.mvi.MviViewModel import com.afternote.feature.onboarding.presentation.OnboardingFailure import com.afternote.feature.onboarding.presentation.R import com.afternote.feature.onboarding.presentation.reporting.AuthFailureStage @@ -13,10 +13,6 @@ import com.afternote.feature.onboarding.presentation.toDisplayMessage import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject import kotlin.coroutines.cancellation.CancellationException @@ -28,6 +24,9 @@ import kotlin.time.Duration.Companion.milliseconds * 회원가입 플로우가 이미 같은 방식이다). * * `TextFieldState` 는 Screen 이 소유하고 VM 은 String 만 들고 있는다. + * + * 전이는 [reduce] 한 곳이고 진입점은 [onIntent] 하나다 (#1802). 기준은 + * `docs/convention/mvi.md`. */ @HiltViewModel internal class FindIdViewModel @@ -35,44 +34,103 @@ internal class FindIdViewModel constructor( private val accountRepository: AccountRepository, private val errorReporter: ErrorReporter, - ) : ViewModel() { - private val _uiState = MutableStateFlow(FindIdUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - + ) : MviViewModel(FindIdUiState()) { private var cooldownJob: Job? = null - fun updateEmail(value: String) = - _uiState.update { - // 이메일이 바뀌면 앞서 받은 계정·에러는 더 이상 그 이메일의 것이 아니다. - it.copy(email = value, foundAccount = null, failure = it.failure.takeIf { _ -> it.email == value }) + override fun onIntent(intent: FindIdIntent) { + when (intent) { + is FindIdIntent.UpdateEmail -> dispatch(FindIdReducerEvent.EmailChanged(intent.value)) + is FindIdIntent.UpdateCertificateCode -> dispatch(FindIdReducerEvent.CertificateCodeChanged(intent.value)) + FindIdIntent.RequestVerificationCode -> requestVerificationCode() + FindIdIntent.VerifyCode -> verifyCode() + FindIdIntent.ConsumeError -> dispatch(FindIdReducerEvent.ErrorConsumed) } + } + + override fun reduce( + state: FindIdUiState, + event: FindIdReducerEvent, + ): FindIdUiState = + when (event) { + // 이메일이 바뀌면 앞서 받은 계정·에러는 더 이상 그 이메일의 것이 아니다. + is FindIdReducerEvent.EmailChanged -> { + state.copy(email = event.value, foundAccount = null, failure = state.failure.takeIf { state.email == event.value }) + } + + is FindIdReducerEvent.CertificateCodeChanged -> { + state.copy(certificateCode = event.value, failure = state.failure.takeIf { state.certificateCode == event.value }) + } + + FindIdReducerEvent.CodeSendStarted -> { + state.copy(isSendingCode = true) + } + + FindIdReducerEvent.CodeSent -> { + state.copy(isVerificationSent = true, failure = null) + } + + is FindIdReducerEvent.CodeSendFailed -> { + state.copy(failure = OnboardingFailure.RequestFailed(event.message)) + } + + FindIdReducerEvent.CodeSendFinished -> { + state.copy(isSendingCode = false) + } + + FindIdReducerEvent.CooldownReloaded -> { + state.copy(resendCooldownSeconds = RESEND_COOLDOWN_SECONDS) + } - fun updateCertificateCode(value: String) = - _uiState.update { - it.copy(certificateCode = value, failure = it.failure.takeIf { _ -> it.certificateCode == value }) + FindIdReducerEvent.CooldownTicked -> { + state.copy(resendCooldownSeconds = state.resendCooldownSeconds - 1) + } + + FindIdReducerEvent.VerifyStarted -> { + state.copy(isVerifying = true, failure = null) + } + + is FindIdReducerEvent.AccountFound -> { + state.copy(foundAccount = event.account) + } + + FindIdReducerEvent.VerificationRejected -> { + state.copy(failure = OnboardingFailure.VerificationRejected) + } + + is FindIdReducerEvent.VerifyFailed -> { + state.copy(failure = OnboardingFailure.RequestFailed(event.message)) + } + + FindIdReducerEvent.VerifyFinished -> { + state.copy(isVerifying = false) + } + + FindIdReducerEvent.ErrorConsumed -> { + state.copy(failure = state.failure.takeUnless { it is OnboardingFailure.RequestFailed }) + } } - fun requestVerificationCode() { - val state = _uiState.value + private fun requestVerificationCode() { + val state = currentState if (!state.isSendCodeEnabled) return viewModelScope.launch { - _uiState.update { it.copy(isSendingCode = true) } + dispatch(FindIdReducerEvent.CodeSendStarted) accountRepository .sendFindCode(state.email) .onSuccess { - _uiState.update { it.copy(isVerificationSent = true, failure = null) } + dispatch(FindIdReducerEvent.CodeSent) startResendCooldown() }.onFailure { error -> // 취소는 장애가 아니다 — 기록·UI 소비 전에 되던져 전파를 보존한다(전수 정정은 #661). if (error is CancellationException) throw error errorReporter.recordAuthFailure(AuthFailureStage.FIND_ACCOUNT_CODE_SEND, error) - _uiState.update { - it.copy( - failure = OnboardingFailure.RequestFailed(error.toDisplayMessage(R.string.onboarding_find_account_failed)), - ) - } + dispatch( + FindIdReducerEvent.CodeSendFailed( + error.toDisplayMessage(R.string.onboarding_find_account_failed), + ), + ) } - _uiState.update { it.copy(isSendingCode = false) } + dispatch(FindIdReducerEvent.CodeSendFinished) } } @@ -85,43 +143,35 @@ internal class FindIdViewModel * 필드 아래 인라인 문구이고, 그 밖의 실패는 사용자가 입력으로 고칠 수 없어 스낵바로 보낸다. * 갈래를 나누지 않으면 네트워크 실패에도 "인증번호가 일치하지 않습니다" 가 뜬다. */ - fun verifyCode() { - val state = _uiState.value + private fun verifyCode() { + val state = currentState if (!state.isVerifyEnabled) return viewModelScope.launch { - _uiState.update { it.copy(isVerifying = true, failure = null) } + dispatch(FindIdReducerEvent.VerifyStarted) accountRepository .findAccount(state.email, state.certificateCode) .onSuccess { account -> - _uiState.update { it.copy(foundAccount = account) } + dispatch(FindIdReducerEvent.AccountFound(account)) }.onFailure { error -> // 취소는 장애가 아니다 — 여기는 계측 대상이 아니지만 실패 UI 로 소비하는 것도 // 막아야 해서 되던진다(전수 정정은 #661). if (error is CancellationException) throw error // 계측하지 않는다 — 인증번호 오타는 사용자의 정상적인 입력 실수다. // 자세한 사유는 AuthFailureStage.FIND_ACCOUNT_CODE_SEND KDoc. - _uiState.update { - if (error is CoreAuthFailure.EmailVerification) { - it.copy(failure = OnboardingFailure.VerificationRejected) - } else { - it.copy( - failure = - OnboardingFailure.RequestFailed( - error.toDisplayMessage(R.string.onboarding_find_account_failed), - ), - ) - } + if (error is CoreAuthFailure.EmailVerification) { + dispatch(FindIdReducerEvent.VerificationRejected) + } else { + dispatch( + FindIdReducerEvent.VerifyFailed( + error.toDisplayMessage(R.string.onboarding_find_account_failed), + ), + ) } } - _uiState.update { it.copy(isVerifying = false) } + dispatch(FindIdReducerEvent.VerifyFinished) } } - fun onErrorConsumed() = - _uiState.update { - it.copy(failure = it.failure.takeUnless { failure -> failure is OnboardingFailure.RequestFailed }) - } - private fun startResendCooldown() { // 중복 방지가 아니라 last-wins 재장전 — 이전 카운트다운을 폐기하고 30초를 새로 센다. // 호출부 가드(isSendCodeEnabled)상 보통 이전 잡은 끝나 있지만, "쿨다운 잡 최대 1개" 를 @@ -129,10 +179,10 @@ internal class FindIdViewModel cooldownJob?.cancel() cooldownJob = viewModelScope.launch { - _uiState.update { it.copy(resendCooldownSeconds = RESEND_COOLDOWN_SECONDS) } - while (_uiState.value.resendCooldownSeconds > 0) { + dispatch(FindIdReducerEvent.CooldownReloaded) + while (currentState.resendCooldownSeconds > 0) { delay(MILLIS_PER_SECOND.milliseconds) - _uiState.update { it.copy(resendCooldownSeconds = it.resendCooldownSeconds - 1) } + dispatch(FindIdReducerEvent.CooldownTicked) } } } diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/LoginContent.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/LoginContent.kt new file mode 100644 index 0000000000..097c938d5d --- /dev/null +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/LoginContent.kt @@ -0,0 +1,329 @@ +package com.afternote.feature.onboarding.presentation.login + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +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.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.ContentType +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentType +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import com.afternote.core.ui.AfternoteTextField +import com.afternote.core.ui.button.AfternoteButton +import com.afternote.core.ui.button.AfternoteButtonType +import com.afternote.core.ui.modifierextention.addFocusCleaner +import com.afternote.core.ui.popup.NetworkErrorPopup +import com.afternote.core.ui.theme.AfternoteDesign +import com.afternote.core.ui.topbar.DetailTopBar +import com.afternote.feature.onboarding.presentation.OnboardingFailureDisplay +import com.afternote.feature.onboarding.presentation.R +import com.afternote.feature.onboarding.presentation.toDisplay + +/** + * 로그인 화면 — stateless 층. 프리뷰·screenshotTest·Robolectric 의 진입점이다. + * + * 소셜 로그인 콜백만 파라미터로 남는다. 토큰을 받아오는 일이 Activity·CredentialManager + * 의존이라 [LoginScreen] 이 하고, ViewModel 에는 문자열만 Intent 로 들어간다. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun LoginContent( + state: LoginUiState, + onIntent: (LoginIntent) -> Unit, + onSignUpClick: () -> Unit, + onFindAccountClick: () -> Unit, + onKakaoLoginClick: () -> Unit, + onGoogleLoginClick: () -> Unit, + onBackClick: () -> Unit, + snackbarHostState: SnackbarHostState, + modifier: Modifier = Modifier, +) { + val focusManager = LocalFocusManager.current + val emailState = rememberTextFieldState(state.email) + val passwordState = rememberTextFieldState(state.password) + + LaunchedEffect(emailState) { + snapshotFlow { emailState.text.toString() }.collect { onIntent(LoginIntent.UpdateEmail(it)) } + } + LaunchedEffect(passwordState) { + snapshotFlow { passwordState.text.toString() }.collect { onIntent(LoginIntent.UpdatePassword(it)) } + } + Scaffold( + modifier = modifier, + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + topBar = { + DetailTopBar( + title = stringResource(R.string.onboarding_login_top_bar_title), + onBackClick = { + focusManager.clearFocus() + onBackClick() + }, + ) + }, + bottomBar = { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 45.dp), + ) { + SocialLoginGroup( + onSignUpClick = onSignUpClick, + onFindAccountClick = onFindAccountClick, + onKakaoLoginClick = onKakaoLoginClick, + onGoogleLoginClick = onGoogleLoginClick, + ) + } + }, + containerColor = Color.Transparent, + ) { innerPadding -> + Column( + modifier = + Modifier + .padding(innerPadding) + .consumeWindowInsets(innerPadding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .imePadding() + .addFocusCleaner(focusManager) + .padding(horizontal = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(39.dp)) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // 아이디 (이메일) 입력 필드 + AfternoteTextField( + state = emailState, + modifier = + Modifier.semantics { contentType = ContentType.Username }, + placeholder = stringResource(R.string.onboarding_login_email_label), + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Next, + ) + + // 비밀번호 필드와 인라인 안내(시안 3628:23437)를 6dp 로 묶는다 — 바깥 8dp 와 달라 + // 별도 Column. 문구 스타일은 아이디 찾기 인라인(FindIdScreen)과 동일 토큰. + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + AfternoteTextField( + state = passwordState, + modifier = + Modifier.semantics { contentType = ContentType.Password }, + placeholder = stringResource(R.string.onboarding_login_password_label), + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + onImeAction = { + if (!state.isLoading) { + focusManager.clearFocus() + onIntent(LoginIntent.SubmitEmailLogin) + } + }, + isError = (state.failure.toDisplay() == OnboardingFailureDisplay.CredentialInline), + ) + + if ((state.failure.toDisplay() == OnboardingFailureDisplay.CredentialInline)) { + Text( + text = stringResource(R.string.onboarding_login_invalid_credentials), + style = AfternoteDesign.typography.captionLargeB, + color = AfternoteDesign.colors.error, + ) + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // 로그인 버튼 + AfternoteButton( + text = stringResource(R.string.onboarding_login_button), + onClick = { + if (!state.isLoading) { + focusManager.clearFocus() + onIntent(LoginIntent.SubmitEmailLogin) + } + }, + modifier = + Modifier + .fillMaxWidth() + .height(48.dp), + type = AfternoteButtonType.Default, + isLoading = state.isLoading, + ) + } + } + + if ((state.failure.toDisplay() == OnboardingFailureDisplay.NetworkRetryPopup)) { + NetworkErrorPopup( + onRetry = { onIntent(LoginIntent.RetryLogin) }, + onDismiss = { onIntent(LoginIntent.DismissNetworkError) }, + ) + } +} + +/** + * 로그인 화면 하단 소셜 로그인 그룹. + * + * `LoginScreen`에서만 쓰이므로 외부 파일로 분리하지 않고 `private`로 캡슐화한다. + */ +@Composable +private fun SocialLoginGroup( + onSignUpClick: () -> Unit, + onFindAccountClick: () -> Unit, + onKakaoLoginClick: () -> Unit, + onGoogleLoginClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val focusManager = LocalFocusManager.current + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // 간편 회원가입하기 버튼 + AfternoteButton( + text = stringResource(R.string.onboarding_login_signup_simple), + type = AfternoteButtonType.Plain, + onClick = { + focusManager.clearFocus() + onSignUpClick() + }, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // 카카오 로그인 버튼 — 배경·심볼만 브랜드 고정색, 프레임 규격은 구글·이메일 버튼과 동일. + SocialLoginButton( + text = stringResource(R.string.onboarding_login_kakao), + iconPainter = painterResource(R.drawable.onboarding_ic_kakao_symbol), + containerColor = KakaoContainerColor, + contentColor = KakaoContentColor, + onClick = { + focusManager.clearFocus() + onKakaoLoginClick() + }, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // 구글 로그인 버튼 — 색은 회원가입 버튼(AfternoteButtonType.Plain)과 같은 gray2/gray9/gray3. + SocialLoginButton( + text = stringResource(R.string.onboarding_login_google), + iconPainter = painterResource(com.afternote.core.ui.R.drawable.core_ui_img_google_logo), + containerColor = AfternoteDesign.colors.gray2, + contentColor = AfternoteDesign.colors.gray9, + border = BorderStroke(1.dp, AfternoteDesign.colors.gray3), + onClick = { + focusManager.clearFocus() + onGoogleLoginClick() + }, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // 아이디/비밀번호 찾기 + Text( + text = stringResource(R.string.onboarding_login_find_account), + style = + AfternoteDesign.typography.captionLargeR.copy( + color = AfternoteDesign.colors.gray6, + textDecoration = TextDecoration.Underline, + ), + modifier = + Modifier.clickable { + focusManager.clearFocus() + onFindAccountClick() + }, + ) + } +} + +/** 카카오 로그인 버튼 배경 — 카카오 브랜드 고정색이라 앱 팔레트 토큰으로 근사하지 않는다. */ +private val KakaoContainerColor = Color(0xFFFEE500) + +/** 카카오 로그인 버튼 라벨 색 — 심볼 벡터(onboarding_ic_kakao_symbol)와 같은 #212121·알파 90.2%. */ +private val KakaoContentColor = Color(0xE6212121) + +/** + * 소셜 로그인 버튼 공통 프레임 — 카카오·구글이 색과 아이콘만 다르고 같은 규격을 쓰도록 강제한다. + * + * 시안의 두 버튼은 출처가 달라(카카오 공식 에셋 이미지 vs 별도 컴포넌트) 셰이프·타이포·아이콘 + * 배치가 제각각이었고, 통일 판단은 FE 재량으로 확정된 건이다(#775). 규격은 이 화면의 다른 + * 버튼([AfternoteButton])과 동일한 6dp 셰이프·48dp 높이·captionLargeB 라벨로 수렴시킨다. + * 아이콘은 장식이라 contentDescription 없이 라벨 [text] 가 접근성 이름을 담당한다. + */ +@Composable +private fun SocialLoginButton( + text: String, + iconPainter: Painter, + containerColor: Color, + contentColor: Color, + onClick: () -> Unit, + modifier: Modifier = Modifier, + border: BorderStroke? = null, +) { + Surface( + onClick = onClick, + modifier = + modifier + .fillMaxWidth() + .height(48.dp), + shape = RoundedCornerShape(6.dp), + color = containerColor, + contentColor = contentColor, + border = border, + ) { + Row( + modifier = Modifier.fillMaxSize(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Image( + painter = iconPainter, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = text, + style = AfternoteDesign.typography.captionLargeB, + ) + } + } +} diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/LoginContract.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/LoginContract.kt new file mode 100644 index 0000000000..3812083275 --- /dev/null +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/LoginContract.kt @@ -0,0 +1,87 @@ +package com.afternote.feature.onboarding.presentation.login + +import com.afternote.core.ui.UiText +import com.afternote.core.ui.mvi.MviIntent +import com.afternote.core.ui.mvi.ReducerEvent +import com.afternote.feature.onboarding.presentation.reporting.AuthProvider + +/** 로그인 화면이 [LoginViewModel] 에 보내는 것 — 사용자가 하려는 것. */ +internal sealed interface LoginIntent : MviIntent { + data class UpdateEmail( + val value: String, + ) : LoginIntent + + data class UpdatePassword( + val value: String, + ) : LoginIntent + + data object SubmitEmailLogin : LoginIntent + + /** + * 소셜 SDK 가 돌려준 토큰으로 로그인한다. 토큰을 받아오는 일은 Activity·CredentialManager + * 의존이라 화면이 하고, ViewModel 에는 문자열만 온다. + */ + data class SubmitKakaoLogin( + val oauthToken: String, + ) : LoginIntent + + data class SubmitGoogleLogin( + val idToken: String, + ) : LoginIntent + + /** + * 소셜 SDK 에서 토큰을 받아오지 못한 실패를 기록한다. + * + * 이 단계는 서버 호출 이전이라 로그인 경로를 타지 않아, 화면이 알려주지 않으면 콘솔에 아무 + * 흔적도 남지 않는다. 사용자 취소는 오류가 아니므로 화면이 걸러서 보낸다. + */ + data class ReportSocialTokenFailure( + val provider: AuthProvider, + val throwable: Throwable, + ) : LoginIntent + + /** 네트워크 실패 팝업의 "다시 시도하기". */ + data object RetryLogin : LoginIntent + + data object DismissNetworkError : LoginIntent + + data object ConsumeLoggedIn : LoginIntent + + data object ConsumeOnboardingStart : LoginIntent + + data object ConsumeError : LoginIntent +} + +/** 상태가 겪은 것. [LoginViewModel] 만 만든다. */ +internal sealed interface LoginReducerEvent : ReducerEvent { + data class EmailChanged( + val value: String, + ) : LoginReducerEvent + + data class PasswordChanged( + val value: String, + ) : LoginReducerEvent + + data object LoginStarted : LoginReducerEvent + + data object LoggedIn : LoginReducerEvent + + /** 소셜 신규 가입자 — 온보딩으로 보낸다. */ + data object OnboardingRequired : LoginReducerEvent + + data object CredentialRejected : LoginReducerEvent + + data object NetworkErrorRaised : LoginReducerEvent + + data class LoginFailed( + val message: UiText, + ) : LoginReducerEvent + + data object NetworkErrorDismissed : LoginReducerEvent + + data object LoggedInConsumed : LoginReducerEvent + + data object OnboardingStartConsumed : LoginReducerEvent + + data object ErrorConsumed : LoginReducerEvent +} diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/LoginEntry.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/LoginEntry.kt deleted file mode 100644 index be867fe10e..0000000000 --- a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/LoginEntry.kt +++ /dev/null @@ -1,183 +0,0 @@ -package com.afternote.feature.onboarding.presentation.login - -import android.app.Activity -import androidx.compose.material3.SnackbarDuration -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.platform.LocalFocusManager -import androidx.compose.ui.platform.LocalResources -import androidx.compose.ui.res.stringResource -import androidx.credentials.CredentialManager -import androidx.credentials.exceptions.NoCredentialException -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.afternote.core.domain.error.CoreAuthFailure -import com.afternote.core.ui.asString -import com.afternote.core.ui.findActivity -import com.afternote.feature.onboarding.presentation.BuildConfig -import com.afternote.feature.onboarding.presentation.OnboardingFailureDisplay -import com.afternote.feature.onboarding.presentation.R -import com.afternote.feature.onboarding.presentation.displayMessageResOrFallback -import com.afternote.feature.onboarding.presentation.login.social.requestGoogleIdToken -import com.afternote.feature.onboarding.presentation.login.social.requestKakaoAccessToken -import com.afternote.feature.onboarding.presentation.reporting.AuthProvider -import com.afternote.feature.onboarding.presentation.snackbarMessage -import com.afternote.feature.onboarding.presentation.toDisplay -import kotlinx.coroutines.launch - -/** - * 로그인 Entry. - * - * ViewModel 주입·이벤트 수집을 전담하고, Screen에 순수 상태만 전달합니다. - * 소셜 로그인 SDK(카카오) 및 Credential Manager(구글)는 Activity/Context에 - * 강하게 의존하므로 UI 레이어(Entry)에서 처리한 뒤 순수 토큰 문자열만 - * ViewModel로 전달하여 ViewModel과 Data 레이어의 플랫폼 독립성을 보장합니다. - */ -@Composable -internal fun LoginEntry( - onLoginSuccess: () -> Unit, - onNewUserOnboarding: () -> Unit, - onSignUpClick: () -> Unit, - onFindAccountClick: () -> Unit, - onBackClick: () -> Unit, - modifier: Modifier = Modifier, - viewModel: LoginViewModel = hiltViewModel(), -) { - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val focusManager = LocalFocusManager.current - val snackbarHostState = remember { SnackbarHostState() } - val coroutineScope = rememberCoroutineScope() - val context = LocalContext.current - val credentialManager = remember(context) { CredentialManager.create(context) } - - // 코루틴 콜백 안에서 여는 문구는 `LocalResources` 로 조회한다 — `LocalContext.current` 경유 - // `getString` 은 로케일·구성이 바뀌어도 옛 문자열을 물고, lint 가 막는다 - // (LocalContextGetResourceValueCall). 여기 `context` 는 Activity·Credential Manager 용이다. - val resources = LocalResources.current - - val googleNoCredentialsMessage = stringResource(R.string.onboarding_login_google_no_credentials) - val screenUnavailableMessage = stringResource(R.string.onboarding_login_screen_unavailable) - - val showErrorSnackbar: (String) -> Unit = { message -> - coroutineScope.launch { - snackbarHostState.showSnackbar( - message = message, - duration = SnackbarDuration.Short, - ) - } - } - - val withClearFocus: (() -> Unit) -> Unit = { action -> - focusManager.clearFocus() - action() - } - - LaunchedEffect(uiState.isLoggedIn) { - if (uiState.isLoggedIn) { - onLoginSuccess() - viewModel.onLoggedInConsumed() - } - } - LaunchedEffect(uiState.shouldStartOnboarding) { - if (uiState.shouldStartOnboarding) { - onNewUserOnboarding() - viewModel.onOnboardingStartConsumed() - } - } - val snackbarMessage = - uiState.failure - .toDisplay() - .snackbarMessage - ?.asString() - LaunchedEffect(snackbarMessage) { - if (snackbarMessage != null) { - showErrorSnackbar(snackbarMessage) - viewModel.onErrorConsumed() - } - } - - LoginScreen( - initialEmail = uiState.email, - initialPassword = uiState.password, - onEmailChange = viewModel::updateEmail, - onPasswordChange = viewModel::updatePassword, - onLoginClick = { withClearFocus { viewModel.loginWithEmail() } }, - onSignUpClick = { withClearFocus { onSignUpClick() } }, - onFindAccountClick = { withClearFocus { onFindAccountClick() } }, - onKakaoLoginClick = { - withClearFocus { - val activity = context.findActivity() - if (activity != null) { - coroutineScope.launch { - requestKakaoAccessToken(activity) - .onSuccess { oauthToken -> - viewModel.loginWithKakao(oauthToken) - }.onFailure { exception -> - // 카카오 동의 화면·계정 로그인 창을 사용자가 닫은 경우(ClientErrorCause.Cancelled). - // 장애가 아니라 정상적인 이탈이므로 리포팅하지 않는다. - if (exception is CoreAuthFailure.UserCancelledAuth) return@onFailure - viewModel.onSocialTokenRequestFailed(AuthProvider.KAKAO, exception) - showErrorSnackbar( - resources.getString( - exception.displayMessageResOrFallback(R.string.onboarding_login_kakao_failed), - ), - ) - } - } - } else { - showErrorSnackbar(screenUnavailableMessage) - } - } - }, - onGoogleLoginClick = { - withClearFocus { - coroutineScope.launch { - requestGoogleIdToken( - context = context, - credentialManager = credentialManager, - serverClientId = BuildConfig.GOOGLE_WEB_CLIENT_ID, - ).onSuccess { idToken -> - viewModel.loginWithGoogle(idToken) - }.onFailure { exception -> - val message = - when (exception) { - // 계정 선택 시트를 사용자가 닫은 경우(GetCredentialCancellationException, - // 내부 타입 TYPE_USER_CANCELED). 정상적인 이탈이라 리포팅하지 않는다. - is CoreAuthFailure.UserCancelledAuth -> { - return@onFailure - } - - // 반면 이건 취소가 아니라 "쓸 계정이 없어 로그인 불가"라 리포팅 대상이다 — - // 배포본 소셜 로그인 불능을 잡아내려면 이쪽이 신호여야 한다. - is NoCredentialException -> { - googleNoCredentialsMessage - } - - else -> { - resources.getString( - exception.displayMessageResOrFallback(R.string.onboarding_login_google_failed), - ) - } - } - viewModel.onSocialTokenRequestFailed(AuthProvider.GOOGLE, exception) - showErrorSnackbar(message) - } - } - } - }, - onBackClick = { withClearFocus { onBackClick() } }, - snackbarHostState = snackbarHostState, - modifier = modifier, - isLoading = uiState.isLoading, - hasCredentialError = (uiState.failure.toDisplay() == OnboardingFailureDisplay.CredentialInline), - showNetworkErrorPopup = (uiState.failure.toDisplay() == OnboardingFailureDisplay.NetworkRetryPopup), - onRetryLogin = viewModel::retryLogin, - onNetworkErrorDismiss = viewModel::onNetworkErrorDismissed, - ) -} diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/LoginScreen.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/LoginScreen.kt index 5c9d6ced09..a6cb6fa56a 100644 --- a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/LoginScreen.kt +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/LoginScreen.kt @@ -1,329 +1,169 @@ package com.afternote.feature.onboarding.presentation.login -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.Image -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.Spacer -import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.input.rememberTextFieldState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost +import android.app.Activity +import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Alignment +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier -import androidx.compose.ui.autofill.ContentType -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.res.painterResource +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.contentType -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.unit.dp -import com.afternote.core.ui.AfternoteTextField -import com.afternote.core.ui.button.AfternoteButton -import com.afternote.core.ui.button.AfternoteButtonType -import com.afternote.core.ui.modifierextention.addFocusCleaner -import com.afternote.core.ui.popup.NetworkErrorPopup -import com.afternote.core.ui.theme.AfternoteDesign -import com.afternote.core.ui.topbar.DetailTopBar +import androidx.credentials.CredentialManager +import androidx.credentials.exceptions.NoCredentialException +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.afternote.core.domain.error.CoreAuthFailure +import com.afternote.core.ui.asString +import com.afternote.core.ui.findActivity +import com.afternote.core.ui.mvi.ObserveFlag +import com.afternote.core.ui.mvi.ObserveSignal +import com.afternote.feature.onboarding.presentation.BuildConfig import com.afternote.feature.onboarding.presentation.R +import com.afternote.feature.onboarding.presentation.displayMessageResOrFallback +import com.afternote.feature.onboarding.presentation.login.social.requestGoogleIdToken +import com.afternote.feature.onboarding.presentation.login.social.requestKakaoAccessToken +import com.afternote.feature.onboarding.presentation.reporting.AuthProvider +import com.afternote.feature.onboarding.presentation.snackbarMessage +import com.afternote.feature.onboarding.presentation.toDisplay +import kotlinx.coroutines.launch -@OptIn(ExperimentalMaterial3Api::class) +/** + * 로그인 화면 — stateful 층. + * + * ViewModel 주입과 일회성 신호 소비를 전담하고, [LoginContent] 에는 상태와 `onIntent` 만 넘긴다. + * 소셜 로그인 SDK(카카오)와 Credential Manager(구글)는 Activity·Context 에 강하게 의존하므로 + * 여기서 토큰을 받아낸 뒤 문자열만 Intent 로 보낸다 — ViewModel·data 계층의 플랫폼 독립성을 + * 그대로 유지한다. + */ @Composable -fun LoginScreen( - initialEmail: String, - initialPassword: String, - onEmailChange: (String) -> Unit, - onPasswordChange: (String) -> Unit, - onLoginClick: () -> Unit, +internal fun LoginScreen( + onLoginSuccess: () -> Unit, + onNewUserOnboarding: () -> Unit, onSignUpClick: () -> Unit, onFindAccountClick: () -> Unit, - onKakaoLoginClick: () -> Unit, - onGoogleLoginClick: () -> Unit, - onRetryLogin: () -> Unit, - onNetworkErrorDismiss: () -> Unit, onBackClick: () -> Unit, - snackbarHostState: SnackbarHostState, modifier: Modifier = Modifier, - isLoading: Boolean = false, - hasCredentialError: Boolean = false, - showNetworkErrorPopup: Boolean = false, + viewModel: LoginViewModel = hiltViewModel(), ) { - val focusManager = LocalFocusManager.current - val emailState = rememberTextFieldState(initialEmail) - val passwordState = rememberTextFieldState(initialPassword) - - LaunchedEffect(emailState) { - snapshotFlow { emailState.text.toString() }.collect(onEmailChange) - } - LaunchedEffect(passwordState) { - snapshotFlow { passwordState.text.toString() }.collect(onPasswordChange) - } - Scaffold( - modifier = modifier, - snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, - topBar = { - DetailTopBar( - title = stringResource(R.string.onboarding_login_top_bar_title), - onBackClick = { - focusManager.clearFocus() - onBackClick() - }, + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + val coroutineScope = rememberCoroutineScope() + val context = LocalContext.current + val credentialManager = remember(context) { CredentialManager.create(context) } + + // 코루틴 콜백 안에서 여는 문구는 `LocalResources` 로 조회한다 — `LocalContext.current` 경유 + // `getString` 은 로케일·구성이 바뀌어도 옛 문자열을 물고, lint 가 막는다 + // (LocalContextGetResourceValueCall). 여기 `context` 는 Activity·Credential Manager 용이다. + val resources = LocalResources.current + + val googleNoCredentialsMessage = stringResource(R.string.onboarding_login_google_no_credentials) + val screenUnavailableMessage = stringResource(R.string.onboarding_login_screen_unavailable) + + val showErrorSnackbar: (String) -> Unit = { message -> + coroutineScope.launch { + snackbarHostState.showSnackbar( + message = message, + duration = SnackbarDuration.Short, ) - }, - bottomBar = { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 45.dp), - ) { - SocialLoginGroup( - onSignUpClick = onSignUpClick, - onFindAccountClick = onFindAccountClick, - onKakaoLoginClick = onKakaoLoginClick, - onGoogleLoginClick = onGoogleLoginClick, - ) + } + } + + ObserveFlag( + raised = uiState.isLoggedIn, + consumed = LoginIntent.ConsumeLoggedIn, + onIntent = viewModel::onIntent, + onRaised = onLoginSuccess, + ) + ObserveFlag( + raised = uiState.shouldStartOnboarding, + consumed = LoginIntent.ConsumeOnboardingStart, + onIntent = viewModel::onIntent, + onRaised = onNewUserOnboarding, + ) + ObserveSignal( + signal = + uiState.failure + .toDisplay() + .snackbarMessage + ?.asString(), + consumed = LoginIntent.ConsumeError, + onIntent = viewModel::onIntent, + // showErrorSnackbar 가 별도 스코프에 launch 하므로 소비 직후의 재시작이 표출을 끊지 않는다. + onSignal = showErrorSnackbar, + ) + + LoginContent( + state = uiState, + onIntent = viewModel::onIntent, + onSignUpClick = onSignUpClick, + onFindAccountClick = onFindAccountClick, + onKakaoLoginClick = { + val activity = context.findActivity() + if (activity != null) { + coroutineScope.launch { + requestKakaoAccessToken(activity) + .onSuccess { oauthToken -> + viewModel.onIntent(LoginIntent.SubmitKakaoLogin(oauthToken)) + }.onFailure { exception -> + // 카카오 동의 화면·계정 로그인 창을 사용자가 닫은 경우(ClientErrorCause.Cancelled). + // 장애가 아니라 정상적인 이탈이므로 리포팅하지 않는다. + if (exception is CoreAuthFailure.UserCancelledAuth) return@onFailure + viewModel.onIntent( + LoginIntent.ReportSocialTokenFailure(AuthProvider.KAKAO, exception), + ) + showErrorSnackbar( + resources.getString( + exception.displayMessageResOrFallback(R.string.onboarding_login_kakao_failed), + ), + ) + } + } + } else { + showErrorSnackbar(screenUnavailableMessage) } }, - containerColor = Color.Transparent, - ) { innerPadding -> - Column( - modifier = - Modifier - .padding(innerPadding) - .consumeWindowInsets(innerPadding) - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .imePadding() - .addFocusCleaner(focusManager) - .padding(horizontal = 20.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Spacer(modifier = Modifier.height(39.dp)) + onGoogleLoginClick = { + coroutineScope.launch { + requestGoogleIdToken( + context = context, + credentialManager = credentialManager, + serverClientId = BuildConfig.GOOGLE_WEB_CLIENT_ID, + ).onSuccess { idToken -> + viewModel.onIntent(LoginIntent.SubmitGoogleLogin(idToken)) + }.onFailure { exception -> + val message = + when (exception) { + // 계정 선택 시트를 사용자가 닫은 경우(GetCredentialCancellationException, + // 내부 타입 TYPE_USER_CANCELED). 정상적인 이탈이라 리포팅하지 않는다. + is CoreAuthFailure.UserCancelledAuth -> { + return@onFailure + } - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - // 아이디 (이메일) 입력 필드 - AfternoteTextField( - state = emailState, - modifier = - Modifier.semantics { contentType = ContentType.Username }, - placeholder = stringResource(R.string.onboarding_login_email_label), - keyboardType = KeyboardType.Email, - imeAction = ImeAction.Next, - ) + // 반면 이건 취소가 아니라 "쓸 계정이 없어 로그인 불가"라 리포팅 대상이다 — + // 배포본 소셜 로그인 불능을 잡아내려면 이쪽이 신호여야 한다. + is NoCredentialException -> { + googleNoCredentialsMessage + } - // 비밀번호 필드와 인라인 안내(시안 3628:23437)를 6dp 로 묶는다 — 바깥 8dp 와 달라 - // 별도 Column. 문구 스타일은 아이디 찾기 인라인(FindIdScreen)과 동일 토큰. - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - AfternoteTextField( - state = passwordState, - modifier = - Modifier.semantics { contentType = ContentType.Password }, - placeholder = stringResource(R.string.onboarding_login_password_label), - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Done, - onImeAction = { - if (!isLoading) { - focusManager.clearFocus() - onLoginClick() + else -> { + resources.getString( + exception.displayMessageResOrFallback(R.string.onboarding_login_google_failed), + ) } - }, - isError = hasCredentialError, + } + viewModel.onIntent( + LoginIntent.ReportSocialTokenFailure(AuthProvider.GOOGLE, exception), ) - - if (hasCredentialError) { - Text( - text = stringResource(R.string.onboarding_login_invalid_credentials), - style = AfternoteDesign.typography.captionLargeB, - color = AfternoteDesign.colors.error, - ) - } + showErrorSnackbar(message) } } - - Spacer(modifier = Modifier.height(24.dp)) - - // 로그인 버튼 - AfternoteButton( - text = stringResource(R.string.onboarding_login_button), - onClick = { - if (!isLoading) { - focusManager.clearFocus() - onLoginClick() - } - }, - modifier = - Modifier - .fillMaxWidth() - .height(48.dp), - type = AfternoteButtonType.Default, - isLoading = isLoading, - ) - } - } - - if (showNetworkErrorPopup) { - NetworkErrorPopup( - onRetry = onRetryLogin, - onDismiss = onNetworkErrorDismiss, - ) - } -} - -/** - * 로그인 화면 하단 소셜 로그인 그룹. - * - * `LoginScreen`에서만 쓰이므로 외부 파일로 분리하지 않고 `private`로 캡슐화한다. - */ -@Composable -private fun SocialLoginGroup( - onSignUpClick: () -> Unit, - onFindAccountClick: () -> Unit, - onKakaoLoginClick: () -> Unit, - onGoogleLoginClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val focusManager = LocalFocusManager.current - Column( - modifier = modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - // 간편 회원가입하기 버튼 - AfternoteButton( - text = stringResource(R.string.onboarding_login_signup_simple), - type = AfternoteButtonType.Plain, - onClick = { - focusManager.clearFocus() - onSignUpClick() - }, - modifier = Modifier.fillMaxWidth(), - ) - - Spacer(modifier = Modifier.height(12.dp)) - - // 카카오 로그인 버튼 — 배경·심볼만 브랜드 고정색, 프레임 규격은 구글·이메일 버튼과 동일. - SocialLoginButton( - text = stringResource(R.string.onboarding_login_kakao), - iconPainter = painterResource(R.drawable.onboarding_ic_kakao_symbol), - containerColor = KakaoContainerColor, - contentColor = KakaoContentColor, - onClick = { - focusManager.clearFocus() - onKakaoLoginClick() - }, - ) - - Spacer(modifier = Modifier.height(12.dp)) - - // 구글 로그인 버튼 — 색은 회원가입 버튼(AfternoteButtonType.Plain)과 같은 gray2/gray9/gray3. - SocialLoginButton( - text = stringResource(R.string.onboarding_login_google), - iconPainter = painterResource(com.afternote.core.ui.R.drawable.core_ui_img_google_logo), - containerColor = AfternoteDesign.colors.gray2, - contentColor = AfternoteDesign.colors.gray9, - border = BorderStroke(1.dp, AfternoteDesign.colors.gray3), - onClick = { - focusManager.clearFocus() - onGoogleLoginClick() - }, - ) - - Spacer(modifier = Modifier.height(24.dp)) - - // 아이디/비밀번호 찾기 - Text( - text = stringResource(R.string.onboarding_login_find_account), - style = - AfternoteDesign.typography.captionLargeR.copy( - color = AfternoteDesign.colors.gray6, - textDecoration = TextDecoration.Underline, - ), - modifier = - Modifier.clickable { - focusManager.clearFocus() - onFindAccountClick() - }, - ) - } -} - -/** 카카오 로그인 버튼 배경 — 카카오 브랜드 고정색이라 앱 팔레트 토큰으로 근사하지 않는다. */ -private val KakaoContainerColor = Color(0xFFFEE500) - -/** 카카오 로그인 버튼 라벨 색 — 심볼 벡터(onboarding_ic_kakao_symbol)와 같은 #212121·알파 90.2%. */ -private val KakaoContentColor = Color(0xE6212121) - -/** - * 소셜 로그인 버튼 공통 프레임 — 카카오·구글이 색과 아이콘만 다르고 같은 규격을 쓰도록 강제한다. - * - * 시안의 두 버튼은 출처가 달라(카카오 공식 에셋 이미지 vs 별도 컴포넌트) 셰이프·타이포·아이콘 - * 배치가 제각각이었고, 통일 판단은 FE 재량으로 확정된 건이다(#775). 규격은 이 화면의 다른 - * 버튼([AfternoteButton])과 동일한 6dp 셰이프·48dp 높이·captionLargeB 라벨로 수렴시킨다. - * 아이콘은 장식이라 contentDescription 없이 라벨 [text] 가 접근성 이름을 담당한다. - */ -@Composable -private fun SocialLoginButton( - text: String, - iconPainter: Painter, - containerColor: Color, - contentColor: Color, - onClick: () -> Unit, - modifier: Modifier = Modifier, - border: BorderStroke? = null, -) { - Surface( - onClick = onClick, - modifier = - modifier - .fillMaxWidth() - .height(48.dp), - shape = RoundedCornerShape(6.dp), - color = containerColor, - contentColor = contentColor, - border = border, - ) { - Row( - modifier = Modifier.fillMaxSize(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - ) { - Image( - painter = iconPainter, - contentDescription = null, - modifier = Modifier.size(20.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = text, - style = AfternoteDesign.typography.captionLargeB, - ) - } - } + }, + onBackClick = onBackClick, + snackbarHostState = snackbarHostState, + modifier = modifier, + ) } diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/LoginUiState.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/LoginUiState.kt index a79e3d411b..9c13c564bc 100644 --- a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/LoginUiState.kt +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/LoginUiState.kt @@ -1,5 +1,6 @@ package com.afternote.feature.onboarding.presentation.login +import com.afternote.core.ui.mvi.UiState import com.afternote.feature.onboarding.presentation.OnboardingFailure /** @@ -21,4 +22,4 @@ internal data class LoginUiState( val shouldStartOnboarding: Boolean = false, /** 실패 한 건의 사유. 입력 수정·재시도·화면 소비로 해제한다. */ val failure: OnboardingFailure? = null, -) +) : UiState diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/LoginViewModel.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/LoginViewModel.kt index 7f91216c77..02b4d71f0b 100644 --- a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/LoginViewModel.kt +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/login/LoginViewModel.kt @@ -1,12 +1,12 @@ package com.afternote.feature.onboarding.presentation.login -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.domain.error.CoreAuthFailure import com.afternote.core.domain.usecase.auth.LoginType import com.afternote.core.domain.usecase.auth.LoginUseCase import com.afternote.core.ui.UiText +import com.afternote.core.ui.mvi.MviViewModel import com.afternote.feature.onboarding.presentation.OnboardingFailure import com.afternote.feature.onboarding.presentation.R import com.afternote.feature.onboarding.presentation.reporting.AuthFailureStage @@ -14,109 +14,174 @@ import com.afternote.feature.onboarding.presentation.reporting.AuthProvider import com.afternote.feature.onboarding.presentation.reporting.recordAuthFailure import com.afternote.feature.onboarding.presentation.toDisplayMessage import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject import kotlin.coroutines.cancellation.CancellationException +/** + * 로그인 ViewModel. + * + * 전이는 [reduce] 한 곳이고 진입점은 [onIntent] 하나다 (#1802). 기준은 `docs/convention/mvi.md`. + */ @HiltViewModel internal class LoginViewModel @Inject constructor( private val loginUseCase: LoginUseCase, private val errorReporter: ErrorReporter, - ) : ViewModel() { - private val _uiState = MutableStateFlow(LoginUiState()) - val uiState = _uiState.asStateFlow() - + ) : MviViewModel(LoginUiState()) { /** 네트워크 실패 팝업의 "다시 시도하기" 가 재실행할 마지막 시도. */ private var lastAttempt: LoginType? = null - fun updateEmail(value: String) { - // 입력이 바뀌면 앞선 자격 거절은 더 이상 이 입력의 판정이 아니다. - _uiState.update { it.copy(email = value, failure = it.failure.takeIf { _ -> it.email == value }) } - } + override fun onIntent(intent: LoginIntent) { + when (intent) { + is LoginIntent.UpdateEmail -> { + dispatch(LoginReducerEvent.EmailChanged(intent.value)) + } - fun updatePassword(value: String) { - _uiState.update { it.copy(password = value, failure = it.failure.takeIf { _ -> it.password == value }) } - } + is LoginIntent.UpdatePassword -> { + dispatch(LoginReducerEvent.PasswordChanged(intent.value)) + } - fun loginWithEmail() { - val state = _uiState.value - login( - LoginType.Email( - email = state.email, - password = state.password, - ), - ) - } + LoginIntent.SubmitEmailLogin -> { + login( + LoginType.Email( + email = currentState.email, + password = currentState.password, + ), + ) + } - fun loginWithKakao(oauthToken: String) { - login(LoginType.Kakao(oauthToken)) - } + is LoginIntent.SubmitKakaoLogin -> { + login(LoginType.Kakao(intent.oauthToken)) + } - fun loginWithGoogle(idToken: String) { - login(LoginType.Google(idToken)) - } + is LoginIntent.SubmitGoogleLogin -> { + login(LoginType.Google(intent.idToken)) + } - /** - * 소셜 SDK 에서 토큰을 받아오지 못한 실패를 기록한다. - * - * 이 단계는 서버 호출 이전이라 [login] 경로를 타지 않아, UI 가 직접 알려주지 않으면 - * 콘솔에 아무 흔적도 남지 않는다. 사용자 취소는 오류가 아니므로 UI 가 걸러서 호출한다. - */ - fun onSocialTokenRequestFailed( - provider: AuthProvider, - throwable: Throwable, - ) { - errorReporter.recordAuthFailure( - stage = AuthFailureStage.SOCIAL_TOKEN_REQUEST, - throwable = throwable, - provider = provider, - ) - } + is LoginIntent.ReportSocialTokenFailure -> { + errorReporter.recordAuthFailure( + stage = AuthFailureStage.SOCIAL_TOKEN_REQUEST, + throwable = intent.throwable, + provider = intent.provider, + ) + } - /** UI 가 [LoginUiState.isLoggedIn] 소비 후 reset. */ - fun onLoggedInConsumed() { - _uiState.update { it.copy(isLoggedIn = false) } - } + LoginIntent.RetryLogin -> { + retryLogin() + } - /** UI 가 [LoginUiState.shouldStartOnboarding] 소비 후 reset. */ - fun onOnboardingStartConsumed() { - _uiState.update { it.copy(shouldStartOnboarding = false) } - } + LoginIntent.DismissNetworkError -> { + dispatch(LoginReducerEvent.NetworkErrorDismissed) + } - /** UI 가 [LoginUiState.failure] 소비 (snackbar 표시) 후 reset. */ - fun onErrorConsumed() { - _uiState.update { it.copy(failure = it.failure.takeUnless { failure -> failure is OnboardingFailure.RequestFailed }) } - } + LoginIntent.ConsumeLoggedIn -> { + dispatch(LoginReducerEvent.LoggedInConsumed) + } - /** 네트워크 실패 팝업의 "다시 시도하기" — 마지막 시도를 같은 자격으로 재실행한다. */ - fun retryLogin() { - _uiState.update { it.copy(failure = null) } - lastAttempt?.let(::login) + LoginIntent.ConsumeOnboardingStart -> { + dispatch(LoginReducerEvent.OnboardingStartConsumed) + } + + LoginIntent.ConsumeError -> { + dispatch(LoginReducerEvent.ErrorConsumed) + } + } } - fun onNetworkErrorDismissed() { - _uiState.update { it.copy(failure = it.failure.takeUnless { failure -> failure == OnboardingFailure.LoginNetworkUnavailable }) } + override fun reduce( + state: LoginUiState, + event: LoginReducerEvent, + ): LoginUiState = + when (event) { + // 입력이 바뀌면 앞선 자격 거절은 더 이상 이 입력의 판정이 아니다. + is LoginReducerEvent.EmailChanged -> { + state.copy( + email = event.value, + failure = + state.failure.takeIf { + state.email == + event.value + }, + ) + } + + is LoginReducerEvent.PasswordChanged -> { + state.copy( + password = event.value, + failure = + state.failure.takeIf { + state.password == + event.value + }, + ) + } + + LoginReducerEvent.LoginStarted -> { + state.copy(isLoading = true, failure = null) + } + + LoginReducerEvent.LoggedIn -> { + state.copy(isLoading = false, isLoggedIn = true) + } + + LoginReducerEvent.OnboardingRequired -> { + state.copy(isLoading = false, shouldStartOnboarding = true) + } + + LoginReducerEvent.CredentialRejected -> { + state.copy(isLoading = false, failure = OnboardingFailure.CredentialsRejected) + } + + LoginReducerEvent.NetworkErrorRaised -> { + state.copy(isLoading = false, failure = OnboardingFailure.LoginNetworkUnavailable) + } + + is LoginReducerEvent.LoginFailed -> { + state.copy(isLoading = false, failure = OnboardingFailure.RequestFailed(event.message)) + } + + LoginReducerEvent.NetworkErrorDismissed -> { + state.copy( + failure = + state.failure.takeUnless { + it == + OnboardingFailure.LoginNetworkUnavailable + }, + ) + } + + LoginReducerEvent.LoggedInConsumed -> { + state.copy(isLoggedIn = false) + } + + LoginReducerEvent.OnboardingStartConsumed -> { + state.copy(shouldStartOnboarding = false) + } + + LoginReducerEvent.ErrorConsumed -> { + state.copy(failure = state.failure.takeUnless { it is OnboardingFailure.RequestFailed }) + } + } + + /** 마지막 시도를 같은 자격으로 재실행한다. */ + private fun retryLogin() { + dispatch(LoginReducerEvent.NetworkErrorDismissed) + lastAttempt?.let(::login) } private fun login(loginType: LoginType) { - if (_uiState.value.isLoading) return + if (currentState.isLoading) return lastAttempt = loginType viewModelScope.launch { - _uiState.update { it.copy(isLoading = true, failure = null) } - val result = loginUseCase(loginType = loginType) - result + dispatch(LoginReducerEvent.LoginStarted) + loginUseCase(loginType = loginType) .onSuccess { isNewUser -> - _uiState.update { - if (isNewUser) { - it.copy(isLoading = false, shouldStartOnboarding = true) - } else { - it.copy(isLoading = false, isLoggedIn = true) - } + if (isNewUser) { + dispatch(LoginReducerEvent.OnboardingRequired) + } else { + dispatch(LoginReducerEvent.LoggedIn) } }.onFailure { exception -> // 화면 이탈로 스코프가 취소된 것을 장애로 기록하거나 실패 UI 로 소비하지 않는다. @@ -132,17 +197,17 @@ internal class LoginViewModel provider = loginType.authProvider, ) } - _uiState.update { - // 루트로 좁혀 `when` 을 exhaustive 하게 만든다 — 사유가 늘면 여기가 컴파일 에러로 - // 잡힌다. `else` 로 뭉개 두면 인라인·팝업으로 갈라야 할 새 사유가 조용히 문구 하나로 - // 흘러간다. 사유를 확인하지 못한 실패(null)는 계속 문구 매핑에 맡긴다. + // 루트로 좁혀 `when` 을 exhaustive 하게 만든다 — 사유가 늘면 여기가 컴파일 에러로 + // 잡힌다. `else` 로 뭉개 두면 인라인·팝업으로 갈라야 할 새 사유가 조용히 문구 하나로 + // 흘러간다. 사유를 확인하지 못한 실패(null)는 계속 문구 매핑에 맡긴다. + val event = when (exception as? CoreAuthFailure) { is CoreAuthFailure.InvalidLoginCredentials -> { - it.copy(isLoading = false, failure = OnboardingFailure.CredentialsRejected) + LoginReducerEvent.CredentialRejected } is CoreAuthFailure.NetworkUnavailable -> { - it.copy(isLoading = false, failure = OnboardingFailure.LoginNetworkUnavailable) + LoginReducerEvent.NetworkErrorRaised } // 소셜로 가입해 로컬 비밀번호가 없는 계정(서버 1702 — BE `AuthService.login`). @@ -150,13 +215,7 @@ internal class LoginViewModel // 문구를 여기서 직접 넣는 것은 사유별 매핑(`displayMessageResOrNull`)이 이 // 타입에 비밀번호 찾기 문구를 물려 두었기 때문이다 — 화면마다 안내가 다르다. is CoreAuthFailure.SocialSignUpAccount -> { - it.copy( - isLoading = false, - failure = - OnboardingFailure.RequestFailed( - UiText.Resource(R.string.onboarding_login_social_signup_account), - ), - ) + LoginReducerEvent.LoginFailed(UiText.Resource(R.string.onboarding_login_social_signup_account)) } // 로그인 경로로 실제 오는 사유는 `SocialLoginRejected`(서버 1208·1209)와, 사유를 @@ -166,7 +225,7 @@ internal class LoginViewModel // 아래 넷은 여기까지 오지 않는다. `EmailAlreadyRegistered`·`EmailVerification`· // `PasswordUnchanged` 는 계정 API 쪽 사유고(`AccountRepositoryImpl.mapAccountFailure`), // BE 는 1206 을 비밀번호 변경·재설정(`AuthService.passwordChange`·`findPassword`) - // 에서만 던진다. `UserCancelledAuth` 는 소셜 SDK 를 부르는 `LoginEntry` 가 그 자리에서 + // 에서만 던진다. `UserCancelledAuth` 는 소셜 SDK 를 부르는 `LoginScreen` 가 그 자리에서 // 걸러낸다 — `LoginUseCase` 는 토큰을 받은 뒤 서버만 친다. `else` 로 뭉개지 않고 // 열거해 두는 건 사유가 늘 때 컴파일러가 여기를 잡게 하려는 것이고, 도달하더라도 // 문구 매핑이 받아 주므로 안내가 비지 않는다. @@ -177,16 +236,12 @@ internal class LoginViewModel is CoreAuthFailure.PasswordUnchanged, null, -> { - it.copy( - isLoading = false, - failure = - OnboardingFailure.RequestFailed( - exception.toDisplayMessage(R.string.onboarding_login_failed), - ), + LoginReducerEvent.LoginFailed( + exception.toDisplayMessage(R.string.onboarding_login_failed), ) } } - } + dispatch(event) } } } diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/navigation/OnboardingEventHosts.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/navigation/OnboardingEventHosts.kt deleted file mode 100644 index b0f9f43e56..0000000000 --- a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/navigation/OnboardingEventHosts.kt +++ /dev/null @@ -1,103 +0,0 @@ -package com.afternote.feature.onboarding.presentation.navigation - -import androidx.compose.material3.SnackbarDuration -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.ui.res.stringResource -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.afternote.core.ui.asString -import com.afternote.feature.onboarding.presentation.R -import com.afternote.feature.onboarding.presentation.findaccount.FindIdUiState -import com.afternote.feature.onboarding.presentation.findaccount.FindIdViewModel -import com.afternote.feature.onboarding.presentation.signup.SignUpUiState -import com.afternote.feature.onboarding.presentation.signup.SignUpViewModel -import com.afternote.feature.onboarding.presentation.snackbarMessage -import com.afternote.feature.onboarding.presentation.toDisplay - -/** - * 아이디 찾기 화면의 Snackbar 호스트 + 단발성 에러 신호 처리. - * - * [FindIdUiState.failure]의 공통 표시 결정 중 스낵바 몫만 소비한다. - */ -@Composable -internal fun rememberFindIdEventHost( - viewModel: FindIdViewModel, - uiState: FindIdUiState, -): SnackbarHostState { - val snackbarHostState = remember { SnackbarHostState() } - // VM 이 UiText 로 폴백까지 확정해 두므로 빈 문구가 도달하지 않는다. - val snackbarMessage = - uiState.failure - .toDisplay() - .snackbarMessage - ?.asString() - - LaunchedEffect(snackbarMessage) { - if (snackbarMessage != null) { - snackbarHostState.showSnackbar( - message = snackbarMessage, - duration = SnackbarDuration.Short, - ) - viewModel.onErrorConsumed() - } - } - return snackbarHostState -} - -/** - * SignUp Step 화면 공통의 Snackbar 호스트 + UI state 단발성 신호 처리. - * - * 각 Step entry 에서 호출해 [SignUpUiState.failure] / [SignUpUiState.isNameRequired] - * 를 일관되게 snackbar 로 노출하고, Step 1 의 경우 [onNavigateToResidentNumber] 콜백으로 - * [SignUpUiState.shouldNavigateToResidentNumber] = true 시점에 네비게이트한다. - * - * sealed Event Channel 대신 UiState 의 nullable/boolean 신호 + [LaunchedEffect] + on*Consumed 패턴으로 통일 - * (Google 공식 가이드 — ViewModel events should always result in a UI state update). - */ -@Composable -internal fun rememberSignUpEventHost( - viewModel: SignUpViewModel, - onNavigateToResidentNumber: (() -> Unit)? = null, -): SnackbarHostState { - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val snackbarHostState = remember { SnackbarHostState() } - - val nameRequiredMessage = stringResource(R.string.onboarding_signup_name_required) - - LaunchedEffect(uiState.shouldNavigateToResidentNumber) { - if (uiState.shouldNavigateToResidentNumber && onNavigateToResidentNumber != null) { - onNavigateToResidentNumber() - viewModel.onResidentNumberNavigatedConsumed() - } - } - - LaunchedEffect(uiState.isNameRequired) { - if (uiState.isNameRequired) { - snackbarHostState.showSnackbar( - message = nameRequiredMessage, - duration = SnackbarDuration.Short, - ) - viewModel.onNameRequiredConsumed() - } - } - - val snackbarMessage = - uiState.failure - .toDisplay() - .snackbarMessage - ?.asString() - LaunchedEffect(snackbarMessage) { - if (snackbarMessage != null) { - snackbarHostState.showSnackbar( - message = snackbarMessage, - duration = SnackbarDuration.Short, - ) - viewModel.onErrorConsumed() - } - } - - return snackbarHostState -} diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/navigation/OnboardingNavHost.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/navigation/OnboardingNavHost.kt index 1dd0cb72ee..58ee5a7d90 100644 --- a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/navigation/OnboardingNavHost.kt +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/navigation/OnboardingNavHost.kt @@ -1,29 +1,24 @@ package com.afternote.feature.onboarding.presentation.navigation import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberNavBackStack import com.afternote.core.ui.navigation.FeatureNavDisplay import com.afternote.core.ui.navigation.FeatureStackBoundary -import com.afternote.feature.onboarding.presentation.OnboardingFailureDisplay -import com.afternote.feature.onboarding.presentation.OnboardingProfileEntry +import com.afternote.feature.onboarding.presentation.OnboardingProfileScreen import com.afternote.feature.onboarding.presentation.WelcomeScreen import com.afternote.feature.onboarding.presentation.findaccount.FindIdScreen import com.afternote.feature.onboarding.presentation.findaccount.FindIdViewModel -import com.afternote.feature.onboarding.presentation.login.LoginEntry +import com.afternote.feature.onboarding.presentation.login.LoginScreen import com.afternote.feature.onboarding.presentation.signup.SignUpPasswordScreen import com.afternote.feature.onboarding.presentation.signup.SignUpResidentNumberScreen import com.afternote.feature.onboarding.presentation.signup.SignUpScreen import com.afternote.feature.onboarding.presentation.signup.SignUpViewModel import com.afternote.feature.onboarding.presentation.terms.OnboardingTermsScreen import com.afternote.feature.onboarding.presentation.terms.TermsDetailScreen -import com.afternote.feature.onboarding.presentation.toDisplay /** * 온보딩 피처가 소유하는 로컬 Navigation 3 스택. @@ -75,7 +70,7 @@ public fun OnboardingNavHost( } entry { - LoginEntry( + LoginScreen( onLoginSuccess = actions::replaceOnboardingWithHome, onNewUserOnboarding = actions::replaceLoginWithWelcome, onSignUpClick = actions::replaceLoginWithSignUp, @@ -89,105 +84,33 @@ public fun OnboardingNavHost( // 보내면 "확인" 뒤에 갈 곳이 없기 때문이다. 화면·라우트 제거는 #943(카카오 단일화) // 몫이라 여기서 지우지 않고, 그때까지 등록만 남긴다. entry { - val uiState by findIdViewModel.uiState.collectAsStateWithLifecycle() - val snackbarHostState = rememberFindIdEventHost(findIdViewModel, uiState) - - FindIdScreen( - initialEmail = uiState.email, - initialCertificateCode = uiState.certificateCode, - isSendingCode = uiState.isSendingCode, - isVerificationSent = uiState.isVerificationSent, - isSendCodeEnabled = uiState.isSendCodeEnabled, - isVerifyEnabled = uiState.isVerifyEnabled, - isNextEnabled = uiState.isNextEnabled, - resendCooldownSeconds = uiState.resendCooldownSeconds, - hasVerificationError = (uiState.failure.toDisplay() == OnboardingFailureDisplay.VerificationInline), - snackbarHostState = snackbarHostState, - onEmailChange = findIdViewModel::updateEmail, - onCertificateCodeChange = findIdViewModel::updateCertificateCode, - onRequestCode = findIdViewModel::requestVerificationCode, - onVerifyCode = findIdViewModel::verifyCode, - // 결과 화면 연결은 아이디 찾기 존치 결정(#456 코멘트) 대기 — 확정 전까지 미배선. - onNextClick = {}, - onBackClick = actions::popBack, - ) + FindIdScreen(viewModel = findIdViewModel, onNextClick = {}, onBackClick = actions::popBack) } entry { - val uiState by signUpViewModel.uiState.collectAsStateWithLifecycle() - val snackbarHostState = - rememberSignUpEventHost( - viewModel = signUpViewModel, - onNavigateToResidentNumber = actions::proceedToSignUpResidentNumber, - ) - SignUpScreen( - initialEmail = uiState.email, - initialVerificationCode = uiState.verificationCode, - isVerificationSent = uiState.isVerificationSent, - isSendingCode = uiState.isSendingCode, - isEmailFormatValid = uiState.isEmailFormatValid, - resendCooldownSeconds = uiState.resendCooldownSeconds, - hasVerificationError = (uiState.failure.toDisplay() == OnboardingFailureDisplay.VerificationInline), - isNextEnabled = uiState.isStep1NextEnabled, - snackbarHostState = snackbarHostState, - onEmailChange = signUpViewModel::updateEmail, - onVerificationCodeChange = signUpViewModel::updateVerificationCode, - onRequestVerification = signUpViewModel::requestVerification, - onNextClick = signUpViewModel::verifyEmailAndProceed, + viewModel = signUpViewModel, + onNavigateToResidentNumber = actions::proceedToSignUpResidentNumber, onBackClick = actions::popBack, ) } entry { - val uiState by signUpViewModel.uiState.collectAsStateWithLifecycle() - val snackbarHostState = rememberSignUpEventHost(signUpViewModel) - SignUpResidentNumberScreen( - initialFrontNumber = uiState.residentFrontNumber, - initialBackNumber = uiState.residentBackNumber, - isNextEnabled = uiState.isStep2NextEnabled, - snackbarHostState = snackbarHostState, - onFrontNumberChange = signUpViewModel::updateResidentFrontNumber, - onBackNumberChange = signUpViewModel::updateResidentBackNumber, + viewModel = signUpViewModel, onNextClick = actions::proceedToSignUpPassword, onBackClick = actions::popBack, ) } entry { - val uiState by signUpViewModel.uiState.collectAsStateWithLifecycle() - val snackbarHostState = rememberSignUpEventHost(signUpViewModel) - - SignUpPasswordScreen( - initialPassword = uiState.signUpPassword, - initialPasswordConfirm = uiState.signUpPasswordConfirm, - isPasswordRuleSatisfied = uiState.isPasswordRuleSatisfied, - isNextEnabled = uiState.isStep3NextEnabled, - snackbarHostState = snackbarHostState, - onPasswordChange = signUpViewModel::updateSignUpPassword, - onPasswordConfirmChange = signUpViewModel::updateSignUpPasswordConfirm, - onNextClick = actions::proceedToTerms, - onBackClick = actions::popBack, - ) + SignUpPasswordScreen(viewModel = signUpViewModel, onNextClick = actions::proceedToTerms, onBackClick = actions::popBack) } entry { - val uiState by signUpViewModel.uiState.collectAsStateWithLifecycle() - val snackbarHostState = rememberSignUpEventHost(signUpViewModel) - - OnboardingTermsScreen( - termsState = uiState.termsState, - isNextEnabled = uiState.isStep4NextEnabled, - snackbarHostState = snackbarHostState, - onTermsToggle = signUpViewModel::toggleTermsAgreed, - onPrivacyToggle = signUpViewModel::togglePrivacyAgreed, - onMarketingToggle = signUpViewModel::toggleMarketingAgreed, - onToggleAll = signUpViewModel::toggleAllTerms, - onViewTermsClick = { _ -> actions.navigateToTermsDetail() }, - onNextClick = actions::proceedToProfile, - onBackClick = actions::popBack, - ) + OnboardingTermsScreen(viewModel = signUpViewModel, onViewTermsClick = { _ -> + actions.navigateToTermsDetail() + }, onNextClick = actions::proceedToProfile, onBackClick = actions::popBack) } entry { @@ -199,7 +122,7 @@ public fun OnboardingNavHost( } entry { - OnboardingProfileEntry( + OnboardingProfileScreen( viewModel = signUpViewModel, onOnboardingComplete = actions::replaceOnboardingWithHome, onBackClick = actions::popBack, diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpContent.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpContent.kt new file mode 100644 index 0000000000..ee6eb17ed4 --- /dev/null +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpContent.kt @@ -0,0 +1,130 @@ +package com.afternote.feature.onboarding.presentation.signup + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.afternote.core.ui.AfternoteTextField +import com.afternote.core.ui.TextFieldType +import com.afternote.core.ui.scaffold.FlowStepScaffold +import com.afternote.core.ui.theme.AfternoteDesign +import com.afternote.feature.onboarding.presentation.OnboardingFailureDisplay +import com.afternote.feature.onboarding.presentation.R +import com.afternote.feature.onboarding.presentation.toDisplay + +/** 상태를 그리는 렌더 계약. 별도 Screen 파일이 수명과 신호 소비를 담당하므로 모듈 내부에 공개한다. */ +@Composable +internal fun SignUpContent( + state: SignUpUiState, + onIntent: (SignUpIntent) -> Unit, + snackbarHostState: SnackbarHostState, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val emailState = rememberTextFieldState(state.email) + val verificationCodeState = rememberTextFieldState(state.verificationCode) + + LaunchedEffect(emailState) { + snapshotFlow { emailState.text.toString() }.collect { onIntent(SignUpIntent.UpdateEmail(it)) } + } + LaunchedEffect(verificationCodeState) { + snapshotFlow { verificationCodeState.text.toString() }.collect { onIntent(SignUpIntent.UpdateVerificationCode(it)) } + } + + val verificationButtonText = + when { + state.isSendingCode -> { + stringResource(R.string.onboarding_signup_verification_requesting) + } + + state.resendCooldownSeconds > 0 -> { + stringResource(R.string.onboarding_signup_verification_resend_cooldown, state.resendCooldownSeconds) + } + + state.isVerificationSent -> { + stringResource(R.string.onboarding_signup_verification_resend) + } + + else -> { + stringResource(R.string.onboarding_signup_verification_request) + } + } + val isVerificationButtonEnabled = + !state.isSendingCode && state.resendCooldownSeconds == 0 && state.isEmailFormatValid + + FlowStepScaffold( + topBarTitle = stringResource(R.string.onboarding_signup_title), + actionButtonText = stringResource(R.string.onboarding_signup_next), + onBackClick = onBackClick, + onActionClick = { onIntent(SignUpIntent.VerifyEmailAndProceed) }, + modifier = modifier, + isActionEnabled = state.isStep1NextEnabled, + currentStep = SignUpStep.EMAIL, + totalSteps = SIGN_UP_TOTAL_STEPS, + progressContentDescription = stringResource(R.string.onboarding_step_description, SignUpStep.EMAIL), + snackbarHostState = snackbarHostState, + content = { + Column( + modifier = + Modifier + .imePadding() + .verticalScroll(rememberScrollState()) + .padding(top = 35.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // 이메일 입력 + 인증번호 받기 + AfternoteTextField( + type = + TextFieldType.Variant7( + text = verificationButtonText, + onClick = { onIntent(SignUpIntent.RequestVerification) }, + enabled = isVerificationButtonEnabled, + ), + state = emailState, + placeholder = stringResource(R.string.onboarding_signup_email_placeholder), + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Next, + ) + + // 인증번호 입력 + AfternoteTextField( + state = verificationCodeState, + placeholder = stringResource(R.string.onboarding_signup_verification_code_placeholder), + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done, + onImeAction = { + if (state.isStep1NextEnabled) onIntent(SignUpIntent.VerifyEmailAndProceed) + }, + ) + + // 인증번호 불일치는 인라인 에러로, 그 외 실패는 스낵바로 나뉜다 (시안 2431-14204). + if ((state.failure.toDisplay() == OnboardingFailureDisplay.VerificationInline)) { + Text( + text = stringResource(R.string.onboarding_signup_verification_mismatch), + style = AfternoteDesign.typography.captionLargeB, + color = AfternoteDesign.colors.error, + ) + } else if (state.isVerificationSent) { + Text( + text = stringResource(R.string.onboarding_signup_verification_sent), + style = AfternoteDesign.typography.captionLargeB, + color = AfternoteDesign.colors.b1, + ) + } + } + }, + ) +} diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpContract.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpContract.kt new file mode 100644 index 0000000000..9573e3e647 --- /dev/null +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpContract.kt @@ -0,0 +1,177 @@ +package com.afternote.feature.onboarding.presentation.signup + +import com.afternote.core.ui.UiText +import com.afternote.core.ui.mvi.MviIntent +import com.afternote.core.ui.mvi.ReducerEvent + +/** + * 회원가입 플로우가 [SignUpViewModel] 에 보내는 것 — 사용자가 하려는 것. + * + * Step 1~4 와 Profile 이 같은 VM 인스턴스를 공유하므로 Intent 도 한 계약에 모인다. + */ +internal sealed interface SignUpIntent : MviIntent { + data class UpdateEmail( + val value: String, + ) : SignUpIntent + + data class UpdateVerificationCode( + val value: String, + ) : SignUpIntent + + data class UpdateResidentFrontNumber( + val value: String, + ) : SignUpIntent + + data class UpdateResidentBackNumber( + val value: String, + ) : SignUpIntent + + data class UpdateSignUpPassword( + val value: String, + ) : SignUpIntent + + data class UpdateSignUpPasswordConfirm( + val value: String, + ) : SignUpIntent + + data class UpdateName( + val value: String, + ) : SignUpIntent + + /** photo picker 선택 — 화면이 `Uri.toString()` 으로 바꿔 보낸다. 취소(null)는 화면 경계에서 걸러진다. */ + data class PickProfileImage( + val uri: String, + ) : SignUpIntent + + data class ToggleTermsAgreed( + val agreed: Boolean, + ) : SignUpIntent + + data class TogglePrivacyAgreed( + val agreed: Boolean, + ) : SignUpIntent + + data class ToggleMarketingAgreed( + val agreed: Boolean, + ) : SignUpIntent + + data class ToggleAllTerms( + val agreed: Boolean, + ) : SignUpIntent + + data object RequestVerification : SignUpIntent + + /** Step 1 "다음" — 이메일·인증번호를 서버에 검증한다. */ + data object VerifyEmailAndProceed : SignUpIntent + + data object SubmitSignUp : SignUpIntent + + data object ConsumeSignedUp : SignUpIntent + + data object ConsumeResidentNumberNavigation : SignUpIntent + + data object ConsumeNameRequired : SignUpIntent + + data object ConsumeError : SignUpIntent +} + +/** 상태가 겪은 것. [SignUpViewModel] 만 만든다. */ +internal sealed interface SignUpReducerEvent : ReducerEvent { + data class EmailChanged( + val value: String, + ) : SignUpReducerEvent + + data class VerificationCodeChanged( + val value: String, + ) : SignUpReducerEvent + + data class ResidentFrontNumberChanged( + val value: String, + ) : SignUpReducerEvent + + data class ResidentBackNumberChanged( + val value: String, + ) : SignUpReducerEvent + + data class SignUpPasswordChanged( + val value: String, + ) : SignUpReducerEvent + + data class SignUpPasswordConfirmChanged( + val value: String, + ) : SignUpReducerEvent + + data class NameChanged( + val value: String, + ) : SignUpReducerEvent + + data class ProfileImagePicked( + val uri: String, + ) : SignUpReducerEvent + + data class TermsAgreementChanged( + val agreed: Boolean, + ) : SignUpReducerEvent + + data class PrivacyAgreementChanged( + val agreed: Boolean, + ) : SignUpReducerEvent + + data class MarketingAgreementChanged( + val agreed: Boolean, + ) : SignUpReducerEvent + + data class AllAgreementsChanged( + val agreed: Boolean, + ) : SignUpReducerEvent + + data object CodeSendStarted : SignUpReducerEvent + + data object CodeSent : SignUpReducerEvent + + data class CodeSendFailed( + val message: UiText, + ) : SignUpReducerEvent + + data object CodeSendFinished : SignUpReducerEvent + + /** 쿨다운을 30초로 재장전한다. */ + data object CooldownReloaded : SignUpReducerEvent + + data object CooldownTicked : SignUpReducerEvent + + data object EmailVerifyStarted : SignUpReducerEvent + + data object EmailVerified : SignUpReducerEvent + + /** 인증번호 무효(서버 code 1207) — 인라인 문구로 알린다. */ + data object VerificationRejected : SignUpReducerEvent + + data class EmailVerifyFailed( + val message: UiText, + ) : SignUpReducerEvent + + data object EmailVerifyFinished : SignUpReducerEvent + + data object NameRequired : SignUpReducerEvent + + data object SubmitStarted : SignUpReducerEvent + + data object AccountCreated : SignUpReducerEvent + + data class SubmitFailed( + val message: UiText, + ) : SignUpReducerEvent + + data object SignedUp : SignUpReducerEvent + + data object SubmitFinished : SignUpReducerEvent + + data object SignedUpConsumed : SignUpReducerEvent + + data object ResidentNumberNavigationConsumed : SignUpReducerEvent + + data object NameRequiredConsumed : SignUpReducerEvent + + data object ErrorConsumed : SignUpReducerEvent +} diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpPasswordContent.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpPasswordContent.kt new file mode 100644 index 0000000000..747620c113 --- /dev/null +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpPasswordContent.kt @@ -0,0 +1,110 @@ +package com.afternote.feature.onboarding.presentation.signup + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.afternote.core.ui.AfternoteTextField +import com.afternote.core.ui.scaffold.FlowStepScaffold +import com.afternote.feature.onboarding.presentation.PasswordRuleItem +import com.afternote.feature.onboarding.presentation.R + +/** 상태를 그리는 렌더 계약. 별도 Screen 파일이 수명과 신호 소비를 담당하므로 모듈 내부에 공개한다. */ +@Composable +internal fun SignUpPasswordContent( + state: SignUpUiState, + onIntent: (SignUpIntent) -> Unit, + snackbarHostState: SnackbarHostState, + onNextClick: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val focusManager = LocalFocusManager.current + val passwordState = rememberTextFieldState(state.signUpPassword) + val passwordConfirmState = rememberTextFieldState(state.signUpPasswordConfirm) + + LaunchedEffect(passwordState) { + snapshotFlow { passwordState.text.toString() }.collect { onIntent(SignUpIntent.UpdateSignUpPassword(it)) } + } + LaunchedEffect(passwordConfirmState) { + snapshotFlow { passwordConfirmState.text.toString() }.collect { onIntent(SignUpIntent.UpdateSignUpPasswordConfirm(it)) } + } + + FlowStepScaffold( + topBarTitle = stringResource(R.string.onboarding_signup_title), + actionButtonText = stringResource(R.string.onboarding_signup_next), + onBackClick = onBackClick, + onActionClick = onNextClick, + modifier = modifier, + isActionEnabled = state.isStep3NextEnabled, + currentStep = SignUpStep.PASSWORD, + totalSteps = SIGN_UP_TOTAL_STEPS, + progressContentDescription = stringResource(R.string.onboarding_step_description, SignUpStep.PASSWORD), + snackbarHostState = snackbarHostState, + content = { + Column( + modifier = + Modifier + .fillMaxWidth() + .imePadding() + .verticalScroll(rememberScrollState()) + .padding(top = 43.dp), + ) { + SignUpInputLabel( + text = stringResource(R.string.onboarding_signup_password_input_label), + ) + + Spacer(modifier = Modifier.height(17.dp)) + + // 비밀번호 입력 + AfternoteTextField( + state = passwordState, + placeholder = stringResource(R.string.onboarding_signup_password_placeholder), + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Next, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // 비밀번호 확인 + AfternoteTextField( + state = passwordConfirmState, + placeholder = stringResource(R.string.onboarding_signup_password_confirm_placeholder), + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + onImeAction = { + focusManager.clearFocus() + if (state.isStep3NextEnabled) onNextClick() + }, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // 안내 문구 + PasswordRuleItem( + text = stringResource(R.string.onboarding_signup_password_rule_combination), + isSatisfied = state.isPasswordRuleSatisfied, + ) + Spacer(modifier = Modifier.height(4.dp)) + PasswordRuleItem( + text = stringResource(R.string.onboarding_signup_password_rule_reuse), + ) + } + }, + ) +} diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpPasswordScreen.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpPasswordScreen.kt index b197238183..30a48943ce 100644 --- a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpPasswordScreen.kt +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpPasswordScreen.kt @@ -1,114 +1,25 @@ package com.afternote.feature.onboarding.presentation.signup -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.input.rememberTextFieldState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.unit.dp -import com.afternote.core.ui.AfternoteTextField -import com.afternote.core.ui.scaffold.FlowStepScaffold -import com.afternote.feature.onboarding.presentation.PasswordRuleItem -import com.afternote.feature.onboarding.presentation.R +import androidx.lifecycle.compose.collectAsStateWithLifecycle @Composable -fun SignUpPasswordScreen( - initialPassword: String, - initialPasswordConfirm: String, - isPasswordRuleSatisfied: Boolean, - isNextEnabled: Boolean, - snackbarHostState: SnackbarHostState, - onPasswordChange: (String) -> Unit, - onPasswordConfirmChange: (String) -> Unit, +internal fun SignUpPasswordScreen( + viewModel: SignUpViewModel, onNextClick: () -> Unit, onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { - val focusManager = LocalFocusManager.current - val passwordState = rememberTextFieldState(initialPassword) - val passwordConfirmState = rememberTextFieldState(initialPasswordConfirm) + val state by viewModel.uiState.collectAsStateWithLifecycle() - LaunchedEffect(passwordState) { - snapshotFlow { passwordState.text.toString() }.collect(onPasswordChange) - } - LaunchedEffect(passwordConfirmState) { - snapshotFlow { passwordConfirmState.text.toString() }.collect(onPasswordConfirmChange) - } - - FlowStepScaffold( - topBarTitle = stringResource(R.string.onboarding_signup_title), - actionButtonText = stringResource(R.string.onboarding_signup_next), + SignUpPasswordContent( + state = state, + onIntent = viewModel::onIntent, + snackbarHostState = rememberSignUpSnackbarHost(state, viewModel::onIntent), + onNextClick = onNextClick, onBackClick = onBackClick, - onActionClick = onNextClick, modifier = modifier, - isActionEnabled = isNextEnabled, - currentStep = SignUpStep.PASSWORD, - totalSteps = SIGN_UP_TOTAL_STEPS, - progressContentDescription = stringResource(R.string.onboarding_step_description, SignUpStep.PASSWORD), - snackbarHostState = snackbarHostState, - content = { - Column( - modifier = - Modifier - .fillMaxWidth() - .imePadding() - .verticalScroll(rememberScrollState()) - .padding(top = 43.dp), - ) { - SignUpInputLabel( - text = stringResource(R.string.onboarding_signup_password_input_label), - ) - - Spacer(modifier = Modifier.height(17.dp)) - - // 비밀번호 입력 - AfternoteTextField( - state = passwordState, - placeholder = stringResource(R.string.onboarding_signup_password_placeholder), - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Next, - ) - - Spacer(modifier = Modifier.height(8.dp)) - - // 비밀번호 확인 - AfternoteTextField( - state = passwordConfirmState, - placeholder = stringResource(R.string.onboarding_signup_password_confirm_placeholder), - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Done, - onImeAction = { - focusManager.clearFocus() - if (isNextEnabled) onNextClick() - }, - ) - - Spacer(modifier = Modifier.height(8.dp)) - - // 안내 문구 - PasswordRuleItem( - text = stringResource(R.string.onboarding_signup_password_rule_combination), - isSatisfied = isPasswordRuleSatisfied, - ) - Spacer(modifier = Modifier.height(4.dp)) - PasswordRuleItem( - text = stringResource(R.string.onboarding_signup_password_rule_reuse), - ) - } - }, ) } diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpResidentNumberContent.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpResidentNumberContent.kt new file mode 100644 index 0000000000..d6c8dbd0f7 --- /dev/null +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpResidentNumberContent.kt @@ -0,0 +1,104 @@ +package com.afternote.feature.onboarding.presentation.signup + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.afternote.core.ui.AfternoteTextField +import com.afternote.core.ui.TextFieldType +import com.afternote.core.ui.scaffold.FlowStepScaffold +import com.afternote.feature.onboarding.presentation.R +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.filter + +/** 상태를 그리는 렌더 계약. 별도 Screen 파일이 수명과 신호 소비를 담당하므로 모듈 내부에 공개한다. */ +@Composable +internal fun SignUpResidentNumberContent( + state: SignUpUiState, + onIntent: (SignUpIntent) -> Unit, + snackbarHostState: SnackbarHostState, + onNextClick: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val frontNumberState = rememberTextFieldState(state.residentFrontNumber) + val backNumberState = rememberTextFieldState(state.residentBackNumber) + val frontFocusRequester = remember { FocusRequester() } + val backFocusRequester = remember { FocusRequester() } + + LaunchedEffect(frontNumberState) { + snapshotFlow { frontNumberState.text.toString() }.collect { onIntent(SignUpIntent.UpdateResidentFrontNumber(it)) } + } + LaunchedEffect(backNumberState) { + snapshotFlow { backNumberState.text.toString() }.collect { onIntent(SignUpIntent.UpdateResidentBackNumber(it)) } + } + + // 앞자리 6자리 입력 완료 시 뒷자리로 포커스 자동 이동 + LaunchedEffect(frontNumberState) { + snapshotFlow { frontNumberState.text } + .filter { it.length == SignUpUiState.RESIDENT_REGISTRATION_FRONT_DIGIT_COUNT } + .collectLatest { backFocusRequester.requestFocus() } + } + + // 화면 진입 시 앞자리 필드에 포커스 → 키보드 표시 + LaunchedEffect(Unit) { + frontFocusRequester.requestFocus() + } + + FlowStepScaffold( + topBarTitle = stringResource(R.string.onboarding_signup_title), + actionButtonText = stringResource(R.string.onboarding_signup_next), + onBackClick = onBackClick, + onActionClick = onNextClick, + modifier = modifier, + isActionEnabled = state.isStep2NextEnabled, + currentStep = SignUpStep.RESIDENT_NUMBER, + totalSteps = SIGN_UP_TOTAL_STEPS, + progressContentDescription = stringResource(R.string.onboarding_step_description, SignUpStep.RESIDENT_NUMBER), + snackbarHostState = snackbarHostState, + content = { + Column( + modifier = + Modifier + .imePadding() + .verticalScroll(rememberScrollState()) + .padding(top = 43.dp), + ) { + SignUpInputLabel( + text = stringResource(R.string.onboarding_signup_resident_number_label), + ) + + Spacer(modifier = Modifier.height(18.dp)) + + AfternoteTextField( + state = frontNumberState, + focusRequester = frontFocusRequester, + type = + TextFieldType.Variant8( + backState = backNumberState, + placeholder = stringResource(R.string.onboarding_signup_resident_number_back_placeholder), + backFocusRequester = backFocusRequester, + frontFocusRequester = frontFocusRequester, + ), + placeholder = stringResource(R.string.onboarding_signup_resident_number_placeholder), + keyboardType = KeyboardType.Number, + onImeAction = { if (state.isStep2NextEnabled) onNextClick() }, + ) + } + }, + ) +} diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpResidentNumberScreen.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpResidentNumberScreen.kt index 54b39022d4..7c3459e971 100644 --- a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpResidentNumberScreen.kt +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpResidentNumberScreen.kt @@ -1,106 +1,25 @@ package com.afternote.feature.onboarding.presentation.signup -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.input.rememberTextFieldState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.unit.dp -import com.afternote.core.ui.AfternoteTextField -import com.afternote.core.ui.TextFieldType -import com.afternote.core.ui.scaffold.FlowStepScaffold -import com.afternote.feature.onboarding.presentation.R -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.filter +import androidx.lifecycle.compose.collectAsStateWithLifecycle @Composable -fun SignUpResidentNumberScreen( - initialFrontNumber: String, - initialBackNumber: String, - isNextEnabled: Boolean, - snackbarHostState: SnackbarHostState, - onFrontNumberChange: (String) -> Unit, - onBackNumberChange: (String) -> Unit, +internal fun SignUpResidentNumberScreen( + viewModel: SignUpViewModel, onNextClick: () -> Unit, onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { - val frontNumberState = rememberTextFieldState(initialFrontNumber) - val backNumberState = rememberTextFieldState(initialBackNumber) - val frontFocusRequester = remember { FocusRequester() } - val backFocusRequester = remember { FocusRequester() } + val state by viewModel.uiState.collectAsStateWithLifecycle() - LaunchedEffect(frontNumberState) { - snapshotFlow { frontNumberState.text.toString() }.collect(onFrontNumberChange) - } - LaunchedEffect(backNumberState) { - snapshotFlow { backNumberState.text.toString() }.collect(onBackNumberChange) - } - - // 앞자리 6자리 입력 완료 시 뒷자리로 포커스 자동 이동 - LaunchedEffect(frontNumberState) { - snapshotFlow { frontNumberState.text } - .filter { it.length == SignUpUiState.RESIDENT_REGISTRATION_FRONT_DIGIT_COUNT } - .collectLatest { backFocusRequester.requestFocus() } - } - - // 화면 진입 시 앞자리 필드에 포커스 → 키보드 표시 - LaunchedEffect(Unit) { - frontFocusRequester.requestFocus() - } - - FlowStepScaffold( - topBarTitle = stringResource(R.string.onboarding_signup_title), - actionButtonText = stringResource(R.string.onboarding_signup_next), + SignUpResidentNumberContent( + state = state, + onIntent = viewModel::onIntent, + snackbarHostState = rememberSignUpSnackbarHost(state, viewModel::onIntent), + onNextClick = onNextClick, onBackClick = onBackClick, - onActionClick = onNextClick, modifier = modifier, - isActionEnabled = isNextEnabled, - currentStep = SignUpStep.RESIDENT_NUMBER, - totalSteps = SIGN_UP_TOTAL_STEPS, - progressContentDescription = stringResource(R.string.onboarding_step_description, SignUpStep.RESIDENT_NUMBER), - snackbarHostState = snackbarHostState, - content = { - Column( - modifier = - Modifier - .imePadding() - .verticalScroll(rememberScrollState()) - .padding(top = 43.dp), - ) { - SignUpInputLabel( - text = stringResource(R.string.onboarding_signup_resident_number_label), - ) - - Spacer(modifier = Modifier.height(18.dp)) - - AfternoteTextField( - state = frontNumberState, - focusRequester = frontFocusRequester, - type = - TextFieldType.Variant8( - backState = backNumberState, - placeholder = stringResource(R.string.onboarding_signup_resident_number_back_placeholder), - backFocusRequester = backFocusRequester, - frontFocusRequester = frontFocusRequester, - ), - placeholder = stringResource(R.string.onboarding_signup_resident_number_placeholder), - keyboardType = KeyboardType.Number, - onImeAction = { if (isNextEnabled) onNextClick() }, - ) - } - }, ) } diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpScreen.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpScreen.kt index da41b248fd..06671e872a 100644 --- a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpScreen.kt +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpScreen.kt @@ -1,137 +1,33 @@ package com.afternote.feature.onboarding.presentation.signup -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.input.rememberTextFieldState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.unit.dp -import com.afternote.core.ui.AfternoteTextField -import com.afternote.core.ui.TextFieldType -import com.afternote.core.ui.scaffold.FlowStepScaffold -import com.afternote.core.ui.theme.AfternoteDesign -import com.afternote.feature.onboarding.presentation.R +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.afternote.core.ui.mvi.ObserveFlag @Composable -fun SignUpScreen( - initialEmail: String, - initialVerificationCode: String, - isVerificationSent: Boolean, - isSendingCode: Boolean, - isEmailFormatValid: Boolean, - resendCooldownSeconds: Int, - hasVerificationError: Boolean, - isNextEnabled: Boolean, - snackbarHostState: SnackbarHostState, - onEmailChange: (String) -> Unit, - onVerificationCodeChange: (String) -> Unit, - onRequestVerification: () -> Unit, - onNextClick: () -> Unit, +internal fun SignUpScreen( + viewModel: SignUpViewModel, + onNavigateToResidentNumber: () -> Unit, onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { - val emailState = rememberTextFieldState(initialEmail) - val verificationCodeState = rememberTextFieldState(initialVerificationCode) - - LaunchedEffect(emailState) { - snapshotFlow { emailState.text.toString() }.collect(onEmailChange) - } - LaunchedEffect(verificationCodeState) { - snapshotFlow { verificationCodeState.text.toString() }.collect(onVerificationCodeChange) - } - - val verificationButtonText = - when { - isSendingCode -> { - stringResource(R.string.onboarding_signup_verification_requesting) - } - - resendCooldownSeconds > 0 -> { - stringResource(R.string.onboarding_signup_verification_resend_cooldown, resendCooldownSeconds) - } - - isVerificationSent -> { - stringResource(R.string.onboarding_signup_verification_resend) - } - - else -> { - stringResource(R.string.onboarding_signup_verification_request) - } - } - val isVerificationButtonEnabled = - !isSendingCode && resendCooldownSeconds == 0 && isEmailFormatValid + val state by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = rememberSignUpSnackbarHost(state, viewModel::onIntent) + + ObserveFlag( + raised = state.shouldNavigateToResidentNumber, + consumed = SignUpIntent.ConsumeResidentNumberNavigation, + onIntent = viewModel::onIntent, + onRaised = onNavigateToResidentNumber, + ) - FlowStepScaffold( - topBarTitle = stringResource(R.string.onboarding_signup_title), - actionButtonText = stringResource(R.string.onboarding_signup_next), + SignUpContent( + state = state, + onIntent = viewModel::onIntent, + snackbarHostState = snackbarHostState, onBackClick = onBackClick, - onActionClick = onNextClick, modifier = modifier, - isActionEnabled = isNextEnabled, - currentStep = SignUpStep.EMAIL, - totalSteps = SIGN_UP_TOTAL_STEPS, - progressContentDescription = stringResource(R.string.onboarding_step_description, SignUpStep.EMAIL), - snackbarHostState = snackbarHostState, - content = { - Column( - modifier = - Modifier - .imePadding() - .verticalScroll(rememberScrollState()) - .padding(top = 35.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - // 이메일 입력 + 인증번호 받기 - AfternoteTextField( - type = - TextFieldType.Variant7( - text = verificationButtonText, - onClick = onRequestVerification, - enabled = isVerificationButtonEnabled, - ), - state = emailState, - placeholder = stringResource(R.string.onboarding_signup_email_placeholder), - keyboardType = KeyboardType.Email, - imeAction = ImeAction.Next, - ) - - // 인증번호 입력 - AfternoteTextField( - state = verificationCodeState, - placeholder = stringResource(R.string.onboarding_signup_verification_code_placeholder), - keyboardType = KeyboardType.Number, - imeAction = ImeAction.Done, - onImeAction = { - if (isNextEnabled) onNextClick() - }, - ) - - // 인증번호 불일치는 인라인 에러로, 그 외 실패는 스낵바로 나뉜다 (시안 2431-14204). - if (hasVerificationError) { - Text( - text = stringResource(R.string.onboarding_signup_verification_mismatch), - style = AfternoteDesign.typography.captionLargeB, - color = AfternoteDesign.colors.error, - ) - } else if (isVerificationSent) { - Text( - text = stringResource(R.string.onboarding_signup_verification_sent), - style = AfternoteDesign.typography.captionLargeB, - color = AfternoteDesign.colors.b1, - ) - } - } - }, ) } diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpSignalHost.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpSignalHost.kt new file mode 100644 index 0000000000..cdc2793f90 --- /dev/null +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpSignalHost.kt @@ -0,0 +1,62 @@ +package com.afternote.feature.onboarding.presentation.signup + +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.res.stringResource +import com.afternote.core.ui.asString +import com.afternote.core.ui.mvi.ObserveFlag +import com.afternote.core.ui.mvi.ObserveSignal +import com.afternote.feature.onboarding.presentation.R +import com.afternote.feature.onboarding.presentation.snackbarMessage +import com.afternote.feature.onboarding.presentation.toDisplay +import kotlinx.coroutines.launch + +/** + * SignUp Step 1~4 · Profile 이 공유하는 스낵바 호스트와 공통 신호 소비. + * + * Step 화면마다 같은 `LaunchedEffect` 를 다시 쓰면 소비 규약이 화면 수만큼 갈린다 — + * 신호를 읽고 `Intent.ConsumeXxx` 를 되쏘는 일은 [ObserveSignal]·[ObserveFlag] 하나로 모은다. + * + * 화면 고유 신호(Step 1 의 [SignUpUiState.shouldNavigateToResidentNumber], Profile 의 + * [SignUpUiState.isSignedUp])는 그 화면이 직접 소비한다. + */ +@Composable +internal fun rememberSignUpSnackbarHost( + state: SignUpUiState, + onIntent: (SignUpIntent) -> Unit, +): SnackbarHostState { + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + val nameRequiredMessage = stringResource(R.string.onboarding_signup_name_required) + + ObserveFlag( + raised = state.isNameRequired, + consumed = SignUpIntent.ConsumeNameRequired, + onIntent = onIntent, + ) { + // 소비가 곧바로 신호를 되돌려 effect 를 재시작시키므로, 표출은 effect 밖 스코프에 맡긴다. + scope.launch { + snackbarHostState.showSnackbar(message = nameRequiredMessage, duration = SnackbarDuration.Short) + } + } + + ObserveSignal( + // VM 이 UiText 로 폴백까지 확정해 두므로 빈 문구가 도달하지 않는다. + signal = + state.failure + .toDisplay() + .snackbarMessage + ?.asString(), + consumed = SignUpIntent.ConsumeError, + onIntent = onIntent, + ) { message -> + scope.launch { + snackbarHostState.showSnackbar(message = message, duration = SnackbarDuration.Short) + } + } + + return snackbarHostState +} diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpUiState.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpUiState.kt index d6d35263d9..5bedd0f5e0 100644 --- a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpUiState.kt +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpUiState.kt @@ -1,6 +1,7 @@ package com.afternote.feature.onboarding.presentation.signup import android.util.Patterns +import com.afternote.core.ui.mvi.UiState import com.afternote.feature.onboarding.presentation.OnboardingFailure import com.afternote.feature.onboarding.presentation.OnboardingPasswordRule import com.afternote.feature.onboarding.presentation.terms.TermsState @@ -65,7 +66,7 @@ internal data class SignUpUiState( val isNameRequired: Boolean = false, /** 실패 한 건의 사유. 화면이 인라인 또는 스낵바로 표시한다. */ val failure: OnboardingFailure? = null, -) { +) : UiState { val isEmailFormatValid: Boolean get() = email.isNotBlank() && Patterns.EMAIL_ADDRESS.matcher(email).matches() diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpViewModel.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpViewModel.kt index 36357d8fc4..b46b0369c1 100644 --- a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpViewModel.kt +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/signup/SignUpViewModel.kt @@ -1,26 +1,21 @@ package com.afternote.feature.onboarding.presentation.signup -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.domain.error.CoreAuthFailure import com.afternote.core.domain.repository.account.AccountRepository import com.afternote.core.domain.usecase.auth.LoginType import com.afternote.core.domain.usecase.auth.LoginUseCase +import com.afternote.core.ui.mvi.MviViewModel import com.afternote.feature.onboarding.presentation.OnboardingFailure import com.afternote.feature.onboarding.presentation.R import com.afternote.feature.onboarding.presentation.reporting.AuthFailureStage import com.afternote.feature.onboarding.presentation.reporting.AuthProvider import com.afternote.feature.onboarding.presentation.reporting.recordAuthFailure -import com.afternote.feature.onboarding.presentation.signup.SignUpViewModel.Companion.RESEND_COOLDOWN_SECONDS import com.afternote.feature.onboarding.presentation.toDisplayMessage import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject import kotlin.coroutines.cancellation.CancellationException @@ -31,12 +26,12 @@ import kotlin.time.Duration.Companion.milliseconds * * `Route.Onboarding` 그래프 스코프에 묶여 SignUp Step 1~4와 Profile 화면이 동일한 인스턴스를 공유. * - * **상태 관리**: 모든 폼 필드 · 플래그 · 약관 · navigation 신호를 [SignUpUiState] 의 단일 - * `MutableStateFlow` 로 통합. UI 는 `collectAsStateWithLifecycle` 로 한 번에 구독. + * **상태 관리**: 모든 폼 필드 · 플래그 · 약관 · navigation 신호를 [SignUpUiState] 하나로 통합하고, + * 전이는 [reduce] 한 곳에서만 일어난다. 화면은 [onIntent] 하나로 들어온다 (#1802). * * **TextFieldState 정책**: TextFieldState 는 각 Screen 이 `rememberTextFieldState` 로 소유하고, * ViewModel 은 평범한 `String` 으로 보관. Screen 이 LaunchedEffect + snapshotFlow 로 변경 사항을 - * VM 에 push, 다른 Screen 은 VM 의 [uiState] 에서 String 으로 read. + * Intent 로 push, 다른 Screen 은 [uiState] 에서 String 으로 read. */ @HiltViewModel internal class SignUpViewModel @@ -45,15 +40,7 @@ internal class SignUpViewModel private val accountRepository: AccountRepository, private val loginUseCase: LoginUseCase, private val errorReporter: ErrorReporter, - ) : ViewModel() { - companion object { - /** "재전송" 클릭 후 다음 요청까지 강제 대기 초. 서버 비용 · SMS 발송량 보호. */ - private const val RESEND_COOLDOWN_SECONDS = 30 - } - - private val _uiState = MutableStateFlow(SignUpUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - + ) : MviViewModel(SignUpUiState()) { private var cooldownJob: Job? = null /** @@ -62,93 +49,197 @@ internal class SignUpViewModel */ private var signUpJob: Job? = null - // ─── 입력 reducer ─── - // 이메일이 바뀌면 앞서 받은 인증 에러는 더 이상 그 이메일의 것이 아니다. - fun updateEmail(value: String) = _uiState.update { it.copy(email = value, failure = it.failure.takeIf { _ -> it.email == value }) } + override fun onIntent(intent: SignUpIntent) { + when (intent) { + is SignUpIntent.UpdateEmail -> dispatch(SignUpReducerEvent.EmailChanged(intent.value)) + is SignUpIntent.UpdateVerificationCode -> dispatch(SignUpReducerEvent.VerificationCodeChanged(intent.value)) + is SignUpIntent.UpdateResidentFrontNumber -> dispatch(SignUpReducerEvent.ResidentFrontNumberChanged(intent.value)) + is SignUpIntent.UpdateResidentBackNumber -> dispatch(SignUpReducerEvent.ResidentBackNumberChanged(intent.value)) + is SignUpIntent.UpdateSignUpPassword -> dispatch(SignUpReducerEvent.SignUpPasswordChanged(intent.value)) + is SignUpIntent.UpdateSignUpPasswordConfirm -> dispatch(SignUpReducerEvent.SignUpPasswordConfirmChanged(intent.value)) + is SignUpIntent.UpdateName -> dispatch(SignUpReducerEvent.NameChanged(intent.value)) + is SignUpIntent.PickProfileImage -> dispatch(SignUpReducerEvent.ProfileImagePicked(intent.uri)) + is SignUpIntent.ToggleTermsAgreed -> dispatch(SignUpReducerEvent.TermsAgreementChanged(intent.agreed)) + is SignUpIntent.TogglePrivacyAgreed -> dispatch(SignUpReducerEvent.PrivacyAgreementChanged(intent.agreed)) + is SignUpIntent.ToggleMarketingAgreed -> dispatch(SignUpReducerEvent.MarketingAgreementChanged(intent.agreed)) + is SignUpIntent.ToggleAllTerms -> dispatch(SignUpReducerEvent.AllAgreementsChanged(intent.agreed)) + SignUpIntent.RequestVerification -> requestVerification() + SignUpIntent.VerifyEmailAndProceed -> verifyEmailAndProceed() + SignUpIntent.SubmitSignUp -> submitSignUp() + SignUpIntent.ConsumeSignedUp -> dispatch(SignUpReducerEvent.SignedUpConsumed) + SignUpIntent.ConsumeResidentNumberNavigation -> dispatch(SignUpReducerEvent.ResidentNumberNavigationConsumed) + SignUpIntent.ConsumeNameRequired -> dispatch(SignUpReducerEvent.NameRequiredConsumed) + SignUpIntent.ConsumeError -> dispatch(SignUpReducerEvent.ErrorConsumed) + } + } - // photo picker 선택은 Entry 가 Uri.toString() 으로 변환해 push — 취소(null)는 Screen 경계에서 무시한다. - fun onProfileImagePicked(uri: String) = _uiState.update { it.copy(profileImageUri = uri) } + override fun reduce( + state: SignUpUiState, + event: SignUpReducerEvent, + ): SignUpUiState = + when (event) { + // 이메일이 바뀌면 앞서 받은 인증 에러는 더 이상 그 이메일의 것이 아니다. + is SignUpReducerEvent.EmailChanged -> { + state.copy(email = event.value, failure = state.failure.takeIf { state.email == event.value }) + } - fun updateVerificationCode(value: String) = - _uiState.update { - it.copy(verificationCode = value, failure = it.failure.takeIf { _ -> it.verificationCode == value }) - } + is SignUpReducerEvent.VerificationCodeChanged -> { + state.copy(verificationCode = event.value, failure = state.failure.takeIf { state.verificationCode == event.value }) + } - fun updateResidentFrontNumber(value: String) = _uiState.update { it.copy(residentFrontNumber = value) } + is SignUpReducerEvent.ResidentFrontNumberChanged -> { + state.copy(residentFrontNumber = event.value) + } - fun updateResidentBackNumber(value: String) = _uiState.update { it.copy(residentBackNumber = value) } + is SignUpReducerEvent.ResidentBackNumberChanged -> { + state.copy(residentBackNumber = event.value) + } - fun updateSignUpPassword(value: String) = _uiState.update { it.copy(signUpPassword = value) } + is SignUpReducerEvent.SignUpPasswordChanged -> { + state.copy(signUpPassword = event.value) + } - fun updateSignUpPasswordConfirm(value: String) = _uiState.update { it.copy(signUpPasswordConfirm = value) } + is SignUpReducerEvent.SignUpPasswordConfirmChanged -> { + state.copy(signUpPasswordConfirm = event.value) + } - fun updateName(value: String) = _uiState.update { it.copy(name = value) } + is SignUpReducerEvent.NameChanged -> { + state.copy(name = event.value) + } - // ─── 단발성 신호 consume (UI 가 소비 후 호출) ─── - fun onSignedUpConsumed() = _uiState.update { it.copy(isSignedUp = false) } + is SignUpReducerEvent.ProfileImagePicked -> { + state.copy(profileImageUri = event.uri) + } - fun onResidentNumberNavigatedConsumed() = _uiState.update { it.copy(shouldNavigateToResidentNumber = false) } + is SignUpReducerEvent.TermsAgreementChanged -> { + state.copy(termsState = state.termsState.copy(isTermsAgreed = event.agreed)) + } - fun onNameRequiredConsumed() = _uiState.update { it.copy(isNameRequired = false) } + is SignUpReducerEvent.PrivacyAgreementChanged -> { + state.copy(termsState = state.termsState.copy(isPrivacyAgreed = event.agreed)) + } - fun onErrorConsumed() = - _uiState.update { - it.copy(failure = it.failure.takeUnless { failure -> failure is OnboardingFailure.RequestFailed }) - } + is SignUpReducerEvent.MarketingAgreementChanged -> { + state.copy(termsState = state.termsState.copy(isMarketingAgreed = event.agreed)) + } - // ─── 약관 ─── - fun toggleTermsAgreed(agreed: Boolean) = - _uiState.update { - it.copy(termsState = it.termsState.copy(isTermsAgreed = agreed)) - } + is SignUpReducerEvent.AllAgreementsChanged -> { + state.copy( + termsState = + state.termsState.copy( + isTermsAgreed = event.agreed, + isPrivacyAgreed = event.agreed, + isMarketingAgreed = event.agreed, + ), + ) + } - fun togglePrivacyAgreed(agreed: Boolean) = - _uiState.update { - it.copy(termsState = it.termsState.copy(isPrivacyAgreed = agreed)) - } + SignUpReducerEvent.CodeSendStarted -> { + state.copy(isSendingCode = true) + } - fun toggleMarketingAgreed(agreed: Boolean) = - _uiState.update { - it.copy(termsState = it.termsState.copy(isMarketingAgreed = agreed)) - } + SignUpReducerEvent.CodeSent -> { + state.copy(isVerificationSent = true, failure = null) + } + + is SignUpReducerEvent.CodeSendFailed -> { + state.copy(failure = OnboardingFailure.RequestFailed(event.message)) + } + + SignUpReducerEvent.CodeSendFinished -> { + state.copy(isSendingCode = false) + } + + SignUpReducerEvent.CooldownReloaded -> { + state.copy(resendCooldownSeconds = RESEND_COOLDOWN_SECONDS) + } + + SignUpReducerEvent.CooldownTicked -> { + state.copy(resendCooldownSeconds = state.resendCooldownSeconds - 1) + } + + SignUpReducerEvent.EmailVerifyStarted -> { + state.copy(isVerifyingEmail = true, failure = null) + } + + SignUpReducerEvent.EmailVerified -> { + state.copy(shouldNavigateToResidentNumber = true) + } + + SignUpReducerEvent.VerificationRejected -> { + state.copy(failure = OnboardingFailure.VerificationRejected) + } - fun toggleAllTerms(allAgreed: Boolean) = - _uiState.update { - it.copy( - termsState = - it.termsState.copy( - isTermsAgreed = allAgreed, - isPrivacyAgreed = allAgreed, - isMarketingAgreed = allAgreed, - ), - ) + is SignUpReducerEvent.EmailVerifyFailed -> { + state.copy(failure = OnboardingFailure.RequestFailed(event.message)) + } + + SignUpReducerEvent.EmailVerifyFinished -> { + state.copy(isVerifyingEmail = false) + } + + SignUpReducerEvent.NameRequired -> { + state.copy(isNameRequired = true) + } + + SignUpReducerEvent.SubmitStarted -> { + state.copy(isLoading = true) + } + + SignUpReducerEvent.AccountCreated -> { + state.copy(isAccountCreated = true) + } + + is SignUpReducerEvent.SubmitFailed -> { + state.copy(failure = OnboardingFailure.RequestFailed(event.message)) + } + + SignUpReducerEvent.SignedUp -> { + state.copy(isSignedUp = true) + } + + SignUpReducerEvent.SubmitFinished -> { + state.copy(isLoading = false) + } + + SignUpReducerEvent.SignedUpConsumed -> { + state.copy(isSignedUp = false) + } + + SignUpReducerEvent.ResidentNumberNavigationConsumed -> { + state.copy(shouldNavigateToResidentNumber = false) + } + + SignUpReducerEvent.NameRequiredConsumed -> { + state.copy(isNameRequired = false) + } + + SignUpReducerEvent.ErrorConsumed -> { + state.copy(failure = state.failure.takeUnless { it is OnboardingFailure.RequestFailed }) + } } - // ─── 액션 ─── - fun requestVerification() { - val state = _uiState.value + private fun requestVerification() { + val state = currentState if (state.isSendingCode || state.resendCooldownSeconds > 0) return viewModelScope.launch { - _uiState.update { it.copy(isSendingCode = true) } + dispatch(SignUpReducerEvent.CodeSendStarted) accountRepository .sendEmailCode(state.email) .onSuccess { - _uiState.update { it.copy(isVerificationSent = true, failure = null) } + dispatch(SignUpReducerEvent.CodeSent) startResendCooldown() }.onFailure { error -> // 취소는 장애가 아니다 — 기록·UI 소비 전에 되던져 전파를 보존한다(전수 정정은 #661). if (error is CancellationException) throw error errorReporter.recordAuthFailure(AuthFailureStage.EMAIL_CODE_SEND, error) - _uiState.update { - it.copy( - failure = - OnboardingFailure.RequestFailed( - error.toDisplayMessage(R.string.onboarding_signup_code_send_failed), - ), - ) - } + dispatch( + SignUpReducerEvent.CodeSendFailed( + error.toDisplayMessage(R.string.onboarding_signup_code_send_failed), + ), + ) } - _uiState.update { it.copy(isSendingCode = false) } + dispatch(SignUpReducerEvent.CodeSendFinished) } } @@ -157,10 +248,10 @@ internal class SignUpViewModel cooldownJob?.cancel() cooldownJob = viewModelScope.launch { - _uiState.update { it.copy(resendCooldownSeconds = RESEND_COOLDOWN_SECONDS) } - while (_uiState.value.resendCooldownSeconds > 0) { - delay(1000.milliseconds) - _uiState.update { it.copy(resendCooldownSeconds = it.resendCooldownSeconds - 1) } + dispatch(SignUpReducerEvent.CooldownReloaded) + while (currentState.resendCooldownSeconds > 0) { + delay(MILLIS_PER_SECOND.milliseconds) + dispatch(SignUpReducerEvent.CooldownTicked) } } } @@ -171,37 +262,33 @@ internal class SignUpViewModel * 인증번호 무효([CoreAuthFailure.EmailVerification] — 불일치/만료/미존재)는 * [SignUpUiState.failure]에 사유를 기록하고 화면이 표시 채널을 고른다. 만료 판정은 서버가 한다. */ - fun verifyEmailAndProceed() { - val state = _uiState.value + private fun verifyEmailAndProceed() { + val state = currentState if (state.isVerifyingEmail) return viewModelScope.launch { - _uiState.update { it.copy(isVerifyingEmail = true, failure = null) } + dispatch(SignUpReducerEvent.EmailVerifyStarted) accountRepository .verifyEmail( email = state.email, certificateCode = state.verificationCode, ).onSuccess { - _uiState.update { it.copy(shouldNavigateToResidentNumber = true) } + dispatch(SignUpReducerEvent.EmailVerified) }.onFailure { error -> // 취소는 장애가 아니다 — 기록·UI 소비 전에 되던져 전파를 보존한다(전수 정정은 #661). if (error is CancellationException) throw error if (error is CoreAuthFailure.EmailVerification) { - // 표시 문구는 화면의 고정 리소스 — 이 값은 인라인 표시 트리거 + 디버깅용 원문. // 인증번호 불일치·만료는 정상적인 사용자 입력 오류라 리포팅하지 않는다. - _uiState.update { it.copy(failure = OnboardingFailure.VerificationRejected) } + dispatch(SignUpReducerEvent.VerificationRejected) } else { errorReporter.recordAuthFailure(AuthFailureStage.EMAIL_VERIFY, error) - _uiState.update { - it.copy( - failure = - OnboardingFailure.RequestFailed( - error.toDisplayMessage(R.string.onboarding_signup_email_verify_failed), - ), - ) - } + dispatch( + SignUpReducerEvent.EmailVerifyFailed( + error.toDisplayMessage(R.string.onboarding_signup_email_verify_failed), + ), + ) } } - _uiState.update { it.copy(isVerifyingEmail = false) } + dispatch(SignUpReducerEvent.EmailVerifyFinished) } } @@ -213,19 +300,19 @@ internal class SignUpViewModel * 가입과 자동 로그인은 따로 성공할 수 있어 재시도 지점이 다르다 * ([SignUpUiState.isAccountCreated]). */ - fun submitSignUp() { + private fun submitSignUp() { if (signUpJob?.isActive == true) return - val state = _uiState.value + val state = currentState val trimmedName = state.name.trim() if (trimmedName.isEmpty()) { - _uiState.update { it.copy(isNameRequired = true) } + dispatch(SignUpReducerEvent.NameRequired) return } signUpJob = viewModelScope.launch { - _uiState.update { it.copy(isLoading = true) } + dispatch(SignUpReducerEvent.SubmitStarted) try { if (!state.isAccountCreated) { accountRepository @@ -235,26 +322,23 @@ internal class SignUpViewModel name = trimmedName, profileUrl = state.profileImageUri, ).onSuccess { - _uiState.update { it.copy(isAccountCreated = true) } + dispatch(SignUpReducerEvent.AccountCreated) }.onFailure { error -> // 취소는 장애가 아니다 — 기록·UI 소비 전에 되던져 전파를 보존한다(전수 정정은 #661). if (error is CancellationException) throw error errorReporter.recordAuthFailure(AuthFailureStage.SIGN_UP, error) - _uiState.update { - it.copy( - failure = - OnboardingFailure.RequestFailed( - error.toDisplayMessage(R.string.onboarding_signup_failed), - ), - ) - } + dispatch( + SignUpReducerEvent.SubmitFailed( + error.toDisplayMessage(R.string.onboarding_signup_failed), + ), + ) return@launch } } loginUseCase(LoginType.Email(email = state.email, password = state.signUpPassword)) .onSuccess { - _uiState.update { it.copy(isSignedUp = true) } + dispatch(SignUpReducerEvent.SignedUp) }.onFailure { error -> // 취소는 장애가 아니다 — 기록·UI 소비 전에 되던져 전파를 보존한다(전수 정정은 #661). if (error is CancellationException) throw error @@ -263,19 +347,22 @@ internal class SignUpViewModel throwable = error, provider = AuthProvider.EMAIL, ) - _uiState.update { - it.copy( - failure = - OnboardingFailure.RequestFailed( - error.toDisplayMessage(R.string.onboarding_signup_auto_login_failed), - ), - ) - } + dispatch( + SignUpReducerEvent.SubmitFailed( + error.toDisplayMessage(R.string.onboarding_signup_auto_login_failed), + ), + ) } } finally { // 어느 갈래로 빠져나가든 버튼 잠금은 풀어야 한다. - _uiState.update { it.copy(isLoading = false) } + dispatch(SignUpReducerEvent.SubmitFinished) } } } + + private companion object { + /** "재전송" 클릭 후 다음 요청까지 강제 대기 초. 서버 비용 · SMS 발송량 보호. */ + const val RESEND_COOLDOWN_SECONDS = 30 + const val MILLIS_PER_SECOND = 1000L + } } diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/terms/OnboardingTermsContent.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/terms/OnboardingTermsContent.kt new file mode 100644 index 0000000000..33f9358f63 --- /dev/null +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/terms/OnboardingTermsContent.kt @@ -0,0 +1,217 @@ +package com.afternote.feature.onboarding.presentation.terms + +import androidx.compose.foundation.Image +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.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.toggleable +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp +import com.afternote.core.ui.button.AfternoteCircularCheckbox +import com.afternote.core.ui.button.CheckboxState +import com.afternote.core.ui.scaffold.FlowStepScaffold +import com.afternote.core.ui.theme.AfternoteDesign +import com.afternote.feature.onboarding.presentation.R +import com.afternote.feature.onboarding.presentation.signup.SIGN_UP_TOTAL_STEPS +import com.afternote.feature.onboarding.presentation.signup.SignUpIntent +import com.afternote.feature.onboarding.presentation.signup.SignUpStep +import com.afternote.feature.onboarding.presentation.signup.SignUpUiState +import com.afternote.core.common.R as CommonR + +/** 상태를 그리는 렌더 계약. 별도 Screen 파일이 수명과 신호 소비를 담당하므로 모듈 내부에 공개한다. */ +@Composable +internal fun OnboardingTermsContent( + state: SignUpUiState, + onIntent: (SignUpIntent) -> Unit, + snackbarHostState: SnackbarHostState, + onViewTermsClick: (TermsType) -> Unit, + onNextClick: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val termsState = state.termsState + FlowStepScaffold( + topBarTitle = stringResource(R.string.onboarding_signup_title), + actionButtonText = stringResource(R.string.onboarding_signup_next), + onBackClick = onBackClick, + onActionClick = onNextClick, + modifier = modifier, + isActionEnabled = state.isStep4NextEnabled, + currentStep = SignUpStep.TERMS, + totalSteps = SIGN_UP_TOTAL_STEPS, + progressContentDescription = stringResource(R.string.onboarding_step_description, SignUpStep.TERMS), + snackbarHostState = snackbarHostState, + content = { + Spacer(modifier = Modifier.height(32.dp)) + + // 로고 + Image( + painter = painterResource(CommonR.drawable.core_common_logo), + contentDescription = stringResource(R.string.onboarding_welcome_logo_description), + modifier = Modifier.height(55.dp), + contentScale = ContentScale.FillHeight, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // 환영 텍스트 + Text( + text = stringResource(R.string.onboarding_terms_welcome), + style = AfternoteDesign.typography.h1, + color = AfternoteDesign.colors.black, + ) + + Spacer(modifier = Modifier.height(32.dp)) + + Text( + text = stringResource(R.string.onboarding_terms_description), + style = + AfternoteDesign.typography.bodySmallB, + color = AfternoteDesign.colors.gray9, + ) + + Spacer(modifier = Modifier.weight(1f)) + + // --- 약관 동의 섹션 --- + + Column( + modifier = + Modifier + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + TermsRow( + title = stringResource(R.string.onboarding_terms_agree_all), + isChecked = termsState.isAllAgreed, + onToggle = { onIntent(SignUpIntent.ToggleAllTerms(!termsState.isAllAgreed)) }, + titleStyle = AfternoteDesign.typography.bodyBase, + ) + + HorizontalDivider( + thickness = 1.dp, + color = AfternoteDesign.colors.gray3, + ) + + // 서비스 이용 약관 (필수) + TermsRow( + title = stringResource(R.string.onboarding_terms_service), + isChecked = termsState.isTermsAgreed, + onToggle = { onIntent(SignUpIntent.ToggleTermsAgreed(!termsState.isTermsAgreed)) }, + titleStyle = AfternoteDesign.typography.bodySmallB, + ) { onViewTermsClick(TermsType.SERVICE) } + + // 개인정보 수집 및 이용 동의서 (필수) + TermsRow( + title = stringResource(R.string.onboarding_terms_privacy), + isChecked = termsState.isPrivacyAgreed, + onToggle = { onIntent(SignUpIntent.TogglePrivacyAgreed(!termsState.isPrivacyAgreed)) }, + titleStyle = AfternoteDesign.typography.bodySmallB, + ) { onViewTermsClick(TermsType.PRIVACY) } + + // 마케팅 수신 동의 (선택) + TermsRow( + title = stringResource(R.string.onboarding_terms_marketing), + isChecked = termsState.isMarketingAgreed, + onToggle = { onIntent(SignUpIntent.ToggleMarketingAgreed(!termsState.isMarketingAgreed)) }, + isOptional = true, + titleStyle = AfternoteDesign.typography.bodySmallB, + ) { onViewTermsClick(TermsType.MARKETING) } + } + Spacer(Modifier.height(71.dp)) + }, + ) +} + +@Composable +private fun TermsRow( + title: String, + isChecked: Boolean, + titleStyle: TextStyle, + onToggle: () -> Unit, + modifier: Modifier = Modifier, + isOptional: Boolean = false, + onViewDetailClick: (() -> Unit)? = null, +) { + // 최상위 Row는 레이아웃 배치만 담당 (이벤트 없음) + Row( + modifier = + modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + // 1. 체크박스 + 텍스트 영역 (Toggleable) + Row( + modifier = + Modifier + // 「전체보기」가 폭을 먼저 가져가고, 제목은 남는 폭 안에서 줄바꿈한다. + // weight 가 없으면 제목이 순서상 앞이라 폭을 먼저 다 먹어, 「전체보기」가 + // 좁은 폭에 갇혀 세로로 깨진다. + // + // fill 은 기본값(true) 이다 — 남는 폭까지 토글 영역이 되어 행의 빈 곳을 + // 눌러도 체크된다. 리스트 행 체크박스의 통상 동작이다. + .weight(1f) + .toggleable( + value = isChecked, + role = Role.Checkbox, + onValueChange = { onToggle() }, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + AfternoteCircularCheckbox( + state = if (isChecked) CheckboxState.Default else CheckboxState.None, + size = 20.dp, + onClick = null, + ) + + Spacer(Modifier.width(8.dp)) + + Column( + verticalArrangement = Arrangement.Center, + ) { + Text( + text = title, + style = titleStyle, + color = AfternoteDesign.colors.gray9, + ) + + if (isOptional) { + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource(R.string.onboarding_terms_marketing_description), + style = AfternoteDesign.typography.captionLargeR, + color = AfternoteDesign.colors.gray4, + ) + } + } + } + + // 2. 전체보기 버튼 (독립된 Clickable — toggleable과 이벤트 분리) + if (onViewDetailClick != null) { + Text( + text = stringResource(R.string.onboarding_terms_view_detail), + style = AfternoteDesign.typography.captionLargeR, + color = AfternoteDesign.colors.gray6, + modifier = + Modifier + .padding(start = 8.dp) + .clickable(role = Role.Button, onClick = onViewDetailClick), + ) + } + } +} diff --git a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/terms/OnboardingTermsScreen.kt b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/terms/OnboardingTermsScreen.kt index dee16cdb4a..e66d530b1f 100644 --- a/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/terms/OnboardingTermsScreen.kt +++ b/feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/terms/OnboardingTermsScreen.kt @@ -1,48 +1,22 @@ package com.afternote.feature.onboarding.presentation.terms -import androidx.compose.foundation.Image -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.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.selection.toggleable -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.unit.dp -import com.afternote.core.ui.button.AfternoteCircularCheckbox -import com.afternote.core.ui.button.CheckboxState -import com.afternote.core.ui.scaffold.FlowStepScaffold -import com.afternote.core.ui.theme.AfternoteDesign -import com.afternote.feature.onboarding.presentation.R -import com.afternote.feature.onboarding.presentation.signup.SIGN_UP_TOTAL_STEPS -import com.afternote.feature.onboarding.presentation.signup.SignUpStep -import com.afternote.core.common.R as CommonR +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.afternote.feature.onboarding.presentation.signup.SignUpViewModel +import com.afternote.feature.onboarding.presentation.signup.rememberSignUpSnackbarHost -enum class TermsType { +internal enum class TermsType { SERVICE, PRIVACY, MARKETING, } @Immutable -data class TermsState( +internal data class TermsState( val isTermsAgreed: Boolean = false, val isPrivacyAgreed: Boolean = false, val isMarketingAgreed: Boolean = false, @@ -50,186 +24,28 @@ data class TermsState( val isAllAgreed: Boolean get() = isTermsAgreed && isPrivacyAgreed && isMarketingAgreed } +/** + * 약관 동의(Step 4) — stateful 층. + * + * 약관 상세·다음 단계 이동은 네비게이션이라 콜백으로 남는다. + */ @Composable -fun OnboardingTermsScreen( - termsState: TermsState, - isNextEnabled: Boolean, - snackbarHostState: SnackbarHostState, - onTermsToggle: (Boolean) -> Unit, - onPrivacyToggle: (Boolean) -> Unit, - onMarketingToggle: (Boolean) -> Unit, - onToggleAll: (Boolean) -> Unit, +internal fun OnboardingTermsScreen( + viewModel: SignUpViewModel, onViewTermsClick: (TermsType) -> Unit, onNextClick: () -> Unit, onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { - FlowStepScaffold( - topBarTitle = stringResource(R.string.onboarding_signup_title), - actionButtonText = stringResource(R.string.onboarding_signup_next), + val state by viewModel.uiState.collectAsStateWithLifecycle() + + OnboardingTermsContent( + state = state, + onIntent = viewModel::onIntent, + snackbarHostState = rememberSignUpSnackbarHost(state, viewModel::onIntent), + onViewTermsClick = onViewTermsClick, + onNextClick = onNextClick, onBackClick = onBackClick, - onActionClick = onNextClick, modifier = modifier, - isActionEnabled = isNextEnabled, - currentStep = SignUpStep.TERMS, - totalSteps = SIGN_UP_TOTAL_STEPS, - progressContentDescription = stringResource(R.string.onboarding_step_description, SignUpStep.TERMS), - snackbarHostState = snackbarHostState, - content = { - Spacer(modifier = Modifier.height(32.dp)) - - // 로고 - Image( - painter = painterResource(CommonR.drawable.core_common_logo), - contentDescription = stringResource(R.string.onboarding_welcome_logo_description), - modifier = Modifier.height(55.dp), - contentScale = ContentScale.FillHeight, - ) - - Spacer(modifier = Modifier.height(24.dp)) - - // 환영 텍스트 - Text( - text = stringResource(R.string.onboarding_terms_welcome), - style = AfternoteDesign.typography.h1, - color = AfternoteDesign.colors.black, - ) - - Spacer(modifier = Modifier.height(32.dp)) - - Text( - text = stringResource(R.string.onboarding_terms_description), - style = - AfternoteDesign.typography.bodySmallB, - color = AfternoteDesign.colors.gray9, - ) - - Spacer(modifier = Modifier.weight(1f)) - - // --- 약관 동의 섹션 --- - - Column( - modifier = - Modifier - .fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - TermsRow( - title = stringResource(R.string.onboarding_terms_agree_all), - isChecked = termsState.isAllAgreed, - onToggle = { onToggleAll(!termsState.isAllAgreed) }, - titleStyle = AfternoteDesign.typography.bodyBase, - ) - - HorizontalDivider( - thickness = 1.dp, - color = AfternoteDesign.colors.gray3, - ) - - // 서비스 이용 약관 (필수) - TermsRow( - title = stringResource(R.string.onboarding_terms_service), - isChecked = termsState.isTermsAgreed, - onToggle = { onTermsToggle(!termsState.isTermsAgreed) }, - titleStyle = AfternoteDesign.typography.bodySmallB, - ) { onViewTermsClick(TermsType.SERVICE) } - - // 개인정보 수집 및 이용 동의서 (필수) - TermsRow( - title = stringResource(R.string.onboarding_terms_privacy), - isChecked = termsState.isPrivacyAgreed, - onToggle = { onPrivacyToggle(!termsState.isPrivacyAgreed) }, - titleStyle = AfternoteDesign.typography.bodySmallB, - ) { onViewTermsClick(TermsType.PRIVACY) } - - // 마케팅 수신 동의 (선택) - TermsRow( - title = stringResource(R.string.onboarding_terms_marketing), - isChecked = termsState.isMarketingAgreed, - onToggle = { onMarketingToggle(!termsState.isMarketingAgreed) }, - isOptional = true, - titleStyle = AfternoteDesign.typography.bodySmallB, - ) { onViewTermsClick(TermsType.MARKETING) } - } - Spacer(Modifier.height(71.dp)) - }, ) } - -@Composable -private fun TermsRow( - title: String, - isChecked: Boolean, - titleStyle: TextStyle, - onToggle: () -> Unit, - modifier: Modifier = Modifier, - isOptional: Boolean = false, - onViewDetailClick: (() -> Unit)? = null, -) { - // 최상위 Row는 레이아웃 배치만 담당 (이벤트 없음) - Row( - modifier = - modifier - .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - // 1. 체크박스 + 텍스트 영역 (Toggleable) - Row( - modifier = - Modifier - // 「전체보기」가 폭을 먼저 가져가고, 제목은 남는 폭 안에서 줄바꿈한다. - // weight 가 없으면 제목이 순서상 앞이라 폭을 먼저 다 먹어, 「전체보기」가 - // 좁은 폭에 갇혀 세로로 깨진다. - // - // fill 은 기본값(true) 이다 — 남는 폭까지 토글 영역이 되어 행의 빈 곳을 - // 눌러도 체크된다. 리스트 행 체크박스의 통상 동작이다. - .weight(1f) - .toggleable( - value = isChecked, - role = Role.Checkbox, - onValueChange = { onToggle() }, - ), - verticalAlignment = Alignment.CenterVertically, - ) { - AfternoteCircularCheckbox( - state = if (isChecked) CheckboxState.Default else CheckboxState.None, - size = 20.dp, - onClick = null, - ) - - Spacer(Modifier.width(8.dp)) - - Column( - verticalArrangement = Arrangement.Center, - ) { - Text( - text = title, - style = titleStyle, - color = AfternoteDesign.colors.gray9, - ) - - if (isOptional) { - Spacer(Modifier.height(4.dp)) - Text( - text = stringResource(R.string.onboarding_terms_marketing_description), - style = AfternoteDesign.typography.captionLargeR, - color = AfternoteDesign.colors.gray4, - ) - } - } - } - - // 2. 전체보기 버튼 (독립된 Clickable — toggleable과 이벤트 분리) - if (onViewDetailClick != null) { - Text( - text = stringResource(R.string.onboarding_terms_view_detail), - style = AfternoteDesign.typography.captionLargeR, - color = AfternoteDesign.colors.gray6, - modifier = - Modifier - .padding(start = 8.dp) - .clickable(role = Role.Button, onClick = onViewDetailClick), - ) - } - } -} diff --git a/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/OnboardingProfileScreenScreenshotTest.kt b/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/OnboardingProfileScreenScreenshotTest.kt index 2765c43c8a..48ff7df77c 100644 --- a/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/OnboardingProfileScreenScreenshotTest.kt +++ b/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/OnboardingProfileScreenScreenshotTest.kt @@ -5,10 +5,11 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.tooling.preview.Preview import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.onboarding.presentation.signup.SignUpUiState import com.android.tools.screenshot.PreviewTest /** - * [OnboardingProfileScreen] 의 시각 회귀 baseline — 초기 진입 상태 (이름 미입력 + 이미지 미선택). + * [OnboardingProfileContent] 의 시각 회귀 baseline — 초기 진입 상태 (이름 미입력 + 이미지 미선택). * * stateful 흐름 Screen 의 첫 baseline. `photoPickerLauncher` 는 Preview 환경에서 placeholder * 동작 (실제 picker 실행 없음) 이라 stable 렌더. @@ -19,14 +20,11 @@ import com.android.tools.screenshot.PreviewTest @Composable internal fun onboardingProfileScreenInitialScreenshot() { AfternoteTheme { - OnboardingProfileScreen( - initialName = "", - displayImageUri = null, + OnboardingProfileContent( + state = SignUpUiState(), + onIntent = {}, snackbarHostState = remember { SnackbarHostState() }, - onNameChange = {}, - onProfileImagePick = {}, onBackClick = {}, - onCompleteClick = {}, ) } } @@ -42,15 +40,11 @@ internal fun onboardingProfileScreenInitialScreenshot() { @Composable internal fun onboardingProfileScreenSubmittingScreenshot() { AfternoteTheme { - OnboardingProfileScreen( - initialName = "애프터노트", - displayImageUri = null, + OnboardingProfileContent( + state = SignUpUiState(name = "애프터노트", isLoading = true), + onIntent = {}, snackbarHostState = remember { SnackbarHostState() }, - onNameChange = {}, - onProfileImagePick = {}, onBackClick = {}, - onCompleteClick = {}, - isSubmitting = true, ) } } diff --git a/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/findaccount/FindIdScreenScreenshotTest.kt b/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/findaccount/FindIdScreenScreenshotTest.kt index 3da40b2300..223cd76c04 100644 --- a/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/findaccount/FindIdScreenScreenshotTest.kt +++ b/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/findaccount/FindIdScreenScreenshotTest.kt @@ -7,10 +7,11 @@ import androidx.compose.ui.tooling.preview.Preview import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.onboarding.presentation.COMPACT_DEVICE_SPEC import com.afternote.feature.onboarding.presentation.LARGE_FONT_SCALE +import com.afternote.feature.onboarding.presentation.OnboardingFailure import com.android.tools.screenshot.PreviewTest /** - * [FindIdScreen] 의 시각 회귀 baseline — 아이디 찾기 1단계 (이메일 인증). + * [FindIdContent] 의 시각 회귀 baseline — 아이디 찾기 1단계 (이메일 인증). * * 시안의 세 상태를 가드: * 1. 초기 진입 — 인증번호 필드에 "확인" 없음 @@ -24,21 +25,16 @@ import com.android.tools.screenshot.PreviewTest @Composable internal fun findIdScreenInitialScreenshot() { AfternoteTheme { - FindIdScreen( - initialEmail = "", - initialCertificateCode = "", - isSendingCode = false, - isVerificationSent = false, - isSendCodeEnabled = false, - isVerifyEnabled = false, - isNextEnabled = false, - resendCooldownSeconds = 0, - hasVerificationError = false, + FindIdContent( + state = + FindIdUiState( + email = "", + certificateCode = "", + isVerificationSent = false, + failure = null, + ), + onIntent = {}, snackbarHostState = remember { SnackbarHostState() }, - onEmailChange = {}, - onCertificateCodeChange = {}, - onRequestCode = {}, - onVerifyCode = {}, onNextClick = {}, onBackClick = {}, ) @@ -50,21 +46,16 @@ internal fun findIdScreenInitialScreenshot() { @Composable internal fun findIdScreenCodeSentScreenshot() { AfternoteTheme { - FindIdScreen( - initialEmail = "parkchae01@gmail.com", - initialCertificateCode = "", - isSendingCode = false, - isVerificationSent = true, - isSendCodeEnabled = true, - isVerifyEnabled = false, - isNextEnabled = false, - resendCooldownSeconds = 0, - hasVerificationError = false, + FindIdContent( + state = + FindIdUiState( + email = "parkchae01@gmail.com", + certificateCode = "", + isVerificationSent = true, + failure = null, + ), + onIntent = {}, snackbarHostState = remember { SnackbarHostState() }, - onEmailChange = {}, - onCertificateCodeChange = {}, - onRequestCode = {}, - onVerifyCode = {}, onNextClick = {}, onBackClick = {}, ) @@ -76,21 +67,16 @@ internal fun findIdScreenCodeSentScreenshot() { @Composable internal fun findIdScreenCodeMismatchScreenshot() { AfternoteTheme { - FindIdScreen( - initialEmail = "parkchae01@gmail.com", - initialCertificateCode = "123456", - isSendingCode = false, - isVerificationSent = true, - isSendCodeEnabled = true, - isVerifyEnabled = true, - isNextEnabled = false, - resendCooldownSeconds = 0, - hasVerificationError = true, + FindIdContent( + state = + FindIdUiState( + email = "parkchae01@gmail.com", + certificateCode = "123456", + isVerificationSent = true, + failure = OnboardingFailure.VerificationRejected, + ), + onIntent = {}, snackbarHostState = remember { SnackbarHostState() }, - onEmailChange = {}, - onCertificateCodeChange = {}, - onRequestCode = {}, - onVerifyCode = {}, onNextClick = {}, onBackClick = {}, ) @@ -107,21 +93,16 @@ internal fun findIdScreenCodeMismatchScreenshot() { @Composable internal fun findIdScreenCodeMismatchCompactScreenshot() { AfternoteTheme { - FindIdScreen( - initialEmail = "parkchae01@gmail.com", - initialCertificateCode = "123456", - isSendingCode = false, - isVerificationSent = true, - isSendCodeEnabled = true, - isVerifyEnabled = true, - isNextEnabled = false, - resendCooldownSeconds = 0, - hasVerificationError = true, + FindIdContent( + state = + FindIdUiState( + email = "parkchae01@gmail.com", + certificateCode = "123456", + isVerificationSent = true, + failure = OnboardingFailure.VerificationRejected, + ), + onIntent = {}, snackbarHostState = remember { SnackbarHostState() }, - onEmailChange = {}, - onCertificateCodeChange = {}, - onRequestCode = {}, - onVerifyCode = {}, onNextClick = {}, onBackClick = {}, ) @@ -138,21 +119,16 @@ internal fun findIdScreenCodeMismatchCompactScreenshot() { @Composable internal fun findIdScreenCodeMismatchLargeFontScreenshot() { AfternoteTheme { - FindIdScreen( - initialEmail = "parkchae01@gmail.com", - initialCertificateCode = "123456", - isSendingCode = false, - isVerificationSent = true, - isSendCodeEnabled = true, - isVerifyEnabled = true, - isNextEnabled = false, - resendCooldownSeconds = 0, - hasVerificationError = true, + FindIdContent( + state = + FindIdUiState( + email = "parkchae01@gmail.com", + certificateCode = "123456", + isVerificationSent = true, + failure = OnboardingFailure.VerificationRejected, + ), + onIntent = {}, snackbarHostState = remember { SnackbarHostState() }, - onEmailChange = {}, - onCertificateCodeChange = {}, - onRequestCode = {}, - onVerifyCode = {}, onNextClick = {}, onBackClick = {}, ) diff --git a/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/login/LoginScreenScreenshotTest.kt b/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/login/LoginScreenScreenshotTest.kt index aae618391f..4744e399b1 100644 --- a/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/login/LoginScreenScreenshotTest.kt +++ b/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/login/LoginScreenScreenshotTest.kt @@ -5,10 +5,11 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.tooling.preview.Preview import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.onboarding.presentation.OnboardingFailure import com.android.tools.screenshot.PreviewTest /** - * [LoginScreen] 의 시각 회귀 baseline — 초기 진입 (빈 입력, 비로딩). + * [LoginContent] 의 시각 회귀 baseline — 초기 진입 (빈 입력, 비로딩). * * 의도된 시각 변경 시 `./gradlew :feature:onboarding:presentation:updateScreenshotTest` 로 갱신. */ @@ -17,21 +18,15 @@ import com.android.tools.screenshot.PreviewTest @Composable internal fun loginScreenInitialScreenshot() { AfternoteTheme { - LoginScreen( - initialEmail = "", - initialPassword = "", - onEmailChange = {}, - onPasswordChange = {}, - onLoginClick = {}, + LoginContent( + state = LoginUiState(), + onIntent = {}, onSignUpClick = {}, onFindAccountClick = {}, onKakaoLoginClick = {}, onGoogleLoginClick = {}, - onRetryLogin = {}, - onNetworkErrorDismiss = {}, onBackClick = {}, snackbarHostState = remember { SnackbarHostState() }, - isLoading = false, ) } } @@ -44,21 +39,20 @@ internal fun loginScreenInitialScreenshot() { @Composable internal fun loginScreenCredentialErrorScreenshot() { AfternoteTheme { - LoginScreen( - initialEmail = "user@example.com", - initialPassword = "wrong-password", - onEmailChange = {}, - onPasswordChange = {}, - onLoginClick = {}, + LoginContent( + state = + LoginUiState( + email = "user@example.com", + password = "wrong-password", + failure = OnboardingFailure.CredentialsRejected, + ), + onIntent = {}, onSignUpClick = {}, onFindAccountClick = {}, onKakaoLoginClick = {}, onGoogleLoginClick = {}, - onRetryLogin = {}, - onNetworkErrorDismiss = {}, onBackClick = {}, snackbarHostState = remember { SnackbarHostState() }, - hasCredentialError = true, ) } } diff --git a/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/signup/SignUpPasswordScreenScreenshotTest.kt b/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/signup/SignUpPasswordScreenScreenshotTest.kt index f2ede7cb31..5231c647e9 100644 --- a/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/signup/SignUpPasswordScreenScreenshotTest.kt +++ b/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/signup/SignUpPasswordScreenScreenshotTest.kt @@ -8,7 +8,7 @@ import com.afternote.core.ui.theme.AfternoteTheme import com.android.tools.screenshot.PreviewTest /** - * [SignUpPasswordScreen] 의 시각 회귀 baseline — 초기 진입 상태 (빈 입력 + 비밀번호 룰 미충족 + 다음 비활성). + * [SignUpPasswordContent] 의 시각 회귀 baseline — 초기 진입 상태 (빈 입력 + 비밀번호 룰 미충족 + 다음 비활성). * * 의도된 시각 변경 시 `./gradlew :feature:onboarding:presentation:updateScreenshotTest` 로 갱신. */ @@ -17,14 +17,10 @@ import com.android.tools.screenshot.PreviewTest @Composable internal fun signUpPasswordScreenInitialScreenshot() { AfternoteTheme { - SignUpPasswordScreen( - initialPassword = "", - initialPasswordConfirm = "", - isPasswordRuleSatisfied = false, - isNextEnabled = false, + SignUpPasswordContent( + state = SignUpUiState(), + onIntent = {}, snackbarHostState = remember { SnackbarHostState() }, - onPasswordChange = {}, - onPasswordConfirmChange = {}, onNextClick = {}, onBackClick = {}, ) diff --git a/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/signup/SignUpResidentNumberScreenScreenshotTest.kt b/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/signup/SignUpResidentNumberScreenScreenshotTest.kt index f4513ff1ae..6962dea2b8 100644 --- a/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/signup/SignUpResidentNumberScreenScreenshotTest.kt +++ b/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/signup/SignUpResidentNumberScreenScreenshotTest.kt @@ -8,7 +8,7 @@ import com.afternote.core.ui.theme.AfternoteTheme import com.android.tools.screenshot.PreviewTest /** - * [SignUpResidentNumberScreen] 의 시각 회귀 baseline — 초기 진입 상태 (빈 입력 + 다음 비활성). + * [SignUpResidentNumberContent] 의 시각 회귀 baseline — 초기 진입 상태 (빈 입력 + 다음 비활성). * * 의도된 시각 변경 시 `./gradlew :feature:onboarding:presentation:updateScreenshotTest` 로 갱신. */ @@ -17,13 +17,10 @@ import com.android.tools.screenshot.PreviewTest @Composable internal fun signUpResidentNumberScreenInitialScreenshot() { AfternoteTheme { - SignUpResidentNumberScreen( - initialFrontNumber = "", - initialBackNumber = "", - isNextEnabled = false, + SignUpResidentNumberContent( + state = SignUpUiState(), + onIntent = {}, snackbarHostState = remember { SnackbarHostState() }, - onFrontNumberChange = {}, - onBackNumberChange = {}, onNextClick = {}, onBackClick = {}, ) diff --git a/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/signup/SignUpScreenScreenshotTest.kt b/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/signup/SignUpScreenScreenshotTest.kt index 2667125fce..1eb45e3b6a 100644 --- a/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/signup/SignUpScreenScreenshotTest.kt +++ b/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/signup/SignUpScreenScreenshotTest.kt @@ -5,10 +5,11 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.tooling.preview.Preview import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.onboarding.presentation.OnboardingFailure import com.android.tools.screenshot.PreviewTest /** - * [SignUpScreen] 의 시각 회귀 baseline — 회원가입 Step 1 (이메일 + 인증번호). + * [SignUpContent] 의 시각 회귀 baseline — 회원가입 Step 1 (이메일 + 인증번호). * * 세 케이스로 화면 흐름의 핵심 상태를 가드: * 1. 초기 진입 — 빈 입력 + "인증번호 받기" 비활성 @@ -22,20 +23,10 @@ import com.android.tools.screenshot.PreviewTest @Composable internal fun signUpScreenInitialScreenshot() { AfternoteTheme { - SignUpScreen( - initialEmail = "", - initialVerificationCode = "", - isVerificationSent = false, - isSendingCode = false, - isEmailFormatValid = false, - resendCooldownSeconds = 0, - hasVerificationError = false, - isNextEnabled = false, + SignUpContent( + state = SignUpUiState(), + onIntent = {}, snackbarHostState = remember { SnackbarHostState() }, - onEmailChange = {}, - onVerificationCodeChange = {}, - onRequestVerification = {}, - onNextClick = {}, onBackClick = {}, ) } @@ -46,20 +37,15 @@ internal fun signUpScreenInitialScreenshot() { @Composable internal fun signUpScreenVerificationInProgressScreenshot() { AfternoteTheme { - SignUpScreen( - initialEmail = "user@example.com", - initialVerificationCode = "", - isVerificationSent = true, - isSendingCode = false, - isEmailFormatValid = true, - resendCooldownSeconds = 20, - hasVerificationError = false, - isNextEnabled = false, + SignUpContent( + state = + SignUpUiState( + email = "user@example.com", + isVerificationSent = true, + resendCooldownSeconds = 20, + ), + onIntent = {}, snackbarHostState = remember { SnackbarHostState() }, - onEmailChange = {}, - onVerificationCodeChange = {}, - onRequestVerification = {}, - onNextClick = {}, onBackClick = {}, ) } @@ -70,20 +56,21 @@ internal fun signUpScreenVerificationInProgressScreenshot() { @Composable internal fun signUpScreenVerificationMismatchScreenshot() { AfternoteTheme { - SignUpScreen( - initialEmail = "user@example.com", - initialVerificationCode = "000000", - isVerificationSent = true, - isSendingCode = false, - isEmailFormatValid = true, - resendCooldownSeconds = 0, - hasVerificationError = true, - isNextEnabled = false, + SignUpContent( + state = + SignUpUiState( + email = "user@example.com", + verificationCode = "000000", + isVerificationSent = true, + // baseline 은 «다음» 이 비활성인 채로 잡혀 있다. isNextEnabled 가 파생값이 된 + // 지금 그 픽셀을 내는 상태는 검증이 아직 진행 중인 프레임이다 — 서버가 무효를 + // 알려 hasVerificationError 가 서고(VerificationRejected) 요청이 끝나 + // isVerifyingEmail 이 내려가기(EmailVerifyFinished) 전 사이다. + isVerifyingEmail = true, + failure = OnboardingFailure.VerificationRejected, + ), + onIntent = {}, snackbarHostState = remember { SnackbarHostState() }, - onEmailChange = {}, - onVerificationCodeChange = {}, - onRequestVerification = {}, - onNextClick = {}, onBackClick = {}, ) } diff --git a/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/terms/OnboardingTermsScreenScreenshotTest.kt b/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/terms/OnboardingTermsScreenScreenshotTest.kt index 9bffe014b4..f62ebea55d 100644 --- a/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/terms/OnboardingTermsScreenScreenshotTest.kt +++ b/feature/onboarding/presentation/src/screenshotTest/kotlin/com/afternote/feature/onboarding/presentation/terms/OnboardingTermsScreenScreenshotTest.kt @@ -6,10 +6,11 @@ import androidx.compose.runtime.remember import androidx.compose.ui.tooling.preview.Preview import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.onboarding.presentation.COMPACT_DEVICE_SPEC +import com.afternote.feature.onboarding.presentation.signup.SignUpUiState import com.android.tools.screenshot.PreviewTest /** - * [OnboardingTermsScreen] 의 시각 회귀 baseline — 초기 진입 (모든 약관 미동의, 다음 비활성). + * [OnboardingTermsContent] 의 시각 회귀 baseline — 초기 진입 (모든 약관 미동의, 다음 비활성). * * 의도된 시각 변경 시 `./gradlew :feature:onboarding:presentation:updateScreenshotTest` 로 갱신. */ @@ -18,14 +19,10 @@ import com.android.tools.screenshot.PreviewTest @Composable internal fun onboardingTermsScreenInitialScreenshot() { AfternoteTheme { - OnboardingTermsScreen( - termsState = TermsState(), - isNextEnabled = false, + OnboardingTermsContent( + state = SignUpUiState(), + onIntent = {}, snackbarHostState = remember { SnackbarHostState() }, - onTermsToggle = {}, - onPrivacyToggle = {}, - onMarketingToggle = {}, - onToggleAll = {}, onViewTermsClick = {}, onNextClick = {}, onBackClick = {}, @@ -43,14 +40,10 @@ internal fun onboardingTermsScreenInitialScreenshot() { @Composable internal fun onboardingTermsScreenInitialCompactScreenshot() { AfternoteTheme { - OnboardingTermsScreen( - termsState = TermsState(), - isNextEnabled = false, + OnboardingTermsContent( + state = SignUpUiState(), + onIntent = {}, snackbarHostState = remember { SnackbarHostState() }, - onTermsToggle = {}, - onPrivacyToggle = {}, - onMarketingToggle = {}, - onToggleAll = {}, onViewTermsClick = {}, onNextClick = {}, onBackClick = {}, diff --git a/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/OnboardingProfilePickerResultTest.kt b/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/OnboardingProfilePickerResultTest.kt index 7cc292e097..f285f788b8 100644 --- a/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/OnboardingProfilePickerResultTest.kt +++ b/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/OnboardingProfilePickerResultTest.kt @@ -14,6 +14,8 @@ import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.performClick import androidx.core.app.ActivityOptionsCompat import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.onboarding.presentation.signup.SignUpIntent +import com.afternote.feature.onboarding.presentation.signup.SignUpUiState import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test @@ -27,7 +29,7 @@ import com.afternote.core.ui.R as CoreUiR * * 결과 처리 helper 는 화면 파일 안에서만 쓰이는 `private` 구현이라 테스트가 직접 부르지 않는다. * 대신 화면의 공개 계약 — 「프로필 수정 버튼」을 눌러 피커를 띄우고 그 결과가 돌아왔을 때 - * `onProfileImagePick` 이 불리는가 — 으로 판정한다. 피커 자체는 실행하지 않고 + * `PickProfileImage` 이 불리는가 — 으로 판정한다. 피커 자체는 실행하지 않고 * [LocalActivityResultRegistryOwner] 를 결과를 즉시 돌려주는 레지스트리로 갈아 끼운다. */ @RunWith(RobolectricTestRunner::class) @@ -47,14 +49,13 @@ class OnboardingProfilePickerResultTest { composeRule.setContent { AfternoteTheme { CompositionLocalProvider(LocalActivityResultRegistryOwner provides registryOwner) { - OnboardingProfileScreen( - initialName = "김노을", - displayImageUri = null, + OnboardingProfileContent( + state = SignUpUiState(name = "김노을"), snackbarHostState = remember { SnackbarHostState() }, - onNameChange = {}, - onProfileImagePick = pickedUris::add, + onIntent = { intent -> + if (intent is SignUpIntent.PickProfileImage) pickedUris.add(Uri.parse(intent.uri)) + }, onBackClick = {}, - onCompleteClick = {}, ) } } diff --git a/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/OnboardingTestDoubles.kt b/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/OnboardingTestDoubles.kt new file mode 100644 index 0000000000..82b0eaa856 --- /dev/null +++ b/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/OnboardingTestDoubles.kt @@ -0,0 +1,53 @@ +package com.afternote.feature.onboarding.presentation + +import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.core.domain.repository.account.AccountRepository +import com.afternote.core.model.AccountRegistration +import com.afternote.core.model.FoundAccount + +/** 계측을 보지 않는 테스트용. */ +internal object NoopErrorReporter : ErrorReporter { + override fun writeFailure( + throwable: Throwable, + attributes: Map, + ) = Unit +} + +/** + * 리듀서 테스트가 쓰는 저장소. **어느 경로도 부르지 않는다** — 순수 전이만 보는 층이라 + * 호출이 일어났다면 그것이 결함이고, 그 자리에서 드러나야 한다(core:data 의 Fake 들과 같은 규칙). + */ +internal object UnusedAccountRepository : AccountRepository { + override suspend fun sendEmailCode(email: String): Result = error("sendEmailCode 는 리듀서 경로에서 호출되면 안 됨") + + override suspend fun verifyEmail( + email: String, + certificateCode: String, + ): Result = error("verifyEmail 은 리듀서 경로에서 호출되면 안 됨") + + override suspend fun sendFindCode(email: String): Result = error("sendFindCode 는 리듀서 경로에서 호출되면 안 됨") + + override suspend fun findAccount( + email: String, + certificateCode: String, + ): Result = error("findAccount 는 리듀서 경로에서 호출되면 안 됨") + + override suspend fun signUp( + email: String, + password: String, + name: String, + profileUrl: String?, + ): Result = error("signUp 은 리듀서 경로에서 호출되면 안 됨") + + override suspend fun resetPassword( + email: String, + certificateCode: String, + newPassword: String, + confirmPassword: String, + ): Result = error("resetPassword 는 리듀서 경로에서 호출되면 안 됨") + + override suspend fun passwordChange( + currentPassword: String, + newPassword: String, + ): Result = error("passwordChange 는 리듀서 경로에서 호출되면 안 됨") +} diff --git a/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/findaccount/FindIdReducerTest.kt b/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/findaccount/FindIdReducerTest.kt new file mode 100644 index 0000000000..c4e51fa97a --- /dev/null +++ b/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/findaccount/FindIdReducerTest.kt @@ -0,0 +1,102 @@ +package com.afternote.feature.onboarding.presentation.findaccount + +import com.afternote.core.model.FoundAccount +import com.afternote.feature.onboarding.presentation.NoopErrorReporter +import com.afternote.feature.onboarding.presentation.OnboardingFailure +import com.afternote.feature.onboarding.presentation.UnusedAccountRepository +import com.afternote.feature.onboarding.presentation.snackbarMessage +import com.afternote.feature.onboarding.presentation.toDisplay +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * 아이디 찾기의 **순수 전이** (#1802). + * + * 코루틴 하네스가 없다 — `Dispatchers.setMain` 도, `runTest` 도 쓰지 않는다. 여기서 보는 + * Intent 는 저장소를 부르지 않고 `dispatch` → `reduce` 만 지나기 때문이고, 그것이 MVI 로 + * 옮긴 이득이다. 비동기·부수효과는 ViewModel 테스트가 본다. + * + * [UnusedAccountRepository] 는 어느 경로가 불려도 `error` 로 드러내므로, 이 파일이 초록인 것 + * 자체가 「이 Intent 들은 저장소를 부르지 않는다」 의 증거다. + * + * Robolectric 은 [FindIdUiState.isEmailFormatValid] 가 읽는 `android.util.Patterns` 때문에만 + * 필요하다 — 순수 JVM 에서는 그 상수가 null 이다. 시간·디스패처는 여전히 개입하지 않는다. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class FindIdReducerTest { + private fun viewModel() = FindIdViewModel(UnusedAccountRepository, NoopErrorReporter) + + @Test + fun `이메일이 바뀌면 앞서 받은 인증 에러가 함께 지워진다`() { + val viewModel = viewModel() + + viewModel.onIntent(FindIdIntent.UpdateEmail("user@example.com")) + viewModel.onIntent(FindIdIntent.UpdateCertificateCode("123456")) + + assertEquals("user@example.com", viewModel.uiState.value.email) + assertEquals("123456", viewModel.uiState.value.certificateCode) + + viewModel.onIntent(FindIdIntent.UpdateEmail("other@example.com")) + + val state = viewModel.uiState.value + assertNull(state.foundAccount) + assertFalse((state.failure == OnboardingFailure.VerificationRejected)) + } + + @Test + fun `ConsumeError 는 스낵바 신호만 지우고 입력은 남긴다`() { + val viewModel = viewModel() + viewModel.onIntent(FindIdIntent.UpdateEmail("user@example.com")) + + viewModel.onIntent(FindIdIntent.ConsumeError) + + val state = viewModel.uiState.value + assertNull(state.failure.toDisplay().snackbarMessage) + assertEquals("user@example.com", state.email) + } + + @Test + fun `이메일 형식이 아니면 인증번호 요청이 저장소로 가지 않는다`() { + val viewModel = viewModel() + viewModel.onIntent(FindIdIntent.UpdateEmail("not-an-email")) + + viewModel.onIntent(FindIdIntent.RequestVerificationCode) + + assertFalse(viewModel.uiState.value.isSendingCode) + } + + @Test + fun `확인이 열리지 않은 상태에서는 검증이 저장소로 가지 않는다`() { + val viewModel = viewModel() + viewModel.onIntent(FindIdIntent.UpdateEmail("user@example.com")) + viewModel.onIntent(FindIdIntent.UpdateCertificateCode("123")) + + viewModel.onIntent(FindIdIntent.VerifyCode) + + assertFalse(viewModel.uiState.value.isVerifying) + } + + @Test + fun `버튼 활성은 상태에서 파생된다`() { + val sent = + FindIdUiState( + email = "user@example.com", + certificateCode = "123456", + isVerificationSent = true, + ) + + assertTrue(sent.isSendCodeEnabled) + assertTrue(sent.isVerifyEnabled) + // "다음" 은 확인으로 계정을 받아야만 열린다. + assertFalse(sent.isNextEnabled) + assertTrue(sent.copy(foundAccount = FoundAccount(name = "사용자", email = "user@example.com")).isNextEnabled) + assertFalse(sent.copy(resendCooldownSeconds = 20).isSendCodeEnabled) + } +} diff --git a/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/login/LoginReducerTest.kt b/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/login/LoginReducerTest.kt new file mode 100644 index 0000000000..bcf9416d93 --- /dev/null +++ b/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/login/LoginReducerTest.kt @@ -0,0 +1,90 @@ +package com.afternote.feature.onboarding.presentation.login + +import com.afternote.core.common.reporting.ErrorReporter +import com.afternote.core.domain.testing.FakeAuthRepository +import com.afternote.core.domain.usecase.auth.LoginUseCase +import com.afternote.feature.onboarding.presentation.OnboardingFailure +import com.afternote.feature.onboarding.presentation.reporting.AuthProvider +import com.afternote.feature.onboarding.presentation.snackbarMessage +import com.afternote.feature.onboarding.presentation.toDisplay +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * 로그인의 **순수 전이** (#1802). + * + * 코루틴 하네스가 없다 — 여기서 보는 Intent 는 `loginUseCase` 를 부르지 않고 `dispatch` → + * `reduce` 만 지난다. 로그인 자체의 비동기 갈래는 [LoginViewModelTest] 가 본다. + */ +class LoginReducerTest { + private val recorded = mutableListOf>() + + private fun viewModel(): LoginViewModel = + LoginViewModel( + // strict 페이크라 로그인 경로가 실제로 불리면 그 자리에서 드러난다. + loginUseCase = LoginUseCase(FakeAuthRepository.strict()), + errorReporter = + object : ErrorReporter { + override fun writeFailure( + throwable: Throwable, + attributes: Map, + ) { + recorded += attributes + } + }, + ) + + @Test + fun `입력이 바뀌면 앞선 자격 거절이 지워진다`() { + val viewModel = viewModel() + + viewModel.onIntent(LoginIntent.UpdateEmail("user@example.com")) + viewModel.onIntent(LoginIntent.UpdatePassword("pw")) + + val state = viewModel.uiState.value + assertEquals("user@example.com", state.email) + assertEquals("pw", state.password) + assertFalse((state.failure == OnboardingFailure.CredentialsRejected)) + } + + @Test + fun `소비 Intent 는 그 신호만 되돌린다`() { + val viewModel = viewModel() + viewModel.onIntent(LoginIntent.UpdateEmail("user@example.com")) + + viewModel.onIntent(LoginIntent.ConsumeLoggedIn) + viewModel.onIntent(LoginIntent.ConsumeOnboardingStart) + viewModel.onIntent(LoginIntent.ConsumeError) + + val state = viewModel.uiState.value + assertFalse(state.isLoggedIn) + assertFalse(state.shouldStartOnboarding) + assertNull(state.failure.toDisplay().snackbarMessage) + assertEquals("user@example.com", state.email) + } + + @Test + fun `네트워크 팝업 닫기는 재실행 없이 상태만 바꾼다`() { + val viewModel = viewModel() + + viewModel.onIntent(LoginIntent.DismissNetworkError) + + assertFalse((viewModel.uiState.value.failure == OnboardingFailure.LoginNetworkUnavailable)) + } + + @Test + fun `소셜 토큰 실패 보고는 계측만 남기고 상태를 바꾸지 않는다`() { + val viewModel = viewModel() + val before = viewModel.uiState.value + + viewModel.onIntent( + LoginIntent.ReportSocialTokenFailure(AuthProvider.GOOGLE, RuntimeException("SDK 실패")), + ) + + assertEquals(before, viewModel.uiState.value) + assertEquals("social_token_request", recorded.single()["auth_stage"]) + assertEquals("google", recorded.single()["auth_provider"]) + } +} diff --git a/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/login/LoginViewModelReportingTest.kt b/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/login/LoginViewModelReportingTest.kt index c76cfb123e..218eb9e270 100644 --- a/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/login/LoginViewModelReportingTest.kt +++ b/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/login/LoginViewModelReportingTest.kt @@ -73,7 +73,7 @@ class LoginViewModelReportingTest { val reporter = FakeErrorReporter() val viewModel = viewModelWith(RuntimeException("서버 거절"), reporter) - viewModel.loginWithKakao("oauth-token") + viewModel.onIntent(LoginIntent.SubmitKakaoLogin("oauth-token")) advanceUntilIdle() val attributes = reporter.written.single() @@ -87,7 +87,7 @@ class LoginViewModelReportingTest { val reporter = FakeErrorReporter() val viewModel = viewModelWith(RuntimeException("서버 거절"), reporter) - viewModel.loginWithEmail() + viewModel.onIntent(LoginIntent.SubmitEmailLogin) advanceUntilIdle() assertEquals("email", reporter.written.single()["auth_provider"]) @@ -99,7 +99,7 @@ class LoginViewModelReportingTest { val reporter = FakeErrorReporter() val viewModel = viewModelWith(RuntimeException("미사용"), reporter) - viewModel.onSocialTokenRequestFailed(AuthProvider.GOOGLE, RuntimeException("SDK 실패")) + viewModel.onIntent(LoginIntent.ReportSocialTokenFailure(AuthProvider.GOOGLE, RuntimeException("SDK 실패"))) val attributes = reporter.written.single() assertEquals("social_token_request", attributes["auth_stage"]) @@ -113,7 +113,7 @@ class LoginViewModelReportingTest { // 화면 이탈로 스코프가 취소되면 runCatching 이 이걸 Result.failure 로 만들어 넘긴다. val viewModel = viewModelWith(CancellationException("스코프 취소"), reporter) - viewModel.loginWithKakao("oauth-token") + viewModel.onIntent(LoginIntent.SubmitKakaoLogin("oauth-token")) advanceUntilIdle() assertTrue("취소는 리포팅 대상이 아니다", reporter.written.isEmpty()) @@ -131,7 +131,7 @@ class LoginViewModelReportingTest { val reporter = FakeErrorReporter() val viewModel = viewModelWith(CoreAuthFailure.InvalidLoginCredentials(RuntimeException("401")), reporter) - viewModel.loginWithEmail() + viewModel.onIntent(LoginIntent.SubmitEmailLogin) advanceUntilIdle() assertTrue(reporter.written.isEmpty()) diff --git a/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/login/LoginViewModelTest.kt b/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/login/LoginViewModelTest.kt index 91edb8a829..2254b08e2b 100644 --- a/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/login/LoginViewModelTest.kt +++ b/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/login/LoginViewModelTest.kt @@ -29,8 +29,8 @@ import java.net.UnknownHostException * [LoginViewModel] 실패 안내 계약 회귀 가드 (#628). * * 계약 — 자격 거절([CoreAuthFailure.InvalidLoginCredentials])은 인라인 상태 - * ([(LoginUiState.failure == OnboardingFailure.CredentialsRejected)], 입력 변경으로 해제), 전송 계층 실패 - * ([CoreAuthFailure.NetworkUnavailable])는 재시도 팝업([(LoginUiState.failure == OnboardingFailure.LoginNetworkUnavailable)]), + * ([OnboardingFailure.CredentialsRejected], 입력 변경으로 해제), 전송 계층 실패 + * ([CoreAuthFailure.NetworkUnavailable])는 재시도 팝업([OnboardingFailure.LoginNetworkUnavailable]), * 소셜 거절([CoreAuthFailure.SocialLoginRejected])·소셜 가입 계정 * ([CoreAuthFailure.SocialSignUpAccount])과 그 밖의 예외는 **원문을 쓰지 않고** 리소스 문구 * 스낵바로 고정한다. 실패 시 [LoginUiState.isLoading] 을 해제한다. @@ -70,9 +70,9 @@ class LoginViewModelTest { ) private fun LoginViewModel.attemptEmailLogin() { - updateEmail("user@example.com") - updatePassword("pw") - loginWithEmail() + onIntent(LoginIntent.UpdateEmail("user@example.com")) + onIntent(LoginIntent.UpdatePassword("pw")) + onIntent(LoginIntent.SubmitEmailLogin) } @Test @@ -100,7 +100,7 @@ class LoginViewModelTest { }) viewModel.attemptEmailLogin() - viewModel.retryLogin() + viewModel.onIntent(LoginIntent.RetryLogin) assertEquals(2, attempts) } @@ -115,7 +115,7 @@ class LoginViewModelTest { }) viewModel.attemptEmailLogin() - viewModel.onNetworkErrorDismissed() + viewModel.onIntent(LoginIntent.DismissNetworkError) assertFalse((viewModel.uiState.value.failure == OnboardingFailure.LoginNetworkUnavailable)) assertEquals(1, attempts) @@ -144,7 +144,7 @@ class LoginViewModelTest { }) viewModel.attemptEmailLogin() - viewModel.updatePassword("new-pw") + viewModel.onIntent(LoginIntent.UpdatePassword("new-pw")) assertFalse((viewModel.uiState.value.failure == OnboardingFailure.CredentialsRejected)) } @@ -214,7 +214,7 @@ class LoginViewModelTest { val viewModel = viewModel(onDefaultLogin = { Result.failure(Exception("실패")) }) viewModel.attemptEmailLogin() - viewModel.onErrorConsumed() + viewModel.onIntent(LoginIntent.ConsumeError) assertNull( viewModel.uiState.value.failure @@ -231,8 +231,8 @@ class LoginViewModelTest { assertTrue(viewModel.uiState.value.failure is OnboardingFailure.RequestFailed) failure = CoreAuthFailure.InvalidLoginCredentials(Exception("invalid credentials")) - viewModel.loginWithEmail() - viewModel.onErrorConsumed() + viewModel.onIntent(LoginIntent.SubmitEmailLogin) + viewModel.onIntent(LoginIntent.ConsumeError) assertEquals(OnboardingFailure.CredentialsRejected, viewModel.uiState.value.failure) assertNull( @@ -247,9 +247,9 @@ class LoginViewModelTest { val viewModel = viewModel { Result.failure(IllegalStateException("failed")) } viewModel.attemptEmailLogin() - viewModel.updateEmail("user@example.com") + viewModel.onIntent(LoginIntent.UpdateEmail("user@example.com")) assertTrue(viewModel.uiState.value.failure is OnboardingFailure.RequestFailed) - viewModel.updateEmail("other@example.com") + viewModel.onIntent(LoginIntent.UpdateEmail("other@example.com")) assertNull(viewModel.uiState.value.failure) } @@ -257,7 +257,7 @@ class LoginViewModelTest { fun `늦은 네트워크 팝업 닫기는 새 요청 실패를 지우지 않는다`() { val viewModel = viewModel { Result.failure(IllegalStateException("failed")) } viewModel.attemptEmailLogin() - viewModel.onNetworkErrorDismissed() + viewModel.onIntent(LoginIntent.DismissNetworkError) assertTrue(viewModel.uiState.value.failure is OnboardingFailure.RequestFailed) } } diff --git a/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/signup/SignUpReducerTest.kt b/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/signup/SignUpReducerTest.kt new file mode 100644 index 0000000000..7c92e6a213 --- /dev/null +++ b/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/signup/SignUpReducerTest.kt @@ -0,0 +1,135 @@ +package com.afternote.feature.onboarding.presentation.signup + +import com.afternote.core.domain.testing.FakeAuthRepository +import com.afternote.core.domain.usecase.auth.LoginUseCase +import com.afternote.feature.onboarding.presentation.NoopErrorReporter +import com.afternote.feature.onboarding.presentation.OnboardingFailure +import com.afternote.feature.onboarding.presentation.UnusedAccountRepository +import com.afternote.feature.onboarding.presentation.snackbarMessage +import com.afternote.feature.onboarding.presentation.toDisplay +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * 회원가입의 **순수 전이** (#1802). + * + * 코루틴 하네스가 없다 — 여기서 보는 Intent 는 저장소를 부르지 않고 `dispatch` → `reduce` 만 + * 지난다. 제출 잠금·부분 성공 복구 같은 비동기 갈래는 [SignUpViewModelSubmitTest] 가 본다. + * + * Robolectric 은 [SignUpUiState.isEmailFormatValid] 가 읽는 `android.util.Patterns` 때문에만 + * 필요하다 — 순수 JVM 에서는 그 상수가 null 이다. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class SignUpReducerTest { + private fun viewModel(): SignUpViewModel = + SignUpViewModel( + accountRepository = UnusedAccountRepository, + loginUseCase = LoginUseCase(FakeAuthRepository.strict()), + errorReporter = NoopErrorReporter, + ) + + @Test + fun `이메일과 인증번호가 바뀌면 인증 에러가 지워진다`() { + val viewModel = viewModel() + + viewModel.onIntent(SignUpIntent.UpdateEmail("user@example.com")) + viewModel.onIntent(SignUpIntent.UpdateVerificationCode("000000")) + + val state = viewModel.uiState.value + assertEquals("user@example.com", state.email) + assertEquals("000000", state.verificationCode) + assertFalse((state.failure == OnboardingFailure.VerificationRejected)) + } + + @Test + fun `전체 동의는 세 항목을 한 번에 뒤집는다`() { + val viewModel = viewModel() + + viewModel.onIntent(SignUpIntent.ToggleAllTerms(true)) + + val agreed = viewModel.uiState.value.termsState + assertTrue(agreed.isTermsAgreed) + assertTrue(agreed.isPrivacyAgreed) + assertTrue(agreed.isMarketingAgreed) + assertTrue(viewModel.uiState.value.isStep4NextEnabled) + + viewModel.onIntent(SignUpIntent.ToggleMarketingAgreed(false)) + + // 선택 항목만 내려가도 필수 두 개가 남아 "다음" 은 열려 있다. + assertFalse(viewModel.uiState.value.termsState.isMarketingAgreed) + assertTrue(viewModel.uiState.value.isStep4NextEnabled) + + viewModel.onIntent(SignUpIntent.TogglePrivacyAgreed(false)) + + assertFalse(viewModel.uiState.value.isStep4NextEnabled) + } + + @Test + fun `단계별 다음 활성은 입력에서 파생된다`() { + val viewModel = viewModel() + + viewModel.onIntent(SignUpIntent.UpdateEmail("user@example.com")) + viewModel.onIntent(SignUpIntent.UpdateVerificationCode("12345")) + assertFalse(viewModel.uiState.value.isStep1NextEnabled) + + viewModel.onIntent(SignUpIntent.UpdateVerificationCode("123456")) + assertTrue(viewModel.uiState.value.isStep1NextEnabled) + + viewModel.onIntent(SignUpIntent.UpdateResidentFrontNumber("900101")) + viewModel.onIntent(SignUpIntent.UpdateResidentBackNumber("1")) + assertTrue(viewModel.uiState.value.isStep2NextEnabled) + + viewModel.onIntent(SignUpIntent.UpdateSignUpPassword("Password1!")) + assertFalse(viewModel.uiState.value.isStep3NextEnabled) + + viewModel.onIntent(SignUpIntent.UpdateSignUpPasswordConfirm("Password1!")) + assertTrue(viewModel.uiState.value.isStep3NextEnabled) + } + + @Test + fun `이름이 비면 제출이 저장소로 가지 않고 안내 신호만 선다`() { + val viewModel = viewModel() + viewModel.onIntent(SignUpIntent.UpdateName(" ")) + + viewModel.onIntent(SignUpIntent.SubmitSignUp) + + assertTrue(viewModel.uiState.value.isNameRequired) + assertFalse(viewModel.uiState.value.isLoading) + + viewModel.onIntent(SignUpIntent.ConsumeNameRequired) + + assertFalse(viewModel.uiState.value.isNameRequired) + } + + @Test + fun `소비 Intent 는 그 신호만 되돌린다`() { + val viewModel = viewModel() + viewModel.onIntent(SignUpIntent.UpdateName("애프터노트")) + + viewModel.onIntent(SignUpIntent.ConsumeSignedUp) + viewModel.onIntent(SignUpIntent.ConsumeResidentNumberNavigation) + viewModel.onIntent(SignUpIntent.ConsumeError) + + val state = viewModel.uiState.value + assertFalse(state.isSignedUp) + assertFalse(state.shouldNavigateToResidentNumber) + assertNull(state.failure.toDisplay().snackbarMessage) + assertEquals("애프터노트", state.name) + } + + @Test + fun `프로필 이미지 선택은 문자열로 보관된다`() { + val viewModel = viewModel() + + viewModel.onIntent(SignUpIntent.PickProfileImage("content://media/picked")) + + assertEquals("content://media/picked", viewModel.uiState.value.profileImageUri) + } +} diff --git a/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/signup/SignUpViewModelSubmitTest.kt b/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/signup/SignUpViewModelSubmitTest.kt index 28530ab8d6..9cd5a5c54d 100644 --- a/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/signup/SignUpViewModelSubmitTest.kt +++ b/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/signup/SignUpViewModelSubmitTest.kt @@ -61,9 +61,9 @@ class SignUpViewModelSubmitTest { ), errorReporter = NoopErrorReporter, ).apply { - updateEmail("user@example.com") - updateSignUpPassword("Password1!") - updateName("애프터노트") + onIntent(SignUpIntent.UpdateEmail("user@example.com")) + onIntent(SignUpIntent.UpdateSignUpPassword("Password1!")) + onIntent(SignUpIntent.UpdateName("애프터노트")) } @Test @@ -80,9 +80,9 @@ class SignUpViewModelSubmitTest { ) val viewModel = viewModel(repository) - viewModel.submitSignUp() - viewModel.submitSignUp() - viewModel.submitSignUp() + viewModel.onIntent(SignUpIntent.SubmitSignUp) + viewModel.onIntent(SignUpIntent.SubmitSignUp) + viewModel.onIntent(SignUpIntent.SubmitSignUp) assertEquals(1, repository.signUpCallCount) assertTrue(viewModel.uiState.value.isLoading) @@ -111,13 +111,13 @@ class SignUpViewModelSubmitTest { } } - viewModel.submitSignUp() + viewModel.onIntent(SignUpIntent.SubmitSignUp) assertEquals(1, repository.signUpCallCount) assertTrue(viewModel.uiState.value.isAccountCreated) assertFalse(viewModel.uiState.value.isSignedUp) - viewModel.submitSignUp() + viewModel.onIntent(SignUpIntent.SubmitSignUp) assertEquals(1, repository.signUpCallCount) assertEquals(2, loginAttempts) @@ -127,9 +127,9 @@ class SignUpViewModelSubmitTest { @Test fun `이름이 공백이면 요청 없이 안내만 세운다`() { val repository = FakeAccountRepository(onSignUp = { error("이름 미입력이면 호출되면 안 됨") }) - val viewModel = viewModel(repository).apply { updateName(" ") } + val viewModel = viewModel(repository).apply { onIntent(SignUpIntent.UpdateName(" ")) } - viewModel.submitSignUp() + viewModel.onIntent(SignUpIntent.SubmitSignUp) assertEquals(0, repository.signUpCallCount) assertTrue(viewModel.uiState.value.isNameRequired) diff --git a/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/terms/OnboardingTermsRowWidthTest.kt b/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/terms/OnboardingTermsRowWidthTest.kt index ec1a6ae0ea..4c5b649075 100644 --- a/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/terms/OnboardingTermsRowWidthTest.kt +++ b/feature/onboarding/presentation/src/test/java/com/afternote/feature/onboarding/presentation/terms/OnboardingTermsRowWidthTest.kt @@ -16,6 +16,8 @@ import com.afternote.core.ui.testing.assertAccessibleClickTargets import com.afternote.core.ui.testing.scanEnabledClickTargets import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.onboarding.presentation.R +import com.afternote.feature.onboarding.presentation.signup.SignUpIntent +import com.afternote.feature.onboarding.presentation.signup.SignUpUiState import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Rule @@ -49,14 +51,12 @@ class OnboardingTermsRowWidthTest { ) { composeRule.setContent { AfternoteTheme { - OnboardingTermsScreen( - termsState = TermsState(), - isNextEnabled = false, + OnboardingTermsContent( + state = SignUpUiState(), + onIntent = { intent -> + if (intent is SignUpIntent.ToggleAllTerms) onToggleAll(intent.agreed) + }, snackbarHostState = remember { SnackbarHostState() }, - onTermsToggle = {}, - onPrivacyToggle = {}, - onMarketingToggle = {}, - onToggleAll = onToggleAll, onViewTermsClick = onViewTermsClick, onNextClick = {}, onBackClick = {}, diff --git a/feature/onboarding/presentation/src/test/kotlin/com/afternote/feature/onboarding/presentation/OnboardingContractTest.kt b/feature/onboarding/presentation/src/test/kotlin/com/afternote/feature/onboarding/presentation/OnboardingContractTest.kt index 45b3db1d80..671d5d53d1 100644 --- a/feature/onboarding/presentation/src/test/kotlin/com/afternote/feature/onboarding/presentation/OnboardingContractTest.kt +++ b/feature/onboarding/presentation/src/test/kotlin/com/afternote/feature/onboarding/presentation/OnboardingContractTest.kt @@ -17,9 +17,10 @@ import androidx.compose.ui.test.performTextInput import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.onboarding.presentation.findaccount.FindPasswordResetScreen import com.afternote.feature.onboarding.presentation.findaccount.FindPasswordScreen -import com.afternote.feature.onboarding.presentation.signup.SignUpScreen -import com.afternote.feature.onboarding.presentation.terms.OnboardingTermsScreen -import com.afternote.feature.onboarding.presentation.terms.TermsState +import com.afternote.feature.onboarding.presentation.signup.SignUpContent +import com.afternote.feature.onboarding.presentation.signup.SignUpIntent +import com.afternote.feature.onboarding.presentation.signup.SignUpUiState +import com.afternote.feature.onboarding.presentation.terms.OnboardingTermsContent import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test @@ -37,25 +38,23 @@ class OnboardingContractTest { @Test fun signUpRequiredInputs_gateNextAndKeepEnteredValues() { - var email by mutableStateOf("") - var code by mutableStateOf("") + // 「다음」 활성은 이제 UiState 파생값이다 — 화면이 받는 것은 상태와 Intent 둘뿐이라, + // VM 이 하는 전이를 테스트가 대신한다. + var state by mutableStateOf(SignUpUiState(isVerificationSent = true)) var nextCalls = 0 composeRule.setContent { AfternoteTheme { - SignUpScreen( - initialEmail = email, - initialVerificationCode = code, - isVerificationSent = true, - isSendingCode = false, - isEmailFormatValid = email.contains('@'), - resendCooldownSeconds = 0, - hasVerificationError = false, - isNextEnabled = email.contains('@') && code.length == 6, + SignUpContent( + state = state, + onIntent = { intent -> + when (intent) { + is SignUpIntent.UpdateEmail -> state = state.copy(email = intent.value) + is SignUpIntent.UpdateVerificationCode -> state = state.copy(verificationCode = intent.value) + SignUpIntent.VerifyEmailAndProceed -> nextCalls += 1 + else -> Unit + } + }, snackbarHostState = remember { SnackbarHostState() }, - onEmailChange = { email = it }, - onVerificationCodeChange = { code = it }, - onRequestVerification = {}, - onNextClick = { nextCalls += 1 }, onBackClick = {}, ) } @@ -67,26 +66,51 @@ class OnboardingContractTest { composeRule.onNode(hasText("다음") and hasClickAction()).assertIsEnabled().performClick() assertEquals(1, nextCalls) - assertEquals("new@example.test", email) - assertEquals("123456", code) + assertEquals("new@example.test", state.email) + assertEquals("123456", state.verificationCode) } @Test fun requiredTerms_enableNext_withoutOptionalMarketing() { - var state by mutableStateOf(TermsState()) + var state by mutableStateOf(SignUpUiState()) var nextCalls = 0 composeRule.setContent { AfternoteTheme { - OnboardingTermsScreen( - termsState = state, - isNextEnabled = state.isTermsAgreed && state.isPrivacyAgreed, - snackbarHostState = remember { SnackbarHostState() }, - onTermsToggle = { state = state.copy(isTermsAgreed = it) }, - onPrivacyToggle = { state = state.copy(isPrivacyAgreed = it) }, - onMarketingToggle = { state = state.copy(isMarketingAgreed = it) }, - onToggleAll = { - state = TermsState(it, it, it) + OnboardingTermsContent( + state = state, + onIntent = { intent -> + val terms = state.termsState + state = + when (intent) { + is SignUpIntent.ToggleTermsAgreed -> { + state.copy(termsState = terms.copy(isTermsAgreed = intent.agreed)) + } + + is SignUpIntent.TogglePrivacyAgreed -> { + state.copy(termsState = terms.copy(isPrivacyAgreed = intent.agreed)) + } + + is SignUpIntent.ToggleMarketingAgreed -> { + state.copy(termsState = terms.copy(isMarketingAgreed = intent.agreed)) + } + + is SignUpIntent.ToggleAllTerms -> { + state.copy( + termsState = + terms.copy( + isTermsAgreed = intent.agreed, + isPrivacyAgreed = intent.agreed, + isMarketingAgreed = intent.agreed, + ), + ) + } + + else -> { + state + } + } }, + snackbarHostState = remember { SnackbarHostState() }, onViewTermsClick = {}, onNextClick = { nextCalls += 1 }, onBackClick = {}, @@ -103,7 +127,7 @@ class OnboardingContractTest { .assertIsEnabled() .performClick() - assertEquals(false, state.isMarketingAgreed) + assertEquals(false, state.termsState.isMarketingAgreed) assertEquals(1, nextCalls) } @@ -182,20 +206,10 @@ class OnboardingContractTest { val restorationTester = StateRestorationTester(composeRule) restorationTester.setContent { AfternoteTheme { - SignUpScreen( - initialEmail = "", - initialVerificationCode = "", - isVerificationSent = true, - isSendingCode = false, - isEmailFormatValid = true, - resendCooldownSeconds = 0, - hasVerificationError = false, - isNextEnabled = false, + SignUpContent( + state = SignUpUiState(isVerificationSent = true), + onIntent = {}, snackbarHostState = remember { SnackbarHostState() }, - onEmailChange = {}, - onVerificationCodeChange = {}, - onRequestVerification = {}, - onNextClick = {}, onBackClick = {}, ) } diff --git a/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt b/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt index 9535e5d60d..e840365209 100644 --- a/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt +++ b/konsist/src/test/kotlin/com/afternote/konsist/MviContractKonsistTest.kt @@ -400,14 +400,6 @@ class MviContractKonsistTest { "com.afternote.feature.mindrecord.presentation.viewmodel.WeeklyReportViewModel", ) - /** #1802 (파일럿) 가 뺀다. */ - private val ISSUE_1802_ONBOARDING = - setOf( - "com.afternote.feature.onboarding.presentation.findaccount.FindIdViewModel", - "com.afternote.feature.onboarding.presentation.login.LoginViewModel", - "com.afternote.feature.onboarding.presentation.signup.SignUpViewModel", - ) - /** * 아직 develop 에 없다. #457(PR #1624, 승인 완료)이 들여오는 네 번째 onboarding ViewModel 이라, * 이 가드가 먼저 머지되면 규칙이 생기기 전에 쓰인 그 PR 이 규칙 B 로 빨개진다. 머지 순서가 @@ -463,14 +455,13 @@ class MviContractKonsistTest { ) /** - * 전환 전 49개. `app` 의 ViewModel 2개는 이 규칙의 대상이 아니라 목록에도 없다 — + * 전환 전 46개 — onboarding 3개는 #1802 파일럿이 전환해 빠졌다. `app` 의 ViewModel 2개는 이 규칙의 대상이 아니라 목록에도 없다 — * 규칙 B 가 `feature/…/presentation` 만 보기 때문이고, 그 2개는 #1809 가 처리한다. * * 목록이 비면 규칙 B 의 예외(`- PENDING_MVI_MIGRATION`)도 함께 지운다. */ val PENDING_MVI_MIGRATION = - ISSUE_1802_ONBOARDING + - PENDING_ARRIVAL_ONBOARDING + + PENDING_ARRIVAL_ONBOARDING + ISSUE_1803_RECEIVER + ISSUE_1804_AFTERNOTE + ISSUE_1805_SETTING + diff --git a/konsist/src/test/kotlin/com/afternote/konsist/ProductionVisibilityKonsistTest.kt b/konsist/src/test/kotlin/com/afternote/konsist/ProductionVisibilityKonsistTest.kt index 027d37e0e4..800069cb4f 100644 --- a/konsist/src/test/kotlin/com/afternote/konsist/ProductionVisibilityKonsistTest.kt +++ b/konsist/src/test/kotlin/com/afternote/konsist/ProductionVisibilityKonsistTest.kt @@ -323,7 +323,7 @@ class ProductionVisibilityKonsistTest { "feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DiaryWriteScreen.kt::function DiaryWriteScreenContent [internal]", ) - /** #1675 해소. 프로필 이미지 선택 결과 helper 는 private 이고 테스트는 화면 동작으로 본다. */ + /** #1675·#1829 해소. Screen과 Content는 각자 파일에서 수명·렌더 책임을 갖는다. */ val ISSUE_1675_ONBOARDING = emptySet() val ISSUE_1676_RECEIVER = @@ -388,12 +388,6 @@ class ProductionVisibilityKonsistTest { "core/ui/src/main/kotlin/com/afternote/core/ui/button/" + "AfternoteRadioGroup.kt::function AfternoteRadioGroup [implicit-public]" to "core:ui README에 문서화된 공용 컴포넌트", - "core/ui/src/main/kotlin/com/afternote/core/ui/mvi/" + - "MviViewModel.kt::class MviViewModel [implicit-public]" to - "MVI 베이스 계약(#1800) — 화면 전환 이슈가 상속하기 시작하면 프로덕션 참조가 생긴다", - "core/ui/src/main/kotlin/com/afternote/core/ui/mvi/" + - "ObserveSignal.kt::function ObserveSignal [implicit-public]" to - "MVI 일회성 신호 소비 관용구(#1800) — 화면 전환 이슈가 소비처를 만든다", "core/ui/src/main/kotlin/com/afternote/core/ui/popup/" + "AfternoteActionMenu.kt::class ActionMenuItem [implicit-public]" to "AfternoteActionMenu 파라미터와 editDeleteActionMenuItems 반환값을 외부 feature가 타입 추론 소비", @@ -403,9 +397,6 @@ class ProductionVisibilityKonsistTest { "feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/" + "presentation/editor/memorial/MemorialMediaSourceState.kt::class MemorialMediaSourceState [internal]" to "다른 프로덕션 파일이 반환값을 타입 추론으로 소비", - "feature/onboarding/presentation/src/main/java/com/afternote/feature/onboarding/presentation/terms/" + - "OnboardingTermsScreen.kt::class TermsType [implicit-public]" to - "OnboardingTermsScreen 콜백이 노출하는 화면 계약 타입", "feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/dto/" + "ReceiverAfternoteDto.kt::class ReceivedMemorialVideoDto [implicit-public]" to "ReceivedPlaylistDto.memorialVideo가 노출하는 직렬화 계약 타입",