티스토리 뷰

계정 복구 서비스 UI

 

이메일 찾기는 이름을 통해 진행할 것이고,

비밀번호 재설정은 이메일 인증을 통해 진행할 것이다.

 

두 화면을 따로 구현할 수도 있지만 같은 나같은 경우는

텍스트 스위치 위젯을 통해 한 화면에서 계정 복구를 진행할 수 있도록 했다.

 

 

TextSwitch

fun ContentDrawScope.drawWithLayer(block: ContentDrawScope.() -> Unit) {
    with(drawContext.canvas.nativeCanvas) {
        val checkPoint = saveLayer(null, null)
        block()
        restoreToCount(checkPoint)
    }
}

@Composable
fun TextSwitch(
    modifier: Modifier = Modifier,
    selectedIndex: Int,
    items: List<String>,
    onSelectionChange: (Int) -> Unit
) {
    val selectedTextColor = CustomTheme.colors.text
    val buttonColor = CustomTheme.colors.background

    BoxWithConstraints(
        modifier
            .height(40.dp)
            .clip(RoundedCornerShape(8.dp))
            .background(CustomTheme.colors.lightGray)
            .padding(4.dp)
    ) {
        if (items.isNotEmpty()) {

            val maxWidth = this.maxWidth
            val tabWidth = maxWidth / items.size

            val indicatorOffset by animateDpAsState(
                targetValue = tabWidth * selectedIndex,
                animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing),
                label = "indicator offset"
            )

            // 흰 버튼 그림자 설정
            Box(
                modifier = Modifier
                    .offset(x = indicatorOffset)
                    .shadow(4.dp, RoundedCornerShape(8.dp))
                    .width(tabWidth)
                    .fillMaxHeight()
            )

            Row(modifier = Modifier
                .fillMaxWidth()
                .drawWithContent {

                    // 선택된 항목의 글자 설정
                    val padding = 4.dp.toPx()
                    drawRoundRect(
                        topLeft = Offset(x = indicatorOffset.toPx() + padding, padding),
                        size = Size(size.width / 2 - padding * 2, size.height - padding * 2),
                        color = selectedTextColor,
                        cornerRadius = CornerRadius(x = 8.dp.toPx(), y = 8.dp.toPx()),
                    )

                    drawWithLayer {
                        drawContent()

                        // 흰 버튼 설정
                        drawRoundRect(
                            topLeft = Offset(x = indicatorOffset.toPx(), 0f),
                            size = Size(size.width / 2, size.height),
                            color = buttonColor,
                            cornerRadius = CornerRadius(x = 8.dp.toPx(), y = 8.dp.toPx()),
                            blendMode = BlendMode.SrcOut
                        )
                    }

                }
            ) {
                items.forEachIndexed { index, text ->
                    Box(
                        modifier = Modifier
                            .width(tabWidth)
                            .fillMaxHeight()
                            .clickable(
                                interactionSource = remember {
                                    MutableInteractionSource()
                                },
                                indication = null,
                                onClick = {
                                    onSelectionChange(index)
                                }
                            ),
                        contentAlignment = Alignment.Center
                    ) {
                        Text(
                            text = text,
                            style = CustomTheme.typography.b4Regular.copy(
                                color = CustomTheme.colors.border
                            )
                        )
                    }
                }
            }
        }
    }
}

어떻게 구현해야 하나 싶었는데 StackOverFlow에 찾아보니까

내 생각대로 동작하는 위젯이 있어서 바로 가져왔다.

 

 

StoreRepository

class StoreRepositoryImpl @Inject constructor(
    private val store: FirebaseFirestore
) : StoreRepository {
	
    ...
    
    override suspend fun getEmailByName(name: String): String? {
        try {
            val query = store.collection(USER)
                .whereEqualTo("name", name).limit(1).get().await()
            val document = query.documents.firstOrNull()
            return document?.getString("email")
        } catch (e: Exception) {
            throw Exception("이메일 찾기 오류: ${e.message}")
        }
    }

    override suspend fun resetPwByEmail(email: String, pw: String) {
        try {
            val query = store.collection(USER)
                .whereEqualTo("email", email).limit(1).get().await()
            val document = query.documents.first()
            document.reference.update("pw", pw).await()
        } catch (e: Exception) {
            throw Exception("비밀번호 재설정 오류: ${e.message}")
        }
    }
}

이메일 찾기 및 비밀번호 재설정 동작은 파이어 스토어에

저장된 유저 정보를 기반으로 구현하였다. 

 

 

계정 복구 상태 정의

