diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3b1d8e4..8ecad0c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,5 @@ +import java.util.Properties + plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) @@ -5,6 +7,10 @@ plugins { alias(libs.plugins.kotlin.serialization) } +val properties = Properties().apply { + load(project.rootProject.file("local.properties").inputStream()) +} + android { namespace = "org.sopt.at" compileSdk = 35 @@ -17,6 +23,7 @@ android { versionName = "1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + buildConfigField("String", "BASE_URL", properties["base.url"].toString()) } buildTypes { @@ -37,11 +44,11 @@ android { } buildFeatures { compose = true + buildConfig = true } } dependencies { - implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.activity.compose) @@ -60,6 +67,10 @@ dependencies { // Compose implementation(libs.androidx.compose.navigation) - implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.collections.immutable) + // NetWorking + implementation(libs.kotlinx.serialization.json) + implementation(libs.retrofit.core) + implementation(libs.retrofit.kotlin.serialization) + implementation(libs.okhttp.logging) } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 95211c2..73174d8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,8 @@ + + ( + @SerialName("success") + val success: Boolean, + @SerialName("code") + val code: String, + @SerialName("message") + val message: String, + @SerialName("data") + val data: T?, +) \ No newline at end of file diff --git a/app/src/main/java/org/sopt/at/data/model/SignInDto.kt b/app/src/main/java/org/sopt/at/data/model/SignInDto.kt new file mode 100644 index 0000000..e92f517 --- /dev/null +++ b/app/src/main/java/org/sopt/at/data/model/SignInDto.kt @@ -0,0 +1,18 @@ +package org.sopt.at.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SignInRequestDto( + @SerialName("loginId") + val loginId: String, + @SerialName("password") + val password: String, +) + +@Serializable +data class SignInResponseDto( + @SerialName("userId") + val userId: Int, +) \ No newline at end of file diff --git a/app/src/main/java/org/sopt/at/data/model/SignUpDto.kt b/app/src/main/java/org/sopt/at/data/model/SignUpDto.kt new file mode 100644 index 0000000..e088aa2 --- /dev/null +++ b/app/src/main/java/org/sopt/at/data/model/SignUpDto.kt @@ -0,0 +1,22 @@ +package org.sopt.at.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SignUpRequestDto( + @SerialName("loginId") + val loginId: String, + @SerialName("password") + val password: String, + @SerialName("nickname") + val nickname: String, +) + +@Serializable +data class SignUpResponseDto( + @SerialName("userId") + val userId: Int, + @SerialName("nickname") + val nickname: String, +) \ No newline at end of file diff --git a/app/src/main/java/org/sopt/at/data/model/UsersDto.kt b/app/src/main/java/org/sopt/at/data/model/UsersDto.kt new file mode 100644 index 0000000..3e0a3b5 --- /dev/null +++ b/app/src/main/java/org/sopt/at/data/model/UsersDto.kt @@ -0,0 +1,10 @@ +package org.sopt.at.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class UserNickNameResponseDto( + @SerialName("nickname") + val nickname: String, +) \ No newline at end of file diff --git a/app/src/main/java/org/sopt/at/data/service/ApiFactory.kt b/app/src/main/java/org/sopt/at/data/service/ApiFactory.kt new file mode 100644 index 0000000..85fbca4 --- /dev/null +++ b/app/src/main/java/org/sopt/at/data/service/ApiFactory.kt @@ -0,0 +1,40 @@ +package org.sopt.at.data.service + +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import org.sopt.at.BuildConfig +import retrofit2.Retrofit + +object ApiFactory { + private const val BASE_URL: String = BuildConfig.BASE_URL + + private val loggingInterceptor = HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY + } + + private val client = OkHttpClient.Builder() + .addInterceptor(loggingInterceptor) + .build() + + val retrofit: Retrofit by lazy { + Retrofit.Builder() + .baseUrl(BASE_URL) + .client(client) + .addConverterFactory(Json.asConverterFactory("application/json".toMediaType())) + .build() + } + + inline fun create(): T = retrofit.create(T::class.java) +} + +object ServicePool { + val authService: AuthService by lazy { + ApiFactory.create() + } + val userService: UserService by lazy { + ApiFactory.create() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/at/data/service/AuthService.kt b/app/src/main/java/org/sopt/at/data/service/AuthService.kt new file mode 100644 index 0000000..3e6039d --- /dev/null +++ b/app/src/main/java/org/sopt/at/data/service/AuthService.kt @@ -0,0 +1,24 @@ +package org.sopt.at.data.service + +import org.sopt.at.data.model.BaseResponseDto +import org.sopt.at.data.model.SignInRequestDto +import org.sopt.at.data.model.SignInResponseDto +import org.sopt.at.data.model.SignUpRequestDto +import org.sopt.at.data.model.SignUpResponseDto +import retrofit2.Call +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.POST + +interface AuthService { + @POST("/api/v1/auth/signup") + fun signup( + @Body request: SignUpRequestDto + ): Call> + + @POST("/api/v1/auth/signin") + fun signin( + @Body request: SignInRequestDto + ): Call> +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/at/data/service/UserService.kt b/app/src/main/java/org/sopt/at/data/service/UserService.kt new file mode 100644 index 0000000..ee58b30 --- /dev/null +++ b/app/src/main/java/org/sopt/at/data/service/UserService.kt @@ -0,0 +1,14 @@ +package org.sopt.at.data.service + +import org.sopt.at.data.model.BaseResponseDto +import org.sopt.at.data.model.UserNickNameResponseDto +import retrofit2.Call +import retrofit2.http.GET +import retrofit2.http.Header + +interface UserService { + @GET("/api/v1/users/me") + fun getUserInfo( + @Header("userId") userId: Int + ): Call> +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/at/navigation/MainNavItem.kt b/app/src/main/java/org/sopt/at/navigation/MainBottomNavItem.kt similarity index 69% rename from app/src/main/java/org/sopt/at/navigation/MainNavItem.kt rename to app/src/main/java/org/sopt/at/navigation/MainBottomNavItem.kt index c74837a..08545f5 100644 --- a/app/src/main/java/org/sopt/at/navigation/MainNavItem.kt +++ b/app/src/main/java/org/sopt/at/navigation/MainBottomNavItem.kt @@ -3,46 +3,41 @@ package org.sopt.at.navigation import androidx.annotation.DrawableRes import androidx.annotation.StringRes import org.sopt.at.R -import org.sopt.at.navigation.MainNavRoute.History -import org.sopt.at.navigation.MainNavRoute.Home -import org.sopt.at.navigation.MainNavRoute.Live -import org.sopt.at.navigation.MainNavRoute.Search -import org.sopt.at.navigation.MainNavRoute.Shorts -internal enum class MainNavItem( +internal enum class MainBottomNavItem( @DrawableRes val activeIcon: Int, @DrawableRes val inactiveIcon: Int, @StringRes val labelId: Int, - val screenRoute: MainNavRoute, + val route: MainBottomNavRoute, ) { HOME( activeIcon = R.drawable.ic_home_active, inactiveIcon = R.drawable.ic_home_inactive, labelId = R.string.home_title, - screenRoute = Home, + route = MainBottomNavRoute.Home, ), SHORTS( activeIcon = R.drawable.ic_shorts_active, inactiveIcon = R.drawable.ic_shorts_inactive, labelId = R.string.shorts_title, - screenRoute = Shorts, + route = MainBottomNavRoute.Shorts, ), LIVE( activeIcon = R.drawable.ic_live_active, inactiveIcon = R.drawable.ic_live_inactive, labelId = R.string.live_title, - screenRoute = Live, + route = MainBottomNavRoute.Live, ), SEARCH( activeIcon = R.drawable.ic_search_active, inactiveIcon = R.drawable.ic_search_inactive, labelId = R.string.search_title, - screenRoute = Search, + route = MainBottomNavRoute.Search, ), HISTORY( activeIcon = R.drawable.ic_history_active, inactiveIcon = R.drawable.ic_history_inactive, labelId = R.string.history_title, - screenRoute = History, + route = MainBottomNavRoute.History, ), } \ No newline at end of file diff --git a/app/src/main/java/org/sopt/at/navigation/MainNavHost.kt b/app/src/main/java/org/sopt/at/navigation/MainNavHost.kt index 8745912..f212abc 100644 --- a/app/src/main/java/org/sopt/at/navigation/MainNavHost.kt +++ b/app/src/main/java/org/sopt/at/navigation/MainNavHost.kt @@ -7,19 +7,11 @@ import androidx.compose.ui.Modifier import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable -import org.sopt.at.navigation.MainNavRoute.History -import org.sopt.at.navigation.MainNavRoute.Home -import org.sopt.at.navigation.MainNavRoute.Live -import org.sopt.at.navigation.MainNavRoute.My -import org.sopt.at.navigation.MainNavRoute.Search -import org.sopt.at.navigation.MainNavRoute.Shorts -import org.sopt.at.navigation.MainNavRoute.SignIn -import org.sopt.at.navigation.MainNavRoute.SignUp import org.sopt.at.ui.history.HistoryScreen import org.sopt.at.ui.home.HomeScreen import org.sopt.at.ui.live.LiveScreen -import org.sopt.at.ui.login.SignInScreen -import org.sopt.at.ui.login.SignUpScreen +import org.sopt.at.ui.login.signin.SignInScreen +import org.sopt.at.ui.login.signup.SignUpScreen import org.sopt.at.ui.my.MyScreen import org.sopt.at.ui.search.SearchScreen import org.sopt.at.ui.shorts.ShortsScreen @@ -29,34 +21,34 @@ import org.sopt.at.ui.shorts.ShortsScreen fun MainNavHost( navController: NavHostController, paddingValues: PaddingValues, - startDestination: String, + startDestination: Route, ) { NavHost( navController = navController, startDestination = startDestination, modifier = Modifier.padding(paddingValues), ) { - composable(Home.route) { + composable { HomeScreen( - navigateToMyScreen = { navController.navigateAddTo(My.route) } + navigateToMyScreen = { navController.navigateAddTo(Route.My) } ) } - composable(Shorts.route) { ShortsScreen() } - composable(Live.route) { LiveScreen() } - composable(Search.route) { SearchScreen() } - composable(History.route) { HistoryScreen() } - composable(My.route) { MyScreen() } - composable(SignIn.route) { + composable { ShortsScreen() } + composable { LiveScreen() } + composable { SearchScreen() } + composable { HistoryScreen() } + composable { MyScreen() } + composable { SignInScreen( - navigateToHomeScreen = { navController.navigateReplaceTo(route = Home.route) }, - navigateToSignUpScreen = { navController.navigateAddTo(SignUp.route) }, + navigateToHomeScreen = { navController.navigateReplaceTo(route = MainBottomNavRoute.Home) }, + navigateToSignUpScreen = { navController.navigateAddTo(Route.SignUp) }, navController = navController, ) } - composable(SignUp.route) { + composable { SignUpScreen( navigateToSignInScreen = { - navController.navigateReplaceTo(SignIn.route) + navController.navigateReplaceTo(Route.SignIn) navController.currentBackStackEntry?.savedStateHandle?.set( "signUpSuccess", true @@ -69,7 +61,7 @@ fun MainNavHost( } fun NavHostController.navigateAddTo( - route: String, + route: Route, ) { this.navigate(route) { popUpTo(route) { @@ -81,7 +73,7 @@ fun NavHostController.navigateAddTo( } fun NavHostController.navigateReplaceTo( - route: String, + route: Route, ) { val currentRoute = this.currentBackStackEntry?.destination?.route ?: return this.navigate(route) { diff --git a/app/src/main/java/org/sopt/at/navigation/MainNavRoute.kt b/app/src/main/java/org/sopt/at/navigation/MainNavRoute.kt index 63b14a1..b2de26b 100644 --- a/app/src/main/java/org/sopt/at/navigation/MainNavRoute.kt +++ b/app/src/main/java/org/sopt/at/navigation/MainNavRoute.kt @@ -2,47 +2,30 @@ package org.sopt.at.navigation import kotlinx.serialization.Serializable -sealed interface MainNavRoute { - val route: String - +sealed interface Route { @Serializable - data object Home : MainNavRoute{ - override val route = "home" - } + data object My : Route @Serializable - data object Shorts : MainNavRoute{ - override val route = "shorts" - } + data object SignIn : Route @Serializable - data object Live : MainNavRoute{ - override val route = "live" - } + data object SignUp : Route +} +sealed interface MainBottomNavRoute : Route { @Serializable - data object Search : MainNavRoute{ - override val route = "search" - } + data object Home : MainBottomNavRoute @Serializable - data object History : MainNavRoute{ - override val route = "history" - } + data object Shorts : MainBottomNavRoute @Serializable - data object My : MainNavRoute{ - override val route = "my" - } + data object Live : MainBottomNavRoute @Serializable - data object SignIn : MainNavRoute{ - override val route = "signin" - } + data object Search : MainBottomNavRoute @Serializable - data object SignUp : MainNavRoute{ - override val route = "signup" - } -} - + data object History : MainBottomNavRoute +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/at/navigation/MainNavigation.kt b/app/src/main/java/org/sopt/at/navigation/MainNavigation.kt index 5933549..e058f2c 100644 --- a/app/src/main/java/org/sopt/at/navigation/MainNavigation.kt +++ b/app/src/main/java/org/sopt/at/navigation/MainNavigation.kt @@ -33,18 +33,9 @@ fun MainNavigation( val navBackStackEntry by navController.currentBackStackEntryAsState() // 상태 val currentRoute = navBackStackEntry?.destination?.route - val items = - persistentListOf( - MainNavItem.HOME, - MainNavItem.SHORTS, - MainNavItem.LIVE, - MainNavItem.SEARCH, - MainNavItem.HISTORY, - ) - val showBottomBar by remember(currentRoute) { derivedStateOf { - items.any { it.screenRoute.route == currentRoute } + MainBottomNavItem.entries.any { (it.route::class.qualifiedName ?: "") == currentRoute } } } @@ -53,8 +44,8 @@ fun MainNavigation( modifier = modifier, containerColor = Color.Black, ) { - items.forEach { item -> - val selected = currentRoute == item.screenRoute.route + MainBottomNavItem.entries.forEach { item -> + val selected = currentRoute == (item.route::class.qualifiedName ?: "") NavigationBarItem( selected = selected, @@ -63,7 +54,7 @@ fun MainNavigation( indicatorColor = Color.Transparent, ), onClick = { - navController.navigate(item.screenRoute.route) { + navController.navigate(item.route) { popUpTo(navController.graph.findStartDestination().id) { saveState = true } diff --git a/app/src/main/java/org/sopt/at/ui/MainActivity.kt b/app/src/main/java/org/sopt/at/ui/MainActivity.kt index 9383450..a14875f 100644 --- a/app/src/main/java/org/sopt/at/ui/MainActivity.kt +++ b/app/src/main/java/org/sopt/at/ui/MainActivity.kt @@ -7,16 +7,13 @@ import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.background import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Scaffold -import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.tooling.preview.Preview import androidx.navigation.compose.rememberNavController +import org.sopt.at.navigation.MainBottomNavRoute import org.sopt.at.navigation.MainNavHost -import org.sopt.at.navigation.MainNavRoute -import org.sopt.at.navigation.MainNavRoute.Home -import org.sopt.at.navigation.MainNavRoute.SignIn import org.sopt.at.navigation.MainNavigation +import org.sopt.at.navigation.Route import org.sopt.at.ui.theme.TvingTheme import org.sopt.at.utils.SharedPreferencesManager @@ -28,8 +25,7 @@ class MainActivity : ComponentActivity() { SharedPreferencesManager.init(context = this) val isLoggedIn = SharedPreferencesManager.isLoggedIn() - val startDestination = - if (isLoggedIn) Home.route else SignIn.route + val startDestination = if (isLoggedIn) MainBottomNavRoute.Home else Route.SignIn val navController = rememberNavController() @@ -37,13 +33,11 @@ class MainActivity : ComponentActivity() { Scaffold( modifier = Modifier .fillMaxSize() - .background(Color.Black), - bottomBar = { - MainNavigation( - navController = navController, - ) - } - ) { innerPadding -> + .background(Color.Black), bottomBar = { + MainNavigation( + navController = navController, + ) + }) { innerPadding -> MainNavHost( navController = navController, paddingValues = innerPadding, diff --git a/app/src/main/java/org/sopt/at/ui/home/HomeScreen.kt b/app/src/main/java/org/sopt/at/ui/home/HomeScreen.kt index 058df6e..94c7091 100644 --- a/app/src/main/java/org/sopt/at/ui/home/HomeScreen.kt +++ b/app/src/main/java/org/sopt/at/ui/home/HomeScreen.kt @@ -104,4 +104,4 @@ fun HomeScreen( Spacer(modifier = Modifier.height(30.dp)) } } -} +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/at/ui/login/SignUpScreen.kt b/app/src/main/java/org/sopt/at/ui/login/SignUpScreen.kt deleted file mode 100644 index 6eefa75..0000000 --- a/app/src/main/java/org/sopt/at/ui/login/SignUpScreen.kt +++ /dev/null @@ -1,219 +0,0 @@ -package org.sopt.at.ui.login - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -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.shape.RoundedCornerShape -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -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.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.TextUnit -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import org.sopt.at.R -import org.sopt.at.ui.login.components.BasicTopBar -import org.sopt.at.ui.login.components.PasswordTextField -import org.sopt.at.ui.login.components.SignUpErrorDialog -import org.sopt.at.ui.login.components.TvingTextField -import org.sopt.at.ui.theme.TvingTheme -import org.sopt.at.utils.RegexUtils -import org.sopt.at.utils.RegexUtils.isValidPassword -import org.sopt.at.utils.SharedPreferencesManager - -@Composable -fun SignUpScreen( - navigateToSignInScreen: () -> Unit, -) { - - var step by remember { mutableIntStateOf(1) } - var id by remember { mutableStateOf("") } - - when (step) { - 1 -> { - SignUpID { - id = it - step = 2 - } - } - - 2 -> { - SignUpPassword { password -> - SharedPreferencesManager.registerUser(id, password) - navigateToSignInScreen() - } - } - } -} - -@Composable -fun SignUpID( - nextStep: (String) -> Unit, -) { - var inputId by remember { mutableStateOf("") } - var showDialog by remember { mutableStateOf(false) } - - Column( - modifier = Modifier - .fillMaxSize() - .background(color = Color.Black) - .padding(horizontal = 20.dp) - .padding(top = 20.dp), - ) { - BasicTopBar(modifier = Modifier.padding(vertical = 20.dp)) - - Text( - text = stringResource(R.string.sign_up_id_title), - fontSize = 20.sp, - color = TvingTheme.colors.gray400, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - fontWeight = FontWeight.SemiBold, - ) - Spacer(modifier = Modifier.padding(15.dp)) - TvingTextField( - value = inputId, - onValueChange = { inputId = it }, - hint = stringResource(R.string.tf_id), - ) - Spacer(modifier = Modifier.padding(5.dp)) - Text( - text = stringResource(R.string.sign_up_id_rule), - fontSize = 12.sp, - color = TvingTheme.colors.gray600, - ) - Spacer(modifier = Modifier.weight(1f)) - SignUpButton( - onClick = { - if (!RegexUtils.isValidId(inputId)) { - showDialog = true - } else { - nextStep(inputId) - } - }, - isFilled = inputId.isNotBlank(), - ) - Spacer(modifier = Modifier.height(50.dp)) - - if (showDialog) { - SignUpErrorDialog( - onDismissRequest = { showDialog = false }, - text = stringResource(R.string.error_invalid_id), - ) - } - } -} - -@Composable -fun SignUpPassword(nextStep: (String) -> Unit) { - var inputPassword by remember { mutableStateOf("") } - var showDialog by remember { mutableStateOf(false) } - - Column( - modifier = Modifier - .fillMaxSize() - .background(color = Color.Black) - .padding(horizontal = 20.dp) - .padding(top = 20.dp), - ) { - BasicTopBar(modifier = Modifier.padding(vertical = 20.dp)) - - Text( - text = stringResource(R.string.sign_up_password_title), - fontSize = 20.sp, - color = TvingTheme.colors.gray400, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - fontWeight = FontWeight.SemiBold, - ) - Spacer(modifier = Modifier.padding(15.dp)) - PasswordTextField( - value = inputPassword, - onValueChange = { inputPassword = it }, - ) - Spacer(modifier = Modifier.padding(5.dp)) - Text( - text = stringResource(R.string.sign_up_password_rule), - fontSize = 12.sp, - color = TvingTheme.colors.gray600, - ) - Spacer(modifier = Modifier.weight(1f)) - SignUpButton( - onClick = { - if (!isValidPassword(inputPassword)) { - showDialog = true - } else { - nextStep(inputPassword) - } - }, - isFilled = inputPassword.isNotBlank(), - ) - Spacer(modifier = Modifier.height(50.dp)) - - if (showDialog) { - SignUpErrorDialog( - onDismissRequest = { showDialog = false }, - text = stringResource(R.string.error_invalid_password), - ) - } - } -} - -@Composable -private fun SignUpButton( - height: Dp = 50.dp, - rounded: Dp = 4.dp, - fontSize: TextUnit = 14.sp, - onClick: () -> Unit = {}, - isFilled: Boolean, -) { - - OutlinedButton( - onClick = onClick, - enabled = isFilled, - modifier = Modifier - .fillMaxWidth() - .height(height), - border = BorderStroke( - width = 1.dp, - color = - if (isFilled) { - Color.Transparent - } else { - TvingTheme.colors.gray600 - } - ), - colors = ButtonDefaults.buttonColors( - containerColor = Color.White, - disabledContainerColor = Color.Black, - ), - shape = RoundedCornerShape(rounded) - ) { - Text( - text = stringResource(R.string.btn_sign_up_next), - color = if (isFilled) { - Color.Black - } else { - TvingTheme.colors.gray500 - }, - fontSize = fontSize, - fontWeight = FontWeight.SemiBold, - ) - } -} diff --git a/app/src/main/java/org/sopt/at/ui/login/components/BasicTopBar.kt b/app/src/main/java/org/sopt/at/ui/login/components/BasicTopBar.kt index d7f2c2b..ec7d612 100644 --- a/app/src/main/java/org/sopt/at/ui/login/components/BasicTopBar.kt +++ b/app/src/main/java/org/sopt/at/ui/login/components/BasicTopBar.kt @@ -8,7 +8,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.tooling.preview.Preview import org.sopt.at.R import org.sopt.at.ui.theme.TvingTheme diff --git a/app/src/main/java/org/sopt/at/ui/login/components/PasswordTextField.kt b/app/src/main/java/org/sopt/at/ui/login/components/PasswordTextField.kt index e423bae..a81d889 100644 --- a/app/src/main/java/org/sopt/at/ui/login/components/PasswordTextField.kt +++ b/app/src/main/java/org/sopt/at/ui/login/components/PasswordTextField.kt @@ -27,14 +27,6 @@ fun PasswordTextField( ) { var isShowPassword by remember { mutableStateOf(false) } - val passwordIconRes = - if (isShowPassword) { - R.drawable.ic_password_on - } else { - R.drawable.ic_password_off - } - - TvingTextField( value = value, onValueChange = onValueChange, @@ -48,7 +40,11 @@ fun PasswordTextField( ) { Icon( imageVector = ImageVector.vectorResource( - id = passwordIconRes + id = if (isShowPassword) { + R.drawable.ic_password_on + } else { + R.drawable.ic_password_off + } ), contentDescription = stringResource(R.string.btn_password_visibility), tint = TvingTheme.colors.gray600, diff --git a/app/src/main/java/org/sopt/at/ui/login/components/TvingTextField.kt b/app/src/main/java/org/sopt/at/ui/login/components/TvingTextField.kt index f3d79e8..8ab41f1 100644 --- a/app/src/main/java/org/sopt/at/ui/login/components/TvingTextField.kt +++ b/app/src/main/java/org/sopt/at/ui/login/components/TvingTextField.kt @@ -10,7 +10,6 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import org.sopt.at.ui.theme.TvingTheme diff --git a/app/src/main/java/org/sopt/at/ui/login/SignInScreen.kt b/app/src/main/java/org/sopt/at/ui/login/signin/SignInScreen.kt similarity index 85% rename from app/src/main/java/org/sopt/at/ui/login/SignInScreen.kt rename to app/src/main/java/org/sopt/at/ui/login/signin/SignInScreen.kt index c329f9f..b4258cd 100644 --- a/app/src/main/java/org/sopt/at/ui/login/SignInScreen.kt +++ b/app/src/main/java/org/sopt/at/ui/login/signin/SignInScreen.kt @@ -1,4 +1,4 @@ -package org.sopt.at.ui.login +package org.sopt.at.ui.login.signin import android.widget.Toast import androidx.compose.foundation.background @@ -10,6 +10,7 @@ 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.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button @@ -22,6 +23,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -42,6 +44,7 @@ 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 androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import androidx.navigation.compose.currentBackStackEntryAsState import kotlinx.coroutines.launch @@ -58,14 +61,16 @@ fun SignInScreen( navigateToSignUpScreen: () -> Unit, navController: NavController, modifier: Modifier = Modifier, + viewModel: SignInViewModel = viewModel(), ) { val context = LocalContext.current val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() - var userId by remember { mutableStateOf("") } - var password by remember { mutableStateOf("") } + val loginId by viewModel.loginId.collectAsState() + val password by viewModel.password.collectAsState() + val signInResult by viewModel.signInResult.collectAsState() val currentBackStackEntry by navController.currentBackStackEntryAsState() @@ -79,14 +84,31 @@ fun SignInScreen( context.getString(R.string.success_signup_message), Toast.LENGTH_SHORT ).show() - savedStateHandle.remove("signUpSuccess") + savedStateHandle?.remove("signUpSuccess") + } + } + + LaunchedEffect(signInResult) { + when (signInResult) { + is SignInResult.Success -> { + navigateToHomeScreen() + } + is SignInResult.Failure -> { + val errorMessage = (signInResult as SignInResult.Failure).message + snackbarHostState.showSnackbar( + message = errorMessage, + duration = SnackbarDuration.Short + ) + } + null -> {} } } Scaffold( modifier = modifier .fillMaxSize() - .background(color = Color.Black), + .background(color = Color.Black) + .imePadding(), snackbarHost = { SnackbarHost(hostState = snackbarHostState) } ) { Column( @@ -105,34 +127,19 @@ fun SignInScreen( ) Spacer(modifier = Modifier.height(30.dp)) TvingTextField( - value = userId, - onValueChange = { userId = it }, + value = loginId, + onValueChange = viewModel::updateLoginId, hint = stringResource(R.string.tf_id) ) Spacer(modifier = Modifier.height(15.dp)) PasswordTextField( value = password, - onValueChange = { password = it }, + onValueChange = viewModel::updatePassword, ) Spacer(modifier = Modifier.height(30.dp)) SignInButton( - userId, password, - onClick = { - val isSuccess = SharedPreferencesManager.login( - id = userId, - password = password - ) - if (isSuccess) { - navigateToHomeScreen() - } else { - scope.launch { - snackbarHostState.showSnackbar( - message = context.getString(R.string.signin_fail_message), - duration = SnackbarDuration.Short - ) - } - } - } + loginId, password, + onClick = viewModel::requestSignIn ) Spacer(modifier = Modifier.height(25.dp)) Row( diff --git a/app/src/main/java/org/sopt/at/ui/login/signin/SignInViewModel.kt b/app/src/main/java/org/sopt/at/ui/login/signin/SignInViewModel.kt new file mode 100644 index 0000000..3208351 --- /dev/null +++ b/app/src/main/java/org/sopt/at/ui/login/signin/SignInViewModel.kt @@ -0,0 +1,91 @@ +package org.sopt.at.ui.login.signin + +import android.util.Log +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.serialization.json.Json +import org.sopt.at.data.model.BaseResponseDto +import org.sopt.at.data.model.SignInRequestDto +import org.sopt.at.data.model.SignInResponseDto +import org.sopt.at.data.service.ServicePool +import org.sopt.at.data.service.ServicePool.authService +import org.sopt.at.utils.SharedPreferencesManager +import retrofit2.Callback + +class SignInViewModel : ViewModel() { + + private val authService by lazy { ServicePool.authService } + + private val _loginId = MutableStateFlow("") + val loginId: StateFlow = _loginId.asStateFlow() + + private val _password = MutableStateFlow("") + val password: StateFlow = _password.asStateFlow() + + private val _signInResult = MutableStateFlow(null) + val signInResult: StateFlow = _signInResult.asStateFlow() + + fun updateLoginId(id: String) { + _loginId.value = id + } + + fun updatePassword(password: String) { + _password.value = password + } + + fun requestSignIn() { + val request = SignInRequestDto( + loginId = _loginId.value, + password = _password.value + ) + + authService.signin( + request = request + ).enqueue(object : + Callback> { + override fun onResponse( + call: retrofit2.Call>, + response: retrofit2.Response>, + ) { + val code = response.code() + if (response.isSuccessful) { + val body: BaseResponseDto? = response.body() + val userId = body?.data?.userId + + SharedPreferencesManager.login( + id = _loginId.value, + password = _password.value + ) + SharedPreferencesManager.saveUserId( + id = userId ?: -1 + ) + + _signInResult.value = SignInResult.Success + + } else { + val errorMessage = try { + val errorBody = response.errorBody()?.string() + Json.decodeFromString>(errorBody ?: "").message + } catch (e: Exception) { + "알 수 없는 오류가 발생했습니다." + } + _signInResult.value = SignInResult.Failure(errorMessage) + } + } + + override fun onFailure( + call: retrofit2.Call>, + t: Throwable, + ) { + Log.d("SignInViewModel", "error: $t.message") + } + }) + } +} + +sealed class SignInResult { + data object Success : SignInResult() + data class Failure(val message: String) : SignInResult() +} diff --git a/app/src/main/java/org/sopt/at/ui/login/signup/SignUpScreen.kt b/app/src/main/java/org/sopt/at/ui/login/signup/SignUpScreen.kt new file mode 100644 index 0000000..dfb115f --- /dev/null +++ b/app/src/main/java/org/sopt/at/ui/login/signup/SignUpScreen.kt @@ -0,0 +1,91 @@ +package org.sopt.at.ui.login.signup + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.viewmodel.compose.viewModel +import org.sopt.at.R +import org.sopt.at.ui.login.signup.components.SignUpErrorDialog +import org.sopt.at.ui.login.signup.components.SignUpInputStep +import org.sopt.at.utils.SharedPreferencesManager + +@Composable +fun SignUpScreen( + navigateToSignInScreen: () -> Unit, + viewModel: SignUpViewModel = viewModel(), +) { + + val step by viewModel.step.collectAsState() + val loginId by viewModel.loginId.collectAsState() + val nickname by viewModel.nickname.collectAsState() + val password by viewModel.password.collectAsState() + + val isRuleError by viewModel.isRuleError.collectAsState() + val errorMessage by viewModel.errorMessage.collectAsState() + val isDialogShow by viewModel.isDialogShow.collectAsState() + + val signUpResult by viewModel.signUpResult.collectAsState() + + if (isDialogShow) { + SignUpErrorDialog( + text = errorMessage, + onDismissRequest = viewModel::dismissDialog + ) + } + + LaunchedEffect(signUpResult) { + when (signUpResult) { + is SignUpResult.Success -> { + SharedPreferencesManager.registerUser(loginId, password) + navigateToSignInScreen() + } + + is SignUpResult.Failure -> { + viewModel.showDialog() + } + + null -> Unit + } + } + + when (step) { + SignUpStep.ID -> { + SignUpInputStep( + title = stringResource(R.string.sign_up_id_title), + hint = stringResource(R.string.tf_id), + value = loginId, + onValueChange = viewModel::updateLoginId, + errorMessage = stringResource(R.string.sign_up_id_rule), + isError = isRuleError, + onNextClick = viewModel::nextStep, + ) + } + + SignUpStep.NICKNAME -> { + SignUpInputStep( + title = stringResource(R.string.sign_up_nickname_title), + hint = stringResource(R.string.tf_nickname), + value = nickname, + onValueChange = viewModel::updateNickname, + errorMessage = stringResource(R.string.sign_up_nickname_rule), + isError = isRuleError, + onNextClick = viewModel::nextStep, + ) + } + + SignUpStep.PASSWORD -> { + SignUpInputStep( + title = stringResource(R.string.sign_up_password_title), + hint = stringResource(R.string.tf_password), + value = password, + onValueChange = viewModel::updatePassword, + errorMessage = stringResource(R.string.sign_up_password_rule), + isError = isRuleError, + onNextClick = viewModel::nextStep, + isPassword = true, + ) + } + } +} diff --git a/app/src/main/java/org/sopt/at/ui/login/signup/SignUpViewModel.kt b/app/src/main/java/org/sopt/at/ui/login/signup/SignUpViewModel.kt new file mode 100644 index 0000000..839e814 --- /dev/null +++ b/app/src/main/java/org/sopt/at/ui/login/signup/SignUpViewModel.kt @@ -0,0 +1,162 @@ +package org.sopt.at.ui.login.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 kotlinx.serialization.json.Json +import org.sopt.at.data.model.BaseResponseDto +import org.sopt.at.data.model.SignUpRequestDto +import org.sopt.at.data.model.SignUpResponseDto +import org.sopt.at.data.service.ServicePool +import org.sopt.at.utils.RegexUtils.isValidId +import org.sopt.at.utils.RegexUtils.isValidNickname +import org.sopt.at.utils.RegexUtils.isValidPassword +import retrofit2.Callback + +enum class SignUpStep { + ID, NICKNAME, PASSWORD +} + +class SignUpViewModel : ViewModel() { + + private val authService by lazy { ServicePool.authService } + + private val _step = MutableStateFlow(SignUpStep.ID) + val step: StateFlow = _step.asStateFlow() + + private val _loginId = MutableStateFlow("") + val loginId: StateFlow = _loginId.asStateFlow() + + private val _nickname = MutableStateFlow("") + val nickname: StateFlow = _nickname.asStateFlow() + + private val _password = MutableStateFlow("") + val password: StateFlow = _password.asStateFlow() + + private val _errorMessage = MutableStateFlow("") + val errorMessage: StateFlow = _errorMessage.asStateFlow() + + private val _isRuleError = MutableStateFlow(false) + val isRuleError: StateFlow = _isRuleError.asStateFlow() + + private val _signUpResult = MutableStateFlow(null) + val signUpResult: StateFlow = _signUpResult.asStateFlow() + + private val _isDialogShow = MutableStateFlow(false) + val isDialogShow: StateFlow = _isDialogShow.asStateFlow() + + fun showDialog() { + _isDialogShow.value = true + } + + fun dismissDialog() { + _isDialogShow.value = false + resetForm() + } + + fun updateLoginId(newId: String) { + _loginId.value = newId + } + + fun updateNickname(newNickname: String) { + _nickname.value = newNickname + } + + fun updatePassword(newPassword: String) { + _password.value = newPassword + } + + private fun setErrorMessage(message: String) { + _errorMessage.value = message + } + + private fun setIsRuleError(isError: Boolean) { + _isRuleError.value = isError + } + + private fun resetForm() { + _loginId.value = "" + _nickname.value = "" + _password.value = "" + _step.value = SignUpStep.ID + _isRuleError.value = false + _errorMessage.value = "" + } + + + private fun requestSignUp() { + val request = SignUpRequestDto( + loginId = _loginId.value, + nickname = _nickname.value, + password = _password.value + ) + + authService.signup(request).enqueue(object : + Callback> { + override fun onResponse( + call: retrofit2.Call>, + response: retrofit2.Response>, + ) { + if (response.isSuccessful) { + _signUpResult.value = SignUpResult.Success + } else { + val errorMessage = try { + val errorBody = response.errorBody()?.string() + Json.decodeFromString>(errorBody ?: "").message + } catch (e: Exception) { + "알 수 없는 오류가 발생했습니다." + } + setErrorMessage(errorMessage) + _signUpResult.value = SignUpResult.Failure + } + } + + override fun onFailure( + call: retrofit2.Call>, + t: Throwable, + ) { + Log.d("SignUpViewModel", "error: $t.message") + } + }) + } + + fun nextStep( + onSuccess: () -> Unit = {}, + ) { + when (_step.value) { + SignUpStep.ID -> { + if (!isValidId(_loginId.value)) { + setIsRuleError(true) + return + } + setIsRuleError(false) + _step.value = SignUpStep.NICKNAME + } + + SignUpStep.NICKNAME -> { + if (!isValidNickname(_nickname.value)) { + setIsRuleError(true) + return + } + setIsRuleError(false) + _step.value = SignUpStep.PASSWORD + } + + SignUpStep.PASSWORD -> { + if (!isValidPassword(_password.value)) { + setIsRuleError(true) + return + } + setIsRuleError(false) + requestSignUp() + } + } + } +} + +sealed class SignUpResult { + data object Success : SignUpResult() + data object Failure : SignUpResult() +} diff --git a/app/src/main/java/org/sopt/at/ui/login/signup/components/SignUpButton.kt b/app/src/main/java/org/sopt/at/ui/login/signup/components/SignUpButton.kt new file mode 100644 index 0000000..b0cb40e --- /dev/null +++ b/app/src/main/java/org/sopt/at/ui/login/signup/components/SignUpButton.kt @@ -0,0 +1,63 @@ +package org.sopt.at.ui.login.signup.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +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 org.sopt.at.R +import org.sopt.at.ui.theme.TvingTheme + +@Composable +fun SignUpButton( + height: Dp = 50.dp, + rounded: Dp = 4.dp, + fontSize: TextUnit = 14.sp, + onClick: () -> Unit = {}, + enabled: Boolean = false, +) { + + OutlinedButton( + onClick = onClick, + enabled = enabled, + modifier = Modifier + .fillMaxWidth() + .height(height), + border = BorderStroke( + width = 1.dp, + color = + if (enabled) { + Color.Transparent + } else { + TvingTheme.colors.gray600 + } + ), + colors = ButtonDefaults.buttonColors( + containerColor = Color.White, + disabledContainerColor = Color.Black, + ), + shape = RoundedCornerShape(rounded) + ) { + Text( + text = stringResource(R.string.btn_sign_up_next), + color = if (enabled) { + Color.Black + } else { + TvingTheme.colors.gray500 + }, + fontSize = fontSize, + fontWeight = FontWeight.SemiBold, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/at/ui/login/components/SignUpErrorDialog.kt b/app/src/main/java/org/sopt/at/ui/login/signup/components/SignUpErrorDialog.kt similarity index 88% rename from app/src/main/java/org/sopt/at/ui/login/components/SignUpErrorDialog.kt rename to app/src/main/java/org/sopt/at/ui/login/signup/components/SignUpErrorDialog.kt index ad8ac4e..b390bc0 100644 --- a/app/src/main/java/org/sopt/at/ui/login/components/SignUpErrorDialog.kt +++ b/app/src/main/java/org/sopt/at/ui/login/signup/components/SignUpErrorDialog.kt @@ -1,4 +1,4 @@ -package org.sopt.at.ui.login.components +package org.sopt.at.ui.login.signup.components import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column @@ -16,7 +16,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource 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 androidx.compose.ui.window.Dialog @@ -73,16 +72,4 @@ fun SignUpErrorDialog( } } } - -} - -@Preview(showBackground = true) -@Composable -private fun Preview() { - TvingTheme { - SignUpErrorDialog( - onDismissRequest = {}, - text = stringResource(R.string.error_invalid_id), - ) - } } \ No newline at end of file diff --git a/app/src/main/java/org/sopt/at/ui/login/signup/components/SignUpInputStep.kt b/app/src/main/java/org/sopt/at/ui/login/signup/components/SignUpInputStep.kt new file mode 100644 index 0000000..31b15f0 --- /dev/null +++ b/app/src/main/java/org/sopt/at/ui/login/signup/components/SignUpInputStep.kt @@ -0,0 +1,107 @@ +package org.sopt.at.ui.login.signup.components + +import androidx.compose.foundation.background +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.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +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 org.sopt.at.R +import org.sopt.at.ui.login.components.BasicTopBar +import org.sopt.at.ui.login.components.PasswordTextField +import org.sopt.at.ui.login.components.TvingTextField +import org.sopt.at.ui.theme.TvingTheme + +@Composable +fun SignUpInputStep( + title: String, + hint: String, + value: String, + onValueChange: (String) -> Unit, + errorMessage: String, + isError: Boolean, + onNextClick: () -> Unit, + modifier: Modifier = Modifier, + isPassword: Boolean = false, + isLoading: Boolean = false, + isButtonEnabled: Boolean = value.isNotBlank(), +) { + Column( + modifier = modifier + .fillMaxSize() + .background(color = Color.Black) + .padding(horizontal = 20.dp) + .padding(top = 20.dp), + ) { + BasicTopBar(modifier = Modifier.padding(vertical = 20.dp)) + + Text( + text = title, + fontSize = 20.sp, + color = TvingTheme.colors.gray400, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + fontWeight = FontWeight.SemiBold, + ) + Spacer(modifier = Modifier.padding(15.dp)) + + if (isPassword) { + PasswordTextField( + value = value, + onValueChange = onValueChange, + ) + } else { + TvingTextField( + value = value, + onValueChange = onValueChange, + hint = hint, + ) + } + + Spacer(modifier = Modifier.padding(5.dp)) + + Text( + text = errorMessage, + fontSize = 12.sp, + color = if (isError) TvingTheme.colors.redA400 else TvingTheme.colors.gray600, + ) + + Spacer(modifier = Modifier.weight(1f)) + + SignUpButton( + onClick = onNextClick, + enabled = isButtonEnabled + ) + + Spacer(modifier = Modifier.height(50.dp)) + + } +} + +@Preview(showBackground = true) +@Composable +private fun Preview() { + TvingTheme { + SignUpInputStep( + title = stringResource(R.string.sign_up_id_title), + hint = stringResource(R.string.tf_id), + value = "", + onValueChange = {}, + errorMessage = "아이디가 중복되었습니다.", + isError = true, + onNextClick = {}, + ) + } + +} \ No newline at end of file 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 31538c1..a53bca1 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 @@ -3,51 +3,97 @@ package org.sopt.at.ui.my import android.content.Intent import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement 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.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel import org.sopt.at.R import org.sopt.at.ui.MainActivity +import org.sopt.at.ui.common.StableImage +import org.sopt.at.ui.login.components.BasicTopBar import org.sopt.at.ui.theme.TvingTheme import org.sopt.at.utils.SharedPreferencesManager @Composable fun MyScreen( modifier: Modifier = Modifier, - id: String = SharedPreferencesManager.getUserId() ?: "", + viewModel: MyViewModel = viewModel(), ) { val context = LocalContext.current + val nickname by viewModel.nickname.collectAsState() + + LaunchedEffect(Unit) { + viewModel.getUserNickName() + } + Column( modifier = modifier .fillMaxSize() .background(Color.Black) .padding(horizontal = 20.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.SpaceEvenly, ) { - Text( - text = "ID: $id", - fontSize = 24.sp, - color = Color.White, - fontWeight = FontWeight.SemiBold + BasicTopBar(modifier = Modifier.padding(vertical = 20.dp)) + Spacer(modifier = Modifier.height(40.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically + ) { + StableImage( + drawableResId = R.drawable.ic_profile_default, + contentDescription = stringResource(R.string.my_profile_image), + modifier = Modifier + .size(74.dp) + .clip(RoundedCornerShape(2.dp)), + contentScale = ContentScale.Crop, + ) + Spacer(modifier = Modifier.height(20.dp)) + Text( + text = nickname, + fontSize = 18.sp, + color = Color.White, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.height(10.dp)) + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_my_edit), + contentDescription = stringResource(R.string.my_edit_profile), + modifier = Modifier + .size(24.dp) + ) + } + + + Spacer( + modifier = Modifier + .weight(1f) ) OutlinedButton( onClick = { @@ -76,6 +122,18 @@ fun MyScreen( fontWeight = FontWeight.SemiBold, ) } + Spacer( + modifier = Modifier + .height(100.dp) + ) } } + +@Preview(showBackground = true) +@Composable +private fun Preview() { + TvingTheme { + MyScreen() + } +} 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 new file mode 100644 index 0000000..819d21a --- /dev/null +++ b/app/src/main/java/org/sopt/at/ui/my/MyViewModel.kt @@ -0,0 +1,59 @@ +package org.sopt.at.ui.my + +import android.util.Log +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.serialization.json.Json +import org.sopt.at.data.model.BaseResponseDto +import org.sopt.at.data.model.SignUpRequestDto +import org.sopt.at.data.model.SignUpResponseDto +import org.sopt.at.data.model.UserNickNameResponseDto +import org.sopt.at.data.service.ServicePool +import org.sopt.at.data.service.ServicePool.authService +import org.sopt.at.ui.login.signup.SignUpResult +import org.sopt.at.utils.SharedPreferencesManager +import retrofit2.Callback + +class MyViewModel : ViewModel() { + + private val userService by lazy { ServicePool.userService } + + private val _nickname = MutableStateFlow("") + val nickname: StateFlow = _nickname.asStateFlow() + + + fun getUserNickName() { + val userId = SharedPreferencesManager.getUserId() + if (userId == -1) return + + userService.getUserInfo(userId).enqueue(object : + Callback> { + override fun onResponse( + call: retrofit2.Call>, + response: retrofit2.Response>, + ) { + if (response.isSuccessful) { + val body: BaseResponseDto? = response.body() + _nickname.value = body?.data?.nickname.toString() + } else { + val errorMessage = try { + val errorBody = response.errorBody()?.string() + Json.decodeFromString>(errorBody ?: "").message + } catch (e: Exception) { + "알 수 없는 오류가 발생했습니다." + } + } + } + + override fun onFailure( + call: retrofit2.Call>, + t: Throwable, + ) { + Log.d("MyViewModel", "error: $t.message") + } + }) + } + +} diff --git a/app/src/main/java/org/sopt/at/utils/RegexUtils.kt b/app/src/main/java/org/sopt/at/utils/RegexUtils.kt index cf7d893..76564e9 100644 --- a/app/src/main/java/org/sopt/at/utils/RegexUtils.kt +++ b/app/src/main/java/org/sopt/at/utils/RegexUtils.kt @@ -2,10 +2,12 @@ package org.sopt.at.utils object RegexUtils { - private val idRegex = Regex("^[a-z0-9]{6,12}$") - private val passwordRegex = Regex("^(?=.*[a-zA-Z])(?=.*[0-9])(?=.*[~!@#$%^&*])[a-zA-Z0-9~!@#$%^&*]{8,15}$") + private val idRegex = Regex("^[a-zA-Z0-9]{8,20}$") + private val passwordRegex = Regex("^[A-Za-z\\d]{8,20}$") + private val nicknameRegex = Regex("^[가-힣a-zA-Z0-9]{1,20}$") fun isValidId(id: String): Boolean = idRegex.matches(id) fun isValidPassword(password: String): Boolean = passwordRegex.matches(password) + fun isValidNickname(nickname: String): Boolean = nicknameRegex.matches(nickname) -} \ No newline at end of file +} diff --git a/app/src/main/java/org/sopt/at/utils/SharedPreferencesManager.kt b/app/src/main/java/org/sopt/at/utils/SharedPreferencesManager.kt index 35460fe..cdb6f6c 100644 --- a/app/src/main/java/org/sopt/at/utils/SharedPreferencesManager.kt +++ b/app/src/main/java/org/sopt/at/utils/SharedPreferencesManager.kt @@ -8,7 +8,8 @@ object SharedPreferencesManager { private const val PREF_NAME = "user_prefs" private const val KEY_USER_ID = "user_id" - private const val KEY_USER_PW = "user_pw" + private const val KEY_LOGIN_ID = "login_id" + private const val KEY_LOGIN_PW = "login_pw" private const val KEY_IS_LOGGED_IN = "is_logged_in" fun init(context: Context) { @@ -17,15 +18,15 @@ object SharedPreferencesManager { fun registerUser(id: String, password: String) { with(prefs.edit()) { - putString(KEY_USER_ID, id) - putString(KEY_USER_PW, password) + putString(KEY_LOGIN_ID, id) + putString(KEY_LOGIN_PW, password) apply() } } fun login(id: String, password: String): Boolean { - val savedId = prefs.getString(KEY_USER_ID, null) - val savedPw = prefs.getString(KEY_USER_PW, null) + val savedId = prefs.getString(KEY_LOGIN_ID, null) + val savedPw = prefs.getString(KEY_LOGIN_PW, null) return if (id == savedId && password == savedPw) { with(prefs.edit()) { @@ -38,6 +39,13 @@ object SharedPreferencesManager { } } + fun saveUserId(id: Int) { + with(prefs.edit()) { + putInt(KEY_USER_ID, id) + apply() + } + } + fun isLoggedIn(): Boolean { return prefs.getBoolean(KEY_IS_LOGGED_IN, false) } @@ -49,6 +57,5 @@ object SharedPreferencesManager { } } - fun getUserId(): String? = prefs.getString(KEY_USER_ID, null) - fun getUserPw(): String? = prefs.getString(KEY_USER_PW, null) + fun getUserId(): Int = prefs.getInt(KEY_USER_ID, -1) } diff --git a/app/src/main/res/drawable/ic_my_edit.xml b/app/src/main/res/drawable/ic_my_edit.xml new file mode 100644 index 0000000..57a1044 --- /dev/null +++ b/app/src/main/res/drawable/ic_my_edit.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e03cf3b..ae59400 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -14,15 +14,15 @@ SignUpActivity + 아이디를 입력해주세요. - 영문 소문자 또는 영문 소문자, 숫자 조합 6~12자리 - 아이디 형식이 올바르지 않습니다.영문 소문자 또는 영문 소문자, 숫자 조합 6~12자리로 입력해주세요. - 비밀번호를 입력해주세요. - 영문, 숫자, 특수문자(~!@#$%^&*)조합 8~15자리 - 비밀번호 형식이 올바르지 않습니다. - 영문, 숫자, 특수문자(~!@#$%^&*)조합 8~15자리로 입력해주세요. - + 닉네임을 입력해주세요. + + 영문 대문자 또는 영문 소문자, 숫자 조합 8~20자리 + 영문 대문자, 소문자, 숫자 조합 8~20자리 + 한글, 영어, 숫자 조합 1~20자리 + 확인 @@ -70,10 +70,13 @@ MyActivity + 프로필 이미지 + 프로필 수정하기" 아이디 비밀번호 + 닉네임 로그인하기 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2ac60b6..a270d4d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,10 +9,13 @@ kotlinxCollectionsImmutable = "0.3.8" lifecycleRuntimeKtx = "2.8.7" activityCompose = "1.10.1" composeBom = "2024.09.00" - -## Compose Navigation +# Compose Navigation androidxComposeNavigation = "2.8.9" -kotlinxSerializationJson = "1.8.1" +# Networking +retrofit = "2.11.0" +retrofit-kotlinx-serialization-json = "1.0.0" +okhttp = "4.12.0" +kotlinx-serialization-json = "1.7.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -29,10 +32,14 @@ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-toolin androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" } - +# Compose Navigation androidx-compose-navigation = { group = "androidx.navigation", name = "navigation-compose", version.ref = "androidxComposeNavigation" } kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" } -kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } +# Network +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" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } @@ -40,4 +47,3 @@ 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" } -