diff --git a/.gitignore b/.gitignore index 7d9c0e4..d1a0b92 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ xcuserdata !src/**/build/ local.properties .idea +**/network_security_config.xml .DS_Store captures .externalNativeBuild diff --git a/composeApp/src/commonMain/kotlin/org/whosin/client/App.kt b/composeApp/src/commonMain/kotlin/org/whosin/client/App.kt index 6607201..c8e7b3c 100644 --- a/composeApp/src/commonMain/kotlin/org/whosin/client/App.kt +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/App.kt @@ -3,12 +3,15 @@ package org.whosin.client import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.navigation.compose.rememberNavController import org.jetbrains.compose.ui.tooling.preview.Preview +import org.whosin.client.core.auth.TokenExpiredManager +import org.whosin.client.core.navigation.Route import org.whosin.client.core.navigation.WhosInNavGraph -import org.whosin.client.presentation.dummy.DummyScreen -import org.whosin.client.presentation.dummy.TokenTestScreen import ui.theme.WhosInTheme @@ -17,6 +20,16 @@ import ui.theme.WhosInTheme fun App() { WhosInTheme { val navController = rememberNavController() + val isTokenExpired by TokenExpiredManager.isTokenExpired.collectAsState() + + LaunchedEffect(isTokenExpired) { + if (isTokenExpired) { + navController.navigate(Route.Login) { + popUpTo(0) { inclusive = true } + } + TokenExpiredManager.reset() + } + } WhosInNavGraph( modifier = Modifier diff --git a/composeApp/src/commonMain/kotlin/org/whosin/client/core/auth/TokenExpiredManager.kt b/composeApp/src/commonMain/kotlin/org/whosin/client/core/auth/TokenExpiredManager.kt new file mode 100644 index 0000000..234deba --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/core/auth/TokenExpiredManager.kt @@ -0,0 +1,18 @@ +package org.whosin.client.core.auth + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +object TokenExpiredManager { + private val _isTokenExpired = MutableStateFlow(false) + val isTokenExpired: StateFlow = _isTokenExpired.asStateFlow() + + fun setTokenExpired() { + _isTokenExpired.value = true + } + + fun reset() { + _isTokenExpired.value = false + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/whosin/client/core/navigation/Route.kt b/composeApp/src/commonMain/kotlin/org/whosin/client/core/navigation/Route.kt index 904417f..7ef462f 100644 --- a/composeApp/src/commonMain/kotlin/org/whosin/client/core/navigation/Route.kt +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/core/navigation/Route.kt @@ -20,13 +20,13 @@ sealed interface Route { data object Signup: Route @Serializable - data object EmailVerification: Route + data class EmailVerification(val email: String): Route @Serializable - data object PasswordInput: Route + data class PasswordInput(val email: String): Route @Serializable - data object NicknameInput: Route + data class NicknameInput(val email: String, val password: String): Route @Serializable data class ClubCodeInput(val returnToMyPage: Boolean = false): Route diff --git a/composeApp/src/commonMain/kotlin/org/whosin/client/core/navigation/WhosInNavGraph.kt b/composeApp/src/commonMain/kotlin/org/whosin/client/core/navigation/WhosInNavGraph.kt index e7698a3..70fa078 100644 --- a/composeApp/src/commonMain/kotlin/org/whosin/client/core/navigation/WhosInNavGraph.kt +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/core/navigation/WhosInNavGraph.kt @@ -38,6 +38,11 @@ fun WhosInNavGraph( navController.navigate(Route.Login) { popUpTo(Route.Splash) { inclusive = true } } + }, + onNavigateToHome = { + navController.navigate(Route.Home) { + popUpTo(Route.AuthGraph) { inclusive = true } + } } ) } @@ -74,35 +79,43 @@ fun WhosInNavGraph( modifier = modifier, onNavigateBack = { navController.navigateUp() }, onNavigateToEmailVerification = { email -> - navController.navigate(Route.EmailVerification) + navController.navigate(Route.EmailVerification(email)) } ) } composable { backStackEntry -> - + val emailVerificationRoute = backStackEntry.toRoute() + EmailVerificationScreen( modifier = modifier, + email = emailVerificationRoute.email, onNavigateBack = { navController.navigateUp() }, onVerificationComplete = { - navController.navigate(Route.PasswordInput) + navController.navigate(Route.PasswordInput(emailVerificationRoute.email)) } ) } - - composable { + + composable { backStackEntry -> + val passwordInputRoute = backStackEntry.toRoute() + PasswordInputScreen( modifier = modifier, onNavigateBack = { navController.navigateUp() }, onPasswordComplete = { password, confirmPassword -> - navController.navigate(Route.NicknameInput) + navController.navigate(Route.NicknameInput(passwordInputRoute.email, password)) } ) } - - composable { + + composable { backStackEntry -> + val nicknameInputRoute = backStackEntry.toRoute() + NicknameInputScreen( modifier = modifier, + email = nicknameInputRoute.email, + password = nicknameInputRoute.password, onNavigateBack = { navController.navigateUp() }, onNavigateToClubCode = { navController.navigate(Route.ClubCodeInput(returnToMyPage = false)) diff --git a/composeApp/src/commonMain/kotlin/org/whosin/client/core/network/HttpClientFactory.kt b/composeApp/src/commonMain/kotlin/org/whosin/client/core/network/HttpClientFactory.kt index 2191610..66370ab 100644 --- a/composeApp/src/commonMain/kotlin/org/whosin/client/core/network/HttpClientFactory.kt +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/core/network/HttpClientFactory.kt @@ -21,9 +21,10 @@ import io.ktor.http.encodedPath import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json import org.whosin.client.BuildKonfig +import org.whosin.client.core.auth.TokenExpiredManager import org.whosin.client.core.datastore.TokenManager import org.whosin.client.data.dto.request.ReissueTokenRequestDto -import org.whosin.client.data.dto.response.TokenDto +import org.whosin.client.data.dto.response.ReissueTokenResponseDto object HttpClientFactory { val BASE_URL = BuildKonfig.BASE_URL @@ -45,10 +46,11 @@ object HttpClientFactory { socketTimeoutMillis = 20_000L requestTimeoutMillis = 20_000L } - install(Auth){ + install(Auth) { bearer { loadTokens { - val accessToken = tokenManager.getAccessToken() ?: "eyJhbGciOiJIUzI1NiJ9.eyJ0b2tlblR5cGUiOiJhY2Nlc3MiLCJ1c2VySWQiOjUsInByb3ZpZGVySWQiOiJsb2NhbGhvc3QiLCJuYW1lIjoi7Iug7KKF7JykIiwicm9sZSI6IlJPTEVfTUVNQkVSIiwiaWF0IjoxNzU5MzgyMzg3LCJleHAiOjE3NTk5ODcxODd9.kT9IH60aCA-6ByEITb-_qPAJY0Oik1bbPKqcBWXzHIk" + val accessToken = tokenManager.getAccessToken() + ?: "eyJhbGciOiJIUzI1NiJ9.eyJ0b2tlblR5cGUiOiJhY2Nlc3MiLCJ1c2VySWQiOjUsInByb3ZpZGVySWQiOiJsb2NhbGhvc3QiLCJuYW1lIjoi7Iug7KKF7JykIiwicm9sZSI6IlJPTEVfTUVNQkVSIiwiaWF0IjoxNzU5MzgyMzg3LCJleHAiOjE3NTk5ODcxODd9.kT9IH60aCA-6ByEITb-_qPAJY0Oik1bbPKqcBWXzHIk" val refreshToken = tokenManager.getRefreshToken() ?: "no_token" BearerTokens(accessToken = accessToken, refreshToken = refreshToken) } @@ -75,7 +77,7 @@ object HttpClientFactory { "auth/login", "auth/email", "auth/email/validation", - "member/reissue" // 토큰 재발급 요청 자체에는 만료된 액세스 토큰을 보내면 안 됨 + "auth/reissue" // 토큰 재발급 요청 ) val isNoAuthPath = pathWithNoAuth.any { noAuthPath -> @@ -88,26 +90,42 @@ object HttpClientFactory { } } refreshTokens { - val rt = tokenManager.getRefreshToken() ?: "no_token" - val response = client.post("member/reissue"){ - setBody { - ReissueTokenRequestDto( - refreshToken = rt + try { + val rt = tokenManager.getRefreshToken() + if (rt.isNullOrEmpty()) { + tokenManager.clearToken() + TokenExpiredManager.setTokenExpired() + return@refreshTokens null + } + + val response = client.post("auth/reissue") { + setBody(ReissueTokenRequestDto(refreshToken = rt)) + markAsRefreshTokenRequest() + }.body() + + if (response.success && response.data != null) { + tokenManager.saveTokens( + accessToken = response.data.accessToken, + refreshToken = response.data.refreshToken + ) + BearerTokens( + accessToken = response.data.accessToken, + refreshToken = response.data.refreshToken ) + } else { + tokenManager.clearToken() + TokenExpiredManager.setTokenExpired() + null } - markAsRefreshTokenRequest() - }.body() - tokenManager.saveTokens( - accessToken = response.accessToken, - refreshToken = response.refreshToken - ) - val accessToken = response.accessToken - val refreshToken = response.refreshToken - BearerTokens(accessToken,refreshToken) + } catch (e: Exception) { + tokenManager.clearToken() + TokenExpiredManager.setTokenExpired() + null + } } } } - install(Logging){ + install(Logging) { logger = object : Logger { override fun log(message: String) { println(message) diff --git a/composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/request/EmailValidationRequestDto.kt b/composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/request/EmailValidationRequestDto.kt new file mode 100644 index 0000000..da9e46e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/request/EmailValidationRequestDto.kt @@ -0,0 +1,12 @@ +package org.whosin.client.data.dto.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class EmailValidationRequestDto( + @SerialName("email") + val email: String, + @SerialName("authCode") + val authCode: String +) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/request/EmailVerificationRequestDto.kt b/composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/request/EmailVerificationRequestDto.kt new file mode 100644 index 0000000..95bd139 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/request/EmailVerificationRequestDto.kt @@ -0,0 +1,10 @@ +package org.whosin.client.data.dto.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class EmailVerificationRequestDto( + @SerialName("email") + val email: String +) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/request/FindPasswordRequestDto.kt b/composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/request/FindPasswordRequestDto.kt new file mode 100644 index 0000000..baf530a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/request/FindPasswordRequestDto.kt @@ -0,0 +1,10 @@ +package org.whosin.client.data.dto.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class FindPasswordRequestDto( + @SerialName("email") + val email: String +) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/request/SignupRequestDto.kt b/composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/request/SignupRequestDto.kt new file mode 100644 index 0000000..9d7dedf --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/request/SignupRequestDto.kt @@ -0,0 +1,14 @@ +package org.whosin.client.data.dto.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SignupRequestDto( + @SerialName("email") + val email: String, + @SerialName("password") + val password: String, + @SerialName("nickName") + val nickName: String +) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/response/EmailVerificationResponseDto.kt b/composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/response/EmailVerificationResponseDto.kt new file mode 100644 index 0000000..3643894 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/response/EmailVerificationResponseDto.kt @@ -0,0 +1,18 @@ +package org.whosin.client.data.dto.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class EmailVerificationResponseDto( + @SerialName("success") + val success: Boolean, + @SerialName("status") + val status: Int, + @SerialName("message") + val message: String, + @SerialName("data") + val data: String? = null, + @SerialName("timestamp") + val timestamp: String? = null +) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/response/FindPasswordResponseDto.kt b/composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/response/FindPasswordResponseDto.kt new file mode 100644 index 0000000..74ec48b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/response/FindPasswordResponseDto.kt @@ -0,0 +1,18 @@ +package org.whosin.client.data.dto.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class FindPasswordResponseDto( + @SerialName("success") + val success: Boolean, + @SerialName("status") + val status: Int, + @SerialName("message") + val message: String, + @SerialName("data") + val data: String? = null, + @SerialName("timestamp") + val timestamp: String? = null +) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/response/LoginResponseDto.kt b/composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/response/LoginResponseDto.kt index 9149800..59171ef 100644 --- a/composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/response/LoginResponseDto.kt +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/response/LoginResponseDto.kt @@ -7,12 +7,14 @@ import kotlinx.serialization.Serializable data class LoginResponseDto( @SerialName("success") val success: Boolean, - @SerialName("code") - val code: Int, + @SerialName("status") + val status: Int, @SerialName("message") val message: String, @SerialName("data") - val data: TokenDto + val data: TokenDto? = null, + @SerialName("timestamp") + val timestamp: String? = null ) @Serializable diff --git a/composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/response/ReissueTokenResponseDto.kt b/composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/response/ReissueTokenResponseDto.kt new file mode 100644 index 0000000..9bcb7d3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/response/ReissueTokenResponseDto.kt @@ -0,0 +1,18 @@ +package org.whosin.client.data.dto.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ReissueTokenResponseDto( + @SerialName("success") + val success: Boolean, + @SerialName("status") + val status: Int, + @SerialName("message") + val message: String, + @SerialName("data") + val data: TokenDto? = null, + @SerialName("timestamp") + val timestamp: String? = null +) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/response/SignupResponseDto.kt b/composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/response/SignupResponseDto.kt new file mode 100644 index 0000000..be3e21d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/response/SignupResponseDto.kt @@ -0,0 +1,18 @@ +package org.whosin.client.data.dto.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SignupResponseDto( + @SerialName("success") + val success: Boolean, + @SerialName("status") + val status: Int, + @SerialName("message") + val message: String, + @SerialName("data") + val data: String? = null, + @SerialName("timestamp") + val timestamp: String? = null +) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/whosin/client/data/remote/RemoteAuthDataSource.kt b/composeApp/src/commonMain/kotlin/org/whosin/client/data/remote/RemoteAuthDataSource.kt new file mode 100644 index 0000000..a72ae98 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/data/remote/RemoteAuthDataSource.kt @@ -0,0 +1,164 @@ +package org.whosin.client.data.remote + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse +import io.ktor.http.isSuccess +import org.whosin.client.core.network.ApiResult +import org.whosin.client.data.dto.request.EmailValidationRequestDto +import org.whosin.client.data.dto.request.EmailVerificationRequestDto +import org.whosin.client.data.dto.request.FindPasswordRequestDto +import org.whosin.client.data.dto.request.LoginRequestDto +import org.whosin.client.data.dto.request.SignupRequestDto +import org.whosin.client.data.dto.response.EmailVerificationResponseDto +import org.whosin.client.data.dto.response.ErrorResponseDto +import org.whosin.client.data.dto.response.FindPasswordResponseDto +import org.whosin.client.data.dto.response.LoginResponseDto +import org.whosin.client.data.dto.response.SignupResponseDto + +class RemoteAuthDataSource( + private val client: HttpClient +) { + suspend fun login(email: String, password: String): ApiResult { + return try { + val response: HttpResponse = client + .post("auth/login") { + setBody( + LoginRequestDto(email = email, password = password) + ) + } + if (response.status.isSuccess()) { + ApiResult.Success( + data = response.body(), + statusCode = response.status.value + ) + } else { + // 에러 응답 파싱 시도 + try { + val errorResponse: ErrorResponseDto = response.body() + ApiResult.Error( + code = response.status.value, + message = errorResponse.message + ) + } catch (e: Exception) { + // 파싱 실패 시 기본 에러 메시지 + ApiResult.Error( + code = response.status.value, + message = "HTTP Error: ${response.status.value}" + ) + } + } + } catch (t: Throwable) { + ApiResult.Error(message = t.message, cause = t) + } + } + + suspend fun sendEmailVerification(email: String): ApiResult { + return try { + val response: HttpResponse = client + .post("auth/email/send") { + setBody( + EmailVerificationRequestDto(email = email) + ) + } + if (response.status.isSuccess()) { + ApiResult.Success( + data = response.body(), + statusCode = response.status.value + ) + } else { + val errorResponse: EmailVerificationResponseDto = response.body() + ApiResult.Error( + code = errorResponse.status, + message = errorResponse.message + ) + } + } catch (t: Throwable) { + ApiResult.Error(message = t.message, cause = t) + } + } + + suspend fun validateEmailCode( + email: String, + authCode: String + ): ApiResult { + return try { + val response: HttpResponse = client + .post("auth/email/validation") { + setBody( + EmailValidationRequestDto(email = email, authCode = authCode) + ) + } + if (response.status.isSuccess()) { + ApiResult.Success( + data = response.body(), + statusCode = response.status.value + ) + } else { + val errorResponse: EmailVerificationResponseDto = response.body() + ApiResult.Error( + code = errorResponse.status, + message = errorResponse.message + ) + } + } catch (t: Throwable) { + ApiResult.Error(message = t.message, cause = t) + } + } + + suspend fun signup( + email: String, + password: String, + nickName: String + ): ApiResult { + return try { + val response: HttpResponse = client + .post("users/signup") { + setBody( + SignupRequestDto(email = email, password = password, nickName = nickName) + ) + } + if (response.status.isSuccess()) { + ApiResult.Success( + data = response.body(), + statusCode = response.status.value + ) + } else { + val errorResponse: SignupResponseDto = response.body() + ApiResult.Error( + code = errorResponse.status, + message = errorResponse.message + ) + } + } catch (t: Throwable) { + ApiResult.Error(message = t.message, cause = t) + } + } + + suspend fun sendPasswordResetEmail(email: String): ApiResult { + return try { + val response: HttpResponse = client + .post("auth/email/find-password") { + setBody( + FindPasswordRequestDto(email = email) + ) + } + if (response.status.isSuccess()) { + ApiResult.Success( + data = response.body(), + statusCode = response.status.value + ) + } else { + val errorResponse: FindPasswordResponseDto = response.body() + ApiResult.Error( + code = errorResponse.status, + message = errorResponse.message + ) + } + } catch (t: Throwable) { + ApiResult.Error(message = t.message, cause = t) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/whosin/client/data/remote/RemoteMemberDataSource.kt b/composeApp/src/commonMain/kotlin/org/whosin/client/data/remote/RemoteMemberDataSource.kt index aa47518..21c67cf 100644 --- a/composeApp/src/commonMain/kotlin/org/whosin/client/data/remote/RemoteMemberDataSource.kt +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/data/remote/RemoteMemberDataSource.kt @@ -4,46 +4,18 @@ import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.request.get import io.ktor.client.request.patch -import io.ktor.client.request.post import io.ktor.client.request.setBody import io.ktor.client.statement.HttpResponse import io.ktor.http.isSuccess import org.whosin.client.core.network.ApiResult -import org.whosin.client.data.dto.request.LoginRequestDto import org.whosin.client.data.dto.request.UpdateMyInfoRequestDto import org.whosin.client.data.dto.response.ErrorResponseDto -import org.whosin.client.data.dto.response.LoginResponseDto import org.whosin.client.data.dto.response.MyInfoResponseDto import org.whosin.client.data.dto.response.UpdateMyInfoResponseDto class RemoteMemberDataSource( private val client: HttpClient ) { - suspend fun login(email: String, password: String): ApiResult { - return try { - val response: HttpResponse = client - // TODO: BaseUrl 가져올 수 있도록 처리 - .post(urlString = "BASEURL/members/login") { - setBody( - LoginRequestDto(email = email, password = password) - ) - } - if (response.status.isSuccess()) { - ApiResult.Success( - data = response.body(), - statusCode = response.status.value - ) - } else { - ApiResult.Error( - code = response.status.value, - message = "HTTP ${response.status.value}" - ) - } - } catch (t: Throwable) { - ApiResult.Error(message = t.message, cause = t) - } - } - // 내 정보 조회 suspend fun getMyInfo(): ApiResult { return try { diff --git a/composeApp/src/commonMain/kotlin/org/whosin/client/data/repository/AuthRepository.kt b/composeApp/src/commonMain/kotlin/org/whosin/client/data/repository/AuthRepository.kt new file mode 100644 index 0000000..0d8c97f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/data/repository/AuthRepository.kt @@ -0,0 +1,40 @@ +package org.whosin.client.data.repository + +import org.whosin.client.data.remote.RemoteAuthDataSource +import org.whosin.client.core.network.ApiResult +import org.whosin.client.data.dto.response.LoginResponseDto +import org.whosin.client.data.dto.response.EmailVerificationResponseDto +import org.whosin.client.data.dto.response.SignupResponseDto +import org.whosin.client.data.dto.response.FindPasswordResponseDto + +class AuthRepository( + private val dataSource: RemoteAuthDataSource +) { + suspend fun login( + email: String, + password: String + ): ApiResult = + dataSource.login(email, password) + + suspend fun sendEmailVerification( + email: String + ): ApiResult = + dataSource.sendEmailVerification(email) + + suspend fun validateEmailCode( + email: String, + authCode: String + ): ApiResult = + dataSource.validateEmailCode(email, authCode) + + suspend fun signup( + email: String, + password: String, + nickName: String + ): ApiResult = + dataSource.signup(email, password, nickName) + + suspend fun sendPasswordResetEmail(email: String): ApiResult = + dataSource.sendPasswordResetEmail(email) + +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/whosin/client/data/repository/MemberRepository.kt b/composeApp/src/commonMain/kotlin/org/whosin/client/data/repository/MemberRepository.kt index 6ccbffe..455cb10 100644 --- a/composeApp/src/commonMain/kotlin/org/whosin/client/data/repository/MemberRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/data/repository/MemberRepository.kt @@ -2,20 +2,15 @@ package org.whosin.client.data.repository import org.whosin.client.data.remote.RemoteMemberDataSource import org.whosin.client.core.network.ApiResult -import org.whosin.client.data.dto.response.LoginResponseDto import org.whosin.client.data.dto.response.MyInfoResponseDto import org.whosin.client.data.dto.response.UpdateMyInfoResponseDto class MemberRepository( private val dataSource: RemoteMemberDataSource ) { - suspend fun login(email: String, password: String): ApiResult = - dataSource.login(email, password) - suspend fun getMyInfo(): ApiResult = dataSource.getMyInfo() suspend fun updateMyInfo(newNickName: String, clubList: List?): ApiResult = dataSource.updateMyInfo(newNickName = newNickName, clubList = clubList) - } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/whosin/client/di/DIModules.kt b/composeApp/src/commonMain/kotlin/org/whosin/client/di/DIModules.kt index 4838b27..992cc06 100644 --- a/composeApp/src/commonMain/kotlin/org/whosin/client/di/DIModules.kt +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/di/DIModules.kt @@ -5,15 +5,20 @@ import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.module import org.whosin.client.core.network.HttpClientFactory import org.whosin.client.data.remote.DummyDataSource +import org.whosin.client.data.remote.RemoteAuthDataSource import org.whosin.client.data.remote.RemoteClubDataSource import org.whosin.client.data.remote.RemoteMemberDataSource +import org.whosin.client.data.repository.AuthRepository import org.whosin.client.data.repository.DummyRepository import org.whosin.client.data.repository.ClubRepository import org.whosin.client.data.repository.MemberRepository import org.whosin.client.presentation.auth.clubcode.AddClubViewModel import org.whosin.client.presentation.dummy.DummyViewModel import org.whosin.client.presentation.dummy.TokenTestViewModel +import org.whosin.client.presentation.auth.login.viewmodel.FindPasswordViewModel import org.whosin.client.presentation.auth.login.viewmodel.LoginViewModel +import org.whosin.client.presentation.auth.login.viewmodel.SignupViewModel +import org.whosin.client.presentation.auth.login.viewmodel.SplashViewModel import org.whosin.client.presentation.home.HomeViewModel import org.whosin.client.presentation.mypage.MyPageViewModel @@ -32,12 +37,14 @@ val httpClientModule = module { } val dataSourceModule = module { + single { RemoteAuthDataSource(get()) } single { RemoteMemberDataSource(get()) } single { RemoteClubDataSource(get()) } single { DummyDataSource(get()) } // TODO: 이후에 삭제 예정 } val repositoryModule = module { + single { AuthRepository(get()) } single { MemberRepository(get()) } single { ClubRepository(get()) } single { DummyRepository(get()) } // TODO: 이후에 삭제 예정 @@ -45,7 +52,10 @@ val repositoryModule = module { // ViewModel을 새로 생성하는 경우에 모듈에 추가하여 사용 val viewModelModule = module { + viewModelOf(::SplashViewModel) viewModelOf(::LoginViewModel) + viewModelOf(::SignupViewModel) + viewModelOf(::FindPasswordViewModel) viewModelOf(::HomeViewModel) viewModelOf(::MyPageViewModel) viewModelOf(::DummyViewModel) // TODO: 이후에 삭제 예정 diff --git a/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/clubcode/ClubCodeInputScreen.kt b/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/clubcode/ClubCodeInputScreen.kt index 037e787..f013866 100644 --- a/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/clubcode/ClubCodeInputScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/clubcode/ClubCodeInputScreen.kt @@ -152,16 +152,20 @@ fun ClubCodeInputScreen( onValueChange = { input -> if (input.length <= 1 && input.all { it.isDigit() }) { val newCode = clubCode.copyOf() - val wasEmpty = clubCode[index].isEmpty() newCode[index] = input clubCode = newCode + // 숫자를 입력했을 때만 다음 박스로 이동 if (input.isNotEmpty() && index < 5) { currentFocusIndex = index + 1 focusRequesters[index + 1].requestFocus() - } else if (input.isEmpty() && !wasEmpty && index > 0) { + keyboardController?.show() + } + // 현재 박스가 비워지고 이전 박스가 있으면 이전으로 이동 + else if (input.isEmpty() && index > 0) { currentFocusIndex = index - 1 focusRequesters[index - 1].requestFocus() + keyboardController?.show() } } }, @@ -170,6 +174,7 @@ fun ClubCodeInputScreen( val prevIndex = index - 1 currentFocusIndex = prevIndex focusRequesters[prevIndex].requestFocus() + keyboardController?.show() } }, onFocusChanged = { isFocused -> @@ -177,6 +182,11 @@ fun ClubCodeInputScreen( currentFocusIndex = index } }, + onClick = { + currentFocusIndex = index + focusRequesters[index].requestFocus() + keyboardController?.show() + }, borderColor = when (currentState) { ClubCodeState.ERROR -> Color(0xFFFF3636) else -> Color(0xFFE5E5E5) diff --git a/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/EmailVerificationScreen.kt b/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/EmailVerificationScreen.kt index 812e382..a41d95d 100644 --- a/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/EmailVerificationScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/EmailVerificationScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -17,6 +18,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -25,12 +27,17 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import kotlinx.coroutines.delay import coil3.compose.AsyncImage +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.ui.tooling.preview.Preview +import org.koin.compose.koinInject +import org.whosin.client.core.network.ApiResult +import org.whosin.client.data.repository.AuthRepository import org.whosin.client.presentation.auth.login.component.CommonLoginButton import org.whosin.client.presentation.auth.login.component.NumberInputBox import whosinclient.composeapp.generated.resources.Res @@ -41,14 +48,20 @@ import whosinclient.composeapp.generated.resources.email_verification_title @Composable fun EmailVerificationScreen( modifier: Modifier = Modifier, + email: String = "", onNavigateBack: () -> Unit = {}, - onVerificationComplete: (String) -> Unit = {} + onVerificationComplete: () -> Unit = {} ) { var verificationCode by remember { mutableStateOf(arrayOf("", "", "", "", "", "")) } var currentFocusIndex by remember { mutableStateOf(0) } + var isLoading by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } val focusRequesters = remember { List(6) { FocusRequester() } } val keyboardController = LocalSoftwareKeyboardController.current + val authRepository: AuthRepository = koinInject() + val coroutineScope = rememberCoroutineScope() + // 화면 진입 시 첫 번째 입력 박스에 포커스 LaunchedEffect(Unit) { delay(300) @@ -110,18 +123,21 @@ fun EmailVerificationScreen( onValueChange = { input -> if (input.length <= 1 && input.all { it.isDigit() }) { val newCode = verificationCode.copyOf() - val wasEmpty = verificationCode[index].isEmpty() newCode[index] = input verificationCode = newCode + // 에러 메시지 초기화 + errorMessage = null + + // 숫자를 입력했을 때만 다음 박스로 이동 if (input.isNotEmpty() && index < 5) { currentFocusIndex = index + 1 focusRequesters[index + 1].requestFocus() - } else if (input.isEmpty() && !wasEmpty && index > 0) { + } + // 현재 박스가 비워지고 이전 박스가 있으면 이전으로 이동 + else if (input.isEmpty() && index > 0) { currentFocusIndex = index - 1 focusRequesters[index - 1].requestFocus() - } else if (input.isNotEmpty()) { - currentFocusIndex = index } } }, @@ -138,6 +154,11 @@ fun EmailVerificationScreen( currentFocusIndex = index } }, + onClick = { + currentFocusIndex = index + focusRequesters[index].requestFocus() + keyboardController?.show() + }, isFocused = currentFocusIndex == index, modifier = Modifier .weight(1f) @@ -145,18 +166,60 @@ fun EmailVerificationScreen( ) } } + + // 에러 메시지 표시 + if (errorMessage != null) { + Text( + text = errorMessage!!, + color = Color(0xFFFF3636), + fontSize = 14.sp, + fontWeight = FontWeight.W500, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp) + ) + } } // 하단 확인 버튼 CommonLoginButton( text = stringResource(Res.string.confirm_button), - onClick = { onVerificationComplete(fullCode) }, - enabled = isComplete, + onClick = { + if (isComplete && !isLoading) { + isLoading = true + + coroutineScope.launch { + when (val result = authRepository.validateEmailCode(email, fullCode)) { + is ApiResult.Success<*> -> { + isLoading = false + onVerificationComplete() + } + + is ApiResult.Error -> { + isLoading = false + errorMessage = result.message + } + } + } + } + }, + enabled = isComplete && !isLoading, modifier = Modifier .align(Alignment.BottomCenter) .padding(horizontal = 16.dp) .padding(bottom = 52.dp) ) + + // 로딩 인디케이터 + if (isLoading) { + CircularProgressIndicator( + color = Color(0xFFF89531), + modifier = Modifier + .align(Alignment.Center) + .size(48.dp) + ) + } } } @@ -165,8 +228,9 @@ fun EmailVerificationScreen( fun VerificationCodeScreenPreview() { EmailVerificationScreen( modifier = Modifier, + email = "test@example.com", onNavigateBack = {}, - onVerificationComplete = { code -> + onVerificationComplete = { // 인증번호 처리 로직 } ) diff --git a/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/FindPasswordScreen.kt b/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/FindPasswordScreen.kt index 73146bd..b98b6c2 100644 --- a/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/FindPasswordScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/FindPasswordScreen.kt @@ -8,8 +8,11 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.IconButton +import androidx.compose.material3.Snackbar import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -21,10 +24,14 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage +import kotlinx.coroutines.delay import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.ui.tooling.preview.Preview +import org.koin.compose.viewmodel.koinViewModel import org.whosin.client.presentation.auth.login.component.CommonLoginButton import org.whosin.client.presentation.auth.login.component.CommonLoginInputField +import org.whosin.client.presentation.auth.login.viewmodel.FindPasswordUiState +import org.whosin.client.presentation.auth.login.viewmodel.FindPasswordViewModel import whosinclient.composeapp.generated.resources.Res import whosinclient.composeapp.generated.resources.back_button import whosinclient.composeapp.generated.resources.email_placeholder @@ -35,9 +42,23 @@ import whosinclient.composeapp.generated.resources.send_email_button fun FindPasswordScreen( modifier: Modifier = Modifier, onNavigateBack: () -> Unit = {}, - onPasswordResetComplete: (String) -> Unit = {} + onPasswordResetComplete: () -> Unit = {}, + viewModel: FindPasswordViewModel = koinViewModel() ) { var email by remember { mutableStateOf("") } + var showSuccessToast by remember { mutableStateOf(false) } + val uiState by viewModel.uiState.collectAsState() + + LaunchedEffect(uiState) { + when (uiState) { + is FindPasswordUiState.Success -> { + showSuccessToast = true + delay(2000) // 2초 후 로그인 화면으로 이동 + onPasswordResetComplete() + } + else -> {} + } + } Box( modifier = modifier @@ -81,13 +102,33 @@ fun FindPasswordScreen( CommonLoginButton( text = stringResource(Res.string.send_email_button), - onClick = { onPasswordResetComplete(email) }, + onClick = { + viewModel.sendPasswordResetEmail(email) + }, enabled = email.isNotBlank(), modifier = Modifier .align(Alignment.BottomCenter) .padding(horizontal = 16.dp) .padding(bottom = 52.dp) ) + + // 토스트 메시지 (Snackbar) + if (showSuccessToast) { + Snackbar( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 120.dp) + .padding(horizontal = 16.dp), + containerColor = Color(0xFF4CAF50), + contentColor = Color.White + ) { + Text( + text = "이메일로 임시 비밀번호가 전송되었습니다.", + fontSize = 14.sp, + fontWeight = FontWeight.W500 + ) + } + } } } @@ -97,8 +138,6 @@ fun PasswordResetScreenPreview() { FindPasswordScreen( modifier = Modifier, onNavigateBack = {}, - onPasswordResetComplete = { email -> - // 이메일 처리 로직 - } + onPasswordResetComplete = {} ) } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/LoginScreen.kt b/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/LoginScreen.kt index 871d8ed..9c2b6fe 100644 --- a/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/LoginScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/LoginScreen.kt @@ -11,9 +11,12 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -27,8 +30,11 @@ import androidx.compose.ui.unit.sp import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.ui.tooling.preview.Preview +import org.koin.compose.viewmodel.koinViewModel import org.whosin.client.presentation.auth.login.component.CommonLoginButton import org.whosin.client.presentation.auth.login.component.CommonLoginInputField +import org.whosin.client.presentation.auth.login.viewmodel.LoginUiState +import org.whosin.client.presentation.auth.login.viewmodel.LoginViewModel import whosinclient.composeapp.generated.resources.Res import whosinclient.composeapp.generated.resources.email_label import whosinclient.composeapp.generated.resources.email_placeholder @@ -45,10 +51,18 @@ fun LoginScreen( modifier: Modifier = Modifier, onNavigateToHome: () -> Unit, onNavigateToFindPassword: () -> Unit = {}, - onNavigateToSignup: () -> Unit = {} + onNavigateToSignup: () -> Unit = {}, + viewModel: LoginViewModel = koinViewModel() ) { var email by remember { mutableStateOf("") } var password by remember { mutableStateOf("") } + val uiState by viewModel.uiState.collectAsState() + + LaunchedEffect(uiState.isSuccess) { + if (uiState.isSuccess) { + onNavigateToHome() + } + } Box( modifier = modifier @@ -106,9 +120,22 @@ fun LoginScreen( modifier = Modifier.padding(bottom = 16.dp) ) + uiState.errorMessage?.let { errorMsg -> + Text( + text = errorMsg, + color = Color.Red, + fontSize = 14.sp, + fontWeight = FontWeight.W400, + modifier = Modifier.padding(bottom = 16.dp) + ) + } + CommonLoginButton( text = stringResource(Res.string.login_button), - onClick = onNavigateToHome, + onClick = { + viewModel.login(email, password) + }, + enabled = email.isNotBlank() && password.isNotBlank() && !uiState.isLoading, modifier = Modifier.padding(bottom = 12.dp) ) } diff --git a/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/NicknameInputScreen.kt b/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/NicknameInputScreen.kt index 17b11d6..4e0530f 100644 --- a/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/NicknameInputScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/NicknameInputScreen.kt @@ -3,13 +3,18 @@ package org.whosin.client.presentation.auth.login import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -23,8 +28,11 @@ import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.ui.tooling.preview.Preview +import org.koin.compose.viewmodel.koinViewModel import org.whosin.client.presentation.auth.login.component.CommonLoginButton import org.whosin.client.presentation.auth.login.component.CommonLoginInputField +import org.whosin.client.presentation.auth.login.viewmodel.SignupUiState +import org.whosin.client.presentation.auth.login.viewmodel.SignupViewModel import whosinclient.composeapp.generated.resources.Res import whosinclient.composeapp.generated.resources.back_button import whosinclient.composeapp.generated.resources.next_button @@ -35,10 +43,29 @@ import whosinclient.composeapp.generated.resources.nickname_welcome_title @Composable fun NicknameInputScreen( modifier: Modifier = Modifier, + email: String, + password: String, onNavigateBack: () -> Unit = {}, - onNavigateToClubCode: (String) -> Unit = {} + onNavigateToClubCode: () -> Unit = {}, + viewModel: SignupViewModel = koinViewModel() ) { var nickname by remember { mutableStateOf("") } + var errorMessage by remember { mutableStateOf(null) } + val uiState by viewModel.uiState.collectAsState() + + LaunchedEffect(uiState) { + when (uiState) { + is SignupUiState.Success -> { + onNavigateToClubCode() + } + + is SignupUiState.Error -> { + errorMessage = (uiState as SignupUiState.Error).message + } + + else -> {} + } + } Box( modifier = modifier @@ -85,17 +112,38 @@ fun NicknameInputScreen( value = nickname, onValueChange = { newValue -> nickname = newValue + errorMessage = null }, placeholder = stringResource(Res.string.nickname_input_placeholder), maxLength = 8 ) + + if (errorMessage != null) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = errorMessage ?: "", + color = Color.Red, + fontSize = 14.sp, + fontWeight = FontWeight.W400 + ) + } + + if (uiState is SignupUiState.Loading) { + Spacer(modifier = Modifier.height(16.dp)) + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = Color(0xFFFF7A00) + ) + } } // 하단 다음 버튼 CommonLoginButton( text = stringResource(Res.string.next_button), - onClick = { onNavigateToClubCode(nickname) }, - enabled = nickname.isNotBlank(), + onClick = { + viewModel.signup(email, password, nickname) + }, + enabled = nickname.isNotBlank() && uiState !is SignupUiState.Loading, modifier = Modifier .align(Alignment.BottomCenter) .padding(horizontal = 16.dp) @@ -109,9 +157,9 @@ fun NicknameInputScreen( fun NicknameInputScreenPreview() { NicknameInputScreen( modifier = Modifier, + email = "test@example.com", + password = "password123", onNavigateBack = {}, - onNavigateToClubCode = { nickname -> - // 닉네임 처리 로직 - } + onNavigateToClubCode = {} ) } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/PasswordInputScreen.kt b/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/PasswordInputScreen.kt index 821483b..dde7687 100644 --- a/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/PasswordInputScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/PasswordInputScreen.kt @@ -43,7 +43,9 @@ fun PasswordInputScreen( var password by remember { mutableStateOf("") } var confirmPassword by remember { mutableStateOf("") } - val isComplete = password.isNotBlank() && confirmPassword.isNotBlank() + val isComplete = password.length >= 8 && + confirmPassword.length >= 8 && + password == confirmPassword Box( modifier = modifier diff --git a/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/SignupEmailInputScreen.kt b/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/SignupEmailInputScreen.kt index 5d988a4..cda015f 100644 --- a/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/SignupEmailInputScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/SignupEmailInputScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.IconButton import androidx.compose.material3.Text +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -25,6 +26,11 @@ import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.ui.tooling.preview.Preview import org.whosin.client.presentation.auth.login.component.CommonLoginButton import org.whosin.client.presentation.auth.login.component.CommonLoginInputField +import org.whosin.client.data.repository.AuthRepository +import org.whosin.client.core.network.ApiResult +import androidx.compose.runtime.rememberCoroutineScope +import kotlinx.coroutines.launch +import org.koin.compose.koinInject import whosinclient.composeapp.generated.resources.Res import whosinclient.composeapp.generated.resources.back_button import whosinclient.composeapp.generated.resources.email_placeholder @@ -38,6 +44,10 @@ fun SignupScreen( onNavigateToEmailVerification: (String) -> Unit = {} ) { var email by remember { mutableStateOf("") } + var isLoading by remember { mutableStateOf(false) } + + val authRepository: AuthRepository = koinInject() + val coroutineScope = rememberCoroutineScope() Box( modifier = modifier @@ -81,13 +91,40 @@ fun SignupScreen( CommonLoginButton( text = stringResource(Res.string.next_button), - onClick = { onNavigateToEmailVerification(email) }, - enabled = email.isNotBlank(), + onClick = { + if (email.isNotBlank() && !isLoading) { + isLoading = true + + coroutineScope.launch { + when (authRepository.sendEmailVerification(email)) { + is ApiResult.Success<*> -> { + isLoading = false + onNavigateToEmailVerification(email) + } + + is ApiResult.Error -> { + isLoading = false + } + } + } + } + }, + enabled = email.isNotBlank() && !isLoading, modifier = Modifier .align(Alignment.BottomCenter) .padding(horizontal = 16.dp) .padding(bottom = 52.dp) ) + + // 로딩 인디케이터 + if (isLoading) { + CircularProgressIndicator( + color = Color(0xFFF89531), + modifier = Modifier + .align(Alignment.Center) + .size(48.dp) + ) + } } } diff --git a/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/SplashScreen.kt b/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/SplashScreen.kt index 616f400..0c6db38 100644 --- a/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/SplashScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/SplashScreen.kt @@ -7,6 +7,8 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -14,18 +16,33 @@ import androidx.compose.ui.unit.dp import kotlinx.coroutines.delay import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.ui.tooling.preview.Preview +import org.koin.compose.viewmodel.koinViewModel +import org.whosin.client.presentation.auth.login.viewmodel.SplashViewModel import whosinclient.composeapp.generated.resources.Res import whosinclient.composeapp.generated.resources.img_logo_white @Composable fun SplashScreen( modifier: Modifier = Modifier, - onNavigateToLogin: () -> Unit = {} + onNavigateToLogin: () -> Unit = {}, + onNavigateToHome: () -> Unit = {}, + viewModel: SplashViewModel = koinViewModel() ) { + val uiState by viewModel.uiState.collectAsState() LaunchedEffect(Unit) { - delay(2000) - onNavigateToLogin() + delay(1500) // 스플래시 화면 표시 시간 + viewModel.checkToken() + } + + LaunchedEffect(uiState.isLoading) { + if (!uiState.isLoading) { + if (uiState.hasToken) { + onNavigateToHome() + } else { + onNavigateToLogin() + } + } } Box( diff --git a/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/component/NumberInputBox.kt b/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/component/NumberInputBox.kt index 76a8b36..33931ff 100644 --- a/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/component/NumberInputBox.kt +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/component/NumberInputBox.kt @@ -2,6 +2,7 @@ package org.whosin.client.presentation.auth.login.component import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size @@ -35,6 +36,7 @@ fun NumberInputBox( onValueChange: (String) -> Unit, onBackspace: (() -> Unit)? = null, onFocusChanged: ((Boolean) -> Unit)? = null, + onClick: (() -> Unit)? = null, containerColor: Color = Color.White, borderColor: Color = Color(0xFFE5E5E5), focusedBorderColor: Color = Color(0xFFF89531), @@ -63,7 +65,8 @@ fun NumberInputBox( modifier = modifier .size(width = 50.dp, height = 54.dp) .background(containerColor, RoundedCornerShape(8.dp)) - .border(1.dp, currentBorderColor, RoundedCornerShape(8.dp)), + .border(1.dp, currentBorderColor, RoundedCornerShape(8.dp)) + .clickable { onClick?.invoke() }, contentAlignment = Alignment.Center ) { BasicTextField( @@ -87,13 +90,17 @@ fun NumberInputBox( ), singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - cursorBrush = SolidColor(Color(0xFFB2B2B2)), - modifier = Modifier.onFocusChanged { focusState -> - onFocusChanged?.invoke(focusState.isFocused) - }, + cursorBrush = SolidColor(Color.Transparent), + modifier = Modifier + .fillMaxSize() + .onFocusChanged { focusState -> + onFocusChanged?.invoke(focusState.isFocused) + }, decorationBox = { innerTextField -> Box( - Modifier.fillMaxSize(), + Modifier + .fillMaxSize() + .clickable { onClick?.invoke() }, contentAlignment = Alignment.Center ) { innerTextField() diff --git a/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/viewmodel/FindPasswordViewModel.kt b/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/viewmodel/FindPasswordViewModel.kt new file mode 100644 index 0000000..32a4a46 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/viewmodel/FindPasswordViewModel.kt @@ -0,0 +1,38 @@ +package org.whosin.client.presentation.auth.login.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import org.whosin.client.core.network.ApiResult +import org.whosin.client.data.repository.AuthRepository + +sealed interface FindPasswordUiState { + data object Idle: FindPasswordUiState + data object Loading: FindPasswordUiState + data object Success: FindPasswordUiState + data class Error(val message: String?): FindPasswordUiState +} + +class FindPasswordViewModel( + private val repository: AuthRepository +): ViewModel() { + private val _uiState: MutableStateFlow = MutableStateFlow(FindPasswordUiState.Idle) + val uiState: StateFlow = _uiState + + fun sendPasswordResetEmail(email: String) { + _uiState.value = FindPasswordUiState.Loading + viewModelScope.launch { + when (val result = repository.sendPasswordResetEmail(email)) { + is ApiResult.Success -> { + _uiState.value = FindPasswordUiState.Success + } + is ApiResult.Error -> { + val message = result.message ?: result.cause?.message + _uiState.value = FindPasswordUiState.Error(message) + } + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/viewmodel/LoginViewModel.kt b/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/viewmodel/LoginViewModel.kt index 7517f45..8f54d20 100644 --- a/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/viewmodel/LoginViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/viewmodel/LoginViewModel.kt @@ -5,32 +5,52 @@ import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch +import org.whosin.client.core.datastore.TokenManager import org.whosin.client.core.network.ApiResult -import org.whosin.client.data.repository.MemberRepository -import org.whosin.client.data.dto.response.TokenDto +import org.whosin.client.data.repository.AuthRepository -sealed interface LoginUiState { - data object Loading: LoginUiState - data class Success(val token: TokenDto): LoginUiState - data class Error(val message: String?): LoginUiState -} +data class LoginUiState( + val isLoading: Boolean = false, + val isSuccess: Boolean = false, + val errorMessage: String? = null +) class LoginViewModel( - private val repository: MemberRepository + private val repository: AuthRepository, + private val tokenManager: TokenManager ): ViewModel() { - private val _uiState: MutableStateFlow = MutableStateFlow(null) - val uiState: StateFlow = _uiState + private val _uiState = MutableStateFlow(LoginUiState()) + val uiState: StateFlow = _uiState fun login(email: String, password: String) { - _uiState.value = LoginUiState.Loading viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) + when (val result = repository.login(email, password)) { is ApiResult.Success -> { - _uiState.value = LoginUiState.Success(result.data.data) + val tokenData = result.data.data + if (tokenData != null) { + tokenManager.saveTokens( + accessToken = tokenData.accessToken, + refreshToken = tokenData.refreshToken + ) + _uiState.value = _uiState.value.copy( + isLoading = false, + isSuccess = true, + errorMessage = null + ) + } else { + _uiState.value = _uiState.value.copy( + isLoading = false, + errorMessage = "토큰 데이터를 받지 못했습니다." + ) + } } is ApiResult.Error -> { - val message = result.message ?: result.cause?.message - _uiState.value = LoginUiState.Error(message) + _uiState.value = _uiState.value.copy( + isLoading = false, + errorMessage = result.message ?: "로그인에 실패했습니다." + ) } } } diff --git a/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/viewmodel/SignupViewModel.kt b/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/viewmodel/SignupViewModel.kt new file mode 100644 index 0000000..5cffbbb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/viewmodel/SignupViewModel.kt @@ -0,0 +1,58 @@ +package org.whosin.client.presentation.auth.login.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import org.whosin.client.core.datastore.TokenManager +import org.whosin.client.core.network.ApiResult +import org.whosin.client.data.repository.AuthRepository + +sealed interface SignupUiState { + data object Idle: SignupUiState + data object Loading: SignupUiState + data object Success: SignupUiState + data class Error(val message: String?): SignupUiState +} + +class SignupViewModel( + private val repository: AuthRepository, + private val tokenManager: TokenManager +): ViewModel() { + private val _uiState: MutableStateFlow = MutableStateFlow(SignupUiState.Idle) + val uiState: StateFlow = _uiState + + fun signup(email: String, password: String, nickName: String) { + _uiState.value = SignupUiState.Loading + viewModelScope.launch { + when (val signupResult = repository.signup(email, password, nickName)) { + is ApiResult.Success -> { + // 회원가입 성공 시 자동 로그인 + when (val loginResult = repository.login(email, password)) { + is ApiResult.Success -> { + val tokenData = loginResult.data.data + if (tokenData != null) { + tokenManager.saveTokens( + accessToken = tokenData.accessToken, + refreshToken = tokenData.refreshToken + ) + _uiState.value = SignupUiState.Success + } else { + _uiState.value = SignupUiState.Error("로그인 후 토큰 데이터를 받지 못했습니다.") + } + } + is ApiResult.Error -> { + val message = loginResult.message ?: loginResult.cause?.message + _uiState.value = SignupUiState.Error(message) + } + } + } + is ApiResult.Error -> { + val message = signupResult.message ?: signupResult.cause?.message + _uiState.value = SignupUiState.Error(message) + } + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/viewmodel/SplashViewModel.kt b/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/viewmodel/SplashViewModel.kt new file mode 100644 index 0000000..fe4c8f3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/viewmodel/SplashViewModel.kt @@ -0,0 +1,32 @@ +package org.whosin.client.presentation.auth.login.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import org.whosin.client.core.datastore.TokenManager + +data class SplashUiState( + val isLoading: Boolean = true, + val hasToken: Boolean = false +) + +class SplashViewModel( + private val tokenManager: TokenManager +) : ViewModel() { + private val _uiState = MutableStateFlow(SplashUiState()) + val uiState: StateFlow = _uiState + + fun checkToken() { + viewModelScope.launch { + val accessToken = tokenManager.getAccessToken() + val hasValidToken = !accessToken.isNullOrEmpty() + + _uiState.value = _uiState.value.copy( + isLoading = false, + hasToken = hasValidToken + ) + } + } +}