sealed class RecoveryState {
    data object Init : RecoveryState()
    data object EmailLoading : RecoveryState()
    data object EmailVerified : RecoveryState()
    data object FindEmail : RecoveryState()
    data object ResetPw : RecoveryState()
    data object Failure : RecoveryState()
    data class Error(val error: String?) : RecoveryState()
}

햇갈릴까봐 설명하자면 Failure는 이메일 찾기를 실패한 상태이고,

Error는 비동기 함수가 정상적으로 실행되지 못한 상태에 해당한다.

 

 

RecoveryViewModel

@HiltViewModel
class RecoveryViewModel @Inject constructor(
    @ApplicationContext private val context: Context,
    private val authRepository: AuthRepository,
    private val storeRepository: StoreRepository
) : ViewModel() {

    private val _recoveryState = MutableStateFlow<RecoveryState>(RecoveryState.Init)
    val recoveryState: StateFlow<RecoveryState> = _recoveryState.asStateFlow()

    private val _userName = mutableStateOf("")
    val userName: State<String> = _userName

    private val _userEmail = mutableStateOf("")
    val userEmail: State<String> = _userEmail

    private val _emailValidation = mutableStateOf("")
    val emailValidation: State<String> = _emailValidation

    private val _pwValidation = mutableStateOf("")
    val pwValidation: State<String> = _pwValidation

    private val _pwCheckValidation = mutableStateOf("")
    val pwCheckValidation: State<String> = _pwCheckValidation

     private var isEmailVerified = false

    private var isPwValid = false
    private var isPwCheckValid = false

    suspend fun emailValidationBtnAction(email: String) {
        when {
            email.isBlank() -> {
                _emailValidation.value = "이메일을 입력해주세요."
            }

            !isValidEmail(email) -> {
                _emailValidation.value = "올바른 이메일 형식이 아닙니다."
            }

            isValidEmail(email) -> {
                _recoveryState.value = RecoveryState.EmailLoading
                checkEmailAccount(email)
            }
        }
    }

    private suspend fun checkEmailAccount(email: String) {
        try {
            val isEmailDuplicated = storeRepository.isEmailDuplicate(email)
            if (isEmailDuplicated) {
                _emailValidation.value = "이메일 인증 요청을 보내는 중입니다."
                sendEmailVerification(email)
                saveUserEmail(context, email)
                checkEmailVerification()
            } else {
                _recoveryState.value = RecoveryState.Init
                _emailValidation.value = "등록된 계정을 찾을 수 없습니다."
            }
        } catch (e: Exception) {
            _recoveryState.value = RecoveryState.Error(e.message)
        }
    }

    private suspend fun sendEmailVerification(email: String) {
        val user = authRepository.createTempAccount(email).user
        authRepository.sendEmailVerification(user)
        _emailValidation.value = "이메일 인증을 진행해주세요."
    }

    private suspend fun checkEmailVerification() {
        var pollingRun = true

        while (pollingRun) {
            val complete = authRepository.checkEmailVerification()
            if (complete == true) {
                isEmailVerified = complete
                pollingRun = false
            }
            delay(2000L)
        }

        if (isEmailVerified) {
            _recoveryState.value = RecoveryState.EmailVerified
            _emailValidation.value = "이메일 인증이 완료되었습니다."
        }
    }

    suspend fun findEmailBtnAction(name: String) {
        try {
            val email = storeRepository.getEmailByName(name)
            if (email != null) {
                _userName.value = name
                _userEmail.value = email
                _recoveryState.value = RecoveryState.FindEmail
            } else {
                _recoveryState.value = RecoveryState.Failure
            }
        } catch (e: Exception) {
            _recoveryState.value = RecoveryState.Error(e.message)
        }
    }

    suspend fun resetPwBtnAction(
        email: String,
        pw: String,
        pwCheck: String
    ) {
        if (isEmailVerified) {
            if (pw.isEmpty()) {
                _pwValidation.value = "비밀번호를 입력해 주세요."
            } else if (!isValidPw(pw)) {
                _pwValidation.value = "비밀번호 형식이 올바르지 않습니다."
            } else {
                _pwValidation.value = ""
                isPwValid = true
            }
        }

        if (isEmailVerified && isPwValid) {
            if (pw != pwCheck) {
                _pwCheckValidation.value = "비밀번호가 일치하지 않습니다."
            } else {
                _pwCheckValidation.value = ""
                isPwCheckValid = true
            }
        }

        if (isEmailVerified && isPwValid && isPwCheckValid) {
            resetPw(email, pw)
            _recoveryState.value = RecoveryState.ResetPw
        }
    }

    private suspend fun resetPw(email: String, pw: String){
        try {
            storeRepository.resetPwByEmail(email, hashPw(pw))
        }catch (e:Exception){
            _recoveryState.value = RecoveryState.Error(e.message)
        }
    }

    fun deleteTempAccount() {
        val inputEmail = getUserEmail(context)
        if (inputEmail != null) {
            try {
                authRepository.loginTempAccount(inputEmail)
                authRepository.deleteTempAccount()
            } catch (e: Exception) {
                _recoveryState.value = RecoveryState.Error(e.message)
            }
        }
    }

    fun initRecoveryState(){
        _recoveryState.value = RecoveryState.Init
        _emailValidation.value = ""
        _pwValidation.value = ""
        _pwCheckValidation.value = ""
    }

    fun initRecoveryResult() {
        _recoveryState.value = RecoveryState.Init
    }
}

