diff --git a/Week09/Deku/.gitignore b/Week09/Deku/.gitignore new file mode 100644 index 0000000..841dd0e --- /dev/null +++ b/Week09/Deku/.gitignore @@ -0,0 +1,22 @@ +*.iml +.gradle +.kotlin/ +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties +*.apk +*.ap_ +*.aab +*.jks +*.keystore +*.hprof diff --git a/Week09/Deku/.idea/.gitignore b/Week09/Deku/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/Week09/Deku/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/Week09/Deku/.idea/AndroidProjectSystem.xml b/Week09/Deku/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/Week09/Deku/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/Week09/Deku/.idea/compiler.xml b/Week09/Deku/.idea/compiler.xml new file mode 100644 index 0000000..b86273d --- /dev/null +++ b/Week09/Deku/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Week09/Deku/.idea/deploymentTargetSelector.xml b/Week09/Deku/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..ca16a99 --- /dev/null +++ b/Week09/Deku/.idea/deploymentTargetSelector.xml @@ -0,0 +1,11 @@ + + + + + + + + + \ No newline at end of file diff --git a/Week09/Deku/.idea/deviceManager.xml b/Week09/Deku/.idea/deviceManager.xml new file mode 100644 index 0000000..91f9558 --- /dev/null +++ b/Week09/Deku/.idea/deviceManager.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/Week09/Deku/.idea/gradle.xml b/Week09/Deku/.idea/gradle.xml new file mode 100644 index 0000000..639c779 --- /dev/null +++ b/Week09/Deku/.idea/gradle.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/Week09/Deku/.idea/inspectionProfiles/Project_Default.xml b/Week09/Deku/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..7061a0d --- /dev/null +++ b/Week09/Deku/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,61 @@ + + + + \ No newline at end of file diff --git a/Week09/Deku/.idea/migrations.xml b/Week09/Deku/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/Week09/Deku/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/Week09/Deku/.idea/misc.xml b/Week09/Deku/.idea/misc.xml new file mode 100644 index 0000000..b2c751a --- /dev/null +++ b/Week09/Deku/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/Week09/Deku/.idea/runConfigurations.xml b/Week09/Deku/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/Week09/Deku/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/Week09/Deku/.idea/vcs.xml b/Week09/Deku/.idea/vcs.xml new file mode 100644 index 0000000..b2bdec2 --- /dev/null +++ b/Week09/Deku/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Week09/Deku/app/.gitignore b/Week09/Deku/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/Week09/Deku/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/Week09/Deku/app/build.gradle.kts b/Week09/Deku/app/build.gradle.kts new file mode 100644 index 0000000..8d11551 --- /dev/null +++ b/Week09/Deku/app/build.gradle.kts @@ -0,0 +1,86 @@ +import java.util.Properties + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) +} + +val localProperties = Properties().apply { + val localPropertiesFile = rootProject.file("local.properties") + if (localPropertiesFile.exists()) { + localPropertiesFile.inputStream().use(::load) + } +} + +fun buildConfigString(value: String): String { + return "\"${value.replace("\\", "\\\\").replace("\"", "\\\"")}\"" +} + +val reqResApiKey = localProperties.getProperty("REQRES_API_KEY") + ?: System.getenv("REQRES_API_KEY") + ?: "" + +android { + namespace = "com.example.deku" + compileSdk { + version = release(36) + } + + defaultConfig { + applicationId = "com.example.deku" + minSdk = 24 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + buildConfigField("String", "REQRES_API_KEY", buildConfigString(reqResApiKey)) + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + buildFeatures { + compose = true + buildConfig = true + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.lifecycle.viewmodel.ktx) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.kotlinx.serialization.json) + implementation(libs.retrofit) + implementation(libs.retrofit.converter.gson) + implementation(libs.okhttp.logging.interceptor) + implementation(libs.gson) + implementation(libs.coil.compose) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + debugImplementation(libs.androidx.compose.ui.tooling) + debugImplementation(libs.androidx.compose.ui.test.manifest) +} diff --git a/Week09/Deku/app/proguard-rules.pro b/Week09/Deku/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/Week09/Deku/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/Week09/Deku/app/src/androidTest/java/com/example/deku/ExampleInstrumentedTest.kt b/Week09/Deku/app/src/androidTest/java/com/example/deku/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..05aaa19 --- /dev/null +++ b/Week09/Deku/app/src/androidTest/java/com/example/deku/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.example.deku + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.example.deku", appContext.packageName) + } +} \ No newline at end of file diff --git a/Week09/Deku/app/src/main/AndroidManifest.xml b/Week09/Deku/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..53bdcf4 --- /dev/null +++ b/Week09/Deku/app/src/main/AndroidManifest.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + diff --git a/Week09/Deku/app/src/main/java/com/example/deku/MainActivity.kt b/Week09/Deku/app/src/main/java/com/example/deku/MainActivity.kt new file mode 100644 index 0000000..fc0cca4 --- /dev/null +++ b/Week09/Deku/app/src/main/java/com/example/deku/MainActivity.kt @@ -0,0 +1,29 @@ +// 앱의 메인 Activity로, Splash에서 받은 홈 타이틀을 Compose 화면 트리에 전달합니다. + +package com.example.deku + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import com.example.deku.core.common.DEFAULT_HOME_TITLE +import com.example.deku.core.common.EXTRA_HOME_TITLE +import com.example.deku.core.designsystem.theme.DekuTheme +import com.example.deku.feature.main.MainScreen + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + // Splash에서 전달한 title을 HomeScreen까지 내려보내는 7주차 시니어 미션 흐름입니다. + val homeTitle = intent.getStringExtra(EXTRA_HOME_TITLE) ?: DEFAULT_HOME_TITLE + + // XML 레이아웃 없이 Compose 테마와 최상위 화면을 여기서 시작합니다. + setContent { + DekuTheme { + MainScreen(homeTitle = homeTitle) + } + } + } +} diff --git a/Week09/Deku/app/src/main/java/com/example/deku/SplashActivity.kt b/Week09/Deku/app/src/main/java/com/example/deku/SplashActivity.kt new file mode 100644 index 0000000..596b758 --- /dev/null +++ b/Week09/Deku/app/src/main/java/com/example/deku/SplashActivity.kt @@ -0,0 +1,36 @@ +// 앱 첫 진입 Activity로, 스플래시 화면을 보여준 뒤 MainActivity로 이동합니다. + +package com.example.deku + +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import com.example.deku.core.common.EXTRA_HOME_TITLE +import com.example.deku.core.common.SPLASH_HOME_TITLE +import com.example.deku.core.designsystem.theme.DekuTheme +import com.example.deku.feature.splash.SplashScreen + +class SplashActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + // SplashScreen은 화면 표시와 타이머만 담당하고, Activity 전환은 이 콜백에서 처리합니다. + setContent { + DekuTheme { + SplashScreen( + onTimeout = { + val intent = Intent(this, MainActivity::class.java).apply { + // Activity 간 데이터 전달: MainActivity가 이 값을 읽어 Home route의 인자로 사용합니다. + putExtra(EXTRA_HOME_TITLE, SPLASH_HOME_TITLE) + } + startActivity(intent) + finish() + } + ) + } + } + } +} diff --git a/Week09/Deku/app/src/main/java/com/example/deku/core/common/AppConstants.kt b/Week09/Deku/app/src/main/java/com/example/deku/core/common/AppConstants.kt new file mode 100644 index 0000000..14045f7 --- /dev/null +++ b/Week09/Deku/app/src/main/java/com/example/deku/core/common/AppConstants.kt @@ -0,0 +1,8 @@ +// Activity 간 전달값과 홈 화면 기본 문구처럼 앱 전역에서 쓰는 상수를 모아둔 파일입니다. + +package com.example.deku.core.common + +const val EXTRA_HOME_TITLE = "com.example.deku.extra.HOME_TITLE" +const val SPLASH_HOME_TITLE = "NIKE" + +internal const val DEFAULT_HOME_TITLE = "Discover" diff --git a/Week09/Deku/app/src/main/java/com/example/deku/core/component/MainBottomBar.kt b/Week09/Deku/app/src/main/java/com/example/deku/core/component/MainBottomBar.kt new file mode 100644 index 0000000..3e86ee1 --- /dev/null +++ b/Week09/Deku/app/src/main/java/com/example/deku/core/component/MainBottomBar.kt @@ -0,0 +1,112 @@ +// 앱 하단 탭 바 UI와 탭 항목 정의를 모아둔 파일입니다. + +package com.example.deku.core.component + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.res.painterResource +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.example.deku.R +import com.example.deku.core.designsystem.ColorDivider +import com.example.deku.core.designsystem.ColorNavUnselected +import com.example.deku.core.designsystem.ColorTextPrimary +import com.example.deku.navigation.MainRouteName + +private data class BottomTabItem( + val label: String, + @param:DrawableRes val iconRes: Int, + val routeName: String +) + +private val bottomTabs = listOf( + BottomTabItem("홈", R.drawable.home, MainRouteName.HOME), + BottomTabItem("구매하기", R.drawable.shop, MainRouteName.SHOP), + BottomTabItem("위시리스트", R.drawable.heart, MainRouteName.WISH_LIST), + BottomTabItem("장바구니", R.drawable.cart, MainRouteName.CART), + BottomTabItem("프로필", R.drawable.profile, MainRouteName.PROFILE) +) + +@Composable +fun MainBottomBar( + currentRoute: String?, + onTabSelected: (String) -> Unit +) { + // 선택된 탭 계산은 현재 routeName과 탭 routeName을 비교해 단순하게 유지합니다. + Surface( + color = Color.White, + shadowElevation = 0.dp + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(Color.White) + .navigationBarsPadding() + ) { + HorizontalDivider(color = ColorDivider, thickness = 1.dp) + Row( + modifier = Modifier + .fillMaxWidth() + .height(64.dp) + .padding(horizontal = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + bottomTabs.forEach { item -> + val selected = currentRoute == item.routeName + val color = if (selected) ColorTextPrimary else ColorNavUnselected + + Column( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .clip(RoundedCornerShape(8.dp)) + .clickable { onTabSelected(item.routeName) } + .padding(vertical = 6.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + painter = painterResource(id = item.iconRes), + contentDescription = item.label, + tint = color, + modifier = Modifier.size(29.dp) + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = item.label, + color = color, + fontSize = 10.sp, + lineHeight = 12.sp, + fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal, + maxLines = 1, + textAlign = TextAlign.Center + ) + } + } + } + } + } +} diff --git a/Week09/Deku/app/src/main/java/com/example/deku/core/designsystem/AppColors.kt b/Week09/Deku/app/src/main/java/com/example/deku/core/designsystem/AppColors.kt new file mode 100644 index 0000000..620f2f7 --- /dev/null +++ b/Week09/Deku/app/src/main/java/com/example/deku/core/designsystem/AppColors.kt @@ -0,0 +1,11 @@ +// 화면 컴포넌트에서 직접 참조하는 앱 공통 색상 토큰입니다. + +package com.example.deku.core.designsystem + +import androidx.compose.ui.graphics.Color + +val ColorTextPrimary = Color(0xFF111111) +val ColorTextSecondary = Color(0xFF5F5F5F) +val ColorNavUnselected = Color(0xFF8A8A8A) +val ColorFrameBackground = Color(0xFFF7F7F7) +val ColorDivider = Color(0x1F203126) diff --git a/Week09/Deku/app/src/main/java/com/example/deku/core/designsystem/theme/Color.kt b/Week09/Deku/app/src/main/java/com/example/deku/core/designsystem/theme/Color.kt new file mode 100644 index 0000000..1c22185 --- /dev/null +++ b/Week09/Deku/app/src/main/java/com/example/deku/core/designsystem/theme/Color.kt @@ -0,0 +1,11 @@ +// MaterialTheme 색상 스킴을 구성하는 Nike 스타일 기본 색상입니다. + +package com.example.deku.core.designsystem.theme + +import androidx.compose.ui.graphics.Color + +val NikeBlack = Color(0xFF111111) +val NikeWhite = Color(0xFFFFFFFF) +val NikeGray = Color(0xFF8A8A8A) +val NikeDarkGray = Color(0xFF2C2C2C) +val NikeFrameBackground = Color(0xFFF7F7F7) diff --git a/Week09/Deku/app/src/main/java/com/example/deku/core/designsystem/theme/Theme.kt b/Week09/Deku/app/src/main/java/com/example/deku/core/designsystem/theme/Theme.kt new file mode 100644 index 0000000..e42f3c3 --- /dev/null +++ b/Week09/Deku/app/src/main/java/com/example/deku/core/designsystem/theme/Theme.kt @@ -0,0 +1,64 @@ +// 앱 전체 MaterialTheme을 설정하는 Compose 테마 파일입니다. + +package com.example.deku.core.designsystem.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = NikeWhite, + secondary = NikeGray, + tertiary = NikeFrameBackground, + background = NikeBlack, + surface = NikeBlack, + onPrimary = NikeBlack, + onSecondary = NikeWhite, + onTertiary = NikeBlack, + onBackground = NikeWhite, + onSurface = NikeWhite +) + +private val LightColorScheme = lightColorScheme( + primary = NikeBlack, + secondary = NikeDarkGray, + tertiary = NikeFrameBackground, + background = NikeWhite, + surface = NikeWhite, + onPrimary = NikeWhite, + onSecondary = NikeWhite, + onTertiary = NikeBlack, + onBackground = NikeBlack, + onSurface = NikeBlack +) + +@Composable +fun DekuTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = false, + content: @Composable () -> Unit +) { + // 브랜드 색상을 유지하기 위해 dynamicColor 기본값은 false로 둡니다. + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} diff --git a/Week09/Deku/app/src/main/java/com/example/deku/core/designsystem/theme/Type.kt b/Week09/Deku/app/src/main/java/com/example/deku/core/designsystem/theme/Type.kt new file mode 100644 index 0000000..d6b2dca --- /dev/null +++ b/Week09/Deku/app/src/main/java/com/example/deku/core/designsystem/theme/Type.kt @@ -0,0 +1,21 @@ +// 앱에서 사용하는 Material3 Typography 설정 파일입니다. + +package com.example.deku.core.designsystem.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + + +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + +) diff --git a/Week09/Deku/app/src/main/java/com/example/deku/core/network/NetworkConfig.kt b/Week09/Deku/app/src/main/java/com/example/deku/core/network/NetworkConfig.kt new file mode 100644 index 0000000..bd61d44 --- /dev/null +++ b/Week09/Deku/app/src/main/java/com/example/deku/core/network/NetworkConfig.kt @@ -0,0 +1,8 @@ +package com.example.deku.core.network + +object NetworkConfig { + const val BASE_URL = "https://reqres.in/api/" + const val API_KEY_HEADER = "x-api-key" + const val ENV_HEADER = "X-Reqres-Env" + const val ENV_VALUE = "prod" +} diff --git a/Week09/Deku/app/src/main/java/com/example/deku/core/network/RetrofitProvider.kt b/Week09/Deku/app/src/main/java/com/example/deku/core/network/RetrofitProvider.kt new file mode 100644 index 0000000..0d14d26 --- /dev/null +++ b/Week09/Deku/app/src/main/java/com/example/deku/core/network/RetrofitProvider.kt @@ -0,0 +1,46 @@ +package com.example.deku.core.network + +import com.example.deku.BuildConfig +import com.example.deku.core.network.interceptor.ApiKeyInterceptor +import com.example.deku.feature.profile.data.remote.ProfileApiService +import java.util.concurrent.TimeUnit +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory + +object RetrofitProvider { + + private val loggingInterceptor: HttpLoggingInterceptor by lazy { + HttpLoggingInterceptor().apply { + level = if (BuildConfig.DEBUG) { + HttpLoggingInterceptor.Level.BODY + } else { + HttpLoggingInterceptor.Level.NONE + } + redactHeader(NetworkConfig.API_KEY_HEADER) + } + } + + private val okHttpClient: OkHttpClient by lazy { + OkHttpClient.Builder() + .addInterceptor(ApiKeyInterceptor { BuildConfig.REQRES_API_KEY }) + .addInterceptor(loggingInterceptor) + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build() + } + + private val retrofit: Retrofit by lazy { + Retrofit.Builder() + .baseUrl(NetworkConfig.BASE_URL) + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + } + + val profileService: ProfileApiService by lazy { + retrofit.create(ProfileApiService::class.java) + } +} diff --git a/Week09/Deku/app/src/main/java/com/example/deku/core/network/interceptor/ApiKeyInterceptor.kt b/Week09/Deku/app/src/main/java/com/example/deku/core/network/interceptor/ApiKeyInterceptor.kt new file mode 100644 index 0000000..f59ff31 --- /dev/null +++ b/Week09/Deku/app/src/main/java/com/example/deku/core/network/interceptor/ApiKeyInterceptor.kt @@ -0,0 +1,22 @@ +package com.example.deku.core.network.interceptor + +import com.example.deku.core.network.NetworkConfig +import okhttp3.Interceptor +import okhttp3.Response + +class ApiKeyInterceptor( + private val apiKeyProvider: () -> String, +) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val requestBuilder = chain.request().newBuilder() + .header(NetworkConfig.ENV_HEADER, NetworkConfig.ENV_VALUE) + + apiKeyProvider() + .trim() + .takeIf { it.isNotEmpty() } + ?.let { apiKey -> requestBuilder.header(NetworkConfig.API_KEY_HEADER, apiKey) } + + return chain.proceed(requestBuilder.build()) + } +} diff --git a/Week09/Deku/app/src/main/java/com/example/deku/data/ProductCatalog.kt b/Week09/Deku/app/src/main/java/com/example/deku/data/ProductCatalog.kt new file mode 100644 index 0000000..b29e15d --- /dev/null +++ b/Week09/Deku/app/src/main/java/com/example/deku/data/ProductCatalog.kt @@ -0,0 +1,106 @@ +// 샘플 상품 데이터와 홈/쇼핑 화면에서 사용하는 상품 필터 함수를 모아둔 파일입니다. + +package com.example.deku.data + +import com.example.deku.R + +object ProductCatalog { + private val baseProducts = listOf( + ProductItem( + id = 1, + name = "Air Jordan XXXVI", + price = "US185", + imageResId = R.drawable.nike_item1, + category = CATEGORY_SHOES, + colorCount = 3, + description = PRODUCT_DESCRIPTION + ), + ProductItem( + id = 2, + name = "Nike Air Force 1 '07", + price = "US185", + imageResId = R.drawable.nike_item2, + category = CATEGORY_SHOES, + colorCount = 5, + description = PRODUCT_DESCRIPTION + ), + ProductItem( + id = 3, + name = "Nike Everyday Plus Cushioned", + price = "US185", + imageResId = R.drawable.nike_item3, + category = CATEGORY_SHOES, + colorCount = 2, + description = PRODUCT_DESCRIPTION + ), + ProductItem( + id = 4, + name = "Nike Dri-FIT Primary Top", + price = "US185", + imageResId = R.drawable.nike_item4, + category = CATEGORY_TOPS, + colorCount = 4, + description = PRODUCT_DESCRIPTION + ), + ProductItem( + id = 5, + name = "Nike Everyday Plus Cushioned", + price = "US185", + imageResId = R.drawable.nike_item5, + category = CATEGORY_SHOES, + colorCount = 4, + description = PRODUCT_DESCRIPTION + ), + ProductItem( + id = 6, + name = "Nike Everyday Plus Cushioned", + price = "US185", + imageResId = R.drawable.nike_item6, + category = CATEGORY_SHOES, + colorCount = 4, + description = PRODUCT_DESCRIPTION + ) + ) + + fun initialProducts(): List = buildList { + // 같은 기본 상품을 여러 그룹으로 복제해 스크롤/그리드 테스트가 가능한 목록을 만듭니다. + repeat(DUMMY_PRODUCT_GROUP_COUNT) { groupIndex -> + baseProducts.forEach { product -> + val nextId = groupIndex * baseProducts.size + product.id + val displayName = if (groupIndex == 0) { + product.name + } else { + "${product.name} ${groupIndex + 1}" + } + + add( + product.copy( + id = nextId, + name = displayName + ) + ) + } + } + } + + fun latestProducts(products: List): List = products.take(5) + + // category가 null이면 "전체" 탭 의미이므로 원본 목록을 그대로 반환합니다. + fun productsByCategory( + products: List, + category: String? + ): List { + return if (category == null) { + products + } else { + products.filter { it.category == category } + } + } + + const val CATEGORY_TOPS = "Tops & T-Shirts" + const val CATEGORY_SHOES = "Shoes" + + private const val DUMMY_PRODUCT_GROUP_COUNT = 6 + private const val PRODUCT_DESCRIPTION = + "The Nike Everyday Plus Cushioned Socks bring comfort to your workout with extra cushioning under the heel and forefoot and a snug, supportive arch band. Sweat-wicking power and breathability up top help keep your feet dry and cool to help push you through that extra set." +} diff --git a/Week09/Deku/app/src/main/java/com/example/deku/data/ProductItem.kt b/Week09/Deku/app/src/main/java/com/example/deku/data/ProductItem.kt new file mode 100644 index 0000000..8f3f93e --- /dev/null +++ b/Week09/Deku/app/src/main/java/com/example/deku/data/ProductItem.kt @@ -0,0 +1,16 @@ +// 화면 전반에서 공유하는 상품 모델로, 위시리스트 상태까지 함께 보관합니다. + +package com.example.deku.data + +import androidx.annotation.DrawableRes + +data class ProductItem( + val id: Int, + val name: String, + val price: String, + @param:DrawableRes val imageResId: Int, + val category: String, + val colorCount: Int, + val description: String, + val isWish: Boolean = false +) diff --git a/Week09/Deku/app/src/main/java/com/example/deku/feature/cart/CartScreen.kt b/Week09/Deku/app/src/main/java/com/example/deku/feature/cart/CartScreen.kt new file mode 100644 index 0000000..4b2dca2 --- /dev/null +++ b/Week09/Deku/app/src/main/java/com/example/deku/feature/cart/CartScreen.kt @@ -0,0 +1,103 @@ +// 장바구니 화면 UI로, 비어있는 상태와 주문하기 버튼을 표시합니다. + +package com.example.deku.feature.cart + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +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.example.deku.R +import com.example.deku.core.designsystem.ColorFrameBackground +import com.example.deku.core.designsystem.ColorNavUnselected +import com.example.deku.core.designsystem.ColorTextPrimary +import com.example.deku.core.designsystem.theme.DekuTheme + +@Composable +fun CartScreen( + onOrderClick: () -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .fillMaxSize() + .background(Color.White) + .padding(horizontal = 24.dp) + .padding(top = 40.dp) + ) { + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Surface( + modifier = Modifier.size(70.dp), + shape = CircleShape, + color = ColorFrameBackground + ) { + Icon( + painter = painterResource(id = R.drawable.cart), + contentDescription = null, + tint = ColorNavUnselected, + modifier = Modifier.padding(17.dp) + ) + } + Spacer(modifier = Modifier.height(20.dp)) + Text( + text = "장바구니가 비어있습니다\n제품을 추가하면 여기에 표시됩니다.", + color = ColorTextPrimary, + textAlign = TextAlign.Center, + fontSize = 16.sp, + lineHeight = 24.sp + ) + } + + Button( + onClick = onOrderClick, + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(start = 20.dp, end = 20.dp, bottom = 8.dp) + .height(52.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color.Black, + contentColor = Color.White + ), + shape = RoundedCornerShape(26.dp) + ) { + Text( + text = "주문하기", + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun CartScreenPreview() { + DekuTheme { + CartScreen(onOrderClick = {}) + } +} diff --git a/Week09/Deku/app/src/main/java/com/example/deku/feature/home/HomeScreen.kt b/Week09/Deku/app/src/main/java/com/example/deku/feature/home/HomeScreen.kt new file mode 100644 index 0000000..2908f08 --- /dev/null +++ b/Week09/Deku/app/src/main/java/com/example/deku/feature/home/HomeScreen.kt @@ -0,0 +1,217 @@ +// 홈 화면 UI로, 브랜드 영역과 최신 상품 섹션 및 상단 이동 기능을 구성합니다. + +package com.example.deku.feature.home + +import android.app.Activity +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.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.lazy.rememberLazyListState +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.example.deku.R +import com.example.deku.core.common.SPLASH_HOME_TITLE +import com.example.deku.core.designsystem.ColorTextPrimary +import com.example.deku.core.designsystem.ColorTextSecondary +import com.example.deku.core.designsystem.theme.DekuTheme +import com.example.deku.data.ProductCatalog +import com.example.deku.data.ProductItem +import com.example.deku.feature.product.HomeProductCard +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import kotlinx.coroutines.launch + +private const val BACK_PRESS_INTERVAL_MILLIS = 2_000L + +@Composable +fun HomeScreen( + title: String, + products: List, + onProductClick: (ProductItem) -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val activity = context as? Activity + var lastBackPressedAt by remember { mutableStateOf(0L) } + // 날짜는 화면 진입 시점 기준으로 보여주면 충분하므로 재구성마다 다시 포맷하지 않습니다. + val today = remember { currentKoreanDate() } + val listState = rememberLazyListState() + val coroutineScope = rememberCoroutineScope() + // 스크롤 값은 자주 바뀌므로 derivedStateOf로 버튼 노출 조건이 변할 때만 재구성합니다. + val showScrollToTop by remember { + derivedStateOf { + listState.firstVisibleItemIndex > 2 + } + } + + // Compose에서는 BackHandler로 시스템 뒤로가기를 가로채 2초 안에 두 번 누르면 Activity를 종료합니다. + BackHandler { + val now = System.currentTimeMillis() + if (now - lastBackPressedAt <= BACK_PRESS_INTERVAL_MILLIS) { + activity?.finish() + } else { + lastBackPressedAt = now + Toast.makeText(context, "한 번 더 누르면 종료됩니다.", Toast.LENGTH_SHORT).show() + } + } + + Box( + modifier = modifier + .fillMaxSize() + .background(Color.White) + ) { + // 홈 콘텐츠를 하나의 LazyColumn에 모아 스크롤 상태와 상단 이동 동작을 한 곳에서 관리합니다. + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + start = 24.dp, + top = 50.dp, + end = 24.dp, + bottom = 32.dp + ) + ) { + // contentType을 섹션별로 지정해 Lazy 레이아웃이 서로 다른 UI를 잘못 재사용하지 않게 합니다. + item(contentType = "header") { + Text( + text = title, + color = ColorTextPrimary, + fontSize = 30.sp, + lineHeight = 36.sp, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(10.dp)) + Text( + text = today, + color = ColorTextSecondary, + fontSize = 20.sp, + lineHeight = 26.sp + ) + } + + item(contentType = "logo") { + Spacer(modifier = Modifier.height(48.dp)) + Image( + painter = painterResource(id = R.drawable.nike_logo), + contentDescription = stringResource(id = R.string.home_brand_logo), + modifier = Modifier + .fillParentMaxWidth() + .height(220.dp) + .padding(horizontal = 36.dp) + ) + } + + item(contentType = "latestTitle") { + Spacer(modifier = Modifier.height(28.dp)) + Text( + text = "What's New", + color = ColorTextPrimary, + fontSize = 22.sp, + lineHeight = 28.sp, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = "나이키 최신 상품", + color = ColorTextSecondary, + fontSize = 15.sp, + lineHeight = 20.sp + ) + Spacer(modifier = Modifier.height(18.dp)) + } + + item(contentType = "latestProducts") { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(end = 8.dp) + ) { + items( + // 홈에서는 최신 상품만 보여주고, 전체 상품 탐색은 Shop 화면이 담당합니다. + items = ProductCatalog.latestProducts(products), + // Lazy item key는 RecyclerView의 stable id처럼 아이템 상태를 안정적으로 묶어줍니다. + key = { product -> product.id }, + contentType = { "latestProduct" } + ) { product -> + HomeProductCard( + product = product, + onClick = onProductClick + ) + } + } + Spacer(modifier = Modifier.height(18.dp)) + } + } + + if (showScrollToTop) { + Button( + onClick = { + // animateScrollToItem은 suspend 함수라 Composable 생명주기에 묶인 scope에서 실행합니다. + coroutineScope.launch { + listState.animateScrollToItem(index = 0) + } + }, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 20.dp, bottom = 20.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color.Black, + contentColor = Color.White + ) + ) { + Text( + text = "맨 위로", + fontSize = 14.sp, + fontWeight = FontWeight.Bold + ) + } + } + } +} + +private fun currentKoreanDate(): String { + val formatter = SimpleDateFormat("M월 d일 EEEE", Locale.KOREAN) + return formatter.format(Date()) +} + +@Preview(showBackground = true) +@Composable +private fun HomeScreenPreview() { + DekuTheme { + HomeScreen( + title = SPLASH_HOME_TITLE, + products = ProductCatalog.initialProducts(), + onProductClick = {} + ) + } +} diff --git a/Week09/Deku/app/src/main/java/com/example/deku/feature/main/MainScreen.kt b/Week09/Deku/app/src/main/java/com/example/deku/feature/main/MainScreen.kt new file mode 100644 index 0000000..0321691 --- /dev/null +++ b/Week09/Deku/app/src/main/java/com/example/deku/feature/main/MainScreen.kt @@ -0,0 +1,66 @@ +// 앱의 최상위 Compose 화면으로, Scaffold/BottomBar/Navigation과 공유 상품 상태를 묶습니다. + +package com.example.deku.feature.main + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +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.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import com.example.deku.core.component.MainBottomBar +import com.example.deku.data.ProductCatalog +import com.example.deku.navigation.MainNavGraph +import com.example.deku.navigation.currentBaseRoute +import com.example.deku.navigation.navigateToBottomTab + +@Composable +fun MainScreen(homeTitle: String) { + val navController = rememberNavController() + // 현재 route를 관찰해 BottomBar의 선택 상태와 화면 전환 상태를 맞춥니다. + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = navBackStackEntry?.destination?.route.currentBaseRoute() + // 위시 상태는 여러 탭에서 함께 쓰이므로 최상위 화면에 올려 단일 진실 공급원으로 관리합니다. + var products by remember { mutableStateOf(ProductCatalog.initialProducts()) } + + fun toggleWish(productId: Int) { + // ProductItem이 immutable data class라 copy로 새 리스트를 만들어 Compose 재구성을 유도합니다. + products = products.map { product -> + if (product.id == productId) { + product.copy(isWish = !product.isWish) + } else { + product + } + } + } + + Scaffold( + modifier = Modifier.fillMaxSize(), + containerColor = Color.White, + bottomBar = { + MainBottomBar( + currentRoute = currentRoute, + onTabSelected = { routeName -> + // BottomBar는 routeName만 올리고, 실제 Navigation 처리는 부모가 담당합니다. + navController.navigateToBottomTab(routeName, homeTitle) + } + ) + } + ) { innerPadding -> + MainNavGraph( + navController = navController, + homeTitle = homeTitle, + products = products, + onWishClick = { product -> toggleWish(product.id) }, + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) + } +} diff --git a/Week09/Deku/app/src/main/java/com/example/deku/feature/product/ProductCards.kt b/Week09/Deku/app/src/main/java/com/example/deku/feature/product/ProductCards.kt new file mode 100644 index 0000000..cba2c9c --- /dev/null +++ b/Week09/Deku/app/src/main/java/com/example/deku/feature/product/ProductCards.kt @@ -0,0 +1,243 @@ +// 홈/쇼핑/위시리스트에서 재사용하는 상품 카드와 상품 이미지 컴포넌트 모음입니다. + +package com.example.deku.feature.product + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.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.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.example.deku.R +import com.example.deku.core.designsystem.ColorFrameBackground +import com.example.deku.core.designsystem.ColorTextPrimary +import com.example.deku.core.designsystem.ColorTextSecondary +import com.example.deku.data.ProductItem + +@Composable +fun HomeProductCard( + product: ProductItem, + onClick: (ProductItem) -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .width(220.dp) + .clip(RoundedCornerShape(8.dp)) + .clickable { onClick(product) } + .padding(14.dp) + ) { + ProductImage( + product = product, + modifier = Modifier + .fillMaxWidth() + .height(140.dp), + imagePadding = 20.dp + ) + Spacer(modifier = Modifier.height(14.dp)) + Text( + text = product.name, + color = ColorTextPrimary, + fontSize = 16.sp, + lineHeight = 20.sp, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = product.price, + color = ColorTextSecondary, + fontSize = 14.sp, + lineHeight = 18.sp + ) + } +} + +@Composable +fun ProductListRow( + product: ProductItem, + onClick: (ProductItem) -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable { onClick(product) } + .padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + ProductImage( + product = product, + modifier = Modifier.size(width = 112.dp, height = 92.dp), + imagePadding = 14.dp + ) + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = product.name, + color = ColorTextPrimary, + fontSize = 16.sp, + lineHeight = 20.sp, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = product.category, + color = ColorTextSecondary, + fontSize = 13.sp, + lineHeight = 17.sp + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = "${product.colorCount} Color", + color = ColorTextSecondary, + fontSize = 13.sp, + lineHeight = 17.sp + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = product.price, + color = ColorTextPrimary, + fontSize = 16.sp, + lineHeight = 20.sp, + fontWeight = FontWeight.Bold + ) + } + } +} + +@Composable +fun ProductGridCard( + product: ProductItem, + onClick: (ProductItem) -> Unit, + onWishClick: (ProductItem) -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .clip(RoundedCornerShape(8.dp)) + .clickable { onClick(product) } + .padding(12.dp) + ) { + Box { + ProductImage( + product = product, + modifier = Modifier + .fillMaxWidth() + .height(132.dp), + imagePadding = 18.dp + ) + Surface( + // 카드 전체 클릭과 위시 버튼 클릭 영역을 분리해 상세 이동과 찜 변경을 구분합니다. + modifier = Modifier + .align(Alignment.TopEnd) + .padding(10.dp) + .size(26.dp) + .clip(CircleShape) + .clickable { onWishClick(product) }, + shape = CircleShape, + color = Color.White + ) { + Icon( + painter = painterResource( + id = if (product.isWish) { + R.drawable.heart_filled + } else { + R.drawable.heart + } + ), + contentDescription = if (product.isWish) { + "위시리스트 제거" + } else { + "위시리스트 추가" + }, + tint = Color.Unspecified, + modifier = Modifier.padding(6.dp) + ) + } + } + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = product.name, + color = ColorTextPrimary, + fontSize = 15.sp, + lineHeight = 19.sp, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = product.category, + color = ColorTextSecondary, + fontSize = 12.sp, + lineHeight = 16.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = "${product.colorCount} Color", + color = ColorTextSecondary, + fontSize = 12.sp, + lineHeight = 16.sp + ) + Spacer(modifier = Modifier.height(10.dp)) + Text( + text = product.price, + color = ColorTextPrimary, + fontSize = 16.sp, + lineHeight = 20.sp, + fontWeight = FontWeight.Bold + ) + } +} + +@Composable +fun ProductImage( + product: ProductItem, + modifier: Modifier = Modifier, + imagePadding: androidx.compose.ui.unit.Dp = 18.dp +) { + // 상품 이미지 배경과 padding을 공통화해 카드 종류가 달라도 이미지 톤을 맞춥니다. + Box( + modifier = modifier.background(ColorFrameBackground), + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(id = product.imageResId), + contentDescription = product.name, + contentScale = ContentScale.Fit, + modifier = Modifier + .fillMaxSize() + .padding(imagePadding) + ) + } +} diff --git a/Week09/Deku/app/src/main/java/com/example/deku/feature/product/ProductDetailScreen.kt b/Week09/Deku/app/src/main/java/com/example/deku/feature/product/ProductDetailScreen.kt new file mode 100644 index 0000000..d93aaf2 --- /dev/null +++ b/Week09/Deku/app/src/main/java/com/example/deku/feature/product/ProductDetailScreen.kt @@ -0,0 +1,239 @@ +// 상품 상세 화면 UI로, 상품 정보와 위시리스트 추가/제거 동작을 제공합니다. + +package com.example.deku.feature.product + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +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.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.example.deku.R +import com.example.deku.core.designsystem.ColorDivider +import com.example.deku.core.designsystem.ColorTextPrimary +import com.example.deku.core.designsystem.ColorTextSecondary +import com.example.deku.core.designsystem.theme.DekuTheme +import com.example.deku.data.ProductCatalog +import com.example.deku.data.ProductItem + +@Composable +fun ProductDetailScreen( + product: ProductItem?, + onBackClick: () -> Unit, + onWishClick: (ProductItem) -> Unit, + modifier: Modifier = Modifier +) { + if (product == null) { + // 상세 route에는 id만 전달되므로, 목록에서 못 찾는 예외 상황을 별도 화면으로 처리합니다. + ProductNotFoundScreen( + onBackClick = onBackClick, + modifier = modifier + ) + return + } + + LazyColumn( + modifier = modifier + .fillMaxSize() + .background(Color.White), + contentPadding = PaddingValues( + start = 24.dp, + top = 28.dp, + end = 24.dp, + bottom = 32.dp + ) + ) { + // 상세 화면은 긴 설명까지 포함하므로 전체를 LazyColumn에 넣어 작은 화면에서도 스크롤되게 합니다. + item(contentType = "detail") { + ProductDetailTopBar( + title = product.name, + onBackClick = onBackClick + ) + Spacer(modifier = Modifier.height(28.dp)) + ProductImage( + product = product, + modifier = Modifier + .fillMaxWidth() + .height(320.dp), + imagePadding = 18.dp + ) + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = product.name, + color = ColorTextPrimary, + fontSize = 28.sp, + lineHeight = 34.sp, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = product.price, + color = ColorTextSecondary, + fontSize = 16.sp, + lineHeight = 22.sp + ) + Spacer(modifier = Modifier.height(28.dp)) + Text( + text = product.description, + color = ColorTextSecondary, + fontSize = 15.sp, + lineHeight = 24.sp + ) + Spacer(modifier = Modifier.height(28.dp)) + DetailSecondaryButton( + text = "사이즈 선택", + onClick = {} + ) + Spacer(modifier = Modifier.height(28.dp)) + Button( + onClick = {}, + modifier = Modifier + .fillMaxWidth() + .height(54.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color.Black, + contentColor = Color.White + ), + shape = RoundedCornerShape(14.dp) + ) { + Text( + text = "장바구니 추가", + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) + } + Spacer(modifier = Modifier.height(28.dp)) + DetailSecondaryButton( + text = if (product.isWish) { + "위시리스트 제거" + } else { + "위시리스트 추가" + }, + onClick = { onWishClick(product) } + ) + } + } +} + +@Composable +private fun ProductDetailTopBar( + title: String, + onBackClick: () -> Unit +) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(40.dp), + contentAlignment = Alignment.Center + ) { + IconButton( + onClick = onBackClick, + modifier = Modifier + .align(Alignment.CenterStart) + .size(40.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.ic_arrow_back), + contentDescription = "뒤로 가기", + tint = ColorTextPrimary + ) + } + Text( + text = title, + color = ColorTextPrimary, + fontSize = 20.sp, + lineHeight = 24.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 48.dp) + ) + } +} + +@Composable +private fun DetailSecondaryButton( + text: String, + onClick: () -> Unit +) { + OutlinedButton( + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + .height(54.dp), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = Color.White, + contentColor = ColorTextPrimary + ), + border = BorderStroke(1.dp, ColorDivider), + shape = RoundedCornerShape(14.dp) + ) { + Text( + text = text, + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) + } +} + +@Composable +private fun ProductNotFoundScreen( + onBackClick: () -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .fillMaxSize() + .background(Color.White) + .padding(start = 24.dp, top = 28.dp, end = 24.dp) + ) { + ProductDetailTopBar( + title = "상품 상세", + onBackClick = onBackClick + ) + Text( + text = "상품을 찾을 수 없습니다.", + color = ColorTextSecondary, + fontSize = 16.sp, + modifier = Modifier.align(Alignment.Center) + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun ProductDetailScreenPreview() { + DekuTheme { + ProductDetailScreen( + product = ProductCatalog.initialProducts().first(), + onBackClick = {}, + onWishClick = {} + ) + } +} diff --git a/Week09/Deku/app/src/main/java/com/example/deku/feature/profile/ProfileScreen.kt b/Week09/Deku/app/src/main/java/com/example/deku/feature/profile/ProfileScreen.kt new file mode 100644 index 0000000..69be8e5 --- /dev/null +++ b/Week09/Deku/app/src/main/java/com/example/deku/feature/profile/ProfileScreen.kt @@ -0,0 +1,456 @@ +package com.example.deku.feature.profile + +import android.widget.Toast +import androidx.annotation.DrawableRes +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +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.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PageSize +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import coil.compose.AsyncImage +import coil.request.ImageRequest +import com.example.deku.R +import com.example.deku.core.designsystem.ColorDivider +import com.example.deku.core.designsystem.ColorFrameBackground +import com.example.deku.core.designsystem.ColorTextPrimary +import com.example.deku.core.designsystem.ColorTextSecondary +import com.example.deku.core.designsystem.theme.DekuTheme +import com.example.deku.feature.profile.model.ProfileUser +import com.example.deku.feature.profile.presentation.ProfileUiState +import com.example.deku.feature.profile.presentation.ProfileViewModel +import com.example.deku.feature.profile.presentation.ProfileViewModelFactory + +@Composable +fun ProfileScreen( + modifier: Modifier = Modifier, + viewModel: ProfileViewModel = viewModel(factory = ProfileViewModelFactory()), +) { + val uiState by viewModel.uiState.collectAsState() + val context = LocalContext.current + val errorMessage = (uiState as? ProfileUiState.Error)?.message + + LaunchedEffect(Unit) { + viewModel.loadProfileScreen() + } + + LaunchedEffect(errorMessage) { + errorMessage?.let { message -> + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + } + } + + when (val state = uiState) { + ProfileUiState.Idle, + ProfileUiState.Loading -> ProfileLoadingScreen(modifier = modifier) + + is ProfileUiState.Success -> ProfileContent( + user = state.user, + followingUsers = state.followingUsers, + modifier = modifier + ) + + is ProfileUiState.Error -> ProfileErrorScreen( + message = state.message, + onRetryClick = { viewModel.loadProfileScreen(forceRefresh = true) }, + modifier = modifier + ) + } +} + +@Composable +private fun ProfileLoadingScreen(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .fillMaxSize() + .background(Color.White) + .padding(top = 88.dp), + contentAlignment = Alignment.TopCenter + ) { + CircularProgressIndicator(color = ColorTextPrimary) + } +} + +@Composable +private fun ProfileErrorScreen( + message: String, + onRetryClick: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxSize() + .background(Color.White) + .padding(horizontal = 24.dp) + .padding(top = 88.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(id = R.string.profile_error_title), + color = ColorTextPrimary, + fontSize = 20.sp, + lineHeight = 26.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(10.dp)) + Text( + text = message, + color = ColorTextSecondary, + fontSize = 14.sp, + lineHeight = 20.sp, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(20.dp)) + Button( + onClick = onRetryClick, + modifier = Modifier + .fillMaxWidth() + .height(52.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color.Black, + contentColor = Color.White + ), + shape = RoundedCornerShape(14.dp) + ) { + Text( + text = stringResource(id = R.string.profile_retry), + fontSize = 15.sp, + fontWeight = FontWeight.Bold + ) + } + } +} + +@Composable +private fun ProfileContent( + user: ProfileUser, + followingUsers: List, + modifier: Modifier = Modifier +) { + LazyColumn( + modifier = modifier + .fillMaxSize() + .background(Color.White), + contentPadding = PaddingValues( + start = 24.dp, + top = 64.dp, + end = 24.dp, + bottom = 32.dp + ) + ) { + item(contentType = "profileCard") { + ProfileCard(user = user) + } + + item(contentType = "followingHeader") { + Spacer(modifier = Modifier.height(28.dp)) + FollowingHeader(count = followingUsers.size) + } + + item(contentType = "followingPager") { + Spacer(modifier = Modifier.height(18.dp)) + if (followingUsers.isEmpty()) { + Text( + text = stringResource(id = R.string.profile_following_empty), + color = ColorTextSecondary, + fontSize = 14.sp, + lineHeight = 20.sp + ) + } else { + FollowingPager(users = followingUsers) + } + } + } +} + +@Composable +private fun ProfileCard(user: ProfileUser) { + Column(modifier = Modifier.fillMaxWidth()) { + ProfileAvatar( + avatarUrl = user.avatarUrl, + contentDescription = stringResource(id = R.string.profile_avatar), + modifier = Modifier + .size(92.dp) + .align(Alignment.CenterHorizontally) + ) + Spacer(modifier = Modifier.height(14.dp)) + Text( + text = user.name, + color = ColorTextPrimary, + fontSize = 24.sp, + lineHeight = 30.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(16.dp)) + OutlinedButton( + onClick = {}, + modifier = Modifier + .height(46.dp) + .align(Alignment.CenterHorizontally), + contentPadding = PaddingValues(horizontal = 20.dp), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = Color.White, + contentColor = ColorTextPrimary + ), + border = androidx.compose.foundation.BorderStroke(1.dp, ColorDivider), + shape = RoundedCornerShape(14.dp) + ) { + Text( + text = stringResource(id = R.string.profile_edit), + fontSize = 14.sp, + fontWeight = FontWeight.Bold + ) + } + Spacer(modifier = Modifier.height(28.dp)) + ProfileMenuRow() + Spacer(modifier = Modifier.height(22.dp)) + HorizontalDivider(color = ColorDivider, thickness = 1.dp) + Spacer(modifier = Modifier.height(18.dp)) + BenefitsRow() + Spacer(modifier = Modifier.height(18.dp)) + HorizontalDivider(color = ColorDivider, thickness = 1.dp) + } +} + +@Composable +private fun ProfileAvatar( + avatarUrl: String, + contentDescription: String, + modifier: Modifier = Modifier +) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(avatarUrl) + .crossfade(true) + .build(), + contentDescription = contentDescription, + placeholder = painterResource(id = R.drawable.nike_logo), + error = painterResource(id = R.drawable.nike_logo), + fallback = painterResource(id = R.drawable.nike_logo), + contentScale = ContentScale.Crop, + modifier = modifier + .clip(CircleShape) + .background(ColorFrameBackground) + ) +} + +@Composable +private fun ProfileMenuRow() { + val menuItems = listOf( + ProfileMenuItem(R.string.profile_menu_order, R.drawable.ic_profile_order), + ProfileMenuItem(R.string.profile_menu_pass, R.drawable.ic_profile_pass), + ProfileMenuItem(R.string.profile_menu_event, R.drawable.ic_profile_event), + ProfileMenuItem(R.string.profile_menu_settings, R.drawable.ic_profile_settings) + ) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + menuItems.forEach { item -> + ProfileMenuButton( + item = item, + modifier = Modifier.weight(1f) + ) + } + } +} + +@Composable +private fun ProfileMenuButton( + item: ProfileMenuItem, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier.padding(vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + val label = stringResource(id = item.labelResId) + Icon( + painter = painterResource(id = item.iconResId), + contentDescription = label, + tint = ColorTextPrimary, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = label, + color = ColorTextPrimary, + fontSize = 14.sp, + lineHeight = 18.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + textAlign = TextAlign.Center + ) + } +} + +@Composable +private fun BenefitsRow() { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(id = R.string.profile_benefits_title), + color = ColorTextPrimary, + fontSize = 18.sp, + lineHeight = 24.sp, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(id = R.string.profile_benefits_subtitle), + color = ColorTextSecondary, + fontSize = 13.sp, + lineHeight = 18.sp + ) + } + Icon( + painter = painterResource(id = R.drawable.ic_chevron_right), + contentDescription = null, + tint = ColorTextSecondary, + modifier = Modifier.size(20.dp) + ) + } +} + +@Composable +private fun FollowingHeader(count: Int) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(id = R.string.profile_following_title_with_count, count), + color = ColorTextPrimary, + fontSize = 22.sp, + lineHeight = 28.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f) + ) + Text( + text = stringResource(id = R.string.profile_following_edit), + color = ColorTextPrimary, + fontSize = 15.sp, + lineHeight = 20.sp, + fontWeight = FontWeight.Bold + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun FollowingPager(users: List) { + val pagerState = rememberPagerState(pageCount = { users.size }) + + HorizontalPager( + state = pagerState, + pageSize = PageSize.Fixed(100.dp), + pageSpacing = 12.dp, + contentPadding = PaddingValues(end = 8.dp), + modifier = Modifier + .fillMaxWidth() + .height(124.dp) + ) { page -> + FollowingUserCard(user = users[page]) + } +} + +@Composable +private fun FollowingUserCard(user: ProfileUser) { + Column( + modifier = Modifier + .width(100.dp) + .padding(vertical = 4.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + ProfileAvatar( + avatarUrl = user.avatarUrl, + contentDescription = stringResource(id = R.string.profile_following_avatar), + modifier = Modifier.size(72.dp) + ) + Spacer(modifier = Modifier.height(10.dp)) + Text( + text = user.name, + color = ColorTextPrimary, + fontSize = 13.sp, + lineHeight = 16.sp, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } +} + +private data class ProfileMenuItem( + val labelResId: Int, + @param:DrawableRes val iconResId: Int, +) + +@Preview(showBackground = true) +@Composable +private fun ProfileContentPreview() { + DekuTheme { + ProfileContent( + user = ProfileUser( + id = 1, + name = "George Bluth", + email = "george.bluth@reqres.in", + avatarUrl = "" + ), + followingUsers = listOf( + ProfileUser(2, "Janet Weaver", "janet.weaver@reqres.in", ""), + ProfileUser(3, "Emma Wong", "emma.wong@reqres.in", ""), + ProfileUser(4, "Eve Holt", "eve.holt@reqres.in", "") + ) + ) + } +} diff --git a/Week09/Deku/app/src/main/java/com/example/deku/feature/profile/data/ProfileRepository.kt b/Week09/Deku/app/src/main/java/com/example/deku/feature/profile/data/ProfileRepository.kt new file mode 100644 index 0000000..be99700 --- /dev/null +++ b/Week09/Deku/app/src/main/java/com/example/deku/feature/profile/data/ProfileRepository.kt @@ -0,0 +1,65 @@ +package com.example.deku.feature.profile.data + +import com.example.deku.feature.profile.data.remote.ProfileApiService +import com.example.deku.feature.profile.data.remote.dto.ReqResUserDto +import com.example.deku.feature.profile.model.ProfileUser +import retrofit2.Response + +class ProfileRepository( + private val service: ProfileApiService, +) { + suspend fun getProfile(userId: Int): Result { + return runCatchingResponse( + call = { service.getUser(userId) }, + emptyBodyMessage = "유저 정보를 받아오지 못했습니다.", + ).fold( + onSuccess = { body -> + body.data?.toProfileUser()?.let(Result.Companion::success) + ?: Result.failure(RuntimeException("유저 데이터가 비어 있습니다.")) + }, + onFailure = Result.Companion::failure, + ) + } + + suspend fun getFollowing(page: Int = 1): Result> { + return runCatchingResponse( + call = { service.getUsers(page = page) }, + emptyBodyMessage = "팔로잉 목록을 받아오지 못했습니다.", + ).fold( + onSuccess = { body -> Result.success(body.data.map { user -> user.toProfileUser() }) }, + onFailure = Result.Companion::failure, + ) + } + + private suspend fun runCatchingResponse( + call: suspend () -> Response, + emptyBodyMessage: String, + ): Result { + return try { + val response = call() + + if (response.isSuccessful) { + response.body()?.let(Result.Companion::success) + ?: Result.failure(RuntimeException(emptyBodyMessage)) + } else { + val errorMessage = response.errorBody() + ?.string() + ?.takeIf { body -> body.isNotBlank() } + ?: response.message().ifBlank { "알 수 없는 네트워크 오류가 발생했습니다." } + + Result.failure(RuntimeException("HTTP ${response.code()}: $errorMessage")) + } + } catch (exception: Exception) { + Result.failure(exception) + } + } + + private fun ReqResUserDto.toProfileUser(): ProfileUser { + return ProfileUser( + id = id, + name = fullName, + email = email, + avatarUrl = avatar, + ) + } +} diff --git a/Week09/Deku/app/src/main/java/com/example/deku/feature/profile/data/remote/ProfileApiService.kt b/Week09/Deku/app/src/main/java/com/example/deku/feature/profile/data/remote/ProfileApiService.kt new file mode 100644 index 0000000..48e4779 --- /dev/null +++ b/Week09/Deku/app/src/main/java/com/example/deku/feature/profile/data/remote/ProfileApiService.kt @@ -0,0 +1,22 @@ +package com.example.deku.feature.profile.data.remote + +import com.example.deku.feature.profile.data.remote.dto.SingleUserResponseDto +import com.example.deku.feature.profile.data.remote.dto.UserListResponseDto +import retrofit2.Response +import retrofit2.http.GET +import retrofit2.http.Path +import retrofit2.http.Query + +interface ProfileApiService { + + @GET("users/{id}") + suspend fun getUser( + @Path("id") userId: Int, + ): Response + + @GET("users") + suspend fun getUsers( + @Query("page") page: Int = 1, + @Query("per_page") perPage: Int = 8, + ): Response +} diff --git a/Week09/Deku/app/src/main/java/com/example/deku/feature/profile/data/remote/dto/ProfileDtos.kt b/Week09/Deku/app/src/main/java/com/example/deku/feature/profile/data/remote/dto/ProfileDtos.kt new file mode 100644 index 0000000..2653b9b --- /dev/null +++ b/Week09/Deku/app/src/main/java/com/example/deku/feature/profile/data/remote/dto/ProfileDtos.kt @@ -0,0 +1,51 @@ +package com.example.deku.feature.profile.data.remote.dto + +import com.google.gson.annotations.SerializedName + +data class ReqResUserDto( + @SerializedName("id") + val id: Int = 0, + @SerializedName("email") + val email: String = "", + @SerializedName("first_name") + val firstName: String = "", + @SerializedName("last_name") + val lastName: String = "", + @SerializedName("avatar") + val avatar: String = "", +) { + val fullName: String + get() = listOf(firstName, lastName) + .filter { namePart -> namePart.isNotBlank() } + .joinToString(" ") + .ifBlank { email.ifBlank { "Unknown User" } } +} + +data class SupportDto( + @SerializedName("url") + val url: String = "", + @SerializedName("text") + val text: String = "", +) + +data class SingleUserResponseDto( + @SerializedName("data") + val data: ReqResUserDto? = null, + @SerializedName("support") + val support: SupportDto? = null, +) + +data class UserListResponseDto( + @SerializedName("page") + val page: Int = 0, + @SerializedName("per_page") + val perPage: Int = 0, + @SerializedName("total") + val total: Int = 0, + @SerializedName("total_pages") + val totalPages: Int = 0, + @SerializedName("data") + val data: List = emptyList(), + @SerializedName("support") + val support: SupportDto? = null, +) diff --git a/Week09/Deku/app/src/main/java/com/example/deku/feature/profile/model/ProfileUser.kt b/Week09/Deku/app/src/main/java/com/example/deku/feature/profile/model/ProfileUser.kt new file mode 100644 index 0000000..f685fdc --- /dev/null +++ b/Week09/Deku/app/src/main/java/com/example/deku/feature/profile/model/ProfileUser.kt @@ -0,0 +1,8 @@ +package com.example.deku.feature.profile.model + +data class ProfileUser( + val id: Int, + val name: String, + val email: String, + val avatarUrl: String, +) diff --git a/Week09/Deku/app/src/main/java/com/example/deku/feature/profile/presentation/ProfileUiState.kt b/Week09/Deku/app/src/main/java/com/example/deku/feature/profile/presentation/ProfileUiState.kt new file mode 100644 index 0000000..8b2f78f --- /dev/null +++ b/Week09/Deku/app/src/main/java/com/example/deku/feature/profile/presentation/ProfileUiState.kt @@ -0,0 +1,17 @@ +package com.example.deku.feature.profile.presentation + +import com.example.deku.feature.profile.model.ProfileUser + +sealed interface ProfileUiState { + data object Idle : ProfileUiState + data object Loading : ProfileUiState + + data class Success( + val user: ProfileUser, + val followingUsers: List = emptyList(), + ) : ProfileUiState + + data class Error( + val message: String, + ) : ProfileUiState +} diff --git a/Week09/Deku/app/src/main/java/com/example/deku/feature/profile/presentation/ProfileViewModel.kt b/Week09/Deku/app/src/main/java/com/example/deku/feature/profile/presentation/ProfileViewModel.kt new file mode 100644 index 0000000..3685dc4 --- /dev/null +++ b/Week09/Deku/app/src/main/java/com/example/deku/feature/profile/presentation/ProfileViewModel.kt @@ -0,0 +1,81 @@ +package com.example.deku.feature.profile.presentation + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.deku.BuildConfig +import com.example.deku.feature.profile.data.ProfileRepository +import java.net.UnknownHostException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class ProfileViewModel( + private val repository: ProfileRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(ProfileUiState.Idle) + val uiState: StateFlow = _uiState.asStateFlow() + + fun loadProfileScreen( + userId: Int = DEFAULT_USER_ID, + page: Int = DEFAULT_PAGE, + forceRefresh: Boolean = false, + ) { + val currentState = _uiState.value + if (!forceRefresh && (currentState is ProfileUiState.Loading || currentState is ProfileUiState.Success)) { + return + } + + if (BuildConfig.REQRES_API_KEY.isBlank()) { + _uiState.value = ProfileUiState.Error( + "local.properties 또는 환경 변수에 REQRES_API_KEY를 추가하세요." + ) + return + } + + viewModelScope.launch { + _uiState.value = ProfileUiState.Loading + + val userResult = repository.getProfile(userId) + if (userResult.isFailure) { + _uiState.value = ProfileUiState.Error( + userResult.exceptionOrNull().toUserMessage() + ) + return@launch + } + val user = userResult.getOrNull() ?: run { + _uiState.value = ProfileUiState.Error("프로필 정보를 불러오지 못했습니다.") + return@launch + } + + val followingResult = repository.getFollowing(page) + if (followingResult.isFailure) { + _uiState.value = ProfileUiState.Error( + followingResult.exceptionOrNull().toUserMessage() + ) + return@launch + } + val followingUsers = followingResult.getOrNull().orEmpty() + + _uiState.value = ProfileUiState.Success( + user = user, + followingUsers = followingUsers.filterNot { followingUser -> followingUser.id == user.id }, + ) + } + } + + private fun Throwable?.toUserMessage(): String { + if (this is UnknownHostException) { + return "인터넷 연결 또는 DNS 설정을 확인해주세요. reqres.in 주소를 찾을 수 없습니다." + } + + return this?.message?.takeIf { message -> message.isNotBlank() } + ?: "프로필 정보를 불러오지 못했습니다." + } + + companion object { + private const val DEFAULT_USER_ID = 1 + private const val DEFAULT_PAGE = 1 + } +} diff --git a/Week09/Deku/app/src/main/java/com/example/deku/feature/profile/presentation/ProfileViewModelFactory.kt b/Week09/Deku/app/src/main/java/com/example/deku/feature/profile/presentation/ProfileViewModelFactory.kt new file mode 100644 index 0000000..28a004d --- /dev/null +++ b/Week09/Deku/app/src/main/java/com/example/deku/feature/profile/presentation/ProfileViewModelFactory.kt @@ -0,0 +1,19 @@ +package com.example.deku.feature.profile.presentation + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.example.deku.core.network.RetrofitProvider +import com.example.deku.feature.profile.data.ProfileRepository + +class ProfileViewModelFactory( + private val repository: ProfileRepository = ProfileRepository(RetrofitProvider.profileService), +) : ViewModelProvider.Factory { + + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(ProfileViewModel::class.java)) { + return ProfileViewModel(repository) as T + } + throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}") + } +} diff --git a/Week09/Deku/app/src/main/java/com/example/deku/feature/shop/ShopScreen.kt b/Week09/Deku/app/src/main/java/com/example/deku/feature/shop/ShopScreen.kt new file mode 100644 index 0000000..c127818 --- /dev/null +++ b/Week09/Deku/app/src/main/java/com/example/deku/feature/shop/ShopScreen.kt @@ -0,0 +1,129 @@ +// 구매하기 화면 UI로, 카테고리 탭과 상품 그리드 탐색을 담당합니다. + +package com.example.deku.feature.shop + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +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.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.example.deku.core.designsystem.ColorNavUnselected +import com.example.deku.core.designsystem.ColorTextPrimary +import com.example.deku.core.designsystem.theme.DekuTheme +import com.example.deku.data.ProductCatalog +import com.example.deku.data.ProductItem +import com.example.deku.feature.product.ProductGridCard + +@Composable +fun ShopScreen( + products: List, + onProductClick: (ProductItem) -> Unit, + onWishClick: (ProductItem) -> Unit, + modifier: Modifier = Modifier +) { + // Pair의 first는 탭에 보이는 문구, second는 실제 필터에 쓰는 category 값입니다. + val tabs = listOf( + "전체" to null, + ProductCatalog.CATEGORY_TOPS to ProductCatalog.CATEGORY_TOPS, + ProductCatalog.CATEGORY_SHOES to ProductCatalog.CATEGORY_SHOES + ) + var selectedTabIndex by remember { mutableStateOf(0) } + val selectedCategory = tabs[selectedTabIndex].second + // products는 MainScreen의 단일 상태에서 내려오므로 위시 변경도 즉시 반영됩니다. + val filteredProducts = ProductCatalog.productsByCategory(products, selectedCategory) + + Column( + modifier = modifier + .fillMaxSize() + .background(Color.White) + .padding(horizontal = 24.dp) + .padding(top = 40.dp) + ) { + TabRow( + selectedTabIndex = selectedTabIndex, + containerColor = Color.White, + contentColor = ColorTextPrimary + ) { + tabs.forEachIndexed { index, tab -> + val title = tab.first + + Tab( + selected = selectedTabIndex == index, + onClick = { selectedTabIndex = index }, + selectedContentColor = ColorTextPrimary, + unselectedContentColor = ColorNavUnselected, + text = { + Text( + text = title, + fontSize = if (title.length > 8) 13.sp else 14.sp, + lineHeight = 18.sp, + fontWeight = if (selectedTabIndex == index) { + FontWeight.Bold + } else { + FontWeight.Normal + }, + maxLines = 1 + ) + } + ) + } + } + + LazyVerticalGrid( + columns = GridCells.Fixed(2), + modifier = Modifier + .fillMaxSize() + .padding(top = 20.dp), + contentPadding = PaddingValues( + start = 12.dp, + end = 12.dp, + bottom = 24.dp + ), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + // Grid에서도 items + key를 지정해 상품 순서가 바뀌어도 아이템 상태가 흔들리지 않게 합니다. + items( + items = filteredProducts, + key = { product -> product.id }, + contentType = { "shopProduct" } + ) { product -> + ProductGridCard( + product = product, + onClick = onProductClick, + onWishClick = onWishClick + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun ShopScreenPreview() { + DekuTheme { + ShopScreen( + products = ProductCatalog.initialProducts(), + onProductClick = {}, + onWishClick = {} + ) + } +} diff --git a/Week09/Deku/app/src/main/java/com/example/deku/feature/splash/SplashScreen.kt b/Week09/Deku/app/src/main/java/com/example/deku/feature/splash/SplashScreen.kt new file mode 100644 index 0000000..2d91abe --- /dev/null +++ b/Week09/Deku/app/src/main/java/com/example/deku/feature/splash/SplashScreen.kt @@ -0,0 +1,67 @@ +// 스플래시 Compose 화면으로, 일정 시간 후 상위 Activity에 전환 요청을 보냅니다. + +package com.example.deku.feature.splash + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.example.deku.R +import com.example.deku.core.common.SPLASH_HOME_TITLE +import com.example.deku.core.designsystem.theme.DekuTheme +import kotlinx.coroutines.delay + +@Composable +fun SplashScreen(onTimeout: () -> Unit) { + // Unit key를 사용해 최초 진입 시 한 번만 타이머가 시작되도록 합니다. + LaunchedEffect(Unit) { + delay(2_000) + onTimeout() + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(Color.White), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = SPLASH_HOME_TITLE, + color = Color(0xFF111111), + fontSize = 50.sp, + lineHeight = 58.sp, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(16.dp)) + Image( + painter = painterResource(id = R.drawable.splash_logo), + contentDescription = stringResource(id = R.string.splash_brand_logo), + modifier = Modifier.size(width = 100.dp, height = 80.dp) + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun SplashScreenPreview() { + DekuTheme { + SplashScreen(onTimeout = {}) + } +} diff --git a/Week09/Deku/app/src/main/java/com/example/deku/feature/wishlist/WishListScreen.kt b/Week09/Deku/app/src/main/java/com/example/deku/feature/wishlist/WishListScreen.kt new file mode 100644 index 0000000..681eace --- /dev/null +++ b/Week09/Deku/app/src/main/java/com/example/deku/feature/wishlist/WishListScreen.kt @@ -0,0 +1,116 @@ +// 위시리스트 화면 UI로, 전체 상품 상태에서 찜한 상품만 골라 보여줍니다. + +package com.example.deku.feature.wishlist + +import androidx.compose.foundation.background +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.example.deku.core.designsystem.ColorTextPrimary +import com.example.deku.core.designsystem.ColorTextSecondary +import com.example.deku.core.designsystem.theme.DekuTheme +import com.example.deku.data.ProductCatalog +import com.example.deku.data.ProductItem +import com.example.deku.feature.product.ProductGridCard + +@Composable +fun WishListScreen( + products: List, + onProductClick: (ProductItem) -> Unit, + onWishClick: (ProductItem) -> Unit, + modifier: Modifier = Modifier +) { + // 별도 저장소를 두지 않고 MainScreen이 가진 상품 목록에서 파생해 화면을 만듭니다. + val wishProducts = products.filter { it.isWish } + + Column( + modifier = modifier + .fillMaxSize() + .background(Color.White) + .padding(horizontal = 24.dp) + .padding(top = 70.dp) + ) { + Text( + text = "위시리스트", + color = ColorTextPrimary, + fontSize = 30.sp, + lineHeight = 36.sp, + fontWeight = FontWeight.Bold + ) + + if (wishProducts.isEmpty()) { + Spacer(modifier = Modifier.height(120.dp)) + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.TopCenter + ) { + Text( + text = "관심 상품이 없습니다\n마음에 드는 상품을 저장해보세요.", + color = ColorTextSecondary, + textAlign = TextAlign.Center, + fontSize = 16.sp, + lineHeight = 24.sp + ) + } + } else { + LazyVerticalGrid( + columns = GridCells.Fixed(2), + modifier = Modifier + .fillMaxSize() + .padding(top = 20.dp), + contentPadding = PaddingValues( + start = 12.dp, + end = 12.dp, + bottom = 24.dp + ), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + // 위시리스트는 원본 상품 상태에서 파생된 목록이므로 id를 key로 사용합니다. + items( + items = wishProducts, + key = { product -> product.id }, + contentType = { "wishProduct" } + ) { product -> + ProductGridCard( + product = product, + onClick = onProductClick, + onWishClick = onWishClick + ) + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun WishListScreenPreview() { + DekuTheme { + WishListScreen( + products = ProductCatalog.initialProducts().mapIndexed { index, product -> + if (index < 3) product.copy(isWish = true) else product + }, + onProductClick = {}, + onWishClick = {} + ) + } +} diff --git a/Week09/Deku/app/src/main/java/com/example/deku/navigation/MainNavGraph.kt b/Week09/Deku/app/src/main/java/com/example/deku/navigation/MainNavGraph.kt new file mode 100644 index 0000000..92287ed --- /dev/null +++ b/Week09/Deku/app/src/main/java/com/example/deku/navigation/MainNavGraph.kt @@ -0,0 +1,114 @@ +// 앱의 NavHost 구성과 BottomBar 탭 이동 헬퍼를 정의한 파일입니다. + +package com.example.deku.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.navigation.NavController +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.toRoute +import com.example.deku.data.ProductItem +import com.example.deku.feature.cart.CartScreen +import com.example.deku.feature.home.HomeScreen +import com.example.deku.feature.profile.ProfileScreen +import com.example.deku.feature.product.ProductDetailScreen +import com.example.deku.feature.shop.ShopScreen +import com.example.deku.feature.wishlist.WishListScreen + +@Composable +fun MainNavGraph( + navController: NavHostController, + homeTitle: String, + products: List, + onWishClick: (ProductItem) -> Unit, + modifier: Modifier = Modifier +) { + // products와 onWishClick을 NavGraph에서 각 화면으로 내려 같은 상태를 공유하게 합니다. + NavHost( + navController = navController, + startDestination = MainRoute.Home(title = homeTitle), + modifier = modifier + ) { + composable { backStackEntry -> + // Type-safe Navigation으로 전달된 Home title 인자를 꺼냅니다. + val route = backStackEntry.toRoute() + HomeScreen( + title = route.title, + products = products, + onProductClick = { product -> + // 상품 id만 상세 route에 넘기고, 상세 화면에서는 현재 상품 목록에서 다시 조회합니다. + navController.navigate(MainRoute.ProductDetail(productId = product.id)) + } + ) + } + composable { + ShopScreen( + products = products, + onProductClick = { product -> + navController.navigate(MainRoute.ProductDetail(productId = product.id)) + }, + onWishClick = onWishClick + ) + } + composable { + WishListScreen( + products = products, + onProductClick = { product -> + navController.navigate(MainRoute.ProductDetail(productId = product.id)) + }, + onWishClick = onWishClick + ) + } + composable { + CartScreen( + onOrderClick = { + // 장바구니의 주문하기 버튼은 구매하기 탭으로 전환되어야 합니다. + navController.navigateToBottomTab(MainRouteName.SHOP, homeTitle) + } + ) + } + composable { + ProfileScreen() + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + ProductDetailScreen( + product = products.find { product -> product.id == route.productId }, + onBackClick = { navController.navigateUp() }, + onWishClick = onWishClick + ) + } + } +} + +fun NavController.navigateToBottomTab(routeName: String, homeTitle: String) { + // BottomBar는 문자열 routeName만 알도록 두고, 실제 route 객체 생성은 Navigation 계층에서 담당합니다. + when (routeName) { + MainRouteName.HOME -> navigateBottom(MainRoute.Home(title = homeTitle)) + MainRouteName.SHOP -> navigateBottom(MainRoute.Shop) + MainRouteName.WISH_LIST -> navigateBottom(MainRoute.WishList) + MainRouteName.CART -> navigateBottom(MainRoute.Cart) + MainRouteName.PROFILE -> navigateBottom(MainRoute.Profile) + } +} + +private fun NavController.navigateBottom(route: T) { + navigate(route) { + // 탭 전환 시 시작 destination까지만 남기고 각 탭의 상태는 가능한 복원합니다. + popUpTo(graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } +} + +fun String?.currentBaseRoute(): String? { + // Type-safe Navigation route 문자열에 붙을 수 있는 인자/쿼리를 제거해 탭 이름 비교에 사용합니다. + return this + ?.substringBefore("/") + ?.substringBefore("?") +} diff --git a/Week09/Deku/app/src/main/java/com/example/deku/navigation/MainRoute.kt b/Week09/Deku/app/src/main/java/com/example/deku/navigation/MainRoute.kt new file mode 100644 index 0000000..16bc5a5 --- /dev/null +++ b/Week09/Deku/app/src/main/java/com/example/deku/navigation/MainRoute.kt @@ -0,0 +1,42 @@ +// Navigation 목적지와 route 이름을 type-safe 방식으로 정의한 파일입니다. + +package com.example.deku.navigation + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +object MainRouteName { + const val HOME = "home" + const val SHOP = "shop" + const val WISH_LIST = "wish_list" + const val CART = "cart" + const val PROFILE = "profile" + const val PRODUCT_DETAIL = "product_detail" +} + +// 문자열 route 대신 Serializable 타입으로 목적지를 정의해 Navigation 인자 오타를 컴파일 단계에서 줄입니다. +sealed interface MainRoute { + @Serializable + @SerialName(MainRouteName.HOME) + data class Home(val title: String) : MainRoute + + @Serializable + @SerialName(MainRouteName.SHOP) + data object Shop : MainRoute + + @Serializable + @SerialName(MainRouteName.WISH_LIST) + data object WishList : MainRoute + + @Serializable + @SerialName(MainRouteName.CART) + data object Cart : MainRoute + + @Serializable + @SerialName(MainRouteName.PROFILE) + data object Profile : MainRoute + + @Serializable + @SerialName(MainRouteName.PRODUCT_DETAIL) + data class ProductDetail(val productId: Int) : MainRoute +} diff --git a/Week09/Deku/app/src/main/res/drawable/cart.xml b/Week09/Deku/app/src/main/res/drawable/cart.xml new file mode 100644 index 0000000..3b6e816 --- /dev/null +++ b/Week09/Deku/app/src/main/res/drawable/cart.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/Week09/Deku/app/src/main/res/drawable/heart.xml b/Week09/Deku/app/src/main/res/drawable/heart.xml new file mode 100644 index 0000000..6597a4a --- /dev/null +++ b/Week09/Deku/app/src/main/res/drawable/heart.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/Week09/Deku/app/src/main/res/drawable/heart_filled.xml b/Week09/Deku/app/src/main/res/drawable/heart_filled.xml new file mode 100644 index 0000000..fd31096 --- /dev/null +++ b/Week09/Deku/app/src/main/res/drawable/heart_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/Week09/Deku/app/src/main/res/drawable/home.xml b/Week09/Deku/app/src/main/res/drawable/home.xml new file mode 100644 index 0000000..20cb4d6 --- /dev/null +++ b/Week09/Deku/app/src/main/res/drawable/home.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/Week09/Deku/app/src/main/res/drawable/ic_arrow_back.xml b/Week09/Deku/app/src/main/res/drawable/ic_arrow_back.xml new file mode 100644 index 0000000..ae8ecfa --- /dev/null +++ b/Week09/Deku/app/src/main/res/drawable/ic_arrow_back.xml @@ -0,0 +1,9 @@ + + + diff --git a/Week09/Deku/app/src/main/res/drawable/ic_chevron_right.xml b/Week09/Deku/app/src/main/res/drawable/ic_chevron_right.xml new file mode 100644 index 0000000..622bb69 --- /dev/null +++ b/Week09/Deku/app/src/main/res/drawable/ic_chevron_right.xml @@ -0,0 +1,9 @@ + + + diff --git a/Week09/Deku/app/src/main/res/drawable/ic_launcher_background.xml b/Week09/Deku/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/Week09/Deku/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Week09/Deku/app/src/main/res/drawable/ic_launcher_foreground.xml b/Week09/Deku/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/Week09/Deku/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/Week09/Deku/app/src/main/res/drawable/ic_profile_event.xml b/Week09/Deku/app/src/main/res/drawable/ic_profile_event.xml new file mode 100644 index 0000000..68c81e0 --- /dev/null +++ b/Week09/Deku/app/src/main/res/drawable/ic_profile_event.xml @@ -0,0 +1,9 @@ + + + diff --git a/Week09/Deku/app/src/main/res/drawable/ic_profile_order.xml b/Week09/Deku/app/src/main/res/drawable/ic_profile_order.xml new file mode 100644 index 0000000..aa77186 --- /dev/null +++ b/Week09/Deku/app/src/main/res/drawable/ic_profile_order.xml @@ -0,0 +1,9 @@ + + + diff --git a/Week09/Deku/app/src/main/res/drawable/ic_profile_pass.xml b/Week09/Deku/app/src/main/res/drawable/ic_profile_pass.xml new file mode 100644 index 0000000..8e7f8a3 --- /dev/null +++ b/Week09/Deku/app/src/main/res/drawable/ic_profile_pass.xml @@ -0,0 +1,9 @@ + + + diff --git a/Week09/Deku/app/src/main/res/drawable/ic_profile_settings.xml b/Week09/Deku/app/src/main/res/drawable/ic_profile_settings.xml new file mode 100644 index 0000000..153cc6e --- /dev/null +++ b/Week09/Deku/app/src/main/res/drawable/ic_profile_settings.xml @@ -0,0 +1,9 @@ + + + diff --git a/Week09/Deku/app/src/main/res/drawable/nike_item1.png b/Week09/Deku/app/src/main/res/drawable/nike_item1.png new file mode 100644 index 0000000..cf18b6c Binary files /dev/null and b/Week09/Deku/app/src/main/res/drawable/nike_item1.png differ diff --git a/Week09/Deku/app/src/main/res/drawable/nike_item2.png b/Week09/Deku/app/src/main/res/drawable/nike_item2.png new file mode 100644 index 0000000..f4e6c33 Binary files /dev/null and b/Week09/Deku/app/src/main/res/drawable/nike_item2.png differ diff --git a/Week09/Deku/app/src/main/res/drawable/nike_item3.png b/Week09/Deku/app/src/main/res/drawable/nike_item3.png new file mode 100644 index 0000000..d2791e4 Binary files /dev/null and b/Week09/Deku/app/src/main/res/drawable/nike_item3.png differ diff --git a/Week09/Deku/app/src/main/res/drawable/nike_item4.png b/Week09/Deku/app/src/main/res/drawable/nike_item4.png new file mode 100644 index 0000000..1dea905 Binary files /dev/null and b/Week09/Deku/app/src/main/res/drawable/nike_item4.png differ diff --git a/Week09/Deku/app/src/main/res/drawable/nike_item5.png b/Week09/Deku/app/src/main/res/drawable/nike_item5.png new file mode 100644 index 0000000..93bca59 Binary files /dev/null and b/Week09/Deku/app/src/main/res/drawable/nike_item5.png differ diff --git a/Week09/Deku/app/src/main/res/drawable/nike_item6.png b/Week09/Deku/app/src/main/res/drawable/nike_item6.png new file mode 100644 index 0000000..21fa5d1 Binary files /dev/null and b/Week09/Deku/app/src/main/res/drawable/nike_item6.png differ diff --git a/Week09/Deku/app/src/main/res/drawable/nike_logo.png b/Week09/Deku/app/src/main/res/drawable/nike_logo.png new file mode 100644 index 0000000..d73dd96 Binary files /dev/null and b/Week09/Deku/app/src/main/res/drawable/nike_logo.png differ diff --git a/Week09/Deku/app/src/main/res/drawable/profile.xml b/Week09/Deku/app/src/main/res/drawable/profile.xml new file mode 100644 index 0000000..1fc97b8 --- /dev/null +++ b/Week09/Deku/app/src/main/res/drawable/profile.xml @@ -0,0 +1,23 @@ + + + + + + + diff --git a/Week09/Deku/app/src/main/res/drawable/shop.xml b/Week09/Deku/app/src/main/res/drawable/shop.xml new file mode 100644 index 0000000..483d117 --- /dev/null +++ b/Week09/Deku/app/src/main/res/drawable/shop.xml @@ -0,0 +1,38 @@ + + + + + + + + + diff --git a/Week09/Deku/app/src/main/res/drawable/splash_logo.png b/Week09/Deku/app/src/main/res/drawable/splash_logo.png new file mode 100644 index 0000000..d6215d1 Binary files /dev/null and b/Week09/Deku/app/src/main/res/drawable/splash_logo.png differ diff --git a/Week09/Deku/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/Week09/Deku/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/Week09/Deku/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Week09/Deku/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/Week09/Deku/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/Week09/Deku/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Week09/Deku/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/Week09/Deku/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/Week09/Deku/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/Week09/Deku/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/Week09/Deku/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/Week09/Deku/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/Week09/Deku/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/Week09/Deku/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/Week09/Deku/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/Week09/Deku/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/Week09/Deku/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/Week09/Deku/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/Week09/Deku/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/Week09/Deku/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/Week09/Deku/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/Week09/Deku/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/Week09/Deku/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/Week09/Deku/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/Week09/Deku/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/Week09/Deku/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/Week09/Deku/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/Week09/Deku/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/Week09/Deku/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/Week09/Deku/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/Week09/Deku/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/Week09/Deku/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/Week09/Deku/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/Week09/Deku/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/Week09/Deku/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/Week09/Deku/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/Week09/Deku/app/src/main/res/values/colors.xml b/Week09/Deku/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/Week09/Deku/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/Week09/Deku/app/src/main/res/values/strings.xml b/Week09/Deku/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..860a479 --- /dev/null +++ b/Week09/Deku/app/src/main/res/values/strings.xml @@ -0,0 +1,22 @@ + + Deku + NIKE logo + NIKE splash logo + 프로필 + NIKE MEMBER + 프로필 이미지 + 프로필 수정 + 주문 + 패스 + 이벤트 + 설정 + 나이키 멤버 혜택 + 0개 사용 가능 + 팔로잉 + 팔로잉 %1$d + 편집 + 표시할 팔로잉 유저가 없습니다. + 팔로잉 프로필 이미지 + 프로필을 불러오지 못했습니다 + 다시 시도 + diff --git a/Week09/Deku/app/src/main/res/values/themes.xml b/Week09/Deku/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..2356cbe --- /dev/null +++ b/Week09/Deku/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +