From 096058137d2e40ed7a6a0eacb471c794ed1cc481 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Thu, 2 Oct 2025 16:57:18 +0900 Subject: [PATCH 01/32] =?UTF-8?q?[feat]:=20=EB=84=A4=ED=8A=B8=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=20=EB=B3=B4=EC=95=88=20=EC=84=A4=EC=A0=95=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20gitignore=EC=97=90?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + composeApp/src/androidMain/AndroidManifest.xml | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) 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/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index 6c3b28f..b187f69 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -10,7 +10,8 @@ android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" - android:theme="@android:style/Theme.Material.Light.NoActionBar"> + android:theme="@android:style/Theme.Material.Light.NoActionBar" + android:networkSecurityConfig="@xml/network_security_config"> From 4a531e15b56f7970472fbc056fac6b8d358a367a Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Thu, 2 Oct 2025 16:57:45 +0900 Subject: [PATCH 02/32] =?UTF-8?q?[feat]:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20Dto=20=EC=B6=94=EA=B0=80=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/request/EmailVerificationRequestDto.kt | 10 ++++++++++ .../response/EmailVerificationResponseDto.kt | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/request/EmailVerificationRequestDto.kt create mode 100644 composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/response/EmailVerificationResponseDto.kt 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/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 From de086049ea0b89089f45ae4cdc7564be22f64f5c Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Thu, 2 Oct 2025 16:58:20 +0900 Subject: [PATCH 03/32] =?UTF-8?q?[feat]:=20DataSource=EC=97=90=20=EC=9D=B4?= =?UTF-8?q?=EB=A9=94=EC=9D=BC=20=EC=9D=B8=EC=A6=9D=20=ED=95=A8=EC=88=98=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/remote/RemoteMemberDataSource.kt | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) 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 56f5c22..bd663dc 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 @@ -9,7 +9,9 @@ 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.EmailVerificationRequestDto import org.whosin.client.data.dto.response.LoginResponseDto +import org.whosin.client.data.dto.response.EmailVerificationResponseDto class RemoteMemberDataSource( private val client: HttpClient @@ -17,8 +19,7 @@ class RemoteMemberDataSource( suspend fun login(email: String, password: String): ApiResult { return try { val response: HttpResponse = client - // TODO: BaseUrl 가져올 수 있도록 처리 - .post(urlString = "BASEURL/members/login") { + .post("api/members/login") { setBody( LoginRequestDto(email = email, password = password) ) @@ -38,4 +39,29 @@ class RemoteMemberDataSource( ApiResult.Error(message = t.message, cause = t) } } + + suspend fun sendEmailVerification(email: String): ApiResult { + return try { + val response: HttpResponse = client + .post("api/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) + } + } } \ No newline at end of file From 2513f64919588e885035364717a6586ec68ef77d Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Thu, 2 Oct 2025 16:59:22 +0900 Subject: [PATCH 04/32] =?UTF-8?q?[feat]:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=ED=95=A8=EC=88=98=20Repository=EC=97=90?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/repository/MemberRepository.kt | 4 +++ .../auth/login/SignupEmailInputScreen.kt | 29 +++++++++++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) 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 71adf94..335ddfd 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 @@ -3,6 +3,7 @@ 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.EmailVerificationResponseDto class MemberRepository( private val dataSource: RemoteMemberDataSource @@ -10,4 +11,7 @@ class MemberRepository( suspend fun login(email: String, password: String): ApiResult = dataSource.login(email, password) + suspend fun sendEmailVerification(email: String): ApiResult = + dataSource.sendEmailVerification(email) + } \ No newline at end of file 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..4f1631b 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 @@ -25,6 +25,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.MemberRepository +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 +43,10 @@ fun SignupScreen( onNavigateToEmailVerification: (String) -> Unit = {} ) { var email by remember { mutableStateOf("") } + var isLoading by remember { mutableStateOf(false) } + + val memberRepository: MemberRepository = koinInject() + val coroutineScope = rememberCoroutineScope() Box( modifier = modifier @@ -81,8 +90,24 @@ 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 (memberRepository.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) From 73014ed06de8c648b934c213224289eb645bfc3d Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Thu, 2 Oct 2025 17:01:05 +0900 Subject: [PATCH 05/32] =?UTF-8?q?[feat]:=20=EB=A1=9C=EB=94=A9=20=EC=9D=B8?= =?UTF-8?q?=EB=94=94=EC=BC=80=EC=9D=B4=ED=84=B0=20=EC=B6=94=EA=B0=80=20(#2?= =?UTF-8?q?6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/auth/login/SignupEmailInputScreen.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) 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 4f1631b..3c28a41 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 @@ -113,6 +114,16 @@ fun SignupScreen( .padding(horizontal = 16.dp) .padding(bottom = 52.dp) ) + + // 로딩 인디케이터 + if (isLoading) { + CircularProgressIndicator( + color = Color(0xFFF89531), + modifier = Modifier + .align(Alignment.Center) + .size(48.dp) + ) + } } } From 3a35802f083d7f631991f225c90ad888b21802dd Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Thu, 2 Oct 2025 17:28:55 +0900 Subject: [PATCH 06/32] =?UTF-8?q?[feat]:=20=EC=9D=B8=EC=A6=9D=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=9E=85=EB=A0=A5=20=EB=B0=95=EC=8A=A4=20UI=20?= =?UTF-8?q?=EC=9E=AC=EC=88=98=EC=A0=95=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/login/component/NumberInputBox.kt | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) 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() From 2fd2c9f8547c380a99a5c04537187886e8a40c5e Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Thu, 2 Oct 2025 17:29:33 +0900 Subject: [PATCH 07/32] =?UTF-8?q?[feat]:=20Route=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=A9=94=EC=9D=BC=EC=9D=84=20=EB=8B=A4=EC=9D=8C=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=9C=BC=EB=A1=9C=20=EB=84=98=EA=B8=B0?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/org/whosin/client/core/navigation/Route.kt | 2 +- .../org/whosin/client/core/navigation/WhosInNavGraph.kt | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) 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 e704b89..c581d0f 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,7 +20,7 @@ sealed interface Route { data object Signup: Route @Serializable - data object EmailVerification: Route + data class EmailVerification(val email: String): Route @Serializable data object PasswordInput: 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 8a434be..fc315eb 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 @@ -6,6 +6,7 @@ import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.navigation +import androidx.navigation.toRoute import org.whosin.client.presentation.auth.clubcode.ClubCodeInputScreen import org.whosin.client.presentation.auth.login.EmailVerificationScreen import org.whosin.client.presentation.auth.login.LoginScreen @@ -74,15 +75,17 @@ 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) From ccb686ff7372376e663bf8217a06eb6e8be59046 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Thu, 2 Oct 2025 17:29:47 +0900 Subject: [PATCH 08/32] =?UTF-8?q?[feat]:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20RequestDto=20=EC=88=98=EC=A0=95=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/dto/request/EmailValidationRequestDto.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/request/EmailValidationRequestDto.kt 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 From ff0eb96dd5040b2529ff077b6516fac0f3e3660b Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Thu, 2 Oct 2025 17:30:47 +0900 Subject: [PATCH 09/32] =?UTF-8?q?[feat]:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20DataSource=EC=97=90?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/remote/RemoteMemberDataSource.kt | 31 +++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) 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 bd663dc..3785f9f 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 @@ -2,16 +2,16 @@ package org.whosin.client.data.remote import io.ktor.client.HttpClient import io.ktor.client.call.body -import io.ktor.client.request.get 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.EmailValidationRequestDto import org.whosin.client.data.dto.request.EmailVerificationRequestDto -import org.whosin.client.data.dto.response.LoginResponseDto +import org.whosin.client.data.dto.request.LoginRequestDto import org.whosin.client.data.dto.response.EmailVerificationResponseDto +import org.whosin.client.data.dto.response.LoginResponseDto class RemoteMemberDataSource( private val client: HttpClient @@ -64,4 +64,29 @@ class RemoteMemberDataSource( ApiResult.Error(message = t.message, cause = t) } } + + suspend fun validateEmailCode(email: String, authCode: String): ApiResult { + return try { + val response: HttpResponse = client + .post("api/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) + } + } } \ No newline at end of file From 66c62baf03fa73c7b5e2cdbe5fa45f3c26accb5c Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Thu, 2 Oct 2025 17:31:11 +0900 Subject: [PATCH 10/32] =?UTF-8?q?[feat]:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EC=BD=94=EB=93=9C=20=EC=9E=85=EB=A0=A5=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20Screen=EC=97=90=20=EC=97=B0=EA=B2=B0=20(#2?= =?UTF-8?q?6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/repository/MemberRepository.kt | 3 + .../auth/login/EmailVerificationScreen.kt | 83 ++++++++++++++++--- 2 files changed, 76 insertions(+), 10 deletions(-) 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 335ddfd..98cc7f7 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 @@ -14,4 +14,7 @@ class MemberRepository( suspend fun sendEmailVerification(email: String): ApiResult = dataSource.sendEmailVerification(email) + suspend fun validateEmailCode(email: String, authCode: String): ApiResult = + dataSource.validateEmailCode(email, authCode) + } \ No newline at end of file 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..02f3fae 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.MemberRepository 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,13 +48,19 @@ 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 memberRepository: MemberRepository = koinInject() + val coroutineScope = rememberCoroutineScope() // 화면 진입 시 첫 번째 입력 박스에 포커스 LaunchedEffect(Unit) { @@ -110,17 +123,20 @@ 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) { - currentFocusIndex = index - 1 - focusRequesters[index - 1].requestFocus() - } else if (input.isNotEmpty()) { + } + // 현재 박스가 비워지고 이전 박스가 있으면 이전으로 이동 + else if (input.isEmpty() && index > 0) { + // 현재 위치에 머물러서 다시 입력할 수 있도록 함 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,59 @@ 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 = memberRepository.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 +227,9 @@ fun EmailVerificationScreen( fun VerificationCodeScreenPreview() { EmailVerificationScreen( modifier = Modifier, + email = "test@example.com", onNavigateBack = {}, - onVerificationComplete = { code -> + onVerificationComplete = { // 인증번호 처리 로직 } ) From 0a165306a70de97ebd5da237e4ea1cfef05bfcc3 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Thu, 2 Oct 2025 17:41:47 +0900 Subject: [PATCH 11/32] =?UTF-8?q?[feat]:=20=EC=9D=B8=EC=A6=9D=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=9E=85=EB=A0=A5=20=EB=A1=9C=EC=A7=81=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../client/presentation/auth/login/EmailVerificationScreen.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 02f3fae..3a77d83 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 @@ -136,8 +136,8 @@ fun EmailVerificationScreen( } // 현재 박스가 비워지고 이전 박스가 있으면 이전으로 이동 else if (input.isEmpty() && index > 0) { - // 현재 위치에 머물러서 다시 입력할 수 있도록 함 - currentFocusIndex = index + currentFocusIndex = index - 1 + focusRequesters[index - 1].requestFocus() } } }, From 6ed715434662c406980c6df64d41881303803b61 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Thu, 2 Oct 2025 17:44:23 +0900 Subject: [PATCH 12/32] =?UTF-8?q?[feat]:=20=EC=9D=B8=EC=A6=9D=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=9E=85=EB=A0=A5=20=EB=A1=9C=EC=A7=81=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/clubcode/ClubCodeInputScreen.kt | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) 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 0cdec61..25cd623 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 @@ -141,16 +141,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() } } }, @@ -159,6 +163,7 @@ fun ClubCodeInputScreen( val prevIndex = index - 1 currentFocusIndex = prevIndex focusRequesters[prevIndex].requestFocus() + keyboardController?.show() } }, onFocusChanged = { isFocused -> @@ -166,6 +171,11 @@ fun ClubCodeInputScreen( currentFocusIndex = index } }, + onClick = { + currentFocusIndex = index + focusRequesters[index].requestFocus() + keyboardController?.show() + }, borderColor = when (currentState) { ClubCodeState.ERROR -> Color(0xFFFF3636) else -> Color(0xFFE5E5E5) From 07575cdc2e01f3c70d5a5899f7d37fd5bb82a3b8 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Fri, 3 Oct 2025 12:26:19 +0900 Subject: [PATCH 13/32] =?UTF-8?q?[feat]:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20Dto=20=EC=B6=94=EA=B0=80=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/dto/request/SignupRequestDto.kt | 14 ++++++++++++++ .../data/dto/response/SignupResponseDto.kt | 18 ++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/request/SignupRequestDto.kt create mode 100644 composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/response/SignupResponseDto.kt 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/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 From ac296f0a29c2a1bb6e78ef5ead882d6401a20513 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Fri, 3 Oct 2025 12:26:45 +0900 Subject: [PATCH 14/32] =?UTF-8?q?[feat]:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EC=9E=85=EB=A0=A5=20=ED=99=94=EB=A9=B4=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=20=EC=88=98=EC=A0=95=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../client/presentation/auth/login/PasswordInputScreen.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 From 401ec8194c3f65bc6b07e186694926bb23ce63df Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Fri, 3 Oct 2025 12:27:27 +0900 Subject: [PATCH 15/32] =?UTF-8?q?[feat]:=20DataSource,=20Repository?= =?UTF-8?q?=EC=97=90=20=ED=95=A8=EC=88=98=20=EC=B6=94=EA=B0=80=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/remote/RemoteMemberDataSource.kt | 36 ++++++++++++++++++- .../data/repository/MemberRepository.kt | 22 ++++++++++-- .../auth/login/EmailVerificationScreen.kt | 11 +++--- .../auth/login/SignupEmailInputScreen.kt | 7 ++-- 4 files changed, 64 insertions(+), 12 deletions(-) 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 3785f9f..50bea24 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 @@ -10,8 +10,10 @@ 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.LoginRequestDto +import org.whosin.client.data.dto.request.SignupRequestDto import org.whosin.client.data.dto.response.EmailVerificationResponseDto import org.whosin.client.data.dto.response.LoginResponseDto +import org.whosin.client.data.dto.response.SignupResponseDto class RemoteMemberDataSource( private val client: HttpClient @@ -65,7 +67,10 @@ class RemoteMemberDataSource( } } - suspend fun validateEmailCode(email: String, authCode: String): ApiResult { + suspend fun validateEmailCode( + email: String, + authCode: String + ): ApiResult { return try { val response: HttpResponse = client .post("api/auth/email/validation") { @@ -89,4 +94,33 @@ class RemoteMemberDataSource( ApiResult.Error(message = t.message, cause = t) } } + + suspend fun signup( + email: String, + password: String, + nickName: String + ): ApiResult { + return try { + val response: HttpResponse = client + .post("api/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) + } + } } \ 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 98cc7f7..918dd61 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 @@ -4,17 +4,33 @@ 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.EmailVerificationResponseDto +import org.whosin.client.data.dto.response.SignupResponseDto class MemberRepository( private val dataSource: RemoteMemberDataSource ) { - suspend fun login(email: String, password: String): ApiResult = + suspend fun login( + email: String, + password: String + ): ApiResult = dataSource.login(email, password) - suspend fun sendEmailVerification(email: String): ApiResult = + suspend fun sendEmailVerification( + email: String + ): ApiResult = dataSource.sendEmailVerification(email) - suspend fun validateEmailCode(email: String, authCode: String): ApiResult = + 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) + } \ No newline at end of file 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 3a77d83..06ed843 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 @@ -58,7 +58,7 @@ fun EmailVerificationScreen( var errorMessage by remember { mutableStateOf(null) } val focusRequesters = remember { List(6) { FocusRequester() } } val keyboardController = LocalSoftwareKeyboardController.current - + val memberRepository: MemberRepository = koinInject() val coroutineScope = rememberCoroutineScope() @@ -125,7 +125,7 @@ fun EmailVerificationScreen( val newCode = verificationCode.copyOf() newCode[index] = input verificationCode = newCode - + // 에러 메시지 초기화 errorMessage = null @@ -166,7 +166,7 @@ fun EmailVerificationScreen( ) } } - + // 에러 메시지 표시 if (errorMessage != null) { Text( @@ -188,13 +188,14 @@ fun EmailVerificationScreen( onClick = { if (isComplete && !isLoading) { isLoading = true - + coroutineScope.launch { when (val result = memberRepository.validateEmailCode(email, fullCode)) { is ApiResult.Success -> { isLoading = false onVerificationComplete() } + is ApiResult.Error -> { isLoading = false errorMessage = result.message @@ -209,7 +210,7 @@ fun EmailVerificationScreen( .padding(horizontal = 16.dp) .padding(bottom = 52.dp) ) - + // 로딩 인디케이터 if (isLoading) { CircularProgressIndicator( 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 3c28a41..b15a000 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 @@ -45,7 +45,7 @@ fun SignupScreen( ) { var email by remember { mutableStateOf("") } var isLoading by remember { mutableStateOf(false) } - + val memberRepository: MemberRepository = koinInject() val coroutineScope = rememberCoroutineScope() @@ -94,13 +94,14 @@ fun SignupScreen( onClick = { if (email.isNotBlank() && !isLoading) { isLoading = true - + coroutineScope.launch { when (memberRepository.sendEmailVerification(email)) { is ApiResult.Success -> { isLoading = false onNavigateToEmailVerification(email) } + is ApiResult.Error -> { isLoading = false } @@ -114,7 +115,7 @@ fun SignupScreen( .padding(horizontal = 16.dp) .padding(bottom = 52.dp) ) - + // 로딩 인디케이터 if (isLoading) { CircularProgressIndicator( From f18c0c3d3893cf1acb8dca9a315f1d8f20fad63c Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Fri, 3 Oct 2025 12:27:43 +0900 Subject: [PATCH 16/32] =?UTF-8?q?[feat]:=20=EB=A3=A8=ED=8A=B8=20=ED=8C=8C?= =?UTF-8?q?=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EC=88=98=EC=A0=95=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../whosin/client/core/navigation/Route.kt | 4 ++-- .../client/core/navigation/WhosInNavGraph.kt | 20 ++++++++++++------- 2 files changed, 15 insertions(+), 9 deletions(-) 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 c581d0f..167b039 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 @@ -23,10 +23,10 @@ sealed interface 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 object ClubCodeInput: 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 fc315eb..a9123f5 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 @@ -82,30 +82,36 @@ fun WhosInNavGraph( 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) From 0b35a2241b9ad9937c2cc3c84c410812444164f2 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Fri, 3 Oct 2025 12:27:57 +0900 Subject: [PATCH 17/32] =?UTF-8?q?[feat]:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20viewModel=20=EC=B6=94=EA=B0=80=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/org/whosin/client/di/DIModules.kt | 2 + .../auth/login/viewmodel/SignupViewModel.kt | 38 +++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/viewmodel/SignupViewModel.kt 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 8d749dc..b6d104a 100644 --- a/composeApp/src/commonMain/kotlin/org/whosin/client/di/DIModules.kt +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/di/DIModules.kt @@ -13,6 +13,7 @@ import org.whosin.client.data.repository.MemberRepository import org.whosin.client.presentation.dummy.DummyViewModel import org.whosin.client.presentation.dummy.TokenTestViewModel import org.whosin.client.presentation.auth.login.viewmodel.LoginViewModel +import org.whosin.client.presentation.auth.login.viewmodel.SignupViewModel import org.whosin.client.presentation.home.HomeViewModel import org.whosin.client.presentation.mypage.MyPageViewModel @@ -45,6 +46,7 @@ val repositoryModule = module { // ViewModel을 새로 생성하는 경우에 모듈에 추가하여 사용 val viewModelModule = module { viewModelOf(::LoginViewModel) + viewModelOf(::SignupViewModel) viewModelOf(::HomeViewModel) viewModelOf(::MyPageViewModel) viewModelOf(::DummyViewModel) // TODO: 이후에 삭제 예정 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..5fcf2da --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/viewmodel/SignupViewModel.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.MemberRepository + +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: MemberRepository +): 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 result = repository.signup(email, password, nickName)) { + is ApiResult.Success -> { + _uiState.value = SignupUiState.Success + } + is ApiResult.Error -> { + val message = result.message ?: result.cause?.message + _uiState.value = SignupUiState.Error(message) + } + } + } + } +} \ No newline at end of file From 9065a1b9b132d10704229e5c6cdc9a06ed6d04f6 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Fri, 3 Oct 2025 12:28:36 +0900 Subject: [PATCH 18/32] =?UTF-8?q?[feat]:=20nickName=20=EC=9E=85=EB=A0=A5?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=EC=97=90=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EB=A1=9C=EC=A7=81=20=EC=97=B0=EA=B2=B0=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/login/NicknameInputScreen.kt | 60 +++++++++++++++++-- 1 file changed, 54 insertions(+), 6 deletions(-) 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 From cc0320cb8f9bc710f8e82d846e6081da9b012b00 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Fri, 3 Oct 2025 12:49:29 +0900 Subject: [PATCH 19/32] =?UTF-8?q?[feat]:=20=EA=B8=B0=EC=A1=B4=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EB=A1=9C=EC=A7=81=EC=9D=84=20api=20?= =?UTF-8?q?=EC=8A=A4=ED=8E=99=EC=97=90=20=EB=A7=9E=EA=B2=8C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../whosin/client/data/dto/response/LoginResponseDto.kt | 8 +++++--- .../whosin/client/data/remote/RemoteMemberDataSource.kt | 7 ++++--- .../presentation/auth/login/viewmodel/LoginViewModel.kt | 7 ++++++- 3 files changed, 15 insertions(+), 7 deletions(-) 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/remote/RemoteMemberDataSource.kt b/composeApp/src/commonMain/kotlin/org/whosin/client/data/remote/RemoteMemberDataSource.kt index 50bea24..13267b3 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 @@ -21,7 +21,7 @@ class RemoteMemberDataSource( suspend fun login(email: String, password: String): ApiResult { return try { val response: HttpResponse = client - .post("api/members/login") { + .post("api/auth/login") { setBody( LoginRequestDto(email = email, password = password) ) @@ -32,9 +32,10 @@ class RemoteMemberDataSource( statusCode = response.status.value ) } else { + val errorResponse: LoginResponseDto = response.body() ApiResult.Error( - code = response.status.value, - message = "HTTP ${response.status.value}" + code = errorResponse.status, + message = errorResponse.message ) } } catch (t: Throwable) { 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..b2041d3 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 @@ -26,7 +26,12 @@ class LoginViewModel( viewModelScope.launch { 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) { + _uiState.value = LoginUiState.Success(tokenData) + } else { + _uiState.value = LoginUiState.Error("토큰 데이터를 받지 못했습니다.") + } } is ApiResult.Error -> { val message = result.message ?: result.cause?.message From 8121194fffe364f4d198690ae04f6a1c544a38bf Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Fri, 3 Oct 2025 12:49:57 +0900 Subject: [PATCH 20/32] =?UTF-8?q?[feat]:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=84=B1=EA=B3=B5=EC=8B=9C=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/login/viewmodel/SignupViewModel.kt | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) 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 index 5fcf2da..23410af 100644 --- 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 @@ -5,6 +5,7 @@ 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 @@ -16,7 +17,8 @@ sealed interface SignupUiState { } class SignupViewModel( - private val repository: MemberRepository + private val repository: MemberRepository, + private val tokenManager: TokenManager ): ViewModel() { private val _uiState: MutableStateFlow = MutableStateFlow(SignupUiState.Idle) val uiState: StateFlow = _uiState @@ -24,12 +26,30 @@ class SignupViewModel( fun signup(email: String, password: String, nickName: String) { _uiState.value = SignupUiState.Loading viewModelScope.launch { - when (val result = repository.signup(email, password, nickName)) { + when (val signupResult = repository.signup(email, password, nickName)) { is ApiResult.Success -> { - _uiState.value = SignupUiState.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 = result.message ?: result.cause?.message + val message = signupResult.message ?: signupResult.cause?.message _uiState.value = SignupUiState.Error(message) } } From 51d143a635a2dfcacae91aa2e0aabe060ec6d230 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Fri, 3 Oct 2025 13:09:28 +0900 Subject: [PATCH 21/32] =?UTF-8?q?[feat]:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=97=90=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=97=B0=EA=B2=B0=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/auth/login/LoginScreen.kt | 43 +++++++++++++++++-- .../auth/login/viewmodel/LoginViewModel.kt | 18 +++++--- 2 files changed, 52 insertions(+), 9 deletions(-) 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..a68cef4 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,25 @@ 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("") } + var errorMessage by remember { mutableStateOf(null) } + val uiState by viewModel.uiState.collectAsState() + + LaunchedEffect(uiState) { + when (uiState) { + is LoginUiState.Success -> { + onNavigateToHome() + } + is LoginUiState.Error -> { + errorMessage = (uiState as LoginUiState.Error).message + } + else -> {} + } + } Box( modifier = modifier @@ -100,15 +121,31 @@ fun LoginScreen( ) CommonLoginInputField( value = password, - onValueChange = { password = it }, + onValueChange = { + password = it + errorMessage = null + }, placeholder = stringResource(Res.string.password_placeholder), isPassword = true, modifier = Modifier.padding(bottom = 16.dp) ) + if (errorMessage != null) { + Text( + text = errorMessage ?: "", + 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 !is LoginUiState.Loading, modifier = Modifier.padding(bottom = 12.dp) ) } 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 b2041d3..43ac7e7 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,21 +5,23 @@ 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 sealed interface LoginUiState { + data object Idle: LoginUiState data object Loading: LoginUiState - data class Success(val token: TokenDto): LoginUiState + data object Success: LoginUiState data class Error(val message: String?): LoginUiState } class LoginViewModel( - private val repository: MemberRepository + private val repository: MemberRepository, + private val tokenManager: TokenManager ): ViewModel() { - private val _uiState: MutableStateFlow = MutableStateFlow(null) - val uiState: StateFlow = _uiState + private val _uiState: MutableStateFlow = MutableStateFlow(LoginUiState.Idle) + val uiState: StateFlow = _uiState fun login(email: String, password: String) { _uiState.value = LoginUiState.Loading @@ -28,7 +30,11 @@ class LoginViewModel( is ApiResult.Success -> { val tokenData = result.data.data if (tokenData != null) { - _uiState.value = LoginUiState.Success(tokenData) + tokenManager.saveTokens( + accessToken = tokenData.accessToken, + refreshToken = tokenData.refreshToken + ) + _uiState.value = LoginUiState.Success } else { _uiState.value = LoginUiState.Error("토큰 데이터를 받지 못했습니다.") } From ff9057acb26c554ccc1b9b2372a990fc385a86ea Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Fri, 3 Oct 2025 14:47:33 +0900 Subject: [PATCH 22/32] =?UTF-8?q?[feat]:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EC=B0=BE=EA=B8=B0=20Dto=20=EC=B6=94=EA=B0=80=20(#2?= =?UTF-8?q?6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/dto/request/FindPasswordRequestDto.kt | 10 ++++++++++ .../dto/response/FindPasswordResponseDto.kt | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/request/FindPasswordRequestDto.kt create mode 100644 composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/response/FindPasswordResponseDto.kt 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/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 From 4f8acbd3fbe6e66601c84aedf3d690e4b3c04af0 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Fri, 3 Oct 2025 14:47:59 +0900 Subject: [PATCH 23/32] =?UTF-8?q?[feat]:=20=EC=9E=84=EC=8B=9C=20=EB=B9=84?= =?UTF-8?q?=EB=B0=80=EB=B2=88=ED=98=B8=20=EB=B0=9C=EA=B8=89=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=20=EA=B5=AC=ED=98=84=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/remote/RemoteMemberDataSource.kt | 27 +++++++++++++++++++ .../data/repository/MemberRepository.kt | 4 +++ 2 files changed, 31 insertions(+) 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 13267b3..6be18e9 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 @@ -9,9 +9,11 @@ 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.FindPasswordResponseDto import org.whosin.client.data.dto.response.LoginResponseDto import org.whosin.client.data.dto.response.SignupResponseDto @@ -124,4 +126,29 @@ class RemoteMemberDataSource( ApiResult.Error(message = t.message, cause = t) } } + + suspend fun sendPasswordResetEmail(email: String): ApiResult { + return try { + val response: HttpResponse = client + .post("api/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/repository/MemberRepository.kt b/composeApp/src/commonMain/kotlin/org/whosin/client/data/repository/MemberRepository.kt index 918dd61..cf3e503 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 @@ -5,6 +5,7 @@ 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 MemberRepository( private val dataSource: RemoteMemberDataSource @@ -33,4 +34,7 @@ class MemberRepository( ): ApiResult = dataSource.signup(email, password, nickName) + suspend fun sendPasswordResetEmail(email: String): ApiResult = + dataSource.sendPasswordResetEmail(email) + } \ No newline at end of file From 38c1a9d1f7537ade26a93bf12b152199de3209c1 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Fri, 3 Oct 2025 14:48:14 +0900 Subject: [PATCH 24/32] =?UTF-8?q?[feat]:=20=EC=9E=84=EC=8B=9C=20=EB=B9=84?= =?UTF-8?q?=EB=B0=80=EB=B2=88=ED=98=B8=20=EB=B0=9C=EA=B8=89=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20viewModel=20=EA=B5=AC=ED=98=84=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/org/whosin/client/di/DIModules.kt | 2 + .../login/viewmodel/FindPasswordViewModel.kt | 38 +++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/viewmodel/FindPasswordViewModel.kt 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 b6d104a..f401933 100644 --- a/composeApp/src/commonMain/kotlin/org/whosin/client/di/DIModules.kt +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/di/DIModules.kt @@ -12,6 +12,7 @@ import org.whosin.client.data.repository.ClubRepository import org.whosin.client.data.repository.MemberRepository 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.home.HomeViewModel @@ -47,6 +48,7 @@ val repositoryModule = module { val viewModelModule = module { 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/login/viewmodel/FindPasswordViewModel.kt b/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/viewmodel/FindPasswordViewModel.kt new file mode 100644 index 0000000..4cfe6a8 --- /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.MemberRepository + +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: MemberRepository +): 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 From 505bea057d40252532cf8ef87c393b64a7f2ec3b Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Fri, 3 Oct 2025 14:48:22 +0900 Subject: [PATCH 25/32] =?UTF-8?q?[feat]:=20=EC=9E=84=EC=8B=9C=20=EB=B9=84?= =?UTF-8?q?=EB=B0=80=EB=B2=88=ED=98=B8=20=EB=B0=9C=EA=B8=89=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EB=A1=9C=EC=A7=81=EA=B3=BC=20=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/login/FindPasswordScreen.kt | 49 +++++++++++++++++-- 1 file changed, 44 insertions(+), 5 deletions(-) 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 From 9be520c37af3058f5c3be71ad90326b14dd1543d Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Sun, 5 Oct 2025 15:46:47 +0900 Subject: [PATCH 26/32] =?UTF-8?q?[refactor]:=20Auth=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=EC=9D=84=20=EB=8B=A4=EB=A5=B8=20=ED=8C=8C=EC=9D=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/remote/RemoteAuthDataSource.kt | 154 ++++++++++++++++++ .../data/remote/RemoteMemberDataSource.kt | 146 ----------------- .../client/data/repository/AuthRepository.kt | 40 +++++ .../data/repository/MemberRepository.kt | 32 ---- .../kotlin/org/whosin/client/di/DIModules.kt | 4 + .../auth/login/EmailVerificationScreen.kt | 8 +- .../auth/login/SignupEmailInputScreen.kt | 8 +- .../login/viewmodel/FindPasswordViewModel.kt | 4 +- .../auth/login/viewmodel/LoginViewModel.kt | 4 +- .../auth/login/viewmodel/SignupViewModel.kt | 4 +- 10 files changed, 212 insertions(+), 192 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/org/whosin/client/data/remote/RemoteAuthDataSource.kt create mode 100644 composeApp/src/commonMain/kotlin/org/whosin/client/data/repository/AuthRepository.kt 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..cfba54d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/data/remote/RemoteAuthDataSource.kt @@ -0,0 +1,154 @@ +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.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("api/auth/login") { + setBody( + LoginRequestDto(email = email, password = password) + ) + } + if (response.status.isSuccess()) { + ApiResult.Success( + data = response.body(), + statusCode = response.status.value + ) + } else { + val errorResponse: LoginResponseDto = response.body() + ApiResult.Error( + code = errorResponse.status, + message = errorResponse.message + ) + } + } catch (t: Throwable) { + ApiResult.Error(message = t.message, cause = t) + } + } + + suspend fun sendEmailVerification(email: String): ApiResult { + return try { + val response: HttpResponse = client + .post("api/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("api/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("api/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("api/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 6be18e9..93db0e9 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 @@ -1,154 +1,8 @@ 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.FindPasswordResponseDto -import org.whosin.client.data.dto.response.LoginResponseDto -import org.whosin.client.data.dto.response.SignupResponseDto class RemoteMemberDataSource( private val client: HttpClient ) { - suspend fun login(email: String, password: String): ApiResult { - return try { - val response: HttpResponse = client - .post("api/auth/login") { - setBody( - LoginRequestDto(email = email, password = password) - ) - } - if (response.status.isSuccess()) { - ApiResult.Success( - data = response.body(), - statusCode = response.status.value - ) - } else { - val errorResponse: LoginResponseDto = response.body() - ApiResult.Error( - code = errorResponse.status, - message = errorResponse.message - ) - } - } catch (t: Throwable) { - ApiResult.Error(message = t.message, cause = t) - } - } - - suspend fun sendEmailVerification(email: String): ApiResult { - return try { - val response: HttpResponse = client - .post("api/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("api/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("api/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("api/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/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 cf3e503..657158b 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 @@ -1,40 +1,8 @@ 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.EmailVerificationResponseDto -import org.whosin.client.data.dto.response.SignupResponseDto -import org.whosin.client.data.dto.response.FindPasswordResponseDto class MemberRepository( private val dataSource: RemoteMemberDataSource ) { - 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/di/DIModules.kt b/composeApp/src/commonMain/kotlin/org/whosin/client/di/DIModules.kt index f401933..3374e52 100644 --- a/composeApp/src/commonMain/kotlin/org/whosin/client/di/DIModules.kt +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/di/DIModules.kt @@ -5,8 +5,10 @@ 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 @@ -33,12 +35,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: 이후에 삭제 예정 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 06ed843..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 @@ -37,7 +37,7 @@ 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.MemberRepository +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 @@ -59,7 +59,7 @@ fun EmailVerificationScreen( val focusRequesters = remember { List(6) { FocusRequester() } } val keyboardController = LocalSoftwareKeyboardController.current - val memberRepository: MemberRepository = koinInject() + val authRepository: AuthRepository = koinInject() val coroutineScope = rememberCoroutineScope() // 화면 진입 시 첫 번째 입력 박스에 포커스 @@ -190,8 +190,8 @@ fun EmailVerificationScreen( isLoading = true coroutineScope.launch { - when (val result = memberRepository.validateEmailCode(email, fullCode)) { - is ApiResult.Success -> { + when (val result = authRepository.validateEmailCode(email, fullCode)) { + is ApiResult.Success<*> -> { isLoading = false onVerificationComplete() } 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 b15a000..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 @@ -26,7 +26,7 @@ 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.MemberRepository +import org.whosin.client.data.repository.AuthRepository import org.whosin.client.core.network.ApiResult import androidx.compose.runtime.rememberCoroutineScope import kotlinx.coroutines.launch @@ -46,7 +46,7 @@ fun SignupScreen( var email by remember { mutableStateOf("") } var isLoading by remember { mutableStateOf(false) } - val memberRepository: MemberRepository = koinInject() + val authRepository: AuthRepository = koinInject() val coroutineScope = rememberCoroutineScope() Box( @@ -96,8 +96,8 @@ fun SignupScreen( isLoading = true coroutineScope.launch { - when (memberRepository.sendEmailVerification(email)) { - is ApiResult.Success -> { + when (authRepository.sendEmailVerification(email)) { + is ApiResult.Success<*> -> { isLoading = false onNavigateToEmailVerification(email) } 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 index 4cfe6a8..32a4a46 100644 --- 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 @@ -6,7 +6,7 @@ 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.MemberRepository +import org.whosin.client.data.repository.AuthRepository sealed interface FindPasswordUiState { data object Idle: FindPasswordUiState @@ -16,7 +16,7 @@ sealed interface FindPasswordUiState { } class FindPasswordViewModel( - private val repository: MemberRepository + private val repository: AuthRepository ): ViewModel() { private val _uiState: MutableStateFlow = MutableStateFlow(FindPasswordUiState.Idle) val uiState: StateFlow = _uiState 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 43ac7e7..3be2c80 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 @@ -7,7 +7,7 @@ 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.repository.AuthRepository sealed interface LoginUiState { data object Idle: LoginUiState @@ -17,7 +17,7 @@ sealed interface LoginUiState { } class LoginViewModel( - private val repository: MemberRepository, + private val repository: AuthRepository, private val tokenManager: TokenManager ): ViewModel() { private val _uiState: MutableStateFlow = MutableStateFlow(LoginUiState.Idle) 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 index 23410af..5cffbbb 100644 --- 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 @@ -7,7 +7,7 @@ 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.repository.AuthRepository sealed interface SignupUiState { data object Idle: SignupUiState @@ -17,7 +17,7 @@ sealed interface SignupUiState { } class SignupViewModel( - private val repository: MemberRepository, + private val repository: AuthRepository, private val tokenManager: TokenManager ): ViewModel() { private val _uiState: MutableStateFlow = MutableStateFlow(SignupUiState.Idle) From 784ac98f6dcd0a6ecbf5421400829d88a6edf0c9 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Sun, 5 Oct 2025 15:48:09 +0900 Subject: [PATCH 27/32] =?UTF-8?q?[refactor]:=20=EB=A7=A4=EB=8B=88=ED=8E=98?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=ED=8C=8C=EC=9D=BC=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- composeApp/src/androidMain/AndroidManifest.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index b187f69..2ea8450 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -11,7 +11,7 @@ android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@android:style/Theme.Material.Light.NoActionBar" - android:networkSecurityConfig="@xml/network_security_config"> + android:usesCleartextTraffic="true"> From 691d6492af4f15c2b80cd732f69b2b4fa096bde6 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Sun, 5 Oct 2025 16:12:36 +0900 Subject: [PATCH 28/32] =?UTF-8?q?[refactor]:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/remote/RemoteAuthDataSource.kt | 20 ++++++++--- .../presentation/auth/login/LoginScreen.kt | 24 ++++---------- .../auth/login/viewmodel/LoginViewModel.kt | 33 ++++++++++++------- 3 files changed, 43 insertions(+), 34 deletions(-) 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 index cfba54d..060bd29 100644 --- a/composeApp/src/commonMain/kotlin/org/whosin/client/data/remote/RemoteAuthDataSource.kt +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/data/remote/RemoteAuthDataSource.kt @@ -13,6 +13,7 @@ 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 @@ -34,11 +35,20 @@ class RemoteAuthDataSource( statusCode = response.status.value ) } else { - val errorResponse: LoginResponseDto = response.body() - ApiResult.Error( - code = errorResponse.status, - message = errorResponse.message - ) + // 에러 응답 파싱 시도 + 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) 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 a68cef4..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 @@ -56,18 +56,11 @@ fun LoginScreen( ) { var email by remember { mutableStateOf("") } var password by remember { mutableStateOf("") } - var errorMessage by remember { mutableStateOf(null) } val uiState by viewModel.uiState.collectAsState() - LaunchedEffect(uiState) { - when (uiState) { - is LoginUiState.Success -> { - onNavigateToHome() - } - is LoginUiState.Error -> { - errorMessage = (uiState as LoginUiState.Error).message - } - else -> {} + LaunchedEffect(uiState.isSuccess) { + if (uiState.isSuccess) { + onNavigateToHome() } } @@ -121,18 +114,15 @@ fun LoginScreen( ) CommonLoginInputField( value = password, - onValueChange = { - password = it - errorMessage = null - }, + onValueChange = { password = it }, placeholder = stringResource(Res.string.password_placeholder), isPassword = true, modifier = Modifier.padding(bottom = 16.dp) ) - if (errorMessage != null) { + uiState.errorMessage?.let { errorMsg -> Text( - text = errorMessage ?: "", + text = errorMsg, color = Color.Red, fontSize = 14.sp, fontWeight = FontWeight.W400, @@ -145,7 +135,7 @@ fun LoginScreen( onClick = { viewModel.login(email, password) }, - enabled = email.isNotBlank() && password.isNotBlank() && uiState !is LoginUiState.Loading, + 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/viewmodel/LoginViewModel.kt b/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/viewmodel/LoginViewModel.kt index 3be2c80..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 @@ -9,23 +9,23 @@ import org.whosin.client.core.datastore.TokenManager import org.whosin.client.core.network.ApiResult import org.whosin.client.data.repository.AuthRepository -sealed interface LoginUiState { - data object Idle: LoginUiState - data object Loading: LoginUiState - data object Success: 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: AuthRepository, private val tokenManager: TokenManager ): ViewModel() { - private val _uiState: MutableStateFlow = MutableStateFlow(LoginUiState.Idle) + 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 -> { val tokenData = result.data.data @@ -34,14 +34,23 @@ class LoginViewModel( accessToken = tokenData.accessToken, refreshToken = tokenData.refreshToken ) - _uiState.value = LoginUiState.Success + _uiState.value = _uiState.value.copy( + isLoading = false, + isSuccess = true, + errorMessage = null + ) } else { - _uiState.value = LoginUiState.Error("토큰 데이터를 받지 못했습니다.") + _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 ?: "로그인에 실패했습니다." + ) } } } From 76de9014ec8dc0f916479613850d13493868d814 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Sun, 5 Oct 2025 16:32:51 +0900 Subject: [PATCH 29/32] =?UTF-8?q?[refactor]:=20path=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../whosin/client/data/remote/RemoteAuthDataSource.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 index 060bd29..a72ae98 100644 --- a/composeApp/src/commonMain/kotlin/org/whosin/client/data/remote/RemoteAuthDataSource.kt +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/data/remote/RemoteAuthDataSource.kt @@ -24,7 +24,7 @@ class RemoteAuthDataSource( suspend fun login(email: String, password: String): ApiResult { return try { val response: HttpResponse = client - .post("api/auth/login") { + .post("auth/login") { setBody( LoginRequestDto(email = email, password = password) ) @@ -58,7 +58,7 @@ class RemoteAuthDataSource( suspend fun sendEmailVerification(email: String): ApiResult { return try { val response: HttpResponse = client - .post("api/auth/email/send") { + .post("auth/email/send") { setBody( EmailVerificationRequestDto(email = email) ) @@ -86,7 +86,7 @@ class RemoteAuthDataSource( ): ApiResult { return try { val response: HttpResponse = client - .post("api/auth/email/validation") { + .post("auth/email/validation") { setBody( EmailValidationRequestDto(email = email, authCode = authCode) ) @@ -115,7 +115,7 @@ class RemoteAuthDataSource( ): ApiResult { return try { val response: HttpResponse = client - .post("api/users/signup") { + .post("users/signup") { setBody( SignupRequestDto(email = email, password = password, nickName = nickName) ) @@ -140,7 +140,7 @@ class RemoteAuthDataSource( suspend fun sendPasswordResetEmail(email: String): ApiResult { return try { val response: HttpResponse = client - .post("api/auth/email/find-password") { + .post("auth/email/find-password") { setBody( FindPasswordRequestDto(email = email) ) From 8571dde9c25f1b4bede6f9acbaf1ba804e2ae392 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Sun, 5 Oct 2025 16:37:05 +0900 Subject: [PATCH 30/32] =?UTF-8?q?[feat]:=20DataStore=EC=97=90=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=EC=9D=B4=20=EC=9E=88=EC=9D=84=20=EA=B2=BD=EC=9A=B0=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=EC=9D=B4=20?= =?UTF-8?q?=EB=90=98=EB=8F=84=EB=A1=9D=20=EA=B5=AC=ED=98=84=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../client/core/navigation/WhosInNavGraph.kt | 5 +++ .../kotlin/org/whosin/client/di/DIModules.kt | 2 ++ .../presentation/auth/login/SplashScreen.kt | 23 +++++++++++-- .../auth/login/viewmodel/SplashViewModel.kt | 32 +++++++++++++++++++ 4 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/viewmodel/SplashViewModel.kt 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 6ca974b..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 } + } } ) } 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 4239a30..992cc06 100644 --- a/composeApp/src/commonMain/kotlin/org/whosin/client/di/DIModules.kt +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/di/DIModules.kt @@ -18,6 +18,7 @@ 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 @@ -51,6 +52,7 @@ val repositoryModule = module { // ViewModel을 새로 생성하는 경우에 모듈에 추가하여 사용 val viewModelModule = module { + viewModelOf(::SplashViewModel) viewModelOf(::LoginViewModel) viewModelOf(::SignupViewModel) viewModelOf(::FindPasswordViewModel) 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/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 + ) + } + } +} From f2410864d4fda169b6200f7f029408a57a576b62 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Sun, 5 Oct 2025 16:56:09 +0900 Subject: [PATCH 31/32] =?UTF-8?q?[refactor]:=20=EA=B8=B0=EC=A1=B4=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=EC=9D=98=20=EC=A4=91=EB=B3=B5=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/remote/RemoteMemberDataSource.kt | 28 ------------------- .../data/repository/MemberRepository.kt | 5 ---- 2 files changed, 33 deletions(-) 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/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 From 8623ceda9a180ab8b66026691b959147742c985e Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Sun, 5 Oct 2025 16:56:43 +0900 Subject: [PATCH 32/32] =?UTF-8?q?[refactor]:=20=EB=A6=AC=ED=94=84=EB=9E=98?= =?UTF-8?q?=EC=8B=9C=20=ED=86=A0=ED=81=B0=20=EC=9E=AC=EB=B0=9C=EA=B8=89=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EB=B0=8F=20=ED=86=A0=ED=81=B0=20=EB=A7=8C?= =?UTF-8?q?=EB=A3=8C=EC=8B=9C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=EC=9C=BC=EB=A1=9C=20=EB=84=A4=EB=B9=84=EA=B2=8C?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/org/whosin/client/App.kt | 17 +++++- .../client/core/auth/TokenExpiredManager.kt | 18 ++++++ .../client/core/network/HttpClientFactory.kt | 56 ++++++++++++------- .../dto/response/ReissueTokenResponseDto.kt | 18 ++++++ 4 files changed, 88 insertions(+), 21 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/org/whosin/client/core/auth/TokenExpiredManager.kt create mode 100644 composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/response/ReissueTokenResponseDto.kt 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/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/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