diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4145f7e..e585606 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -77,6 +77,8 @@ dependencies { // Accompanist implementation(libs.accompanist.pager) implementation(libs.accompanist.pager.indicators) + implementation(libs.androidx.datastore.core.android) + implementation(libs.androidx.datastore.preferences.core.jvm) // Testing testImplementation(libs.junit) @@ -94,4 +96,8 @@ dependencies { implementation(libs.retrofit) implementation(libs.retrofit.kotlin.serialization.converter) implementation(libs.kotlinx.serialization.json) + + // DataStore + implementation("androidx.datastore:datastore-preferences:1.0.0") + implementation("androidx.datastore:datastore-preferences-core:1.0.0") } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 01f6134..db16463 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -13,6 +13,7 @@ android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.ANDANDROID" + android:usesCleartextTraffic="true" tools:targetApi="31"> + preferences[tokenKey] = token + } + } + + fun getToken(): Flow = context.dataStore.data.map { preferences -> + preferences[tokenKey] + } + + suspend fun clearToken() { + context.dataStore.edit { preferences -> + preferences.remove(tokenKey) + } + } + + companion object { + @Volatile + private var instance: AuthLocalDataSource? = null + + fun getInstance(context: Context): AuthLocalDataSource { + return instance ?: synchronized(this) { + instance ?: AuthLocalDataSource(context.applicationContext).also { + instance = it + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/data/model/request/AuthRequest.kt b/app/src/main/java/org/sopt/and/data/model/request/AuthRequest.kt new file mode 100644 index 0000000..61c4ce8 --- /dev/null +++ b/app/src/main/java/org/sopt/and/data/model/request/AuthRequest.kt @@ -0,0 +1,22 @@ +package org.sopt.and.data.model.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SignUpRequest( + @SerialName("username") + val username: String, + @SerialName("password") + val password: String, + @SerialName("hobby") + val hobby: String +) + +@Serializable +data class SignInRequest( + @SerialName("username") + val username: String, + @SerialName("password") + val password: String +) \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/data/model/response/AuthResponse.kt b/app/src/main/java/org/sopt/and/data/model/response/AuthResponse.kt new file mode 100644 index 0000000..357c124 --- /dev/null +++ b/app/src/main/java/org/sopt/and/data/model/response/AuthResponse.kt @@ -0,0 +1,30 @@ +package org.sopt.and.data.model.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class BaseResponse( + @SerialName("result") + val result: T? = null, + @SerialName("code") + val code: String? = null +) + +@Serializable +data class SignUpResponse( + @SerialName("no") + val no: Int +) + +@Serializable +data class SignInResponse( + @SerialName("token") + val token: String +) + +@Serializable +data class HobbyResponse( + @SerialName("hobby") + val hobby: String +) \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/data/service/AuthService.kt b/app/src/main/java/org/sopt/and/data/service/AuthService.kt new file mode 100644 index 0000000..f0b437c --- /dev/null +++ b/app/src/main/java/org/sopt/and/data/service/AuthService.kt @@ -0,0 +1,30 @@ +package org.sopt.and.data.service + +import org.sopt.and.data.model.request.SignInRequest +import org.sopt.and.data.model.request.SignUpRequest +import org.sopt.and.data.model.response.BaseResponse +import org.sopt.and.data.model.response.HobbyResponse +import org.sopt.and.data.model.response.SignInResponse +import org.sopt.and.data.model.response.SignUpResponse +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.POST + +interface AuthService { + @POST("user") + suspend fun signUp( + @Body request: SignUpRequest + ): Response> + + @POST("login") + suspend fun signIn( + @Body request: SignInRequest + ): Response> + + @GET("user/my-hobby") + suspend fun getMyHobby( + @Header("token") token: String + ): Response> +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/domain/User.kt b/app/src/main/java/org/sopt/and/domain/User.kt index f6527c9..59fe431 100644 --- a/app/src/main/java/org/sopt/and/domain/User.kt +++ b/app/src/main/java/org/sopt/and/domain/User.kt @@ -1,6 +1,7 @@ package org.sopt.and.domain data class User( - var email: String = "", - var password: String = "" + val username: String = "", + val password: String = "", + val hobby: String = "" // hobby 필드 추가 ) \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/component/TextFeild.kt b/app/src/main/java/org/sopt/and/presentation/component/PasswordInputField.kt similarity index 63% rename from app/src/main/java/org/sopt/and/presentation/component/TextFeild.kt rename to app/src/main/java/org/sopt/and/presentation/component/PasswordInputField.kt index 674d05d..8bcc102 100644 --- a/app/src/main/java/org/sopt/and/presentation/component/TextFeild.kt +++ b/app/src/main/java/org/sopt/and/presentation/component/PasswordInputField.kt @@ -19,59 +19,6 @@ import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -@Composable -fun EmailInputField( - value: String, - placeholder: String, - onValueChange: (String) -> Unit, - modifier: Modifier = Modifier, - isError: Boolean = false, - errorMessage: String? = null -) { - val containerColor by animateColorAsState( - targetValue = if (isError) Color.DarkGray.copy(red = 0.4f) else Color.DarkGray, - label = "containerColor" - ) - - Column { - TextField( - value = value, - onValueChange = onValueChange, - modifier = modifier - .fillMaxWidth() - .height(60.dp), - placeholder = { - Text( - text = placeholder, - color = Color.Gray - ) - }, - colors = TextFieldDefaults.colors( - focusedContainerColor = containerColor, - unfocusedContainerColor = containerColor, - focusedTextColor = Color.White, - unfocusedTextColor = Color.White, - focusedIndicatorColor = if (isError) Color.Red.copy(alpha = 0.5f) else Color.Transparent, - unfocusedIndicatorColor = if (isError) Color.Red.copy(alpha = 0.5f) else Color.Transparent, - errorContainerColor = containerColor, - errorIndicatorColor = Color.Red.copy(alpha = 0.5f) - ), - shape = RoundedCornerShape(5.dp), - singleLine = true, - isError = isError - ) - - if (isError && errorMessage != null) { - Text( - text = errorMessage, - color = Color.Red.copy(alpha = 0.8f), - fontSize = 12.sp, - modifier = Modifier.padding(start = 4.dp, top = 4.dp) - ) - } - } -} - @Composable fun PasswordInputField( value: String, diff --git a/app/src/main/java/org/sopt/and/presentation/component/TextInputField.kt b/app/src/main/java/org/sopt/and/presentation/component/TextInputField.kt new file mode 100644 index 0000000..192fbb6 --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/component/TextInputField.kt @@ -0,0 +1,55 @@ +// presentation/component/TextInputField.kt +package org.sopt.and.presentation.component + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +@Composable +fun TextInputField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + placeholder: String = "", + isError: Boolean = false, + maxLength: Int = 8, + singleLine: Boolean = true +) { + TextField( + value = value, + onValueChange = { + if (it.length <= maxLength) { + onValueChange(it) + } + }, + modifier = modifier + .fillMaxWidth() + .height(60.dp), + placeholder = { + Text( + text = placeholder, + color = Color.Gray + ) + }, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.DarkGray, + unfocusedContainerColor = Color.DarkGray, + focusedTextColor = Color.White, + unfocusedTextColor = Color.White, + focusedIndicatorColor = if (isError) Color.Red.copy(alpha = 0.5f) else Color.Transparent, + unfocusedIndicatorColor = if (isError) Color.Red.copy(alpha = 0.5f) else Color.Transparent, + errorContainerColor = Color.DarkGray, + errorIndicatorColor = Color.Red.copy(alpha = 0.5f) + ), + shape = RoundedCornerShape(5.dp), + singleLine = singleLine, + isError = isError + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/main/MainScreen.kt b/app/src/main/java/org/sopt/and/presentation/main/MainScreen.kt index 017189c..b5ae9db 100644 --- a/app/src/main/java/org/sopt/and/presentation/main/MainScreen.kt +++ b/app/src/main/java/org/sopt/and/presentation/main/MainScreen.kt @@ -1,6 +1,5 @@ package org.sopt.and.presentation.main -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable @@ -17,26 +16,21 @@ import org.sopt.and.presentation.navigation.NavGraph @Composable fun MainScreen() { val navController = rememberNavController() - var bottomNaviVisible by remember { mutableStateOf(false) } - var userEmail by remember { mutableStateOf("") } + var isLoggedIn by remember { mutableStateOf(false) } Scaffold( bottomBar = { - if (bottomNaviVisible) { + if (isLoggedIn) { BottomNavigationBar( - navController = navController, - userEmail = userEmail + navController = navController ) } } - ) { innerPadding: PaddingValues -> + ) { innerPadding -> NavGraph( navController = navController, - isLogined = { isLogined -> - bottomNaviVisible = isLogined - }, - onEmailUpdated = { email -> - userEmail = email + isLoggedIn = { loggedIn -> + isLoggedIn = loggedIn }, modifier = Modifier.padding(innerPadding) ) diff --git a/app/src/main/java/org/sopt/and/presentation/mypage/MyPageScreen.kt b/app/src/main/java/org/sopt/and/presentation/mypage/MyPageScreen.kt index 5176df4..4940db4 100644 --- a/app/src/main/java/org/sopt/and/presentation/mypage/MyPageScreen.kt +++ b/app/src/main/java/org/sopt/and/presentation/mypage/MyPageScreen.kt @@ -5,14 +5,21 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import org.sopt.and.data.local.AuthLocalDataSource import org.sopt.and.presentation.mypage.component.EmptyInfoIcon import org.sopt.and.presentation.mypage.component.MyMenuSection import org.sopt.and.presentation.mypage.component.MyPageTopBar @@ -20,20 +27,31 @@ import org.sopt.and.presentation.mypage.component.PurchaseText @Composable fun MyPageScreen( - myPageViewModel: MyPageViewModel = viewModel(), - modifier: Modifier = Modifier, - email: String + myPageViewModel: MyPageViewModel = viewModel( + factory = MyPageViewModel.provideFactory( + authLocalDataSource = AuthLocalDataSource.getInstance(LocalContext.current) + ) + ), + modifier: Modifier = Modifier ) { val uiState by myPageViewModel.uiState.collectAsState() + val snackBarHostState = remember { SnackbarHostState() } - myPageViewModel.updateEmail(email) + LaunchedEffect(uiState.errorMessage) { + uiState.errorMessage?.let { message -> + snackBarHostState.showSnackbar(message) + } + } Column( modifier = Modifier .fillMaxSize() .background(Color.Black) ) { - MyPageTopBar(email = uiState.email) + MyPageTopBar( + hobby = uiState.hobby, + isLoading = uiState.isLoading + ) Spacer(modifier = Modifier.height(1.dp)) @@ -61,10 +79,15 @@ fun MyPageScreen( EmptyInfoIcon(message = "관심 프로그램이 없어요.") } + + SnackbarHost( + hostState = snackBarHostState, + modifier = Modifier.padding(16.dp) + ) } @Preview(showBackground = true) @Composable private fun MyPageScreenPreview() { - MyPageScreen(email = "wavve@example.com") + MyPageScreen() } \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/mypage/MyPageViewModel.kt b/app/src/main/java/org/sopt/and/presentation/mypage/MyPageViewModel.kt index df272b5..61ff6fd 100644 --- a/app/src/main/java/org/sopt/and/presentation/mypage/MyPageViewModel.kt +++ b/app/src/main/java/org/sopt/and/presentation/mypage/MyPageViewModel.kt @@ -1,22 +1,111 @@ package org.sopt.and.presentation.mypage import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.sopt.and.data.ServicePool +import org.sopt.and.data.local.AuthLocalDataSource -data class MyPageUiState( - val email: String = "", - val hasTicket: Boolean = false, - val hasWatchHistory: Boolean = false, - val hasInterestProgram: Boolean = false -) - -class MyPageViewModel : ViewModel() { +class MyPageViewModel( + private val authLocalDataSource: AuthLocalDataSource +) : ViewModel() { private val _uiState = MutableStateFlow(MyPageUiState()) val uiState: StateFlow = _uiState.asStateFlow() - fun updateEmail(email: String) { - _uiState.value = _uiState.value.copy(email = email) + data class MyPageUiState( + val hobby: String = "", + val isLoading: Boolean = false, + val errorMessage: String? = null + ) + + init { + fetchMyHobby() + } + + fun fetchMyHobby() { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + try { + authLocalDataSource.getToken().collect { token -> + if (token != null) { + try { + val response = ServicePool.authService.getMyHobby(token) + when { + response.isSuccessful && response.body()?.result != null -> { + _uiState.update { + it.copy( + hobby = response.body()?.result?.hobby ?: "", + isLoading = false + ) + } + } + response.code() == 401 -> { + _uiState.update { + it.copy( + errorMessage = "토큰이 없습니다", + isLoading = false + ) + } + } + response.code() == 403 -> { + _uiState.update { + it.copy( + errorMessage = "유효하지 않은 토큰입니다", + isLoading = false + ) + } + } + else -> { + _uiState.update { + it.copy( + errorMessage = "취미 조회에 실패했습니다", + isLoading = false + ) + } + } + } + } catch (e: Exception) { + _uiState.update { + it.copy( + errorMessage = "네트워크 오류가 발생했습니다", + isLoading = false + ) + } + } + } else { + _uiState.update { + it.copy( + errorMessage = "로그인이 필요합니다", + isLoading = false + ) + } + } + } + } catch (e: Exception) { + _uiState.update { + it.copy( + errorMessage = "토큰 조회에 실패했습니다", + isLoading = false + ) + } + } + } + } + + companion object { + fun provideFactory( + authLocalDataSource: AuthLocalDataSource + ): ViewModelProvider.Factory = object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return MyPageViewModel(authLocalDataSource) as T + } + } } } \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/mypage/component/MypageComponents.kt b/app/src/main/java/org/sopt/and/presentation/mypage/component/MypageComponents.kt index 81b8f9d..bcb1772 100644 --- a/app/src/main/java/org/sopt/and/presentation/mypage/component/MypageComponents.kt +++ b/app/src/main/java/org/sopt/and/presentation/mypage/component/MypageComponents.kt @@ -2,12 +2,22 @@ package org.sopt.and.presentation.mypage.component import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +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.material.icons.Icons import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.outlined.Warning +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -19,7 +29,10 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @Composable -fun MyPageTopBar(email: String) { +fun MyPageTopBar( + hobby: String, + isLoading: Boolean = false +) { Row( modifier = Modifier .fillMaxWidth() @@ -30,7 +43,6 @@ fun MyPageTopBar(email: String) { horizontalArrangement = Arrangement.SpaceBetween ) { Row( - modifier = Modifier, horizontalArrangement = Arrangement.Start, verticalAlignment = Alignment.CenterVertically ) { @@ -41,15 +53,29 @@ fun MyPageTopBar(email: String) { ) Spacer(modifier = Modifier.width(10.dp)) - Text( - text = email, - fontSize = 15.sp, - color = Color.White, - fontWeight = FontWeight.Bold - ) + Column { + Text( + text = "내 취미", + fontSize = 15.sp, + color = Color.White, + fontWeight = FontWeight.Bold + ) + if (isLoading) { + CircularProgressIndicator( + color = Color.White, + modifier = Modifier.size(20.dp) + ) + } else { + Text( + text = hobby, + fontSize = 13.sp, + color = Color.White + ) + } + } } + Row( - modifier = Modifier, horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically ) { diff --git a/app/src/main/java/org/sopt/and/presentation/navigation/BottomNavItem.kt b/app/src/main/java/org/sopt/and/presentation/navigation/BottomNavItem.kt index 62871fb..67c880c 100644 --- a/app/src/main/java/org/sopt/and/presentation/navigation/BottomNavItem.kt +++ b/app/src/main/java/org/sopt/and/presentation/navigation/BottomNavItem.kt @@ -10,24 +10,24 @@ import org.sopt.and.Route sealed class BottomNavItem( val name: String, val icon: ImageVector, - val route: Any + val route: String ) { data object Home : BottomNavItem( name = "홈", icon = Icons.Default.Home, - route = Route.Home + route = Route.Home.route ) data object Search : BottomNavItem( name = "검색", icon = Icons.Default.Search, - route = Route.Search + route = Route.Search.route ) data object MyPage : BottomNavItem( name = "MY", icon = Icons.Default.Person, - route = Route.MyPage("") + route = Route.MyPage.route ) companion object { diff --git a/app/src/main/java/org/sopt/and/presentation/navigation/BottomNavigationBar.kt b/app/src/main/java/org/sopt/and/presentation/navigation/BottomNavigationBar.kt index ea029af..b480f19 100644 --- a/app/src/main/java/org/sopt/and/presentation/navigation/BottomNavigationBar.kt +++ b/app/src/main/java/org/sopt/and/presentation/navigation/BottomNavigationBar.kt @@ -2,9 +2,6 @@ package org.sopt.and.presentation.navigation import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem @@ -12,16 +9,13 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color 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.navigation.NavHostController import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController -import org.sopt.and.Route @Composable fun BottomNavigationBar( @@ -31,13 +25,11 @@ fun BottomNavigationBar( BottomNavItem.MyPage ), navController: NavHostController, - userEmail: String, modifier: Modifier = Modifier ) { val backStackEntry = navController.currentBackStackEntryAsState() Column(modifier = modifier.background(color = Color(0xFF1B1B1B))) { - NavigationBar( modifier = modifier, containerColor = Color.Black @@ -47,14 +39,8 @@ fun BottomNavigationBar( NavigationBarItem( selected = selected, onClick = { - navController.navigate(when (item) { - is BottomNavItem.MyPage -> Route.MyPage(userEmail).createRoute(userEmail) - is BottomNavItem.Home -> Route.Home.route - is BottomNavItem.Search -> Route.Search.route - }) { - popUpTo(navController.graph.startDestinationId) { - saveState = true - } + navController.navigate(item.route) { + popUpTo(navController.graph.startDestinationId) launchSingleTop = true restoreState = true } @@ -85,5 +71,5 @@ fun BottomNavigationBar( @Composable fun BottomNavigationBarPreview() { val navController = rememberNavController() - BottomNavigationBar(navController = navController, userEmail = "test@example.com") + BottomNavigationBar(navController = navController) } \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/navigation/NavGraph.kt b/app/src/main/java/org/sopt/and/presentation/navigation/NavGraph.kt index a8ed632..4dae141 100644 --- a/app/src/main/java/org/sopt/and/presentation/navigation/NavGraph.kt +++ b/app/src/main/java/org/sopt/and/presentation/navigation/NavGraph.kt @@ -3,10 +3,8 @@ package org.sopt.and.presentation.navigation import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.navigation.NavHostController -import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable -import androidx.navigation.navArgument import org.sopt.and.Route import org.sopt.and.presentation.home.HomeScreen import org.sopt.and.presentation.mypage.MyPageScreen @@ -14,14 +12,12 @@ import org.sopt.and.presentation.search.SearchScreen import org.sopt.and.presentation.signin.SignInScreen import org.sopt.and.presentation.signup.SignUpScreen - @Composable fun NavGraph( navController: NavHostController, modifier: Modifier = Modifier, - startDestination: String = Route.SignIn().route, - isLogined: (Boolean) -> Unit = {}, - onEmailUpdated: (String) -> Unit = {} + startDestination: String = Route.SignIn.route, + isLoggedIn: (Boolean) -> Unit = {} ) { NavHost( navController = navController, @@ -32,55 +28,34 @@ fun NavGraph( HomeScreen() } - composable( - route = Route.SignIn().route, - arguments = listOf( - navArgument("email") { - type = NavType.StringType - defaultValue = "" - }, - navArgument("password") { - type = NavType.StringType - defaultValue = "" - } - ) - ) { backStackEntry -> - val email = backStackEntry.arguments?.getString("email") ?: "" - val password = backStackEntry.arguments?.getString("password") ?: "" + composable(route = Route.SignIn.route) { SignInScreen( - email = email, - password = password, - navigateToMyPage = { userEmail -> - onEmailUpdated(userEmail) + onLoginSuccess = { navController.navigate(Route.Home.route) { popUpTo(navController.graph.startDestinationId) { inclusive = true } launchSingleTop = true } - isLogined(true) + isLoggedIn(true) }, - navigateToSignUp = { - navController.navigate(Route.SignUp.route) { - popUpTo(Route.SignIn().route) { - saveState = true - } - launchSingleTop = true - restoreState = true - } + onSignUpClick = { + navController.navigate(Route.SignUp.route) } ) } composable(route = Route.SignUp.route) { SignUpScreen( - navigateToSignIn = { user -> - navController.navigate(Route.SignIn(user.email, user.password).createRoute(user.email, user.password)) { + onSignUpSuccess = { + navController.navigate(Route.SignIn.route) { popUpTo(Route.SignUp.route) { inclusive = true } - launchSingleTop = true } + }, + onBackClick = { + navController.popBackStack() } ) } @@ -89,17 +64,8 @@ fun NavGraph( SearchScreen() } - composable( - route = Route.MyPage("").route, - arguments = listOf( - navArgument("email") { - type = NavType.StringType - defaultValue = "" - } - ) - ) { backStackEntry -> - val email = backStackEntry.arguments?.getString("email") ?: "" - MyPageScreen(email = email) + composable(route = Route.MyPage.route) { + MyPageScreen() } } } \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/signin/SignInScreen.kt b/app/src/main/java/org/sopt/and/presentation/signin/SignInScreen.kt index 97e649f..4ed27b0 100644 --- a/app/src/main/java/org/sopt/and/presentation/signin/SignInScreen.kt +++ b/app/src/main/java/org/sopt/and/presentation/signin/SignInScreen.kt @@ -2,12 +2,30 @@ package org.sopt.and.presentation.signin import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.CircleShape +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.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -18,38 +36,38 @@ 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 kotlinx.coroutines.launch -import org.sopt.and.presentation.component.ActionText -import org.sopt.and.presentation.component.EmailInputField +import org.sopt.and.data.local.AuthLocalDataSource import org.sopt.and.presentation.component.PasswordInputField import org.sopt.and.presentation.component.SignBottomBox +import org.sopt.and.presentation.component.TextInputField @Composable fun SignInScreen( - signInViewModel: SignInViewModel = viewModel(), + signInViewModel: SignInViewModel = viewModel( + factory = SignInViewModel.provideFactory( + AuthLocalDataSource.getInstance(LocalContext.current) + ) + ), modifier: Modifier = Modifier, - email: String = "", - password: String = "", - navigateToSignUp: () -> Unit = {}, - navigateToMyPage: (String) -> Unit = {} + onLoginSuccess: () -> Unit = {}, + onSignUpClick: () -> Unit = {} ) { val uiState by signInViewModel.uiState.collectAsState() - val context = LocalContext.current val snackBarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() - LaunchedEffect(email, password) { - if (email.isNotEmpty() && password.isNotEmpty()) { - signInViewModel.updateRegisteredUser(email, password) - } - } - LaunchedEffect(uiState.errorMessage) { uiState.errorMessage?.let { message -> snackBarHostState.showSnackbar(message) } } + LaunchedEffect(uiState.token) { + uiState.token?.let { + onLoginSuccess() + } + } + Column( modifier = Modifier .fillMaxSize() @@ -84,11 +102,11 @@ fun SignInScreen( Spacer(modifier = Modifier.height(30.dp)) - EmailInputField( - value = uiState.email, - onValueChange = { signInViewModel.onEmailChange(it) }, - placeholder = "이메일 주소 또는 아이디", - isError = uiState.errorMessage?.contains("이메일") == true + TextInputField( + value = uiState.username, + onValueChange = { signInViewModel.onUsernameChange(it) }, + placeholder = "username", + isError = uiState.errorMessage?.contains("username") == true ) Spacer(modifier = Modifier.height(5.dp)) @@ -97,7 +115,7 @@ fun SignInScreen( value = uiState.password, onValueChange = { signInViewModel.onPasswordChange(it) }, showPassword = uiState.showPassword, - placeholder = "비밀번호", + placeholder = "password", onVisibilityChange = { signInViewModel.onPasswordVisibilityChange() }, isError = uiState.errorMessage?.contains("비밀번호") == true ) @@ -105,21 +123,17 @@ fun SignInScreen( Spacer(modifier = Modifier.height(30.dp)) Button( - onClick = { - scope.launch { - if (signInViewModel.signIn(uiState.email, uiState.password)) { - snackBarHostState.showSnackbar("로그인 성공!") - navigateToMyPage(uiState.email) - } - } - }, + onClick = { signInViewModel.signIn() }, modifier = Modifier .fillMaxWidth() .height(50.dp), colors = ButtonDefaults.buttonColors( containerColor = Color.Blue, contentColor = Color.White - ) + ), + enabled = !uiState.isLoading && + uiState.username.isNotBlank() && + uiState.password.isNotBlank() ) { Text(text = "로그인", fontSize = 15.sp) } @@ -130,26 +144,38 @@ fun SignInScreen( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly ) { - ActionText( + Text( text = "아이디 찾기", - nextScreen = { } + color = Color.Gray, + modifier = Modifier.clickable { } ) Text(text = "|", color = Color.Gray) - ActionText( + Text( text = "비밀번호 재설정", - nextScreen = { } + color = Color.Gray, + modifier = Modifier.clickable { } ) Text(text = "|", color = Color.Gray) - ActionText( + Text( text = "회원 가입", - nextScreen = { navigateToSignUp() } + color = Color.Gray, + modifier = Modifier.clickable { onSignUpClick() } ) } Spacer(modifier = Modifier.height(55.dp)) + Spacer(modifier = Modifier.height(20.dp)) + SignBottomBox() } + + if (uiState.isLoading) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.CenterHorizontally), + color = Color.White + ) + } } SnackbarHost( @@ -158,7 +184,6 @@ fun SignInScreen( ) } - @Preview @Composable fun SignInScreenPreview() { diff --git a/app/src/main/java/org/sopt/and/presentation/signin/SignInViewModel.kt b/app/src/main/java/org/sopt/and/presentation/signin/SignInViewModel.kt index 541ee50..5583af8 100644 --- a/app/src/main/java/org/sopt/and/presentation/signin/SignInViewModel.kt +++ b/app/src/main/java/org/sopt/and/presentation/signin/SignInViewModel.kt @@ -1,45 +1,51 @@ package org.sopt.and.presentation.signin import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update -import org.sopt.and.domain.User +import kotlinx.coroutines.launch +import org.sopt.and.data.ServicePool +import org.sopt.and.data.local.AuthLocalDataSource +import org.sopt.and.data.model.request.SignInRequest -data class SignInUiState( - val email: String = "", - val password: String = "", - val showPassword: Boolean = false, - val registeredUser: User? = null, - val errorMessage: String? = null -) - -class SignInViewModel : ViewModel() { +class SignInViewModel( + private val authDataStore: AuthLocalDataSource +) : ViewModel() { private val _uiState = MutableStateFlow(SignInUiState()) val uiState: StateFlow = _uiState.asStateFlow() - fun updateRegisteredUser(email: String, password: String) { - _uiState.update { currentState -> - currentState.copy( - registeredUser = User(email, password), - email = email, - password = password - ) + data class SignInUiState( + val username: String = "", + val password: String = "", + val showPassword: Boolean = false, + val isLoading: Boolean = false, + val errorMessage: String? = null, + val token: String? = null + ) + + private fun validateInput(input: String, fieldName: String): String? { + return when { + input.isBlank() -> "${fieldName}을 입력해주세요" + input.length > 8 -> "${fieldName}은 8자 이하여야 합니다" + else -> null } } - fun onEmailChange(email: String) { + fun onUsernameChange(username: String) { _uiState.update { it.copy( - email = email, - errorMessage = null + username = username, + errorMessage = validateInput(username, "username") ) } } fun onPasswordChange(password: String) { _uiState.update { it.copy( password = password, - errorMessage = null + errorMessage = validateInput(password, "password") ) } } @@ -47,25 +53,55 @@ class SignInViewModel : ViewModel() { _uiState.update { it.copy(showPassword = !it.showPassword) } } - fun signIn(email: String, password: String): Boolean { - val currentState = _uiState.value + fun signIn() { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + try { + val response = ServicePool.authService.signIn( + SignInRequest( + username = _uiState.value.username, + password = _uiState.value.password + ) + ) - if (email.isBlank() || password.isBlank()) { - _uiState.update { it.copy(errorMessage = "이메일과 비밀번호를 입력해주세요.") } - return false + when { + response.isSuccessful && response.body()?.result != null -> { + response.body()?.result?.token?.let { token -> + authDataStore.saveToken(token) + _uiState.update { it.copy(token = token) } + } + } + response.code() == 400 -> { + val errorMessage = when(response.body()?.code) { + "01" -> "요청이 유효하지 않습니다" + "02" -> "로그인 정보가 올바르지 않습니다" + else -> "로그인에 실패했습니다" + } + _uiState.update { it.copy(errorMessage = errorMessage) } + } + response.code() == 403 -> { + _uiState.update { it.copy(errorMessage = "비밀번호가 틀렸습니다") } + } + else -> { + _uiState.update { it.copy(errorMessage = "로그인에 실패했습니다") } + } + } + } catch (e: Exception) { + _uiState.update { it.copy(errorMessage = "네트워크 오류가 발생했습니다") } + } finally { + _uiState.update { it.copy(isLoading = false) } + } } + } - return if (currentState.registeredUser != null) { - if (email == currentState.registeredUser.email && - password == currentState.registeredUser.password) { - true - } else { - _uiState.update { it.copy(errorMessage = "이메일 또는 비밀번호가 일치하지 않습니다.") } - false + companion object { + fun provideFactory( + authDataStore: AuthLocalDataSource + ): ViewModelProvider.Factory = object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return SignInViewModel(authDataStore) as T } - } else { - _uiState.update { it.copy(errorMessage = "등록되지 않은 사용자입니다.") } - false } } } \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/signup/SignUpScreen.kt b/app/src/main/java/org/sopt/and/presentation/signup/SignUpScreen.kt index 58c4018..da31f63 100644 --- a/app/src/main/java/org/sopt/and/presentation/signup/SignUpScreen.kt +++ b/app/src/main/java/org/sopt/and/presentation/signup/SignUpScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState @@ -34,17 +35,16 @@ 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.and.domain.User -import org.sopt.and.presentation.component.EmailInputField import org.sopt.and.presentation.component.PasswordInputField import org.sopt.and.presentation.component.SignBottomBox +import org.sopt.and.presentation.component.TextInputField @Composable fun SignUpScreen( signUpViewModel: SignUpViewModel = viewModel(), modifier: Modifier = Modifier, - navigateToSignIn: (User) -> Unit = {}, - onNavigateBack: () -> Unit = {} + onSignUpSuccess: () -> Unit = {}, + onBackClick: () -> Unit = {} ) { val uiState by signUpViewModel.uiState.collectAsState() val context = LocalContext.current @@ -56,6 +56,13 @@ fun SignUpScreen( } } + LaunchedEffect(uiState.isSuccess) { + if (uiState.isSuccess) { + Toast.makeText(context, "회원가입이 완료되었습니다!", Toast.LENGTH_SHORT).show() + onSignUpSuccess() + } + } + Column( modifier = Modifier .fillMaxSize() @@ -78,7 +85,7 @@ fun SignUpScreen( Icon( modifier = Modifier .size(30.dp) - .clickable { onNavigateBack() }, + .clickable { onBackClick() }, imageVector = Icons.Default.Close, contentDescription = "닫기", tint = Color.White @@ -88,25 +95,18 @@ fun SignUpScreen( Spacer(modifier = Modifier.height(40.dp)) Text( - text = "이메일과 비밀번호 만으로\nWavve를 즐길 수 있어요!", - fontSize = 20.sp, + text = "아이디와 비밀번호, 취미를 입력하여\nWavve를 즐길 수 있어요!", + fontSize = 23.sp, color = Color.White, ) - Spacer(modifier = Modifier.height(25.dp)) - - EmailInputField( - value = uiState.email, - onValueChange = { signUpViewModel.onEmailChange(it) }, - placeholder = "wavve@example.com", - isError = uiState.errorMessage?.contains("이메일") == true - ) - - Spacer(modifier = Modifier.height(10.dp)) + Spacer(modifier = Modifier.height(40.dp)) - Text( - text = "! 로그인, 비밀번호 찾기, 알림에 사용되니 정확한 이메일을\n입력해 주세요.", - color = Color.Gray, + TextInputField( + value = uiState.username, + onValueChange = { signUpViewModel.onUsernameChange(it) }, + placeholder = "username (8자 이하)", + isError = uiState.errorMessage?.contains("username") == true ) Spacer(modifier = Modifier.height(20.dp)) @@ -115,30 +115,39 @@ fun SignUpScreen( value = uiState.password, onValueChange = { signUpViewModel.onPasswordChange(it) }, showPassword = uiState.showPassword, - placeholder = "wavve 비밀번호 설정", + placeholder = "password (8자 이하)", onVisibilityChange = { signUpViewModel.onPasswordVisibilityChange() }, - isError = uiState.errorMessage?.contains("비밀번호") == true + isError = uiState.errorMessage?.contains("password") == true + ) + + Spacer(modifier = Modifier.height(20.dp)) + + TextInputField( + value = uiState.hobby, + onValueChange = { signUpViewModel.onHobbyChange(it) }, + placeholder = "hobby (8자 이하)", + isError = uiState.errorMessage?.contains("hobby") == true ) Spacer(modifier = Modifier.height(10.dp)) Text( - text = "! 비밀번호는 8-20자 이내, 영문 대소문자, 숫자, 특수문자 중\n3가지 이상 혼용하여 입력해 주세요.", + text = "! 모든 입력값은 8자 이하여야 합니다", color = Color.Gray, ) - Spacer(modifier = Modifier.height(10.dp)) - SignBottomBox() } + if (uiState.isLoading) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.CenterHorizontally), + color = Color.White + ) + } + Button( - onClick = { - if (signUpViewModel.onSignUpClick()) { - Toast.makeText(context, "회원가입이 완료되었습니다!", Toast.LENGTH_SHORT).show() - navigateToSignIn(signUpViewModel.getUser()) - } - }, + onClick = { signUpViewModel.signUp() }, modifier = Modifier .fillMaxWidth() .height(60.dp), @@ -147,7 +156,10 @@ fun SignUpScreen( contentColor = Color.White ), shape = RectangleShape, - enabled = uiState.email.isNotBlank() && uiState.password.isNotBlank() + enabled = !uiState.isLoading && + uiState.username.isNotBlank() && + uiState.password.isNotBlank() && + uiState.hobby.isNotBlank() ) { Text("Wavve 회원가입", fontSize = 17.sp) } diff --git a/app/src/main/java/org/sopt/and/presentation/signup/SignUpViewModel.kt b/app/src/main/java/org/sopt/and/presentation/signup/SignUpViewModel.kt index 3870ff4..2ec155c 100644 --- a/app/src/main/java/org/sopt/and/presentation/signup/SignUpViewModel.kt +++ b/app/src/main/java/org/sopt/and/presentation/signup/SignUpViewModel.kt @@ -1,98 +1,100 @@ +// presentation/signup/SignUpViewModel.kt + package org.sopt.and.presentation.signup import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update -import android.util.Patterns -import java.util.regex.Pattern -import org.sopt.and.domain.User - -data class SignUpUiState( - val email: String = "", - val password: String = "", - val showPassword: Boolean = false, - val isSignUpSuccess: Boolean = false, - val errorMessage: String? = null -) - -sealed class SignUpEvent { - data object Success : SignUpEvent() - data class Error(val message: String) : SignUpEvent() -} +import kotlinx.coroutines.launch +import org.sopt.and.data.ServicePool +import org.sopt.and.data.model.request.SignUpRequest class SignUpViewModel : ViewModel() { private val _uiState = MutableStateFlow(SignUpUiState()) val uiState: StateFlow = _uiState.asStateFlow() - fun onEmailChange(email: String) { - _uiState.update { - it.copy( - email = email, - errorMessage = null - ) + data class SignUpUiState( + val username: String = "", + val password: String = "", + val hobby: String = "", + val showPassword: Boolean = false, + val isLoading: Boolean = false, + val errorMessage: String? = null, + val isSuccess: Boolean = false + ) + + private fun validateInput(input: String, fieldName: String): String? { + return when { + input.isBlank() -> "${fieldName}을 입력해주세요" + input.length > 8 -> "${fieldName}은 8자 이하여야 합니다" + else -> null } } + fun onUsernameChange(username: String) { + _uiState.update { it.copy( + username = username, + errorMessage = validateInput(username, "username") + ) } + } + fun onPasswordChange(password: String) { - _uiState.update { - it.copy( - password = password, - errorMessage = null - ) - } + _uiState.update { it.copy( + password = password, + errorMessage = validateInput(password, "password") + ) } + } + + fun onHobbyChange(hobby: String) { + _uiState.update { it.copy( + hobby = hobby, + errorMessage = validateInput(hobby, "hobby") + ) } } fun onPasswordVisibilityChange() { _uiState.update { it.copy(showPassword = !it.showPassword) } } - fun onSignUpClick(): Boolean { - val currentState = _uiState.value - - return when { - !isEmailValid(currentState.email) -> { - _uiState.update { - it.copy( - errorMessage = "유효하지 않은 이메일 형식입니다." - ) - } - false - } - - !isPasswordValid(currentState.password) -> { - _uiState.update { - it.copy( - errorMessage = "비밀번호는 8-20자의 영문 대소문자, 숫자, 특수문자를 포함해야 합니다." + fun signUp() { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + try { + val response = ServicePool.authService.signUp( + SignUpRequest( + username = _uiState.value.username, + password = _uiState.value.password, + hobby = _uiState.value.hobby ) - } - false - } + ) - else -> { - _uiState.update { - it.copy( - isSignUpSuccess = true, - errorMessage = null - ) + when { + response.isSuccessful && response.body()?.result != null -> { + _uiState.update { it.copy(isSuccess = true) } + } + response.code() == 400 -> { + val errorMessage = when(response.body()?.code) { + "00" -> "요청이 유효하지 않습니다" + "01" -> "입력값이 8자를 초과했습니다" + else -> "회원가입에 실패했습니다" + } + _uiState.update { it.copy(errorMessage = errorMessage) } + } + response.code() == 409 -> { + _uiState.update { it.copy(errorMessage = "이미 존재하는 username입니다") } + } + else -> { + _uiState.update { it.copy(errorMessage = "회원가입에 실패했습니다") } + } } - true + } catch (e: Exception) { + _uiState.update { it.copy(errorMessage = "네트워크 오류가 발생했습니다") } + } finally { + _uiState.update { it.copy(isLoading = false) } } } } - - fun getUser(): User = User(_uiState.value.email, _uiState.value.password) - - private fun isEmailValid(email: String): Boolean = - email.isNotBlank() && emailPattern.matcher(email).matches() - - private fun isPasswordValid(password: String): Boolean = - password.isNotBlank() && passwordPattern.matcher(password).matches() - - companion object { - private val emailPattern = Patterns.EMAIL_ADDRESS - private val passwordPattern = - Pattern.compile("^(?=.*[A-Z])(?=.*[a-z])(?=.*[0-9])(?=.*[!@#\$%^&*]).{8,20}$") - } } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8042b7d..051fb70 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,6 +20,8 @@ okhttp = "4.11.0" retrofit = "2.9.0" retrofitKotlinSerializationConverter = "1.0.0" kotlinxSerializationJson = "1.6.3" +datastoreCoreAndroid = "1.1.1" +datastorePreferencesCoreJvm = "1.1.1" [libraries] accompanist-pager = { module = "com.google.accompanist:accompanist-pager", version.ref = "accompanistPager" } @@ -53,6 +55,8 @@ okhttp-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-i retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } retrofit-kotlin-serialization-converter = { group = "com.jakewharton.retrofit", name = "retrofit2-kotlinx-serialization-converter", version.ref = "retrofitKotlinSerializationConverter" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } +androidx-datastore-core-android = { group = "androidx.datastore", name = "datastore-core-android", version.ref = "datastoreCoreAndroid" } +androidx-datastore-preferences-core-jvm = { group = "androidx.datastore", name = "datastore-preferences-core-jvm", version.ref = "datastorePreferencesCoreJvm" } [plugins]