diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1707a5b..3077754 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,7 +1,14 @@ +import java.util.Properties + plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) +} + +val properties = Properties().apply { + load(project.rootProject.file("local.properties").inputStream()) } android { @@ -16,6 +23,8 @@ android { versionName = "1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + buildConfigField("String", "BASE_URL", properties["base.url"].toString()) } buildTypes { @@ -36,6 +45,7 @@ android { } buildFeatures { compose = true + buildConfig = true } } @@ -59,5 +69,9 @@ dependencies { androidTestImplementation(libs.androidx.ui.test.junit4) debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.test.manifest) - + implementation(libs.kotlinx.serialization.json) + implementation(libs.retrofit.core) + implementation(libs.retrofit.kotlin.serialization) + implementation(libs.okhttp.logging) + implementation(libs.accompanist.pager) } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 54d36eb..afc54de 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,13 +1,17 @@ + + + android:theme="@style/Theme.ATSOPTANDROID" + android:usesCleartextTraffic="true" + > create(): T = retrofit.create(T::class.java) +} + +object ServicePool { + val userService: AuthService by lazy { + ApiFactory.create() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/at/data/network/AuthService.kt b/app/src/main/java/org/sopt/at/data/network/AuthService.kt new file mode 100644 index 0000000..c09b266 --- /dev/null +++ b/app/src/main/java/org/sopt/at/data/network/AuthService.kt @@ -0,0 +1,31 @@ +package org.sopt.at.data.network + +import org.sopt.at.data.network.ApiConstraints.API +import org.sopt.at.data.network.ApiConstraints.AUTH +import org.sopt.at.data.network.ApiConstraints.USERS +import org.sopt.at.data.network.ApiConstraints.VERSION +import org.sopt.at.data.network.dto.RequestSignInDto +import org.sopt.at.data.network.dto.RequestSignUpDto +import org.sopt.at.data.network.dto.ResponseMyNicknameDto +import org.sopt.at.data.network.dto.ResponseSignInDto +import org.sopt.at.data.network.dto.ResponseSignUpDto +import retrofit2.Response +import retrofit2.Call +import retrofit2.http.* + +interface AuthService { + @POST("$API/$VERSION/$AUTH/signup") + suspend fun signUp( + @Body request: RequestSignUpDto, + ): Response + + @POST("$API/$VERSION/$AUTH/signin") + suspend fun signIn ( + @Body request: RequestSignInDto, + ): Response + + @GET("$API/$VERSION/$USERS/me") + suspend fun getMyNickname( + @Header("userId") userId: Long + ): Response +} diff --git a/app/src/main/java/org/sopt/at/data/network/dto/RequestSignInDto.kt b/app/src/main/java/org/sopt/at/data/network/dto/RequestSignInDto.kt new file mode 100644 index 0000000..84aba7d --- /dev/null +++ b/app/src/main/java/org/sopt/at/data/network/dto/RequestSignInDto.kt @@ -0,0 +1,12 @@ +package org.sopt.at.data.network.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class RequestSignInDto( + @SerialName("loginId") + val loginId: String, + @SerialName("password") + val password: String, +) \ No newline at end of file diff --git a/app/src/main/java/org/sopt/at/data/network/dto/RequestSignUpDto.kt b/app/src/main/java/org/sopt/at/data/network/dto/RequestSignUpDto.kt new file mode 100644 index 0000000..0d2d90a --- /dev/null +++ b/app/src/main/java/org/sopt/at/data/network/dto/RequestSignUpDto.kt @@ -0,0 +1,14 @@ +package org.sopt.at.data.network.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class RequestSignUpDto( + @SerialName("loginId") + val loginId: String, + @SerialName("password") + val password: String, + @SerialName("nickname") + val nickname: String +) diff --git a/app/src/main/java/org/sopt/at/data/network/dto/ResponseMyNicknameDto.kt b/app/src/main/java/org/sopt/at/data/network/dto/ResponseMyNicknameDto.kt new file mode 100644 index 0000000..676662f --- /dev/null +++ b/app/src/main/java/org/sopt/at/data/network/dto/ResponseMyNicknameDto.kt @@ -0,0 +1,22 @@ +package org.sopt.at.data.network.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ResponseMyNicknameDto( + @SerialName("success") + val success: Boolean, + @SerialName("code") + val code: String, + @SerialName("message") + val message: String, + @SerialName("data") + val data: MyInfoData? = null +) + +@Serializable +data class MyInfoData( + @SerialName("nickname") + val nickname: String +) diff --git a/app/src/main/java/org/sopt/at/data/network/dto/ResponseSignInDto.kt b/app/src/main/java/org/sopt/at/data/network/dto/ResponseSignInDto.kt new file mode 100644 index 0000000..637cff7 --- /dev/null +++ b/app/src/main/java/org/sopt/at/data/network/dto/ResponseSignInDto.kt @@ -0,0 +1,21 @@ +package org.sopt.at.data.network.dto +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ResponseSignInDto( + @SerialName("success") + val success: Boolean, + @SerialName("code") + val code: String, + @SerialName("message") + val message: String, + @SerialName("data") + val data: SignInDataDto? = null +) + +@Serializable +data class SignInDataDto( + @SerialName("userId") + val userId: Long +) \ No newline at end of file diff --git a/app/src/main/java/org/sopt/at/data/network/dto/ResponseSignUpDto.kt b/app/src/main/java/org/sopt/at/data/network/dto/ResponseSignUpDto.kt new file mode 100644 index 0000000..ece4d84 --- /dev/null +++ b/app/src/main/java/org/sopt/at/data/network/dto/ResponseSignUpDto.kt @@ -0,0 +1,24 @@ +package org.sopt.at.data.network.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ResponseSignUpDto( + @SerialName("success") + val success: Boolean, + @SerialName("code") + val code: String, + @SerialName("message") + val message: String, + @SerialName("data") + val data: SignUpData? = null +) + +@Serializable +data class SignUpData( + @SerialName("userId") + val userId: Long, + @SerialName("nickname") + val nickname: String +) diff --git a/app/src/main/java/org/sopt/at/domain/model/NetworkModels.kt b/app/src/main/java/org/sopt/at/domain/model/NetworkModels.kt new file mode 100644 index 0000000..4d141e7 --- /dev/null +++ b/app/src/main/java/org/sopt/at/domain/model/NetworkModels.kt @@ -0,0 +1,7 @@ +package org.sopt.at.domain.model + +data class SignupRequest(val id: String, val password: String, val nickname: String) +data class SignupResponse(val isSuccess: Boolean, val message: String) +data class LoginRequest(val id: String, val password: String) +data class LoginResponse(val userId: Int) +data class NicknameResponse(val nickname: String) diff --git a/app/src/main/java/org/sopt/at/model/User.kt b/app/src/main/java/org/sopt/at/domain/model/User.kt similarity index 67% rename from app/src/main/java/org/sopt/at/model/User.kt rename to app/src/main/java/org/sopt/at/domain/model/User.kt index 04b3865..6afd049 100644 --- a/app/src/main/java/org/sopt/at/model/User.kt +++ b/app/src/main/java/org/sopt/at/domain/model/User.kt @@ -1,4 +1,4 @@ -package org.sopt.at.model +package org.sopt.at.domain.model data class User( val userId: String, diff --git a/app/src/main/java/org/sopt/at/repository/UserRepository.kt b/app/src/main/java/org/sopt/at/domain/repository/UserRepository.kt similarity index 56% rename from app/src/main/java/org/sopt/at/repository/UserRepository.kt rename to app/src/main/java/org/sopt/at/domain/repository/UserRepository.kt index c76f269..782f5c5 100644 --- a/app/src/main/java/org/sopt/at/repository/UserRepository.kt +++ b/app/src/main/java/org/sopt/at/domain/repository/UserRepository.kt @@ -1,11 +1,31 @@ -package org.sopt.at.repository +package org.sopt.at.domain.repository +import android.content.Context +import androidx.compose.ui.platform.LocalContext import kotlinx.coroutines.flow.MutableStateFlow -import org.sopt.at.model.User +import org.sopt.at.data.local.UserLocalDataSource +import org.sopt.at.domain.model.User -class UserRepository { +class UserRepository(context: Context) { private val _registeredUsers = MutableStateFlow>(emptyList()) + private val userLocalDataSource = UserLocalDataSource(context =context) + + fun isLoggedIn():Boolean{ + return (userLocalDataSource.userId != DEFAULT_USER_ID) + } + + fun getLocalUserId():Long{ + return userLocalDataSource.userId + } + + fun setLocalUserId(userId:Long){ + userLocalDataSource.userId = userId + } + + fun clearLocalUserId(){ + userLocalDataSource.userId = DEFAULT_USER_ID + } fun registerUser(user: User) { val currentUsers = _registeredUsers.value.toMutableList() @@ -33,4 +53,8 @@ class UserRepository { return result } + + companion object { + private const val DEFAULT_USER_ID = -1L + } } diff --git a/app/src/main/java/org/sopt/at/ui/TvingApp.kt b/app/src/main/java/org/sopt/at/ui/TvingApp.kt index 3dc0df0..d7a4636 100644 --- a/app/src/main/java/org/sopt/at/ui/TvingApp.kt +++ b/app/src/main/java/org/sopt/at/ui/TvingApp.kt @@ -8,21 +8,22 @@ import androidx.compose.material3.NavigationBarItemDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController +import kotlinx.coroutines.flow.MutableStateFlow import org.sopt.at.R -import org.sopt.at.repository.UserRepository +import org.sopt.at.domain.model.User +import org.sopt.at.domain.repository.UserRepository +import org.sopt.at.ui.signin.SignInViewModel import org.sopt.at.ui.my.MyScreen import org.sopt.at.ui.my.MyViewModel import org.sopt.at.ui.screen.HistoryScreen @@ -31,59 +32,52 @@ import org.sopt.at.ui.screen.LiveScreen import org.sopt.at.ui.screen.SearchScreen import org.sopt.at.ui.screen.ShortsScreen import org.sopt.at.ui.signin.SignInScreen -import org.sopt.at.ui.signin.SignInViewModel import org.sopt.at.ui.signup.IdEntryScreen +import org.sopt.at.ui.signup.NicknameEntryScreen import org.sopt.at.ui.signup.PasswordEntryScreen import org.sopt.at.ui.signup.SignUpViewModel @Composable fun TvingApp() { + val context = LocalContext.current val navController = rememberNavController() - val userRepository = remember { UserRepository() } + val userRepository = remember { UserRepository(context = context) } val signInViewModel = remember { SignInViewModel(userRepository) } val signUpViewModel = remember { SignUpViewModel() } - val myViewModel = remember { MyViewModel() } + val myViewModel = remember { MyViewModel(userRepository) } - var isLoggedIn by remember { mutableStateOf(false) } - - LaunchedEffect(myViewModel.logoutEvent) { - if (myViewModel.logoutEvent.value) { - isLoggedIn = false - navController.navigate("signin") { - popUpTo(navController.graph.startDestinationId) { inclusive = true } - } - myViewModel.resetLogoutEvent() - } - } + val isLoggedIn = remember{MutableStateFlow(userRepository.isLoggedIn())} Scaffold( bottomBar = { - if (isLoggedIn) { + if (isLoggedIn.value) { TvingBottomNavigation(navController) } } ) { paddingValues -> NavHost( navController = navController, - startDestination = if (isLoggedIn) "home" else "signin", + startDestination = if (isLoggedIn.value) "home" else "signin", modifier = Modifier.padding(paddingValues) ) { composable("signin") { SignInScreen( viewModel = signInViewModel, navigateToSignUp = { navController.navigate("signup/id") }, - navigateToMyView = { userId -> - isLoggedIn = true - myViewModel.setUserInfo(userId) - navController.navigate("home") { - popUpTo("signin") { inclusive = true } - } - } + navigateToHome = { navController.navigate("home") } ) } composable("signup/id") { IdEntryScreen( + viewModel = signUpViewModel, + onBackClicked = { navController.popBackStack() }, + onNextClicked = { navController.navigate("signup/nickname") } + ) + } + + composable("signup/nickname") { + NicknameEntryScreen( viewModel = signUpViewModel, onBackClicked = { navController.popBackStack() }, onNextClicked = { navController.navigate("signup/password") } @@ -95,18 +89,18 @@ fun TvingApp() { viewModel = signUpViewModel, onBackClicked = { navController.popBackStack() }, onCompleteClicked = { - if (signUpViewModel.validatePassword()) { userRepository.registerUser( - org.sopt.at.model.User( - signUpViewModel.uiState.value.userId, + User( + signUpViewModel.uiState.value.loginId, signUpViewModel.uiState.value.password ) ) navController.navigate("signin") { popUpTo("signin") { inclusive = true } } - } - } + + }, + signInSuccess = {isLoggedIn.value =true} ) } @@ -138,7 +132,9 @@ fun TvingApp() { MyScreen( viewModel = myViewModel, onBackClick = { navController.popBackStack() }, - onLogoutClick = { myViewModel.logout() } + onLogoutClick = { navController.navigate("signin") + userRepository.isLoggedIn() + } ) } } diff --git a/app/src/main/java/org/sopt/at/ui/component/TvingTextField.kt b/app/src/main/java/org/sopt/at/ui/component/TvingTextField.kt index 556354c..74a23bb 100644 --- a/app/src/main/java/org/sopt/at/ui/component/TvingTextField.kt +++ b/app/src/main/java/org/sopt/at/ui/component/TvingTextField.kt @@ -27,7 +27,7 @@ fun TvingTextField( modifier: Modifier = Modifier, isPassword: Boolean = false, isPasswordVisible: Boolean = false, - onTogglePasswordVisibility: (() -> Unit)? = null, + onTogglePasswordVisibility:()->Unit = {}, keyboardOptions: KeyboardOptions = KeyboardOptions.Default, keyboardActions: KeyboardActions = KeyboardActions.Default ) { diff --git a/app/src/main/java/org/sopt/at/ui/my/MyScreen.kt b/app/src/main/java/org/sopt/at/ui/my/MyScreen.kt index ef6fec1..897fc21 100644 --- a/app/src/main/java/org/sopt/at/ui/my/MyScreen.kt +++ b/app/src/main/java/org/sopt/at/ui/my/MyScreen.kt @@ -53,13 +53,11 @@ fun MyScreen( val uiState by viewModel.uiState.collectAsState() val logoutEvent by viewModel.logoutEvent.collectAsState() - LaunchedEffect(logoutEvent) { - if (logoutEvent) { - onLogoutClick() - viewModel.resetLogoutEvent() - } + LaunchedEffect (Unit){ + viewModel.getMyNickName() } + val menuItems = listOf( "이용권 구독" to { }, "회원정보 수정" to { }, @@ -70,7 +68,7 @@ fun MyScreen( Scaffold( topBar = { TopAppBar( - title = { }, + title = { }, navigationIcon = { IconButton(onClick = onBackClick) { Icon( @@ -146,7 +144,7 @@ fun MyScreen( Spacer(modifier = Modifier.weight(1f)) Button( - onClick = { }, + onClick = { }, colors = ButtonDefaults.buttonColors( containerColor = Color.Transparent ), @@ -242,13 +240,16 @@ fun MyScreen( MenuItemWithArrow( text = "라이브 예약 알림", - onClick = { } + onClick = { } ) Spacer(modifier = Modifier.height(16.dp)) Button( - onClick = { viewModel.logout() }, + onClick = { + viewModel.logout() + onLogoutClick() + }, colors = ButtonDefaults.buttonColors( containerColor = Color.DarkGray ), diff --git a/app/src/main/java/org/sopt/at/ui/my/MyViewModel.kt b/app/src/main/java/org/sopt/at/ui/my/MyViewModel.kt index ea72d5b..9c12269 100644 --- a/app/src/main/java/org/sopt/at/ui/my/MyViewModel.kt +++ b/app/src/main/java/org/sopt/at/ui/my/MyViewModel.kt @@ -1,43 +1,49 @@ package org.sopt.at.ui.my import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.sopt.at.data.network.AuthService +import org.sopt.at.data.network.ServicePool +import org.sopt.at.domain.repository.UserRepository + +data class MyUiState( + val userId: String = "", + val hasSubscription: Boolean = false, + val subscriptionName: String = "", + val cashAmount: Int = 0, + val downloadCount: Int = 0 + +) + +class MyViewModel( + private val userRepository: UserRepository +) : ViewModel() { + + private val authService: AuthService = ServicePool.userService -class MyViewModel : ViewModel() { private val _uiState = MutableStateFlow(MyUiState()) val uiState: StateFlow = _uiState.asStateFlow() private val _logoutEvent = MutableStateFlow(false) val logoutEvent: StateFlow = _logoutEvent.asStateFlow() - fun setUserInfo(userId: String) { - _uiState.value = _uiState.value.copy( - userId = userId - ) - } - - fun updateSubscriptionInfo(hasSubscription: Boolean, cashAmount: Int) { - _uiState.value = _uiState.value.copy( - hasSubscription = hasSubscription, - cashAmount = cashAmount - ) + fun getMyNickName(){ + val localUserID = userRepository.getLocalUserId() + viewModelScope.launch { + val response = authService.getMyNickname(localUserID) + if (response.isSuccessful){ + _uiState.update { it.copy(userId = response.body()!!.data!!.nickname) } + } + } } fun logout() { - _logoutEvent.value = true - } - - fun resetLogoutEvent() { - _logoutEvent.value = false + userRepository.clearLocalUserId() } - data class MyUiState( - val userId: String = "", - val hasSubscription: Boolean = false, - val subscriptionName: String = "", - val cashAmount: Int = 0, - val downloadCount: Int = 0 - ) } diff --git a/app/src/main/java/org/sopt/at/ui/signin/SignInScreen.kt b/app/src/main/java/org/sopt/at/ui/signin/SignInScreen.kt index 2e60e0d..ef5e093 100644 --- a/app/src/main/java/org/sopt/at/ui/signin/SignInScreen.kt +++ b/app/src/main/java/org/sopt/at/ui/signin/SignInScreen.kt @@ -2,16 +2,7 @@ package org.sopt.at.ui.signin import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -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.width +import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.HorizontalDivider @@ -33,6 +24,7 @@ import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kotlinx.coroutines.launch @@ -43,31 +35,31 @@ import org.sopt.at.ui.components.TvingTextField fun SignInScreen( viewModel: SignInViewModel, navigateToSignUp: () -> Unit, - navigateToMyView: (String) -> Unit + navigateToHome: () -> Unit ) { val uiState by viewModel.uiState.collectAsState() val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() val focusManager = LocalFocusManager.current - LaunchedEffect(uiState.isSignInSuccessful) { - if (uiState.isSignInSuccessful) { - viewModel.resetSignInState() - navigateToMyView(uiState.userId) + LaunchedEffect(uiState.loginState) { + if (uiState.loginState){ + navigateToHome() + viewModel.changeLoginState() } } - LaunchedEffect(uiState.signInError) { - uiState.signInError?.let { error -> + LaunchedEffect(uiState.showSnackBar) { + if (uiState.showSnackBar) { scope.launch { - snackbarHostState.showSnackbar(message = error) + snackbarHostState.showSnackbar(message = uiState.errorMessage) } + viewModel.changeSnackBarVisible( visible = false) } } Scaffold( snackbarHost = { SnackbarHost(snackbarHostState) } - ) { paddingValues -> Box( modifier = Modifier @@ -107,8 +99,8 @@ fun SignInScreen( onValueChange = viewModel::updatePassword, label = "비밀번호", isPassword = true, - isPasswordVisible = uiState.isPasswordVisible, - onTogglePasswordVisibility = viewModel::togglePasswordVisibility, + isPasswordVisible = uiState.passwordVisible, + onTogglePasswordVisibility = { viewModel.changePasswordVisible(visible = !uiState.passwordVisible) }, keyboardActions = KeyboardActions( onDone = { focusManager.clearFocus() @@ -119,6 +111,7 @@ fun SignInScreen( Spacer(modifier = Modifier.height(24.dp)) + // 로그인 버튼 TvingButton( text = "로그인하기", onClick = viewModel::signIn, @@ -136,7 +129,7 @@ fun SignInScreen( text = "아이디 찾기", color = Color.Gray, fontSize = 14.sp, - modifier = Modifier.clickable { } + modifier = Modifier.clickable { } ) VerticalDivider( @@ -151,7 +144,7 @@ fun SignInScreen( text = "비밀번호 찾기", color = Color.Gray, fontSize = 14.sp, - modifier = Modifier.clickable { } + modifier = Modifier.clickable { } ) VerticalDivider( @@ -185,4 +178,4 @@ fun SignInScreen( } } } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/at/ui/signin/SignInViewModel.kt b/app/src/main/java/org/sopt/at/ui/signin/SignInViewModel.kt index 436fad6..b49003a 100644 --- a/app/src/main/java/org/sopt/at/ui/signin/SignInViewModel.kt +++ b/app/src/main/java/org/sopt/at/ui/signin/SignInViewModel.kt @@ -4,59 +4,68 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import org.sopt.at.repository.UserRepository +import org.sopt.at.data.network.AuthService +import org.sopt.at.data.network.ServicePool +import org.sopt.at.data.network.dto.RequestSignInDto +import org.sopt.at.domain.repository.UserRepository -class SignInViewModel( - private val userRepository: UserRepository -) : ViewModel() { +data class SignInUiState( + val userId: String = "", + val password: String = "", + val passwordVisible: Boolean = false, + val showSnackBar:Boolean = false, + val errorMessage: String = "", + val loginState: Boolean = false, +) +class SignInViewModel(private val userRepository: UserRepository) : ViewModel() { + + private val authService: AuthService = ServicePool.userService private val _uiState = MutableStateFlow(SignInUiState()) - val uiState: StateFlow = _uiState.asStateFlow() + val uiState: StateFlow get() = _uiState - fun updateUserId(userId: String) { - _uiState.value = _uiState.value.copy(userId = userId) + fun updateUserId(newId: String) { + _uiState.update { it.copy(userId = newId) } } - fun updatePassword(password: String) { - _uiState.value = _uiState.value.copy(password = password) + fun updatePassword(newPassword: String) { + _uiState.update { it.copy(password = newPassword) } } - fun togglePasswordVisibility() { - _uiState.value = _uiState.value.copy( - isPasswordVisible = !_uiState.value.isPasswordVisible - ) + fun changeSnackBarVisible(visible:Boolean){ + _uiState.update { it.copy(showSnackBar = visible) } } - fun signIn() { - viewModelScope.launch { - val userId = _uiState.value.userId - val password = _uiState.value.password - - println("로그인 시도 - ID: $userId, Password: $password") + fun changePasswordVisible(visible:Boolean){ + _uiState.update { it.copy(passwordVisible = visible) } + } - val isAuthenticated = userRepository.authenticateUser(userId, password) + fun changeLoginState(){ + _uiState.update { it.copy(loginState = !uiState.value.loginState) } + } - _uiState.value = _uiState.value.copy( - isSignInSuccessful = isAuthenticated, - signInError = if (!isAuthenticated) "아이디 또는 비밀번호가 일치하지 않습니다." else null - ) + fun signIn() { + val current = _uiState.value + if (current.userId.isEmpty() || current.password.isEmpty()) { + _uiState.update { it.copy(errorMessage = "아이디와 비밀번호를 입력해주세요.") } + return } - } - fun resetSignInState() { - _uiState.value = _uiState.value.copy( - isSignInSuccessful = false, - signInError = null - ) - } + val request = RequestSignInDto(current.userId, current.password) - data class SignInUiState( - val userId: String = "", - val password: String = "", - val isPasswordVisible: Boolean = false, - val isSignInSuccessful: Boolean = false, - val signInError: String? = null - ) -} + viewModelScope.launch { + val response = authService.signIn(request) + if(response.isSuccessful){ + _uiState.update { it.copy(loginState = true) } + userRepository.setLocalUserId(response.body()!!.data!!.userId) + }else { + _uiState.update { + it.copy(errorMessage = "로그인 실패: ${response.message()}",) + } + changeSnackBarVisible(visible = true) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/at/ui/signup/IdEntryScreen.kt b/app/src/main/java/org/sopt/at/ui/signup/IdEntryScreen.kt index c3c2f53..92532a3 100644 --- a/app/src/main/java/org/sopt/at/ui/signup/IdEntryScreen.kt +++ b/app/src/main/java/org/sopt/at/ui/signup/IdEntryScreen.kt @@ -2,31 +2,17 @@ package org.sopt.at.ui.signup import android.widget.Toast 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.* import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.* 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 import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction @@ -40,23 +26,20 @@ import org.sopt.at.ui.components.TvingTextField @Composable fun IdEntryScreen( viewModel: SignUpViewModel, - onBackClicked: () -> Unit, - onNextClicked: () -> Unit + onNextClicked: () -> Unit, + onBackClicked: () -> Unit ) { - val uiState by viewModel._uiState.collectAsState() - val focusManager = LocalFocusManager.current + val uiState by viewModel.uiState.collectAsState() val context = LocalContext.current - LaunchedEffect(uiState.idError) { - uiState.idError?.let { error -> - Toast.makeText(context, error, Toast.LENGTH_SHORT).show() - } + uiState.errorMessage?.let { error -> + Toast.makeText(context, error, Toast.LENGTH_SHORT).show() } Scaffold( topBar = { TopAppBar( - title = { }, + title = {}, navigationIcon = { IconButton(onClick = onBackClicked) { Icon( @@ -97,14 +80,14 @@ fun IdEntryScreen( Spacer(modifier = Modifier.height(32.dp)) TvingTextField( - value = uiState.userId, - onValueChange = viewModel::updateUserId, + value = uiState.loginId, + onValueChange = viewModel::updateLoginId, label = "아이디", keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), keyboardActions = KeyboardActions( onDone = { - focusManager.clearFocus() - if (viewModel.validateUserId()) { + if (uiState.loginId.isNotEmpty()) { + viewModel.setId(uiState.loginId) onNextClicked() } } @@ -114,7 +97,7 @@ fun IdEntryScreen( Spacer(modifier = Modifier.height(8.dp)) Text( - text = "영문 소문자 또는 영문 소문자, 숫자 조합 6~12자리", + text = "영문, 숫자 조합 8~20자", color = Color.Gray, fontSize = 12.sp, modifier = Modifier.fillMaxWidth() @@ -125,11 +108,12 @@ fun IdEntryScreen( TvingButton( text = "다음", onClick = { - if (viewModel.validateUserId()) { + if (uiState.loginId.isNotEmpty()) { + viewModel.setId(uiState.loginId) onNextClicked() } }, - enabled = uiState.userId.isNotEmpty() + enabled = uiState.loginId.isNotEmpty() ) Spacer(modifier = Modifier.height(48.dp)) diff --git a/app/src/main/java/org/sopt/at/ui/signup/NicknameEntryScreen.kt b/app/src/main/java/org/sopt/at/ui/signup/NicknameEntryScreen.kt new file mode 100644 index 0000000..302ca16 --- /dev/null +++ b/app/src/main/java/org/sopt/at/ui/signup/NicknameEntryScreen.kt @@ -0,0 +1,123 @@ +package org.sopt.at.ui.signup + +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +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 +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.sopt.at.R +import org.sopt.at.ui.components.TvingButton +import org.sopt.at.ui.components.TvingTextField + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NicknameEntryScreen( + viewModel: SignUpViewModel, + onNextClicked: () -> Unit, + onBackClicked: () -> Unit +) { + val uiState by viewModel.uiState.collectAsState() + val context = LocalContext.current + + uiState.errorMessage?.let { error -> + Toast.makeText(context, error, Toast.LENGTH_SHORT).show() + } + + Scaffold( + topBar = { + TopAppBar( + title = {}, + navigationIcon = { + IconButton(onClick = onBackClicked) { + Icon( + painter = painterResource(id = R.drawable.ic_back_arrow), + contentDescription = "back", + tint = Color.White + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Black + ) + ) + } + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + .padding(paddingValues) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(32.dp)) + + Text( + text = "닉네임을 입력해주세요.", + color = Color.White, + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(32.dp)) + + TvingTextField( + value = uiState.nickname, + onValueChange = viewModel::updateNickname, + label = "닉네임", + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions( + onDone = { + if (uiState.nickname.isNotEmpty()) { + viewModel.setNickname(uiState.nickname) + onNextClicked() + } + } + ) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "한글/영어/숫자만 사용 가능. 1자 ~ 20자 이내.", + color = Color.Gray, + fontSize = 12.sp, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.weight(1f)) + + TvingButton( + text = "다음", + onClick = { + if (uiState.nickname.isNotEmpty()) { + viewModel.setNickname(uiState.nickname) + onNextClicked() + } + }, + enabled = uiState.nickname.isNotEmpty() + ) + + Spacer(modifier = Modifier.height(48.dp)) + } + } + } +} diff --git a/app/src/main/java/org/sopt/at/ui/signup/PasswordEntryScreen.kt b/app/src/main/java/org/sopt/at/ui/signup/PasswordEntryScreen.kt index 9d97f04..e7bc1b4 100644 --- a/app/src/main/java/org/sopt/at/ui/signup/PasswordEntryScreen.kt +++ b/app/src/main/java/org/sopt/at/ui/signup/PasswordEntryScreen.kt @@ -1,25 +1,13 @@ package org.sopt.at.ui.signup +import android.util.Log import android.widget.Toast 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.* import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.* 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 @@ -41,22 +29,22 @@ import org.sopt.at.ui.components.TvingTextField fun PasswordEntryScreen( viewModel: SignUpViewModel, onBackClicked: () -> Unit, - onCompleteClicked: () -> Unit + onCompleteClicked: () -> Unit, + signInSuccess:()->Unit, ) { - val uiState by viewModel._uiState.collectAsState() + val uiState by viewModel.uiState.collectAsState() val focusManager = LocalFocusManager.current val context = LocalContext.current - LaunchedEffect(uiState.passwordError) { - uiState.passwordError?.let { error -> - Toast.makeText(context, error, Toast.LENGTH_SHORT).show() - } + uiState.errorMessage?.let { error -> + Toast.makeText(context, error, Toast.LENGTH_SHORT).show() + Log.e("zz", error) } Scaffold( topBar = { TopAppBar( - title = { }, + title = {}, navigationIcon = { IconButton(onClick = onBackClicked) { Icon( @@ -107,9 +95,14 @@ fun PasswordEntryScreen( keyboardActions = KeyboardActions( onDone = { focusManager.clearFocus() - if (viewModel.validatePassword()) { - onCompleteClicked() - } + viewModel.singUpClicked( + password = uiState.password, + onSuccess = { + onCompleteClicked() + signInSuccess() + }, + onFailure = {} + ) } ) ) @@ -117,7 +110,7 @@ fun PasswordEntryScreen( Spacer(modifier = Modifier.height(8.dp)) Text( - text = "영문, 숫자, 특수문자(~!@#$%^&*) 조합 8~15자리", + text = "영문, 숫자, 특수문자(~!@#\$%^&*) 조합 8~15자리", color = Color.Gray, fontSize = 12.sp, modifier = Modifier.fillMaxWidth() @@ -128,9 +121,14 @@ fun PasswordEntryScreen( TvingButton( text = "완료", onClick = { - if (viewModel.validatePassword()) { - onCompleteClicked() - } + viewModel.singUpClicked( + password = uiState.password, + onSuccess = { + onCompleteClicked() + signInSuccess() + }, + onFailure = {} + ) }, enabled = uiState.password.isNotEmpty() ) diff --git a/app/src/main/java/org/sopt/at/ui/signup/SignUpViewModel.kt b/app/src/main/java/org/sopt/at/ui/signup/SignUpViewModel.kt index be86b59..774e4b8 100644 --- a/app/src/main/java/org/sopt/at/ui/signup/SignUpViewModel.kt +++ b/app/src/main/java/org/sopt/at/ui/signup/SignUpViewModel.kt @@ -1,70 +1,97 @@ package org.sopt.at.ui.signup +import android.util.Log import androidx.lifecycle.ViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import org.sopt.at.data.network.AuthService +import org.sopt.at.data.network.ServicePool +import org.sopt.at.data.network.dto.RequestSignUpDto +import org.sopt.at.data.network.dto.ResponseSignUpDto + +data class SignUpUiState( + val loginId: String = "", + val password: String = "", + val nickname: String = "", + val isPasswordVisible: Boolean = false, + val isLoading: Boolean = false, + val errorMessage: String? = null, +) class SignUpViewModel : ViewModel() { - val _uiState = MutableStateFlow(SignUpUiState()) + private val _uiState = MutableStateFlow(SignUpUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private val authService: AuthService = ServicePool.userService + - fun updateUserId(userId: String) { - _uiState.value = _uiState.value.copy(userId = userId) + fun updateLoginId(id: String) { + _uiState.update { it.copy(loginId = id) } } - fun updatePassword(password: String) { - _uiState.value = _uiState.value.copy(password = password) + fun updatePassword(pw: String) { + _uiState.update { it.copy(password = pw) } } - fun togglePasswordVisibility() { - _uiState.value = _uiState.value.copy(isPasswordVisible = !_uiState.value.isPasswordVisible) + fun updateNickname(nick: String) { + _uiState.update { it.copy(nickname = nick) } } - fun validateUserId(): Boolean { - val userId = _uiState.value.userId - val idPattern = "^[a-zA-Z0-9]{6,12}$".toRegex() - - val isValid = idPattern.matches(userId) - if (!isValid) { - _uiState.value = _uiState.value.copy( - idError = "아이디는 영문 대/소문자 또는 영문 소문자, 숫자 조합 6~12자리여야 합니다." - ) - } else { - _uiState.value = _uiState.value.copy(idError = null) - } + fun togglePasswordVisibility() { + _uiState.update { it.copy(isPasswordVisible = !it.isPasswordVisible) } + } - return isValid + fun setId(id: String) { + _uiState.update { it.copy(loginId = id) } } - fun validatePassword(): Boolean { - val password = _uiState.value.password - val passwordPattern = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[~!@#\$%^&*])[A-Za-z\\d~!@#\$%^&*]{8,15}$".toRegex() - - val isValid = passwordPattern.matches(password) - if (!isValid) { - _uiState.value = _uiState.value.copy( - passwordError = "비밀번호는 영문, 숫자, 특수문자(~!@#$%^&*) 조합 8~15자리여야 합니다." - ) - } else { - _uiState.value = _uiState.value.copy(passwordError = null) - } + fun setNickname(nick: String) { + _uiState.update { it.copy(nickname = nick) } + } - return isValid + fun singUpClicked(password: String, onSuccess: () -> Unit, onFailure: (String) -> Unit) { + _uiState.update { it.copy(password = password) } + signUp(onSuccess = onSuccess, onFailure = onFailure) } - fun resetRegistrationState() { - _uiState.value = _uiState.value.copy( - isRegistrationSuccessful = false + private fun signUp(onSuccess: () -> Unit, onFailure: (String) -> Unit) { + val signUpRequest = RequestSignUpDto( + loginId = _uiState.value.loginId, + nickname = _uiState.value.nickname, + password = _uiState.value.password ) + + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + + try { + val response = authService.signUp(signUpRequest) + + if (response.isSuccessful) { + Log.e("zz", "success" + response.message().toString()) + _uiState.update { it.copy(errorMessage = response.body()?.message ?: "") } + onSuccess() + } else{ + val error = response.errorBody()?.string().toString() + + val errorMessage = try { + val parsedError = Json.decodeFromString(error ?: "") + parsedError.message + } catch (e: Exception) { + "오류 메시지를 불러올 수 없습니다." + } + + _uiState.update { it.copy(isLoading = false, errorMessage = errorMessage) } + onFailure(errorMessage) + + } + } catch (e: Exception) { + val error = e.message ?: "네트워크 오류" + _uiState.update { it.copy(isLoading = false, errorMessage = error) } + onFailure(error) + } + } } - data class SignUpUiState( - val userId: String = "", - val password: String = "", - val isPasswordVisible: Boolean = false, - val idError: String? = null, - val passwordError: String? = null, - val isRegistrationSuccessful: Boolean = false - ) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index aef5847..b41fbc5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,6 +11,11 @@ composeBom = "2024.09.00" animationCoreLint = "1.8.0-rc02" material = "1.12.0" navigation_compose = "2.7.7" +retrofit = "2.11.0" +retrofit-kotlinx-serialization-json = "1.0.0" +okhttp = "4.12.0" +kotlinx-serialization-json = "1.7.1" +accompanist-pager = "0.25.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -30,9 +35,15 @@ androidx-material3 = { group = "androidx.compose.material3", name = "material3" androidx-animation-core-lint = { group = "androidx.compose.animation", name = "animation-core-lint", version.ref = "animationCoreLint" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation_compose" } +retrofit-core = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } +retrofit-kotlin-serialization = { group = "com.jakewharton.retrofit", name = "retrofit2-kotlinx-serialization-converter", version.ref = "retrofit-kotlinx-serialization-json" } +okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" } +accompanist-pager = { group = "com.google.accompanist", name = "accompanist-pager", version.ref = "accompanist-pager"} +accompanist-pager-indicators = { group = "com.google.accompanist", name = "accompanist-pager-indicators", version.ref = "accompanist-pager"} [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } - +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } \ No newline at end of file