diff --git a/week7/app/build.gradle.kts b/week7/app/build.gradle.kts index 00b281e8..bc15c331 100644 --- a/week7/app/build.gradle.kts +++ b/week7/app/build.gradle.kts @@ -1,9 +1,16 @@ +import java.util.Properties +import kotlin.apply + plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.serialization) } +val properties = Properties().apply{ + load(project.rootProject.file("local.properties").inputStream()) +} + android { namespace = "com.example.nike" compileSdk { @@ -20,6 +27,8 @@ android { versionName = "1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + buildConfigField("String", "BASE_URL", properties["base.url"].toString()) + buildConfigField("String", "API_KEY", "\"${properties["api.key"]}\"") } buildTypes { @@ -37,6 +46,7 @@ android { } buildFeatures { compose = true + buildConfig = true } } @@ -59,4 +69,10 @@ dependencies { androidTestImplementation(libs.androidx.compose.ui.test.junit4) debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.test.manifest) + implementation("io.coil-kt:coil-compose:2.6.0") + implementation("com.squareup.retrofit2:retrofit:2.9.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") + implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0") + implementation("com.squareup.okhttp3:logging-interceptor:4.12.0") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0") } \ No newline at end of file diff --git a/week7/app/src/main/AndroidManifest.xml b/week7/app/src/main/AndroidManifest.xml index 6d34e1c6..a78a9ac6 100644 --- a/week7/app/src/main/AndroidManifest.xml +++ b/week7/app/src/main/AndroidManifest.xml @@ -2,6 +2,8 @@ + + + val request = chain.request().newBuilder() + .addHeader("x-api-key", BuildConfig.API_KEY) + .build() + chain.proceed(request) + } + .build() + + private val instance: Retrofit = Retrofit.Builder() + .baseUrl(BASE_URL) + .client(okHttpClient) + .addConverterFactory(json.asConverterFactory("application/json; charset=UTF-8".toMediaType())) + .build() + + fun create(service: Class): T = instance.create(service) + + val profileApi: ProfileApi by lazy { + create(ProfileApi::class.java) + } +} diff --git a/week7/app/src/main/java/com/example/nike/data/remote/api/ProfileApi.kt b/week7/app/src/main/java/com/example/nike/data/remote/api/ProfileApi.kt new file mode 100644 index 00000000..e8d2a513 --- /dev/null +++ b/week7/app/src/main/java/com/example/nike/data/remote/api/ProfileApi.kt @@ -0,0 +1,12 @@ +package com.example.nike.data.remote.api + +import com.example.nike.data.remote.dto.response.UserResponse +import retrofit2.http.GET +import retrofit2.http.Query + +interface ProfileApi { + @GET("users") + suspend fun getUsers( + @Query("page") page: Int = 1, + ): UserResponse +} \ No newline at end of file diff --git a/week7/app/src/main/java/com/example/nike/data/remote/dto/response/UserResponse.kt b/week7/app/src/main/java/com/example/nike/data/remote/dto/response/UserResponse.kt new file mode 100644 index 00000000..fa5e9cb9 --- /dev/null +++ b/week7/app/src/main/java/com/example/nike/data/remote/dto/response/UserResponse.kt @@ -0,0 +1,9 @@ +package com.example.nike.data.remote.dto.response + +import com.example.nike.domain.model.profile.User +import kotlinx.serialization.Serializable + +@Serializable +data class UserResponse( + val data: List, +) \ No newline at end of file diff --git a/week7/app/src/main/java/com/example/nike/data/remote/repository/ProfileRepository.kt b/week7/app/src/main/java/com/example/nike/data/remote/repository/ProfileRepository.kt new file mode 100644 index 00000000..7bff58b1 --- /dev/null +++ b/week7/app/src/main/java/com/example/nike/data/remote/repository/ProfileRepository.kt @@ -0,0 +1,12 @@ +package com.example.nike.data.remote.repository + +import com.example.nike.data.remote.RetrofitClient +import com.example.nike.domain.model.profile.User + +class ProfileRepository { + private val profileApi = RetrofitClient.profileApi + + suspend fun getUsers(): List { + return profileApi.getUsers().data + } +} \ No newline at end of file diff --git a/week7/app/src/main/java/com/example/nike/domain/model/profile/User.kt b/week7/app/src/main/java/com/example/nike/domain/model/profile/User.kt new file mode 100644 index 00000000..efd9da31 --- /dev/null +++ b/week7/app/src/main/java/com/example/nike/domain/model/profile/User.kt @@ -0,0 +1,18 @@ +package com.example.nike.domain.model.profile + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class User( + @SerialName("id") + val id: Int, + @SerialName("email") + val email: String, + @SerialName("first_name") + val firstName: String, + @SerialName("last_name") + val lastName: String, + @SerialName("avatar") + val avatar: String, +) \ No newline at end of file diff --git a/week7/app/src/main/java/com/example/nike/presentation/profile/ProfileScreen.kt b/week7/app/src/main/java/com/example/nike/presentation/profile/ProfileScreen.kt index 28087aff..e469e5f5 100644 --- a/week7/app/src/main/java/com/example/nike/presentation/profile/ProfileScreen.kt +++ b/week7/app/src/main/java/com/example/nike/presentation/profile/ProfileScreen.kt @@ -1,37 +1,351 @@ package com.example.nike.presentation.profile +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import coil.compose.AsyncImage +import com.example.nike.R import com.example.nike.core.designsystem.theme.NikeTheme +import com.example.nike.domain.model.profile.User @Composable fun ProfileRoute( modifier: Modifier = Modifier, + viewModel: ProfileViewModel = viewModel(), ) { + val users by viewModel.users.collectAsStateWithLifecycle() + + val me = users.find { it.id == 1 } + val following = users.filter { it.id != 1 } + ProfileScreen( + me = me, + followingList = following, modifier = modifier, ) } @Composable private fun ProfileScreen( + me: User?, + followingList: List, modifier: Modifier = Modifier, ) { - Box( + LazyColumn( modifier = modifier.fillMaxSize(), - contentAlignment = Alignment.Center, ) { + item { + MyProfileSection(me = me) + } + + item { + HorizontalDivider(color = Color(0xFFF6F6F6), thickness = 8.dp) + } + + item { + MemberBenefitSection() + } + + item { + HorizontalDivider(color = Color(0xFFF6F6F6), thickness = 8.dp) + } + + item { + FollowingListSection(followingList = followingList) + } + + item { + Footer() + } + } +} + +@Composable +private fun MyProfileSection( + me: User?, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding( + top = 21.dp, + bottom = 25.dp, + ), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AsyncImage( + model = me?.avatar, + contentDescription = null, + modifier = Modifier + .size(84.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop, + placeholder = painterResource(R.drawable.img_profile_placeholder), + error = painterResource(R.drawable.img_profile_placeholder) + ) + + Spacer(modifier = Modifier.height(30.dp)) + + Text( + text = "${me?.firstName} ${me?.lastName}", + color = Color.Black, + fontSize = 20.sp, + ) + + Spacer(modifier = Modifier.height(30.dp)) + + Box( + modifier = Modifier + .clip(RoundedCornerShape(50)) + .border( + width = 1.dp, + color = Color(0xFFE4E4E4), + shape = RoundedCornerShape(50), + ) + .padding( + vertical = 16.dp, + horizontal = 50.dp, + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.profile_edit), + color = Color.Black, + fontSize = 16.sp, + ) + } + + Spacer(modifier = Modifier.height(40.dp)) + + MenuRow() + } +} + +@Composable +private fun MenuRow( + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + MenuItem( + icon = R.drawable.ic_profile_order, + label = R.string.profile_menu_order, + ) + + VerticalDivider( + color = Color(0xFFCDCDCD), + thickness = 1.dp, + modifier = Modifier + .height(31.dp) + ) + + MenuItem( + icon = R.drawable.ic_profile_pass, + label = R.string.profile_menu_pass, + ) + + VerticalDivider( + color = Color(0xFFCDCDCD), + thickness = 1.dp, + modifier = Modifier + .height(30.dp) + ) + + MenuItem( + icon = R.drawable.ic_profile_event, + label = R.string.profile_menu_event, + ) + + VerticalDivider( + color = Color(0xFFCDCDCD), + thickness = 1.dp, + modifier = Modifier + .height(30.dp) + ) + + MenuItem( + icon = R.drawable.ic_profile_setting, + label = R.string.profile_menu_setting, + ) + } +} + +@Composable +private fun MenuItem( + @DrawableRes icon: Int, + @StringRes label: Int, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(10.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = ImageVector.vectorResource(icon), + contentDescription = null, + tint = Color.Unspecified, + ) + Text( - text = "프로필 화면", + text = stringResource(label), color = Color.Black, - fontSize = 20.sp + fontSize = 12.sp, + ) + } +} + +@Composable +private fun MemberBenefitSection( + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding( + horizontal = 24.dp, + vertical = 32.dp, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column( + modifier = Modifier, + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text( + text = stringResource(R.string.profile_member_benefit), + color = Color.Black, + fontSize = 16.sp, + ) + + Text( + text = stringResource(R.string.profile_member_benefit_count), + color = Color(0xFF767676), + fontSize = 12.sp, + ) + } + + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_profile_chevron_right), + contentDescription = null, + tint = Color.Unspecified, + ) + } +} + +@Composable +private fun FollowingListSection( + followingList: List, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding( + top = 28.dp, + bottom = 115.dp, + ), + verticalArrangement = Arrangement.spacedBy(18.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = 24.dp + ), + horizontalArrangement = Arrangement.Absolute.SpaceBetween, + ) { + Text( + text = stringResource(R.string.profile_following_list, followingList.size), + color = Color.Black, + fontSize = 14.sp, + ) + + Text( + text = stringResource(R.string.profile_following_list_edit), + color = Color(0xFF767676), + fontSize = 12.sp, + ) + } + + LazyRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + contentPadding = PaddingValues(horizontal = 24.dp) + ) { + items(followingList) { user -> + AsyncImage( + model = user.avatar, + contentDescription = null, + modifier = Modifier + .size(105.dp), + contentScale = ContentScale.Crop, + ) + } + } + } +} + +@Composable +private fun Footer( + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .background(Color(0xFFF6F6F6)) + .fillMaxWidth() + .padding( + vertical = 19.dp, + horizontal = 79.dp, + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.profile_footer), + color = Color(0xFF767676), + fontSize = 12.sp, ) } } @@ -39,7 +353,20 @@ private fun ProfileScreen( @Preview(showBackground = true) @Composable private fun ProfileScreenPreview() { + val dummyUsers = listOf( + User(id = 1, email = "me@nike.com", firstName = "김", lastName = "민지", avatar = ""), + User(id = 2, email = "a@nike.com", firstName = "김", lastName = "영희", avatar = ""), + User(id = 3, email = "b@nike.com", firstName = "이", lastName = "서연", avatar = ""), + User(id = 4, email = "c@nike.com", firstName = "박", lastName = "철수", avatar = ""), + ) + + val me = dummyUsers.find { it.id == 1 } + val following = dummyUsers.filter { it.id != 1 } + NikeTheme { - ProfileScreen() + ProfileScreen( + me = me, + followingList = following, + ) } } \ No newline at end of file diff --git a/week7/app/src/main/java/com/example/nike/presentation/profile/ProfileViewModel.kt b/week7/app/src/main/java/com/example/nike/presentation/profile/ProfileViewModel.kt new file mode 100644 index 00000000..e564acc4 --- /dev/null +++ b/week7/app/src/main/java/com/example/nike/presentation/profile/ProfileViewModel.kt @@ -0,0 +1,34 @@ +package com.example.nike.presentation.profile + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.nike.data.remote.repository.ProfileRepository +import com.example.nike.domain.model.profile.User +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class ProfileViewModel : ViewModel() { + + private val profileRepository = ProfileRepository() + + private val _users = MutableStateFlow>(emptyList()) + val users: StateFlow> = _users.asStateFlow() + + init { + getUsers() + } + + private fun getUsers() { + viewModelScope.launch { + runCatching { + profileRepository.getUsers() + }.onSuccess { users -> + _users.value = users + }.onFailure { + it.printStackTrace() + } + } + } +} \ No newline at end of file diff --git a/week7/app/src/main/res/drawable/ic_profile_chevron_right.xml b/week7/app/src/main/res/drawable/ic_profile_chevron_right.xml new file mode 100644 index 00000000..bd3e8c53 --- /dev/null +++ b/week7/app/src/main/res/drawable/ic_profile_chevron_right.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/week7/app/src/main/res/drawable/ic_profile_event.xml b/week7/app/src/main/res/drawable/ic_profile_event.xml new file mode 100644 index 00000000..87b97327 --- /dev/null +++ b/week7/app/src/main/res/drawable/ic_profile_event.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/week7/app/src/main/res/drawable/ic_profile_order.xml b/week7/app/src/main/res/drawable/ic_profile_order.xml new file mode 100644 index 00000000..bec2bbf2 --- /dev/null +++ b/week7/app/src/main/res/drawable/ic_profile_order.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/week7/app/src/main/res/drawable/ic_profile_pass.xml b/week7/app/src/main/res/drawable/ic_profile_pass.xml new file mode 100644 index 00000000..8512c200 --- /dev/null +++ b/week7/app/src/main/res/drawable/ic_profile_pass.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/week7/app/src/main/res/drawable/ic_profile_setting.xml b/week7/app/src/main/res/drawable/ic_profile_setting.xml new file mode 100644 index 00000000..befb75a8 --- /dev/null +++ b/week7/app/src/main/res/drawable/ic_profile_setting.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/week7/app/src/main/res/drawable/img_profile_placeholder.png b/week7/app/src/main/res/drawable/img_profile_placeholder.png new file mode 100644 index 00000000..62dbd5fa Binary files /dev/null and b/week7/app/src/main/res/drawable/img_profile_placeholder.png differ diff --git a/week7/app/src/main/res/values/strings.xml b/week7/app/src/main/res/values/strings.xml index 2dca00de..b40db328 100644 --- a/week7/app/src/main/res/values/strings.xml +++ b/week7/app/src/main/res/values/strings.xml @@ -23,4 +23,15 @@ 위시리스트 %d Colours + + 프로필 수정 + 주문 + 패스 + 이벤트 + 설정 + 나이키 멤버 혜택 + 0개 사용 가능 + 팔로잉 (%d) + 편집 + 회원 가입일: 2025년 9월 \ No newline at end of file diff --git a/week7/build.gradle.kts b/week7/build.gradle.kts index 3ca28836..00d2c4a2 100644 --- a/week7/build.gradle.kts +++ b/week7/build.gradle.kts @@ -2,5 +2,5 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.kotlin.compose) apply false - + id("org.jetbrains.kotlin.plugin.serialization") version "2.2.10" apply false } \ No newline at end of file