diff --git a/app/src/main/java/com/nativelap/heartguard/view/component/auth/AuthVerificationCodeInput.kt b/app/src/main/java/com/nativelap/heartguard/view/component/auth/AuthVerificationCodeInput.kt
index b9595a7c..d822438a 100644
--- a/app/src/main/java/com/nativelap/heartguard/view/component/auth/AuthVerificationCodeInput.kt
+++ b/app/src/main/java/com/nativelap/heartguard/view/component/auth/AuthVerificationCodeInput.kt
@@ -26,6 +26,7 @@ import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.error
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.KeyboardType
@@ -42,6 +43,7 @@ internal fun AuthVerificationCodeInput(
code: String,
onCodeChange: (String) -> Unit,
enabled: Boolean,
+ errorDescription: String? = null,
modifier: Modifier = Modifier,
) {
var isFocused by remember { mutableStateOf(false) }
@@ -59,7 +61,10 @@ internal fun AuthVerificationCodeInput(
.fillMaxWidth()
.keyboardInputBounds()
.onFocusChanged { focusState -> isFocused = focusState.isFocused }
- .semantics { contentDescription = accessibilityDescription },
+ .semantics {
+ contentDescription = accessibilityDescription
+ errorDescription?.let { description -> error(description) }
+ },
enabled = enabled,
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
@@ -85,12 +90,12 @@ internal fun AuthVerificationCodeInput(
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(CODE_CELL_CORNER_RADIUS),
).border(
- width = if (isCurrentCell) 2.dp else 0.dp,
+ width = if (errorDescription != null || isCurrentCell) 2.dp else 0.dp,
color =
- if (isCurrentCell) {
- MaterialTheme.extraColors.authPrimary
- } else {
- androidx.compose.ui.graphics.Color.Transparent
+ when {
+ errorDescription != null -> MaterialTheme.colorScheme.error
+ isCurrentCell -> MaterialTheme.extraColors.authPrimary
+ else -> androidx.compose.ui.graphics.Color.Transparent
},
shape = RoundedCornerShape(CODE_CELL_CORNER_RADIUS),
).padding(horizontal = 4.dp),
@@ -130,6 +135,7 @@ private fun AuthVerificationCodeInputPreview() {
code = "123",
onCodeChange = {},
enabled = true,
+ errorDescription = stringResource(R.string.auth_signup_invalid_code_error),
)
}
}
diff --git a/app/src/main/java/com/nativelap/heartguard/view/route/auth/AuthRoutes.kt b/app/src/main/java/com/nativelap/heartguard/view/route/auth/AuthRoutes.kt
index 85a01467..052c9cf7 100644
--- a/app/src/main/java/com/nativelap/heartguard/view/route/auth/AuthRoutes.kt
+++ b/app/src/main/java/com/nativelap/heartguard/view/route/auth/AuthRoutes.kt
@@ -180,6 +180,7 @@ internal fun HeartGuardSignUpRoute(
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val normalizedEmail = email.trim().lowercase(Locale.ROOT)
val isCodeSentForCurrentEmail = uiState.emailForRequestedCode == normalizedEmail
+ val emailCodeRemainingSeconds = uiState.emailCodeRemainingSecondsByEmail[normalizedEmail] ?: 0
BackHandler(
enabled = uiState.isSubmitting || uiState.isRequestingEmailCode || uiState.isVerifyingEmailCode || step > 0,
@@ -275,9 +276,24 @@ internal fun HeartGuardSignUpRoute(
stringResource(R.string.auth_signup_registration_error)
}
}
+ val isInvalidVerificationCode =
+ uiState.failure == AppAccountSignUpFailure.INVALID_VERIFICATION_CODE
+ val isExpiredVerificationCode = isInvalidVerificationCode && emailCodeRemainingSeconds <= 0
val statusMessage =
- uiState.failureMessage?.takeIf { serverMessage -> serverMessage.isNotBlank() }
- ?: fallbackStatusMessage
+ when {
+ isInvalidVerificationCode && !isExpiredVerificationCode -> {
+ fallbackStatusMessage
+ }
+
+ isExpiredVerificationCode -> {
+ stringResource(R.string.auth_signup_expired_code_error)
+ }
+
+ else -> {
+ uiState.failureMessage?.takeIf { serverMessage -> serverMessage.isNotBlank() }
+ ?: fallbackStatusMessage
+ }
+ }
AuthSignUpScreen(
name = name,
@@ -289,10 +305,11 @@ internal fun HeartGuardSignUpRoute(
isEmailCodeSent = isCodeSentForCurrentEmail,
isEmailCodeResendBlocked =
(uiState.emailCodeCooldownSecondsByEmail[normalizedEmail] ?: 0) > 0,
- emailCodeRemainingSeconds = uiState.emailCodeRemainingSecondsByEmail[normalizedEmail] ?: 0,
+ emailCodeRemainingSeconds = emailCodeRemainingSeconds,
isSubmitting = uiState.isSubmitting || uiState.isVerifyingEmailCode,
statusMessage = statusMessage,
isStatusError = uiState.failure != null,
+ isVerificationCodeError = isInvalidVerificationCode,
step = step,
onEvent = { event ->
when (event) {
@@ -311,6 +328,9 @@ internal fun HeartGuardSignUpRoute(
}
is AuthSignUpScreenEvent.VerificationCodeChanged -> {
+ if (uiState.failure == AppAccountSignUpFailure.INVALID_VERIFICATION_CODE) {
+ viewModel.clearEmailCodeVerification()
+ }
verificationCode = event.verificationCode.filter { digit -> digit in '0'..'9' }.take(6)
}
diff --git a/app/src/main/java/com/nativelap/heartguard/view/screen/auth/AuthSignUpScreen.kt b/app/src/main/java/com/nativelap/heartguard/view/screen/auth/AuthSignUpScreen.kt
index 91945e30..559010af 100644
--- a/app/src/main/java/com/nativelap/heartguard/view/screen/auth/AuthSignUpScreen.kt
+++ b/app/src/main/java/com/nativelap/heartguard/view/screen/auth/AuthSignUpScreen.kt
@@ -44,6 +44,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.LiveRegionMode
+import androidx.compose.ui.semantics.liveRegion
+import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
@@ -76,6 +79,7 @@ internal fun AuthSignUpScreen(
isSubmitting: Boolean,
statusMessage: String?,
isStatusError: Boolean,
+ isVerificationCodeError: Boolean,
step: Int,
onEvent: (AuthSignUpScreenEvent) -> Unit,
modifier: Modifier = Modifier,
@@ -251,8 +255,13 @@ internal fun AuthSignUpScreen(
onEvent(AuthSignUpScreenEvent.VerificationCodeChanged(code))
},
enabled = !isSubmitting && !isSendingEmailCode && emailCodeRemainingSeconds > 0,
+ errorDescription = statusMessage.takeIf { isVerificationCodeError },
+ )
+ SignUpErrorMessage(
+ isError = isStatusError,
+ message = statusMessage,
+ isLiveRegion = isVerificationCodeError,
)
- SignUpErrorMessage(isStatusError, statusMessage)
}
if (step == SIGN_UP_CODE_STEP) {
@@ -261,16 +270,26 @@ internal fun AuthSignUpScreen(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
- Text(
- text =
- stringResource(
- R.string.auth_signup_code_remaining_time,
- emailCodeRemainingSeconds / SECONDS_PER_MINUTE,
- emailCodeRemainingSeconds % SECONDS_PER_MINUTE,
- ),
- color = MaterialTheme.extraColors.secondaryText,
- style = MaterialTheme.typography.bodySmall,
- )
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = stringResource(R.string.auth_signup_code_remaining_time_label),
+ color = MaterialTheme.extraColors.secondaryText,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ Text(
+ text =
+ stringResource(
+ R.string.auth_signup_code_remaining_time_value,
+ emailCodeRemainingSeconds / SECONDS_PER_MINUTE,
+ emailCodeRemainingSeconds % SECONDS_PER_MINUTE,
+ ),
+ color = MaterialTheme.colorScheme.primary,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
TextButton(
onClick = { onEvent(AuthSignUpScreenEvent.RequestEmailCodeClicked) },
enabled =
@@ -459,11 +478,23 @@ internal fun AuthSignUpScreen(
private fun SignUpErrorMessage(
isError: Boolean,
message: String?,
+ isLiveRegion: Boolean = false,
) {
if (isError && message != null) {
Text(
text = message,
- modifier = Modifier.padding(top = HeartGuardSpacing.AuthErrorMessageGap),
+ modifier =
+ Modifier
+ .padding(top = HeartGuardSpacing.AuthErrorMessageGap)
+ .then(
+ if (isLiveRegion) {
+ Modifier.semantics {
+ liveRegion = LiveRegionMode.Polite
+ }
+ } else {
+ Modifier
+ },
+ ),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
@@ -556,6 +587,7 @@ private fun AuthSignUpScreenPreview() {
isSubmitting = false,
statusMessage = null,
isStatusError = false,
+ isVerificationCodeError = false,
step = SIGN_UP_EMAIL_STEP,
onEvent = {},
)
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 02f865cf..43861bbf 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -36,7 +36,8 @@
인증번호 다시 받기
재요청
%1$d초
- 남은 시간 %1$02d:%2$02d
+ 남은 시간
+ %1$02d:%2$02d
인증번호 입력
%1$d자리 입력됨
인증 완료
@@ -53,7 +54,8 @@
먼저 이메일 인증코드를 받아주세요.
입력한 정보를 확인해주세요.
가입에 실패했습니다. 코드 만료·이메일 중복 여부를 확인해주세요.
- 인증코드가 올바르지 않거나 만료되었습니다. 코드를 다시 확인해주세요.
+ 인증번호가 올바르지 않아요. 다시 확인해주세요.
+ 인증번호가 만료되었어요. 새 인증번호를 받아주세요.
회사 계정 설정이 완료되지 않았습니다. 관리자에게 문의해주세요.
로그인
8자 이상 입력해주세요
diff --git a/docs/PRODUCT_BRIEF.md b/docs/PRODUCT_BRIEF.md
index bbf79e31..932897e3 100644
--- a/docs/PRODUCT_BRIEF.md
+++ b/docs/PRODUCT_BRIEF.md
@@ -20,6 +20,15 @@
- 포기할 수 있는 것: Coil 메모리 사용량은 최대한 낮게 유지하고, 서명 URL을 `expiresAt`까지 세션 메모리에서 재사용하며, 다른 기기의 사진 변경은 URL 만료 전까지 늦게 반영될 수 있음 (출처: 사용자 확인 답변, 2026-11-05)
- 이렇게 한 이유: 이미지 지연을 줄이려는 목표에 맞추면서 메모리 사용을 아끼기 위해 세션 URL 캐시를 선택함 (출처: 사용자 요청 및 확인 답변, 2026-11-05)
+## 회원가입 인증번호 오류 표시
+
+- 마지막 확인: 2026-11-07
+- 핵심 사용자: 폭염 현장 작업자 (출처: `README.md`의 개요, 2026-11-06 확인)
+- 해결할 문제: 인증번호가 틀렸을 때 입력 오류를 화면에서 즉시 알아보기 어려움 (출처: 사용자 요청 및 제공 스크린샷, 2026-11-06)
+- 가장 중요한 점: 입력칸 오류 테두리와 오류 문구로 불일치를 바로 인지하게 함 (출처: 사용자 답변, 2026-11-06)
+- 허용할 절충: 현재 앱/API 범위에서 처리하고, 서버 계약이 필요한 남은 시도 횟수는 표시하지 않음 (출처: 사용자 확인 답변, 2026-11-06)
+- 이렇게 한 이유: 서버 변경 없이 인증 실패 피드백을 즉시 알아볼 수 있게 하려는 사용자 선택 (출처: 사용자 답변, 2026-11-06)
+
## 프로필 사진 변경 시트 제목 간격
- 마지막 확인: 2026-11-06
@@ -49,6 +58,7 @@
## 변경 이력
- 2026-11-06: 홈 위치 기반 날씨 — coarse 위치 권한 동의 후 현재 좌표를 사용하고, 실패 시 기존 팀 날씨 또는 수동 입력으로 대체.
+- 2026-11-06: 회원가입 인증번호 오류 표시 — 불일치·재입력·만료 상태를 구분하고 입력 오류 의미를 보조기술에 전달.
- 2026-11-05: 프로필 이미지 로딩 최적화 — 사용자 목표와 승인한 세션 캐시 절충을 반영.
- 2026-11-06: 프로필 사진 변경 시트 제목 간격 — 제목 위쪽 여백을 약 5.dp 늘림.
- 2026-11-06: 로그인·회원가입 인증 버튼 너비 — 입력 영역과 정렬하고 기존 반응형 여백을 유지.