From 4d2e1ffcdcb94d2795c492e6c4c8aace8044bd23 Mon Sep 17 00:00:00 2001 From: dgddgd314 Date: Sat, 4 Jul 2026 03:18:03 +0900 Subject: [PATCH 1/5] feat: memo logic changed --- .../hangsha_android/data/network/api/MemoApi.kt | 5 +++++ .../data/repository/MemoRepository.kt | 6 ++++++ .../hangsha_android/ui/navigation/HangshaNavHost.kt | 9 +++++++++ .../ui/view/eventdetail/EventDetailViewModel.kt | 13 ++++++++----- .../hangsha_android/ui/view/mypage/MyPageScreen.kt | 11 ++++++++--- 5 files changed, 36 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/example/hangsha_android/data/network/api/MemoApi.kt b/app/src/main/java/com/example/hangsha_android/data/network/api/MemoApi.kt index fa478b6..86ea3c3 100644 --- a/app/src/main/java/com/example/hangsha_android/data/network/api/MemoApi.kt +++ b/app/src/main/java/com/example/hangsha_android/data/network/api/MemoApi.kt @@ -16,6 +16,11 @@ interface MemoApi { @GET("api/v1/memos") suspend fun getMemos(): Response + @GET("api/v1/memos/by-event/{eventId}") + suspend fun getMemoByEvent( + @Path("eventId") eventId: Long + ): Response + @POST("api/v1/memos") suspend fun createMemo( @Body request: CreateMemoRequest diff --git a/app/src/main/java/com/example/hangsha_android/data/repository/MemoRepository.kt b/app/src/main/java/com/example/hangsha_android/data/repository/MemoRepository.kt index 11ce02a..3aa4871 100644 --- a/app/src/main/java/com/example/hangsha_android/data/repository/MemoRepository.kt +++ b/app/src/main/java/com/example/hangsha_android/data/repository/MemoRepository.kt @@ -15,6 +15,12 @@ class MemoRepository @Inject constructor( return memoApi.getMemos() } + suspend fun getMemoByEvent( + eventId: Long + ): Response { + return memoApi.getMemoByEvent(eventId = eventId) + } + suspend fun createMemo( eventId: Long, content: String, diff --git a/app/src/main/java/com/example/hangsha_android/ui/navigation/HangshaNavHost.kt b/app/src/main/java/com/example/hangsha_android/ui/navigation/HangshaNavHost.kt index 285b10e..34f1918 100644 --- a/app/src/main/java/com/example/hangsha_android/ui/navigation/HangshaNavHost.kt +++ b/app/src/main/java/com/example/hangsha_android/ui/navigation/HangshaNavHost.kt @@ -586,6 +586,15 @@ fun NavGraphBuilder.mainGraph(navController: NavHostController) { onInterestPriorityClick = { navController.navigate(HangshaDestinations.InterestPriority.route) }, + onTimetableClick = { + navController.navigate(BottomTab.Timetable.route) { + popUpTo(HangshaDestinations.Main.route) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + }, onBookmarksClick = { navController.navigate(HangshaDestinations.MyBookmarks.route) }, diff --git a/app/src/main/java/com/example/hangsha_android/ui/view/eventdetail/EventDetailViewModel.kt b/app/src/main/java/com/example/hangsha_android/ui/view/eventdetail/EventDetailViewModel.kt index 7731a35..2acef56 100644 --- a/app/src/main/java/com/example/hangsha_android/ui/view/eventdetail/EventDetailViewModel.kt +++ b/app/src/main/java/com/example/hangsha_android/ui/view/eventdetail/EventDetailViewModel.kt @@ -285,11 +285,14 @@ class EventDetailViewModel @Inject constructor( memoLoadJob?.cancel() memoLoadJob = viewModelScope.launch { runCatching { - memoRepository.getMemos() - .requireBody("Memo list response was empty.") - .items - .firstOrNull { it.eventId == eventId } - ?.toEventDetailMemo() + val response = memoRepository.getMemoByEvent(eventId) + if (response.code() == 404) { + null + } else if (!response.isSuccessful) { + throw HttpException(response) + } else { + response.body()?.toEventDetailMemo() + } }.onSuccess { memo -> _uiState.update { it.copy( diff --git a/app/src/main/java/com/example/hangsha_android/ui/view/mypage/MyPageScreen.kt b/app/src/main/java/com/example/hangsha_android/ui/view/mypage/MyPageScreen.kt index d23af5d..2b53f6a 100644 --- a/app/src/main/java/com/example/hangsha_android/ui/view/mypage/MyPageScreen.kt +++ b/app/src/main/java/com/example/hangsha_android/ui/view/mypage/MyPageScreen.kt @@ -89,6 +89,7 @@ fun MyPageScreen( onDraftProfileImageDeleted: () -> Unit, onSaveProfileEdit: () -> Unit, onInterestPriorityClick: () -> Unit, + onTimetableClick: () -> Unit, onBookmarksClick: () -> Unit, onBookmarkedEventClick: (Long) -> Unit, onMemoListClick: () -> Unit, @@ -139,7 +140,7 @@ fun MyPageScreen( onClick = onInterestPriorityClick ) Spacer(modifier = Modifier.height(8.dp)) - TimetableRegistrationRow() + TimetableRegistrationRow(onClick = onTimetableClick) Spacer(modifier = Modifier.height(29.dp)) BookmarksPreviewSection( items = uiState.bookmarkedEvents, @@ -457,9 +458,13 @@ private fun PriorityChip( // 시간표 등록 영역 @Composable -private fun TimetableRegistrationRow() { +private fun TimetableRegistrationRow( + onClick: () -> Unit +) { Surface( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), shape = RoundedCornerShape(8.dp), color = PureWhite, border = androidx.compose.foundation.BorderStroke(1.dp, BorderColor) From dcc10fe0414fa5379a6882bef893e5df6cd4772a Mon Sep 17 00:00:00 2001 From: dgddgd314 Date: Sat, 4 Jul 2026 04:01:18 +0900 Subject: [PATCH 2/5] feat: login page separate and ui fix --- .../ui/navigation/HangshaNavHost.kt | 63 ++- .../ui/view/login/LoginScreen.kt | 372 ++++++++++++++---- .../ui/view/login/LoginViewModel.kt | 51 +-- app/src/main/res/drawable/hangsha_logo.png | Bin 0 -> 5671 bytes app/src/main/res/drawable/ic_google_logo.xml | 18 + 5 files changed, 373 insertions(+), 131 deletions(-) create mode 100644 app/src/main/res/drawable/hangsha_logo.png create mode 100644 app/src/main/res/drawable/ic_google_logo.xml diff --git a/app/src/main/java/com/example/hangsha_android/ui/navigation/HangshaNavHost.kt b/app/src/main/java/com/example/hangsha_android/ui/navigation/HangshaNavHost.kt index 34f1918..54d359a 100644 --- a/app/src/main/java/com/example/hangsha_android/ui/navigation/HangshaNavHost.kt +++ b/app/src/main/java/com/example/hangsha_android/ui/navigation/HangshaNavHost.kt @@ -17,7 +17,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -47,21 +46,20 @@ import com.example.hangsha_android.ui.view.eventdetail.EventDetailScreen import com.example.hangsha_android.ui.view.eventdetail.EventDetailViewModel import com.example.hangsha_android.ui.view.interestpriority.InterestPriorityScreen import com.example.hangsha_android.ui.view.interestpriority.InterestPriorityViewModel +import com.example.hangsha_android.ui.view.login.OpeningScreen import com.example.hangsha_android.ui.view.mypage.MyPageScreen import com.example.hangsha_android.ui.view.mypage.MyPageViewModel import com.example.hangsha_android.ui.view.mymemos.MyMemosScreen import com.example.hangsha_android.ui.view.mymemos.MyMemosViewModel -import com.example.hangsha_android.ui.view.serverhealth.ServerHealthViewModel import com.example.hangsha_android.ui.view.signup.SignUpScreen import com.example.hangsha_android.ui.view.signup.SignUpViewModel import com.google.android.gms.auth.api.signin.GoogleSignIn import com.google.android.gms.auth.api.signin.GoogleSignInOptions import com.google.android.gms.common.api.ApiException -import kotlinx.coroutines.launch -import kotlinx.coroutines.tasks.await sealed class HangshaDestinations(val route: String) { data object Login : HangshaDestinations("login") + data object CredentialLogin : HangshaDestinations("credential_login") data object SignUp : HangshaDestinations("sign_up") data object Main : HangshaDestinations("main") data object InterestPriority : HangshaDestinations("interest_priority") @@ -108,10 +106,7 @@ fun NavGraphBuilder.loginGraph(navController: NavHostController) { composable(HangshaDestinations.Login.route) { val loginViewModel: LoginViewModel = hiltViewModel() val loginUiState by loginViewModel.uiState.collectAsState() - val serverHealthViewModel: ServerHealthViewModel = hiltViewModel() - val serverHealthUiState by serverHealthViewModel.uiState.collectAsState() val context = LocalContext.current - val coroutineScope = rememberCoroutineScope() val googleSignInOptions = remember(BuildConfig.GOOGLE_SERVER_CLIENT_ID) { GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) .requestEmail() @@ -157,40 +152,44 @@ fun NavGraphBuilder.loginGraph(navController: NavHostController) { loginViewModel.onLoginSuccessConsumed() } - LoginScreen( - onLoginClick = { loginViewModel.loginWithCredentials() }, + OpeningScreen( + loginUiState = loginUiState, + onEmailLoginClick = { + navController.navigate(HangshaDestinations.CredentialLogin.route) + }, onSignUpClick = { navController.navigate(HangshaDestinations.SignUp.route) }, - onUsernameChanged = { value -> loginViewModel.onUsernameChanged(value) }, - onPasswordChanged = { value -> loginViewModel.onPasswordChanged(value) }, onGoogleLoginClick = { if (BuildConfig.GOOGLE_SERVER_CLIENT_ID.isBlank()) { loginViewModel.onGoogleLoginConfigMissing() } else { googleLoginLauncher.launch(googleSignInClient.signInIntent) } - }, - onClearGoogleLoginHistoryClick = { - loginViewModel.onGoogleHistoryClearStarted() - coroutineScope.launch { - runCatching { - googleSignInClient.signOut().await() - }.fold( - onSuccess = { - loginViewModel.onGoogleHistoryCleared() - }, - onFailure = { error -> - loginViewModel.onGoogleLoginError( - error.message ?: "Failed to clear Google login history." - ) - } - ) - } - }, - onCheckServerClick = { serverHealthViewModel.checkServer() }, - loginUiState = loginUiState, - serverHealthUiState = serverHealthUiState + } + ) + } + + composable(HangshaDestinations.CredentialLogin.route) { + val loginViewModel: LoginViewModel = hiltViewModel() + val loginUiState by loginViewModel.uiState.collectAsState() + + LaunchedEffect(loginUiState.isLoginSuccessful) { + if (!loginUiState.isLoginSuccessful) { + return@LaunchedEffect + } + + navController.navigate(HangshaDestinations.Main.route) { + popUpTo(HangshaDestinations.Login.route) { inclusive = true } + } + loginViewModel.onLoginSuccessConsumed() + } + + LoginScreen( + onLoginClick = { loginViewModel.loginWithCredentials() }, + onUsernameChanged = { value -> loginViewModel.onUsernameChanged(value) }, + onPasswordChanged = { value -> loginViewModel.onPasswordChanged(value) }, + loginUiState = loginUiState ) } } diff --git a/app/src/main/java/com/example/hangsha_android/ui/view/login/LoginScreen.kt b/app/src/main/java/com/example/hangsha_android/ui/view/login/LoginScreen.kt index 7d86b74..fb88490 100644 --- a/app/src/main/java/com/example/hangsha_android/ui/view/login/LoginScreen.kt +++ b/app/src/main/java/com/example/hangsha_android/ui/view/login/LoginScreen.kt @@ -1,125 +1,257 @@ package com.example.hangsha_android.ui.view.login +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.border 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.offset import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Button +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.example.hangsha_android.ui.components.CheckServerButton -import com.example.hangsha_android.ui.components.ClearGoogleLoginHistoryButton -import com.example.hangsha_android.ui.view.serverhealth.ServerHealthUiState +import androidx.compose.ui.unit.sp +import com.example.hangsha_android.R + +private val AuthContentWidth = 252.dp +private val AuthButtonHeight = 34.dp +private val AuthFieldHeight = 34.dp +private val AuthRoundShape = CircleShape +private val AuthBlack = Color(0xFF000000) +private val AuthWhite = Color(0xFFFFFFFF) +private val AuthBorder = Color(0xFFE0E0E0) +private val AuthPlaceholder = Color(0xFF8F8F8F) +private val AuthError = Color(0xFFFF4058) +private val AuthYellow = Color(0xFFFFD344) + +@Composable +fun OpeningScreen( + loginUiState: LoginUiState, + onEmailLoginClick: () -> Unit, + onGoogleLoginClick: () -> Unit, + onSignUpClick: () -> Unit +) { + Box( + modifier = Modifier + .fillMaxSize() + .background(AuthWhite), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier + .width(AuthContentWidth) + .offset(y = (-18).dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + HangshaBrand() + Spacer(modifier = Modifier.height(36.dp)) + OpeningButton( + text = "로그인", + onClick = onEmailLoginClick, + enabled = !loginUiState.isAnyLoginLoading + ) + Spacer(modifier = Modifier.height(12.dp)) + OpeningButton( + text = "구글 계정으로 계속하기", + onClick = onGoogleLoginClick, + enabled = !loginUiState.isAnyLoginLoading, + isLoading = loginUiState.isGoogleLoginLoading, + leadingIcon = { + Image( + painter = painterResource(id = R.drawable.ic_google_logo), + contentDescription = null, + modifier = Modifier.size(22.dp) + ) + } + ) + Spacer(modifier = Modifier.height(14.dp)) + OpeningButton( + text = "회원가입", + onClick = onSignUpClick, + enabled = !loginUiState.isAnyLoginLoading, + containerColor = AuthYellow, + contentColor = AuthBlack, + borderColor = AuthYellow, + shadowElevation = 2.dp + ) + loginUiState.loginMessage?.let { message -> + Spacer(modifier = Modifier.height(18.dp)) + Text( + text = message, + modifier = Modifier.fillMaxWidth(), + color = AuthError, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center + ) + } + } + } +} @Composable fun LoginScreen( onLoginClick: () -> Unit, - onSignUpClick: () -> Unit, onUsernameChanged: (String) -> Unit, onPasswordChanged: (String) -> Unit, - onGoogleLoginClick: () -> Unit, - onClearGoogleLoginHistoryClick: () -> Unit, - onCheckServerClick: () -> Unit, - loginUiState: LoginUiState, - serverHealthUiState: ServerHealthUiState + loginUiState: LoginUiState ) { Box( modifier = Modifier .fillMaxSize() - .background(MaterialTheme.colorScheme.background), + .background(AuthWhite), contentAlignment = Alignment.Center ) { Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding(horizontal = 24.dp) + modifier = Modifier + .width(AuthContentWidth) + .offset(y = (-8).dp), + horizontalAlignment = Alignment.CenterHorizontally ) { - Text(text = "Login") - Spacer(modifier = Modifier.height(16.dp)) - OutlinedTextField( + Text( + text = "로그인", + color = AuthBlack, + fontSize = 27.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.sp + ) + Spacer(modifier = Modifier.height(50.dp)) + AuthTextField( value = loginUiState.username, onValueChange = onUsernameChanged, - modifier = Modifier.fillMaxWidth(), + placeholder = "이메일", enabled = !loginUiState.isAnyLoginLoading, - singleLine = true, - label = { Text(text = "Email") } + keyboardType = KeyboardType.Email ) Spacer(modifier = Modifier.height(12.dp)) - OutlinedTextField( + AuthTextField( value = loginUiState.password, onValueChange = onPasswordChanged, - modifier = Modifier.fillMaxWidth(), + placeholder = "비밀번호", enabled = !loginUiState.isAnyLoginLoading, - singleLine = true, - label = { Text(text = "Password") }, + keyboardType = KeyboardType.Password, visualTransformation = PasswordVisualTransformation() ) - Spacer(modifier = Modifier.height(16.dp)) - Button( - onClick = onLoginClick, - modifier = Modifier.fillMaxWidth(), - enabled = !loginUiState.isAnyLoginLoading - ) { - if (loginUiState.isCredentialLoginLoading) { - LoginProgressIndicator(size = 18.dp) - } else { - Text(text = "Login") - } - } - Spacer(modifier = Modifier.height(12.dp)) - OutlinedButton( - onClick = onSignUpClick, - modifier = Modifier.fillMaxWidth(), - enabled = !loginUiState.isAnyLoginLoading - ) { - Text(text = "Sign Up") - } - Spacer(modifier = Modifier.height(12.dp)) - OutlinedButton( - onClick = onGoogleLoginClick, - modifier = Modifier.fillMaxWidth(), - enabled = !loginUiState.isAnyLoginLoading - ) { - if (loginUiState.isGoogleLoginLoading) { - LoginProgressIndicator(size = 18.dp) - } else { - Text(text = "Continue with Google") - } - } - Spacer(modifier = Modifier.height(12.dp)) - ClearGoogleLoginHistoryButton( - onClick = onClearGoogleLoginHistoryClick, - enabled = !loginUiState.isAnyLoginLoading && !loginUiState.isGoogleHistoryClearing - ) loginUiState.loginMessage?.let { message -> - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(24.dp)) Text( text = message, - style = MaterialTheme.typography.bodyMedium + modifier = Modifier.fillMaxWidth(), + color = AuthError, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center ) - } - Spacer(modifier = Modifier.height(12.dp)) - CheckServerButton( - onClick = onCheckServerClick, - isLoading = serverHealthUiState.isCheckingServer + Spacer(modifier = Modifier.height(28.dp)) + } ?: Spacer(modifier = Modifier.height(16.dp)) + SubmitButton( + text = "로그인 하기", + onClick = onLoginClick, + enabled = !loginUiState.isAnyLoginLoading, + isLoading = loginUiState.isCredentialLoginLoading ) - serverHealthUiState.serverCheckMessage?.let { message -> - Spacer(modifier = Modifier.height(12.dp)) + } + } +} + +@Composable +private fun HangshaBrand() { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(id = R.drawable.hangsha_logo), + contentDescription = "행샤 로고", + modifier = Modifier.size(86.dp), + contentScale = ContentScale.Fit + ) + Spacer(modifier = Modifier.width(22.dp)) + Text( + text = "행샤", + color = AuthBlack, + fontSize = 29.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.sp + ) + } +} + +@Composable +private fun OpeningButton( + text: String, + onClick: () -> Unit, + enabled: Boolean, + modifier: Modifier = Modifier, + isLoading: Boolean = false, + containerColor: Color = AuthWhite, + contentColor: Color = AuthBlack, + borderColor: Color = AuthBorder, + shadowElevation: Dp = 2.dp, + leadingIcon: (@Composable () -> Unit)? = null +) { + Surface( + onClick = onClick, + modifier = modifier + .fillMaxWidth() + .height(AuthButtonHeight), + enabled = enabled && !isLoading, + shape = AuthRoundShape, + color = containerColor, + contentColor = contentColor, + shadowElevation = shadowElevation, + border = BorderStroke(1.dp, borderColor) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 15.dp), + contentAlignment = Alignment.Center + ) { + if (isLoading) { + LoginProgressIndicator(size = 16.dp, color = contentColor) + } else { + leadingIcon?.let { + Box( + modifier = Modifier.align(Alignment.CenterStart), + contentAlignment = Alignment.Center + ) { + it() + } + } Text( - text = message, - style = MaterialTheme.typography.bodyMedium + text = text, + color = contentColor, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 0.sp, + textAlign = TextAlign.Center ) } } @@ -127,9 +259,99 @@ fun LoginScreen( } @Composable -private fun LoginProgressIndicator(size: Dp) { +private fun AuthTextField( + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + enabled: Boolean, + keyboardType: KeyboardType, + modifier: Modifier = Modifier, + visualTransformation: VisualTransformation = VisualTransformation.None +) { + Box( + modifier = modifier + .fillMaxWidth() + .height(AuthFieldHeight) + .background(AuthWhite, AuthRoundShape) + .border(1.dp, AuthBorder, AuthRoundShape) + .padding(horizontal = 14.dp), + contentAlignment = Alignment.CenterStart + ) { + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier.fillMaxWidth(), + enabled = enabled, + singleLine = true, + textStyle = TextStyle( + color = AuthBlack, + fontSize = 17.sp, + fontWeight = FontWeight.Normal, + letterSpacing = 0.sp + ), + cursorBrush = SolidColor(AuthBlack), + keyboardOptions = KeyboardOptions(keyboardType = keyboardType), + visualTransformation = visualTransformation, + decorationBox = { innerTextField -> + if (value.isBlank()) { + Text( + text = placeholder, + color = AuthPlaceholder, + fontSize = 15.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 0.sp + ) + } + innerTextField() + } + ) + } +} + +@Composable +private fun SubmitButton( + text: String, + onClick: () -> Unit, + enabled: Boolean, + isLoading: Boolean +) { + Surface( + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + .height(AuthButtonHeight), + enabled = enabled, + shape = AuthRoundShape, + color = AuthBlack, + contentColor = AuthWhite + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + if (isLoading) { + LoginProgressIndicator(size = 16.dp, color = AuthWhite) + } else { + Text( + text = text, + color = AuthWhite, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 0.sp + ) + } + } + } +} + +@Composable +private fun LoginProgressIndicator( + size: Dp, + color: Color = MaterialTheme.colorScheme.primary +) { CircularProgressIndicator( - modifier = Modifier.height(size), + modifier = Modifier.size(size), + color = color, strokeWidth = 2.dp ) } diff --git a/app/src/main/java/com/example/hangsha_android/ui/view/login/LoginViewModel.kt b/app/src/main/java/com/example/hangsha_android/ui/view/login/LoginViewModel.kt index f5d9ee9..12ba034 100644 --- a/app/src/main/java/com/example/hangsha_android/ui/view/login/LoginViewModel.kt +++ b/app/src/main/java/com/example/hangsha_android/ui/view/login/LoginViewModel.kt @@ -54,21 +54,21 @@ class LoginViewModel @Inject constructor( val password = currentState.password when { - email.isBlank() -> onAuthFailure("Please enter your email.") - password.isBlank() -> onAuthFailure("Please enter your password.") + email.isBlank() -> onAuthFailure("이메일을 입력해주세요.") + password.isBlank() -> onAuthFailure("비밀번호를 입력해주세요.") else -> viewModelScope.launch { onCredentialLoginStarted() val result = runCatching { val response = authRepository.login(email = email, password = password) - saveAccessTokenFromResponse(response, "login") + saveAccessTokenFromResponse(response) loadOrganizationNames() loadExcludedKeywords() } result.fold( onSuccess = { - onAuthSuccess("Login succeeded.") + onAuthSuccess("로그인되었습니다.") }, onFailure = { error -> onAuthFailure(error, "login") @@ -79,11 +79,11 @@ class LoginViewModel @Inject constructor( } fun onGoogleLoginConfigMissing() { - onAuthFailure("GOOGLE_SERVER_CLIENT_ID is not configured.") + onAuthFailure("Google 로그인이 아직 설정되지 않았습니다.") } fun onGoogleLoginCancelled() { - onAuthFailure("Google login was cancelled.") + onAuthFailure("Google 로그인이 취소되었습니다.") } fun onGoogleLoginError(message: String) { @@ -103,7 +103,7 @@ class LoginViewModel @Inject constructor( _uiState.update { it.copy( isGoogleHistoryClearing = false, - loginMessage = "Cleared local Google sign-in history." + loginMessage = "Google 로그인 기록을 지웠습니다." ) } } @@ -115,7 +115,7 @@ class LoginViewModel @Inject constructor( } if (serverAuthCode.isNullOrBlank()) { - onGoogleLoginError("Google login did not return a server auth code.") + onGoogleLoginError("Google 로그인 정보를 가져오지 못했습니다.") return } @@ -124,14 +124,14 @@ class LoginViewModel @Inject constructor( val result = runCatching { val response = authRepository.loginWithGoogle(serverAuthCode) - saveAccessTokenFromResponse(response, "Google login") + saveAccessTokenFromResponse(response) loadOrganizationNames() loadExcludedKeywords() } result.fold( onSuccess = { - onAuthSuccess("Google login succeeded.") + onAuthSuccess("Google 로그인이 완료되었습니다.") }, onFailure = { error -> onAuthFailure(error, "Google login") @@ -169,8 +169,7 @@ class LoginViewModel @Inject constructor( } private fun saveAccessTokenFromResponse( - response: retrofit2.Response, - actionLabel: String + response: retrofit2.Response ) { if (!response.isSuccessful) { throw HttpException(response) @@ -178,7 +177,7 @@ class LoginViewModel @Inject constructor( val accessToken = response.body()?.accessToken if (accessToken.isNullOrBlank()) { - throw IllegalStateException("$actionLabel succeeded but access token was missing.") + throw IllegalStateException("로그인 응답에 필요한 토큰이 없습니다.") } authTokenStorage.saveAccessToken(accessToken) @@ -221,20 +220,24 @@ class LoginViewModel @Inject constructor( private fun onAuthFailure(error: Throwable, actionLabel: String) { val message = when (error) { - is UnknownHostException -> "No internet connection. Please check your network." - is SocketTimeoutException -> "The request timed out. Please try again." + is UnknownHostException -> "인터넷 연결을 확인해주세요." + is SocketTimeoutException -> "요청 시간이 초과되었습니다. 다시 시도해주세요." is HttpException -> when (error.code()) { - 400 -> "Invalid $actionLabel request." - 401 -> "Incorrect email or password." - 403 -> "You do not have permission to continue." - 404 -> "Account information could not be found." - in 500..599 -> "Server error occurred. Please try again later." - else -> "${actionLabel.replaceFirstChar(Char::titlecase)} failed with code ${error.code()}." + 400 -> "입력한 정보를 확인해주세요." + 401 -> if (actionLabel == "login") { + "이메일 또는 비밀번호가 일치하지 않습니다." + } else { + "Google 로그인에 실패했습니다." + } + 403 -> "계속 진행할 권한이 없습니다." + 404 -> "계정 정보를 찾을 수 없습니다." + in 500..599 -> "서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요." + else -> "로그인에 실패했습니다. (${error.code()})" } - is IOException -> "Network error occurred. Please try again." + is IOException -> "네트워크 오류가 발생했습니다. 다시 시도해주세요." is IllegalStateException -> error.message - ?: "${actionLabel.replaceFirstChar(Char::titlecase)} failed." - else -> error.message ?: "${actionLabel.replaceFirstChar(Char::titlecase)} failed." + ?: "로그인에 실패했습니다." + else -> error.message ?: "로그인에 실패했습니다." } onAuthFailure(message) diff --git a/app/src/main/res/drawable/hangsha_logo.png b/app/src/main/res/drawable/hangsha_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..cb53a5ed3944c1cedde91504354ad8603343dde3 GIT binary patch literal 5671 zcmV+?7TD>DP)I2Y0!t1>iTMDL6Qe{9p39ko z~wd{)=z46cTH7wPj@|CPd)Wi zl|c)@N&Rt(O!Gc6j1-?H-K{MD^PQJyA^i3;PtZb9?;6f1k#T|?>Lc6kHPZilf;2Tp zypl*lm}0RmK1`A3Ghnn3o}s~8%zWDU;^S%lK=l}hUrLjvW|&u~oYcM+GN-z@5bPsO zHAs=;q?|83_B`dAdW`h*qoh$DbFLqp-}=Vw6q`Dt1u6g>V;`QilX@aa8s#Y~RHI+` zlUA~FZ}Qx0=619{jL>$}hW=wu3q*0HOnku{dsqL^v(Gt*h z?sUa*S6;qIi=kGo;fFtN_x;5?7gFLYr%{fgQ0>~aD`q;`y|hHMo~^tvZutLqXff2v zHOi5Q#qZ;t;cgm*5JCmjuW)8AnQ>yn58k21tHU>qhd8(W_&r(-wQ`Meuri+7v13O} ztAOeugsP{9`yJEin7M=&zq+YO+WF#h^A6tS-g5(CTZHXVZoMYuLVhd;Y+I z1D0-Hse{njS>e)!gk!NI{SA6ibqJgciL1u$iM%CanzG?Ajc zz1`TdWs9t$v_L6>`RJpM8e?N)rfu6=zla$9{ryHZn-%Xxo7dIByz8#JoP0iSuu$om zDtZ`a&z^N2dE^motWb#f$&)9Ics#E4^$60@(cwJ&@WamO)2DSUr4Z&DZ@fXMsjgnV zN}6afG&E!!J$h8PsT9Lx)j}M!wYBM&7D3Ri!d|zj)S<+omNb!qKXkNl<3>uQQnqeW zse=$Iw5doFIq;!lLj|GTtJ}QlAgq7<;tX9=MHB9)a=6_Kfzt0s5v2O3zl6~S0;S)N zBADIX-QtC@^-sUFh|${G3PS}QIBHxLRC{4DT2xR zy2C1l$uE#Jkwci&VWHL5S#=O@O7->iHaCj(ON$ur{lnff{6nJvMUM)tUPKT0{=uW- z#TQ@HLZuL5fdW?lFs4esxQKyvE{1;aW|Kx4;%HN?U%#HTmwGgTp`x?16BqT~z=3^*+fd5UiF3DUgc{pm^d72(GZ@V zp00TdQ~Syosf1~o@Tj2Gt5*lvrh-5nsUY4Sn%!0bx6?8QUg{21jO8#zS^KIOZM~zO zQ~~bICUrdYL`DhWSxDMwKR@8-&tD_XD=0uHt0_Jb@N?%mud zHp%d~MbG~05Je4`)HiL~L{n4Z0}zy@;sh0}b*iwKHIkE5YCu^`_rRQ>^Ov}<3hgQV zHH;YCA#a^Jb;{VebEo6ZrWAx=p>U;rVJRxKpI}m#g}9Hmx5~s#&d#!ET&Tvv?28d9 zzpS6hll@S0k)oxcrNXdK5%d1(#_-(01TQ!AU7?=~`yhbzk8WrXL4Wj; zRImbWt1w=Ys!y^^SmcCCw5c%iS5rCf^i2wni)w?;U*0+Hgr`BfDM3)Y1r}=IYwkWt zrJfNuM~znPwph7E2^XVM_VRYYbDpLp1a3-VfdV~9v2y0}8LS3ys13pTm!i^IAw#oO zyl&op=1VhItsy&Td#O|y7fLw+TFmO7_&B7gN}!)gm~HiN`pWTI4yy^?2|jq}{KXQo z6dXz4IgAn+Ul`6$_`<4Th>)bHywJ`S9vZ@wju)qgYgfjuyPEPu} z!5kFwQe(#~`fp%EZbDr`2o}#5Q+ka-vbUH1pf2;zkEslS&GeK=j=!uTb->1GiSUiV1i1yS2_uN83fHoD@{Dt}l&zFz{3o6}S zDzAQFkYP7f(UL|50q_e8gg8m1wG+-ESP#+7X`#|Qm(*j|oO{IdhhYEuAbqbL<+ElFm8RO&Qq!Q%>3ZT$~YSt!w=Vt;DDl8h&-rkOBpHW4H ztx1&g2?P+fB$YR(OGnifcMoGiukZJ{*iYrvvL4@Y%J=(T_g+wAWvD!TzH84FZ&Adb zeDX;L{(q#9L+xAwC8@Sx1=abB2~J_CVD--k(4n>xl2kAr_$P9ij}z_&LCDM#FGU4` zDwnT6ZJM`lx$(UqE!s+Rd3fxjYIgpj5sW!1R2fcxGl7younv7tojE3g>im`69IT>a z9|eIzje`P=0^3as>+D6`y>42lXvsO^l~ehEY`^(I?e|)la2Q$A6au327b_`}0#qtc zlBE_b%&hd)-&h$~dkU^&FjU~f7m?0+7!7V_DU&MSeC(y!zJ-!Agq0V{f8YCCivi`QmH5i)Lxq1e#uawmUxTG8!o)< ztu899op6kT-|b5y4IRhb^sl+`ZYHSRbVHc-P>7iBM2*zk+#K23suF>kZ+^aNs)E%9 zsyV--D0FxD3u=Z6g}<8$HOX9-K>XIr+jg2=1I^`Jn{o5TwwEe*f9j=z0B0yfimTIe zO%+Lxib@3PD_jfx<=5Kh?W0mruwbb0Oi>p0`|(dvCe7-Vg7|$4h!0^*lY)priqq56 zW)#=_tyG{eHy>2BeGj$PlwiU453AF|OFaTJ)cOSiHNryLEh#Ga=y|z7HHchEQE7pa zNWnt7k&w*oON=o&If+`z3TZgKmtHD3(R$hMhNY<>AQ|89vP}hVjBRt|S#!i$wZH`U z6s1Mq79&uS8Wb$yrB2~5Pylrdo(ld+QNk7Yr3)sOaqMG@`PU&Sw!QNv)`Y za2;!EYKoFiW-k>+@qV8c57J5lM@=HyR9N*R3Z1`8AyAT95G*;x0jB1r=y<$jsL(!w zaG~b2C`l!P z6-B(maay1?C>J+w{(y!dPdexdWYi3vl z%(F?|jzC|PrUIb;h0+KPTA+YxH5Mk*ghQ=6o=QuUA_(`FpqD2NL!cxo306l(NAaEL zT1p*6O$ATrAcq!&B~TJo1WPW&ht6Nc*8E-8pr1dFtj0vRDi=C`F`OR9dIn*25JH8ig)PepWHrUkj=Hji zIB0>A%qLj4t&UTe@W;-SBY!pOV7g1`1nT>TLcHm=I&mzAsD~j?l5&ED`}^t_R#BK> zj2rq7;ZZ>vWq=C|R{p5tj>h^Q_YaTNh5K<1oq?eo3%V($c@7iH=6V`gWp* zXjqtQ9taekx$T+e1@r#Fs{(zAR9H$!OO^r@2Pt-RbR=FsT-u_F@vB_Dil{9-XNP40 zR6f*Dwr}2~FK;uN*_@u`b}#gXXilV|nT3gEFX5&nhZ%T5jrGYW(Sna21-=fL{M#)>G+P^+mB z(fNzv^t^K{D}RwRk-*C5KjaR16LYCU{nO60>L5(&a8ts>jZ>4?-X%>Wn3_eIieOZTq{~ znd$dP6Dqmv^n3gN>i0iHn+jg)@Osgmzv=+rzv9ATYuB!&%$dIDNE0eQJN=XAu3x_n zwa?zYd$;W_rK3>CHU_w{NKt&cTD;A4k$hQ5xm!939%gvl3O`zbJzqJ3)zO!b{8fScbJkJ7U zW9#njj?x-cnwubo(xV@wqoX6n`nze{wt4QS=<+elM2MurDp{l~xm#-ATK_{t{`CI}T9ju7 z1Rs4?F*aBv_@&lcmX)Zludm~pEB?&i{@cAWsR0^Bj)zzhiN-?}bMLj(|VtD=8vuEqBUAra(i(h(tXlTg1 z_10Sx+!<-`xpy%Gv1frZCMG6quGw&Ga(omL(f`GT#NluQdz{N-{@vilcu5#;gO5!& zjmTf7lkpLk3O;bN@|4Hk>*C(Hu*%;^=7Hbc`!EsC$Hg@AnRxC){O--)dkydT%9Sfk zJPUch?d|O`ewMcU&oWPrlSf*2NK+9m{u3ai2&h z^W)!TUgDbao;}wR@!bDdyoNUqnJ0t=d*q3D;=ysA5ESeYm({#HKSw(rk7uFsarg`b ziqqlQ*$+PWpcW{FndSR}8jD{%gbk`-jK7@bxpU`GOT}5ZVz3UvkinKIg9`>G;dI*C z+KTz$>kEN!hv%aYtmHTBrTY#4R=n=iRAJp+gvpwa3z-PxHl8?k(_!W(IZd^wv07SM zjE_J5*jl%4U5pE|E&mtt;k1r>AL1IEkI7-eL@o2-aEH^HGJo7N;vyVQtzPvD;ci|c z9njHDN{|z;}xPSS0`NJfx4VCZHPe09bT8q==`B~>PnT*A4Tl>I)1D1A6 zQkSxC4h4l(K?4(xoIiiwynOj`9jhpDcZi6I!VBS&aaq8erluybhxL#Lm`@yxL+0un?F1vKints!o6WzyLPSP-n-~%hf2t*X^!s! zLMCctXbSGkNxFtom^p(}?!#PaEMLK{yWEkBjWFnrd$*{+Dx$~M>_#OE>@@kk@*L@rKkfc3~8|kI7WQVQ%rcWapOjk`ETC5**SCOjJOWs`t!$z zA?Wg6-0L_pUm3=hO{k2b9f$O8UUdKc_d9RD{kE}X%NEo+FxrT`z0U>LvK3EVaZRM*SDpuTG0V}4#;?5Y7;GZ`T_`{(>vQ^As z%OXx~cVXy~c zklueEDB%8UlmoxR + + + + + From 47e82f6c1dcb7daa06cfe4d88484d64f03fc24d2 Mon Sep 17 00:00:00 2001 From: dgddgd314 Date: Sat, 4 Jul 2026 04:01:42 +0900 Subject: [PATCH 3/5] feat: signup page ui change --- .../ui/navigation/HangshaNavHost.kt | 11 +- .../ui/view/login/LoginScreen.kt | 57 ++++ .../ui/view/signup/SignUpScreen.kt | 284 +++++++++++++++--- .../ui/view/signup/SignUpViewModel.kt | 1 + 4 files changed, 308 insertions(+), 45 deletions(-) diff --git a/app/src/main/java/com/example/hangsha_android/ui/navigation/HangshaNavHost.kt b/app/src/main/java/com/example/hangsha_android/ui/navigation/HangshaNavHost.kt index 54d359a..d4e15d5 100644 --- a/app/src/main/java/com/example/hangsha_android/ui/navigation/HangshaNavHost.kt +++ b/app/src/main/java/com/example/hangsha_android/ui/navigation/HangshaNavHost.kt @@ -166,6 +166,12 @@ fun NavGraphBuilder.loginGraph(navController: NavHostController) { } else { googleLoginLauncher.launch(googleSignInClient.signInIntent) } + }, + onKakaoLoginClick = { + // TODO(KAKAO_LOGIN): Connect Kakao social login after the UI pass. + }, + onNaverLoginClick = { + // TODO(NAVER_LOGIN): Connect Naver social login after the UI pass. } ) } @@ -222,9 +228,8 @@ fun NavGraphBuilder.signUpGraph(navController: NavHostController) { onPasswordConfirmationChanged = { value -> signUpViewModel.onPasswordConfirmationChanged(value) }, - onSignUpClick = { signUpViewModel.signUp() }, - onNavigateBack = { - navController.popBackStack() + onSignUpClick = { + // TODO(SIGN_UP_VM): Wire account creation after the sign-up UI pass. } ) } diff --git a/app/src/main/java/com/example/hangsha_android/ui/view/login/LoginScreen.kt b/app/src/main/java/com/example/hangsha_android/ui/view/login/LoginScreen.kt index fb88490..b318ca6 100644 --- a/app/src/main/java/com/example/hangsha_android/ui/view/login/LoginScreen.kt +++ b/app/src/main/java/com/example/hangsha_android/ui/view/login/LoginScreen.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.example.hangsha_android.R @@ -50,12 +51,16 @@ private val AuthBorder = Color(0xFFE0E0E0) private val AuthPlaceholder = Color(0xFF8F8F8F) private val AuthError = Color(0xFFFF4058) private val AuthYellow = Color(0xFFFFD344) +private val KakaoYellow = Color(0xFFFFE812) +private val NaverGreen = Color(0xFF03C75A) @Composable fun OpeningScreen( loginUiState: LoginUiState, onEmailLoginClick: () -> Unit, onGoogleLoginClick: () -> Unit, + onKakaoLoginClick: () -> Unit, + onNaverLoginClick: () -> Unit, onSignUpClick: () -> Unit ) { Box( @@ -91,6 +96,34 @@ fun OpeningScreen( ) } ) + Spacer(modifier = Modifier.height(12.dp)) + OpeningButton( + text = "카카오톡 계정으로 계속하기", + onClick = onKakaoLoginClick, + enabled = !loginUiState.isAnyLoginLoading, + leadingIcon = { + SocialBadge( + text = "TALK", + containerColor = AuthBlack, + contentColor = KakaoYellow, + fontSize = 6.sp + ) + } + ) + Spacer(modifier = Modifier.height(12.dp)) + OpeningButton( + text = "네이버 계정으로 계속하기", + onClick = onNaverLoginClick, + enabled = !loginUiState.isAnyLoginLoading, + leadingIcon = { + SocialBadge( + text = "N", + containerColor = NaverGreen, + contentColor = AuthWhite, + fontSize = 16.sp + ) + } + ) Spacer(modifier = Modifier.height(14.dp)) OpeningButton( text = "회원가입", @@ -258,6 +291,30 @@ private fun OpeningButton( } } +@Composable +private fun SocialBadge( + text: String, + containerColor: Color, + contentColor: Color, + fontSize: TextUnit +) { + Box( + modifier = Modifier + .size(22.dp) + .background(containerColor, CircleShape), + contentAlignment = Alignment.Center + ) { + Text( + text = text, + color = contentColor, + fontSize = fontSize, + fontWeight = FontWeight.Bold, + letterSpacing = 0.sp, + textAlign = TextAlign.Center + ) + } +} + @Composable private fun AuthTextField( value: String, diff --git a/app/src/main/java/com/example/hangsha_android/ui/view/signup/SignUpScreen.kt b/app/src/main/java/com/example/hangsha_android/ui/view/signup/SignUpScreen.kt index 8b88f65..1a33e59 100644 --- a/app/src/main/java/com/example/hangsha_android/ui/view/signup/SignUpScreen.kt +++ b/app/src/main/java/com/example/hangsha_android/ui/view/signup/SignUpScreen.kt @@ -1,24 +1,49 @@ package com.example.hangsha_android.ui.view.signup import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.border 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.offset import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Button -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +private val SignUpContentWidth = 280.dp +private val SignUpFieldHeight = 37.dp +private val SignUpButtonHeight = 37.dp +private val SignUpRoundShape = CircleShape +private val SignUpBlack = Color(0xFF000000) +private val SignUpWhite = Color(0xFFFFFFFF) +private val SignUpBorder = Color(0xFFE0E0E0) +private val SignUpPlaceholder = Color(0xFF8F8F8F) +private val SignUpError = Color(0xFFFF4058) +private val SignUpErrorContainer = Color(0xFFFFD8DE) @Composable fun SignUpScreen( @@ -26,64 +51,239 @@ fun SignUpScreen( onEmailChanged: (String) -> Unit, onPasswordChanged: (String) -> Unit, onPasswordConfirmationChanged: (String) -> Unit, - onSignUpClick: () -> Unit, - onNavigateBack: () -> Unit + onSignUpClick: () -> Unit ) { + val passwordErrors = passwordValidationErrors(uiState.password) + val hasPasswordError = passwordErrors.isNotEmpty() + val hasPasswordConfirmationError = uiState.passwordConfirmation.isNotEmpty() && + uiState.password != uiState.passwordConfirmation + Box( modifier = Modifier .fillMaxSize() - .background(MaterialTheme.colorScheme.background), + .background(SignUpWhite), contentAlignment = Alignment.Center ) { Column( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + .width(SignUpContentWidth) + .offset(y = (-5).dp), + horizontalAlignment = Alignment.CenterHorizontally ) { - Text(text = "Sign Up", style = MaterialTheme.typography.headlineSmall) - Spacer(modifier = Modifier.height(16.dp)) - OutlinedTextField( + Text( + text = "계정 생성", + color = SignUpBlack, + fontSize = 23.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.sp + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "이메일과 비밀번호를 설정해주세요", + color = SignUpBlack, + fontSize = 14.sp, + fontWeight = FontWeight.Normal, + letterSpacing = 0.sp + ) + Spacer(modifier = Modifier.height(46.dp)) + SignUpTextField( value = uiState.email, onValueChange = onEmailChanged, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - label = { Text(text = "Email") } + placeholder = "email@snu.ac.kr", + keyboardType = KeyboardType.Email ) - Spacer(modifier = Modifier.height(12.dp)) - OutlinedTextField( + Spacer(modifier = Modifier.height(22.dp)) + SignUpTextField( value = uiState.password, onValueChange = onPasswordChanged, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - label = { Text(text = "Password") }, - visualTransformation = PasswordVisualTransformation() + placeholder = "비밀번호", + isError = hasPasswordError, + keyboardType = KeyboardType.Password, + visualTransformation = if (hasPasswordError) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + } ) - Spacer(modifier = Modifier.height(12.dp)) - OutlinedTextField( + passwordErrors.forEach { error -> + Spacer(modifier = Modifier.height(12.dp)) + SignUpErrorPill(text = error) + } + Spacer(modifier = Modifier.height(if (passwordErrors.isEmpty()) 22.dp else 20.dp)) + SignUpTextField( value = uiState.passwordConfirmation, onValueChange = onPasswordConfirmationChanged, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - label = { Text(text = "Confirm Password") }, - visualTransformation = PasswordVisualTransformation() + placeholder = "비밀번호 확인", + isError = hasPasswordConfirmationError, + keyboardType = KeyboardType.Password, + visualTransformation = VisualTransformation.None ) - Spacer(modifier = Modifier.height(16.dp)) - Button( + if (hasPasswordConfirmationError) { + Spacer(modifier = Modifier.height(12.dp)) + SignUpErrorPill(text = "비밀번호가 일치하지 않습니다.") + } + Spacer(modifier = Modifier.height(22.dp)) + SignUpSubmitButton( onClick = onSignUpClick, - modifier = Modifier.fillMaxWidth(), - enabled = uiState.isSubmitEnabled - ) { - Text(text = "Create Account") + isLoading = uiState.isSignUpLoading + ) + } + } +} + +@Composable +private fun SignUpTextField( + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + keyboardType: KeyboardType, + modifier: Modifier = Modifier, + isError: Boolean = false, + visualTransformation: VisualTransformation = VisualTransformation.None +) { + val borderColor = if (isError) SignUpError else SignUpBorder + + Box( + modifier = modifier + .fillMaxWidth() + .height(SignUpFieldHeight) + .background(SignUpWhite, SignUpRoundShape) + .border(1.dp, borderColor, SignUpRoundShape) + .padding(horizontal = 15.dp), + contentAlignment = Alignment.CenterStart + ) { + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + textStyle = TextStyle( + color = SignUpBlack, + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + letterSpacing = 0.sp + ), + cursorBrush = SolidColor(SignUpBlack), + keyboardOptions = KeyboardOptions(keyboardType = keyboardType), + visualTransformation = visualTransformation, + decorationBox = { innerTextField -> + if (value.isBlank()) { + Text( + text = placeholder, + color = SignUpPlaceholder, + fontSize = 15.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 0.sp + ) + } + innerTextField() } - Spacer(modifier = Modifier.height(12.dp)) - OutlinedButton( - onClick = onNavigateBack, - modifier = Modifier.fillMaxWidth() - ) { - Text(text = "Back to Login") + ) + } +} + +@Composable +private fun SignUpErrorPill(text: String) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(37.dp) + .background(SignUpErrorContainer, SignUpRoundShape) + .border(1.dp, SignUpError, SignUpRoundShape) + .padding(horizontal = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(26.dp) + .border(2.dp, SignUpError, CircleShape), + contentAlignment = Alignment.Center + ) { + Text( + text = "!", + color = SignUpError, + fontSize = 19.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.sp, + textAlign = TextAlign.Center + ) + } + Spacer(modifier = Modifier.width(11.dp)) + Text( + text = text, + color = SignUpBlack, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 0.sp + ) + } +} + +@Composable +private fun SignUpSubmitButton( + onClick: () -> Unit, + isLoading: Boolean +) { + Surface( + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + .height(SignUpButtonHeight), + enabled = !isLoading, + shape = SignUpRoundShape, + color = SignUpBlack, + contentColor = SignUpWhite + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + if (isLoading) { + SignUpProgressIndicator(size = 16.dp) + } else { + Text( + text = "계정 생성", + color = SignUpWhite, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 0.sp + ) } } } } + +@Composable +private fun SignUpProgressIndicator(size: Dp) { + CircularProgressIndicator( + modifier = Modifier.size(size), + color = SignUpWhite, + strokeWidth = 2.dp + ) +} + +private fun passwordValidationErrors(password: String): List { + if (password.isEmpty()) { + return emptyList() + } + + return buildList { + if (password.length < 8) { + add("8자 이상이어야 합니다.") + } + if (!password.hasRequiredPasswordCharacters()) { + add("영문, 숫자, 특수문자를 포함해 주세요.") + } + if (password.any(Char::isWhitespace)) { + add("공백은 사용할 수 없습니다.") + } + } +} + +private fun String.hasRequiredPasswordCharacters(): Boolean { + val hasLetter = any(Char::isLetter) + val hasDigit = any(Char::isDigit) + val hasSpecial = any { !it.isLetterOrDigit() && !it.isWhitespace() } + + return hasLetter && hasDigit && hasSpecial +} diff --git a/app/src/main/java/com/example/hangsha_android/ui/view/signup/SignUpViewModel.kt b/app/src/main/java/com/example/hangsha_android/ui/view/signup/SignUpViewModel.kt index c76f848..ab86849 100644 --- a/app/src/main/java/com/example/hangsha_android/ui/view/signup/SignUpViewModel.kt +++ b/app/src/main/java/com/example/hangsha_android/ui/view/signup/SignUpViewModel.kt @@ -53,6 +53,7 @@ class SignUpViewModel @Inject constructor( } } + // TODO(SIGN_UP_VM): Reconnect this after the UI-only sign-up pass is approved. fun signUp() { val currentState = _uiState.value From 59c73688a117f104513dad94907da85a31511291 Mon Sep 17 00:00:00 2001 From: dgddgd314 Date: Sat, 4 Jul 2026 18:27:11 +0900 Subject: [PATCH 4/5] feat: register api connect --- .../data/network/model/EventDetailResponse.kt | 2 +- .../network/model/EventSummaryResponse.kt | 2 +- .../data/repository/AuthRepository.kt | 8 ++- .../ui/navigation/HangshaNavHost.kt | 12 ++-- .../ui/view/calendar/CalendarEvent.kt | 2 +- .../ui/view/login/LoginScreen.kt | 45 ++++++++------ .../ui/view/signup/SignUpScreen.kt | 12 +++- .../ui/view/signup/SignUpUiState.kt | 15 +++++ .../ui/view/signup/SignUpViewModel.kt | 57 +++++++++++++++--- .../{hangsha_logo.png => logo_with_text.png} | Bin 10 files changed, 117 insertions(+), 38 deletions(-) rename app/src/main/res/drawable/{hangsha_logo.png => logo_with_text.png} (100%) diff --git a/app/src/main/java/com/example/hangsha_android/data/network/model/EventDetailResponse.kt b/app/src/main/java/com/example/hangsha_android/data/network/model/EventDetailResponse.kt index cc794dc..71e45a9 100644 --- a/app/src/main/java/com/example/hangsha_android/data/network/model/EventDetailResponse.kt +++ b/app/src/main/java/com/example/hangsha_android/data/network/model/EventDetailResponse.kt @@ -4,7 +4,7 @@ data class EventDetailResponse( val id: Long, val title: String, val imageUrl: String?, - val operationMode: String, + val operationMode: String?, val statusId: Long, val eventTypeId: Long, val orgId: Long, diff --git a/app/src/main/java/com/example/hangsha_android/data/network/model/EventSummaryResponse.kt b/app/src/main/java/com/example/hangsha_android/data/network/model/EventSummaryResponse.kt index 148be22..57d656c 100644 --- a/app/src/main/java/com/example/hangsha_android/data/network/model/EventSummaryResponse.kt +++ b/app/src/main/java/com/example/hangsha_android/data/network/model/EventSummaryResponse.kt @@ -4,7 +4,7 @@ data class EventSummaryResponse( val id: Long, val title: String, val imageUrl: String?, - val operationMode: String, + val operationMode: String?, val statusId: Long, val eventTypeId: Long, val orgId: Long, diff --git a/app/src/main/java/com/example/hangsha_android/data/repository/AuthRepository.kt b/app/src/main/java/com/example/hangsha_android/data/repository/AuthRepository.kt index b44cf3e..2994ea2 100644 --- a/app/src/main/java/com/example/hangsha_android/data/repository/AuthRepository.kt +++ b/app/src/main/java/com/example/hangsha_android/data/repository/AuthRepository.kt @@ -32,12 +32,16 @@ class AuthRepository @Inject constructor( } // 회원가입 - suspend fun register(email: String, password: String): Response { + suspend fun register( + email: String, + password: String, + username: String + ): Response { return authApi.register( RegisterRequest( email = email, password = password, - username = "username" // TODO: username이 가야되긴 하는데, 웹에서도 입력받는 부분이 없어서 임시로 고정값 넣어둠. 나중에 수정 필요 + username = username ) ) } diff --git a/app/src/main/java/com/example/hangsha_android/ui/navigation/HangshaNavHost.kt b/app/src/main/java/com/example/hangsha_android/ui/navigation/HangshaNavHost.kt index d4e15d5..1a6d86e 100644 --- a/app/src/main/java/com/example/hangsha_android/ui/navigation/HangshaNavHost.kt +++ b/app/src/main/java/com/example/hangsha_android/ui/navigation/HangshaNavHost.kt @@ -172,6 +172,9 @@ fun NavGraphBuilder.loginGraph(navController: NavHostController) { }, onNaverLoginClick = { // TODO(NAVER_LOGIN): Connect Naver social login after the UI pass. + }, + onGuestContinueClick = { + // TODO(GUEST_CONTINUE): Connect guest-mode navigation after the UI pass. } ) } @@ -217,20 +220,21 @@ fun NavGraphBuilder.signUpGraph(navController: NavHostController) { return@LaunchedEffect } - navController.popBackStack() + navController.navigate(HangshaDestinations.Main.route) { + popUpTo(HangshaDestinations.Login.route) { inclusive = true } + } signUpViewModel.onSignUpSuccessConsumed() } SignUpScreen( uiState = signUpUiState, onEmailChanged = { value -> signUpViewModel.onEmailChanged(value) }, + onUsernameChanged = { value -> signUpViewModel.onUsernameChanged(value) }, onPasswordChanged = { value -> signUpViewModel.onPasswordChanged(value) }, onPasswordConfirmationChanged = { value -> signUpViewModel.onPasswordConfirmationChanged(value) }, - onSignUpClick = { - // TODO(SIGN_UP_VM): Wire account creation after the sign-up UI pass. - } + onSignUpClick = { signUpViewModel.signUp() } ) } } diff --git a/app/src/main/java/com/example/hangsha_android/ui/view/calendar/CalendarEvent.kt b/app/src/main/java/com/example/hangsha_android/ui/view/calendar/CalendarEvent.kt index 1f1442f..8754a1b 100644 --- a/app/src/main/java/com/example/hangsha_android/ui/view/calendar/CalendarEvent.kt +++ b/app/src/main/java/com/example/hangsha_android/ui/view/calendar/CalendarEvent.kt @@ -7,7 +7,7 @@ data class CalendarEvent( val date: LocalDate, val title: String, val imageUrl: String?, - val operationMode: String, + val operationMode: String?, val statusId: Long, val eventTypeId: Long, val orgId: Long, diff --git a/app/src/main/java/com/example/hangsha_android/ui/view/login/LoginScreen.kt b/app/src/main/java/com/example/hangsha_android/ui/view/login/LoginScreen.kt index b318ca6..76a0844 100644 --- a/app/src/main/java/com/example/hangsha_android/ui/view/login/LoginScreen.kt +++ b/app/src/main/java/com/example/hangsha_android/ui/view/login/LoginScreen.kt @@ -4,9 +4,9 @@ import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image 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.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 @@ -51,6 +51,7 @@ private val AuthBorder = Color(0xFFE0E0E0) private val AuthPlaceholder = Color(0xFF8F8F8F) private val AuthError = Color(0xFFFF4058) private val AuthYellow = Color(0xFFFFD344) +private val AuthMuted = Color(0xFF777777) private val KakaoYellow = Color(0xFFFFE812) private val NaverGreen = Color(0xFF03C75A) @@ -61,7 +62,8 @@ fun OpeningScreen( onGoogleLoginClick: () -> Unit, onKakaoLoginClick: () -> Unit, onNaverLoginClick: () -> Unit, - onSignUpClick: () -> Unit + onSignUpClick: () -> Unit, + onGuestContinueClick: () -> Unit ) { Box( modifier = Modifier @@ -134,6 +136,19 @@ fun OpeningScreen( borderColor = AuthYellow, shadowElevation = 2.dp ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "로그인 없이 게스트로 계속", + modifier = Modifier.clickable( + enabled = !loginUiState.isAnyLoginLoading, + onClick = onGuestContinueClick + ), + color = AuthMuted, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 0.sp, + textAlign = TextAlign.Center + ) loginUiState.loginMessage?.let { message -> Spacer(modifier = Modifier.height(18.dp)) Text( @@ -216,24 +231,14 @@ fun LoginScreen( @Composable private fun HangshaBrand() { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Image( - painter = painterResource(id = R.drawable.hangsha_logo), - contentDescription = "행샤 로고", - modifier = Modifier.size(86.dp), - contentScale = ContentScale.Fit - ) - Spacer(modifier = Modifier.width(22.dp)) - Text( - text = "행샤", - color = AuthBlack, - fontSize = 29.sp, - fontWeight = FontWeight.Bold, - letterSpacing = 0.sp - ) - } + Image( + painter = painterResource(id = R.drawable.logo_with_text), + contentDescription = "행샤 로고", + modifier = Modifier + .width(170.dp) + .height(86.dp), + contentScale = ContentScale.Fit + ) } @Composable diff --git a/app/src/main/java/com/example/hangsha_android/ui/view/signup/SignUpScreen.kt b/app/src/main/java/com/example/hangsha_android/ui/view/signup/SignUpScreen.kt index 1a33e59..03d6f6c 100644 --- a/app/src/main/java/com/example/hangsha_android/ui/view/signup/SignUpScreen.kt +++ b/app/src/main/java/com/example/hangsha_android/ui/view/signup/SignUpScreen.kt @@ -49,6 +49,7 @@ private val SignUpErrorContainer = Color(0xFFFFD8DE) fun SignUpScreen( uiState: SignUpUiState, onEmailChanged: (String) -> Unit, + onUsernameChanged: (String) -> Unit, onPasswordChanged: (String) -> Unit, onPasswordConfirmationChanged: (String) -> Unit, onSignUpClick: () -> Unit @@ -79,13 +80,13 @@ fun SignUpScreen( ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = "이메일과 비밀번호를 설정해주세요", + text = "이메일, 사용자명과 비밀번호를 설정해주세요", color = SignUpBlack, fontSize = 14.sp, fontWeight = FontWeight.Normal, letterSpacing = 0.sp ) - Spacer(modifier = Modifier.height(46.dp)) + Spacer(modifier = Modifier.height(34.dp)) SignUpTextField( value = uiState.email, onValueChange = onEmailChanged, @@ -93,6 +94,13 @@ fun SignUpScreen( keyboardType = KeyboardType.Email ) Spacer(modifier = Modifier.height(22.dp)) + SignUpTextField( + value = uiState.username, + onValueChange = onUsernameChanged, + placeholder = "사용자명", + keyboardType = KeyboardType.Text + ) + Spacer(modifier = Modifier.height(22.dp)) SignUpTextField( value = uiState.password, onValueChange = onPasswordChanged, diff --git a/app/src/main/java/com/example/hangsha_android/ui/view/signup/SignUpUiState.kt b/app/src/main/java/com/example/hangsha_android/ui/view/signup/SignUpUiState.kt index d571649..d9041cc 100644 --- a/app/src/main/java/com/example/hangsha_android/ui/view/signup/SignUpUiState.kt +++ b/app/src/main/java/com/example/hangsha_android/ui/view/signup/SignUpUiState.kt @@ -2,6 +2,7 @@ package com.example.hangsha_android.ui.view.signup data class SignUpUiState( val email: String = "", + val username: String = "", val password: String = "", val passwordConfirmation: String = "", val signUpMessage: String? = null, @@ -10,8 +11,22 @@ data class SignUpUiState( ) { val isSubmitEnabled: Boolean get() = email.isNotBlank() && + username.isNotBlank() && password.isNotBlank() && + password.isValidSignUpPassword() && passwordConfirmation.isNotBlank() && password == passwordConfirmation && !isSignUpLoading } + +private fun String.isValidSignUpPassword(): Boolean { + val hasLetter = any(Char::isLetter) + val hasDigit = any(Char::isDigit) + val hasSpecial = any { !it.isLetterOrDigit() && !it.isWhitespace() } + + return length >= 8 && + hasLetter && + hasDigit && + hasSpecial && + none(Char::isWhitespace) +} diff --git a/app/src/main/java/com/example/hangsha_android/ui/view/signup/SignUpViewModel.kt b/app/src/main/java/com/example/hangsha_android/ui/view/signup/SignUpViewModel.kt index ab86849..9777f07 100644 --- a/app/src/main/java/com/example/hangsha_android/ui/view/signup/SignUpViewModel.kt +++ b/app/src/main/java/com/example/hangsha_android/ui/view/signup/SignUpViewModel.kt @@ -2,7 +2,11 @@ package com.example.hangsha_android.ui.view.signup import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.example.hangsha_android.data.local.AuthTokenStorage +import com.example.hangsha_android.data.network.model.LoginResponse import com.example.hangsha_android.data.repository.AuthRepository +import com.example.hangsha_android.data.repository.ExcludedKeywordsRepository +import com.example.hangsha_android.data.repository.UserRepository import java.io.IOException import java.net.SocketTimeoutException import java.net.UnknownHostException @@ -14,10 +18,14 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import retrofit2.HttpException +import retrofit2.Response @HiltViewModel class SignUpViewModel @Inject constructor( - private val authRepository: AuthRepository + private val authRepository: AuthRepository, + private val authTokenStorage: AuthTokenStorage, + private val userRepository: UserRepository, + private val excludedKeywordsRepository: ExcludedKeywordsRepository ) : ViewModel() { private val _uiState = MutableStateFlow(SignUpUiState()) @@ -33,6 +41,16 @@ class SignUpViewModel @Inject constructor( } } + fun onUsernameChanged(username: String) { + _uiState.update { + it.copy( + username = username, + isSignUpSuccessful = false, + signUpMessage = null + ) + } + } + fun onPasswordChanged(password: String) { _uiState.update { it.copy( @@ -53,12 +71,11 @@ class SignUpViewModel @Inject constructor( } } - // TODO(SIGN_UP_VM): Reconnect this after the UI-only sign-up pass is approved. fun signUp() { val currentState = _uiState.value if (!currentState.isSubmitEnabled) { - onSignUpFailure("Please check your email and password inputs.") + onSignUpFailure("Please check your email, username, and password inputs.") return } @@ -74,12 +91,13 @@ class SignUpViewModel @Inject constructor( runCatching { val response = authRepository.register( email = currentState.email.trim(), - password = currentState.password + password = currentState.password, + username = currentState.username.trim() ) - if (!response.isSuccessful) { - throw HttpException(response) - } + saveAccessTokenFromResponse(response) + loadOrganizationNames() + loadExcludedKeywords() }.fold( onSuccess = { onSignUpSuccess() @@ -130,6 +148,31 @@ class SignUpViewModel @Inject constructor( onSignUpFailure(message) } + private fun saveAccessTokenFromResponse(response: Response) { + if (!response.isSuccessful) { + throw HttpException(response) + } + + val accessToken = response.body()?.accessToken + if (accessToken.isNullOrBlank()) { + throw IllegalStateException("Sign-up response did not include an access token.") + } + + authTokenStorage.saveAccessToken(accessToken) + } + + private suspend fun loadOrganizationNames() { + runCatching { + userRepository.ensureOrganizationNamesLoaded() + } + } + + private suspend fun loadExcludedKeywords() { + runCatching { + excludedKeywordsRepository.refreshExcludedKeywords() + } + } + private fun onSignUpSuccess() { _uiState.update { it.copy( diff --git a/app/src/main/res/drawable/hangsha_logo.png b/app/src/main/res/drawable/logo_with_text.png similarity index 100% rename from app/src/main/res/drawable/hangsha_logo.png rename to app/src/main/res/drawable/logo_with_text.png From fbd35a2064f87b3361799fcb63d59067ef29e156 Mon Sep 17 00:00:00 2001 From: dgddgd314 Date: Sat, 4 Jul 2026 18:39:04 +0900 Subject: [PATCH 5/5] feat: onboarding screen impl{ --- .../ui/navigation/HangshaNavHost.kt | 39 +++- .../ui/view/onboarding/OnboardingScreen.kt | 195 ++++++++++++++++++ .../ui/view/onboarding/OnboardingUiState.kt | 14 ++ .../ui/view/onboarding/OnboardingViewModel.kt | 139 +++++++++++++ .../ui/view/signup/SignUpScreen.kt | 14 +- .../ui/view/signup/SignUpUiState.kt | 2 - .../ui/view/signup/SignUpViewModel.kt | 14 +- 7 files changed, 390 insertions(+), 27 deletions(-) create mode 100644 app/src/main/java/com/example/hangsha_android/ui/view/onboarding/OnboardingScreen.kt create mode 100644 app/src/main/java/com/example/hangsha_android/ui/view/onboarding/OnboardingUiState.kt create mode 100644 app/src/main/java/com/example/hangsha_android/ui/view/onboarding/OnboardingViewModel.kt diff --git a/app/src/main/java/com/example/hangsha_android/ui/navigation/HangshaNavHost.kt b/app/src/main/java/com/example/hangsha_android/ui/navigation/HangshaNavHost.kt index 1a6d86e..80e74ea 100644 --- a/app/src/main/java/com/example/hangsha_android/ui/navigation/HangshaNavHost.kt +++ b/app/src/main/java/com/example/hangsha_android/ui/navigation/HangshaNavHost.kt @@ -51,6 +51,8 @@ import com.example.hangsha_android.ui.view.mypage.MyPageScreen import com.example.hangsha_android.ui.view.mypage.MyPageViewModel import com.example.hangsha_android.ui.view.mymemos.MyMemosScreen import com.example.hangsha_android.ui.view.mymemos.MyMemosViewModel +import com.example.hangsha_android.ui.view.onboarding.OnboardingScreen +import com.example.hangsha_android.ui.view.onboarding.OnboardingViewModel import com.example.hangsha_android.ui.view.signup.SignUpScreen import com.example.hangsha_android.ui.view.signup.SignUpViewModel import com.google.android.gms.auth.api.signin.GoogleSignIn @@ -61,6 +63,7 @@ sealed class HangshaDestinations(val route: String) { data object Login : HangshaDestinations("login") data object CredentialLogin : HangshaDestinations("credential_login") data object SignUp : HangshaDestinations("sign_up") + data object Onboarding : HangshaDestinations("onboarding") data object Main : HangshaDestinations("main") data object InterestPriority : HangshaDestinations("interest_priority") data object MyBookmarks : HangshaDestinations("my_bookmarks") @@ -98,6 +101,7 @@ fun HangshaNavHost( ) { loginGraph(navController = navController) signUpGraph(navController = navController) + onboardingGraph(navController = navController) mainGraph(navController = navController) } } @@ -220,7 +224,7 @@ fun NavGraphBuilder.signUpGraph(navController: NavHostController) { return@LaunchedEffect } - navController.navigate(HangshaDestinations.Main.route) { + navController.navigate(HangshaDestinations.Onboarding.route) { popUpTo(HangshaDestinations.Login.route) { inclusive = true } } signUpViewModel.onSignUpSuccessConsumed() @@ -229,7 +233,6 @@ fun NavGraphBuilder.signUpGraph(navController: NavHostController) { SignUpScreen( uiState = signUpUiState, onEmailChanged = { value -> signUpViewModel.onEmailChanged(value) }, - onUsernameChanged = { value -> signUpViewModel.onUsernameChanged(value) }, onPasswordChanged = { value -> signUpViewModel.onPasswordChanged(value) }, onPasswordConfirmationChanged = { value -> signUpViewModel.onPasswordConfirmationChanged(value) @@ -239,6 +242,38 @@ fun NavGraphBuilder.signUpGraph(navController: NavHostController) { } } +fun NavGraphBuilder.onboardingGraph(navController: NavHostController) { + composable(HangshaDestinations.Onboarding.route) { + val onboardingViewModel: OnboardingViewModel = hiltViewModel() + val onboardingUiState by onboardingViewModel.uiState.collectAsState() + val context = LocalContext.current + + LaunchedEffect(onboardingUiState.onboardingMessage) { + val message = onboardingUiState.onboardingMessage ?: return@LaunchedEffect + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + onboardingViewModel.onOnboardingMessageConsumed() + } + + LaunchedEffect(onboardingUiState.isUsernameSaved) { + if (!onboardingUiState.isUsernameSaved) { + return@LaunchedEffect + } + + // TODO(ONBOARDING): Add the remaining onboarding steps after username setup. + navController.navigate(HangshaDestinations.Main.route) { + popUpTo(HangshaDestinations.Onboarding.route) { inclusive = true } + } + onboardingViewModel.onUsernameSavedConsumed() + } + + OnboardingScreen( + uiState = onboardingUiState, + onUsernameChanged = { value -> onboardingViewModel.onUsernameChanged(value) }, + onContinueClick = { onboardingViewModel.saveUsername() } + ) + } +} + fun NavGraphBuilder.mainGraph(navController: NavHostController) { navigation( startDestination = BottomTab.Calendar.route, diff --git a/app/src/main/java/com/example/hangsha_android/ui/view/onboarding/OnboardingScreen.kt b/app/src/main/java/com/example/hangsha_android/ui/view/onboarding/OnboardingScreen.kt new file mode 100644 index 0000000..0c00ba6 --- /dev/null +++ b/app/src/main/java/com/example/hangsha_android/ui/view/onboarding/OnboardingScreen.kt @@ -0,0 +1,195 @@ +package com.example.hangsha_android.ui.view.onboarding + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +private val OnboardingContentWidth = 280.dp +private val OnboardingFieldHeight = 37.dp +private val OnboardingButtonHeight = 37.dp +private val OnboardingRoundShape = CircleShape +private val OnboardingBlack = Color(0xFF000000) +private val OnboardingWhite = Color(0xFFFFFFFF) +private val OnboardingBorder = Color(0xFFE0E0E0) +private val OnboardingPlaceholder = Color(0xFF8F8F8F) +private val OnboardingError = Color(0xFFFF4058) + +@Composable +fun OnboardingScreen( + uiState: OnboardingUiState, + onUsernameChanged: (String) -> Unit, + onContinueClick: () -> Unit +) { + Box( + modifier = Modifier + .fillMaxSize() + .background(OnboardingWhite), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier + .width(OnboardingContentWidth) + .offset(y = (-8).dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "사용자명 설정", + color = OnboardingBlack, + fontSize = 23.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.sp + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "행샤에서 사용할 이름을 입력해주세요", + color = OnboardingBlack, + fontSize = 14.sp, + fontWeight = FontWeight.Normal, + letterSpacing = 0.sp, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(46.dp)) + OnboardingTextField( + value = uiState.username, + onValueChange = onUsernameChanged, + placeholder = "사용자명", + isError = uiState.usernameErrorMessage != null + ) + uiState.usernameErrorMessage?.let { message -> + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = message, + modifier = Modifier.fillMaxWidth(), + color = OnboardingError, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center + ) + } + Spacer(modifier = Modifier.height(22.dp)) + OnboardingSubmitButton( + onClick = onContinueClick, + enabled = uiState.isSubmitEnabled, + isLoading = uiState.isSavingUsername + ) + } + } +} + +@Composable +private fun OnboardingTextField( + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + isError: Boolean +) { + val borderColor = if (isError) OnboardingError else OnboardingBorder + + Box( + modifier = Modifier + .fillMaxWidth() + .height(OnboardingFieldHeight) + .background(OnboardingWhite, OnboardingRoundShape) + .border(1.dp, borderColor, OnboardingRoundShape) + .padding(horizontal = 15.dp), + contentAlignment = Alignment.CenterStart + ) { + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + textStyle = TextStyle( + color = OnboardingBlack, + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + letterSpacing = 0.sp + ), + cursorBrush = SolidColor(OnboardingBlack), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text), + decorationBox = { innerTextField -> + if (value.isBlank()) { + Text( + text = placeholder, + color = OnboardingPlaceholder, + fontSize = 15.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 0.sp + ) + } + innerTextField() + } + ) + } +} + +@Composable +private fun OnboardingSubmitButton( + onClick: () -> Unit, + enabled: Boolean, + isLoading: Boolean +) { + Surface( + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + .height(OnboardingButtonHeight), + enabled = enabled, + shape = OnboardingRoundShape, + color = if (enabled) OnboardingBlack else Color(0xFFBDBDBD), + contentColor = OnboardingWhite + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + if (isLoading) { + OnboardingProgressIndicator(size = 16.dp) + } else { + Text( + text = "계속", + color = OnboardingWhite, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 0.sp + ) + } + } + } +} + +@Composable +private fun OnboardingProgressIndicator(size: Dp) { + CircularProgressIndicator( + modifier = Modifier.size(size), + color = OnboardingWhite, + strokeWidth = 2.dp + ) +} diff --git a/app/src/main/java/com/example/hangsha_android/ui/view/onboarding/OnboardingUiState.kt b/app/src/main/java/com/example/hangsha_android/ui/view/onboarding/OnboardingUiState.kt new file mode 100644 index 0000000..dcd35f5 --- /dev/null +++ b/app/src/main/java/com/example/hangsha_android/ui/view/onboarding/OnboardingUiState.kt @@ -0,0 +1,14 @@ +package com.example.hangsha_android.ui.view.onboarding + +data class OnboardingUiState( + val username: String = "", + val usernameErrorMessage: String? = null, + val onboardingMessage: String? = null, + val isSavingUsername: Boolean = false, + val isUsernameSaved: Boolean = false +) { + val isSubmitEnabled: Boolean + get() = username.isNotBlank() && + usernameErrorMessage == null && + !isSavingUsername +} diff --git a/app/src/main/java/com/example/hangsha_android/ui/view/onboarding/OnboardingViewModel.kt b/app/src/main/java/com/example/hangsha_android/ui/view/onboarding/OnboardingViewModel.kt new file mode 100644 index 0000000..b47876f --- /dev/null +++ b/app/src/main/java/com/example/hangsha_android/ui/view/onboarding/OnboardingViewModel.kt @@ -0,0 +1,139 @@ +package com.example.hangsha_android.ui.view.onboarding + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.hangsha_android.data.repository.UserRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import java.io.IOException +import java.net.SocketTimeoutException +import java.net.UnknownHostException +import javax.inject.Inject +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 retrofit2.HttpException + +@HiltViewModel +class OnboardingViewModel @Inject constructor( + private val userRepository: UserRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(OnboardingUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun onUsernameChanged(username: String) { + _uiState.update { + it.copy( + username = username, + usernameErrorMessage = validateUsername(username), + onboardingMessage = null, + isUsernameSaved = false + ) + } + } + + fun saveUsername() { + val currentState = _uiState.value + val usernameError = validateUsername(currentState.username) + if (usernameError != null) { + _uiState.update { it.copy(usernameErrorMessage = usernameError) } + return + } + + viewModelScope.launch { + _uiState.update { + it.copy( + isSavingUsername = true, + onboardingMessage = null, + isUsernameSaved = false + ) + } + + runCatching { + val response = userRepository.updateMyUsername(currentState.username) + if (!response.isSuccessful) { + throw HttpException(response) + } + }.fold( + onSuccess = { + _uiState.update { + it.copy( + username = currentState.username.trim(), + isSavingUsername = false, + isUsernameSaved = true, + usernameErrorMessage = null + ) + } + }, + onFailure = { error -> + _uiState.update { + it.copy( + isSavingUsername = false, + isUsernameSaved = false, + onboardingMessage = mapOnboardingErrorMessage(error) + ) + } + } + ) + } + } + + fun onOnboardingMessageConsumed() { + _uiState.update { + it.copy(onboardingMessage = null) + } + } + + fun onUsernameSavedConsumed() { + _uiState.update { + it.copy(isUsernameSaved = false) + } + } + + private fun validateUsername(username: String): String? { + val trimmedUsername = username.trim() + if (trimmedUsername.isBlank()) { + return "사용자 이름을 입력해 주세요." + } + + val maxLength = if (trimmedUsername.any { char -> char.isKorean() }) { + KOREAN_USERNAME_MAX_LENGTH + } else { + ENGLISH_USERNAME_MAX_LENGTH + } + return if (trimmedUsername.length > maxLength) { + "사용자 이름은 ${maxLength}자 이하여야 합니다." + } else { + null + } + } + + private fun mapOnboardingErrorMessage(error: Throwable): String { + return when (error) { + is IllegalArgumentException -> error.message ?: "사용자 이름을 확인해 주세요." + is UnknownHostException -> "인터넷 연결을 확인해 주세요." + is SocketTimeoutException -> "요청 시간이 초과되었습니다. 다시 시도해 주세요." + is HttpException -> when (error.code()) { + 400 -> "사용자 이름을 확인해 주세요." + 401 -> "로그인이 필요합니다." + 403 -> "사용자 이름을 수정할 권한이 없습니다." + 409 -> "이미 사용 중인 사용자 이름입니다." + in 500..599 -> "서버 오류가 발생했습니다. 잠시 후 다시 시도해 주세요." + else -> "사용자 이름 저장에 실패했습니다. (${error.code()})" + } + is IOException -> "네트워크 오류가 발생했습니다. 다시 시도해 주세요." + else -> error.message ?: "사용자 이름 저장에 실패했습니다." + } + } +} + +private fun Char.isKorean(): Boolean { + return this in '\uAC00'..'\uD7A3' || + this in '\u1100'..'\u11FF' || + this in '\u3130'..'\u318F' +} + +private const val ENGLISH_USERNAME_MAX_LENGTH = 20 +private const val KOREAN_USERNAME_MAX_LENGTH = 10 diff --git a/app/src/main/java/com/example/hangsha_android/ui/view/signup/SignUpScreen.kt b/app/src/main/java/com/example/hangsha_android/ui/view/signup/SignUpScreen.kt index 03d6f6c..7ab2723 100644 --- a/app/src/main/java/com/example/hangsha_android/ui/view/signup/SignUpScreen.kt +++ b/app/src/main/java/com/example/hangsha_android/ui/view/signup/SignUpScreen.kt @@ -49,7 +49,6 @@ private val SignUpErrorContainer = Color(0xFFFFD8DE) fun SignUpScreen( uiState: SignUpUiState, onEmailChanged: (String) -> Unit, - onUsernameChanged: (String) -> Unit, onPasswordChanged: (String) -> Unit, onPasswordConfirmationChanged: (String) -> Unit, onSignUpClick: () -> Unit @@ -80,13 +79,13 @@ fun SignUpScreen( ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = "이메일, 사용자명과 비밀번호를 설정해주세요", + text = "이메일과 비밀번호를 설정해주세요", color = SignUpBlack, fontSize = 14.sp, fontWeight = FontWeight.Normal, letterSpacing = 0.sp ) - Spacer(modifier = Modifier.height(34.dp)) + Spacer(modifier = Modifier.height(46.dp)) SignUpTextField( value = uiState.email, onValueChange = onEmailChanged, @@ -94,13 +93,6 @@ fun SignUpScreen( keyboardType = KeyboardType.Email ) Spacer(modifier = Modifier.height(22.dp)) - SignUpTextField( - value = uiState.username, - onValueChange = onUsernameChanged, - placeholder = "사용자명", - keyboardType = KeyboardType.Text - ) - Spacer(modifier = Modifier.height(22.dp)) SignUpTextField( value = uiState.password, onValueChange = onPasswordChanged, @@ -124,7 +116,7 @@ fun SignUpScreen( placeholder = "비밀번호 확인", isError = hasPasswordConfirmationError, keyboardType = KeyboardType.Password, - visualTransformation = VisualTransformation.None + visualTransformation = PasswordVisualTransformation() ) if (hasPasswordConfirmationError) { Spacer(modifier = Modifier.height(12.dp)) diff --git a/app/src/main/java/com/example/hangsha_android/ui/view/signup/SignUpUiState.kt b/app/src/main/java/com/example/hangsha_android/ui/view/signup/SignUpUiState.kt index d9041cc..7a89d7c 100644 --- a/app/src/main/java/com/example/hangsha_android/ui/view/signup/SignUpUiState.kt +++ b/app/src/main/java/com/example/hangsha_android/ui/view/signup/SignUpUiState.kt @@ -2,7 +2,6 @@ package com.example.hangsha_android.ui.view.signup data class SignUpUiState( val email: String = "", - val username: String = "", val password: String = "", val passwordConfirmation: String = "", val signUpMessage: String? = null, @@ -11,7 +10,6 @@ data class SignUpUiState( ) { val isSubmitEnabled: Boolean get() = email.isNotBlank() && - username.isNotBlank() && password.isNotBlank() && password.isValidSignUpPassword() && passwordConfirmation.isNotBlank() && diff --git a/app/src/main/java/com/example/hangsha_android/ui/view/signup/SignUpViewModel.kt b/app/src/main/java/com/example/hangsha_android/ui/view/signup/SignUpViewModel.kt index 9777f07..8a4aaba 100644 --- a/app/src/main/java/com/example/hangsha_android/ui/view/signup/SignUpViewModel.kt +++ b/app/src/main/java/com/example/hangsha_android/ui/view/signup/SignUpViewModel.kt @@ -41,16 +41,6 @@ class SignUpViewModel @Inject constructor( } } - fun onUsernameChanged(username: String) { - _uiState.update { - it.copy( - username = username, - isSignUpSuccessful = false, - signUpMessage = null - ) - } - } - fun onPasswordChanged(password: String) { _uiState.update { it.copy( @@ -75,7 +65,7 @@ class SignUpViewModel @Inject constructor( val currentState = _uiState.value if (!currentState.isSubmitEnabled) { - onSignUpFailure("Please check your email, username, and password inputs.") + onSignUpFailure("Please check your email and password inputs.") return } @@ -92,7 +82,7 @@ class SignUpViewModel @Inject constructor( val response = authRepository.register( email = currentState.email.trim(), password = currentState.password, - username = currentState.username.trim() + username = "" ) saveAccessTokenFromResponse(response)