이메일 인증 로직은 회원가입과 전반적으로 동일하나

이메일이 중복될 때 (db에 해당 이메일로 생성된 계정이 존재할 때)

이메일 인증 요청 메일을 수신하도록 한다.

 

해당 뷰모델은 MyApp 컴포저블 함수에서 선언되어 해당 뷰모델을 

사용하는 화면에서 인자로 받아 사용되기에 상태가 직접적으로 초기화되지 않는다.

 

따라서 해당 화면이 종료되는 시점에서 모든 변수의 상태를 

초기값으로 초기화하하는 함수를 실행해야한다. 

 

 

RecoveryScreen

enum class Recovery(val mode: Int) {
    FIND_EMAIL(0),
    RESET_PASSWORD(1)
}

@Composable
fun RecoveryScreen(navController: NavHostController, recoveryViewModel: RecoveryViewModel) {

    // 비동기 관련
    val coroutineScope = rememberCoroutineScope()

    // 토스트 메시지 출력을 위해 선언
    val context = LocalContext.current

    // 밸리데이션 메시지 선언
    val emailValidation = recoveryViewModel.emailValidation.value
    val pwValidation = recoveryViewModel.pwValidation.value
    val pwCheckValidation = recoveryViewModel.pwCheckValidation.value

    // TextSwitch의 초기값 설정
    val items = remember { listOf("이메일 찾기", "비밀번호 재설정") }
    var mode by remember { mutableIntStateOf(Recovery.FIND_EMAIL.mode) }

    // 사용자 입력값
    var name by remember { mutableStateOf("") }
    var email by remember { mutableStateOf("") }
    var pw by remember { mutableStateOf("") }
    var pwCheck by remember { mutableStateOf("") }

    // 이메일 인증 작업 관련
    var isEmailLoading by remember { mutableStateOf(false) }
    var isEmailVerified by remember { mutableStateOf(false) }

    // mode에 따라 변하는 값들
    var explain by remember { mutableStateOf("") }
    var btnText by remember { mutableStateOf("") }
    var onClick by remember { mutableStateOf({}) }
    var btnEnabled by remember { mutableStateOf(false) }

    // 회원가입 상태별 동작 정의
    LaunchedEffect(Unit) {

        recoveryViewModel.deleteTempAccount()

        recoveryViewModel.recoveryState.collect { recoveryState ->
            when (recoveryState) {

                // 초기 상태 -> 비동기 작업 진행 여부 변수를 초기값으로 초기화
                is RecoveryState.Init -> {
                    isEmailLoading = false
                }

                // 이메일 인증 시도 시
                is RecoveryState.EmailLoading -> {
                    isEmailLoading = true
                }

                // 이메일 인증 완료 시
                is RecoveryState.EmailVerified -> {
                    isEmailLoading = false
                    isEmailVerified = true
                    recoveryViewModel.deleteTempAccount()
                }

                // 이메일 찾기 성공 시
                is RecoveryState.FindEmail -> {
                    recoveryViewModel.initRecoveryResult()
                    val route = "$COMPLETE/find_email"
                    navController.navigate(route)
                }

                // 비밀번호 재설정 성공 시
                is RecoveryState.ResetPw -> {
                    recoveryViewModel.initRecoveryResult()
                    val route = "$COMPLETE/reset_password"
                    navController.navigate(route) {
                        popUpTo(RECOVERY) { inclusive = true }
                    }
                }

                // 이메일 인증 실패 시
                is RecoveryState.Failure -> {
                    showToast(context, "등록된 계정을 찾을 수 없습니다.")
                }

                // 에러 발생 시
                is RecoveryState.Error -> {
                    Log.e("SignUp", "${recoveryState.error}")
                }
            }
        }
    }

    // 화면을 떠날 때
    DisposableEffect(navController) {
        onDispose {
            recoveryViewModel.deleteTempAccount()
            recoveryViewModel.initRecoveryState()
        }
    }

    when (mode) {
        Recovery.FIND_EMAIL.mode -> {
            explain = "이름을 입력해\n계정 이메일을 확인하세요."
            btnText = "이메일 찾기"
            onClick = {
                coroutineScope.launch {
                    recoveryViewModel.findEmailBtnAction(name)
                }
            }
            btnEnabled = name.isNotEmpty()
        }

        Recovery.RESET_PASSWORD.mode -> {
            explain = "이메일을 인증해\n비밀번호 재설정을 진행하세요"
            btnText = "비밀번호 재설정"
            onClick = {
                coroutineScope.launch {
                    recoveryViewModel.resetPwBtnAction(email, pw, pwCheck)
                }
            }
            btnEnabled = isEmailVerified && pw.isNotEmpty() && pwCheck.isNotEmpty()
        }
    }

    ContentResizingScreen(
        contentColumn = {
            Spacer(modifier = Modifier.height(48.dp))
            Text(
                text = explain,
                style = CustomTheme.typography.h3Bold
            )
            Spacer(modifier = Modifier.height(28.dp))
            TextSwitch(
                selectedIndex = mode,
                items = items,
                onSelectionChange = { mode = it }
            )
            Spacer(modifier = Modifier.height(40.dp))

            when (mode) {
                Recovery.FIND_EMAIL.mode -> {
                    Text(text = "이름")
                    Spacer(modifier = Modifier.height(8.dp))
                    OutlinedTextField(
                        value = name,
                        onValueChange = { name = it },
                        hint = "이름 입력",
                        maxLength = 10,
                        isLastField = true
                    )
                }

                Recovery.RESET_PASSWORD.mode -> {
                    TextAndValidation(
                        text = "계정 이메일",
                        validation = emailValidation,
                        isVerified = isEmailVerified
                    )
                    Spacer(modifier = Modifier.height(8.dp))
                    TextFieldAndButton(
                        textField = {
                            OutlinedTextField(
                                value = email,
                                onValueChange = { email = it },
                                hint = "계정 이메일 입력",
                                enabled = !isEmailLoading && !isEmailVerified
                            )
                        },
                        button = {
                            OutlinedButton(
                                onClick = {
                                    coroutineScope.launch {
                                        recoveryViewModel.emailValidationBtnAction(email)
                                    }
                                },
                                text = "인증 요청",
                                color = CustomTheme.colors.skyBlue,
                                enabled = !isEmailLoading && !isEmailVerified
                            )
                        }
                    )
                    Spacer(modifier = Modifier.height(28.dp))
                    TextAndValidation(
                        text = "새 비밀번호",
                        validation = pwValidation,
                    )
                    Spacer(modifier = Modifier.height(8.dp))
                    OutlinedTextField(
                        value = pw,
                        onValueChange = { pw = it },
                        hint = "비밀번호 입력 (영문, 숫자, 특수문자 포함 8~20자)",
                        isPassword = true
                    )
                    Spacer(modifier = Modifier.height(28.dp))
                    TextAndValidation(
                        text = "새 비밀번호 확인",
                        validation = pwCheckValidation,
                    )
                    Spacer(modifier = Modifier.height(8.dp))
                    OutlinedTextField(
                        value = pwCheck,
                        onValueChange = { pwCheck = it },
                        hint = "비밀번호 재입력",
                        isPassword = true,
                        isLastField = true
                    )
                }
            }
        },
        bottomRow = {
            Box(modifier = Modifier.weight(1f)) {
                Button(
                    text = "돌아가기",
                    onClick = {
                        navController.navigate(LOGIN) {
                            popUpTo(RECOVERY) { inclusive = true }
                        }
                    },
                    color = CustomTheme.colors.orange
                )
            }
            Spacer(modifier = Modifier.width(16.dp))
            Box(modifier = Modifier.weight(1f)) {
                Button(
                    text = btnText,
                    onClick = onClick,
                    color = CustomTheme.colors.skyBlue,
                    enabled = btnEnabled
                )
            }
        }
    )
}

