diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3cf63d5..fd57678 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,12 +1,20 @@ +import java.util.Properties + plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) + id("kotlin-parcelize") +} + +val properties = Properties().apply { + load(project.rootProject.file("local.properties").inputStream()) } android { namespace = "com.sopt.dive" - compileSdk = 35 + compileSdk = 36 defaultConfig { applicationId = "com.sopt.dive" @@ -16,6 +24,7 @@ android { versionName = "1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + buildConfigField("String", "BASE_URL", properties["BASE_URL"].toString()) } buildTypes { @@ -36,6 +45,7 @@ android { } buildFeatures { compose = true + buildConfig = true } } @@ -56,4 +66,11 @@ dependencies { androidTestImplementation(libs.androidx.ui.test.junit4) debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.test.manifest) -} \ No newline at end of file + implementation(libs.androidx.compose.navigation) + implementation(libs.androidx.lifecycle.viewmodel.ktx) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.kotlinx.serialization.json) + implementation(libs.retrofit.core) + implementation(libs.retrofit.kotlin.serialization) + implementation(libs.okhttp.logging) +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cd578ed..5ed94bd 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,7 +2,11 @@ + + + + android:theme="@style/Theme.Dive" + android:windowSoftInputMode="adjustResize"> - - \ No newline at end of file + diff --git a/app/src/main/java/com/sopt/dive/MainActivity.kt b/app/src/main/java/com/sopt/dive/MainActivity.kt index f23c687..76d1c09 100644 --- a/app/src/main/java/com/sopt/dive/MainActivity.kt +++ b/app/src/main/java/com/sopt/dive/MainActivity.kt @@ -3,45 +3,20 @@ package com.sopt.dive import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview +import androidx.navigation.compose.rememberNavController import com.sopt.dive.ui.theme.DiveTheme +import com.sopt.dive.util.Navigator class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - enableEdgeToEdge() + setContent { DiveTheme { - Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - Greeting( - name = "Android", - modifier = Modifier.padding(innerPadding) - ) - } + val navController = rememberNavController() + Navigator(navController) } } } } -@Composable -fun Greeting(name: String, modifier: Modifier = Modifier) { - Text( - text = "Hello $name!", - modifier = modifier - ) -} - -@Preview(showBackground = true) -@Composable -fun GreetingPreview() { - DiveTheme { - Greeting("Android") - } -} \ No newline at end of file diff --git a/app/src/main/java/com/sopt/dive/data/ApiFactory.kt b/app/src/main/java/com/sopt/dive/data/ApiFactory.kt new file mode 100644 index 0000000..3fd46f3 --- /dev/null +++ b/app/src/main/java/com/sopt/dive/data/ApiFactory.kt @@ -0,0 +1,37 @@ +package com.sopt.dive.data + +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import com.sopt.dive.BuildConfig +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit + +object ApiFactory { + private val BASE_URL: String = BuildConfig.BASE_URL + + private val loggingInterceptor = HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY + } + + private val client = OkHttpClient.Builder() + .addInterceptor(loggingInterceptor) + .build() + + private val json = Json { + explicitNulls = false + } + + 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) +} diff --git a/app/src/main/java/com/sopt/dive/data/ServicePool.kt b/app/src/main/java/com/sopt/dive/data/ServicePool.kt new file mode 100644 index 0000000..25c29ca --- /dev/null +++ b/app/src/main/java/com/sopt/dive/data/ServicePool.kt @@ -0,0 +1,14 @@ +package com.sopt.dive.data + +import com.sopt.dive.data.api.AuthService +import com.sopt.dive.data.api.UserService + +object ServicePool { + val userService: UserService by lazy { + ApiFactory.create() + } + + val authService: AuthService by lazy { + ApiFactory.create() + } +} diff --git a/app/src/main/java/com/sopt/dive/data/api/AuthService.kt b/app/src/main/java/com/sopt/dive/data/api/AuthService.kt new file mode 100644 index 0000000..e35297a --- /dev/null +++ b/app/src/main/java/com/sopt/dive/data/api/AuthService.kt @@ -0,0 +1,15 @@ +package com.sopt.dive.data.api + +import com.sopt.dive.data.dto.LoginDataDto +import com.sopt.dive.data.dto.RequestLoginDto +import com.sopt.dive.data.dto.ResponseSuccessDto +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.POST + +interface AuthService { + @POST("api/v1/auth/login") + suspend fun login( + @Body request: RequestLoginDto + ): Response> +} diff --git a/app/src/main/java/com/sopt/dive/data/api/UserService.kt b/app/src/main/java/com/sopt/dive/data/api/UserService.kt new file mode 100644 index 0000000..504914c --- /dev/null +++ b/app/src/main/java/com/sopt/dive/data/api/UserService.kt @@ -0,0 +1,22 @@ +package com.sopt.dive.data.api + +import com.sopt.dive.data.dto.RequestSignupDto +import com.sopt.dive.data.dto.ResponseSuccessDto +import com.sopt.dive.data.dto.ResponseUserDto +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Path + +interface UserService { + @POST("api/v1/users") + suspend fun signup( + @Body request: RequestSignupDto + ): Response> + + @GET("api/v1/users/{id}") + fun fetchUserInfo( + @Path("id") id: Int, + ): Response> +} diff --git a/app/src/main/java/com/sopt/dive/data/dto/RequestLoginDto.kt b/app/src/main/java/com/sopt/dive/data/dto/RequestLoginDto.kt new file mode 100644 index 0000000..c808972 --- /dev/null +++ b/app/src/main/java/com/sopt/dive/data/dto/RequestLoginDto.kt @@ -0,0 +1,13 @@ +package com.sopt.dive.data.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class RequestLoginDto( + @SerialName("username") + val username: String, + + @SerialName("password") + val password: String +) diff --git a/app/src/main/java/com/sopt/dive/data/dto/RequestSignupDto.kt b/app/src/main/java/com/sopt/dive/data/dto/RequestSignupDto.kt new file mode 100644 index 0000000..f7e1a55 --- /dev/null +++ b/app/src/main/java/com/sopt/dive/data/dto/RequestSignupDto.kt @@ -0,0 +1,22 @@ +package com.sopt.dive.data.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class RequestSignupDto( + @SerialName("username") + val username: String, + + @SerialName("password") + val password: String, + + @SerialName("name") + val name: String, + + @SerialName("email") + val email: String, + + @SerialName("age") + val age: Int +) diff --git a/app/src/main/java/com/sopt/dive/data/dto/ResponseErrorDto.kt b/app/src/main/java/com/sopt/dive/data/dto/ResponseErrorDto.kt new file mode 100644 index 0000000..3ec723b --- /dev/null +++ b/app/src/main/java/com/sopt/dive/data/dto/ResponseErrorDto.kt @@ -0,0 +1,49 @@ +package com.sopt.dive.data.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ResponseErrorDto( + + @SerialName("success") + val success: Boolean = false, + + @SerialName("code") + val code: String, + + @SerialName("message") + val message: String, + + @SerialName("data") + val data: ErrorDataDto? + +) + +@Serializable +data class ErrorDataDto( + + @SerialName("code") + val code: String, + + @SerialName("message") + val message: String, + + @SerialName("errors") + val errors: List? + +) + +@Serializable +data class FieldErrorDto( + + @SerialName("field") + val field: String, + + @SerialName("value") + val value: String, + + @SerialName("reason") + val reason: String + +) diff --git a/app/src/main/java/com/sopt/dive/data/dto/ResponseLoginDto.kt b/app/src/main/java/com/sopt/dive/data/dto/ResponseLoginDto.kt new file mode 100644 index 0000000..aaf6b3f --- /dev/null +++ b/app/src/main/java/com/sopt/dive/data/dto/ResponseLoginDto.kt @@ -0,0 +1,14 @@ +package com.sopt.dive.data.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + + +@Serializable +data class LoginDataDto( + @SerialName("userId") + val userId: Int, + + @SerialName("message") + val message: String +) diff --git a/app/src/main/java/com/sopt/dive/data/dto/ResponseSuccessDto.kt b/app/src/main/java/com/sopt/dive/data/dto/ResponseSuccessDto.kt new file mode 100644 index 0000000..f700bd9 --- /dev/null +++ b/app/src/main/java/com/sopt/dive/data/dto/ResponseSuccessDto.kt @@ -0,0 +1,13 @@ +package com.sopt.dive.data.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + + +@Serializable +data class ResponseSuccessDto( + @SerialName("success") val success: Boolean, + @SerialName("code") val code: String, + @SerialName("message") val message: String, + @SerialName("data") val data: T? +) diff --git a/app/src/main/java/com/sopt/dive/data/dto/ResponseUserDto.kt b/app/src/main/java/com/sopt/dive/data/dto/ResponseUserDto.kt new file mode 100644 index 0000000..6bdda5f --- /dev/null +++ b/app/src/main/java/com/sopt/dive/data/dto/ResponseUserDto.kt @@ -0,0 +1,27 @@ +package com.sopt.dive.data.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +enum class Status { ACTIVE } + +@Serializable +data class ResponseUserDto( + @SerialName("id") + val id: Long, + + @SerialName("username") + val username: String, + + @SerialName("name") + val name: String, + + @SerialName("email") + val email: String, + + @SerialName("age") + val age: Int, + + @SerialName("status") + val status: Status +) diff --git a/app/src/main/java/com/sopt/dive/model/HomeProfileInfo.kt b/app/src/main/java/com/sopt/dive/model/HomeProfileInfo.kt new file mode 100644 index 0000000..493027b --- /dev/null +++ b/app/src/main/java/com/sopt/dive/model/HomeProfileInfo.kt @@ -0,0 +1,11 @@ +package com.sopt.dive.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class HomeProfileInfo( + val userName: String, + val title: String, + val content: String +) : Parcelable diff --git a/app/src/main/java/com/sopt/dive/model/User.kt b/app/src/main/java/com/sopt/dive/model/User.kt new file mode 100644 index 0000000..b5977c1 --- /dev/null +++ b/app/src/main/java/com/sopt/dive/model/User.kt @@ -0,0 +1,13 @@ +package com.sopt.dive.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class User( + val id: String, + val pw: String, + val name: String, + val email: String, + val age: Int +) : Parcelable diff --git a/app/src/main/java/com/sopt/dive/ui/components/CommonButton.kt b/app/src/main/java/com/sopt/dive/ui/components/CommonButton.kt new file mode 100644 index 0000000..bdd37c4 --- /dev/null +++ b/app/src/main/java/com/sopt/dive/ui/components/CommonButton.kt @@ -0,0 +1,27 @@ +package com.sopt.dive.ui.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import com.sopt.dive.ui.theme.MainPinkBackground + +@Composable +fun CommonButton( + textMessage: String, + onClick: () -> Unit, + modifier: Modifier = Modifier) { + Button( + onClick = onClick, + modifier = modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = MainPinkBackground, + contentColor = Color.White + ), + ) { + Text(textMessage, modifier) + } +} diff --git a/app/src/main/java/com/sopt/dive/ui/components/CommonInputField.kt b/app/src/main/java/com/sopt/dive/ui/components/CommonInputField.kt new file mode 100644 index 0000000..a1ec93a --- /dev/null +++ b/app/src/main/java/com/sopt/dive/ui/components/CommonInputField.kt @@ -0,0 +1,84 @@ +package com.sopt.dive.ui.components + +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.text.KeyboardOptions +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun CommonInputField( + titleText: String, + value: String, + onValueChange: (String) -> Unit, + placeMessage: String, + keyboardOptions: KeyboardOptions, + modifier: Modifier = Modifier, + visualTransformation: VisualTransformation = VisualTransformation.None, + errorMessage: String? = null +) { + + Column(modifier = modifier) { + + Text(titleText.uppercase().toString(), modifier = Modifier.fillMaxWidth()) + + TextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier + .fillMaxWidth() + .background(color = Color.Transparent) + .padding(bottom = 40.dp), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.White, unfocusedContainerColor = Color.Transparent + ), + isError = errorMessage != null, + supportingText = { + if (errorMessage != null) { + Text( + text = errorMessage, fontSize = 12.sp + ) + } + }, + placeholder = { Text(placeMessage) }, + singleLine = true, + keyboardOptions = keyboardOptions, + visualTransformation = visualTransformation + + ) + } + +} + +@Preview +@Composable +private fun CommonInputFieldPreview() { + var inputText by remember { mutableStateOf("") } + CommonInputField( + onValueChange = { inputText = it }, + titleText = "birthday", + value = inputText, + placeMessage = "닉네임을 입력해주세요", + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, imeAction = ImeAction.Done + ), + visualTransformation = PasswordVisualTransformation(), + ) +} diff --git a/app/src/main/java/com/sopt/dive/ui/components/UserProfileInfo.kt b/app/src/main/java/com/sopt/dive/ui/components/UserProfileInfo.kt new file mode 100644 index 0000000..37bf42f --- /dev/null +++ b/app/src/main/java/com/sopt/dive/ui/components/UserProfileInfo.kt @@ -0,0 +1,94 @@ +package com.sopt.dive.ui.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +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 com.sopt.dive.R +import com.sopt.dive.model.HomeProfileInfo +import com.sopt.dive.ui.theme.SubPinkBackground + +@Composable +fun UserProfileCard(user: HomeProfileInfo, modifier: Modifier = Modifier) { + var isFollowing by remember { mutableStateOf(false) } + + Row( + modifier = modifier + .fillMaxWidth() + .padding(12.dp) + ) { + + Image( + painter = painterResource(id = R.drawable.profile_photo), + contentDescription = "프로필 사진", + modifier = Modifier + .size(70.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop, + ) + Column(modifier.padding(horizontal = 30.dp)) { + Text( + user.title, + Modifier.padding(top = 2.dp, bottom = 7.dp), + fontWeight = FontWeight.Bold + ) + Text(user.content) + } + + Button( + onClick = { + isFollowing = !isFollowing + + }, + modifier = Modifier.size(width = 120.dp, height = 40.dp), + contentPadding = PaddingValues(0.dp), + colors = ButtonDefaults.buttonColors( + containerColor = if (isFollowing) Color.Gray else SubPinkBackground, + contentColor = Color.White + ), + ) { + Text( + text = if (isFollowing) "Unfollow" else "Follow", + color = if (isFollowing) Color.Black else Color.White, + fontSize = 12.sp, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } + } + +} + +@Preview +@Composable +private fun InputPreview() { + UserProfileCard( + HomeProfileInfo( + userName = "gey", + title = "ddd", + content = "gkdl dkssud qldhk duddnjsgl" + ) + ) +} diff --git a/app/src/main/java/com/sopt/dive/ui/screens/CardScreen.kt b/app/src/main/java/com/sopt/dive/ui/screens/CardScreen.kt new file mode 100644 index 0000000..655af8d --- /dev/null +++ b/app/src/main/java/com/sopt/dive/ui/screens/CardScreen.kt @@ -0,0 +1,136 @@ +package com.sopt.dive.ui.screens + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +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 com.sopt.dive.ui.theme.MainPinkText + + +enum class CardState(val imageResId: Int) { + Front(com.sopt.dive.R.drawable.card_front_image), + Back(com.sopt.dive.R.drawable.card_image) +} + +@Composable +fun CardScreen(paddingValues: PaddingValues) { + var isFlipped by remember { mutableStateOf(false) } + val rotation = remember { Animatable(0f) } + var isInitial by remember { mutableStateOf(true) } + + + LaunchedEffect(isFlipped) { + if (isInitial) { + isInitial = false + return@LaunchedEffect + } + + val currentRotation = if (isFlipped) 180f else 0f + rotation.animateTo( + targetValue = currentRotation, + animationSpec = tween( + durationMillis = 1000, + easing = FastOutSlowInEasing + ) + ) + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + + Text( + modifier = Modifier + .fillMaxWidth(), + text = "오늘의 부적", + color = MainPinkText, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ) + Text( + modifier = Modifier + .fillMaxWidth(), + text = "👇🏻 눌러서 확인하기 ", + color = Color.Gray, + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + ) + + Card( + modifier = Modifier + .fillMaxSize() + .height(250.dp) + .padding(horizontal = 20.dp, vertical = 30.dp) + .clickable { + isFlipped = !isFlipped + } + .graphicsLayer( + rotationY = rotation.value % 360f, + cameraDistance = 12f * 80 + ) + .border(2.dp, Color.Gray, RoundedCornerShape(40.dp)) + .shadow(8.dp, RoundedCornerShape(40.dp)) + .padding(2.dp) + ) { + val rotationMod = rotation.value % 360f + val isFront = rotationMod <= 90f || rotationMod >= 270f + + Image( + painter = painterResource( + id = if (isFront) CardState.Front.imageResId else CardState.Back.imageResId + ), + contentDescription = "카드 이미지", + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + if (!isFront) rotationY = 180f + }, + contentScale = ContentScale.Crop + ) + + } + Spacer(modifier = Modifier.height(24.dp)) + } +} + +@Preview +@Composable +private fun CardScreenPreview() { + CardScreen(paddingValues = PaddingValues.Zero) +} + diff --git a/app/src/main/java/com/sopt/dive/ui/screens/HomeScreen.kt b/app/src/main/java/com/sopt/dive/ui/screens/HomeScreen.kt new file mode 100644 index 0000000..4c47205 --- /dev/null +++ b/app/src/main/java/com/sopt/dive/ui/screens/HomeScreen.kt @@ -0,0 +1,34 @@ +package com.sopt.dive.ui.screens + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.sopt.dive.viewmodel.MainViewModel +import androidx.compose.runtime.getValue +import com.sopt.dive.ui.components.UserProfileCard + +@Composable +fun HomeScreen(paddingValues: PaddingValues, viewModel: MainViewModel = viewModel()) { + val homeProfileList by viewModel.homeProfileList.collectAsState() + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(10.dp) + ) { + itemsIndexed( + items = homeProfileList, + key = { _, user -> user.hashCode() } + ) + { _, user -> + UserProfileCard(user = user) + } + } +} + diff --git a/app/src/main/java/com/sopt/dive/ui/screens/LoginScreen.kt b/app/src/main/java/com/sopt/dive/ui/screens/LoginScreen.kt new file mode 100644 index 0000000..86db8b8 --- /dev/null +++ b/app/src/main/java/com/sopt/dive/ui/screens/LoginScreen.kt @@ -0,0 +1,95 @@ +package com.sopt.dive.ui.screens + +import android.widget.Toast +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.sopt.dive.data.dto.RequestLoginDto +import com.sopt.dive.ui.components.CommonButton +import com.sopt.dive.ui.components.CommonInputField +import com.sopt.dive.viewmodel.UserViewModel +import okhttp3.Request + +@Composable +fun LoginScreen( + userViewModel: UserViewModel, + onLoginSuccess: () -> Unit, + onSignUpClick: () -> Unit +) { + + var idText by remember { mutableStateOf("") } + var pwText by remember { mutableStateOf("") } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(20.dp) + ) { + Text( + text = "Welcome To SOPT", + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + fontWeight = FontWeight.Bold, + fontSize = 30.sp + ) + + CommonInputField( + titleText = "id", + value = idText, + onValueChange = { idText = it }, + placeMessage = "아이디를 입력해주세요", + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Next, + keyboardType = KeyboardType.Text + ) + ) + + CommonInputField( + titleText = "pw", + value = pwText, + onValueChange = { pwText = it }, + placeMessage = "비밀번호를 입력해주세요", + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done, + keyboardType = KeyboardType.Password + ), + visualTransformation = PasswordVisualTransformation() + ) + + Spacer(modifier = Modifier.weight(1f)) + + CommonButton( + onClick = { + val request = RequestLoginDto(idText, pwText) + userViewModel.loginUser(request) + }, + textMessage = "로그인" + ) + + Text( + text = "회원가입하기", + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 40.dp) + .clickable(onClick = onSignUpClick), + textAlign = TextAlign.Center, + fontSize = 15.sp, + style = TextStyle(textDecoration = TextDecoration.Underline, color = Color.Gray) + ) + } + +} diff --git a/app/src/main/java/com/sopt/dive/ui/screens/ProfileScreen.kt b/app/src/main/java/com/sopt/dive/ui/screens/ProfileScreen.kt new file mode 100644 index 0000000..e2a9e07 --- /dev/null +++ b/app/src/main/java/com/sopt/dive/ui/screens/ProfileScreen.kt @@ -0,0 +1,63 @@ +package com.sopt.dive.ui.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.sopt.dive.ui.theme.MainPinkBackground +import com.sopt.dive.viewmodel.UserViewModel +import androidx.compose.runtime.getValue + +@Composable +fun ProfileScreen(paddingValues: PaddingValues, userViewModel: UserViewModel) { + val user by userViewModel.currentUser.collectAsState() + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = " Profile", + fontSize = 28.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(20.dp)) + + Text(text = "ID: ${user?.id}", fontSize = 20.sp) + + Text(text = "PW: ${user?.pw}", fontSize = 20.sp) + + Text(text = "Name: ${user?.name}", fontSize = 20.sp) + + Text(text = "Email: ${user?.email}", fontSize = 20.sp) + + Text(text = "Age: ${user?.age}", fontSize = 20.sp) + + Spacer(modifier = Modifier.height(40.dp)) + + Button( + onClick = { userViewModel.logoutUser() }, + colors = ButtonDefaults.buttonColors( + containerColor = MainPinkBackground, + contentColor = Color.White + ) + ) { + Text("로그아웃") + } + } +} diff --git a/app/src/main/java/com/sopt/dive/ui/screens/SignUpScreen.kt b/app/src/main/java/com/sopt/dive/ui/screens/SignUpScreen.kt new file mode 100644 index 0000000..2be318b --- /dev/null +++ b/app/src/main/java/com/sopt/dive/ui/screens/SignUpScreen.kt @@ -0,0 +1,156 @@ +package com.sopt.dive.ui.screens + +import android.widget.Toast +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.sopt.dive.data.dto.RequestSignupDto +import com.sopt.dive.ui.components.CommonButton +import com.sopt.dive.ui.components.CommonInputField +import com.sopt.dive.ui.validators.InputValidators +import com.sopt.dive.util.ErrorMessages +import com.sopt.dive.viewmodel.UserViewModel + +@Composable +fun SignUpScreen( + userViewModel: UserViewModel, + onSignUpComplete: () -> Unit +) { + val context = LocalContext.current + var idText by remember { mutableStateOf("") } + var pwText by remember { mutableStateOf("") } + var nameText by remember { mutableStateOf("") } + var emailText by remember { mutableStateOf("") } + var ageText by remember { mutableStateOf("") } + + val idError = if (idText.isNotBlank() && !InputValidators.isValidId(idText)) { + ErrorMessages.ID_ERROR_MESSAGE + } else null + + val pwError = if (pwText.isNotBlank() && !InputValidators.isValidPw(pwText)) { + ErrorMessages.PW_ERROR_MESSAGE + } else null + + val nameError = + if (nameText.isNotBlank() && !InputValidators.isValidNickName(nameText)) { + ErrorMessages.NICKNAME_ERROR_MESSAGE + } else null + + val emailError = + if (ageText.isNotBlank() && !InputValidators.isValidEmail(emailText)) { + ErrorMessages.EMAIL_ERROR_MESSAGE + } else null + + val ageError = + if (ageText.isNotBlank() && !InputValidators.isValidAge(ageText)) { + ErrorMessages.AGE_ERROR_MESSAGE + } else null + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp) + .verticalScroll(rememberScrollState()) + ) { + Text( + text = "SIGN UP", + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + fontWeight = FontWeight.Bold, + fontSize = 30.sp + ) + + CommonInputField( + titleText = "id", + value = idText, + onValueChange = { idText = it }, + placeMessage = "아이디를 입력해주세요", + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Next, + keyboardType = KeyboardType.Text + ), + errorMessage = idError + ) + + CommonInputField( + titleText = "pw", + value = pwText, + onValueChange = { pwText = it }, + placeMessage = "비밀번호를 입력해주세요", + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Next, + keyboardType = KeyboardType.Password + ), + visualTransformation = PasswordVisualTransformation(), + errorMessage = pwError + ) + + CommonInputField( + titleText = "name", + value = nameText, + onValueChange = { nameText = it }, + placeMessage = "이름을 입력해주세요", + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Next, + keyboardType = KeyboardType.Text + ), + errorMessage = nameError + ) + CommonInputField( + titleText = "email", + value = emailText, + onValueChange = { emailText = it }, + placeMessage = "이메일을 입력해주세요", + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Next, + keyboardType = KeyboardType.Email + ), + errorMessage = emailError + ) + CommonInputField( + titleText = "age", + value = ageText, + onValueChange = { ageText = it }, + placeMessage = "나이를 입력해주세요", + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done, + keyboardType = KeyboardType.Number + ), + errorMessage = ageError + ) + + Spacer(modifier = Modifier.height(24.dp)) + + CommonButton( + onClick = { + + if ( + idError == null && pwError == null && nameError == null && emailError == null && ageError == null && + idText.isNotBlank() && pwText.isNotBlank() && nameText.isNotBlank() && emailText.isNotBlank() && ageText.isNotBlank() + ) { + val request = + RequestSignupDto(idText, pwText, nameText, emailText, ageText.toInt()) + + userViewModel.signUpUser(request) + Toast.makeText(context, "회원가입 완료! 로그인 화면으로 돌아갑니다.", Toast.LENGTH_SHORT).show() + onSignUpComplete() + } else { + Toast.makeText(context, "모든 정보를 입력해주세요", Toast.LENGTH_SHORT).show() + } + }, + textMessage = "회원가입하기" + ) + } +} diff --git a/app/src/main/java/com/sopt/dive/ui/theme/Color.kt b/app/src/main/java/com/sopt/dive/ui/theme/Color.kt index cedab92..ce21895 100644 --- a/app/src/main/java/com/sopt/dive/ui/theme/Color.kt +++ b/app/src/main/java/com/sopt/dive/ui/theme/Color.kt @@ -8,4 +8,8 @@ val Pink80 = Color(0xFFEFB8C8) val Purple40 = Color(0xFF6650a4) val PurpleGrey40 = Color(0xFF625b71) -val Pink40 = Color(0xFF7D5260) \ No newline at end of file +val Pink40 = Color(0xFF7D5260) +val SubPinkBackground = Color(0xFFFF7C7C) // #ff7c7c +val MainPinkText = Color(0xFFFF5353) // #ff5353 +val MainPinkBackground = Color(0xFFFFA5A5) // #ffa5a5 + diff --git a/app/src/main/java/com/sopt/dive/ui/theme/Theme.kt b/app/src/main/java/com/sopt/dive/ui/theme/Theme.kt index f9d9027..5bb2483 100644 --- a/app/src/main/java/com/sopt/dive/ui/theme/Theme.kt +++ b/app/src/main/java/com/sopt/dive/ui/theme/Theme.kt @@ -1,6 +1,5 @@ package com.sopt.dive.ui.theme -import android.app.Activity import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme diff --git a/app/src/main/java/com/sopt/dive/ui/validators/InputValidators.kt b/app/src/main/java/com/sopt/dive/ui/validators/InputValidators.kt new file mode 100644 index 0000000..6b07bb9 --- /dev/null +++ b/app/src/main/java/com/sopt/dive/ui/validators/InputValidators.kt @@ -0,0 +1,12 @@ +package com.sopt.dive.ui.validators + +object InputValidators { + private val ageRegex = Regex("^([1-9][0-9]?|1[01][0-9]|120)$") + private val emailRegex = Regex(".+@.+") + private val passwordRegex = Regex("^(?=.*[A-Z])(?=.*[a-z])(?=.*\\d)(?=.*[!@#\$%^&*()_+=\\-{}\\[\\]:;\"'<>,.?/`~|]).{8,64}$") + fun isValidId(id:String): Boolean = id.length in 6..10 + fun isValidPw(pw: String): Boolean { return passwordRegex.matches(pw) } + fun isValidNickName(nickname:String): Boolean = nickname.isNotBlank() + fun isValidAge(input: String) = ageRegex.matches(input) + fun isValidEmail(input: String) = emailRegex.matches(input) +} diff --git a/app/src/main/java/com/sopt/dive/util/ErrorMessages.kt b/app/src/main/java/com/sopt/dive/util/ErrorMessages.kt new file mode 100644 index 0000000..a420cc8 --- /dev/null +++ b/app/src/main/java/com/sopt/dive/util/ErrorMessages.kt @@ -0,0 +1,9 @@ +package com.sopt.dive.util + +object ErrorMessages { + const val ID_ERROR_MESSAGE = "ID는 6~10글자 사이여야 합니다." + const val PW_ERROR_MESSAGE = "비밀번호는 8~64자이며 대문자, 소문자, 숫자, 특수문자를 각각 1자 이상 포함해야 합니다." + const val NICKNAME_ERROR_MESSAGE = "이름을 입력해주세요" + const val EMAIL_ERROR_MESSAGE = "이메일 형식으로 입력해주세요." + const val AGE_ERROR_MESSAGE = "나이 형식으로 입력해주세요.(1세 ~ 120세)" +} diff --git a/app/src/main/java/com/sopt/dive/util/Navigator.kt b/app/src/main/java/com/sopt/dive/util/Navigator.kt new file mode 100644 index 0000000..335b1e8 --- /dev/null +++ b/app/src/main/java/com/sopt/dive/util/Navigator.kt @@ -0,0 +1,168 @@ +package com.sopt.dive.util + +import Route +import android.widget.Toast +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +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.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import androidx.lifecycle.createSavedStateHandle +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import androidx.lifecycle.viewmodel.compose.viewModel +import com.sopt.dive.ui.screens.HomeScreen +import com.sopt.dive.ui.screens.LoginScreen +import com.sopt.dive.ui.screens.ProfileScreen +import com.sopt.dive.ui.screens.CardScreen +import com.sopt.dive.ui.screens.SignUpScreen +import com.sopt.dive.viewmodel.UserViewModel + +sealed class BottomNavItem( + val route: Route, + val label: String, + val icon: ImageVector +) { + data object HomeItem : BottomNavItem( + Route.Home, + "Home", + Icons.Default.Home + ) + + data object ProfileItem : BottomNavItem( + Route.Profile, + "Profile", + Icons.Default.Person + ) + + data object SettingsItem : BottomNavItem( + Route.Settings, + "Settings", + Icons.Default.Settings + ) + + companion object { + val items = listOf(HomeItem, ProfileItem, SettingsItem) + } +} + +@Composable +fun Navigator(navController: NavHostController = rememberNavController()) { + val userViewModel: UserViewModel = viewModel( + factory = viewModelFactory { + initializer { + UserViewModel(createSavedStateHandle()) + } + } + ) + val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route + val showBottomBar = currentRoute !in listOf(Route.Login.path, Route.SignUp.path) + + Scaffold( + bottomBar = { + if (showBottomBar) { + NavigationBar { + BottomNavItem.items.forEach { item -> + NavigationBarItem( + selected = currentRoute == item.route.path, // currentRoute와 item.route.path를 비교 + onClick = { + navController.navigate(item.route.path) { + popUpTo(Route.Home.path) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + }, + icon = { Icon(item.icon, contentDescription = item.label) }, + label = { Text(item.label) }) + } + } + } + } + ) { innerPadding -> + NavHost( + navController = navController, + startDestination = Route.Login.path + ) { + composable(Route.Login.path) { + val loginSuccess by userViewModel.loginSuccess.collectAsStateWithLifecycle() + + LoginScreen( + userViewModel = userViewModel, + onLoginSuccess = { + navController.navigate(Route.Home.path) { + popUpTo(Route.Login.path) { inclusive = true } + launchSingleTop = true + } + + }, + onSignUpClick = { + navController.navigate(Route.SignUp.path) + } + + ) + val context = LocalContext.current + LaunchedEffect(loginSuccess) { + when (loginSuccess) { + true -> { + navController.navigate(Route.Home.path) { + popUpTo(Route.Login.path) { inclusive = true } + launchSingleTop = true + } + } + + false -> { + Toast.makeText(context, "로그인 실패 ", Toast.LENGTH_SHORT).show() + } + + null -> "" + } + } + } + + composable(Route.SignUp.path) { + SignUpScreen( + userViewModel = userViewModel, + onSignUpComplete = + { + navController.popBackStack(Route.Login.path, inclusive = false) + } + ) + } + + composable(Route.Home.path) { + HomeScreen(innerPadding) + } + + composable(Route.Profile.path) { + val loggedInUserId by userViewModel.loggedInUserId.collectAsState() + ProfileScreen(innerPadding, userViewModel) + + LaunchedEffect(loggedInUserId) { + loggedInUserId?.let { userViewModel.fetchUser(it) } + } + } + + composable(Route.Settings.path) { + CardScreen(innerPadding) + } + } + } +} diff --git a/app/src/main/java/com/sopt/dive/util/Route.kt b/app/src/main/java/com/sopt/dive/util/Route.kt new file mode 100644 index 0000000..9a58248 --- /dev/null +++ b/app/src/main/java/com/sopt/dive/util/Route.kt @@ -0,0 +1,7 @@ +sealed class Route(val path: String) { + object Login : Route("login") + object SignUp : Route("signup") + object Home : Route("home") + object Profile : Route("profile") + object Settings : Route("settings") +} diff --git a/app/src/main/java/com/sopt/dive/viewmodel/MainViewModel.kt b/app/src/main/java/com/sopt/dive/viewmodel/MainViewModel.kt new file mode 100644 index 0000000..7c7af94 --- /dev/null +++ b/app/src/main/java/com/sopt/dive/viewmodel/MainViewModel.kt @@ -0,0 +1,43 @@ +package com.sopt.dive.viewmodel + +import androidx.lifecycle.ViewModel +import com.sopt.dive.model.HomeProfileInfo +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +// MainViewModel에서 갱신해줘야할 State : UI에서 관리하는 것을 데이터 클래스로 보관하고 State만 렌더링 + +class MainViewModel : ViewModel() { + // 내부 전용으로만 사용해서 밖에서 변경하지 못하도록 (변경 가능한 데이터 흐름) + private val _homeProfileList = MutableStateFlow>(emptyList()) + + // 외부 전용으로만 사용해서 읽기 전용으로 사용 + val homeProfileList: StateFlow> = _homeProfileList.asStateFlow() + + init { + loadHomeProfileList() + } + + private fun loadHomeProfileList() { + _homeProfileList.value = listOf( + HomeProfileInfo(userName = "지니", title = "집 보내주세요", content = "일하기 싫어요 "), + HomeProfileInfo(userName = "지니", title = "집 보내주세요2", content = "일하기 싫어요 "), + HomeProfileInfo(userName = "지니", title = "집 보내주세요3", content = "일하기 싫어요 "), + HomeProfileInfo(userName = "지니", title = "집 보내주세요4", content = "일하기 싫어요 "), + HomeProfileInfo(userName = "지니", title = "집 보내주세요5", content = "일하기 싫어요 "), + HomeProfileInfo(userName = "지니", title = "집 보내주세요6", content = "일하기 싫어요 "), + HomeProfileInfo(userName = "지니", title = "집 보내주세요7", content = "일하기 싫어요 "), + HomeProfileInfo(userName = "지니", title = "집 보내주세요8", content = "일하기 싫어요 "), + HomeProfileInfo(userName = "지니", title = "집 보내주세요9", content = "일하기 싫어요 "), + HomeProfileInfo(userName = "지니", title = "집 보내주세요10", content = "일하기 싫어요 "), + HomeProfileInfo(userName = "지니", title = "집 보내주세요11", content = "일하기 싫어요 "), + HomeProfileInfo(userName = "지니", title = "집 보내주세요12", content = "일하기 싫어요 "), + HomeProfileInfo(userName = "지니", title = "집 보내주세요13", content = "일하기 싫어요 "), + HomeProfileInfo(userName = "지니", title = "집 보내주세요14", content = "일하기 싫어요 "), + HomeProfileInfo(userName = "지니", title = "집 보내주세요15", content = "일하기 싫어요 "), + ) + } + +} + diff --git a/app/src/main/java/com/sopt/dive/viewmodel/UserViewModel.kt b/app/src/main/java/com/sopt/dive/viewmodel/UserViewModel.kt new file mode 100644 index 0000000..1690d97 --- /dev/null +++ b/app/src/main/java/com/sopt/dive/viewmodel/UserViewModel.kt @@ -0,0 +1,131 @@ +package com.sopt.dive.viewmodel + + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.sopt.dive.data.ServicePool +import com.sopt.dive.data.dto.RequestLoginDto +import com.sopt.dive.data.dto.RequestSignupDto +import com.sopt.dive.data.dto.ResponseUserDto +import com.sopt.dive.model.User +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +// UserViewMpdel에서 관리해야할 State : UI에서 관리하는 것을 데이터 클래스로 보관하고 State만 렌더링 +/* +id , pw, 닉네임, 생일 => 로그인과 회원가입 창에서 사용할 데이터 +* */ +class UserViewModel( + private val savedStateHandle: SavedStateHandle //? +) : ViewModel() { + companion object { + private const val USER = "user" + } + + private val userService by lazy { ServicePool.userService } + private val authService by lazy { ServicePool.authService } + + private val _loginSuccess = MutableStateFlow(null) + val loginSuccess = _loginSuccess.asStateFlow() + + private val _loggedInUserId = MutableStateFlow(null) + val loggedInUserId: StateFlow = _loggedInUserId.asStateFlow() + + private val _userDetail = MutableStateFlow(null) + + private var _currentUser = MutableStateFlow(null) + val currentUser: StateFlow = _currentUser.asStateFlow() + + fun signUpUser(request: RequestSignupDto) { + viewModelScope.launch { + try { + val response = userService.signup(request) + Log.d("====SignUp", "====HTTP Status: ${response.code()}") + if (response.isSuccessful){ + val body = response.body() + val user = body?.data?.toUser() + + _currentUser.value = user + _userDetail.value = user + } + else { + Log.e("signup_error","오류: ${response.code()}") + } + }catch (t: Throwable){ + Log.e("SignUp - Failure ",t.message.toString()) + } + } + } + + + fun loginUser(request : RequestLoginDto){ + viewModelScope.launch { + try { + val response = authService.login(request) + Log.d("==== login","=====HTTP Status : ${response.code()}") + + if(response.isSuccessful){ + val body = response.body() + _loggedInUserId.value = body?.data?.userId + _loginSuccess.value = body?.success ==true + _loggedInUserId.value?.let { fetchUser(it) } + }else { + _loginSuccess.value = false + Log.e("=====login_error","오류: ${response.code()}") + + Log.e("=====login_errorBody","오류: ${response.body().toString()}") + } + }catch (t: Throwable){ + Log.e("===login_failure",t.message.toString()) + _loginSuccess.value = false + } + } + + + } + + fun fetchUser(userId: Int) { + viewModelScope.launch { + try { + val response = userService.fetchUserInfo(userId) + Log.d("==== fetchUser","=====HTTP Status : ${response.code()}") + + if (response.isSuccessful) { + val body = response.body() + val user = body?.data?.toUser() + + _currentUser.value = user + _userDetail.value = user + + Log.d("fetch success", "유저 정보 조회 성공: $user") + } else { + Log.e("fetch error", "오류: ${response.code()}") + Log.e("fetch errorBody", "오류: ${response.errorBody()?.string()}") + } + + } catch (t: Throwable) { + Log.e("fetch failure", t.message.toString()) + } + } + } + private fun ResponseUserDto.toUser(): User { + return User( + id = this.username, + pw = "", + name = this.name, + email = this.email, + age = this.age + ) + } + + fun logoutUser() { + _currentUser.value = null + savedStateHandle[USER] = null + _loggedInUserId.value = null + } + +} diff --git a/app/src/main/res/drawable/card_front_image.png b/app/src/main/res/drawable/card_front_image.png new file mode 100644 index 0000000..f126da0 Binary files /dev/null and b/app/src/main/res/drawable/card_front_image.png differ diff --git a/app/src/main/res/drawable/card_image.png b/app/src/main/res/drawable/card_image.png new file mode 100644 index 0000000..72c4ebe Binary files /dev/null and b/app/src/main/res/drawable/card_image.png differ diff --git a/app/src/main/res/drawable/profile_photo.jpg b/app/src/main/res/drawable/profile_photo.jpg new file mode 100644 index 0000000..37afc32 Binary files /dev/null and b/app/src/main/res/drawable/profile_photo.jpg differ diff --git a/docs/week1/exploration_modifier.md b/docs/week1/exploration_modifier.md new file mode 100644 index 0000000..103d4a8 --- /dev/null +++ b/docs/week1/exploration_modifier.md @@ -0,0 +1,72 @@ +## Modifier에 관한 구글의 경고는 쓸모있을까? + +1차 세미나를 듣고, 과제를 하면서 Modifier가 굉장히 꾸밈뿐만 아니라, UI의 구조와 동작을 결정하는 중요한 객체라는 것을 알게되었다. +과제 후, Modifier의 공식문서를 다시 읽어보면서 왜 구글이 Modifier를 항상 첫 번째 선택적 매개변수로 두라고 강조하는지 찾아보았다. +공식 문서에서 Modifier는 다음과 같이 정리한다. + + + 1. Modifier는 Optional parameter 중 항상 첫 번째 선택적 매개변수로 두어야한다. + 2. 이름은 반드시 `modifier`로 하고, 기본 값은 Modifier이어야 한다. + 3. Modifier는 UI의 모양, 행동 둘 다 바꾸는 역할을 하기에, 컴포저블의 가장 루트 레이아웃에 적용되어야한다. + 4. Modifier 파라미터는 오직 하나만 존재해야한다. + + +Image + + +### **🤔 그럼 왜 '항상 첫 번째 optional parameter일까?** +이 이유를 찾기 위해 Jetpack Compose 공식 가이드라인과 Compose API Guidelines 문서를 읽어본 결과 다음과 같은 이유를 정리할 수 있었다. + +**1. UI 컴포넌트의 핵심 원칙은 '확장성(Scalability)'과 '일관성(Consistency)'이다.** +UI 컴포넌트에서는 컴포넌트가 장기적으로 유지, 확장될 수 있도록 확장성과 모든 컴포넌트가 동일한 구조와 규칙을 가져 개발자가 새로운 API를 직관적으로 예측할 수 있는 일관성 원칙을 강조한다. +이러한 이유로 Modifier는 거의 모든 컴포넌트에서 사용되므로, 항상 동일한 위치(첫 번째 optional parameter)에 두어야 개발자가 일관되고 예측가능한 방식으로 코드를 작성할 수 있다. + +**2.Modifier는 외형과 행동을 모두 제어하는 파라미터다.** +Modifier의 역할은 모양과 행동에 둘 다 영향 미치는 요소라, 대부분의 컴포넌트에 반드시 존재해야한다. 그래서 대부분의 컴포넌트에 존재하기에 항상 같은 위치에 와야한다고 생각한다. + + +**3. Modifier는 단순하지만 매우 자주 사용되는 파라미터다.** +Modifier는 외형과 행동을 모두 제어하기에, 굉장히 자주 사용된다. 그래서 대부분의 컴포저블이 Modifier를 포함하고 있으며, 이를 맨 앞에 두면 이름을 생략하고 바로 사용할 수 있다. 이렇게 사용하면 직관적으로 작성할 수도 있고, 일관성을 유지할 수 있기 때문이다. + + + + Button(onClick = {}, Modifier.padding(8.dp)) + +### 📚 추가적으로 알게 된 점** + +공식 문서에서는 **Modifier를 변수로 추출해 재사용할 것**을 권장한다. +그 이유는 다음과 같다. + +Image + +- Modifier는 **불변(immutable)** 객체이지만, + 매번 `Modifier.padding(8.dp)` 와 같이 새로 작성하면 + `Compose`는 **새로운 인스턴스**로 인식해 비교 연산이 늘어난다. +- `Modifier` 체인을 변수로 추출해 재사용하면 + `Compose`가 동일한 객체로 인식하여 **비교 비용이 줄어든다.** +- 이로 인해 **불필요한 객체 생성과 비교 연산이 줄어**, + **성능이 개선될 수 있다.** + + +또한 다른 Modifier 체인을 추가하고 싶을 때는 +`then()` 함수를 사용해 기존 Modifier에 안전하게 체이닝할 수 있다. + + val containerModifier = Modifier.padding(8.dp) + + val clickableModifier = Modifier.clickable { /* handle click */ } + + Box( + modifier = containerModifier.then(clickableModifier) + ) { + Text("Click Me!") + } + +이번 탐구 과제를 통해 Modifier에 대해 많은 것을 알게되었다. 다음 과제부터, Modifier의 장점을 활용해서 코드를 구현해야겠다. + +---------- +📄 [관련 공식문서] +[compose component - API 가이드 라인] +https://android.googlesource.com/platform/frameworks/support/+/androidx-main/compose/docs/compose-component-api-guidelines.md + +[compose - modifiers] +https://developer.android.com/develop/ui/compose/modifiers diff --git a/docs/week1/exploration_spacing.md b/docs/week1/exploration_spacing.md new file mode 100644 index 0000000..80fd5f0 --- /dev/null +++ b/docs/week1/exploration_spacing.md @@ -0,0 +1,28 @@ + +## 🫥 빈칸은 어떻게 만들어야할까 ? Spacer, Modifier.padding +빈칸을 구분하는 방법에는 크게 2가지가 있다. `Spacer`와 `Modifier.padding` 이 두개의 차이에 대해 알아보고, 어떨 때 사용해야 적절한지 기준을 정해보려고 한다. + +`Spacer` 와 `Modifier.padding`은 UI를 그릴 때, 컴포넌트 사이 간의 간격을 조정하기 위해 사용된다. + +공식 문서에서 `Modifier.padding()`와 `Spacer`의 설명 +- **Modifier.padding()** + `Modifier.padding()` 은 내부 여백을 만들기 위한 도구이다. + Image + +- **Spacer** + `Spacer`는 요소 간의 빈 공간을 만들어주는 레이아웃 컴포저블이다. + Image + + + +위 공식문서 설명처럼 `Modifier.padding()`은 컴포넌트 자신의 내부 여백을 만들어주는 역할을 한다. 즉 해당 컴포넌트의 콘텐츠가 테두리에 너무 밀착되지 않도록 안쪽에 여유 공간을 주는 것이다. +반면 `Spacer`는 컴포넌트 사이에 독립적인 빈 공간을 생성하는 레이아웃 컴포저블이다. `Modifier.padding` 처럼 내장되어, 내부 여백을 채우는 것이 아니라 요소 간의 간격을 조정하기 위해 사용되는 빈 레이아웃이다. + + +## 🤔 어떨 때 어떤 걸 사용하는 것이 좋을까 ? +위에서 알아본 것처럼 `Spacer`와 *Modifier.padding*은 겉보기엔 여백을 만든다는 공통점이 있지만 분명 차이점도 있다. 이번에 찾아보면서 생각해본 방법은 아래와 같다. +- 컴포넌트 내부의 여백 -> ***Modifier.padding*** +- 컴포넌트 사이 간격 -> ***Spacer*** +- 유연한 공간 채움 -> ***Spacer, Modifier.weight*** + +-> *실습하면서 로그인 버튼만 아래로 배치하고 싶었는데, 안되는 어려움을 겪었을 때 OB님이 알려주신 방법이다... `Spacer`와 `Modifier.weight(1f)`를 같이 사용하면 `Modifier.weight`가 비율을 계산해 버튼이 배치되고 남은 부분에 `Spacer`가 남은 공간을 모두 차지하여 레이아웃이 자연스럽게 화면 전체에 균형있게 배치된다고 ,, OB님 짱 👍 ,, ,,,* diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d9e638a..4aa717e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,6 +8,12 @@ espressoCore = "3.7.0" lifecycleRuntimeKtx = "2.9.4" activityCompose = "1.11.0" composeBom = "2024.09.00" +androidxComposeNavigation = "2.8.9" +lifecycleViewmodel = "2.9.4" +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" } @@ -24,9 +30,16 @@ 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" } +androidx-compose-navigation = { group = "androidx.navigation", name = "navigation-compose", version.ref = "androidxComposeNavigation" } +androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycleViewmodel" } +androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodel" } +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" } 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" }