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 파라미터는 오직 하나만 존재해야한다.
+
+
+
+
+
+### **🤔 그럼 왜 '항상 첫 번째 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를 변수로 추출해 재사용할 것**을 권장한다.
+그 이유는 다음과 같다.
+
+
+
+- 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()` 은 내부 여백을 만들기 위한 도구이다.
+
+
+- **Spacer**
+ `Spacer`는 요소 간의 빈 공간을 만들어주는 레이아웃 컴포저블이다.
+
+
+
+
+위 공식문서 설명처럼 `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" }