if문 써서 처리할수도 있지만 좀 더 직관적으로 코드를 작성하기 위해

enum class에 각 계정 복구 모드를 정의하여 사용하였다.

 

mode(int)는 textSwitch위젯의 현재 인덱스에 해당하는 값이다. 

 

 

CompleteScreen

@Composable
fun MyApp() {
    val context = LocalContext.current
    val autoLogin = getAutoLogin(context)
    val navController = rememberNavController()
    val recoveryViewModel = hiltViewModel<RecoveryViewModel>()

    NavHost(
        navController = navController,
        startDestination = if (autoLogin) HOME else LOGIN,
        exitTransition = { ExitTransition.None }
    ) {
        ...
        composable(route = RECOVERY) { RecoveryScreen(navController, recoveryViewModel) }
        composable(
            route = "$COMPLETE/{complete_task}",
            arguments = listOf(navArgument("complete_task") { type = NavType.StringType })
        ) {
            val msg = it.arguments?.getString("complete_task")
            CompleteScreen(navController, msg, recoveryViewModel)
        }
    }
}

complete_task라는 매개변수를 완료화면 경로에 포함하고,

계정복구화면과 완료화면이 동일한 뷰모델을 공유하도록 하여 데이터를 전달한다.

 

@Composable
fun CompleteScreen(
    navController: NavHostController,
    complete: String?,
    recoveryViewModel: RecoveryViewModel? = null
) {

    // 이메일 찾기 관련 변수들
    val userName = recoveryViewModel?.userName?.value
    val userEmail = recoveryViewModel?.userEmail?.value

    // 경로 인자에 따라 변하는 값들
    var explain by remember { mutableStateOf(AnnotatedString("")) }
    var buttonText by remember { mutableStateOf("") }
    var route by remember { mutableStateOf("") }

    // 경로 인자에 따라 설명, 버튼 텍스트, 루트를 초기화
    when (complete) {
        "sign_up" -> {
            explain = buildAnnotatedString {
                append("회원가입이 완료되었습니다.")
            }
            buttonText = "로그인"
            route = LOGIN
        }

        "find_email" -> {
            explain = buildAnnotatedString {
                append("${userName}님의 계정 이메일은\n")
                withStyle(style = SpanStyle(color = CustomTheme.colors.skyBlue)) {
                    append(userEmail)
                }
                append(" 입니다.")
            }
            buttonText = "확인"
            route = RECOVERY
        }

        "reset_password" -> {
            explain = buildAnnotatedString {
                append("비밀번호 재설정이 완료되었습니다.")
            }
            buttonText = "로그인"
            route = LOGIN
        }
    }

    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center
    ) {
        Column(
            modifier = Modifier
                .fillMaxWidth()
                .weight(1f),
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.Center
        ) {
            Svg(
                drawableId = R.drawable.ic_complete,
                size = 32.dp,
                iconColor = CustomTheme.colors.check
            )
            Spacer(modifier = Modifier.height(28.dp))
            Text(
                text = explain,
                style = CustomTheme.typography.b1Medium,
                color = CustomTheme.colors.text,
                textAlign = TextAlign.Center
            )
        }
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .padding(start = 40.dp, end = 40.dp, bottom = 28.dp)
        ) {
            Button(
                text = buttonText,
                onClick = {
                    navController.navigate(route) {
                        popUpTo(COMPLETE) { inclusive = true }
                    }
                },
                color = CustomTheme.colors.skyBlue
            )
        }
    }
}

 

전달받은 경로 인자별로 다른 텍스트를 출력하고 버튼 동작을 설정한다. 

 

 

회고

사실 이걸 구현해야하나 말아야하나 고민을 좀 했는데

아무래도 있는게 좋을 거 같아서 여러 화면 참고하면서 구현해봤다.

 

드디어 길고 길었던 계정 관련 기능 개발이 끝났다.

 

사실 실무에서는 파이어베이스가 아닌 따로 서버를 구축하여 사용하고, 

하나하나 따지고 보면 보완할 점도 많은 로직이겠지만

일단은 내가 생각한 계정 관련 기능을 전부 구현했다는 사실에 만족한다.

 

 

개인사정

원래 여기까지 하고 이력서를 쓰려고 했는데 생각해보니까

필자가 고졸에 미필인데 뽑아줄 거 같지도 않고 친구들 다 갔다오는데

군대 계속 안갔다오는 것도 마음에 걸려서 준비 좀 하고 군대부터 다녀오려고 한다.

 

토익 준비하고 카투사 지원해보고 안되면 개인시간 많이 준다는 공군가려고 한다.

갔다와서 프로젝트를 마저 하던지 아니면 짬나는 시간에 하던지 해야겠다.

당분간은 토익을 공부할 예정이라 개발 일지 작성은 한동안 중단을 할 예정이다.

 

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/02   »
1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28
글 보관함