Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,6 @@ google-services.json

# Android Profiling
*.hprof

# Claude Code (로컬 설정, 커밋하지 않음)
.claude/
7 changes: 0 additions & 7 deletions Yoshi/NikeApp/.claude/settings.local.json

This file was deleted.

6 changes: 6 additions & 0 deletions Yoshi/NikeApp/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,9 @@
.externalNativeBuild
.cxx
local.properties

# 키스토어 / 앱 서명 (절대 커밋 금지)
*.jks
*.keystore
keystore.properties
signing.properties
24 changes: 24 additions & 0 deletions Yoshi/NikeApp/app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
import java.util.Properties

plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
}

// local.properties 에서 API 키를 읽어옵니다. (local.properties 는 git 에 올라가지 않습니다)
val localProperties = Properties().apply {
val localFile = rootProject.file("local.properties")
if (localFile.exists()) {
localFile.inputStream().use { load(it) }
}
}

android {
namespace = "com.example.NikeApp"
compileSdk = 35
Expand All @@ -17,6 +27,10 @@ android {
versionName = "1.0"

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

// local.properties 의 NIKE_API_KEY 값을 BuildConfig.REQRES_API_KEY 로 노출
val reqresApiKey = localProperties.getProperty("NIKE_API_KEY") ?: ""
buildConfigField("String", "REQRES_API_KEY", "\"$reqresApiKey\"")
}

buildTypes {
Expand All @@ -37,6 +51,7 @@ android {
}
buildFeatures {
compose = true
buildConfig = true
}
}

Expand All @@ -58,6 +73,15 @@ dependencies {
implementation(libs.androidx.navigation.compose)
implementation(libs.kotlinx.serialization.json)

// Coil — Compose 이미지 로딩
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)

// Retrofit + OkHttp — ReqRes API 통신
implementation(libs.retrofit)
implementation(libs.retrofit.kotlinx.serialization)
implementation(libs.okhttp.logging.interceptor)

testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.example.NikeApp.data

import com.example.NikeApp.data.remote.ReqResApi
import com.example.NikeApp.data.remote.ReqResClient
import com.example.NikeApp.data.remote.dto.toUser
import com.example.NikeApp.model.User

/**
* 사용자 데이터 저장소.
* 화면(Composable)은 이 Repository 의 suspend 함수만 호출하면 됩니다.
*/
class UserRepository(
private val api: ReqResApi = ReqResClient.api,
) {
/** 미션 요구사항: 내 정보(userId = 1) 가져오기 */
suspend fun getMyProfile(): User = api.getUser(MY_USER_ID).data.toUser()

/** 팔로잉 리스트 구성: 사용자 목록에서 나(1번)를 제외하고 가져오기 */
suspend fun getFollowing(): List<User> =
api.getUsers(page = 1).data
.filter { it.id != MY_USER_ID }
.map { it.toUser() }

companion object {
/** 워크북 미션 기준 내 userId 는 1번 */
const val MY_USER_ID = 1
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.example.NikeApp.data

import android.content.Context
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf

/**
* 위시리스트 ID 집합 저장소
* 앱이 종료/재실행되어도 데이터가 유지
* Compose에서 즉시 반응할 수 있게 [State] 사용
*/
class WishlistRepository(context: Context) {

private val prefs = context.applicationContext
.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)

private val _wishlistIds = mutableStateOf<Set<String>>(loadFromPrefs())

/** 현재 위시리스트에 담긴 상품 ID 집합 */
val wishlistIds: State<Set<String>> = _wishlistIds

/**
* 하트 on/off
* 이미 들어있으면 제거 없으면 추가
*/
fun toggle(productId: String) {
val current = _wishlistIds.value
val updated = if (productId in current) current - productId else current + productId
_wishlistIds.value = updated
saveToPrefs(updated)
}

fun isWished(productId: String): Boolean = productId in _wishlistIds.value

private fun loadFromPrefs(): Set<String> =
prefs.getStringSet(KEY_IDS, emptySet())?.toSet() ?: emptySet()

private fun saveToPrefs(ids: Set<String>) {
prefs.edit().putStringSet(KEY_IDS, ids).apply()
}

companion object {
private const val PREFS_NAME = "wishlist_prefs"
private const val KEY_IDS = "wishlist_ids"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.example.NikeApp.data.remote

import com.example.NikeApp.data.remote.dto.SingleUserResponse
import com.example.NikeApp.data.remote.dto.UserListResponse
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query

/**
* ReqRes(https://reqres.in) 사용자 API
*/
interface ReqResApi {

/** 단일 사용자 조회 — 미션 요구사항: userId = 1 */
@GET("api/users/{id}")
suspend fun getUser(@Path("id") id: Int): SingleUserResponse

/** 사용자 목록 조회 — 팔로잉 리스트 구성에 사용 */
@GET("api/users")
suspend fun getUsers(@Query("page") page: Int = 1): UserListResponse
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.example.NikeApp.data.remote

import com.example.NikeApp.BuildConfig
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory

/**
* Retrofit + OkHttp 설정을 한 곳에서 관리하는 싱글톤.
* - 모든 요청에 ReqRes 가 요구하는 `x-api-key` 헤더를 자동으로 붙입니다.
* - API 키는 .env -> BuildConfig.REQRES_API_KEY 를 통해 주입됩니다.
*/
object ReqResClient {

private const val BASE_URL = "https://reqres.in/"

private val json = Json {
ignoreUnknownKeys = true // 응답에 모르는 필드가 있어도 무시
}

private val okHttpClient: OkHttpClient = OkHttpClient.Builder()
// 1) API 키 헤더 자동 추가
.addInterceptor { chain ->
val request = chain.request().newBuilder()
.addHeader("x-api-key", BuildConfig.REQRES_API_KEY)
.build()
chain.proceed(request)
}
// 2) 요청/응답 로깅 (디버깅용)
.addInterceptor(
HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
)
.build()

val api: ReqResApi = Retrofit.Builder()
.baseUrl(BASE_URL)
.client(okHttpClient)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
.create(ReqResApi::class.java)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.example.NikeApp.data.remote.dto

import com.example.NikeApp.model.User
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

/**
* ReqRes API 의 사용자 한 명을 표현하는 DTO
* 예) GET https://reqres.in/api/users/1 -> data 필드
*/
@Serializable
data class UserDto(
val id: Int,
val email: String,
@SerialName("first_name") val firstName: String,
@SerialName("last_name") val lastName: String,
val avatar: String,
)

/** GET /api/users/{id} 응답 */
@Serializable
data class SingleUserResponse(
val data: UserDto,
)

/** GET /api/users?page= 응답 */
@Serializable
data class UserListResponse(
val page: Int,
@SerialName("per_page") val perPage: Int,
val total: Int,
@SerialName("total_pages") val totalPages: Int,
val data: List<UserDto>,
)

/** DTO -> 도메인 모델 변환 (닉네임 = first_name + last_name) */
fun UserDto.toUser(): User = User(
id = id,
nickname = "$firstName $lastName",
avatarUrl = avatar,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.example.NikeApp.model

import androidx.annotation.DrawableRes

/**
* 상품 데이터 모델
*/
data class Product(
val id: String,
val name: String,
val price: Int,
@DrawableRes val imageRes: Int? = null,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.example.NikeApp.model

import com.example.NikeApp.R

/**
* 홈/구매하기 화면에서 사용하는 더미 상품 목록
* 홈 화면: 5개
* 구매하기: 8개
*/
val SampleProducts: List<Product> = listOf(
Product(id = "p1", name = "Air Force 1 '07", price = 150, imageRes = R.drawable.air_force_1_07),
Product(id = "p2", name = "Air Max 90", price = 200, imageRes = R.drawable.air_max_90),
Product(id = "p3", name = "Dunk Low Retro", price = 175, imageRes = R.drawable.dunk_low_retro),
Product(id = "p4", name = "Air Jordan 1 Mid", price = 220, imageRes = R.drawable.air_jordan_1_mid),
Product(id = "p5", name = "Cortez Basic", price = 100, imageRes = R.drawable.cortez_basic),
Product(id = "p6", name = "Pegasus 41", price = 130, imageRes = R.drawable.pegasus_41),
Product(id = "p7", name = "Blazer Mid '77", price = 90, imageRes = R.drawable.blazer_mid_77),
Product(id = "p8", name = "Sportswear Crew Socks", price = 15, imageRes = R.drawable.sportswear_crew_socks),
)

/** 가격 형식 변환 */
fun Product.formattedPrice(): String = "US$%,d".format(price)
12 changes: 12 additions & 0 deletions Yoshi/NikeApp/app/src/main/java/com/example/NikeApp/model/User.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.example.NikeApp.model

/**
* 화면에서 사용하는 사용자 도메인 모델
* - [nickname] 은 ReqRes 의 first_name + last_name 을 합친 값
* - [avatarUrl] 은 프로필/팔로잉 이미지로 사용
*/
data class User(
val id: Int,
val nickname: String,
val avatarUrl: String,
)
34 changes: 30 additions & 4 deletions Yoshi/NikeApp/app/src/main/java/com/example/NikeApp/ui/NikeApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@ 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.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.example.NikeApp.data.WishlistRepository
import com.example.NikeApp.ui.component.NikeBottomBar
import com.example.NikeApp.ui.navigation.AppDestination
import com.example.NikeApp.ui.navigation.BottomTab
Expand All @@ -26,11 +29,16 @@ import com.example.NikeApp.ui.screen.WishlistScreen
* NavController는 화면 이동과 뒤로 가기를 담당
* Scaffold는 BottomBar와 본문을 분리하여 배치
* NavHost는 실제 화면이 교체되며 그려지는 컨테이너
* WishlistRepository는 Activity 스코프로 1회만 생성하여 모든 화면이 동일한 위시리스트 상태를 공유
*/
@Composable
fun NikeApp() {
val navController = rememberNavController()

// applicationContext 기반으로 1회만 생성 → 모든 화면이 동일 인스턴스 공유
val context = LocalContext.current
val wishlistRepository = remember(context) { WishlistRepository(context) }

// 현재 어떤 화면이 보여지고 있는지 NavController에서 관찰 → BottomBar 선택 상태로 변환
val backStackEntry by navController.currentBackStackEntryAsState()
val currentTab: BottomTab = backStackEntry?.destination.toBottomTab() ?: BottomTab.Home
Expand All @@ -48,18 +56,36 @@ fun NikeApp() {
startDestination = AppDestination.Home,
modifier = Modifier.padding(innerPadding),
) {
mainGraph(onNavigateToPurchase = { navController.navigateToTab(BottomTab.Purchase) })
mainGraph(
wishlistRepository = wishlistRepository,
onNavigateToPurchase = { navController.navigateToTab(BottomTab.Purchase) },
)
}
}
}

/** NavGraph 정의를 확장 함수로 분리 */
/**
* NavGraph 정의를 확장 함수로 분리함
*/
private fun NavGraphBuilder.mainGraph(
wishlistRepository: WishlistRepository,
onNavigateToPurchase: () -> Unit,
) {
composable<AppDestination.Home> { HomeScreen() }
composable<AppDestination.Purchase> { PurchaseScreen() }
composable<AppDestination.Wishlist> { WishlistScreen() }
composable<AppDestination.Purchase> {
val wishlistIds by wishlistRepository.wishlistIds
PurchaseScreen(
wishlistIds = wishlistIds,
onToggleWishlist = wishlistRepository::toggle,
)
}
composable<AppDestination.Wishlist> {
val wishlistIds by wishlistRepository.wishlistIds
WishlistScreen(
wishlistIds = wishlistIds,
onToggleWishlist = wishlistRepository::toggle,
)
}
composable<AppDestination.Cart> {
// 장바구니 → 구매하기
CartScreen(onOrderClick = onNavigateToPurchase)
Expand Down
Loading